#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('Gdk', '3.0')
from gi.repository import Gtk, Gdk, GLib
import cairo
import random
import sys
import json
import os
CELL = 25
W, H = 10, 20
BOARD_W = W * CELL
BOARD_H = H * CELL
SIDEBAR_W = 8 * CELL
next_time = 400
base_time = 400
PIECES = [
[[1,1,1,1]],
[[1,1],[1,1]],
[[0,1,0],[1,1,1]],
[[0,1,1],[1,1,0]],
[[1,1,0],[0,1,1]],
[[1,0,0],[1,1,1]],
[[0,0,1],[1,1,1]]
]
COLORS = [ (0.9,0.2,0.2), (0.2,0.9,0.3), (0.95,0.85,0.2), (0.2,0.4,0.9), (0.85,0.3,0.9), (0.2,0.9,0.9), (0.6,0.6,0.6) ]
class Piece:
def __init__(self, id):
self.id = id
self.shape = [row[:] for row in PIECES[id]]
self.x = W//2 - len(self.shape[0])//2
self.y = -len(self.shape)
def rotate(self):
r = list(zip(*self.shape[::-1]))
self.shape = [list(row) for row in r]
class TetrisWindow(Gtk.Window):
def __init__(self):
super().__init__(title="Tetris")
self.set_default_size(BOARD_W + SIDEBAR_W + 40, BOARD_H + 40)
self.set_resizable(False)
self.board = [[0]*W for _ in range(H)]
self.score = 0
# extended scoring
self.total_blocks = 0 # total placed blocks over the game
self.combo = 0 # current consecutive clears (combo)
self.last_cleared = 0 # last cleared lines count
# level progression
self.level = 0
self.clears_done = 0 # number of clear-events completed (not lines)
self.level_speed_delta = 50 # milliseconds to speed up per level (easy to tweak)
# visual effects state
self.particles = []
self.floating_texts = []
# effects timer id (for particles/floating texts when not animating)
self._effects_timer_id = None
# camera shake
self.shake_frames = 0
self.shake_magnitude = 0
# highscore
self.highscore = 0
self._hs_path = os.path.join(os.path.dirname(__file__), 'tetris_highscore.json')
self.load_highscore()
self.paused = False
self.cur = self.new_piece()
self.next = self.new_piece()
self.darea = Gtk.DrawingArea()
self.darea.set_size_request(BOARD_W + SIDEBAR_W, BOARD_H)
self.darea.connect('draw', self.on_draw)
self.add(self.darea)
self.connect('key-press-event', self.on_key)
self.timeout_id = GLib.timeout_add(next_time, self.tick)
def new_piece(self):
return Piece(random.randrange(len(PIECES)))
def game_over(self):
# stop timer
try:
if self.timeout_id:
GLib.source_remove(self.timeout_id)
except Exception:
pass
# show dialog then quit
dlg = Gtk.MessageDialog(parent=self, flags=0, message_type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.OK, text="Game Over")
dlg.format_secondary_text(f"Score: {self.score}")
dlg.run()
dlg.destroy()
# update highscore
if self.score > self.highscore:
self.highscore = self.score
self.save_highscore()
Gtk.main_quit()
def spawn_next(self):
# move next into current and create a new next; return False if game over
self.cur = self.next
self.next = self.new_piece()
# if any block of the new current collides or top row occupied -> game over
if self.collide(self.cur):
self.game_over()
return False
# also if top row has any block (stack reached top), consider game over
if any(self.board[0]):
self.game_over()
return False
return True
def collide(self, piece):
for i, row in enumerate(piece.shape):
for j, v in enumerate(row):
if not v: continue
bx = piece.x + j
by = piece.y + i
if bx < 0 or bx >= W: return True
if by >= H: return True
if by >=0 and self.board[by][bx]: return True
return False
def place(self, piece):
for i, row in enumerate(piece.shape):
for j, v in enumerate(row):
if not v: continue
bx = piece.x + j
by = piece.y + i
if 0 <= by < H and 0 <= bx < W:
self.board[by][bx] = piece.id + 1
self.total_blocks += 1
# small per-block placement score
self.score += 1
def clear_lines(self):
# synchronous fallback: remove all full rows and return count
new_rows = [row[:] for row in self.board if not all(row)]
cleared = H - len(new_rows)
if cleared > 0:
for _ in range(cleared):
new_rows.insert(0, [0]*W)
self.board = new_rows
return cleared
def detect_full_rows(self):
rows = [i for i in range(H) if all(self.board[i])]
return rows
def remove_rows(self, rows):
# remove rows (list of indices), keep order
new_rows = [self.board[i] for i in range(H) if i not in set(rows)]
# add empty rows on top
for _ in range(len(rows)):
new_rows.insert(0, [0]*W)
self.board = new_rows
def animate_clear(self, rows, after_callback=None):
# rows: list of indices to clear; animate a few flashes then remove
if not rows:
if after_callback: after_callback()
return
self._anim_rows = set(rows)
self._anim_phase = 0
self._after_clear_callback = after_callback
self.animating = True
# prepare particles for visual effect: one particle per cell in rows
self.particles = []
for r in rows:
for c in range(W):
if self.board[r][c]:
for i in range(10):
# particle: x,y, vx, vy, life(0..1), start_color, end_color, size
px = 10 + c*CELL + CELL/2
py = 10 + r*CELL + CELL/2
angle = random.uniform(0, 2*3.14159)
speed = random.uniform(1.0,7.0)
vx = speed * random.uniform(-1.0, 1.0)
vy = -abs(random.uniform(1.0,8.0))
life = 1.0
col = COLORS[(self.board[r][c]-1) % len(COLORS)]
# end color slightly faded
end_col = (min(1, col[0]+0.3), min(1, col[1]+0.3), min(1, col[2]+0.3))
size = random.uniform(2.0,5.0)
self.particles.append([px, py, vx, vy, life, col, end_col, size])
# start an effects timer to animate particles/floating texts at higher frame rate
self._start_effects_timer()
# start timer: toggle flash every 120ms, do 6 phases (3 flashes)
if hasattr(self, '_anim_timer_id') and self._anim_timer_id:
GLib.source_remove(self._anim_timer_id)
self._anim_timer_id = GLib.timeout_add(120, self._anim_tick)
def _anim_tick(self):
self._anim_phase += 1
# queue redraw to show flash
self.darea.queue_draw()
# update particles/texts during animation at anim tick rate
self._update_particles(anim=True)
self._update_floating_texts(anim=True)
# after 6 phases remove rows
if self._anim_phase >= 6:
rows = sorted(self._anim_rows)
# compute scoring before rows are removed (we still have the board cells)
lines = len(rows)
# compute number of cleared blocks
cleared_blocks = 0
for r in rows:
cleared_blocks += sum(1 for v in self.board[r] if v)
# scoring: base by lines, bonus by cleared blocks, combo multiplier
bonus = cleared_blocks * 10
# combo: if previous clear >0 then increment combo else reset was handled earlier
self.combo += 1 if lines>0 else 0
combo_mul = 1 + (self.combo - 1) * 0.5 if self.combo>1 else 1
score_gain = int((bonus) * combo_mul)
self.score += score_gain
self.last_cleared = lines
# create floating text at center of cleared area (avoid stacking)
minr, maxr = min(rows), max(rows)
cx = 10 + (W*CELL)/2
cy = 10 + ((minr + maxr + 1)/2.0) * CELL
# avoid stacking: shift horizontally by current number of floating_texts
shift = (len(self.floating_texts) % 5) * 12 - 24
self.floating_texts.append([cx + shift, cy, f"+{score_gain}", 45])
# progress level count per clear-event
self.clears_done += len(rows)
# when clears_done reaches threshold, level up
threshold = 7 + self.level
if self.clears_done >= threshold:
self.clears_done -= threshold
self.level += 1
# speed up main tick timer
try:
if getattr(self, 'timeout_id', None):
GLib.source_remove(self.timeout_id)
except Exception:
pass
# reduce next_time by delta but keep a minimum
# global next_time
next_time = max(50, base_time - self.level_speed_delta * self.level)
# self.level_speed_delta = 0
# print(next_time)
self.timeout_id = GLib.timeout_add(next_time, self.tick)
# camera shake proportional to lines
self.shake_frames = 8
self.shake_magnitude = min(12, 3 * lines)
# then remove rows
self.remove_rows(rows)
# cleanup
try:
GLib.source_remove(self._anim_timer_id)
except Exception:
pass
self._anim_timer_id = None
self._anim_rows = set()
self._anim_phase = 0
self.animating = False
# redraw final
self.darea.queue_draw()
if self._after_clear_callback:
cb = self._after_clear_callback
self._after_clear_callback = None
cb()
return False
return True
def _update_particles(self, anim=False):
# wrapper for compatibility: if anim True, run the same update; keep API
# (we keep original signature for existing calls)
newp = []
wind = random.uniform(-0.15, 0.15)
for p in self.particles:
px, py, vx, vy, life, scol, ecol, size = p
vx += wind * 0.3
vy += 0.25
px += vx
py += vy
life -= 0.06 if anim else 0.04
if life > 0:
newp.append([px, py, vx, vy, life, scol, ecol, size])
self.particles = newp
def load_highscore(self):
try:
if os.path.exists(self._hs_path):
with open(self._hs_path, 'r') as f:
data = json.load(f)
self.highscore = int(data.get('highscore', 0))
except Exception:
self.highscore = 0
def save_highscore(self):
try:
with open(self._hs_path, 'w') as f:
json.dump({'highscore': int(self.highscore)}, f)
except Exception:
pass
def _update_floating_texts(self, anim=False):
# each text: x,y,text,life
newt = []
for t in self.floating_texts:
x,y,txt,life = t
# move up and fade
y -= 1.2 if anim else 0.8
life -= 2 if anim else 1
if life>0:
newt.append([x,y,txt,life])
self.floating_texts = newt
def _start_effects_timer(self):
if getattr(self, '_effects_timer_id', None):
return
self._effects_timer_id = GLib.timeout_add(60, self._effects_tick)
def _stop_effects_timer(self):
if getattr(self, '_effects_timer_id', None):
try:
GLib.source_remove(self._effects_timer_id)
except Exception:
pass
self._effects_timer_id = None
def _effects_tick(self):
# update particles and floating texts at higher frame rate
self._update_particles(anim=False)
self._update_floating_texts(anim=False)
# stop timer if nothing left
if not self.particles and not self.floating_texts:
self._effects_timer_id = None
return False
self.darea.queue_draw()
return True
def tick(self):
# if animating, skip gravity and actions until animation finishes
if getattr(self, 'animating', False):
# still update animations
self._update_particles()
self._update_floating_texts()
return True
if self.paused: return True
self.cur.y += 1
if self.collide(self.cur):
self.cur.y -= 1
self.place(self.cur)
rows = self.detect_full_rows()
if rows:
# animate then continue
def after():
lines = len(rows)
# combo handled in anim tick; here just spawn next
self.combo = self.combo # no-op placeholder
if not self.spawn_next():
return
self.animate_clear(rows, after_callback=after)
else:
# no lines cleared: reset combo
self.combo = 0
if not self.spawn_next():
return False
self.darea.queue_draw()
return True
def on_key(self, widget, event):
key = Gdk.keyval_name(event.keyval)
if key in ("q", "Q"):
Gtk.main_quit()
if key in ('p', 'P'):
self.paused = not self.paused
# if animating, ignore other keys except quit/pause
if getattr(self, 'animating', False):
return
if self.paused: return
if key in ("Left", "a", "A"):
self.cur.x -= 1
if self.collide(self.cur): self.cur.x += 1
elif key in ("Right", "d", "D"):
self.cur.x += 1
if self.collide(self.cur): self.cur.x -= 1
elif key in ("Down", "s", "S"):
self.cur.y += 1
if self.collide(self.cur):
self.cur.y -= 1
self.place(self.cur)
rows = self.detect_full_rows()
if rows:
def after():
# spawn handled after animation; keep combo placeholder
if not self.spawn_next():
return
self.animate_clear(rows, after_callback=after)
else:
# no clear -> reset combo
self.combo = 0
if not self.spawn_next():
return
elif key in ("Up","w","W"):
old = [r[:] for r in self.cur.shape]
self.cur.rotate()
if self.collide(self.cur):
self.cur.shape = old
elif key == 'space':
while not self.collide(self.cur):
self.cur.y += 1
self.cur.y -= 1
self.place(self.cur)
rows = self.detect_full_rows()
if rows:
def after():
if not self.spawn_next():
return
self.animate_clear(rows, after_callback=after)
else:
# no clear -> reset combo
self.combo = 0
if not self.spawn_next():
return
self.darea.queue_draw()
def on_draw(self, widget, cr):
# background
cr.set_source_rgb(0.1,0.1,0.1)
cr.paint()
# camera shake offset
offx = 0
offy = 0
if self.shake_frames > 0:
offx = random.uniform(-self.shake_magnitude, self.shake_magnitude)
offy = random.uniform(-self.shake_magnitude, self.shake_magnitude)
self.shake_frames -= 1
# draw board background (apply camera offset)
cr.set_source_rgb(0.0,0.0,0.0)
cr.rectangle(10+offx,10+offy,BOARD_W, BOARD_H)
cr.fill()
# draw cells
for i in range(H):
for j in range(W):
val = self.board[i][j]
if val:
# during animation, if this row is being flashed, toggle visibility
if hasattr(self, '_anim_rows') and i in getattr(self, '_anim_rows', set()):
phase = getattr(self, '_anim_phase', 0)
# flash on even phases
if phase % 2 == 0:
color = COLORS[(val-1) % len(COLORS)]
cr.set_source_rgb(*color)
cr.rectangle(10+offx + j*CELL, 10+offy + i*CELL, CELL-1, CELL-1)
cr.fill()
else:
color = COLORS[(val-1) % len(COLORS)]
cr.set_source_rgb(*color)
cr.rectangle(10+offx + j*CELL, 10+offy + i*CELL, CELL-1, CELL-1)
cr.fill()
# draw current piece
for i,row in enumerate(self.cur.shape):
for j,v in enumerate(row):
if not v: continue
bx = self.cur.x + j
by = self.cur.y + i
if by >=0:
color = COLORS[self.cur.id % len(COLORS)]
cr.set_source_rgb(*color)
cr.rectangle(10+offx + bx*CELL, 10+offy + by*CELL, CELL-1, CELL-1)
cr.fill()
# sidebar: next piece & score
sx = 20 + BOARD_W
sy = 20
cr.set_source_rgb(0.2,0.2,0.2)
cr.rectangle(sx, 10, SIDEBAR_W-20, BOARD_H)
cr.fill()
cr.set_source_rgb(1,1,1)
cr.select_font_face("Monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
cr.set_font_size(18)
cr.move_to(sx+10, sy+20)
cr.show_text(f"Score: {self.score}")
cr.move_to(sx+10, sy+50)
cr.set_font_size(12)
cr.show_text(f"Blocks: {self.total_blocks}")
cr.move_to(sx+10, sy+70)
cr.show_text(f"Combo: x{1 + max(0,self.combo-1)*0.5:.1f}")
cr.move_to(sx+10, sy+90)
cr.show_text(f"Level: {self.level}")
cr.move_to(sx+10, sy+110)
cr.show_text(f"Clears: {self.clears_done}/{7 + self.level}")
cr.move_to(sx+10, sy+130)
cr.show_text("Next:")
# draw next piece
for i,row in enumerate(self.next.shape):
for j,v in enumerate(row):
if not v: continue
color = COLORS[self.next.id % len(COLORS)]
cr.set_source_rgb(*color)
cr.rectangle(sx + 10 + j*CELL, sy + 140 + i*CELL, CELL-1, CELL-1)
cr.fill()
# draw particles (interpolate color by life)
for p in self.particles:
px, py, vx, vy, life, scol, ecol, size = p
alpha = max(0, min(1, life))
# linear interpolation of color
r = scol[0]*life + ecol[0]*(1-life)
g = scol[1]*life + ecol[1]*(1-life)
b = scol[2]*life + ecol[2]*(1-life)
cr.set_source_rgba(r, g, b, alpha)
cr.arc(px+offx, py+offy, size, 0, 2*3.14159)
cr.fill()
# draw floating texts
cr.set_font_size(18)
for t in self.floating_texts:
x,y,txt,life = t
alpha = max(0, min(1, life/45.0))
cr.set_source_rgba(1,1,1, alpha)
cr.move_to(x+offx, y+offy)
cr.show_text(txt)
if __name__ == '__main__':
win = TetrisWindow()
win.connect('destroy', Gtk.main_quit)
win.show_all()
Gtk.main()