Add Pac-Man power-on flow and document Odin integration

- 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.
This commit is contained in:
Jeff Luke committed 2026-10-17 18:47:26 -07:00
1 parent c1b2195834
commit 41ef12f954
27 files changed
+577 -48

No files matched your search

+50 -2
View File
@@ -13,6 +13,10 @@ var model = Model.new()
var view
var audio
var accumulator := 0.0
signal power_state_changed(state: String)
const BOOT_SECONDS := 4.0
var power_state := "off"
var boot_elapsed := 0.0
var credits := 0
var rfid_seen: Dictionary = {}
var rfid_initialized := false
@@ -30,7 +34,11 @@ func receive_device_feedback(feedback: Dictionary) -> void:
var ident := str(entry.get("id", ""))
if ident.is_empty() or rfid_seen.has(ident): continue
rfid_seen[ident] = true
if device_id.is_empty() or is_running or credits > 0 or model.state != "idle": continue
if power_state == "off" and entry.get("device") == run_config.get("power_on_device", "") and entry.get("kind") == "input" and entry.get("value") == true and str(entry.get("error", "")).is_empty():
for sample in feedback.get("devices", []):
if sample.get("id") == entry.get("device") and int(entry.get("age_ms", 999999)) < 2000 and int(sample.get("age_ms", 999999)) < 2000 and str(sample.get("error", "")).is_empty():
power_on()
if power_state != "ready" or device_id.is_empty() or is_running or credits > 0 or model.state != "idle": continue
if entry.get("device") != device_id or entry.get("kind") != "rfid" or not str(entry.get("error", "")).is_empty(): continue
var uid := str(entry.get("value", "")).to_upper()
if uid.is_empty(): continue
@@ -57,13 +65,40 @@ func _ready() -> void:
model.sound_requested.connect(audio.play_effect)
model.finished.connect(_on_finished)
_apply_config()
if str(run_config.get("power_on_device", "")).is_empty(): power_on()
func configure(config: Dictionary = {}) -> void:
power_off()
super.configure(config)
credits = 0
rfid_seen.clear()
rfid_initialized = false
_apply_config()
if str(run_config.get("power_on_device", "")).is_empty(): power_on()
func power_on() -> bool:
if power_state != "off": return false
boot_elapsed = 0.0
power_state = "booting"
view.power_state = power_state
view.startup_time = 0.0
audio.stop_all()
power_state_changed.emit(power_state)
return true
func power_off() -> void:
is_running = false
credits = 0
boot_elapsed = 0.0
power_state = "off"
model.reset()
accumulator = 0.0
if audio != null: audio.stop_all()
if view != null:
view.power_state = power_state
view.startup_time = 0.0
view.credits = 0
power_state_changed.emit(power_state)
func _apply_config() -> void:
if view != null:
@@ -75,7 +110,7 @@ func _apply_config() -> void:
model.extra_life_score = maxi(0, int(run_config.get("extra_life_score", extra_life_score)))
func start_game() -> bool:
if is_running: return false
if is_running or power_state != "ready": return false
if not str(run_config.get("rfid_credit_device", "")).is_empty():
if credits < 1: return false
credits -= 1
@@ -117,6 +152,19 @@ func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed(pair[0]): model.requested = pair[1]
func _physics_process(delta: float) -> void:
if power_state == "off": return
if power_state == "booting":
boot_elapsed = minf(BOOT_SECONDS, boot_elapsed + delta)
view.startup_time = boot_elapsed
if boot_elapsed >= BOOT_SECONDS:
power_state = "ready"
view.power_state = power_state
view.startup_time = 0.0
# Ignore scans collected while the ROM check was running.
rfid_initialized = false
power_state_changed.emit(power_state)
return
if not is_running: view.startup_time += delta
if not is_running:
model.clock += delta
if is_running:
+64
View File
@@ -10,6 +10,8 @@ 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
@@ -28,6 +30,17 @@ func _process(_dt: float) -> void:
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)
@@ -145,6 +158,57 @@ func _text(message: String, at: Vector2, color := Color.WHITE) -> void:
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))