#!/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()
posted @ 2025-11-21 20:31  Gon-Tata  阅读(9)  评论(0)    收藏  举报