Added Glitch Room Source code to Gitty

This commit is contained in:
Jeff Luke committed 2026-10-04 11:31:17 -07:00
commit 1ac9eb5644
505 files changed
+28504

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
extends Node
signal changed
## Machine-local bindings; joypads are resolved by identity, never "All Devices".
const FILE := "user://arcade_controls.cfg"
const STEPS := [
["arcade_start", "Press PLAYER ONE START"],
["arcade_menu", "Press PLAYER ONE SELECT / RETURN TO MENU"],
["arcade_up", "Move PLAYER ONE joystick UP"],
["arcade_down", "Move PLAYER ONE joystick DOWN"],
["arcade_left", "Move PLAYER ONE joystick LEFT"],
["arcade_right", "Move PLAYER ONE joystick RIGHT"],
["arcade_action_1", "Press FIRE / LEFT MISSILE BATTERY"],
["arcade_action_2", "Press SUPERZAPPER / CENTER MISSILE BATTERY"],
["arcade_action_3", "Press RIGHT MISSILE BATTERY"],
["robotron_fire_up", "Choose ROBOTRON FIRE UP — button or second joystick"],
["robotron_fire_down", "Choose ROBOTRON FIRE DOWN — button or second joystick"],
["robotron_fire_left", "Choose ROBOTRON FIRE LEFT — button or second joystick"],
["robotron_fire_right", "Choose ROBOTRON FIRE RIGHT — button or second joystick"],
]
var bindings: Dictionary = {}
var problem := ""
var path := FILE
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
load_profile()
Input.joy_connection_changed.connect(_devices_changed)
func _input(event: InputEvent) -> void:
handle_quit(event)
func handle_quit(event: InputEvent) -> bool:
if event is InputEventKey and event.pressed and not event.echo and (event.physical_keycode == KEY_ESCAPE or event.keycode == KEY_ESCAPE):
get_viewport().set_input_as_handled()
get_tree().quit()
return true
return false
func devices() -> Array:
var found: Array = []
for id in Input.get_connected_joypads():
found.append({"id": id, "guid": Input.get_joy_guid(id), "name": Input.get_joy_name(id)})
return found
func matches(identity: Dictionary, connected: Array) -> Array:
return connected.filter(func(d): return d.guid == identity.guid and d.name == identity.name)
func encode(event: InputEvent, connected: Array) -> Dictionary:
var result := {}
if event is InputEventKey:
if event.physical_keycode in [KEY_ESCAPE, KEY_F1]: return {}
result = {"kind": "key", "code": event.physical_keycode, "logical": event.keycode if event.physical_keycode == 0 else 0}
elif event is InputEventJoypadButton:
result = {"kind": "button", "code": event.button_index}
elif event is InputEventJoypadMotion:
if absf(event.axis_value) < .65: return {}
result = {"kind": "axis", "code": event.axis, "sign": -1 if event.axis_value < 0 else 1}
elif event is InputEventMouseButton:
if event.button_index > MOUSE_BUTTON_MIDDLE: return {}
return {"kind": "mouse", "code": event.button_index}
else:
return {}
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
var current := connected.filter(func(d): return d.id == event.device)
if current.is_empty(): return {}
var identity: Dictionary = current[0].duplicate()
var same := matches(identity, connected)
identity["slot"] = same.find(current[0])
identity["count"] = same.size()
identity.erase("id")
result["controller"] = identity
return result
func resolve(binding: Dictionary, connected: Array) -> int:
var identity: Dictionary = binding.controller
var same := matches(identity, connected)
# An absent twin must not silently hand its controls to the remaining twin.
if same.size() != int(identity.count): return -1
return int(same[int(identity.slot)].id) if int(identity.slot) < same.size() else -1
func decode(binding: Dictionary, connected: Array) -> InputEvent:
var event: InputEvent
match binding.kind:
"key":
event = InputEventKey.new()
event.physical_keycode = int(binding.code)
event.keycode = int(binding.get("logical", 0))
"mouse":
event = InputEventMouseButton.new()
event.button_index = int(binding.code)
"button":
event = InputEventJoypadButton.new()
event.button_index = int(binding.code)
"axis":
event = InputEventJoypadMotion.new()
event.axis = int(binding.code)
event.axis_value = float(binding.sign)
_:
return null
if binding.has("controller"):
var id := resolve(binding, connected)
if id < 0: return null
event.device = id
return event
func valid(data: Dictionary) -> bool:
for step in STEPS:
if not data.get(step[0]) is Dictionary: return false
var b: Dictionary = data[step[0]]
if b.get("kind", "") not in ["key", "mouse", "button", "axis"]: return false
if not b.get("code") is int or b.code < 0: return false
if b.kind == "axis" and b.get("sign", 0) not in [-1, 1]: return false
if b.kind in ["axis", "button"]:
if not b.get("controller") is Dictionary: return false
var c: Dictionary = b.controller
if not c.get("guid") is String or not c.get("name") is String: return false
if not c.get("slot") is int or not c.get("count") is int: return false
if c.slot < 0 or c.count <= c.slot: return false
return true
func load_profile() -> bool:
var config := ConfigFile.new()
if config.load(path) != OK: return false
var data = config.get_value("controls", "bindings", {})
if config.get_value("controls", "version", 0) != 1 or not data is Dictionary or not valid(data):
problem = "Saved controls could not be read. Please run setup again."
return false
bindings = data
apply_profile()
return true
func save_profile(data: Dictionary) -> Error:
if not valid(data): return ERR_INVALID_DATA
var config := ConfigFile.new()
config.set_value("controls", "version", 1)
config.set_value("controls", "bindings", data)
var error := config.save(path + ".tmp")
if error != OK: return error
error = DirAccess.rename_absolute(ProjectSettings.globalize_path(path + ".tmp"), ProjectSettings.globalize_path(path))
if error != OK: return error
bindings = data.duplicate(true)
apply_profile()
return OK
func apply_profile() -> void:
if bindings.is_empty(): return
problem = ""
var connected := devices()
for action in bindings:
var event := decode(bindings[action], connected)
replace_action(action, event)
if action in ["arcade_up", "arcade_down", "arcade_left", "arcade_right"]:
replace_action(action.replace("arcade_", "robotron_move_"), event)
if event == null:
problem = "A saved controller is missing or the controller order changed. Reconnect it or run Control Setup."
elif bindings[action].has("controller") and int(bindings[action].controller.count) > 1 and problem.is_empty():
problem = "Identical controllers detected: check PLAYER ONE after reconnecting USB devices. Run setup if they swap."
changed.emit()
func replace_action(action: String, event: InputEvent) -> void:
Input.action_release(action)
InputMap.action_erase_events(action)
InputMap.action_set_deadzone(action, .35)
if event != null: InputMap.action_add_event(action, event)
func _devices_changed(_device: int, _connected: bool) -> void:
apply_profile.call_deferred()
func describe(binding: Dictionary) -> String:
var event := decode(binding, devices())
var text := event.as_text() if event != null else "%s %s (not connected)" % [binding.kind, binding.code]
if binding.has("controller"):
text += " — %s #%d" % [binding.controller.name, int(binding.controller.slot) + 1]
return text
+1
View File
@@ -0,0 +1 @@
uid://b58xhfwt4fvby
+52
View File
@@ -0,0 +1,52 @@
class_name ArcadeGame
extends Node
signal game_started
signal game_won
signal game_lost
signal game_reset
var is_running: bool = false
var run_config: Dictionary = {}
func configure(config: Dictionary = {}) -> void:
run_config = config
func start_game() -> bool:
if is_running:
return false
is_running = true
game_started.emit()
return true
func win_game() -> void:
if not is_running:
return
is_running = false
game_won.emit()
func lose_game() -> void:
if not is_running:
return
is_running = false
game_lost.emit()
func reset_game() -> void:
is_running = false
game_reset.emit()
func restart_game() -> void:
reset_game()
start_game()
+1
View File
@@ -0,0 +1 @@
uid://bv62i5q5hvkqd
+55
View File
@@ -0,0 +1,55 @@
@tool
class_name GameDefinition
extends Resource
@export var game_id: StringName = &""
@export var display_name: String = ""
@export var scene: PackedScene
@export var required_capabilities: Array[StringName] = []
@export var optional_capabilities: Array[StringName] = []
## Require every common capability, plus every capability in at least one alternative.
@export var control_alternatives: Array[PackedStringArray] = []
@export_group("Events")
@export var events: Array[GameEventDefinition] = []
func can_run_on(
cabinet: CabinetProfile
) -> bool:
for capability in required_capabilities:
if not cabinet.has_capability(capability):
return false
if control_alternatives.is_empty(): return true
for alternative in control_alternatives:
var supported := true
for capability in alternative:
if not cabinet.has_capability(StringName(capability)): supported = false
if supported: return true
return false
func get_event(
event_id: StringName
) -> GameEventDefinition:
for event in events:
if event == null:
continue
if event.event_id == event_id:
return event
return null
func has_event(
event_id: StringName
) -> bool:
return get_event(event_id) != null
+1
View File
@@ -0,0 +1 @@
uid://o538v0kdvs0j
+8
View File
@@ -0,0 +1,8 @@
@tool
class_name GameEventDefinition
extends Resource
@export var event_id: StringName = &""
@export var display_name: String = ""
@export_multiline var description: String = ""
+1
View File
@@ -0,0 +1 @@
uid://byegr76ey4613
+85
View File
@@ -0,0 +1,85 @@
@tool
class_name CabinetProfile
extends Resource
@export var cabinet_id: StringName = &""
@export var display_name: String = ""
@export var capabilities: Array[StringName] = []
@export var hardware_config: HardwareConfig
const FEET_TO_METERS: float = 0.3048
const METERS_TO_FEET: float = 1.0 / FEET_TO_METERS
#
# Stored internally in meters for compatibility with the
# existing room/layout system.
#
@export_storage
var footprint_width_m: float = 0.80
@export_storage
var footprint_depth_m: float = 0.75
@export_storage
var height_m: float = 1.80
#
# Designer-facing dimensions.
#
@export_group("Physical Dimensions")
@export_range(0.25, 12.0, 0.01, "suffix: ft")
var footprint_width_ft: float:
get:
return (
footprint_width_m
* METERS_TO_FEET
)
set(value):
footprint_width_m = (
value
* FEET_TO_METERS
)
@export_range(0.25, 12.0, 0.01, "suffix: ft")
var footprint_depth_ft: float:
get:
return (
footprint_depth_m
* METERS_TO_FEET
)
set(value):
footprint_depth_m = (
value
* FEET_TO_METERS
)
@export_range(0.25, 20.0, 0.01, "suffix: ft")
var height_ft: float:
get:
return (
height_m
* METERS_TO_FEET
)
set(value):
height_m = (
value
* FEET_TO_METERS
)
@export_group("Physical Appearance")
@export var editor_texture: Texture2D = null
@export_range(0.1, 4.0, 0.05)
var editor_texture_scale: float = 1.0
func has_capability(capability: StringName) -> bool:
return capabilities.has(capability)
+1
View File
@@ -0,0 +1 @@
uid://c0jj5j5loqsma
+23
View File
@@ -0,0 +1,23 @@
@tool
class_name HardwareAction
extends Resource
enum ActionType {
DIGITAL_OUTPUT,
PULSE_OUTPUT,
}
@export var action_id: StringName = &""
@export var display_name: String = ""
@export var action_type: ActionType = ActionType.PULSE_OUTPUT
@export_range(0, 1000, 1)
var gpio_pin: int = 17
@export_range(1, 10000, 1)
var pulse_duration_ms: int = 250
@export var active_high: bool = true
+1
View File
@@ -0,0 +1 @@
uid://c7eu1n5yey1x4
+18
View File
@@ -0,0 +1,18 @@
@tool
class_name HardwareConfig
extends Resource
@export var controller_id: StringName = &""
@export var actions: Array[HardwareAction] = []
func get_action(action_id: StringName) -> HardwareAction:
for action in actions:
if action.action_id == action_id:
return action
return null
func has_action(action_id: StringName) -> bool:
return get_action(action_id) != null
+1
View File
@@ -0,0 +1 @@
uid://60whm57itpjb
+141
View File
@@ -0,0 +1,141 @@
extends Node
const VERSION := "arcade-cabinet-2"
var cabinet_id := "cabinet_01"
var database: ProjectDatabase = preload("res://data/project_database.tres")
var cabinet: CabinetProfile
var connection: Node
var game: ArcadeGame
var session := ""
var phase := "idle"
var boot := ""
var reports: Array[Dictionary] = []
var outcomes: Dictionary = {}
var busy := false
var executing := false
var event_number := 0
var notice: Label
func _ready() -> void:
var config := ConfigFile.new()
config.load("user://arcade_network.cfg")
cabinet_id = str(config.get_value("network", "cabinet_id", cabinet_id))
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--cabinet="): cabinet_id = arg.trim_prefix("--cabinet=")
for definition in database.cabinets:
if str(definition.cabinet_id) == cabinet_id: cabinet = definition
boot = Crypto.new().generate_random_bytes(16).hex_encode()
# Remote mode must never also run the launcher's local reward mapping.
RoomRuntime.active_assignment = null
HardwareManager.active_cabinet = null
connection = preload("res://core/network/room_connection.gd").new()
add_child(connection)
connection.configure("cabinet")
var layer := CanvasLayer.new()
add_child(layer)
notice = Label.new()
notice.text = "Waiting for the room operator"
notice.position = Vector2(30, 1000)
notice.add_theme_font_size_override("font_size", 22)
layer.add_child(notice)
var timer := Timer.new()
timer.wait_time = .5
timer.timeout.connect(poll)
add_child(timer)
timer.start()
poll()
func poll() -> void:
if busy or executing: return
busy = true
var sent := reports.duplicate(true)
var result: Dictionary = await connection.post("/cabinet/poll", {
"cabinet": cabinet_id, "protocol": 2, "boot": boot, "version": VERSION,
"gpio": "real" if HardwareManager.real_gpio else "simulation", "reports": sent,
})
busy = false
if result.has("error"):
notice.text = "Room connection unavailable — waiting to reconnect"
return
for report in sent: reports.erase(report)
if str(result.get("session", "")) != session:
session = str(result.get("session", ""))
outcomes.clear()
reports.clear()
unload()
phase = "idle"
for command in result.get("commands", []):
executing = true
await execute(command)
executing = false
notice.text = "Waiting for the room operator" if game == null else ""
func unload() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
if is_instance_valid(game):
remove_child(game)
game.queue_free()
game = null
func event(name: String) -> void:
event_number += 1
reports.append({"kind": "event", "id": boot + ":" + str(event_number), "session": session, "event": name})
phase = {"started": "playing", "won": "won", "lost": "lost"}.get(name, phase)
if name in ["won", "lost"] and game != null:
game.set_process_unhandled_input(false)
game.set_process_input(false)
func acknowledge(command: Dictionary, ok: bool, detail: String) -> void:
var report := {"kind": "ack", "id": command.id, "session": session, "ok": ok, "detail": detail}
outcomes[command.id] = report
reports.append(report)
func execute(command: Dictionary) -> void:
if command.get("session") != session: return
if outcomes.has(command.id):
reports.append(outcomes[command.id])
return
match command.get("type"):
"prepare":
var definition: GameDefinition
for entry in database.games:
if str(entry.game_id) == str(command.get("game", "pacman")): definition = entry
if definition == null or cabinet == null or not definition.can_run_on(cabinet):
acknowledge(command, false, "Game is missing or incompatible with this cabinet")
return
if command.get("variant", "standard") != "standard":
acknowledge(command, false, "Unsupported game variant")
return
unload()
game = definition.scene.instantiate()
if str(definition.game_id) == "missile_command": game.set("relative_mouse_aim", true)
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if str(definition.game_id) in ["missile_command", "tempest"] else Input.MOUSE_MODE_VISIBLE
add_child(game)
game.configure({})
game.game_started.connect(func(): event("started"))
game.game_won.connect(func(): event("won"))
game.game_lost.connect(func(): event("lost"))
phase = "ready"
acknowledge(command, true, definition.display_name + " loaded and ready")
"start":
var ok: bool = game != null and phase == "ready" and game.start_game()
acknowledge(command, ok, "Game started" if ok else "Cabinet was not ready")
"reset":
unload()
phase = "idle"
acknowledge(command, true, "Game unloaded; cabinet idle")
"reward", "output":
if (command.type == "reward" and phase != "won") or not HardwareManager.real_gpio or cabinet == null or cabinet.hardware_config == null:
acknowledge(command, false, "Reward requires a win and real GPIO mode")
return
var action := cabinet.hardware_config.get_action(StringName(command.get("action_id", "coin_release")))
if action == null:
acknowledge(command, false, "Hardware action is not defined on this cabinet")
return
var ok: bool = await HardwareManager.client.send({
"controller": str(cabinet.hardware_config.controller_id), "pin": action.gpio_pin,
"mode": "pulse" if action.action_type == HardwareAction.ActionType.PULSE_OUTPUT else "set", "active_high": action.active_high, "duration_ms": action.pulse_duration_ms,
"request_id": command.id,
})
acknowledge(command, ok, "GPIO bridge accepted pulse; delivery unconfirmed" if ok else "GPIO failed or acknowledgement lost; inspect cabinet")
_:
acknowledge(command, false, "Unsupported command")
+1
View File
@@ -0,0 +1 @@
uid://dfoc8gpbuicc4
+28
View File
@@ -0,0 +1,28 @@
extends Node
## Transport boundary: gameplay and operator code only exchange command/state dictionaries.
var url := "http://127.0.0.1:8766"
var token := ""
func configure(role: String) -> void:
var config := ConfigFile.new()
config.load("user://arcade_network.cfg")
url = str(config.get_value("network", "url", url)).trim_suffix("/")
token = str(config.get_value("network", role + "_token", ""))
if OS.has_environment("ARCADE_ROOM_URL"): url = OS.get_environment("ARCADE_ROOM_URL").trim_suffix("/")
var name := "ARCADE_%s_TOKEN" % role.to_upper()
if OS.has_environment(name): token = OS.get_environment(name)
func post(path: String, payload: Dictionary = {}) -> Dictionary:
if token.is_empty(): return {"error": "Room connection token is not configured"}
var request := HTTPRequest.new()
request.timeout = 2.0
add_child(request)
var error := request.request(url + path, ["Content-Type: application/json", "Authorization: Bearer " + token], HTTPClient.METHOD_POST, JSON.stringify(payload))
var result: Dictionary = {"error": "Room controller is unreachable"}
if error == OK:
var response: Array = await request.request_completed
var body = JSON.parse_string(response[3].get_string_from_utf8())
if response[0] == HTTPRequest.RESULT_SUCCESS and body is Dictionary:
result = body
request.queue_free()
return result
+1
View File
@@ -0,0 +1 @@
uid://bhsk7cucg2sbx
+243
View File
@@ -0,0 +1,243 @@
class_name RoomLogicEngine
extends RefCounted
signal event_received(
source_id: StringName,
event_id: StringName
)
signal rule_triggered(
rule_id: StringName,
source_id: StringName,
event_id: StringName,
target_id: StringName,
action_id: StringName
)
signal action_requested(
target_id: StringName,
action_id: StringName,
rule_id: StringName
)
signal trace_entry_created(
entry: Dictionary
)
var room: RoomDefinition
var _pending_actions: Array[Dictionary] = []
var _simulation_time: float = 0.0
func configure(
room_definition: RoomDefinition
) -> void:
room = room_definition
reset()
func reset() -> void:
_pending_actions.clear()
_simulation_time = 0.0
func get_simulation_time() -> float:
return _simulation_time
func emit_event(
source_id: StringName,
event_id: StringName
) -> void:
if room == null:
return
event_received.emit(
source_id,
event_id
)
_create_trace_entry(
&"event",
{
"source_id": source_id,
"event_id": event_id,
}
)
for rule in room.logic_rules:
if rule == null:
continue
if rule.source_id != source_id:
continue
if rule.event_id != event_id:
continue
_trigger_rule(
rule
)
func advance(
delta_seconds: float
) -> void:
if delta_seconds <= 0.0:
return
_simulation_time += delta_seconds
_process_pending_actions()
func _trigger_rule(
rule: RoomLogicRule
) -> void:
rule_triggered.emit(
rule.rule_id,
rule.source_id,
rule.event_id,
rule.target_id,
rule.action_id
)
_create_trace_entry(
&"rule",
{
"rule_id": rule.rule_id,
"source_id": rule.source_id,
"event_id": rule.event_id,
"target_id": rule.target_id,
"action_id": rule.action_id,
"delay_seconds": rule.delay_seconds,
}
)
if rule.delay_seconds <= 0.0:
_request_action(
rule.target_id,
rule.action_id,
rule.rule_id
)
return
_pending_actions.append(
{
"execute_at": (
_simulation_time
+ rule.delay_seconds
),
"target_id": (
rule.target_id
),
"action_id": (
rule.action_id
),
"rule_id": (
rule.rule_id
),
}
)
func _process_pending_actions() -> void:
if _pending_actions.is_empty():
return
var ready_actions: Array[Dictionary] = []
var remaining_actions: Array[Dictionary] = []
for pending in _pending_actions:
var execute_at: float = (
pending.get(
"execute_at",
0.0
)
)
if execute_at <= _simulation_time:
ready_actions.append(
pending
)
else:
remaining_actions.append(
pending
)
_pending_actions = remaining_actions
for pending in ready_actions:
_request_action(
pending.get(
"target_id",
&""
),
pending.get(
"action_id",
&""
),
pending.get(
"rule_id",
&""
)
)
func _request_action(
target_id: StringName,
action_id: StringName,
rule_id: StringName
) -> void:
_create_trace_entry(
&"action",
{
"target_id": target_id,
"action_id": action_id,
"rule_id": rule_id,
}
)
action_requested.emit(
target_id,
action_id,
rule_id
)
func _create_trace_entry(
entry_type: StringName,
data: Dictionary
) -> void:
var entry := data.duplicate()
entry["type"] = entry_type
entry["time"] = _simulation_time
trace_entry_created.emit(
entry
)
func has_pending_actions() -> bool:
return not _pending_actions.is_empty()
+1
View File
@@ -0,0 +1 @@
uid://cxw5i220nmobr
+25
View File
@@ -0,0 +1,25 @@
@tool
class_name RoomLogicRule
extends Resource
@export var rule_id: StringName = &""
@export_group("Source")
@export var source_id: StringName = &""
@export var event_id: StringName = &""
@export_group("Target")
@export var target_id: StringName = &""
@export var action_id: StringName = &""
@export_group("Timing")
@export_range(
0.0,
3600.0,
0.1
)
var delay_seconds: float = 0.0
+1
View File
@@ -0,0 +1 @@
uid://cp40t70asl4p0
+38
View File
@@ -0,0 +1,38 @@
@tool
class_name RoomWallDefinition
extends Resource
const FEET_TO_METERS: float = 0.3048
const METERS_TO_FEET: float = 1.0 / FEET_TO_METERS
@export var wall_id: StringName = &""
@export var start: Vector2 = Vector2.ZERO
@export var end: Vector2 = Vector2(
1.0,
0.0
)
#
# Stored internally in meters so existing room/layout
# rendering code keeps working without modification.
#
@export_storage
var thickness: float = 0.10
@export_group("Physical Dimensions")
@export_range(0.01, 4.0, 0.01, "suffix: ft")
var thickness_ft: float:
get:
return (
thickness
* METERS_TO_FEET
)
set(value):
thickness = (
value
* FEET_TO_METERS
)
+1
View File
@@ -0,0 +1 @@
uid://dl7ydh8g6877n
+7
View File
@@ -0,0 +1,7 @@
class_name GameEvents
const STARTED: StringName = &"game_started"
const WON: StringName = &"game_won"
const LOST: StringName = &"game_lost"
const RESET: StringName = &"game_reset"
+1
View File
@@ -0,0 +1 @@
uid://cs0xs0amg3lrv
+81
View File
@@ -0,0 +1,81 @@
class_name GameHost
extends Node
signal game_loaded(game: ArcadeGame)
signal game_unloaded
var current_game: ArcadeGame
func load_game(game_definition: GameDefinition) -> ArcadeGame:
unload_game()
if game_definition == null:
push_error("GameHost received a null GameDefinition.")
return null
if game_definition.scene == null:
push_error(
"Game '%s' has no scene assigned."
% game_definition.game_id
)
return null
var instance := game_definition.scene.instantiate()
if not instance is ArcadeGame:
push_error(
"Game scene '%s' does not inherit from ArcadeGame."
% game_definition.game_id
)
instance.queue_free()
return null
current_game = instance as ArcadeGame
add_child(current_game)
current_game.game_started.connect(_on_game_started)
current_game.game_won.connect(_on_game_won)
current_game.game_lost.connect(_on_game_lost)
current_game.game_reset.connect(_on_game_reset)
game_loaded.emit(current_game)
print(
"[GAME HOST] Loaded '%s'"
% game_definition.display_name
)
return current_game
func unload_game() -> void:
if current_game == null:
return
print("[GAME HOST] Unloading current game.")
current_game.queue_free()
current_game = null
game_unloaded.emit()
func _on_game_started() -> void:
RoomRuntime.fire_game_event(GameEvents.STARTED)
func _on_game_won() -> void:
RoomRuntime.fire_game_event(GameEvents.WON)
func _on_game_lost() -> void:
RoomRuntime.fire_game_event(GameEvents.LOST)
func _on_game_reset() -> void:
RoomRuntime.fire_game_event(GameEvents.RESET)
+1
View File
@@ -0,0 +1 @@
uid://cp6novpbgl1uk
+77
View File
@@ -0,0 +1,77 @@
extends Node
signal action_activated(cabinet_id: StringName, action_id: StringName, context: Dictionary)
signal action_deactivated(cabinet_id: StringName, action_id: StringName, context: Dictionary)
signal status_changed(message: String)
var active_cabinet: CabinetProfile
var real_gpio := false
var status := "GPIO simulation (use --gpio=real on the Pi)"
var client: Node
var generations: Dictionary = {}
func _ready() -> void:
real_gpio = "--gpio=real" in OS.get_cmdline_user_args()
if real_gpio:
client = preload("res://core/runtime/pi_gpio_client.gd").new()
client.status_changed.connect(_status)
add_child(client)
_status("GPIO real mode — local Pi bridge required")
if "--gpio-test" in OS.get_cmdline_user_args():
test_led.call_deferred()
func _status(message: String) -> void:
status = message
print("[GPIO] ", message)
status_changed.emit(message)
func configure(cabinet: CabinetProfile) -> void:
active_cabinet = cabinet
func trigger(action_id: StringName, context: Dictionary = {}) -> void:
if active_cabinet == null:
_status("No active cabinet for GPIO action")
return
trigger_for_cabinet(active_cabinet, action_id, context)
func trigger_for_cabinet(cabinet: CabinetProfile, action_id: StringName, context: Dictionary = {}) -> void:
if cabinet == null or cabinet.hardware_config == null:
_status("Missing cabinet hardware configuration")
return
var action := cabinet.hardware_config.get_action(action_id)
if action == null:
_status("Unknown hardware action: %s" % action_id)
return
_send_action(cabinet, action, context)
func _send_action(cabinet: CabinetProfile, action: HardwareAction, context: Dictionary) -> void:
var key := "%s:%s" % [cabinet.hardware_config.controller_id, action.gpio_pin]
var generation := int(generations.get(key, 0)) + 1
generations[key] = generation
var pulse := action.action_type == HardwareAction.ActionType.PULSE_OUTPUT
var active := true if pulse else bool(context.get("active", true))
if real_gpio:
var ok: bool = await client.send({
"controller": str(cabinet.hardware_config.controller_id),
"pin": action.gpio_pin,
"active_high": action.active_high,
"mode": "pulse" if pulse else "set",
"duration_ms": action.pulse_duration_ms,
"active": active,
})
if not ok: return
else:
print("[MOCK GPIO] %s cabinet=%s action=%s BCM=%d" % ["PULSE" if pulse else "SET", cabinet.cabinet_id, action.action_id, action.gpio_pin])
if active:
action_activated.emit(cabinet.cabinet_id, action.action_id, context)
else:
action_deactivated.emit(cabinet.cabinet_id, action.action_id, context)
if pulse:
await get_tree().create_timer(action.pulse_duration_ms / 1000.0).timeout
if int(generations.get(key, 0)) == generation:
# Physical timing belongs to the bridge, even if the game exits mid-pulse.
action_deactivated.emit(cabinet.cabinet_id, action.action_id, context)
func test_led() -> void:
var cabinet = load("res://data/cabinets/cabinet_01.tres").duplicate(true)
var action: HardwareAction = cabinet.hardware_config.get_action(&"coin_release")
action.pulse_duration_ms = 2000
_send_action(cabinet, action, {"source": "gpio_led_test"})
+1
View File
@@ -0,0 +1 @@
uid://5d4k46jwlo4y
+48
View File
@@ -0,0 +1,48 @@
class_name LaunchConfig
var cabinet_id: StringName
var scenario_id: StringName
func _init(
default_cabinet: StringName = &"",
default_scenario: StringName = &""
) -> void:
cabinet_id = default_cabinet
scenario_id = default_scenario
static func from_command_line(
default_cabinet: StringName = &"",
default_scenario: StringName = &""
) -> LaunchConfig:
var config := LaunchConfig.new(
default_cabinet,
default_scenario
)
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--cabinet="):
config.cabinet_id = StringName(
arg.trim_prefix("--cabinet=")
)
elif arg.begins_with("--scenario="):
config.scenario_id = StringName(
arg.trim_prefix("--scenario=")
)
return config
func print_debug() -> void:
print("")
print("========= LAUNCH CONFIG =========")
print("Cabinet: ", cabinet_id)
print("Scenario: ", scenario_id)
print("=================================")
print("")
+1
View File
@@ -0,0 +1 @@
uid://brd536grsx38f
+19
View File
@@ -0,0 +1,19 @@
class_name LogicPathId
static func make(
cabinet_id: StringName,
event_id: StringName,
action_id: StringName,
device_id: StringName
) -> StringName:
return StringName(
"%s::%s::%s::%s"
% [
cabinet_id,
event_id,
action_id,
device_id
]
)
+1
View File
@@ -0,0 +1 @@
uid://cy746kl1sgsrn
+58
View File
@@ -0,0 +1,58 @@
extends Node
signal completed(id: int, ok: bool)
signal status_changed(message: String)
const URL := "http://127.0.0.1:8765"
var pending: Array[Dictionary] = []
var serial := 0
var busy := false
var heartbeat: HTTPRequest
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
heartbeat = HTTPRequest.new()
heartbeat.timeout = 1.0
add_child(heartbeat)
var timer := Timer.new()
timer.wait_time = 1.0
timer.timeout.connect(_heartbeat)
add_child(timer)
timer.start()
func _heartbeat() -> void:
if heartbeat.get_http_client_status() == HTTPClient.STATUS_DISCONNECTED:
heartbeat.request(URL + "/heartbeat", [], HTTPClient.METHOD_POST, "{}")
func send(payload: Dictionary) -> bool:
if pending.size() >= 32:
status_changed.emit("GPIO output queue is full")
return false
serial += 1
var id := serial
pending.append({"id": id, "payload": payload})
_pump.call_deferred()
while true:
var result: Array = await completed
if result[0] == id: return result[1]
return false
func _pump() -> void:
if busy or pending.is_empty(): return
busy = true
var item: Dictionary = pending[0]
var request := HTTPRequest.new()
request.timeout = 2.0
add_child(request)
var ok := false
var message := "GPIO bridge unavailable — start pi_gpio_bridge.py on this Pi"
var error := request.request(URL + "/output", ["Content-Type: application/json"], HTTPClient.METHOD_POST, JSON.stringify(item.payload))
if error == OK:
var response: Array = await request.request_completed
var body = JSON.parse_string(response[3].get_string_from_utf8())
ok = response[0] == HTTPRequest.RESULT_SUCCESS and response[1] == 200 and body is Dictionary and body.get("ok", false) == true
if body is Dictionary and body.has("error"): message = str(body.error)
request.queue_free()
pending.pop_front()
busy = false
status_changed.emit("GPIO %s accepted by Pi bridge" % item.payload.pin if ok else message)
completed.emit(item.id, ok)
_pump.call_deferred()
+1
View File
@@ -0,0 +1 @@
uid://74k3b8polfi2
+50
View File
@@ -0,0 +1,50 @@
@tool
class_name ProjectDatabase
extends Resource
@export var scenarios: Array[ScenarioDefinition] = []
@export var cabinets: Array[CabinetProfile] = []
@export var games: Array[GameDefinition] = []
func get_scenario(
scenario_id: StringName
) -> ScenarioDefinition:
for scenario in scenarios:
if scenario == null:
continue
if scenario.scenario_id == scenario_id:
return scenario
return null
func get_cabinet(
cabinet_id: StringName
) -> CabinetProfile:
for cabinet in cabinets:
if cabinet == null:
continue
if cabinet.cabinet_id == cabinet_id:
return cabinet
return null
func get_game(
game_id: StringName
) -> GameDefinition:
for game in games:
if game == null:
continue
if game.game_id == game_id:
return game
return null
+1
View File
@@ -0,0 +1 @@
uid://diqnrxrhwx2ug
+315
View File
@@ -0,0 +1,315 @@
class_name RoomController
extends Node
signal scenario_loaded(scenario: ScenarioDefinition)
signal validation_completed(
result: ValidationResult
)
signal cabinet_status_changed(
cabinet_id: StringName,
status: StringName
)
signal room_status_changed(
status: StringName
)
var database: ProjectDatabase
var active_scenario: ScenarioDefinition
var cabinet_statuses: Dictionary = {}
var room: RoomDefinition
func configure(
project_database: ProjectDatabase,
room_definition: RoomDefinition = null
) -> void:
database = project_database
room = room_definition
func load_scenario(
scenario_id: StringName
) -> bool:
if database == null:
push_error(
"RoomController has no ProjectDatabase."
)
return false
var scenario := database.get_scenario(
scenario_id
)
if scenario == null:
push_error(
"Scenario '%s' not found."
% scenario_id
)
return false
active_scenario = scenario
_initialize_cabinet_statuses()
print("")
print("======= ROOM CONTROLLER =======")
print(
"Loaded scenario: %s v%d"
% [
scenario.display_name,
scenario.version
]
)
print("===============================")
print("")
scenario_loaded.emit(
active_scenario
)
room_status_changed.emit(
&"LOADED"
)
return true
func validate_active_scenario() -> ValidationResult:
var result := ValidationResult.new()
result.merge(
ScenarioValidator.validate(
active_scenario
)
)
if room != null:
result.merge(
RoomValidator.validate(
room,
database
)
)
validation_completed.emit(
result
)
if result.is_valid():
room_status_changed.emit(
&"VALID"
)
else:
room_status_changed.emit(
&"INVALID"
)
return result
func prep_room() -> bool:
if active_scenario == null:
push_error(
"Cannot prep room: no scenario loaded."
)
return false
var validation := validate_active_scenario()
if not validation.is_valid():
print(
"[ROOM CONTROLLER] Prep aborted: "
+ validation.get_summary()
)
room_status_changed.emit(
&"PREP_FAILED"
)
return false
room_status_changed.emit(
&"PREPARING"
)
print("")
print("=========== PREP ROOM ===========")
for assignment in active_scenario.assignments:
_prep_assignment(assignment)
print("=================================")
print("")
room_status_changed.emit(
&"READY"
)
return true
func _prep_assignment(
assignment: ScenarioAssignment
) -> void:
var cabinet := assignment.cabinet
var game := assignment.game
_set_cabinet_status(
cabinet.cabinet_id,
&"PREPARING"
)
print(
"[ROOM CONTROLLER] Cabinet '%s'"
% cabinet.cabinet_id
)
print(
" Game: %s"
% game.game_id
)
print(
" Variant: %s"
% assignment.variant
)
print(
" Hardware: %s"
% cabinet.hardware_config.controller_id
)
#
# Networking will eventually happen here.
#
# send_to_cabinet(
# cabinet.cabinet_id,
# scenario,
# assignment
# )
#
print(
" [SIMULATION] Cabinet ready."
)
_set_cabinet_status(
cabinet.cabinet_id,
&"READY"
)
func _initialize_cabinet_statuses() -> void:
cabinet_statuses.clear()
if active_scenario == null:
return
for assignment in active_scenario.assignments:
if assignment == null:
continue
if assignment.cabinet == null:
continue
_set_cabinet_status(
assignment.cabinet.cabinet_id,
&"NOT_PREPARED"
)
func _set_cabinet_status(
cabinet_id: StringName,
status: StringName
) -> void:
cabinet_statuses[cabinet_id] = status
cabinet_status_changed.emit(
cabinet_id,
status
)
func get_cabinet_status(
cabinet_id: StringName
) -> StringName:
return cabinet_statuses.get(
cabinet_id,
&"UNKNOWN"
)
func simulate_game_event(
cabinet_id: StringName,
event_name: StringName
) -> void:
if active_scenario == null:
push_error(
"Cannot simulate event: no active scenario."
)
return
var assignment := (
active_scenario
.get_assignment_for_cabinet(
cabinet_id
)
)
if assignment == null:
push_error(
"No assignment exists for cabinet '%s'."
% cabinet_id
)
return
print("")
print(
"[ROOM SIMULATION] %s -> %s"
% [
cabinet_id,
event_name
]
)
var actions := (
assignment
.get_actions_for_event(
event_name
)
)
if actions.is_empty():
print(
"[ROOM SIMULATION] No actions mapped."
)
return
for action_id in actions:
print(
"[ROOM SIMULATION] %s -> %s"
% [
event_name,
action_id
]
)
HardwareManager.trigger_for_cabinet(
assignment.cabinet,
action_id,
{
"game_event": event_name
}
)
+1
View File
@@ -0,0 +1 @@
uid://dbe24sbmbh6tt
+69
View File
@@ -0,0 +1,69 @@
extends Node
signal game_event_fired(
event_name: StringName,
cabinet_id: StringName
)
var active_assignment: ScenarioAssignment
func configure(assignment: ScenarioAssignment) -> void:
if assignment == null:
push_error("Cannot configure RoomRuntime with null assignment.")
return
active_assignment = assignment
HardwareManager.configure(assignment.cabinet)
print("")
print("======= ROOM RUNTIME =======")
print("Cabinet: ", assignment.cabinet.display_name)
print("Game: ", assignment.game.display_name)
print("Variant: ", assignment.variant)
print("============================")
print("")
func fire_game_event(event_name: StringName) -> void:
if active_assignment == null:
push_error("RoomRuntime has no active assignment.")
return
print("")
print(
"[GAME EVENT] %s -> %s"
% [
active_assignment.game.game_id,
event_name
]
)
game_event_fired.emit(
event_name,
active_assignment.cabinet.cabinet_id
)
var actions := active_assignment.get_actions_for_event(
event_name
)
if actions.is_empty():
print("[SCENARIO] No hardware actions mapped.")
return
for action_id in actions:
print(
"[SCENARIO] %s -> %s"
% [event_name, action_id]
)
HardwareManager.trigger(
action_id,
{
"game_event": event_name
}
)
+1
View File
@@ -0,0 +1 @@
uid://eelj57byt0su
+223
View File
@@ -0,0 +1,223 @@
class_name RoomValidator
static func validate(
room: RoomDefinition,
database: ProjectDatabase
) -> ValidationResult:
var result := ValidationResult.new()
if room == null:
result.add_error(
"RoomDefinition is null."
)
return result
_validate_cabinet_placements(
room,
database,
result
)
_validate_devices(
room,
result
)
_validate_bindings(
room,
database,
result
)
return result
static func _validate_cabinet_placements(
room: RoomDefinition,
database: ProjectDatabase,
result: ValidationResult
) -> void:
var seen_ids: Dictionary = {}
for placement in room.cabinet_placements:
if placement == null:
result.add_warning(
"Room contains a null cabinet placement."
)
continue
var cabinet_id := placement.cabinet_id
if cabinet_id == &"":
result.add_error(
"Room contains a cabinet placement with no cabinet_id."
)
continue
if seen_ids.has(cabinet_id):
result.add_error(
"Cabinet '%s' is placed in the room more than once."
% cabinet_id
)
else:
seen_ids[cabinet_id] = true
if database != null:
if database.get_cabinet(
cabinet_id
) == null:
result.add_error(
"Room references unknown cabinet '%s'."
% cabinet_id
)
static func _validate_devices(
room: RoomDefinition,
result: ValidationResult
) -> void:
var seen_ids: Dictionary = {}
for device in room.devices:
if device == null:
result.add_warning(
"Room contains a null device."
)
continue
if device.device_id == &"":
result.add_error(
"Room contains a device with no device_id."
)
continue
if seen_ids.has(
device.device_id
):
result.add_error(
"Room device '%s' appears more than once."
% device.device_id
)
else:
seen_ids[
device.device_id
] = true
static func _validate_bindings(
room: RoomDefinition,
database: ProjectDatabase,
result: ValidationResult
) -> void:
var seen_bindings: Dictionary = {}
for binding in room.bindings:
if binding == null:
result.add_warning(
"Room contains a null device binding."
)
continue
var cabinet_id := binding.cabinet_id
var action_id := binding.hardware_action_id
var device_id := binding.device_id
if cabinet_id == &"":
result.add_error(
"A room binding has no cabinet_id."
)
continue
if action_id == &"":
result.add_error(
"A room binding for cabinet '%s' has no hardware action."
% cabinet_id
)
continue
if device_id == &"":
result.add_error(
"A room binding for cabinet '%s' has no device_id."
% cabinet_id
)
continue
if room.get_cabinet_placement(
cabinet_id
) == null:
result.add_error(
(
"Binding references cabinet '%s', "
+ "but that cabinet is not placed in the room."
)
% cabinet_id
)
if room.get_device(
device_id
) == null:
result.add_error(
"Binding references unknown room device '%s'."
% device_id
)
var cabinet: CabinetProfile = null
if database != null:
cabinet = database.get_cabinet(
cabinet_id
)
if cabinet == null:
result.add_error(
"Binding references unknown cabinet '%s'."
% cabinet_id
)
else:
if cabinet.hardware_config == null:
result.add_error(
"Cabinet '%s' has no HardwareConfig."
% cabinet_id
)
elif not cabinet.hardware_config.has_action(
action_id
):
result.add_error(
(
"Room binding references unknown hardware action '%s' "
+ "on cabinet '%s'."
)
% [
action_id,
cabinet_id
]
)
var binding_key := (
"%s::%s::%s"
% [
cabinet_id,
action_id,
device_id
]
)
if seen_bindings.has(
binding_key
):
result.add_warning(
"Duplicate room binding '%s'."
% binding_key
)
else:
seen_bindings[
binding_key
] = true
+1
View File
@@ -0,0 +1 @@
uid://bbu1ejey4q6vx
+211
View File
@@ -0,0 +1,211 @@
class_name ScenarioValidator
static func validate(
scenario: ScenarioDefinition
) -> ValidationResult:
var result := ValidationResult.new()
if scenario == null:
result.add_error("Scenario is null.")
return result
if scenario.assignments.is_empty():
result.add_error(
"Scenario has no cabinet assignments."
)
return result
var seen_cabinets: Dictionary = {}
for assignment in scenario.assignments:
_validate_assignment(
assignment,
seen_cabinets,
result
)
return result
static func _validate_assignment(
assignment: ScenarioAssignment,
seen_cabinets: Dictionary,
result: ValidationResult
) -> void:
if assignment == null:
result.add_error(
"Scenario contains a null assignment."
)
return
if assignment.cabinet == null:
result.add_error(
"An assignment has no cabinet."
)
return
var cabinet := assignment.cabinet
var cabinet_id := cabinet.cabinet_id
if cabinet_id == &"":
result.add_error(
"A cabinet has no cabinet_id."
)
if seen_cabinets.has(cabinet_id):
result.add_error(
"Cabinet '%s' appears more than once."
% cabinet_id
)
else:
seen_cabinets[cabinet_id] = true
if assignment.game == null:
result.add_error(
"Cabinet '%s' has no game assigned."
% cabinet_id
)
return
var game := assignment.game
if game.game_id == &"":
result.add_error(
"Cabinet '%s' has a game with no game_id."
% cabinet_id
)
if game.scene == null:
result.add_error(
"Game '%s' has no scene assigned."
% game.game_id
)
if not game.can_run_on(cabinet):
result.add_error(
"Game '%s' cannot run on cabinet '%s'."
% [
game.game_id,
cabinet_id
]
)
if cabinet.hardware_config == null:
result.add_error(
"Cabinet '%s' has no HardwareConfig."
% cabinet_id
)
return
_validate_event_mappings(
assignment,
result
)
static func _validate_event_mappings(
assignment: ScenarioAssignment,
result: ValidationResult
) -> void:
var cabinet := assignment.cabinet
var cabinet_id := cabinet.cabinet_id
var hardware := cabinet.hardware_config
var game:= assignment.game
_validate_game_events(
game,
result
)
for mapping in assignment.event_mappings:
if mapping == null:
result.add_warning(
"Cabinet '%s' contains a null event mapping."
% cabinet_id
)
continue
if mapping.game_event == &"":
result.add_warning(
"Cabinet '%s' has a mapping with no game event."
% cabinet_id
)
else:
if not game.has_event(
mapping.game_event
):
result.add_error(
(
"Game '%s' does not define event '%s' "
+ "used by cabinet '%s'."
)
% [
game.game_id,
mapping.game_event,
cabinet_id
]
)
if mapping.hardware_actions.is_empty():
result.add_warning(
"Event '%s' on cabinet '%s' has no actions."
% [
mapping.game_event,
cabinet_id
]
)
for action_id in mapping.hardware_actions:
if not hardware.has_action(action_id):
result.add_error(
"Cabinet '%s': event '%s' references unknown hardware action '%s'."
% [
cabinet_id,
mapping.game_event,
action_id
]
)
static func _validate_game_events(
game: GameDefinition,
result: ValidationResult
) -> void:
var seen_events: Dictionary = {}
for event in game.events:
if event == null:
result.add_warning(
"Game '%s' contains a null event definition."
% game.game_id
)
continue
if event.event_id == &"":
result.add_error(
"Game '%s' contains an event with no event_id."
% game.game_id
)
continue
if seen_events.has(
event.event_id
):
result.add_error(
"Game '%s' defines event '%s' more than once."
% [
game.game_id,
event.event_id
]
)
else:
seen_events[
event.event_id
] = true
+1
View File
@@ -0,0 +1 @@
uid://dhcgaiviprvg8
+53
View File
@@ -0,0 +1,53 @@
@tool
class_name ValidationResult
extends RefCounted
var errors: Array[String] = []
var warnings: Array[String] = []
func add_error(message: String) -> void:
errors.append(message)
func add_warning(message: String) -> void:
warnings.append(message)
func is_valid() -> bool:
return errors.is_empty()
func has_warnings() -> bool:
return not warnings.is_empty()
func get_summary() -> String:
if not is_valid():
return "%d error(s), %d warning(s)" % [
errors.size(),
warnings.size()
]
if has_warnings():
return "Valid with %d warning(s)" % warnings.size()
return "Valid"
func merge(
other: ValidationResult
) -> void:
if other == null:
return
errors.append_array(
other.errors
)
warnings.append_array(
other.warnings
)
var result := ValidationResult.new()
+1
View File
@@ -0,0 +1 @@
uid://bey4rq671pjlp
+21
View File
@@ -0,0 +1,21 @@
@tool
class_name ScenarioAssignment
extends Resource
@export var cabinet: CabinetProfile
@export var game: GameDefinition
@export var variant: StringName = &"standard"
@export var event_mappings: Array[ScenarioEventMapping] = []
func get_actions_for_event(event_name: StringName) -> Array[StringName]:
var result: Array[StringName] = []
for mapping in event_mappings:
if mapping.game_event == event_name:
result.append_array(mapping.hardware_actions)
return result
@@ -0,0 +1 @@
uid://ibujr0ervhhq
+24
View File
@@ -0,0 +1,24 @@
@tool
class_name ScenarioDefinition
extends Resource
@export var scenario_id: StringName = &""
@export var display_name: String = ""
@export var version: int = 1
@export var assignments: Array[ScenarioAssignment] = []
func get_assignment_for_cabinet(
cabinet_id: StringName
) -> ScenarioAssignment:
for assignment in assignments:
if assignment.cabinet == null:
continue
if assignment.cabinet.cabinet_id == cabinet_id:
return assignment
return null
@@ -0,0 +1 @@
uid://d3pt6qa05kcnl
+318
View File
@@ -0,0 +1,318 @@
class_name ScenarioEditSession
extends RefCounted
signal scenario_changed(
scenario: ScenarioDefinition
)
signal dirty_changed(
is_dirty: bool
)
signal saved
signal reverted
var source_scenario: ScenarioDefinition
var working_scenario: ScenarioDefinition
var is_dirty: bool = false
var saved_state: Dictionary = {}
func begin(
scenario: ScenarioDefinition
) -> bool:
if scenario == null:
push_error(
"ScenarioEditSession cannot begin with a null scenario."
)
return false
source_scenario = scenario
_create_working_copy()
return true
func mark_dirty() -> void:
if working_scenario == null:
_set_dirty(
false
)
return
var current_state := (
_capture_scenario_state(
working_scenario
)
)
_set_dirty(
current_state != saved_state
)
func save() -> bool:
if source_scenario == null:
push_error(
"ScenarioEditSession has no source scenario."
)
return false
if working_scenario == null:
push_error(
"ScenarioEditSession has no working scenario."
)
return false
_apply_working_copy_to_source()
var path := source_scenario.resource_path
if path.is_empty():
push_error(
"Source ScenarioDefinition has no resource path."
)
return false
var error := ResourceSaver.save(
source_scenario,
path
)
if error != OK:
push_error(
"Failed to save ScenarioDefinition. Error: %s"
% error
)
return false
saved_state = (
_capture_scenario_state(
working_scenario
)
)
_set_dirty(
false
)
saved.emit()
return true
func revert() -> void:
if source_scenario == null:
return
_create_working_copy()
reverted.emit()
print(
"[SCENARIO EDITOR] Reverted unsaved changes."
)
func _create_working_copy() -> void:
var copy := ScenarioDefinition.new()
copy.scenario_id = source_scenario.scenario_id
copy.display_name = source_scenario.display_name
copy.version = source_scenario.version
for source_assignment in source_scenario.assignments:
if source_assignment == null:
continue
var assignment := (
ScenarioAssignment.new()
)
#
# Preserve references to the actual
# game/cabinet definitions.
#
assignment.cabinet = (
source_assignment.cabinet
)
assignment.game = (
source_assignment.game
)
assignment.variant = (
source_assignment.variant
)
assignment.event_mappings = (
_duplicate_mappings(
source_assignment.event_mappings
)
)
copy.assignments.append(
assignment
)
working_scenario = copy
#
# This working copy is now our clean,
# saved baseline.
#
saved_state = (
_capture_scenario_state(
working_scenario
)
)
_set_dirty(
false
)
scenario_changed.emit(
working_scenario
)
func _duplicate_mappings(
source_mappings: Array[ScenarioEventMapping]
) -> Array[ScenarioEventMapping]:
var result: Array[ScenarioEventMapping] = []
for source_mapping in source_mappings:
if source_mapping == null:
continue
var mapping := ScenarioEventMapping.new()
mapping.game_event = (
source_mapping.game_event
)
mapping.hardware_actions = (
source_mapping.hardware_actions.duplicate()
)
result.append(mapping)
return result
func _apply_working_copy_to_source() -> void:
for working_assignment in working_scenario.assignments:
if working_assignment == null:
continue
if working_assignment.cabinet == null:
continue
var source_assignment := (
source_scenario.get_assignment_for_cabinet(
working_assignment.cabinet.cabinet_id
)
)
if source_assignment == null:
continue
source_assignment.event_mappings = (
_duplicate_mappings(
working_assignment.event_mappings
)
)
func _capture_scenario_state(
scenario: ScenarioDefinition
) -> Dictionary:
if scenario == null:
return {}
var state := {
"scenario_id": scenario.scenario_id,
"display_name": scenario.display_name,
"version": scenario.version,
"assignments": [],
}
for assignment in scenario.assignments:
if assignment == null:
state["assignments"].append(
null
)
continue
var assignment_state := {
"cabinet_id": StringName(),
"game_id": StringName(),
"variant": assignment.variant,
"event_mappings": [],
}
if assignment.cabinet != null:
assignment_state["cabinet_id"] = (
assignment.cabinet.cabinet_id
)
if assignment.game != null:
assignment_state["game_id"] = (
assignment.game.game_id
)
for mapping in assignment.event_mappings:
if mapping == null:
assignment_state[
"event_mappings"
].append(
null
)
continue
var mapping_state := {
"game_event": mapping.game_event,
"hardware_actions": [],
}
for action_id in mapping.hardware_actions:
mapping_state[
"hardware_actions"
].append(
action_id
)
assignment_state[
"event_mappings"
].append(
mapping_state
)
state["assignments"].append(
assignment_state
)
return state
func _set_dirty(
value: bool
) -> void:
if is_dirty == value:
return
is_dirty = value
dirty_changed.emit(
is_dirty
)
@@ -0,0 +1 @@
uid://dc74xmy3c5hy2
+7
View File
@@ -0,0 +1,7 @@
@tool
class_name ScenarioEventMapping
extends Resource
@export var game_event: StringName = &""
@export var hardware_actions: Array[StringName] = []
@@ -0,0 +1 @@
uid://lixrugm1v2d2
+8
View File
@@ -0,0 +1,8 @@
@tool
class_name CabinetPlacement
extends Resource
@export var cabinet_id: StringName = &""
@export var position: Vector2 = Vector2.ZERO
@export var rotation_degrees: float = 0.0
+1
View File
@@ -0,0 +1 @@
uid://cwblyetugbr4d
+86
View File
@@ -0,0 +1,86 @@
@tool
class_name RoomDefinition
extends Resource
@export var room_id: StringName = &""
@export var display_name: String = ""
@export var cabinet_placements: Array[CabinetPlacement] = []
@export var devices: Array[RoomDeviceDefinition] = []
@export var bindings: Array[RoomDeviceBinding] = []
@export_group("Geometry")
@export var boundary_points: PackedVector2Array = PackedVector2Array()
@export var walls: Array[RoomWallDefinition] = []
@export_group("Logic")
@export var logic_rules: Array[RoomLogicRule] = []
func get_device(
device_id: StringName
) -> RoomDeviceDefinition:
for device in devices:
if device == null:
continue
if device.device_id == device_id:
return device
return null
func get_cabinet_placement(
cabinet_id: StringName
) -> CabinetPlacement:
for placement in cabinet_placements:
if placement == null:
continue
if placement.cabinet_id == cabinet_id:
return placement
return null
func get_device_for_action(
cabinet_id: StringName,
action_id: StringName
) -> RoomDeviceDefinition:
for binding in bindings:
if binding == null:
continue
if (
binding.cabinet_id == cabinet_id
and
binding.hardware_action_id == action_id
):
return get_device(binding.device_id)
return null
func get_wall(
wall_id: StringName
) -> RoomWallDefinition:
for wall in walls:
if wall == null:
continue
if wall.wall_id == wall_id:
return wall
return null
func has_wall(
wall_id: StringName
) -> bool:
return get_wall(
wall_id
) != null
+1
View File
@@ -0,0 +1 @@
uid://dgm2dkw5rar8i
+15
View File
@@ -0,0 +1,15 @@
@tool
class_name RoomDeviceBinding
extends Resource
@export var cabinet_id: StringName = &""
@export var hardware_action_id: StringName = &""
@export var device_id: StringName = &""
@export_group("Physical Cable Route")
@export var route_points: PackedVector2Array = (
PackedVector2Array()
)
@@ -0,0 +1 @@
uid://r2rpbasss511
+75
View File
@@ -0,0 +1,75 @@
@tool
class_name RoomDeviceDefinition
extends Resource
enum DeviceType {
GENERIC,
COIN_RELEASE,
LIGHT,
DOOR_LOCK,
DRAWER_LOCK,
BUZZER
}
enum Behavior {
MOMENTARY,
LATCHED
}
@export var device_id: StringName = &""
@export var display_name: String = ""
@export var device_type: DeviceType = DeviceType.GENERIC
@export var behavior: Behavior = Behavior.MOMENTARY
@export var initial_active: bool = false
@export_group("Layout")
@export var position: Vector2 = Vector2.ZERO
const FEET_TO_METERS: float = 0.3048
const METERS_TO_FEET: float = 1.0 / FEET_TO_METERS
#
# Internal/storage representation.
#
@export_storage
var physical_size_m: Vector2 = Vector2(
0.25,
0.25
)
#
# Designer-facing representation.
#
@export_group("Physical Dimensions")
@export_custom(
PROPERTY_HINT_NONE,
"suffix: ft"
)
var physical_size_ft: Vector2:
get:
return (
physical_size_m
* METERS_TO_FEET
)
set(value):
physical_size_m = (
value
* FEET_TO_METERS
)
@export_group("Physical Appearance")
@export var editor_texture: Texture2D = null
@export_range(0.1, 4.0, 0.05)
var editor_texture_scale: float = 1.0
@@ -0,0 +1 @@
uid://ejum18yr7xeo
+420
View File
@@ -0,0 +1,420 @@
class_name RoomEditSession
extends RefCounted
signal room_changed(room: RoomDefinition)
signal dirty_changed(is_dirty: bool)
signal saved
signal reverted
var source_room: RoomDefinition
var working_room: RoomDefinition
var is_dirty: bool = false
var saved_state: Dictionary = {}
func begin(room: RoomDefinition) -> bool:
if room == null:
push_error(
"RoomEditSession cannot begin with a null room."
)
return false
source_room = room
_create_working_copy()
return true
func mark_dirty() -> void:
if working_room == null:
_set_dirty(
false
)
return
var current_state := (
_capture_room_state(
working_room
)
)
_set_dirty(
current_state != saved_state
)
func save() -> bool:
if source_room == null:
push_error(
"RoomEditSession has no source room."
)
return false
if working_room == null:
push_error(
"RoomEditSession has no working room."
)
return false
_apply_working_copy_to_source()
var path := source_room.resource_path
if path.is_empty():
push_error(
"Source RoomDefinition has no resource path."
)
return false
var error := ResourceSaver.save(
source_room,
path
)
if error != OK:
push_error(
"Failed to save RoomDefinition. Error: %s"
% error
)
return false
print(
"[ROOM EDITOR] Saved room to '%s'."
% path
)
saved_state = (
_capture_room_state(
working_room
)
)
_set_dirty(
false
)
saved.emit()
return true
func revert() -> void:
if source_room == null:
return
_create_working_copy()
reverted.emit()
print(
"[ROOM EDITOR] Reverted unsaved changes."
)
func reload_from_disk() -> bool:
if source_room == null:
return false
var path := (
source_room.resource_path
)
if path.is_empty():
push_error(
"Source RoomDefinition has no resource path."
)
return false
var reloaded := (
ResourceLoader.load(
path,
"",
ResourceLoader.CACHE_MODE_REPLACE
)
as RoomDefinition
)
if reloaded == null:
push_error(
"Failed to reload RoomDefinition from '%s'."
% path
)
return false
source_room = reloaded
_create_working_copy()
print(
"[ROOM EDITOR] Reloaded room from disk: '%s'."
% path
)
return true
func _create_working_copy() -> void:
working_room = (
_duplicate_room(
source_room
)
)
saved_state = (
_capture_room_state(
working_room
)
)
_set_dirty(
false
)
room_changed.emit(
working_room
)
func _apply_working_copy_to_source() -> void:
for working_placement in working_room.cabinet_placements:
if working_placement == null:
continue
var source_placement := (
source_room.get_cabinet_placement(
working_placement.cabinet_id
)
)
if source_placement == null:
continue
source_placement.position = (
working_placement.position
)
source_placement.rotation_degrees = (
working_placement.rotation_degrees
)
for working_device in working_room.devices:
if working_device == null:
continue
var source_device := (
source_room.get_device(
working_device.device_id
)
)
if source_device == null:
continue
source_device.position = (
working_device.position
)
for working_binding in working_room.bindings:
if working_binding == null:
continue
var source_binding: RoomDeviceBinding = null
for candidate in source_room.bindings:
if candidate == null:
continue
if (
candidate.cabinet_id
== working_binding.cabinet_id
and candidate.hardware_action_id
== working_binding.hardware_action_id
and candidate.device_id
== working_binding.device_id
):
source_binding = candidate
break
if source_binding == null:
continue
source_binding.route_points = (
working_binding.route_points.duplicate()
)
#
# Copy authored room geometry.
#
source_room.boundary_points = (
working_room.boundary_points.duplicate()
)
source_room.walls = _duplicate_walls(
working_room.walls
)
func _duplicate_room(
source: RoomDefinition
) -> RoomDefinition:
var copy := (
source.duplicate(true)
as RoomDefinition
)
if copy == null:
return null
#
# Explicitly duplicate authored geometry so
# wall Resources are never shared with source_room.
#
copy.boundary_points = (
source.boundary_points.duplicate()
)
copy.walls = _duplicate_walls(
source.walls
)
return copy
func _duplicate_walls(
source: Array[RoomWallDefinition]
) -> Array[RoomWallDefinition]:
var result: Array[RoomWallDefinition] = []
for wall in source:
if wall == null:
continue
var copy := (
wall.duplicate(true)
as RoomWallDefinition
)
if copy != null:
result.append(
copy
)
return result
func _capture_room_state(
room: RoomDefinition
) -> Dictionary:
if room == null:
return {}
var state := {
"room_id": room.room_id,
"display_name": room.display_name,
"boundary_points": room.boundary_points.duplicate(),
"cabinet_placements": [],
"devices": [],
"bindings": [],
"walls": [],
}
for placement in room.cabinet_placements:
if placement == null:
state["cabinet_placements"].append(
null
)
continue
state["cabinet_placements"].append(
{
"cabinet_id": placement.cabinet_id,
"position": placement.position,
"rotation_degrees": (
placement.rotation_degrees
),
}
)
for device in room.devices:
if device == null:
state["devices"].append(
null
)
continue
state["devices"].append(
{
"device_id": device.device_id,
"display_name": device.display_name,
"device_type": device.device_type,
"behavior": device.behavior,
"initial_active": (
device.initial_active
),
"position": device.position,
}
)
for binding in room.bindings:
if binding == null:
state["bindings"].append(
null
)
continue
state["bindings"].append(
{
"cabinet_id": binding.cabinet_id,
"hardware_action_id": (
binding.hardware_action_id
),
"device_id": binding.device_id,
"route_points": (
binding.route_points.duplicate()
),
}
)
for wall in room.walls:
if wall == null:
state["walls"].append(
null
)
continue
state["walls"].append(
{
"wall_id": wall.wall_id,
"start": wall.start,
"end": wall.end,
"thickness": wall.thickness,
}
)
return state
func _set_dirty(
value: bool
) -> void:
if is_dirty == value:
return
is_dirty = value
dirty_changed.emit(
is_dirty
)
+1
View File
@@ -0,0 +1 @@
uid://w0en0xus0g5j
+312
View File
@@ -0,0 +1,312 @@
@tool
class_name RoomSimulator
extends Node
signal device_state_changed(
device_id: StringName,
active: bool
)
signal binding_activated(
cabinet_id: StringName,
action_id: StringName,
device_id: StringName
)
signal binding_deactivated(
cabinet_id: StringName,
action_id: StringName,
device_id: StringName
)
signal path_activated(
path_id: StringName,
cabinet_id: StringName,
event_id: StringName,
action_id: StringName,
device_id: StringName
)
signal path_deactivated(
path_id: StringName,
cabinet_id: StringName,
event_id: StringName,
action_id: StringName,
device_id: StringName
)
var room: RoomDefinition
var device_states: Dictionary = {}
func configure(
room_definition: RoomDefinition
) -> void:
room = room_definition
device_states.clear()
if room == null:
push_error(
"RoomSimulator received a null RoomDefinition."
)
return
for device in room.devices:
if device == null:
continue
device_states[
device.device_id
] = device.initial_active
if not Engine.is_editor_hint():
_connect_hardware_manager()
print("")
print("======= ROOM SIMULATOR =======")
print("Room: ", room.display_name)
print("Devices: ", room.devices.size())
print("==============================")
print("")
func _connect_hardware_manager() -> void:
if not HardwareManager.action_activated.is_connected(
_on_hardware_action_activated
):
HardwareManager.action_activated.connect(
_on_hardware_action_activated
)
if not HardwareManager.action_deactivated.is_connected(
_on_hardware_action_deactivated
):
HardwareManager.action_deactivated.connect(
_on_hardware_action_deactivated
)
func reset() -> void:
if room == null:
return
for device in room.devices:
if device == null:
continue
_set_device_state(
device.device_id,
device.initial_active
)
func get_device_state(
device_id: StringName
) -> bool:
return device_states.get(
device_id,
false
)
#
# Runtime HardwareManager entry points
#
func _on_hardware_action_activated(
cabinet_id: StringName,
action_id: StringName,
context: Dictionary
) -> void:
_process_action_activated(
cabinet_id,
action_id,
context
)
func _on_hardware_action_deactivated(
cabinet_id: StringName,
action_id: StringName,
context: Dictionary
) -> void:
_process_action_deactivated(
cabinet_id,
action_id,
context
)
#
# Editor simulation entry points
#
func simulate_action_activated(
cabinet_id: StringName,
action_id: StringName,
context: Dictionary = {}
) -> void:
_process_action_activated(
cabinet_id,
action_id,
context
)
func simulate_action_deactivated(
cabinet_id: StringName,
action_id: StringName,
context: Dictionary = {}
) -> void:
_process_action_deactivated(
cabinet_id,
action_id,
context
)
#
# Shared processing
#
func _process_action_activated(
cabinet_id: StringName,
action_id: StringName,
context: Dictionary
) -> void:
if room == null:
return
var device := room.get_device_for_action(
cabinet_id,
action_id
)
if device == null:
print(
"[SIMULATION] No room device bound to %s:%s"
% [
cabinet_id,
action_id
]
)
return
var event_id := StringName(
context.get(
"game_event",
"unknown_event"
)
)
var path_id := LogicPathId.make(
cabinet_id,
event_id,
action_id,
device.device_id
)
path_activated.emit(
path_id,
cabinet_id,
event_id,
action_id,
device.device_id
)
binding_activated.emit(
cabinet_id,
action_id,
device.device_id
)
_set_device_state(
device.device_id,
true
)
func _process_action_deactivated(
cabinet_id: StringName,
action_id: StringName,
context: Dictionary
) -> void:
if room == null:
return
var device := room.get_device_for_action(
cabinet_id,
action_id
)
if device == null:
return
var event_id := StringName(
context.get(
"game_event",
"unknown_event"
)
)
var path_id := LogicPathId.make(
cabinet_id,
event_id,
action_id,
device.device_id
)
path_deactivated.emit(
path_id,
cabinet_id,
event_id,
action_id,
device.device_id
)
binding_deactivated.emit(
cabinet_id,
action_id,
device.device_id
)
if (
device.behavior
== RoomDeviceDefinition.Behavior.MOMENTARY
):
_set_device_state(
device.device_id,
false
)
func _set_device_state(
device_id: StringName,
active: bool
) -> void:
device_states[
device_id
] = active
print(
"[SIMULATION] Device '%s' -> %s"
% [
device_id,
"ACTIVE" if active else "INACTIVE"
]
)
device_state_changed.emit(
device_id,
active
)
+1
View File
@@ -0,0 +1 @@
uid://bfb7sad2mjnlf
@@ -0,0 +1,102 @@
class_name SimulatedHardwareBackend
extends RefCounted
signal pin_state_changed(
cabinet_id: StringName,
gpio_pin: int,
active: bool,
action_id: StringName,
context: Dictionary
)
signal trace_entry_created(
entry: Dictionary
)
var pin_states: Dictionary = {}
func reset() -> void:
pin_states.clear()
func activate_action(
cabinet_id: StringName,
action: HardwareAction,
context: Dictionary
) -> void:
if action == null:
return
var gpio_active := action.active_high
_set_pin_state(
cabinet_id,
action,
gpio_active,
true,
context
)
func deactivate_action(
cabinet_id: StringName,
action: HardwareAction,
context: Dictionary
) -> void:
if action == null:
return
var gpio_active := not action.active_high
_set_pin_state(
cabinet_id,
action,
gpio_active,
false,
context
)
func _set_pin_state(
cabinet_id: StringName,
action: HardwareAction,
electrical_state: bool,
logical_active: bool,
context: Dictionary
) -> void:
var key := "%s::%d" % [
cabinet_id,
action.gpio_pin
]
pin_states[key] = electrical_state
var entry := {
"type": &"gpio",
"cabinet_id": cabinet_id,
"action_id": action.action_id,
"gpio_pin": action.gpio_pin,
"electrical_state": electrical_state,
"logical_active": logical_active,
"active_high": action.active_high,
"context": context.duplicate(),
}
trace_entry_created.emit(
entry
)
pin_state_changed.emit(
cabinet_id,
action.gpio_pin,
logical_active,
action.action_id,
context
)
@@ -0,0 +1 @@
uid://b3xyoxcebkhoc
+42
View File
@@ -0,0 +1,42 @@
@tool
class_name TestSequence
extends Resource
@export var sequence_id: StringName = &""
@export var display_name: String = ""
@export_group("Compatibility")
@export var scenario_id: StringName = &""
@export_multiline
var description: String = ""
@export var steps: Array[TestSequenceStep] = []
func get_sorted_steps() -> Array[TestSequenceStep]:
var result: Array[TestSequenceStep] = []
for step in steps:
if step == null:
continue
result.append(
step
)
result.sort_custom(
func(
a: TestSequenceStep,
b: TestSequenceStep
) -> bool:
return (
a.time_offset_seconds
< b.time_offset_seconds
)
)
return result
+1
View File
@@ -0,0 +1 @@
uid://clbm4alwyibrd
+13
View File
@@ -0,0 +1,13 @@
@tool
class_name TestSequenceStep
extends Resource
@export_range(0.0, 36000.0, 0.1)
var time_offset_seconds: float = 0.0
@export var source_id: StringName = &""
@export var event_id: StringName = &""
@export_multiline
var note: String = ""
@@ -0,0 +1 @@
uid://c5022gsswr487