Files
room_glitch/games/frogger/frogger.gd
T
Jeff Luke c1b2195834 Expand Glitch operations hub, cabinet authoring, deployment, and RFID support
Operator hub
- Introduce Escape Gallery branding and Glitch: the Dark Machine room views
- Add cabinet illustrations, Pac-Man marquee/gameplay artwork, and waiting states
- Add device feedback panels with RFID readings, input/output states, and stale status
- Add logged operator hardware interventions with request deduplication

Room and cabinet authoring
- Add a visual custom-PCB connection editor with per-cabinet device inventory
- Support device IDs, bindings, wiring notes, input polarity, and validation
- Add physical-room label/dimension toggles and editable doorway annotations
- Preserve doorway changes through undo, save, and revert
- Expand cabinet profiles, room layout, and Glitch scenario resources

Scenarios and gameplay
- Add per-assignment game configuration and runtime feature validation
- Add timed test wins and Frogger individual-home objectives
- Add the three-cabinet timed/score/Frogger test scenario
- Add Cabinet 1 Pac-Man RFID credits, credit sound, and CREDIT 0/1 display
- Require physical Start after an accepted scan and prevent duplicate credits

Pi hardware and deployment
- Add GPIO feedback sampling and a separate multiplexed RFID helper
- Validate reader pin ownership and input/output conflicts
- Add SSH cabinet startup and Pi desktop-session service support
- Add master packaging, site setup, SSH enrollment, and staged cabinet releases
- Verify deployment manifests and support explicit activation/status/log commands

Documentation and verification
- Consolidate the root README around the current architecture and setup workflow
- Add the complete master/Pi/RFID export and deployment walkthrough
- Document network isolation, credentials, card identification, and rollback
- Add PCB, room-editor, feedback, credit, deployment, and controller tests
- Isolate protocol tests from designer-edited cabinet configurations

Known limitations
- RFID helper compilation and physical Pi commissioning remain unverified
- Fleet provisioning does not yet configure RFID-only installations completely
- Broader Pac-Man tests retain a ghost-capture assertion failure
- Staged exports must be rebuilt and checked before deploying runtime 2.3
2026-10-17 14:53:54 -07:00

335 lines
9.4 KiB
GDScript

@tool
extends ArcadeGame
@export_range(0,99) var boards_to_win: int = 1
## Zero disables the individual-home objective; normal play uses board completion.
@export_range(0,5) var homes_to_win: int = 0
@export_range(1,9) var starting_lives: int = 3
@export_range(15,120) var crossing_time: float = 60.0
@export_range(.5,2) var difficulty: float = 1.0
@export var music_enabled: bool = true
const HOME_X := [16,64,112,160,208]
var phase := "title"
var phase_time := 0.0
var clock := 0.0
var score := 0
var high_score := 0
var lives := 3
var board_number := 1
var homes: Array[bool] = [false,false,false,false,false]
var frog := Vector2(112,232)
var from := Vector2(112,232)
var destination := Vector2(112,232)
var direction := 0
var hop_left := 0.0
var repeat_left := 0.0
var time_left := 60.0
var furthest_row := 0
var warned := false
var extra_awarded := false
var death_kind := "squash"
var lanes: Array[Dictionary] = []
var leaderboard: Array[int] = []
var attract_time := 0.0
var demo_active := false
var demo_time := 0.0
var idle_score := 0
@onready var view: Node2D = $PixelScreen/Board
func _ready() -> void:
make_lanes()
lives = starting_lives
func make_lanes() -> void:
lanes.clear()
var definitions := [
[1,"car",-24.0,16.0,80.0,20.0,1], [2,"car",28.0,16.0,82.0,48.0,3],
[3,"car",-32.0,16.0,78.0,8.0,0], [4,"car",45.0,16.0,110.0,70.0,2],
[5,"car",-22.0,32.0,100.0,30.0,4], [7,"turtle",-24.0,48.0,72.0,4.0,0],
[8,"log",25.0,48.0,98.0,30.0,0], [9,"log",36.0,96.0,144.0,5.0,0],
[10,"turtle",-30.0,32.0,70.0,14.0,1], [11,"log",22.0,64.0,100.0,40.0,0]]
for item in definitions:
lanes.append({"row":item[0],"kind":item[1],"speed":item[2]*(1+(board_number-1)*.10)*difficulty,"width":item[3],"spacing":item[4],"offset":item[5],"sprite":item[6]})
func start_game() -> bool:
if not super.start_game(): return false
demo_active = false
attract_time = 0
$Audio.stop_all()
score = 0
lives = starting_lives
board_number = 1
extra_awarded = false
homes.fill(false)
make_lanes()
respawn()
phase = "ready"
phase_time = 1.5
$Audio.effect("coin-in")
return true
func reset_game() -> void:
super.reset_game()
demo_active = false
attract_time = 0
$Audio.stop_all()
phase = "title"
clock = 0
score = 0
board_number = 1
lives = starting_lives
homes.fill(false)
make_lanes()
respawn()
func respawn() -> void:
frog = Vector2(112,232)
from = frog
destination = frog
direction = 0
hop_left = 0
repeat_left = 0
time_left = crossing_time
furthest_row = 0
warned = false
func _unhandled_input(event: InputEvent) -> void:
if Engine.is_editor_hint() or (event is InputEventKey and event.echo): return
if event.is_action_pressed("arcade_start"):
if not is_running: start_game()
func pressed(action: String) -> bool:
return Input.is_action_pressed(action)
func hop(step: Vector2i) -> bool:
if phase != "play" or hop_left > 0 or absi(step.x)+absi(step.y) != 1: return false
var target := frog+Vector2(step)*16
if target.x < 8 or target.x > 216 or target.y > 232 or target.y < 40: return false
direction = 0 if step.y < 0 else (2 if step.y > 0 else (1 if step.x < 0 else 3))
from = frog
destination = target
hop_left = .12
$Audio.effect("hop")
return true
func _physics_process(delta: float) -> void:
clock += delta
if Engine.is_editor_hint():
attract_time = fmod(clock,44.0)
view.queue_redraw()
return
if phase in ["title","scores"]:
attract_time += delta
if phase == "title" and attract_time >= 44.0:
begin_demo()
view.queue_redraw()
return
elif phase == "scores" and attract_time >= 7.0:
phase = "title"
attract_time = 0
elif phase in ["ready","dead","home","clear"]:
phase_time -= delta
if phase_time <= 0:
match phase:
"ready":
phase = "play"
if music_enabled and not demo_active: $Audio.music()
"dead":
if lives <= 0 and not demo_active:
finish(false)
else:
respawn()
phase = "play"
if music_enabled and not demo_active: $Audio.music()
"home":
var target := int(run_config.get("homes_to_win", homes_to_win))
if not demo_active and target > 0 and homes.count(true) >= target:
finish(true)
elif not homes.has(false):
add_score(1000)
phase = "clear"
phase_time = 2.0
else:
respawn()
phase = "play"
"clear":
if demo_active:
end_demo()
elif int(run_config.get("boards_to_win", boards_to_win)) > 0 and board_number >= int(run_config.get("boards_to_win", boards_to_win)):
finish(true)
else:
board_number += 1
homes.fill(false)
make_lanes()
respawn()
phase = "ready"
phase_time = 1.5
elif phase == "play":
repeat_left -= delta
if hop_left <= 0 and repeat_left <= 0:
var step := Vector2i.ZERO
if demo_active: step = demo_step()
elif pressed("arcade_up"): step = Vector2i.UP
elif pressed("arcade_down"): step = Vector2i.DOWN
elif pressed("arcade_left"): step = Vector2i.LEFT
elif pressed("arcade_right"): step = Vector2i.RIGHT
if step != Vector2i.ZERO and hop(step): repeat_left = .16
step_world(delta)
if demo_active:
demo_time += delta
if demo_time >= 32.0: end_demo()
view.queue_redraw()
func begin_demo() -> void:
idle_score = score
demo_active = true
demo_time = 0
score = 0
lives = starting_lives
board_number = 1
homes.fill(false)
clock = 0
make_lanes()
respawn()
phase = "play"
$Audio.stop_all()
func end_demo() -> void:
demo_active = false
score = idle_score
phase = "scores"
attract_time = 0
$Audio.stop_all()
# Predict the same lane positions and hitboxes used by live gameplay.
# The demo supplies ordinary hops; it has no invulnerability or scripted teleports.
func demo_safe(step: Vector2i) -> bool:
var target := frog + Vector2(step)*16
if target.x < 8 or target.x > 216 or target.y < 40 or target.y > 232: return false
if target.y == 40:
for i in range(5):
if absf(target.x-HOME_X[i]) <= 7 and not homes[i]: return true
return false
for tick in range(1,13):
var t := tick * .01
var point := frog.lerp(target,t/.12)
var row := roundi((232-point.y)/16)
for lane in lanes:
if lane.row != row: continue
var supported := false
var predicted := lane.duplicate()
predicted.offset += lane.speed*t
for x in lane_objects(predicted):
if lane.kind == "car" and point.x+6 > x+2 and point.x-6 < x+lane.width-2: return false
if lane.kind != "car" and point.x-5 >= x and point.x+5 <= x+lane.width:
supported = true
if tick == 12 and lane.kind != "car":
if not supported: return false
if lane.kind == "turtle" and lane.sprite == 1 and fposmod(clock+t,7.0) > 5.3: return false
return true
func demo_step() -> Vector2i:
if demo_safe(Vector2i.UP): return Vector2i.UP
var target_x := 112.0
var distance := INF
for i in range(5):
if not homes[i] and absf(frog.x-HOME_X[i]) < distance:
distance = absf(frog.x-HOME_X[i])
target_x = HOME_X[i]
var side := Vector2i.RIGHT if target_x > frog.x else Vector2i.LEFT
if absf(target_x-frog.x)>7 and demo_safe(side): return side
return Vector2i.ZERO
func lane_objects(lane: Dictionary) -> Array[float]:
var result: Array[float] = []
var start: float = fposmod(lane.offset,lane.spacing)-lane.spacing
while start < 224:
result.append(start)
start += lane.spacing
return result
func submerged(lane: Dictionary) -> bool:
return lane.kind == "turtle" and lane.sprite == 1 and fposmod(clock,7.0) > 5.7
func step_world(delta: float) -> void:
time_left -= delta
if time_left <= 0:
die("time")
return
if time_left <= 10 and not warned:
warned = true
$Audio.effect("time")
for lane in lanes: lane.offset = fposmod(lane.offset+lane.speed*delta,lane.spacing)
if hop_left > 0:
hop_left = maxf(0,hop_left-delta)
frog = from.lerp(destination,1.0-hop_left/.12)
if hop_left <= 0:
var row := roundi((232-frog.y)/16)
if row > furthest_row:
add_score((row-furthest_row)*10)
furthest_row = row
# Evaluate the current rendered location, including the hop trajectory.
var row := roundi((232-frog.y)/16)
if row >= 12 and hop_left <= 0:
var index := -1
for i in range(5):
if absf(frog.x-HOME_X[i]) <= 7: index = i
if index < 0 or homes[index]:
die("squash")
else:
homes[index] = true
add_score(50+int(time_left)*10)
phase = "home"
phase_time = .8
$Audio.effect("extra")
return
for lane in lanes:
if lane.row != row: continue
var supported := false
for x in lane_objects(lane):
if lane.kind == "car":
if frog.x+5 > x+2 and frog.x-5 < x+lane.width-2:
die("squash")
return
elif frog.x-4 >= x and frog.x+4 <= x+lane.width and not submerged(lane):
supported = true
if lane.kind != "car":
# Support is checked on landing; hops may cross a platform edge in flight.
if hop_left <= 0:
if not supported:
die("plunk")
return
frog.x += lane.speed*delta
if frog.x < 5 or frog.x > 219: die("plunk")
break
func die(kind: String) -> void:
if phase != "play": return
lives -= 1
death_kind = kind
phase = "dead"
phase_time = 1.1
hop_left = 0
$Audio.stop_music()
$Audio.effect(kind)
func add_score(value: int) -> void:
score += value
if not demo_active: high_score = maxi(high_score,score)
if score >= 20000 and not extra_awarded:
extra_awarded = true
lives += 1
$Audio.effect("extra")
func finish(won: bool) -> void:
if demo_active:
end_demo()
return
$Audio.stop_all()
leaderboard.append(score)
leaderboard.sort()
leaderboard.reverse()
if leaderboard.size() > 5: leaderboard.resize(5)
phase = "won" if won else "lost"
if won: win_game()
else: lose_game()