288 lines
7.2 KiB
GDScript
288 lines
7.2 KiB
GDScript
extends Node2D
|
|
|
|
const BOARD_WIDTH := 10
|
|
const BOARD_HEIGHT := 20
|
|
const CELL_SIZE := 24 # pixels
|
|
|
|
# Types of pieces
|
|
# Each shape is a list of Vector2i, relative to a pivot (0,0)
|
|
const SHAPES := {
|
|
"I": [Vector2i(-1,0), Vector2i(0,0), Vector2i(1,0), Vector2i(2,0)],
|
|
"O": [Vector2i(0,0), Vector2i(1,0), Vector2i(0,1), Vector2i(1,1)],
|
|
"T": [Vector2i(-1,0), Vector2i(0,0), Vector2i(1,0), Vector2i(0,1)],
|
|
"L": [Vector2i(-1,0), Vector2i(0,0), Vector2i(1,0), Vector2i(1,1)],
|
|
"J": [Vector2i(-1,1), Vector2i(-1,0), Vector2i(0,0), Vector2i(1,0)],
|
|
"S": [Vector2i(0,0), Vector2i(1,0), Vector2i(-1,1), Vector2i(0,1)]
|
|
}
|
|
|
|
# Link shape keys to colors
|
|
const COLORS := {
|
|
"I": Color(0.0, 1.0, 1.0),
|
|
"O": Color(1.0, 1.0, 0.0),
|
|
"T": Color(0.6, 0.0, 0.8),
|
|
"L": Color(1.0, 0.5, 0.0),
|
|
"J": Color(0.0, 0.0, 1.0),
|
|
"S": Color(0.0, 0.5, 0.0)
|
|
}
|
|
|
|
# 2D grid: 0 = empty, else a String shape key
|
|
var grid: Array = []
|
|
|
|
# Current falling piece
|
|
var current_shape_key: String
|
|
var current_blocks: Array[Vector2i] = []
|
|
var current_pos: Vector2i
|
|
var current_color: Color
|
|
|
|
# Next piece
|
|
var next_shape_key: String = ""
|
|
var rng := RandomNumberGenerator.new()
|
|
|
|
# Drop timing
|
|
var drop_interval := 0.7
|
|
var drop_timer := 0.0
|
|
var is_game_over := false
|
|
|
|
# Signals to update HUD and preview
|
|
signal lines_cleared(total_lines: int, score: int, highscore: int)
|
|
signal next_piece_changed(shape_key: String, shapes: Dictionary, colors: Dictionary)
|
|
signal game_over(score: int, highscore: int, total_lines: int)
|
|
|
|
var score := 0
|
|
var highscore := 0
|
|
var total_lines_cleared := 0
|
|
|
|
func reset_game() -> void:
|
|
score = 0
|
|
total_lines_cleared = 0
|
|
drop_timer = 0.0
|
|
is_game_over = false
|
|
|
|
_init_grid()
|
|
_spawn_initial_pieces()
|
|
|
|
# Update HUD to show reset stats
|
|
emit_signal("lines_cleared", total_lines_cleared, score, highscore)
|
|
queue_redraw()
|
|
|
|
func _ready() -> void:
|
|
rng.randomize()
|
|
_load_highscore()
|
|
reset_game()
|
|
|
|
func _process(delta: float) -> void:
|
|
if is_game_over:
|
|
return
|
|
|
|
drop_timer += delta
|
|
if drop_timer >= drop_interval:
|
|
drop_timer = 0.0
|
|
_try_move(Vector2i(0, 1), true)
|
|
|
|
_handle_input()
|
|
queue_redraw()
|
|
|
|
func _init_grid() -> void:
|
|
grid.resize(BOARD_HEIGHT)
|
|
for y in range(BOARD_HEIGHT):
|
|
grid[y] = []
|
|
grid[y].resize(BOARD_WIDTH)
|
|
for x in range(BOARD_WIDTH):
|
|
grid[y][x] = "" # empty string = no blocks
|
|
|
|
func _spawn_initial_pieces() -> void:
|
|
next_shape_key = _random_shape()
|
|
_spawn_new_piece()
|
|
|
|
func _random_shape() -> String:
|
|
var keys := SHAPES.keys()
|
|
return keys[rng.randi() % keys.size()]
|
|
|
|
func _spawn_new_piece() -> void:
|
|
# Move "next" to "current"
|
|
current_shape_key = next_shape_key
|
|
|
|
# Get the raw shape array (untyped)
|
|
var shape_array: Array = SHAPES[current_shape_key]
|
|
|
|
# Fill our typed Array[Vector2i]
|
|
current_blocks.clear()
|
|
for p in shape_array:
|
|
current_blocks.append(p as Vector2i)
|
|
|
|
# Color for this piece
|
|
current_color = Color.WHITE
|
|
if COLORS.has(current_shape_key):
|
|
current_color = COLORS[current_shape_key] as Color
|
|
|
|
# Spawn near top center
|
|
current_pos = Vector2i(BOARD_WIDTH / 2, 1)
|
|
|
|
# Choose the next upcoming piece
|
|
next_shape_key = _random_shape()
|
|
emit_signal("next_piece_changed", next_shape_key, SHAPES, COLORS)
|
|
|
|
# Check for immediate collision → game over
|
|
if _is_colliding(current_pos, current_blocks):
|
|
_game_over()
|
|
|
|
func _handle_input() -> void:
|
|
if is_game_over:
|
|
return
|
|
|
|
if Input.is_action_just_pressed("move_left"):
|
|
_try_move(Vector2i(-1, 0))
|
|
if Input.is_action_just_pressed("move_right"):
|
|
_try_move(Vector2i(1, 0))
|
|
if Input.is_action_pressed("soft_drop"):
|
|
_try_move(Vector2i(0, 1))
|
|
if Input.is_action_just_pressed("rotate"):
|
|
_try_rotate()
|
|
|
|
func _try_move(offset: Vector2i, lock_on_fail: bool = false) -> void:
|
|
var new_pos := current_pos + offset
|
|
if not _is_colliding(new_pos, current_blocks):
|
|
current_pos = new_pos
|
|
elif lock_on_fail and offset.y > 0:
|
|
_lock_piece()
|
|
|
|
func _try_rotate() -> void:
|
|
var rotated: Array[Vector2i] = []
|
|
for b in current_blocks:
|
|
# (x, y) → (-y, x)
|
|
rotated.append(Vector2i(-b.y, b.x))
|
|
|
|
if not _is_colliding(current_pos, rotated):
|
|
current_blocks = rotated
|
|
|
|
func _is_colliding(pos: Vector2i, blocks: Array[Vector2i]) -> bool:
|
|
for b in blocks:
|
|
var x := pos.x + b.x
|
|
var y := pos.y + b.y
|
|
if x < 0 or x >= BOARD_WIDTH or y < 0 or y >= BOARD_HEIGHT:
|
|
return true
|
|
if grid[y][x] != "":
|
|
return true
|
|
return false
|
|
|
|
func _lock_piece() -> void:
|
|
# Transfer current piece into grid
|
|
var points: int
|
|
for b in current_blocks:
|
|
var x := current_pos.x + b.x
|
|
var y := current_pos.y + b.y
|
|
if y >= 0 and y < BOARD_HEIGHT and x >= 0 and x < BOARD_WIDTH:
|
|
grid[y][x] = current_shape_key
|
|
|
|
var cleared := _clear_full_lines()
|
|
|
|
# scoring (simple rules)
|
|
if cleared > 0:
|
|
total_lines_cleared += cleared
|
|
match cleared:
|
|
1:
|
|
points = 100
|
|
2:
|
|
points = 300
|
|
3:
|
|
points = 500
|
|
4:
|
|
points = 800
|
|
_:
|
|
points = cleared * 200
|
|
score += points
|
|
if score > highscore:
|
|
highscore = score
|
|
_save_highscore()
|
|
|
|
emit_signal("lines_cleared", total_lines_cleared, score, highscore)
|
|
|
|
_spawn_new_piece()
|
|
|
|
func _clear_full_lines() -> int:
|
|
var cleared_lines := 0
|
|
var y := BOARD_HEIGHT - 1
|
|
|
|
while y >= 0:
|
|
var is_full := true
|
|
for x in range(BOARD_WIDTH):
|
|
if grid[y][x] == "":
|
|
is_full = false
|
|
break
|
|
|
|
if is_full:
|
|
# Remove this row and drop everything above it down by 1
|
|
_drop_lines_above(y)
|
|
cleared_lines += 1
|
|
# IMPORTANT:
|
|
# Do NOT change y here.
|
|
# After dropping, a new row has moved into index y,
|
|
# so we re-check the same y in the next loop iteration.
|
|
else:
|
|
# Only move up when the current row was not cleared
|
|
y -= 1
|
|
|
|
return cleared_lines
|
|
|
|
func _drop_lines_above(row: int) -> void:
|
|
for y in range(row, 0, -1):
|
|
for x in range(BOARD_WIDTH):
|
|
grid[y][x] = grid[y - 1][x]
|
|
# top row becomes empty
|
|
for x in range(BOARD_WIDTH):
|
|
grid[0][x] = ""
|
|
|
|
func _draw() -> void:
|
|
# Draw background grid (optional)
|
|
for y in range(BOARD_HEIGHT):
|
|
for x in range(BOARD_WIDTH):
|
|
var rect := Rect2(
|
|
Vector2(x * CELL_SIZE, y * CELL_SIZE),
|
|
Vector2(CELL_SIZE, CELL_SIZE)
|
|
)
|
|
draw_rect(rect, Color(0.05, 0.05, 0.05), false, 1.0)
|
|
|
|
# Draw placed blocks
|
|
for y in range(BOARD_HEIGHT):
|
|
for x in range(BOARD_WIDTH):
|
|
var key: String = grid[y][x]
|
|
if key != "":
|
|
var color: Color = Color.WHITE
|
|
if COLORS.has(key):
|
|
color = COLORS[key] as Color
|
|
_draw_cell(x, y, color)
|
|
|
|
# Draw current falling piece
|
|
for b in current_blocks:
|
|
var x := current_pos.x + b.x
|
|
var y := current_pos.y + b.y
|
|
_draw_cell(x, y, current_color)
|
|
|
|
func _draw_cell(x: int, y: int, color: Color) -> void:
|
|
var pos := Vector2(x * CELL_SIZE, y * CELL_SIZE)
|
|
var rect := Rect2(pos, Vector2(CELL_SIZE, CELL_SIZE))
|
|
draw_rect(rect.grow(-1), color, true) # small border
|
|
|
|
func _game_over() -> void:
|
|
is_game_over = true
|
|
|
|
# Make sure HUD shows final values
|
|
emit_signal("lines_cleared", total_lines_cleared, score, highscore)
|
|
|
|
# Inform main scene that the game ended
|
|
emit_signal("game_over", score, highscore, total_lines_cleared)
|
|
|
|
func _save_highscore() -> void:
|
|
var cfg := ConfigFile.new()
|
|
cfg.set_value("stats", "highscore", highscore)
|
|
cfg.save("user://tetris.cfg")
|
|
|
|
func _load_highscore() -> void:
|
|
var cfg := ConfigFile.new()
|
|
var err := cfg.load("user://tetris.cfg")
|
|
if err == OK:
|
|
highscore = int(cfg.get_value("stats", "highscore", 0))
|
|
else:
|
|
highscore = 0
|
|
|