- Add a four-second arcade-style boot sequence followed by the Character / Nickname introduction. - Separate cabinet power-on from RFID credit and player Start. - Add operator Power on control and cabinet power-state reporting. - Prevent repeated power commands from restarting boot or gameplay. - Ignore RFID scans received before the cabinet is ready. - Add power-state, RFID, controller, and GPIO feedback regression tests. - Expand the master walkthrough with export, packaging, file transfer, fresh-Pi setup, RFID compilation, and end-to-end testing instructions. - Correct tester hardware documentation: BCM23/J20 drives an LED; physical buttons connect through a separate I2C1 input board. - Document operator-triggered power-on as the current test workflow and retain the temporary 2.4 configuration compatibility notes. - Record successful physical testing of power-on, RFID credit, and player Start. - Add an Odin integration README describing the proposed Rust/NATS bridge, shared messages, Flutter GM interface, autopilot sequencing, hardware ownership, and Starfish SSH-management role. - Define migration milestones and information needed from the framework owner; link the integration guide from the root README. Validation: 69 controller tests, 10 GPIO tests, Pac-Man power/RFID checks, managed-cabinet lifecycle checks, and visual screen inspection.
215 lines
9.8 KiB
GDScript
215 lines
9.8 KiB
GDScript
@tool
|
|
extends Node2D
|
|
const Maze = preload("res://games/pacman/pacman_maze.gd")
|
|
const Rules = preload("res://games/pacman/pacman_rules.gd")
|
|
const SHEET = preload("res://games/pacman/art/sprites.png")
|
|
const MAZE_RECT := Rect2(0, 24, 224, 248)
|
|
const GAME_RECT := Rect2(0, 0, 224, 288)
|
|
const FONT = preload("res://games/pacman/art/text.png")
|
|
const DEATH_ANIMATION_SECONDS := 1.55
|
|
const DEATH_POP_SECONDS := 0.24
|
|
var model
|
|
var debug_grid := false
|
|
var power_state := "off"
|
|
var startup_time := 0.0
|
|
var credit_required := false
|
|
var credits := 0
|
|
|
|
func _ready() -> void:
|
|
texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
|
|
|
func _process(_dt: float) -> void:
|
|
if Engine.is_editor_hint(): return
|
|
var available := get_viewport_rect().size
|
|
var factor := minf(available.x / 224.0, available.y / 288.0)
|
|
if factor >= 1.0: factor = floorf(factor)
|
|
scale = Vector2.ONE * factor
|
|
position = ((available - Vector2(224, 288) * factor) * 0.5).floor()
|
|
queue_redraw()
|
|
|
|
func _draw() -> void:
|
|
draw_rect(_viewport_bounds(), Color.BLACK)
|
|
if model == null: return
|
|
if power_state == "off":
|
|
_text("WAITING FOR POWER", Vector2(44, 136))
|
|
return
|
|
if power_state == "booting":
|
|
_boot_screen()
|
|
_draw_edge_mask(GAME_RECT)
|
|
return
|
|
if model.state == "idle":
|
|
_character_intro()
|
|
_draw_edge_mask(GAME_RECT)
|
|
return
|
|
if model.state == "intermission":
|
|
_intermission()
|
|
_draw_edge_mask(GAME_RECT)
|
|
return
|
|
var flash: bool = model.state == "clear" and int(model.phase_time * 5) % 2 == 0
|
|
draw_texture_rect_region(SHEET, MAZE_RECT, Rect2(228, 0, 224, 248), Color(0.7, 0.7, 1.0) if flash else Color.WHITE)
|
|
for tile in model.dots:
|
|
var center := _pixel(Vector2(tile))
|
|
if model.dots[tile]:
|
|
if int(model.clock * 4) % 2 == 0: draw_circle(center, 3.5, Color("ffb8ae"))
|
|
else: draw_rect(Rect2(center - Vector2.ONE, Vector2(2, 2)), Color("ffb8ae"))
|
|
if model.fruit_time > 0:
|
|
_sprite(Rect2(504 + Rules.fruit_index(model.level) * 16, 48, 16, 16), _actor_pixel(Vector2(13.5, 17)))
|
|
if model.state not in ["ready", "dying", "death_pop", "lost", "won", "clear"]:
|
|
for i in range(4): _ghost(i)
|
|
if model.state not in ["ready", "death_pop", "lost", "won", "clear"]: _player()
|
|
if model.state == "death_pop":
|
|
_sprite(Rect2(664, 0, 16, 16), _actor_pixel(Maze.position(model.player)))
|
|
if model.popup_time > 0: _text(model.popup_text, _pixel(model.popup_position) - Vector2(12, 4), Color.CYAN)
|
|
_draw_edge_mask(MAZE_RECT)
|
|
_text("1UP", Vector2(24, 0))
|
|
_text("HIGH SCORE", Vector2(80, 0))
|
|
_text(str(model.score).pad_zeros(2), Vector2(8, 8))
|
|
_text(str(model.high_score).pad_zeros(2), Vector2(88, 8))
|
|
if credit_required: _text("CREDIT %d" % credits, Vector2(120, 280))
|
|
for i in range(mini(model.lives - 1, 5)):
|
|
_sprite(Rect2(600, 16, 16, 16), Vector2(16 + i * 16, 280))
|
|
if not credit_required: _text("L%d" % model.level, Vector2(192, 276), Color("ffb852"))
|
|
match model.state:
|
|
"idle":
|
|
_banner("SCAN CARD" if credit_required and credits == 0 else "PRESS START", Color.YELLOW)
|
|
_text("ARROWS TO MOVE", Vector2(56, 256))
|
|
"ready":
|
|
_banner("READY!", Color.YELLOW)
|
|
_text("PLAYER ONE", Vector2(72, 112), Color("33aaff"))
|
|
"won": _banner("YOU WIN!", Color.CYAN)
|
|
"lost": _banner("GAME OVER", Color.RED)
|
|
if debug_grid:
|
|
for y in range(31):
|
|
for x in range(28):
|
|
if Maze.walkable(Vector2i(x,y)):
|
|
draw_rect(Rect2(x * 8, y * 8 + 24, 8, 8), Color(0, 1, 0, 0.25), false)
|
|
|
|
func _viewport_bounds() -> Rect2:
|
|
if Engine.is_editor_hint(): return GAME_RECT
|
|
return get_global_transform_with_canvas().affine_inverse() * get_viewport_rect()
|
|
|
|
func _draw_edge_mask(visible_area: Rect2) -> void:
|
|
var outer := _viewport_bounds().merge(visible_area)
|
|
draw_rect(Rect2(outer.position, Vector2(visible_area.position.x - outer.position.x, outer.size.y)), Color.BLACK)
|
|
draw_rect(Rect2(Vector2(visible_area.end.x, outer.position.y), Vector2(outer.end.x - visible_area.end.x, outer.size.y)), Color.BLACK)
|
|
draw_rect(Rect2(Vector2(visible_area.position.x, outer.position.y), Vector2(visible_area.size.x, visible_area.position.y - outer.position.y)), Color.BLACK)
|
|
draw_rect(Rect2(Vector2(visible_area.position.x, visible_area.end.y), Vector2(visible_area.size.x, outer.end.y - visible_area.end.y)), Color.BLACK)
|
|
|
|
func _pixel(tile: Vector2) -> Vector2:
|
|
return Vector2(4, 28) + tile * 8.0
|
|
|
|
func _actor_pixel(tile: Vector2) -> Vector2:
|
|
return _pixel(tile)
|
|
|
|
func _intermission() -> void:
|
|
var elapsed: float = 5.28 - model.phase_time
|
|
var frame := int(model.clock * 12) % 2
|
|
if elapsed < 2.64:
|
|
var x := 240.0 - elapsed * 105.0
|
|
_sprite(Rect2(456 + frame * 16, 16, 16, 16), Vector2(x, 144))
|
|
_sprite(Rect2(488 + frame * 16, 64, 16, 16), Vector2(x + 40, 144))
|
|
else:
|
|
var x := -40.0 + (elapsed - 2.64) * 110.0
|
|
_sprite(Rect2(584 + frame * 16, 64, 16, 16), Vector2(x + 55, 144))
|
|
draw_texture_rect_region(SHEET, Rect2(x - 16, 128, 32, 32), Rect2(504 + frame * 32, 16, 32, 32))
|
|
_text("LEVEL %d CLEAR" % model.level, Vector2(56, 200), Color.YELLOW)
|
|
|
|
func _sprite(region: Rect2, center: Vector2) -> void:
|
|
draw_texture_rect_region(SHEET, Rect2(center.round() - Vector2(8, 8), Vector2(16, 16)), region)
|
|
if center.x < 8: draw_texture_rect_region(SHEET, Rect2(center.round() + Vector2(216, -8), Vector2(16, 16)), region)
|
|
elif center.x > 216: draw_texture_rect_region(SHEET, Rect2(center.round() - Vector2(232, 8), Vector2(16, 16)), region)
|
|
|
|
func _player() -> void:
|
|
var p: Dictionary = model.player
|
|
var region := Rect2(456, 0, 16, 16)
|
|
if model.state == "dying":
|
|
var elapsed: float = DEATH_ANIMATION_SECONDS - model.phase_time
|
|
var frame := clampi(int(elapsed / DEATH_ANIMATION_SECONDS * 11.0), 0, 10)
|
|
region = Rect2(488 + frame * 16, 0, 16, 16)
|
|
else:
|
|
var row: int = {Vector2i.RIGHT: 0, Vector2i.LEFT: 1, Vector2i.UP: 2, Vector2i.DOWN: 3}.get(p.dir, 0)
|
|
var frame: int = [0, 1, 2, 1][int(model.player_anim_time * 12.0) % 4] if p.tile != p.next else 1
|
|
region = Rect2(456 + frame * 16, row * 16, 16, 16)
|
|
_sprite(region, _actor_pixel(Maze.position(p)))
|
|
|
|
func _ghost(index: int) -> void:
|
|
var g: Dictionary = model.ghosts[index]
|
|
var direction: int = {Vector2i.RIGHT: 0, Vector2i.LEFT: 1, Vector2i.UP: 2, Vector2i.DOWN: 3}.get(g.dir, 0)
|
|
var frame := int(model.clock * 8) % 2
|
|
var region := Rect2(456 + direction * 32 + frame * 16, 64 + index * 16, 16, 16)
|
|
if g.status in ["eyes", "entering"]: region = Rect2(584 + direction * 16, 80, 16, 16)
|
|
elif g.frightened:
|
|
var white: bool = model.fright_time < 2.0 and int(model.fright_time * 6) % 2 == 0
|
|
region = Rect2(584 + frame * 16 + (32 if white else 0), 64, 16, 16)
|
|
var center := _actor_pixel(Maze.position(g))
|
|
if g.status == "house": center.y += sin(model.clock * 6 + index) * 2
|
|
_sprite(region, center)
|
|
|
|
func _banner(message: String, color: Color) -> void:
|
|
var width := message.length() * 8
|
|
draw_rect(Rect2(112 - width / 2 - 4, 160, width + 8, 12), Color.BLACK)
|
|
_text(message, Vector2(112 - width / 2, 162), color)
|
|
|
|
func _text(message: String, at: Vector2, color := Color.WHITE) -> void:
|
|
for i in range(message.length()):
|
|
var c := message[i]
|
|
var region := Rect2()
|
|
var code := c.unicode_at(0)
|
|
if code >= 65 and code <= 79: region = Rect2((code - 65) * 8, 0, 8, 8)
|
|
elif code >= 80 and code <= 90: region = Rect2((code - 80) * 8, 8, 8, 8)
|
|
elif code >= 48 and code <= 57: region = Rect2((code - 48) * 8, 16, 8, 8)
|
|
elif c == "/":
|
|
for dot in range(6): draw_rect(Rect2(at + Vector2(i * 8 + 6 - dot, dot + 1), Vector2.ONE), color)
|
|
continue
|
|
elif c == "!": region = Rect2(88, 8, 8, 8)
|
|
else: continue
|
|
draw_texture_rect_region(FONT, Rect2(at + Vector2(i * 8, 0), Vector2(8, 8)), region, color)
|
|
|
|
# Cosmetic recreation of the original video-RAM test, not an actual ROM test.
|
|
# All graphics remain on the native 224 x 288 pixel grid; no shader or noise texture.
|
|
func _boot_screen() -> void:
|
|
if startup_time < 0.12 or startup_time >= 3.75: return
|
|
var colors := [Color.CYAN, Color.WHITE, Color.YELLOW, Color("ffb8ff"), Color("ffb852"), Color.RED]
|
|
var blue := startup_time >= 1.8
|
|
var sweep := int(startup_time * 16.0)
|
|
for y in range(36):
|
|
for x in range(28):
|
|
var tile := (y * 7 + (x % 8) + sweep) % 32
|
|
var color: Color = Color("2121de") if blue else colors[(x + y) % colors.size()]
|
|
var at := Vector2(x * 8, y * 8)
|
|
match y % 9:
|
|
0, 1: pass
|
|
2: _text("P" if tile % 3 else "0", at, color)
|
|
3: draw_texture_rect_region(SHEET, Rect2(at, Vector2(8, 8)), Rect2(456 + (tile % 3) * 8, 0, 8, 8), color)
|
|
4: draw_rect(Rect2(at + Vector2(2, 3), Vector2(2, 2)), Color.WHITE)
|
|
5: draw_texture_rect_region(SHEET, Rect2(at, Vector2(8, 8)), Rect2((tile % 4) * 8, 0, 8, 8), color)
|
|
6: _text("0", at, color)
|
|
7: draw_texture_rect_region(SHEET, Rect2(at, Vector2(8, 8)), Rect2(456 + (tile % 4) * 8, 64, 8, 8), color)
|
|
8: if x % 4 == 0: _text(str(tile % 10), at, color)
|
|
|
|
func _character_intro() -> void:
|
|
_text("1UP", Vector2(24, 0))
|
|
_text("HIGH SCORE", Vector2(80, 0))
|
|
_text("00", Vector2(32, 8))
|
|
_text(str(model.high_score).pad_zeros(2), Vector2(88, 8))
|
|
_text("CHARACTER / NICKNAME", Vector2(40, 40))
|
|
var names := ["SHADOW", "SPEEDY", "BASHFUL", "POKEY"]
|
|
var nicknames := ["BLINKY", "PINKY", "INKY", "CLYDE"]
|
|
var colors := [Color("ff0000"), Color("ffb8ff"), Color("00ffff"), Color("ffb852")]
|
|
var elapsed := fmod(startup_time, 22.0)
|
|
for i in range(4):
|
|
var age := elapsed - float(i) * 1.6
|
|
if age < 0.2: continue
|
|
var y := 64 + i * 24
|
|
_sprite(Rect2(456 + (int(startup_time * 8) % 2) * 16, 64 + i * 16, 16, 16), Vector2(24, y + 4))
|
|
_text(names[i].left(clampi(int((age - 0.4) * 20), 0, names[i].length())), Vector2(48, y), colors[i])
|
|
_text(nicknames[i].left(clampi(int((age - 0.9) * 20), 0, nicknames[i].length())), Vector2(136, y), colors[i])
|
|
if elapsed > 7.0:
|
|
draw_rect(Rect2(64, 196, 2, 2), Color("ffb8ae"))
|
|
_text("10 PTS", Vector2(88, 192))
|
|
if int(startup_time * 4) % 2 == 0: draw_circle(Vector2(65, 216), 3.5, Color("ffb8ae"))
|
|
_text("50 PTS", Vector2(88, 212))
|
|
_text("1980 NAMCO", Vector2(72, 240), Color("ffb8ff"))
|
|
_text("SCAN CARD" if credit_required and credits == 0 else "PRESS START", Vector2(64, 264), Color.YELLOW)
|
|
_text("CREDIT %d" % credits, Vector2(16, 280))
|