commit 1ac9eb56442b8b2b1bf8c57c2351b4161a97a651 Author: Jeff Luke Date: Sun Oct 4 11:31:17 2026 -0700 Added Glitch Room Source code to Gitty diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0af181c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +/android/ diff --git a/addons/escape_room/escape_room_plugin.gd b/addons/escape_room/escape_room_plugin.gd new file mode 100644 index 0000000..4a93631 --- /dev/null +++ b/addons/escape_room/escape_room_plugin.gd @@ -0,0 +1,182 @@ +@tool +extends EditorPlugin + + +const WORKSPACE_SCENE := preload( + "res://editor/workspace/EscapeRoomWorkspace.tscn" +) + +const DATABASE_SETTING := ( + "arcade_escape/project_database" +) + +const ROOM_SETTING := ( + "arcade_escape/default_room" +) + + +var workspace: EscapeRoomWorkspace + + +func _enter_tree() -> void: + _ensure_project_settings() + _create_workspace() + + +func _exit_tree() -> void: + _destroy_workspace() + + +func _has_main_screen() -> bool: + return true + + +func _make_visible(visible: bool) -> void: + if workspace != null: + workspace.visible = visible + + +func _get_plugin_name() -> String: + return "Escape Room" + + +func _get_plugin_icon() -> Texture2D: + return EditorInterface.get_editor_theme().get_icon( + "Node2D", + "EditorIcons" + ) + + +func _create_workspace() -> void: + + if workspace != null: + return + + var database := _load_project_database() + var room := _load_default_room() + + if database == null: + return + + if room == null: + return + + workspace = WORKSPACE_SCENE.instantiate() + + workspace.set_undo_redo_manager( + get_undo_redo() + ) + + EditorInterface.get_editor_main_screen().add_child( + workspace + ) + + workspace.set_anchors_and_offsets_preset( + Control.PRESET_FULL_RECT + ) + + if not workspace.configure( + database, + room + ): + push_error( + "Arcade Escape editor workspace failed to configure." + ) + + _make_visible(false) + + +func _destroy_workspace() -> void: + if workspace == null: + return + + if is_instance_valid(workspace): + workspace.queue_free() + + workspace = null + +func _ensure_project_settings() -> void: + + if not ProjectSettings.has_setting( + DATABASE_SETTING + ): + ProjectSettings.set_setting( + DATABASE_SETTING, + "res://data/project_database.tres" + ) + + ProjectSettings.set_initial_value( + DATABASE_SETTING, + "res://data/project_database.tres" + ) + + if not ProjectSettings.has_setting( + ROOM_SETTING + ): + ProjectSettings.set_setting( + ROOM_SETTING, + "res://data/rooms/arcade_room.tres" + ) + + ProjectSettings.set_initial_value( + ROOM_SETTING, + "res://data/rooms/arcade_room.tres" + ) + + ProjectSettings.save() + +func _load_project_database() -> ProjectDatabase: + + var path := String( + ProjectSettings.get_setting( + DATABASE_SETTING, + "" + ) + ) + + if path.is_empty(): + push_error( + "Arcade Escape project database path is empty." + ) + return null + + var resource := ResourceLoader.load( + path + ) + + if not resource is ProjectDatabase: + push_error( + "Arcade Escape expected ProjectDatabase at '%s'." + % path + ) + return null + + return resource as ProjectDatabase + +func _load_default_room() -> RoomDefinition: + + var path := String( + ProjectSettings.get_setting( + ROOM_SETTING, + "" + ) + ) + + if path.is_empty(): + push_error( + "Arcade Escape default room path is empty." + ) + return null + + var resource := ResourceLoader.load( + path + ) + + if not resource is RoomDefinition: + push_error( + "Arcade Escape expected RoomDefinition at '%s'." + % path + ) + return null + + return resource as RoomDefinition diff --git a/addons/escape_room/escape_room_plugin.gd.uid b/addons/escape_room/escape_room_plugin.gd.uid new file mode 100644 index 0000000..88a03e8 --- /dev/null +++ b/addons/escape_room/escape_room_plugin.gd.uid @@ -0,0 +1 @@ +uid://dey5xa25bteww diff --git a/addons/escape_room/plugin.cfg b/addons/escape_room/plugin.cfg new file mode 100644 index 0000000..59a9450 --- /dev/null +++ b/addons/escape_room/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Escape Room" +description="Escape room authoring tools." +author="Escape Gallery" +version="0.1.0" +script="escape_room_plugin.gd" \ No newline at end of file diff --git a/arcade_room.sqlite3 b/arcade_room.sqlite3 new file mode 100644 index 0000000..a2108ba Binary files /dev/null and b/arcade_room.sqlite3 differ diff --git a/art/devices/tex_cabinet.png b/art/devices/tex_cabinet.png new file mode 100644 index 0000000..147f71d Binary files /dev/null and b/art/devices/tex_cabinet.png differ diff --git a/art/devices/tex_cabinet.png.import b/art/devices/tex_cabinet.png.import new file mode 100644 index 0000000..71e1cb6 --- /dev/null +++ b/art/devices/tex_cabinet.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cistuspr5bf8h" +path="res://.godot/imported/tex_cabinet.png-f6576ddd6a1e314af47e0494fc6c164e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://art/devices/tex_cabinet.png" +dest_files=["res://.godot/imported/tex_cabinet.png-f6576ddd6a1e314af47e0494fc6c164e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/art/devices/tex_coin_release.png b/art/devices/tex_coin_release.png new file mode 100644 index 0000000..fcf08f0 Binary files /dev/null and b/art/devices/tex_coin_release.png differ diff --git a/art/devices/tex_coin_release.png.import b/art/devices/tex_coin_release.png.import new file mode 100644 index 0000000..d186c21 --- /dev/null +++ b/art/devices/tex_coin_release.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b7i278fi27mbh" +path="res://.godot/imported/tex_coin_release.png-3fe2091a6961abd0e69cf82228b39c9e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://art/devices/tex_coin_release.png" +dest_files=["res://.godot/imported/tex_coin_release.png-3fe2091a6961abd0e69cf82228b39c9e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/art/devices/tex_light.png b/art/devices/tex_light.png new file mode 100644 index 0000000..1813988 Binary files /dev/null and b/art/devices/tex_light.png differ diff --git a/art/devices/tex_light.png.import b/art/devices/tex_light.png.import new file mode 100644 index 0000000..3e124ff --- /dev/null +++ b/art/devices/tex_light.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b7piqhhrvttw8" +path="res://.godot/imported/tex_light.png-80cad21869b9d5cc56aa8fcfdd96c808.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://art/devices/tex_light.png" +dest_files=["res://.godot/imported/tex_light.png-80cad21869b9d5cc56aa8fcfdd96c808.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/core/games/arcade_controls.gd b/core/games/arcade_controls.gd new file mode 100644 index 0000000..3bd411d --- /dev/null +++ b/core/games/arcade_controls.gd @@ -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 diff --git a/core/games/arcade_controls.gd.uid b/core/games/arcade_controls.gd.uid new file mode 100644 index 0000000..7251e60 --- /dev/null +++ b/core/games/arcade_controls.gd.uid @@ -0,0 +1 @@ +uid://b58xhfwt4fvby diff --git a/core/games/arcade_game.gd b/core/games/arcade_game.gd new file mode 100644 index 0000000..ecb4637 --- /dev/null +++ b/core/games/arcade_game.gd @@ -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() diff --git a/core/games/arcade_game.gd.uid b/core/games/arcade_game.gd.uid new file mode 100644 index 0000000..65bafb0 --- /dev/null +++ b/core/games/arcade_game.gd.uid @@ -0,0 +1 @@ +uid://bv62i5q5hvkqd diff --git a/core/games/game_definition.gd b/core/games/game_definition.gd new file mode 100644 index 0000000..2a35f2e --- /dev/null +++ b/core/games/game_definition.gd @@ -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 diff --git a/core/games/game_definition.gd.uid b/core/games/game_definition.gd.uid new file mode 100644 index 0000000..33ee39d --- /dev/null +++ b/core/games/game_definition.gd.uid @@ -0,0 +1 @@ +uid://o538v0kdvs0j diff --git a/core/games/game_event_definition.gd b/core/games/game_event_definition.gd new file mode 100644 index 0000000..8206e98 --- /dev/null +++ b/core/games/game_event_definition.gd @@ -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 = "" diff --git a/core/games/game_event_definition.gd.uid b/core/games/game_event_definition.gd.uid new file mode 100644 index 0000000..50b0d54 --- /dev/null +++ b/core/games/game_event_definition.gd.uid @@ -0,0 +1 @@ +uid://byegr76ey4613 diff --git a/core/hardware/cabinet_profile.gd b/core/hardware/cabinet_profile.gd new file mode 100644 index 0000000..5842e74 --- /dev/null +++ b/core/hardware/cabinet_profile.gd @@ -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) diff --git a/core/hardware/cabinet_profile.gd.uid b/core/hardware/cabinet_profile.gd.uid new file mode 100644 index 0000000..335fdfc --- /dev/null +++ b/core/hardware/cabinet_profile.gd.uid @@ -0,0 +1 @@ +uid://c0jj5j5loqsma diff --git a/core/hardware/hardware_action.gd b/core/hardware/hardware_action.gd new file mode 100644 index 0000000..0e7bcc0 --- /dev/null +++ b/core/hardware/hardware_action.gd @@ -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 diff --git a/core/hardware/hardware_action.gd.uid b/core/hardware/hardware_action.gd.uid new file mode 100644 index 0000000..f247758 --- /dev/null +++ b/core/hardware/hardware_action.gd.uid @@ -0,0 +1 @@ +uid://c7eu1n5yey1x4 diff --git a/core/hardware/hardware_config.gd b/core/hardware/hardware_config.gd new file mode 100644 index 0000000..a88d078 --- /dev/null +++ b/core/hardware/hardware_config.gd @@ -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 diff --git a/core/hardware/hardware_config.gd.uid b/core/hardware/hardware_config.gd.uid new file mode 100644 index 0000000..a00d0b5 --- /dev/null +++ b/core/hardware/hardware_config.gd.uid @@ -0,0 +1 @@ +uid://60whm57itpjb diff --git a/core/network/managed_cabinet.gd b/core/network/managed_cabinet.gd new file mode 100644 index 0000000..a6a9f68 --- /dev/null +++ b/core/network/managed_cabinet.gd @@ -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") diff --git a/core/network/managed_cabinet.gd.uid b/core/network/managed_cabinet.gd.uid new file mode 100644 index 0000000..c389ed0 --- /dev/null +++ b/core/network/managed_cabinet.gd.uid @@ -0,0 +1 @@ +uid://dfoc8gpbuicc4 diff --git a/core/network/room_connection.gd b/core/network/room_connection.gd new file mode 100644 index 0000000..9d20b72 --- /dev/null +++ b/core/network/room_connection.gd @@ -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 diff --git a/core/network/room_connection.gd.uid b/core/network/room_connection.gd.uid new file mode 100644 index 0000000..ced4bd0 --- /dev/null +++ b/core/network/room_connection.gd.uid @@ -0,0 +1 @@ +uid://bhsk7cucg2sbx diff --git a/core/rooms/room_logic_engine.gd b/core/rooms/room_logic_engine.gd new file mode 100644 index 0000000..8ab771c --- /dev/null +++ b/core/rooms/room_logic_engine.gd @@ -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() diff --git a/core/rooms/room_logic_engine.gd.uid b/core/rooms/room_logic_engine.gd.uid new file mode 100644 index 0000000..331b40b --- /dev/null +++ b/core/rooms/room_logic_engine.gd.uid @@ -0,0 +1 @@ +uid://cxw5i220nmobr diff --git a/core/rooms/room_logic_rule.gd b/core/rooms/room_logic_rule.gd new file mode 100644 index 0000000..8307aed --- /dev/null +++ b/core/rooms/room_logic_rule.gd @@ -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 diff --git a/core/rooms/room_logic_rule.gd.uid b/core/rooms/room_logic_rule.gd.uid new file mode 100644 index 0000000..27308fd --- /dev/null +++ b/core/rooms/room_logic_rule.gd.uid @@ -0,0 +1 @@ +uid://cp40t70asl4p0 diff --git a/core/rooms/room_wall_definition.gd b/core/rooms/room_wall_definition.gd new file mode 100644 index 0000000..4e4d391 --- /dev/null +++ b/core/rooms/room_wall_definition.gd @@ -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 + ) diff --git a/core/rooms/room_wall_definition.gd.uid b/core/rooms/room_wall_definition.gd.uid new file mode 100644 index 0000000..f6a51a0 --- /dev/null +++ b/core/rooms/room_wall_definition.gd.uid @@ -0,0 +1 @@ +uid://dl7ydh8g6877n diff --git a/core/runtime/game_events.gd b/core/runtime/game_events.gd new file mode 100644 index 0000000..07dee2a --- /dev/null +++ b/core/runtime/game_events.gd @@ -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" diff --git a/core/runtime/game_events.gd.uid b/core/runtime/game_events.gd.uid new file mode 100644 index 0000000..88c2ebd --- /dev/null +++ b/core/runtime/game_events.gd.uid @@ -0,0 +1 @@ +uid://cs0xs0amg3lrv diff --git a/core/runtime/game_host.gd b/core/runtime/game_host.gd new file mode 100644 index 0000000..db22e7c --- /dev/null +++ b/core/runtime/game_host.gd @@ -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) diff --git a/core/runtime/game_host.gd.uid b/core/runtime/game_host.gd.uid new file mode 100644 index 0000000..5122396 --- /dev/null +++ b/core/runtime/game_host.gd.uid @@ -0,0 +1 @@ +uid://cp6novpbgl1uk diff --git a/core/runtime/hardware_manager.gd b/core/runtime/hardware_manager.gd new file mode 100644 index 0000000..bf0b274 --- /dev/null +++ b/core/runtime/hardware_manager.gd @@ -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"}) diff --git a/core/runtime/hardware_manager.gd.uid b/core/runtime/hardware_manager.gd.uid new file mode 100644 index 0000000..770478d --- /dev/null +++ b/core/runtime/hardware_manager.gd.uid @@ -0,0 +1 @@ +uid://5d4k46jwlo4y diff --git a/core/runtime/launch_config.gd b/core/runtime/launch_config.gd new file mode 100644 index 0000000..9c7d90a --- /dev/null +++ b/core/runtime/launch_config.gd @@ -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("") diff --git a/core/runtime/launch_config.gd.uid b/core/runtime/launch_config.gd.uid new file mode 100644 index 0000000..6459d71 --- /dev/null +++ b/core/runtime/launch_config.gd.uid @@ -0,0 +1 @@ +uid://brd536grsx38f diff --git a/core/runtime/logic_path_id.gd b/core/runtime/logic_path_id.gd new file mode 100644 index 0000000..f417c38 --- /dev/null +++ b/core/runtime/logic_path_id.gd @@ -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 + ] + ) diff --git a/core/runtime/logic_path_id.gd.uid b/core/runtime/logic_path_id.gd.uid new file mode 100644 index 0000000..c359f5e --- /dev/null +++ b/core/runtime/logic_path_id.gd.uid @@ -0,0 +1 @@ +uid://cy746kl1sgsrn diff --git a/core/runtime/pi_gpio_client.gd b/core/runtime/pi_gpio_client.gd new file mode 100644 index 0000000..858c918 --- /dev/null +++ b/core/runtime/pi_gpio_client.gd @@ -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() diff --git a/core/runtime/pi_gpio_client.gd.uid b/core/runtime/pi_gpio_client.gd.uid new file mode 100644 index 0000000..54ca7c2 --- /dev/null +++ b/core/runtime/pi_gpio_client.gd.uid @@ -0,0 +1 @@ +uid://74k3b8polfi2 diff --git a/core/runtime/project_database.gd b/core/runtime/project_database.gd new file mode 100644 index 0000000..6d67f9e --- /dev/null +++ b/core/runtime/project_database.gd @@ -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 diff --git a/core/runtime/project_database.gd.uid b/core/runtime/project_database.gd.uid new file mode 100644 index 0000000..991e289 --- /dev/null +++ b/core/runtime/project_database.gd.uid @@ -0,0 +1 @@ +uid://diqnrxrhwx2ug diff --git a/core/runtime/room_controller.gd b/core/runtime/room_controller.gd new file mode 100644 index 0000000..dbd3ae8 --- /dev/null +++ b/core/runtime/room_controller.gd @@ -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 + } + ) diff --git a/core/runtime/room_controller.gd.uid b/core/runtime/room_controller.gd.uid new file mode 100644 index 0000000..d0f7a41 --- /dev/null +++ b/core/runtime/room_controller.gd.uid @@ -0,0 +1 @@ +uid://dbe24sbmbh6tt diff --git a/core/runtime/room_runtime.gd b/core/runtime/room_runtime.gd new file mode 100644 index 0000000..1b2727d --- /dev/null +++ b/core/runtime/room_runtime.gd @@ -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 + } + ) diff --git a/core/runtime/room_runtime.gd.uid b/core/runtime/room_runtime.gd.uid new file mode 100644 index 0000000..7f09576 --- /dev/null +++ b/core/runtime/room_runtime.gd.uid @@ -0,0 +1 @@ +uid://eelj57byt0su diff --git a/core/runtime/room_validator.gd b/core/runtime/room_validator.gd new file mode 100644 index 0000000..8f47742 --- /dev/null +++ b/core/runtime/room_validator.gd @@ -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 diff --git a/core/runtime/room_validator.gd.uid b/core/runtime/room_validator.gd.uid new file mode 100644 index 0000000..2a80e6a --- /dev/null +++ b/core/runtime/room_validator.gd.uid @@ -0,0 +1 @@ +uid://bbu1ejey4q6vx diff --git a/core/runtime/scenario_validator.gd b/core/runtime/scenario_validator.gd new file mode 100644 index 0000000..ffe072d --- /dev/null +++ b/core/runtime/scenario_validator.gd @@ -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 diff --git a/core/runtime/scenario_validator.gd.uid b/core/runtime/scenario_validator.gd.uid new file mode 100644 index 0000000..b740c71 --- /dev/null +++ b/core/runtime/scenario_validator.gd.uid @@ -0,0 +1 @@ +uid://dhcgaiviprvg8 diff --git a/core/runtime/validation_result.gd b/core/runtime/validation_result.gd new file mode 100644 index 0000000..ace695d --- /dev/null +++ b/core/runtime/validation_result.gd @@ -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() diff --git a/core/runtime/validation_result.gd.uid b/core/runtime/validation_result.gd.uid new file mode 100644 index 0000000..f33ea59 --- /dev/null +++ b/core/runtime/validation_result.gd.uid @@ -0,0 +1 @@ +uid://bey4rq671pjlp diff --git a/core/scenarios/scenario_assignment.gd b/core/scenarios/scenario_assignment.gd new file mode 100644 index 0000000..c92a37a --- /dev/null +++ b/core/scenarios/scenario_assignment.gd @@ -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 diff --git a/core/scenarios/scenario_assignment.gd.uid b/core/scenarios/scenario_assignment.gd.uid new file mode 100644 index 0000000..9ee2598 --- /dev/null +++ b/core/scenarios/scenario_assignment.gd.uid @@ -0,0 +1 @@ +uid://ibujr0ervhhq diff --git a/core/scenarios/scenario_definition.gd b/core/scenarios/scenario_definition.gd new file mode 100644 index 0000000..23e07f0 --- /dev/null +++ b/core/scenarios/scenario_definition.gd @@ -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 diff --git a/core/scenarios/scenario_definition.gd.uid b/core/scenarios/scenario_definition.gd.uid new file mode 100644 index 0000000..328594f --- /dev/null +++ b/core/scenarios/scenario_definition.gd.uid @@ -0,0 +1 @@ +uid://d3pt6qa05kcnl diff --git a/core/scenarios/scenario_edit_session.gd b/core/scenarios/scenario_edit_session.gd new file mode 100644 index 0000000..27782dd --- /dev/null +++ b/core/scenarios/scenario_edit_session.gd @@ -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 + ) diff --git a/core/scenarios/scenario_edit_session.gd.uid b/core/scenarios/scenario_edit_session.gd.uid new file mode 100644 index 0000000..f2b80f8 --- /dev/null +++ b/core/scenarios/scenario_edit_session.gd.uid @@ -0,0 +1 @@ +uid://dc74xmy3c5hy2 diff --git a/core/scenarios/scenario_event_mapping.gd b/core/scenarios/scenario_event_mapping.gd new file mode 100644 index 0000000..3ad63ae --- /dev/null +++ b/core/scenarios/scenario_event_mapping.gd @@ -0,0 +1,7 @@ +@tool +class_name ScenarioEventMapping +extends Resource + + +@export var game_event: StringName = &"" +@export var hardware_actions: Array[StringName] = [] diff --git a/core/scenarios/scenario_event_mapping.gd.uid b/core/scenarios/scenario_event_mapping.gd.uid new file mode 100644 index 0000000..1c3160f --- /dev/null +++ b/core/scenarios/scenario_event_mapping.gd.uid @@ -0,0 +1 @@ +uid://lixrugm1v2d2 diff --git a/core/simulation/cabinet_placement.gd b/core/simulation/cabinet_placement.gd new file mode 100644 index 0000000..94a5d9b --- /dev/null +++ b/core/simulation/cabinet_placement.gd @@ -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 diff --git a/core/simulation/cabinet_placement.gd.uid b/core/simulation/cabinet_placement.gd.uid new file mode 100644 index 0000000..0108753 --- /dev/null +++ b/core/simulation/cabinet_placement.gd.uid @@ -0,0 +1 @@ +uid://cwblyetugbr4d diff --git a/core/simulation/room_definition.gd b/core/simulation/room_definition.gd new file mode 100644 index 0000000..7ad5616 --- /dev/null +++ b/core/simulation/room_definition.gd @@ -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 diff --git a/core/simulation/room_definition.gd.uid b/core/simulation/room_definition.gd.uid new file mode 100644 index 0000000..d289e7b --- /dev/null +++ b/core/simulation/room_definition.gd.uid @@ -0,0 +1 @@ +uid://dgm2dkw5rar8i diff --git a/core/simulation/room_device_binding.gd b/core/simulation/room_device_binding.gd new file mode 100644 index 0000000..427502b --- /dev/null +++ b/core/simulation/room_device_binding.gd @@ -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() +) diff --git a/core/simulation/room_device_binding.gd.uid b/core/simulation/room_device_binding.gd.uid new file mode 100644 index 0000000..2e67104 --- /dev/null +++ b/core/simulation/room_device_binding.gd.uid @@ -0,0 +1 @@ +uid://r2rpbasss511 diff --git a/core/simulation/room_device_definition.gd b/core/simulation/room_device_definition.gd new file mode 100644 index 0000000..73a37a3 --- /dev/null +++ b/core/simulation/room_device_definition.gd @@ -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 diff --git a/core/simulation/room_device_definition.gd.uid b/core/simulation/room_device_definition.gd.uid new file mode 100644 index 0000000..a9b87a7 --- /dev/null +++ b/core/simulation/room_device_definition.gd.uid @@ -0,0 +1 @@ +uid://ejum18yr7xeo diff --git a/core/simulation/room_edit_session.gd b/core/simulation/room_edit_session.gd new file mode 100644 index 0000000..cd96f66 --- /dev/null +++ b/core/simulation/room_edit_session.gd @@ -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 + ) diff --git a/core/simulation/room_edit_session.gd.uid b/core/simulation/room_edit_session.gd.uid new file mode 100644 index 0000000..24bc7f2 --- /dev/null +++ b/core/simulation/room_edit_session.gd.uid @@ -0,0 +1 @@ +uid://w0en0xus0g5j diff --git a/core/simulation/room_simulator.gd b/core/simulation/room_simulator.gd new file mode 100644 index 0000000..ca8306f --- /dev/null +++ b/core/simulation/room_simulator.gd @@ -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 + ) diff --git a/core/simulation/room_simulator.gd.uid b/core/simulation/room_simulator.gd.uid new file mode 100644 index 0000000..23f90dc --- /dev/null +++ b/core/simulation/room_simulator.gd.uid @@ -0,0 +1 @@ +uid://bfb7sad2mjnlf diff --git a/core/simulation/simulated_hardware_backend.gd b/core/simulation/simulated_hardware_backend.gd new file mode 100644 index 0000000..502963e --- /dev/null +++ b/core/simulation/simulated_hardware_backend.gd @@ -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 + ) diff --git a/core/simulation/simulated_hardware_backend.gd.uid b/core/simulation/simulated_hardware_backend.gd.uid new file mode 100644 index 0000000..f7f904e --- /dev/null +++ b/core/simulation/simulated_hardware_backend.gd.uid @@ -0,0 +1 @@ +uid://b3xyoxcebkhoc diff --git a/core/simulation/test_sequence.gd b/core/simulation/test_sequence.gd new file mode 100644 index 0000000..a0a5334 --- /dev/null +++ b/core/simulation/test_sequence.gd @@ -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 diff --git a/core/simulation/test_sequence.gd.uid b/core/simulation/test_sequence.gd.uid new file mode 100644 index 0000000..0f42249 --- /dev/null +++ b/core/simulation/test_sequence.gd.uid @@ -0,0 +1 @@ +uid://clbm4alwyibrd diff --git a/core/simulation/test_sequence_stop.gd b/core/simulation/test_sequence_stop.gd new file mode 100644 index 0000000..3742948 --- /dev/null +++ b/core/simulation/test_sequence_stop.gd @@ -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 = "" diff --git a/core/simulation/test_sequence_stop.gd.uid b/core/simulation/test_sequence_stop.gd.uid new file mode 100644 index 0000000..7fbacc2 --- /dev/null +++ b/core/simulation/test_sequence_stop.gd.uid @@ -0,0 +1 @@ +uid://c5022gsswr487 diff --git a/data/cabinets/cabinet_01.tres b/data/cabinets/cabinet_01.tres new file mode 100644 index 0000000..af3c88c --- /dev/null +++ b/data/cabinets/cabinet_01.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="CabinetProfile" format=3 uid="uid://cl4j1vtv388pt"] + +[ext_resource type="Resource" uid="uid://cxna0oimf8mop" path="res://data/cabinets/cabinet_01_hardware.tres" id="1_b4cnm"] +[ext_resource type="Texture2D" uid="uid://cistuspr5bf8h" path="res://art/devices/tex_cabinet.png" id="1_hv02u"] +[ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="1_v1x8f"] + +[resource] +script = ExtResource("1_v1x8f") +cabinet_id = &"cabinet_01" +display_name = "Cabinet 01" +capabilities = Array[StringName]([&"joystick_4way", &"button_1", &"start_button", &"joystick_horizontal"]) +hardware_config = ExtResource("1_b4cnm") +footprint_width_m = 0.9144000000000001 +footprint_depth_m = 0.9144000000000001 +height_m = 1.8288000000000002 +footprint_width_ft = 3.0 +footprint_depth_ft = 3.0 +height_ft = 6.0 +editor_texture = ExtResource("1_hv02u") +metadata/_custom_type_script = "uid://c0jj5j5loqsma" diff --git a/data/cabinets/cabinet_01_hardware.tres b/data/cabinets/cabinet_01_hardware.tres new file mode 100644 index 0000000..0d3aefb --- /dev/null +++ b/data/cabinets/cabinet_01_hardware.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="HardwareConfig" format=3 uid="uid://cxna0oimf8mop"] + +[ext_resource type="Script" uid="uid://c7eu1n5yey1x4" path="res://core/hardware/hardware_action.gd" id="1_w3p60"] +[ext_resource type="Script" uid="uid://60whm57itpjb" path="res://core/hardware/hardware_config.gd" id="2_s5has"] + +[sub_resource type="Resource" id="Resource_ogdj5"] +script = ExtResource("1_w3p60") +action_id = &"coin_release" +display_name = "Coin Release" +pulse_duration_ms = 600 +metadata/_custom_type_script = "uid://c7eu1n5yey1x4" + +[sub_resource type="Resource" id="Resource_gu74o"] +script = ExtResource("1_w3p60") +action_id = &"start_light" +display_name = "Start Light" +gpio_pin = 20 +metadata/_custom_type_script = "uid://c7eu1n5yey1x4" + +[resource] +script = ExtResource("2_s5has") +controller_id = &"cabinet_01_gpio" +actions = Array[ExtResource("1_w3p60")]([SubResource("Resource_ogdj5"), SubResource("Resource_gu74o")]) +metadata/_custom_type_script = "uid://60whm57itpjb" diff --git a/data/cabinets/cabinet_02.tres b/data/cabinets/cabinet_02.tres new file mode 100644 index 0000000..6031849 --- /dev/null +++ b/data/cabinets/cabinet_02.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="CabinetProfile" format=3 uid="uid://d1gyyfaaclas8"] + +[ext_resource type="Resource" uid="uid://ddv64yg3h5vsi" path="res://data/cabinets/cabinet_02_hardware.tres" id="1_bfw68"] +[ext_resource type="Texture2D" uid="uid://cistuspr5bf8h" path="res://art/devices/tex_cabinet.png" id="1_lrfm7"] +[ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="2_lrfm7"] + +[resource] +script = ExtResource("2_lrfm7") +cabinet_id = &"cabinet_02" +display_name = "Cabinet 02" +capabilities = Array[StringName]([&"joystick_4way", &"button_1", &"start_button", &"joystick_horizontal"]) +hardware_config = ExtResource("1_bfw68") +footprint_width_m = 0.9144000000000001 +footprint_depth_m = 0.9144000000000001 +height_m = 1.8288000000000002 +footprint_width_ft = 3.0 +footprint_depth_ft = 3.0 +height_ft = 6.0 +editor_texture = ExtResource("1_lrfm7") +metadata/_custom_type_script = "uid://c0jj5j5loqsma" diff --git a/data/cabinets/cabinet_02_hardware.tres b/data/cabinets/cabinet_02_hardware.tres new file mode 100644 index 0000000..abde4a9 --- /dev/null +++ b/data/cabinets/cabinet_02_hardware.tres @@ -0,0 +1,33 @@ +[gd_resource type="Resource" script_class="HardwareConfig" format=3 uid="uid://ddv64yg3h5vsi"] + +[ext_resource type="Script" uid="uid://c7eu1n5yey1x4" path="res://core/hardware/hardware_action.gd" id="1_dasqc"] +[ext_resource type="Script" uid="uid://60whm57itpjb" path="res://core/hardware/hardware_config.gd" id="2_ye2fg"] + +[sub_resource type="Resource" id="Resource_ogdj5"] +script = ExtResource("1_dasqc") +action_id = &"coin_release" +display_name = "Coin Release" +gpio_pin = 27 +pulse_duration_ms = 400 +metadata/_custom_type_script = "uid://c7eu1n5yey1x4" + +[sub_resource type="Resource" id="Resource_dasqc"] +script = ExtResource("1_dasqc") +action_id = &"start_light" +display_name = "Start Light" +gpio_pin = 20 +metadata/_custom_type_script = "uid://c7eu1n5yey1x4" + +[sub_resource type="Resource" id="Resource_t81ly"] +script = ExtResource("1_dasqc") +action_id = &"glitch_code" +display_name = "Glitch Code" +gpio_pin = 21 +pulse_duration_ms = 1200 +metadata/_custom_type_script = "uid://c7eu1n5yey1x4" + +[resource] +script = ExtResource("2_ye2fg") +controller_id = &"cabinet_02_gpio" +actions = Array[ExtResource("1_dasqc")]([SubResource("Resource_ogdj5"), SubResource("Resource_dasqc"), SubResource("Resource_t81ly")]) +metadata/_custom_type_script = "uid://60whm57itpjb" diff --git a/data/cabinets/cabinet_03.tres b/data/cabinets/cabinet_03.tres new file mode 100644 index 0000000..a6c75e1 --- /dev/null +++ b/data/cabinets/cabinet_03.tres @@ -0,0 +1,15 @@ +[gd_resource type="Resource" script_class="CabinetProfile" format=3 uid="uid://d4ns05ptr4ybw"] + +[ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="1_0s4iw"] +[ext_resource type="Resource" uid="uid://4t6n8gvu717o" path="res://data/cabinets/cabinet_03_hardware.tres" id="1_ojmvu"] + +[resource] +script = ExtResource("1_0s4iw") +cabinet_id = &"cabinet_03" +display_name = "Cabinet 03" +capabilities = Array[StringName]([&"spinner", &"button_1", &"start_button"]) +hardware_config = ExtResource("1_ojmvu") +footprint_width_m = 1.0 +footprint_depth_m = 1.0 +height_m = 2.0 +metadata/_custom_type_script = "uid://c0jj5j5loqsma" diff --git a/data/cabinets/cabinet_03_hardware.tres b/data/cabinets/cabinet_03_hardware.tres new file mode 100644 index 0000000..c414537 --- /dev/null +++ b/data/cabinets/cabinet_03_hardware.tres @@ -0,0 +1,18 @@ +[gd_resource type="Resource" script_class="HardwareConfig" format=3 uid="uid://4t6n8gvu717o"] + +[ext_resource type="Script" uid="uid://c7eu1n5yey1x4" path="res://core/hardware/hardware_action.gd" id="1_2vvyl"] +[ext_resource type="Script" uid="uid://60whm57itpjb" path="res://core/hardware/hardware_config.gd" id="2_hx0bc"] + +[sub_resource type="Resource" id="Resource_ogdj5"] +script = ExtResource("1_2vvyl") +action_id = &"coin_release" +display_name = "Coin Release" +gpio_pin = 27 +pulse_duration_ms = 400 +metadata/_custom_type_script = "uid://c7eu1n5yey1x4" + +[resource] +script = ExtResource("2_hx0bc") +controller_id = &"cabinet_02_gpio" +actions = Array[ExtResource("1_2vvyl")]([SubResource("Resource_ogdj5")]) +metadata/_custom_type_script = "uid://60whm57itpjb" diff --git a/data/devices/coin_release_a.tres b/data/devices/coin_release_a.tres new file mode 100644 index 0000000..061b92b --- /dev/null +++ b/data/devices/coin_release_a.tres @@ -0,0 +1,7 @@ +[gd_resource type="Resource" script_class="RoomDeviceDefinition" format=3 uid="uid://b5f2nynxh0ul2"] + +[ext_resource type="Script" uid="uid://ejum18yr7xeo" path="res://core/simulation/room_device_definition.gd" id="1_mqpli"] + +[resource] +script = ExtResource("1_mqpli") +metadata/_custom_type_script = "uid://ejum18yr7xeo" diff --git a/data/editor/buttonGroup.tres b/data/editor/buttonGroup.tres new file mode 100644 index 0000000..4da5976 --- /dev/null +++ b/data/editor/buttonGroup.tres @@ -0,0 +1,3 @@ +[gd_resource type="ButtonGroup" format=3 uid="uid://cly57dx12c8dg"] + +[resource] diff --git a/data/game_events/game_lost.tres b/data/game_events/game_lost.tres new file mode 100644 index 0000000..26f8c36 --- /dev/null +++ b/data/game_events/game_lost.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="GameEventDefinition" format=3 uid="uid://bfr1jcppwfl3y"] + +[ext_resource type="Script" uid="uid://byegr76ey4613" path="res://core/games/game_event_definition.gd" id="1_c8820"] + +[resource] +script = ExtResource("1_c8820") +event_id = &"game_lost" +display_name = "Game Lost" +metadata/_custom_type_script = "uid://byegr76ey4613" diff --git a/data/game_events/game_reset.tres b/data/game_events/game_reset.tres new file mode 100644 index 0000000..69034b2 --- /dev/null +++ b/data/game_events/game_reset.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="GameEventDefinition" format=3 uid="uid://c16gsr3mhf8kt"] + +[ext_resource type="Script" uid="uid://byegr76ey4613" path="res://core/games/game_event_definition.gd" id="1_ud73p"] + +[resource] +script = ExtResource("1_ud73p") +event_id = &"game_reset" +display_name = "Game Reset" +metadata/_custom_type_script = "uid://byegr76ey4613" diff --git a/data/game_events/game_started.tres b/data/game_events/game_started.tres new file mode 100644 index 0000000..6e1d9dc --- /dev/null +++ b/data/game_events/game_started.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="GameEventDefinition" format=3 uid="uid://dnpg3vgcuutmb"] + +[ext_resource type="Script" uid="uid://byegr76ey4613" path="res://core/games/game_event_definition.gd" id="1_yxh1o"] + +[resource] +script = ExtResource("1_yxh1o") +event_id = &"game_started" +display_name = "Game Started" +metadata/_custom_type_script = "uid://byegr76ey4613" diff --git a/data/game_events/game_won.tres b/data/game_events/game_won.tres new file mode 100644 index 0000000..8e51989 --- /dev/null +++ b/data/game_events/game_won.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="GameEventDefinition" format=3 uid="uid://djimuypcf66vn"] + +[ext_resource type="Script" uid="uid://byegr76ey4613" path="res://core/games/game_event_definition.gd" id="1_jg0s2"] + +[resource] +script = ExtResource("1_jg0s2") +event_id = &"game_won" +display_name = "Game Won" +metadata/_custom_type_script = "uid://byegr76ey4613" diff --git a/data/games/frogger.tres b/data/games/frogger.tres new file mode 100644 index 0000000..e104f6a --- /dev/null +++ b/data/games/frogger.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="GameDefinition" format=3] + +[ext_resource type="PackedScene" path="res://games/frogger/Frogger.tscn" id="1_bn3jg"] +[ext_resource type="Script" path="res://core/games/game_event_definition.gd" id="1_kd6b3"] +[ext_resource type="Script" path="res://core/games/game_definition.gd" id="1_mpx30"] +[ext_resource type="Resource" path="res://data/game_events/game_started.tres" id="2_q2a4n"] +[ext_resource type="Resource" path="res://data/game_events/game_won.tres" id="3_otabs"] +[ext_resource type="Resource" path="res://data/game_events/game_lost.tres" id="4_x4djy"] +[ext_resource type="Resource" path="res://data/game_events/game_reset.tres" id="5_madm2"] + +[resource] +script = ExtResource("1_mpx30") +game_id = &"frogger" +display_name = "Frogger" +scene = ExtResource("1_bn3jg") +required_capabilities = Array[StringName]([&"joystick_4way", &"start_button"]) +events = Array[ExtResource("1_kd6b3")]([ExtResource("2_q2a4n"), ExtResource("3_otabs"), ExtResource("4_x4djy"), ExtResource("5_madm2")]) + + diff --git a/data/games/galaga.tres b/data/games/galaga.tres new file mode 100644 index 0000000..2346acc --- /dev/null +++ b/data/games/galaga.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="GameDefinition" format=3 uid="uid://cnfhiky5834pw"] + +[ext_resource type="Script" uid="uid://o538v0kdvs0j" path="res://core/games/game_definition.gd" id="1_0vgic"] +[ext_resource type="Script" uid="uid://byegr76ey4613" path="res://core/games/game_event_definition.gd" id="1_aoerb"] +[ext_resource type="PackedScene" uid="uid://byorfa7yxhhrv" path="res://games/galaga/Galaga.tscn" id="1_wh674"] +[ext_resource type="Resource" uid="uid://dnpg3vgcuutmb" path="res://data/game_events/game_started.tres" id="2_ujnca"] +[ext_resource type="Resource" uid="uid://djimuypcf66vn" path="res://data/game_events/game_won.tres" id="3_0fx7n"] +[ext_resource type="Resource" uid="uid://bfr1jcppwfl3y" path="res://data/game_events/game_lost.tres" id="4_m6ahh"] +[ext_resource type="Resource" uid="uid://c16gsr3mhf8kt" path="res://data/game_events/game_reset.tres" id="5_5dr5h"] + +[sub_resource type="Resource" id="Resource_fsaru"] +script = ExtResource("1_aoerb") +event_id = &"game_activated" +display_name = "Game Activated" +metadata/_custom_type_script = "uid://byegr76ey4613" + +[resource] +script = ExtResource("1_0vgic") +game_id = &"galaga" +display_name = "Galaga" +scene = ExtResource("1_wh674") +required_capabilities = Array[StringName]([&"joystick_horizontal", &"button_1", &"start_button"]) +events = Array[ExtResource("1_aoerb")]([ExtResource("2_ujnca"), ExtResource("3_0fx7n"), ExtResource("4_m6ahh"), ExtResource("5_5dr5h"), SubResource("Resource_fsaru")]) +metadata/_custom_type_script = "uid://o538v0kdvs0j" diff --git a/data/games/missile_command.tres b/data/games/missile_command.tres new file mode 100644 index 0000000..38aead2 --- /dev/null +++ b/data/games/missile_command.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="GameDefinition" load_steps=8 format=3] +[ext_resource type="Script" path="res://core/games/game_definition.gd" id="1"] +[ext_resource type="Script" path="res://core/games/game_event_definition.gd" id="2"] +[ext_resource type="PackedScene" path="res://games/missile_command/MissileCommand.tscn" id="3"] +[ext_resource type="Resource" path="res://data/game_events/game_started.tres" id="4"] +[ext_resource type="Resource" path="res://data/game_events/game_won.tres" id="5"] +[ext_resource type="Resource" path="res://data/game_events/game_lost.tres" id="6"] +[ext_resource type="Resource" path="res://data/game_events/game_reset.tres" id="7"] +[resource] +script = ExtResource("1") +game_id = &"missile_command" +display_name = "Missile Command" +scene = ExtResource("3") +required_capabilities = Array[StringName]([&"trackball", &"button_1", &"button_2", &"button_3", &"start_button"]) +events = Array[ExtResource("2")]([ExtResource("4"), ExtResource("5"), ExtResource("6"), ExtResource("7")]) + diff --git a/data/games/pacman.tres b/data/games/pacman.tres new file mode 100644 index 0000000..ef56662 --- /dev/null +++ b/data/games/pacman.tres @@ -0,0 +1,18 @@ +[gd_resource type="Resource" script_class="GameDefinition" format=3 uid="uid://c3v8o4nubjakg"] + +[ext_resource type="PackedScene" uid="uid://y0xg0i8yacqd" path="res://games/pacman/Pacman.tscn" id="1_bn3jg"] +[ext_resource type="Script" uid="uid://byegr76ey4613" path="res://core/games/game_event_definition.gd" id="1_kd6b3"] +[ext_resource type="Script" uid="uid://o538v0kdvs0j" path="res://core/games/game_definition.gd" id="1_mpx30"] +[ext_resource type="Resource" uid="uid://dnpg3vgcuutmb" path="res://data/game_events/game_started.tres" id="2_q2a4n"] +[ext_resource type="Resource" uid="uid://djimuypcf66vn" path="res://data/game_events/game_won.tres" id="3_otabs"] +[ext_resource type="Resource" uid="uid://bfr1jcppwfl3y" path="res://data/game_events/game_lost.tres" id="4_x4djy"] +[ext_resource type="Resource" uid="uid://c16gsr3mhf8kt" path="res://data/game_events/game_reset.tres" id="5_madm2"] + +[resource] +script = ExtResource("1_mpx30") +game_id = &"pacman" +display_name = "Pac-Man" +scene = ExtResource("1_bn3jg") +required_capabilities = Array[StringName]([&"joystick_4way", &"start_button"]) +events = Array[ExtResource("1_kd6b3")]([ExtResource("2_q2a4n"), ExtResource("3_otabs"), ExtResource("4_x4djy"), ExtResource("5_madm2")]) +metadata/_custom_type_script = "uid://o538v0kdvs0j" diff --git a/data/games/robotron.tres b/data/games/robotron.tres new file mode 100644 index 0000000..d3fe08f --- /dev/null +++ b/data/games/robotron.tres @@ -0,0 +1,18 @@ +[gd_resource type="Resource" script_class="GameDefinition" format=3 uid="uid://bn8vgu5vrevgx"] + +[ext_resource type="Script" uid="uid://o538v0kdvs0j" path="res://core/games/game_definition.gd" id="1"] +[ext_resource type="Script" uid="uid://byegr76ey4613" path="res://core/games/game_event_definition.gd" id="2"] +[ext_resource type="PackedScene" uid="uid://bbe38558vhtp1" path="res://games/robotron/Robotron.tscn" id="3"] +[ext_resource type="Resource" uid="uid://dnpg3vgcuutmb" path="res://data/game_events/game_started.tres" id="4"] +[ext_resource type="Resource" uid="uid://djimuypcf66vn" path="res://data/game_events/game_won.tres" id="5"] +[ext_resource type="Resource" uid="uid://bfr1jcppwfl3y" path="res://data/game_events/game_lost.tres" id="6"] +[ext_resource type="Resource" uid="uid://c16gsr3mhf8kt" path="res://data/game_events/game_reset.tres" id="7"] + +[resource] +script = ExtResource("1") +game_id = &"robotron" +display_name = "Robotron 2084" +scene = ExtResource("3") +required_capabilities = Array[StringName]([&"start_button"]) +control_alternatives = Array[PackedStringArray]([PackedStringArray("dual_joystick_8way"), PackedStringArray("joystick_8way", "button_1", "button_2", "button_3", "button_4")]) +events = Array[ExtResource("2")]([ExtResource("4"), ExtResource("5"), ExtResource("6"), ExtResource("7")]) diff --git a/data/games/space_invaders.tres b/data/games/space_invaders.tres new file mode 100644 index 0000000..1a71d77 --- /dev/null +++ b/data/games/space_invaders.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="GameDefinition" format=3 uid="uid://yiejlr5h143m"] + +[ext_resource type="Script" uid="uid://o538v0kdvs0j" path="res://core/games/game_definition.gd" id="1"] +[ext_resource type="Script" uid="uid://byegr76ey4613" path="res://core/games/game_event_definition.gd" id="2"] +[ext_resource type="PackedScene" uid="uid://75kpoxbn0gv7" path="res://games/space_invaders/SpaceInvaders.tscn" id="3"] +[ext_resource type="Resource" uid="uid://dnpg3vgcuutmb" path="res://data/game_events/game_started.tres" id="4"] +[ext_resource type="Resource" uid="uid://djimuypcf66vn" path="res://data/game_events/game_won.tres" id="5"] +[ext_resource type="Resource" uid="uid://bfr1jcppwfl3y" path="res://data/game_events/game_lost.tres" id="6"] +[ext_resource type="Resource" uid="uid://c16gsr3mhf8kt" path="res://data/game_events/game_reset.tres" id="7"] + +[resource] +script = ExtResource("1") +game_id = &"space_invaders" +display_name = "Space Invaders" +scene = ExtResource("3") +required_capabilities = Array[StringName]([&"joystick_horizontal", &"button_1", &"start_button"]) +events = Array[ExtResource("2")]([ExtResource("4"), ExtResource("5"), ExtResource("6"), ExtResource("7")]) diff --git a/data/games/tempest.tres b/data/games/tempest.tres new file mode 100644 index 0000000..21eefe5 --- /dev/null +++ b/data/games/tempest.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="GameDefinition" load_steps=8 format=3] +[ext_resource type="Script" path="res://core/games/game_definition.gd" id="1"] +[ext_resource type="Script" path="res://core/games/game_event_definition.gd" id="2"] +[ext_resource type="PackedScene" path="res://games/tempest/Tempest.tscn" id="3"] +[ext_resource type="Resource" path="res://data/game_events/game_started.tres" id="4"] +[ext_resource type="Resource" path="res://data/game_events/game_won.tres" id="5"] +[ext_resource type="Resource" path="res://data/game_events/game_lost.tres" id="6"] +[ext_resource type="Resource" path="res://data/game_events/game_reset.tres" id="7"] +[resource] +script = ExtResource("1") +game_id = &"tempest" +display_name = "Tempest" +scene = ExtResource("3") +required_capabilities = Array[StringName]([&"button_1", &"button_2", &"start_button"]) +control_alternatives = Array[PackedStringArray]([PackedStringArray("joystick_horizontal"), PackedStringArray("spinner"), PackedStringArray("trackball")]) +events = Array[ExtResource("2")]([ExtResource("4"), ExtResource("5"), ExtResource("6"), ExtResource("7")]) diff --git a/data/project_database.tres b/data/project_database.tres new file mode 100644 index 0000000..3c25818 --- /dev/null +++ b/data/project_database.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="ProjectDatabase" format=3 uid="uid://8qqlypjp3hd4"] + +[ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="1_uu70x"] +[ext_resource type="Script" uid="uid://o538v0kdvs0j" path="res://core/games/game_definition.gd" id="2_an0il"] +[ext_resource type="Resource" uid="uid://cl4j1vtv388pt" path="res://data/cabinets/cabinet_01.tres" id="2_t6ujr"] +[ext_resource type="Resource" uid="uid://d1gyyfaaclas8" path="res://data/cabinets/cabinet_02.tres" id="3_bfrqh"] +[ext_resource type="Script" uid="uid://d3pt6qa05kcnl" path="res://core/scenarios/scenario_definition.gd" id="3_uw7gr"] +[ext_resource type="Script" uid="uid://diqnrxrhwx2ug" path="res://core/runtime/project_database.gd" id="4_algy0"] +[ext_resource type="Resource" uid="uid://c3v8o4nubjakg" path="res://data/games/pacman.tres" id="4_bfrqh"] +[ext_resource type="Resource" uid="uid://d4ns05ptr4ybw" path="res://data/cabinets/cabinet_03.tres" id="4_gbm0a"] +[ext_resource type="Resource" uid="uid://cnfhiky5834pw" path="res://data/games/galaga.tres" id="5_b427t"] +[ext_resource type="Resource" uid="uid://cqn58n5ahydil" path="res://data/scenarios/proof_of_concept.tres" id="7_gbm0a"] +[ext_resource type="Resource" path="res://data/games/tempest.tres" id="8_0ke0w"] +[ext_resource type="Resource" uid="uid://tp7cvbnbhcrj" path="res://data/scenarios/galaga_proof_of_concept.tres" id="8_ib4xl"] +[ext_resource type="Resource" path="res://data/games/missile_command.tres" id="9_0ke0w"] +[ext_resource type="Resource" path="res://data/games/frogger.tres" id="10_8pm0t"] +[ext_resource type="Resource" uid="uid://c07wbog147dml" path="res://data/scenarios/two_cabinet_test.tres" id="10_b427t"] +[ext_resource type="Resource" uid="uid://yiejlr5h143m" path="res://data/games/space_invaders.tres" id="11_space_invaders"] +[ext_resource type="Resource" path="res://data/games/robotron.tres" id="12_robotron"] + +[resource] +script = ExtResource("4_algy0") +scenarios = Array[ExtResource("3_uw7gr")]([ExtResource("7_gbm0a"), ExtResource("8_ib4xl"), ExtResource("10_b427t")]) +cabinets = Array[ExtResource("1_uu70x")]([ExtResource("2_t6ujr"), ExtResource("3_bfrqh"), ExtResource("4_gbm0a")]) +games = Array[ExtResource("2_an0il")]([ExtResource("4_bfrqh"), ExtResource("5_b427t"), ExtResource("8_0ke0w"), ExtResource("9_0ke0w"), ExtResource("10_8pm0t"), ExtResource("11_space_invaders"), ExtResource("12_robotron")]) +metadata/_custom_type_script = "uid://diqnrxrhwx2ug" diff --git a/data/rooms/arcade_room.tres b/data/rooms/arcade_room.tres new file mode 100644 index 0000000..2b9743a --- /dev/null +++ b/data/rooms/arcade_room.tres @@ -0,0 +1,165 @@ +[gd_resource type="Resource" script_class="RoomDefinition" format=3 uid="uid://dh5basntm1etd"] + +[ext_resource type="Script" uid="uid://r2rpbasss511" path="res://core/simulation/room_device_binding.gd" id="1_icsgi"] +[ext_resource type="Script" uid="uid://cwblyetugbr4d" path="res://core/simulation/cabinet_placement.gd" id="2_384hm"] +[ext_resource type="Script" uid="uid://ejum18yr7xeo" path="res://core/simulation/room_device_definition.gd" id="2_hg7bw"] +[ext_resource type="Script" uid="uid://dgm2dkw5rar8i" path="res://core/simulation/room_definition.gd" id="3_384hm"] +[ext_resource type="Texture2D" uid="uid://b7i278fi27mbh" path="res://art/devices/tex_coin_release.png" id="4_12s2r"] +[ext_resource type="Script" uid="uid://cp40t70asl4p0" path="res://core/rooms/room_logic_rule.gd" id="4_t81ly"] +[ext_resource type="Script" uid="uid://dl7ydh8g6877n" path="res://core/rooms/room_wall_definition.gd" id="5_384hm"] +[ext_resource type="Texture2D" uid="uid://b7piqhhrvttw8" path="res://art/devices/tex_light.png" id="5_vv75m"] + +[sub_resource type="Resource" id="Resource_icsgi"] +script = ExtResource("1_icsgi") +cabinet_id = &"cabinet_01" +hardware_action_id = &"coin_release" +device_id = &"coin_release_a" +route_points = PackedVector2Array() +metadata/_custom_type_script = "uid://r2rpbasss511" + +[sub_resource type="Resource" id="Resource_hg7bw"] +script = ExtResource("1_icsgi") +cabinet_id = &"cabinet_02" +hardware_action_id = &"coin_release" +device_id = &"coin_release_b" +route_points = PackedVector2Array() +metadata/_custom_type_script = "uid://r2rpbasss511" + +[sub_resource type="Resource" id="Resource_384hm"] +script = ExtResource("1_icsgi") +cabinet_id = &"cabinet_01" +hardware_action_id = &"start_light" +device_id = &"light_a" +route_points = PackedVector2Array() +metadata/_custom_type_script = "uid://r2rpbasss511" + +[sub_resource type="Resource" id="Resource_t81ly"] +script = ExtResource("1_icsgi") +cabinet_id = &"cabinet_02" +hardware_action_id = &"start_light" +device_id = &"light_b" +route_points = PackedVector2Array() +metadata/_custom_type_script = "uid://r2rpbasss511" + +[sub_resource type="Resource" id="Resource_clsof"] +script = ExtResource("1_icsgi") +cabinet_id = &"cabinet_02" +hardware_action_id = &"glitch_code" +device_id = &"glitch_code" +route_points = PackedVector2Array(-3.5252714, -0.4624894, -2.2890172, -0.45565927, -2.2890172, 1.6343619) +metadata/_custom_type_script = "uid://r2rpbasss511" + +[sub_resource type="Resource" id="Resource_prnev"] +script = ExtResource("2_384hm") +cabinet_id = &"cabinet_01" +position = Vector2(-5.9484997, 0.14750004) +metadata/_custom_type_script = "uid://cwblyetugbr4d" + +[sub_resource type="Resource" id="Resource_dicmx"] +script = ExtResource("2_384hm") +cabinet_id = &"cabinet_02" +position = Vector2(-3.5153918, 0.14274521) +metadata/_custom_type_script = "uid://cwblyetugbr4d" + +[sub_resource type="Resource" id="Resource_vv75m"] +script = ExtResource("2_hg7bw") +device_id = &"coin_release_a" +display_name = "Coin Release A" +device_type = 1 +position = Vector2(-6.015, 0.555) +physical_size_m = Vector2(0.2, 0.2) +physical_size_ft = Vector2(0.656168, 0.656168) +editor_texture = ExtResource("4_12s2r") +metadata/_custom_type_script = "uid://ejum18yr7xeo" + +[sub_resource type="Resource" id="Resource_12s2r"] +script = ExtResource("2_hg7bw") +device_id = &"coin_release_b" +display_name = "Coin Release B" +device_type = 1 +position = Vector2(-3.5799642, 0.5683867) +physical_size_m = Vector2(0.2, 0.2) +physical_size_ft = Vector2(0.656168, 0.656168) +editor_texture = ExtResource("4_12s2r") +metadata/_custom_type_script = "uid://ejum18yr7xeo" + +[sub_resource type="Resource" id="Resource_2f6ns"] +script = ExtResource("2_hg7bw") +device_id = &"light_a" +display_name = "Light A" +position = Vector2(-6.47, 0.56) +physical_size_m = Vector2(0.1, 0.1) +physical_size_ft = Vector2(0.328084, 0.328084) +editor_texture = ExtResource("5_vv75m") +metadata/_custom_type_script = "uid://ejum18yr7xeo" + +[sub_resource type="Resource" id="Resource_ks63c"] +script = ExtResource("2_hg7bw") +device_id = &"light_b" +display_name = "Light B" +position = Vector2(-4.032238, 0.57653165) +physical_size_m = Vector2(0.1, 0.1) +physical_size_ft = Vector2(0.328084, 0.328084) +editor_texture = ExtResource("5_vv75m") +metadata/_custom_type_script = "uid://ejum18yr7xeo" + +[sub_resource type="Resource" id="Resource_kddy2"] +script = ExtResource("2_hg7bw") +device_id = &"glitch_code" +display_name = "Glitch Code" +position = Vector2(-2.755003, 1.5123456) +physical_size_ft = Vector2(0.82021, 0.82021) +metadata/_custom_type_script = "uid://ejum18yr7xeo" + +[sub_resource type="Resource" id="Resource_mi6lf"] +script = ExtResource("4_t81ly") +rule_id = &"coin_release_to_cabinet_02" +source_id = &"coin_release_a" +event_id = &"activated" +target_id = &"cabinet_02" +action_id = &"glitch_code" +metadata/_custom_type_script = "uid://cp40t70asl4p0" + +[sub_resource type="Resource" id="Resource_s1l3b"] +script = ExtResource("5_384hm") +wall_id = &"wall_001" +start = Vector2(-7.0104, -0.3048) +end = Vector2(-2.4384, -0.3048) +thickness = 0.030480000000000004 +thickness_ft = 0.1 + +[sub_resource type="Resource" id="Resource_74jgn"] +script = ExtResource("5_384hm") +wall_id = &"wall_002" +start = Vector2(-2.4384, -0.3048) +end = Vector2(-2.4384, 7.3152) +thickness = 0.030480000000000004 +thickness_ft = 0.1 + +[sub_resource type="Resource" id="Resource_enjae"] +script = ExtResource("5_384hm") +wall_id = &"wall_003" +start = Vector2(-2.4384, 7.3152) +end = Vector2(-7.0104, 7.3152) +thickness = 0.030480000000000004 +thickness_ft = 0.1 + +[sub_resource type="Resource" id="Resource_rvtk7"] +script = ExtResource("5_384hm") +wall_id = &"wall_004" +start = Vector2(-7.0104, 7.3152) +end = Vector2(-7.0104, -0.3048) +thickness = 0.030480000000000004 +thickness_ft = 0.1 + +[resource] +script = ExtResource("3_384hm") +room_id = &"arcade_room" +display_name = "Arcade Escape Room" +cabinet_placements = Array[ExtResource("2_384hm")]([SubResource("Resource_prnev"), SubResource("Resource_dicmx")]) +devices = Array[ExtResource("2_hg7bw")]([SubResource("Resource_vv75m"), SubResource("Resource_12s2r"), SubResource("Resource_2f6ns"), SubResource("Resource_ks63c"), SubResource("Resource_kddy2")]) +bindings = Array[ExtResource("1_icsgi")]([SubResource("Resource_icsgi"), SubResource("Resource_hg7bw"), SubResource("Resource_384hm"), SubResource("Resource_t81ly"), SubResource("Resource_clsof")]) +boundary_points = PackedVector2Array(-7.3152, -0.6096, -2.1336, -0.6096, -2.1336, 7.62, -7.3152, 7.62) +walls = Array[ExtResource("5_384hm")]([SubResource("Resource_s1l3b"), SubResource("Resource_74jgn"), SubResource("Resource_enjae"), SubResource("Resource_rvtk7")]) +logic_rules = Array[ExtResource("4_t81ly")]([SubResource("Resource_mi6lf")]) +metadata/_custom_type_script = "uid://dgm2dkw5rar8i" diff --git a/data/scenarios/galaga_proof_of_concept.tres b/data/scenarios/galaga_proof_of_concept.tres new file mode 100644 index 0000000..aab4f5f --- /dev/null +++ b/data/scenarios/galaga_proof_of_concept.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="ScenarioDefinition" format=3 uid="uid://tp7cvbnbhcrj"] + +[ext_resource type="Script" uid="uid://ibujr0ervhhq" path="res://core/scenarios/scenario_assignment.gd" id="1_bmvl4"] +[ext_resource type="Resource" uid="uid://cl4j1vtv388pt" path="res://data/cabinets/cabinet_01.tres" id="2_6ve38"] +[ext_resource type="Script" uid="uid://lixrugm1v2d2" path="res://core/scenarios/scenario_event_mapping.gd" id="3_5fya7"] +[ext_resource type="Resource" uid="uid://cnfhiky5834pw" path="res://data/games/galaga.tres" id="4_bmvl4"] +[ext_resource type="Script" uid="uid://d3pt6qa05kcnl" path="res://core/scenarios/scenario_definition.gd" id="5_kf1wo"] + +[sub_resource type="Resource" id="Resource_35sju"] +script = ExtResource("3_5fya7") +game_event = &"game_won" +hardware_actions = Array[StringName]([&"coin_release"]) +metadata/_custom_type_script = "uid://lixrugm1v2d2" + +[sub_resource type="Resource" id="Resource_hcvte"] +script = ExtResource("1_bmvl4") +cabinet = ExtResource("2_6ve38") +game = ExtResource("4_bmvl4") +event_mappings = Array[ExtResource("3_5fya7")]([SubResource("Resource_35sju")]) +metadata/_custom_type_script = "uid://ibujr0ervhhq" + +[resource] +script = ExtResource("5_kf1wo") +scenario_id = &"galaga_proof_of_concept" +display_name = "Galaga Proof of Concept" +assignments = Array[ExtResource("1_bmvl4")]([SubResource("Resource_hcvte")]) +metadata/_custom_type_script = "uid://d3pt6qa05kcnl" diff --git a/data/scenarios/proof_of_concept.tres b/data/scenarios/proof_of_concept.tres new file mode 100644 index 0000000..6cab07e --- /dev/null +++ b/data/scenarios/proof_of_concept.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="ScenarioDefinition" format=3 uid="uid://cqn58n5ahydil"] + +[ext_resource type="Script" uid="uid://ibujr0ervhhq" path="res://core/scenarios/scenario_assignment.gd" id="1_kwm4o"] +[ext_resource type="Resource" uid="uid://cl4j1vtv388pt" path="res://data/cabinets/cabinet_01.tres" id="2_35sju"] +[ext_resource type="Script" uid="uid://d3pt6qa05kcnl" path="res://core/scenarios/scenario_definition.gd" id="2_adekr"] +[ext_resource type="Script" uid="uid://lixrugm1v2d2" path="res://core/scenarios/scenario_event_mapping.gd" id="3_s2uc4"] +[ext_resource type="Resource" uid="uid://c3v8o4nubjakg" path="res://data/games/pacman.tres" id="4_ikhug"] + +[sub_resource type="Resource" id="Resource_35sju"] +script = ExtResource("3_s2uc4") +game_event = &"game_won" +hardware_actions = Array[StringName]([&"coin_release"]) +metadata/_custom_type_script = "uid://lixrugm1v2d2" + +[sub_resource type="Resource" id="Resource_hcvte"] +script = ExtResource("1_kwm4o") +cabinet = ExtResource("2_35sju") +game = ExtResource("4_ikhug") +event_mappings = Array[ExtResource("3_s2uc4")]([SubResource("Resource_35sju")]) +metadata/_custom_type_script = "uid://ibujr0ervhhq" + +[resource] +script = ExtResource("2_adekr") +scenario_id = &"proof_of_concept" +display_name = "Proof of Concept" +assignments = Array[ExtResource("1_kwm4o")]([SubResource("Resource_hcvte")]) +metadata/_custom_type_script = "uid://d3pt6qa05kcnl" diff --git a/data/scenarios/two_cabinet_test.tres b/data/scenarios/two_cabinet_test.tres new file mode 100644 index 0000000..842e951 --- /dev/null +++ b/data/scenarios/two_cabinet_test.tres @@ -0,0 +1,56 @@ +[gd_resource type="Resource" script_class="ScenarioDefinition" format=3 uid="uid://c07wbog147dml"] + +[ext_resource type="Script" uid="uid://ibujr0ervhhq" path="res://core/scenarios/scenario_assignment.gd" id="1_5cmdd"] +[ext_resource type="Resource" uid="uid://cl4j1vtv388pt" path="res://data/cabinets/cabinet_01.tres" id="2_cr7dd"] +[ext_resource type="Script" uid="uid://d3pt6qa05kcnl" path="res://core/scenarios/scenario_definition.gd" id="2_dpskr"] +[ext_resource type="Script" uid="uid://lixrugm1v2d2" path="res://core/scenarios/scenario_event_mapping.gd" id="3_30g8e"] +[ext_resource type="Resource" uid="uid://c3v8o4nubjakg" path="res://data/games/pacman.tres" id="4_2f6ns"] +[ext_resource type="Resource" uid="uid://d1gyyfaaclas8" path="res://data/cabinets/cabinet_02.tres" id="5_2f6ns"] +[ext_resource type="Resource" uid="uid://cnfhiky5834pw" path="res://data/games/galaga.tres" id="6_ks63c"] + +[sub_resource type="Resource" id="Resource_2f6ns"] +script = ExtResource("3_30g8e") +game_event = &"game_won" +hardware_actions = Array[StringName]([&"coin_release"]) + +[sub_resource type="Resource" id="Resource_mqta7"] +script = ExtResource("3_30g8e") +game_event = &"game_started" +hardware_actions = Array[StringName]([&"start_light"]) + +[sub_resource type="Resource" id="Resource_6bgvn"] +script = ExtResource("1_5cmdd") +cabinet = ExtResource("2_cr7dd") +game = ExtResource("4_2f6ns") +event_mappings = Array[ExtResource("3_30g8e")]([SubResource("Resource_2f6ns"), SubResource("Resource_mqta7")]) +metadata/_custom_type_script = "uid://ibujr0ervhhq" + +[sub_resource type="Resource" id="Resource_e4xp4"] +script = ExtResource("3_30g8e") +game_event = &"game_won" +hardware_actions = Array[StringName]([&"coin_release"]) + +[sub_resource type="Resource" id="Resource_meq6s"] +script = ExtResource("3_30g8e") +game_event = &"game_started" +hardware_actions = Array[StringName]([&"start_light"]) + +[sub_resource type="Resource" id="Resource_5kcm4"] +script = ExtResource("3_30g8e") +game_event = &"game_activated" +hardware_actions = Array[StringName]([&"glitch_code"]) +metadata/_custom_type_script = "uid://lixrugm1v2d2" + +[sub_resource type="Resource" id="Resource_ks63c"] +script = ExtResource("1_5cmdd") +cabinet = ExtResource("5_2f6ns") +game = ExtResource("6_ks63c") +event_mappings = Array[ExtResource("3_30g8e")]([SubResource("Resource_e4xp4"), SubResource("Resource_meq6s"), SubResource("Resource_5kcm4")]) +metadata/_custom_type_script = "uid://ibujr0ervhhq" + +[resource] +script = ExtResource("2_dpskr") +scenario_id = &"two_cabinet_test" +display_name = "Two Cabinet Test" +assignments = Array[ExtResource("1_5cmdd")]([SubResource("Resource_6bgvn"), SubResource("Resource_ks63c")]) +metadata/_custom_type_script = "uid://d3pt6qa05kcnl" diff --git a/data/test_sequences/two_cabinet_rehearsal.tres b/data/test_sequences/two_cabinet_rehearsal.tres new file mode 100644 index 0000000..7b0c74c --- /dev/null +++ b/data/test_sequences/two_cabinet_rehearsal.tres @@ -0,0 +1,28 @@ +[gd_resource type="Resource" script_class="TestSequence" format=3 uid="uid://dwp3puv7hedyc"] + +[ext_resource type="Script" uid="uid://clbm4alwyibrd" path="res://core/simulation/test_sequence.gd" id="1_gosw1"] +[ext_resource type="Script" uid="uid://c5022gsswr487" path="res://core/simulation/test_sequence_stop.gd" id="2_3566u"] + +[sub_resource type="Resource" id="Resource_wi8aj"] +script = ExtResource("2_3566u") +source_id = &"cabinet_01" +event_id = &"game_started" +note = "Start Cabinet 1" +metadata/_custom_type_script = "uid://c5022gsswr487" + +[sub_resource type="Resource" id="Resource_rn5ip"] +script = ExtResource("2_3566u") +time_offset_seconds = 2.0 +source_id = &"cabinet_01" +event_id = &"game_won" +note = "Win cabinet 1" +metadata/_custom_type_script = "uid://c5022gsswr487" + +[resource] +script = ExtResource("1_gosw1") +sequence_id = &"two_cabinet_rehearsal" +display_name = "Two Cabinet Rehearsal" +scenario_id = &"two_cabinet_test" +description = "Starts Cabinet 1, then wins Cabinet 1 and verifies the downstream Cabinet 2 chain." +steps = Array[ExtResource("2_3566u")]([SubResource("Resource_wi8aj"), SubResource("Resource_rn5ip")]) +metadata/_custom_type_script = "uid://clbm4alwyibrd" diff --git a/editor/workspace/EscapeRoomWorkspace.tscn b/editor/workspace/EscapeRoomWorkspace.tscn new file mode 100644 index 0000000..6d93f8e --- /dev/null +++ b/editor/workspace/EscapeRoomWorkspace.tscn @@ -0,0 +1,423 @@ +[gd_scene format=3 uid="uid://d0c4psurpl2f2"] + +[ext_resource type="Script" uid="uid://ca320x55f2h3y" path="res://ui/room_layout.gd" id="1_0vaos"] +[ext_resource type="Script" uid="uid://jxob25ucisxl" path="res://editor/workspace/escape_room_workspace.gd" id="1_moiuv"] +[ext_resource type="Script" uid="uid://bfb7sad2mjnlf" path="res://core/simulation/room_simulator.gd" id="1_ynivy"] +[ext_resource type="PackedScene" uid="uid://c5c0mnwwuqruw" path="res://ui/RoomLayoutItem.tscn" id="2_ynivy"] +[ext_resource type="ButtonGroup" uid="uid://cly57dx12c8dg" path="res://data/editor/buttonGroup.tres" id="3_fj88e"] +[ext_resource type="Script" uid="uid://dm5remdyomnc4" path="res://ui/logic_graph.gd" id="3_moiuv"] +[ext_resource type="Script" uid="uid://b0tn15y67fac4" path="res://editor/workspace/simulation_timeline.gd" id="7_prwl5"] + +[node name="EscapeRoomWorkspace" type="Control" unique_id=1163239148] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1_moiuv") + +[node name="RoomSimulator" type="Node" parent="." unique_id=2142830911] +script = ExtResource("1_ynivy") +metadata/_custom_type_script = "uid://bfb7sad2mjnlf" + +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=764379781] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Toolbar" type="HBoxContainer" parent="VBoxContainer" unique_id=863837601] +layout_mode = 2 + +[node name="ScenarioLabel" type="Label" parent="VBoxContainer/Toolbar" unique_id=1332916735] +layout_mode = 2 +text = "SCENARIO" + +[node name="ScenarioSelect" type="OptionButton" parent="VBoxContainer/Toolbar" unique_id=57947397] +layout_mode = 2 + +[node name="ValidateButton" type="Button" parent="VBoxContainer/Toolbar" unique_id=1178727524] +layout_mode = 2 +text = "VALIDATE" + +[node name="Spacer" type="VSeparator" parent="VBoxContainer/Toolbar" unique_id=1649273947] +layout_mode = 2 + +[node name="StatusLabel" type="Label" parent="VBoxContainer/Toolbar" unique_id=2058830606] +layout_mode = 2 +text = "STATUS" + +[node name="SimulationBar" type="HBoxContainer" parent="VBoxContainer" unique_id=359662494] +layout_mode = 2 + +[node name="TestLabel" type="Label" parent="VBoxContainer/SimulationBar" unique_id=103460454] +layout_mode = 2 +text = "TEST:" + +[node name="TestCabinetSelect" type="OptionButton" parent="VBoxContainer/SimulationBar" unique_id=1218863658] +layout_mode = 2 + +[node name="TestEventSelect" type="OptionButton" parent="VBoxContainer/SimulationBar" unique_id=700842260] +layout_mode = 2 + +[node name="TestEventButton" type="Button" parent="VBoxContainer/SimulationBar" unique_id=763809162] +layout_mode = 2 +text = "FIRE EVENT" + +[node name="MainViews" type="TabContainer" parent="VBoxContainer" unique_id=442177872] +layout_mode = 2 +size_flags_vertical = 3 +current_tab = 1 + +[node name="Physical" type="Control" parent="VBoxContainer/MainViews" unique_id=642902521] +visible = false +layout_mode = 2 +metadata/_tab_index = 0 + +[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/MainViews/Physical" unique_id=53218428] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="PhysicalToolbar" type="HBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer" unique_id=1361233586] +layout_mode = 2 + +[node name="SelectButton" type="Button" parent="VBoxContainer/MainViews/Physical/VBoxContainer/PhysicalToolbar" unique_id=1918084356] +layout_mode = 2 +toggle_mode = true +button_pressed = true +button_group = ExtResource("3_fj88e") +text = "SELECT" + +[node name="DrawWallButton" type="Button" parent="VBoxContainer/MainViews/Physical/VBoxContainer/PhysicalToolbar" unique_id=1782596667] +layout_mode = 2 +toggle_mode = true +button_group = ExtResource("3_fj88e") +text = "DRAW WALL" + +[node name="EditBoundaryButton" type="Button" parent="VBoxContainer/MainViews/Physical/VBoxContainer/PhysicalToolbar" unique_id=1099180096] +layout_mode = 2 +toggle_mode = true +button_group = ExtResource("3_fj88e") +text = "EDIT BOUNDARY" + +[node name="AddBoundaryPointButton" type="Button" parent="VBoxContainer/MainViews/Physical/VBoxContainer/PhysicalToolbar" unique_id=1392564272] +layout_mode = 2 +toggle_mode = true +button_group = ExtResource("3_fj88e") +text = "ADD POINT" + +[node name="DeleteBoundaryPointButton" type="Button" parent="VBoxContainer/MainViews/Physical/VBoxContainer/PhysicalToolbar" unique_id=121279084] +layout_mode = 2 +toggle_mode = true +button_group = ExtResource("3_fj88e") +text = "DELETE POINT" + +[node name="FocusRoomButton" type="Button" parent="VBoxContainer/MainViews/Physical/VBoxContainer/PhysicalToolbar" unique_id=1583370791] +layout_mode = 2 +toggle_mode = true +text = "FOCUS ON ROOM" + +[node name="ResetViewButton" type="Button" parent="VBoxContainer/MainViews/Physical/VBoxContainer/PhysicalToolbar" unique_id=1229994678] +layout_mode = 2 +toggle_mode = true +text = "RESET VIEW" + +[node name="ZoomLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/PhysicalToolbar" unique_id=671655429] +layout_mode = 2 +text = "100%" + +[node name="LayoutWorkspace" type="HBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer" unique_id=1651349629] +propagate_maximum_size = false +layout_mode = 2 +size_flags_vertical = 3 + +[node name="RoomLayoutPanel" type="PanelContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace" unique_id=1991145282] +custom_minimum_size = Vector2(0, 300) +clip_contents = true +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="RoomLayout" type="Control" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/RoomLayoutPanel" unique_id=1170586875] +layout_mode = 2 +focus_mode = 2 +script = ExtResource("1_0vaos") +item_scene = ExtResource("2_ynivy") +grid_size_meters = 0.305 + +[node name="PropertyPanel" type="PanelContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace" unique_id=1004135858] +custom_minimum_size = Vector2(300, 0) +custom_maximum_size = Vector2(300, -1) +layout_mode = 2 + +[node name="MarginContainer" type="MarginContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel" unique_id=2020502330] +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer" unique_id=57561269] +layout_mode = 2 + +[node name="PropertiesTitle" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=201532758] +layout_mode = 2 + +[node name="SelectedLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1453764819] +layout_mode = 2 + +[node name="HSeparator" type="HSeparator" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1092920259] +layout_mode = 2 + +[node name="XContainer" type="HBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1033218596] +layout_mode = 2 + +[node name="XLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/XContainer" unique_id=18757878] +layout_mode = 2 +text = "X (ft)" + +[node name="XSpinBox" type="SpinBox" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/XContainer" unique_id=2096096703] +layout_mode = 2 +size_flags_horizontal = 3 +min_value = -6562.0 +max_value = 16405.0 +step = 0.001 +allow_greater = true +allow_lesser = true + +[node name="YContainer" type="HBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=42809798] +layout_mode = 2 + +[node name="YLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/YContainer" unique_id=1673769933] +layout_mode = 2 +text = "Y (ft)" + +[node name="YSpinBox" type="SpinBox" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/YContainer" unique_id=1535738497] +layout_mode = 2 +size_flags_horizontal = 3 +min_value = -6562.0 +max_value = 16405.0 +step = 0.001 +allow_greater = true +allow_lesser = true + +[node name="RotationContainer" type="HBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=993197800] +layout_mode = 2 + +[node name="RotationLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/RotationContainer" unique_id=555802567] +layout_mode = 2 +text = "ROTATE" + +[node name="RotationSpinBox" type="SpinBox" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/RotationContainer" unique_id=1837154818] +layout_mode = 2 +size_flags_horizontal = 3 +min_value = -360.0 +max_value = 360.0 +editable = false + +[node name="SnapCheckBox" type="CheckBox" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1856155383] +layout_mode = 2 +button_pressed = true +text = "Snap to Grid" + +[node name="GridContainer" type="HBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1969314828] +layout_mode = 2 + +[node name="GridLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/GridContainer" unique_id=1070729216] +layout_mode = 2 +text = "Grid (ft)" + +[node name="GridSpinBox" type="SpinBox" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/GridContainer" unique_id=823428489] +layout_mode = 2 +size_flags_horizontal = 3 +min_value = 0.05 +max_value = 7.0 +step = 0.001 +value = 1.001 + +[node name="HSeparator2" type="HSeparator" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=2075347966] +layout_mode = 2 + +[node name="DirtyLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=164864025] +layout_mode = 2 + +[node name="SaveButton" type="Button" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=2059276515] +layout_mode = 2 +text = "SAVE ROOM" + +[node name="RevertButton" type="Button" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1017042755] +layout_mode = 2 +text = "REVERT" + +[node name="ShowConnectionsCheckBox" type="CheckBox" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=112263641] +layout_mode = 2 +button_pressed = true +text = "Show Connections" + +[node name="HSeparator3" type="HSeparator" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1552395963] +layout_mode = 2 + +[node name="WallStartXContainer" type="HBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1767771179] +layout_mode = 2 + +[node name="WallStartXLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallStartXContainer" unique_id=1029352572] +layout_mode = 2 +text = "Start X (ft)" + +[node name="WallStartXSpinBox" type="SpinBox" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallStartXContainer" unique_id=1517604292] +layout_mode = 2 +size_flags_horizontal = 3 +min_value = -6562.0 +max_value = 16405.0 +step = 0.001 +allow_greater = true +allow_lesser = true + +[node name="WallStartYContainer" type="HBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1191148572] +layout_mode = 2 + +[node name="WallStartYLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallStartYContainer" unique_id=2056551712] +layout_mode = 2 +text = "Start Y (ft)" + +[node name="WallStartYSpinBox" type="SpinBox" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallStartYContainer" unique_id=1446663095] +layout_mode = 2 +size_flags_horizontal = 3 +min_value = -6562.0 +max_value = 16405.0 +step = 0.001 +allow_greater = true +allow_lesser = true + +[node name="HSeparator4" type="HSeparator" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1250367093] +layout_mode = 2 + +[node name="WallEndXContainer" type="HBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1087233909] +layout_mode = 2 + +[node name="WallEndXLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallEndXContainer" unique_id=1469094168] +layout_mode = 2 +text = "End X (ft)" + +[node name="WallEndXSpinBox" type="SpinBox" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallEndXContainer" unique_id=401819232] +layout_mode = 2 +size_flags_horizontal = 3 +min_value = -6562.0 +max_value = 16405.0 +step = 0.001 +allow_greater = true +allow_lesser = true + +[node name="WallEndYContainer" type="HBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=575834449] +layout_mode = 2 + +[node name="WallEndYLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallEndYContainer" unique_id=87035740] +layout_mode = 2 +text = "End Y (ft)" + +[node name="WallEndYSpinBox" type="SpinBox" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallEndYContainer" unique_id=1018331962] +layout_mode = 2 +size_flags_horizontal = 3 +min_value = -6562.0 +max_value = 16405.0 +step = 0.001 +allow_greater = true +allow_lesser = true + +[node name="WallThicknessContainer" type="HBoxContainer" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=75655223] +layout_mode = 2 + +[node name="WallThicknessLabel" type="Label" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallThicknessContainer" unique_id=167585585] +layout_mode = 2 +text = "Thickness" + +[node name="WallThicknessSpinBox" type="SpinBox" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallThicknessContainer" unique_id=803776797] +layout_mode = 2 +size_flags_horizontal = 3 +min_value = 0.01 +max_value = 4.0 +step = 0.01 +value = 0.09999999999999999 +suffix = " ft" + +[node name="DeleteWallButton" type="Button" parent="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=933863750] +layout_mode = 2 +text = "DELETE WALL" + +[node name="Logic" type="Control" parent="VBoxContainer/MainViews" unique_id=323886775] +layout_mode = 2 +metadata/_tab_index = 1 + +[node name="AutoArrangeButton" type="Button" parent="VBoxContainer/MainViews/Logic" unique_id=924050908] +layout_mode = 0 +offset_left = 1786.0 +offset_top = -29.0 +offset_right = 1917.0 +offset_bottom = 2.0 +text = "AUTO ARRANGE" + +[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/MainViews/Logic" unique_id=1993927655] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="EditorBar" type="HBoxContainer" parent="VBoxContainer/MainViews/Logic/VBoxContainer" unique_id=723659829] +layout_mode = 2 + +[node name="ScenarioDirtyLabel" type="Label" parent="VBoxContainer/MainViews/Logic/VBoxContainer/EditorBar" unique_id=1492077890] +layout_mode = 2 +text = "SCENARIO SAVED" + +[node name="VSeparator" type="VSeparator" parent="VBoxContainer/MainViews/Logic/VBoxContainer/EditorBar" unique_id=812436932] +layout_mode = 2 + +[node name="ValidateScenarioButton" type="Button" parent="VBoxContainer/MainViews/Logic/VBoxContainer/EditorBar" unique_id=277208391] +layout_mode = 2 +text = "VALIDATE" + +[node name="SaveScenarioButton" type="Button" parent="VBoxContainer/MainViews/Logic/VBoxContainer/EditorBar" unique_id=100451844] +layout_mode = 2 +text = "SAVE +" + +[node name="RevertScenarioButton" type="Button" parent="VBoxContainer/MainViews/Logic/VBoxContainer/EditorBar" unique_id=1682080798] +layout_mode = 2 +text = "REVERT" + +[node name="LogicGraph" type="GraphEdit" parent="VBoxContainer/MainViews/Logic/VBoxContainer" unique_id=628220896] +propagate_maximum_size = true +layout_mode = 2 +size_flags_vertical = 3 +theme_override_colors/activity = Color(0, 1, 0.278, 1) +script = ExtResource("3_moiuv") + +[node name="SimulationTimeline" type="VBoxContainer" parent="VBoxContainer" unique_id=759773235] +custom_minimum_size = Vector2(0, 180) +layout_mode = 2 +script = ExtResource("7_prwl5") + +[connection signal="item_selected" from="VBoxContainer/Toolbar/ScenarioSelect" to="." method="_on_scenario_select_item_selected"] +[connection signal="pressed" from="VBoxContainer/Toolbar/ValidateButton" to="." method="_on_validate_button_pressed"] +[connection signal="item_selected" from="VBoxContainer/SimulationBar/TestCabinetSelect" to="." method="_on_test_cabinet_select_item_selected"] +[connection signal="pressed" from="VBoxContainer/SimulationBar/TestEventButton" to="." method="_on_test_event_button_pressed"] +[connection signal="pressed" from="VBoxContainer/MainViews/Physical/VBoxContainer/PhysicalToolbar/SelectButton" to="." method="_on_select_tool_button_pressed"] +[connection signal="pressed" from="VBoxContainer/MainViews/Physical/VBoxContainer/PhysicalToolbar/DrawWallButton" to="." method="_on_draw_wall_button_pressed"] +[connection signal="pressed" from="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/SaveButton" to="." method="_on_save_room_button_pressed"] +[connection signal="pressed" from="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/RevertButton" to="." method="_on_revert_room_button_pressed"] +[connection signal="toggled" from="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/ShowConnectionsCheckBox" to="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/RoomLayoutPanel/RoomLayout" method="_on_show_connections_check_box_toggled"] +[connection signal="value_changed" from="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallStartXContainer/WallStartXSpinBox" to="." method="_on_wall_start_x_spin_box_value_changed"] +[connection signal="value_changed" from="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallStartYContainer/WallStartYSpinBox" to="." method="_on_wall_start_y_spin_box_value_changed"] +[connection signal="value_changed" from="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallEndXContainer/WallEndXSpinBox" to="." method="_on_wall_end_x_spin_box_value_changed"] +[connection signal="value_changed" from="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallEndYContainer/WallEndYSpinBox" to="." method="_on_wall_end_y_spin_box_value_changed"] +[connection signal="value_changed" from="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/WallThicknessContainer/WallThicknessSpinBox" to="." method="_on_wall_thickness_spin_box_value_changed"] +[connection signal="pressed" from="VBoxContainer/MainViews/Physical/VBoxContainer/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/DeleteWallButton" to="." method="_on_delete_wall_button_pressed"] +[connection signal="pressed" from="VBoxContainer/MainViews/Logic/VBoxContainer/EditorBar/ValidateScenarioButton" to="." method="_on_validate_scenario_button_pressed"] +[connection signal="pressed" from="VBoxContainer/MainViews/Logic/VBoxContainer/EditorBar/SaveScenarioButton" to="." method="_on_save_scenario_button_pressed"] +[connection signal="pressed" from="VBoxContainer/MainViews/Logic/VBoxContainer/EditorBar/RevertScenarioButton" to="." method="_on_revert_scenario_button_pressed"] diff --git a/editor/workspace/editor_simulation_driver.gd b/editor/workspace/editor_simulation_driver.gd new file mode 100644 index 0000000..b51f0a3 --- /dev/null +++ b/editor/workspace/editor_simulation_driver.gd @@ -0,0 +1,983 @@ +@tool +class_name EditorSimulationDriver +extends RefCounted + + +signal action_activated( + cabinet_id: StringName, + action_id: StringName, + context: Dictionary +) + +signal action_deactivated( + cabinet_id: StringName, + action_id: StringName, + context: Dictionary +) + +signal device_state_changed( + device_id: StringName, + active: bool +) + +signal trace_entry_created( + entry: Dictionary +) + +signal sequence_started( + sequence: TestSequence +) + +signal sequence_finished( + sequence: TestSequence +) + +signal sequence_stopped + +var sequence_settling: bool = false +var room: RoomDefinition +var scenario: ScenarioDefinition +var logic_engine: RoomLogicEngine +var simulated_device_states: Dictionary = {} +var hardware_backend: SimulatedHardwareBackend + +var pending_hardware_deactivations: Array[Dictionary] = [] + +var paused: bool = false + +var active_sequence: TestSequence = null + +var sequence_start_time: float = 0.0 + +var pending_sequence_steps: Array[Dictionary] = [] + +var sequence_running: bool = false + +func start_sequence( + sequence: TestSequence +) -> void: + + if sequence == null: + return + + # + # Starting a rehearsal means starting from + # a clean simulated-room state. + # + reset_simulation() + + active_sequence = sequence + + sequence_start_time = ( + _get_simulation_time() + ) + + pending_sequence_steps.clear() + + for step in sequence.get_sorted_steps(): + + pending_sequence_steps.append( + { + "execute_at": ( + sequence_start_time + + step.time_offset_seconds + ), + + "step": step, + } + ) + + sequence_running = true + sequence_settling = false + + trace_entry_created.emit( + { + "type": &"sequence", + "time": _get_simulation_time(), + "sequence_id": sequence.sequence_id, + "display_name": sequence.display_name, + "state": &"started", + } + ) + + sequence_started.emit( + sequence + ) + + # + # Execute all steps authored at 0.0 immediately. + # + _process_pending_sequence_steps() + + +func advance( + delta_seconds: float +) -> void: + + if paused: + return + + _advance_internal( + delta_seconds + ) + +func configure( + room_definition: RoomDefinition, + scenario_definition: ScenarioDefinition +) -> void: + + room = room_definition + scenario = scenario_definition + + pending_hardware_deactivations.clear() + simulated_device_states.clear() + + logic_engine = RoomLogicEngine.new() + + logic_engine.configure( + room_definition + ) + + logic_engine.action_requested.connect( + _on_logic_action_requested + ) + + logic_engine.trace_entry_created.connect( + _on_logic_trace_entry_created + ) + + hardware_backend = SimulatedHardwareBackend.new() + + hardware_backend.pin_state_changed.connect( + _on_simulated_pin_state_changed + ) + + hardware_backend.trace_entry_created.connect( + _on_hardware_trace_entry_created + ) + +func simulate_game_event( + cabinet_id: StringName, + event_id: StringName +) -> void: + + if scenario == null: + push_error( + "EditorSimulationDriver has no scenario." + ) + return + + var assignment := ( + scenario.get_assignment_for_cabinet( + cabinet_id + ) + ) + + if assignment == null: + push_error( + "No scenario assignment exists for cabinet '%s'." + % cabinet_id + ) + return + + # + # Feed the event into the room-level logic engine. + # + emit_room_event( + cabinet_id, + event_id + ) + + # + # Preserve the existing scenario mapping: + # + # game event + # -> cabinet hardware action + # + var actions := ( + assignment.get_actions_for_event( + event_id + ) + ) + + for action_id in actions: + + _activate_action( + cabinet_id, + action_id, + { + "game_event": event_id + } + ) + + +func _activate_action( + cabinet_id: StringName, + action_id: StringName, + context: Dictionary +) -> void: + + if room == null: + return + + var assignment := ( + scenario.get_assignment_for_cabinet( + cabinet_id + ) + ) + + if assignment == null: + return + + if assignment.cabinet == null: + return + + if assignment.cabinet.hardware_config == null: + return + + var action := ( + assignment.cabinet.hardware_config.get_action( + action_id + ) + ) + + if action == null: + push_error( + "Editor simulation could not find hardware action '%s' on cabinet '%s'." + % [ + action_id, + cabinet_id + ] + ) + return + + action_activated.emit( + cabinet_id, + action_id, + context + ) + + hardware_backend.activate_action( + cabinet_id, + action, + context + ) + + match action.action_type: + HardwareAction.ActionType.PULSE_OUTPUT: + _schedule_deactivation( + cabinet_id, + action_id, + context, + action.pulse_duration_ms + ) + + HardwareAction.ActionType.DIGITAL_OUTPUT: + # + # For now, treat a digital output as latched. + # We'll add explicit ON/OFF editing later. + # + pass + + +func _schedule_deactivation( + cabinet_id: StringName, + action_id: StringName, + context: Dictionary, + duration_ms: int +) -> void: + + var duration_seconds := ( + float(duration_ms) + / 1000.0 + ) + + pending_hardware_deactivations.append( + { + "execute_at": ( + _get_simulation_time() + + duration_seconds + ), + + "cabinet_id": cabinet_id, + "action_id": action_id, + "context": context.duplicate(), + } + ) + +func _get_simulation_time() -> float: + + if logic_engine == null: + return 0.0 + + return logic_engine.get_simulation_time() + +func emit_room_event( + source_id: StringName, + event_id: StringName +) -> void: + + if logic_engine == null: + return + + logic_engine.emit_event( + source_id, + event_id + ) + +func _on_logic_action_requested( + target_id: StringName, + action_id: StringName, + rule_id: StringName +) -> void: + + if room == null: + return + + # + # First: direct room-device action. + # + var device := room.get_device( + target_id + ) + + if device != null: + + _apply_device_action( + device, + action_id, + rule_id + ) + + return + + # + # Second: cabinet hardware action. + # + var assignment := ( + scenario.get_assignment_for_cabinet( + target_id + ) + ) + + if assignment != null: + + _activate_action( + target_id, + action_id, + { + "logic_rule": rule_id + } + ) + + return + + print( + "[SIM] Unhandled logic action: ", + target_id, + ".", + action_id + ) + +func _apply_device_action( + device: RoomDeviceDefinition, + action_id: StringName, + _rule_id: StringName +) -> void: + + match action_id: + + &"activate": + _set_device_active( + device.device_id, + true + ) + + _emit_device_event( + device.device_id, + &"activated" + ) + + &"deactivate": + _set_device_active( + device.device_id, + false + ) + + _emit_device_event( + device.device_id, + &"deactivated" + ) + + &"turn_on": + _set_device_active( + device.device_id, + true + ) + + _emit_device_event( + device.device_id, + &"turned_on" + ) + + &"turn_off": + _set_device_active( + device.device_id, + false + ) + + _emit_device_event( + device.device_id, + &"turned_off" + ) + + &"unlock": + _set_device_active( + device.device_id, + true + ) + + _emit_device_event( + device.device_id, + &"unlocked" + ) + + &"lock": + _set_device_active( + device.device_id, + false + ) + + _emit_device_event( + device.device_id, + &"locked" + ) + + _: + print( + "[SIM] Unsupported device action: ", + device.device_id, + ".", + action_id + ) + +func _set_device_active( + device_id: StringName, + active: bool +) -> void: + + var previous_active := bool( + simulated_device_states.get( + device_id, + false + ) + ) + + simulated_device_states[ + device_id + ] = active + + device_state_changed.emit( + device_id, + active + ) + + # + # Record the simulated physical-state transition. + # + if previous_active != active: + + trace_entry_created.emit( + { + "type": &"device", + "time": _get_simulation_time(), + "device_id": device_id, + "active": active, + } + ) + +func _emit_device_event( + device_id: StringName, + event_id: StringName +) -> void: + + if logic_engine == null: + return + + logic_engine.emit_event( + device_id, + event_id + ) + +func _on_logic_trace_entry_created( + entry: Dictionary +) -> void: + + var timed_entry := ( + entry.duplicate(true) + ) + + timed_entry["time"] = ( + _get_simulation_time() + ) + + print( + "[SIM TRACE] ", + timed_entry + ) + + trace_entry_created.emit( + timed_entry + ) + +func _propagate_hardware_action_to_devices( + cabinet_id: StringName, + action_id: StringName, + active: bool, + context: Dictionary +) -> void: + + if room == null: + return + + for binding in room.bindings: + + if binding == null: + continue + + if binding.cabinet_id != cabinet_id: + continue + + if binding.hardware_action_id != action_id: + continue + + var device := room.get_device( + binding.device_id + ) + + if device == null: + continue + + _set_device_active( + device.device_id, + active + ) + + print( + "[SIM DEVICE PATH] ", + cabinet_id, + ".", + action_id, + " -> ", + device.device_id, + " active=", + active, + " context=", + context + ) + + if active: + + _emit_device_event( + device.device_id, + &"activated" + ) + + else: + + _emit_device_event( + device.device_id, + &"deactivated" + ) + +func _on_simulated_pin_state_changed( + cabinet_id: StringName, + _gpio_pin: int, + active: bool, + action_id: StringName, + context: Dictionary +) -> void: + + _propagate_hardware_action_to_devices( + cabinet_id, + action_id, + active, + context + ) + +func _on_hardware_trace_entry_created( + entry: Dictionary +) -> void: + + var timed_entry := ( + entry.duplicate(true) + ) + + timed_entry["time"] = ( + _get_simulation_time() + ) + + print( + "[GPIO SIM] ", + timed_entry + ) + + trace_entry_created.emit( + timed_entry + ) + +func _process_pending_hardware_deactivations() -> void: + + if pending_hardware_deactivations.is_empty(): + return + + var ready: Array[Dictionary] = [] + var remaining: Array[Dictionary] = [] + + for pending in pending_hardware_deactivations: + + var execute_at: float = ( + pending.get( + "execute_at", + 0.0 + ) + ) + + if execute_at <= _get_simulation_time(): + ready.append( + pending + ) + else: + remaining.append( + pending + ) + + pending_hardware_deactivations = remaining + + for pending in ready: + + _deactivate_hardware_action( + pending.get( + "cabinet_id", + &"" + ), + + pending.get( + "action_id", + &"" + ), + + pending.get( + "context", + {} + ) + ) + +func _deactivate_hardware_action( + cabinet_id: StringName, + action_id: StringName, + context: Dictionary +) -> void: + + if scenario == null: + return + + var assignment := ( + scenario.get_assignment_for_cabinet( + cabinet_id + ) + ) + + if assignment == null: + return + + if assignment.cabinet == null: + return + + if assignment.cabinet.hardware_config == null: + return + + var action := ( + assignment.cabinet.hardware_config.get_action( + action_id + ) + ) + + if action == null: + return + + action_deactivated.emit( + cabinet_id, + action_id, + context + ) + + if hardware_backend != null: + + hardware_backend.deactivate_action( + cabinet_id, + action, + context + ) + +func set_paused( + value: bool +) -> void: + + paused = value + + +func is_paused() -> bool: + + return paused + + +func step( + delta_seconds: float +) -> void: + + if delta_seconds <= 0.0: + return + + _advance_internal( + delta_seconds + ) + + +func reset_simulation() -> void: + + pending_sequence_steps.clear() + + active_sequence = null + sequence_running = false + sequence_start_time = 0.0 + sequence_settling = false + + pending_hardware_deactivations.clear() + simulated_device_states.clear() + + if logic_engine != null: + logic_engine.reset() + + if hardware_backend != null: + hardware_backend.reset() + + paused = false + +func _advance_internal( + delta_seconds: float +) -> void: + + if delta_seconds <= 0.0: + return + + if logic_engine == null: + return + + logic_engine.advance( + delta_seconds + ) + + _process_pending_sequence_steps() + + _process_pending_hardware_deactivations() + + _finish_sequence_if_ready() + +func _process_pending_sequence_steps() -> void: + + if not sequence_running: + return + + if pending_sequence_steps.is_empty(): + + _finish_sequence_if_ready() + + return + + _finish_sequence_if_ready() + + var current_time := ( + _get_simulation_time() + ) + + var ready: Array[Dictionary] = [] + var remaining: Array[Dictionary] = [] + + for pending in pending_sequence_steps: + + var execute_at := float( + pending.get( + "execute_at", + 0.0 + ) + ) + + if execute_at <= current_time: + + ready.append( + pending + ) + + else: + + remaining.append( + pending + ) + + pending_sequence_steps = remaining + + for pending in ready: + + var step := ( + pending.get( + "step" + ) + as TestSequenceStep + ) + + if step == null: + continue + + _execute_sequence_step( + step + ) + + _finish_sequence_if_ready() + +func _execute_sequence_step( + step: TestSequenceStep +) -> void: + + trace_entry_created.emit( + { + "type": &"input", + "time": _get_simulation_time(), + "source_id": step.source_id, + "event_id": step.event_id, + "note": step.note, + } + ) + + # + # If the source is a scenario cabinet, this is a + # game/cabinet event and must travel through the + # existing scenario mapping as well as room logic. + # + if scenario != null: + + var assignment := ( + scenario.get_assignment_for_cabinet( + step.source_id + ) + ) + + if assignment != null: + + simulate_game_event( + step.source_id, + step.event_id + ) + + return + + # + # Otherwise this is a generic room/world input. + # + emit_room_event( + step.source_id, + step.event_id + ) + +func _finish_sequence_if_ready() -> void: + + if not sequence_running: + return + + # + # Still waiting to inject authored inputs. + # + if not pending_sequence_steps.is_empty(): + return + + # + # All authored inputs have been injected. + # Enter settling state once. + # + if not sequence_settling: + + sequence_settling = true + + if active_sequence != null: + + trace_entry_created.emit( + { + "type": &"sequence", + "time": _get_simulation_time(), + "sequence_id": active_sequence.sequence_id, + "display_name": active_sequence.display_name, + "state": &"settling", + } + ) + + # + # Room logic still has delayed work. + # + if ( + logic_engine != null + and logic_engine.has_pending_actions() + ): + return + + # + # Hardware pulses are still active. + # + if not pending_hardware_deactivations.is_empty(): + return + + # + # Everything has settled. + # + var completed_sequence := active_sequence + + sequence_running = false + sequence_settling = false + active_sequence = null + + if completed_sequence == null: + return + + trace_entry_created.emit( + { + "type": &"sequence", + "time": _get_simulation_time(), + "sequence_id": completed_sequence.sequence_id, + "display_name": completed_sequence.display_name, + "state": &"finished", + } + ) + + sequence_finished.emit( + completed_sequence + ) + +func stop_sequence() -> void: + + if not sequence_running: + return + + pending_sequence_steps.clear() + + sequence_running = false + sequence_settling = false + active_sequence = null + + trace_entry_created.emit( + { + "type": &"sequence", + "time": _get_simulation_time(), + "state": &"stopped", + } + ) + + sequence_stopped.emit() diff --git a/editor/workspace/editor_simulation_driver.gd.uid b/editor/workspace/editor_simulation_driver.gd.uid new file mode 100644 index 0000000..41c21aa --- /dev/null +++ b/editor/workspace/editor_simulation_driver.gd.uid @@ -0,0 +1 @@ +uid://cooxa42fh5jd diff --git a/editor/workspace/escape_room_editor_context.gd b/editor/workspace/escape_room_editor_context.gd new file mode 100644 index 0000000..ef51e09 --- /dev/null +++ b/editor/workspace/escape_room_editor_context.gd @@ -0,0 +1,126 @@ +@tool +class_name EscapeRoomEditorContext +extends RefCounted + + +signal room_changed( + room: RoomDefinition +) + +signal scenario_changed( + scenario: ScenarioDefinition +) + +signal selection_changed + + +var database: ProjectDatabase + +var source_room: RoomDefinition +var source_scenario: ScenarioDefinition + +var room_edit_session := RoomEditSession.new() +var scenario_edit_session := ScenarioEditSession.new() + + +func configure( + project_database: ProjectDatabase, + room: RoomDefinition +) -> bool: + + if project_database == null: + push_error( + "EscapeRoomEditorContext requires a ProjectDatabase." + ) + return false + + if room == null: + push_error( + "EscapeRoomEditorContext requires a RoomDefinition." + ) + return false + + database = project_database + source_room = room + + if not room_edit_session.room_changed.is_connected( + _on_working_room_changed + ): + room_edit_session.room_changed.connect( + _on_working_room_changed + ) + + if not room_edit_session.begin( + source_room + ): + return false + if not scenario_edit_session.scenario_changed.is_connected( + _on_working_scenario_changed + ): + scenario_edit_session.scenario_changed.connect( + _on_working_scenario_changed + ) + + return true + +func _on_working_scenario_changed( + scenario: ScenarioDefinition +) -> void: + + scenario_changed.emit( + scenario + ) + +func open_scenario( + scenario: ScenarioDefinition +) -> bool: + + if scenario == null: + push_error( + "Cannot open a null scenario." + ) + return false + + source_scenario = scenario + + if not scenario_edit_session.begin( + source_scenario + ): + return false + + scenario_changed.emit( + scenario_edit_session.working_scenario + ) + + return true + + +func get_room() -> RoomDefinition: + return room_edit_session.working_room + + +func get_scenario() -> ScenarioDefinition: + return scenario_edit_session.working_scenario + + +func save_room() -> bool: + return room_edit_session.save() + + +func revert_room() -> void: + room_edit_session.revert() + + +func save_scenario() -> bool: + return scenario_edit_session.save() + + +func revert_scenario() -> void: + scenario_edit_session.revert() + + +func _on_working_room_changed( + room: RoomDefinition +) -> void: + + room_changed.emit(room) diff --git a/editor/workspace/escape_room_editor_context.gd.uid b/editor/workspace/escape_room_editor_context.gd.uid new file mode 100644 index 0000000..69887ef --- /dev/null +++ b/editor/workspace/escape_room_editor_context.gd.uid @@ -0,0 +1 @@ +uid://t82k7x32o5gc diff --git a/editor/workspace/escape_room_editor_selection.gd b/editor/workspace/escape_room_editor_selection.gd new file mode 100644 index 0000000..a27c3c0 --- /dev/null +++ b/editor/workspace/escape_room_editor_selection.gd @@ -0,0 +1,72 @@ +@tool +class_name EscapeRoomEditorSelection +extends RefCounted + + +signal changed + + +var cabinet_id: StringName = &"" +var device_id: StringName = &"" +var event_id: StringName = &"" +var action_id: StringName = &"" +var wall_id: StringName = &"" + + +func clear() -> void: + + _clear_values() + changed.emit() + + +func select_cabinet( + id: StringName +) -> void: + + _clear_values() + cabinet_id = id + changed.emit() + + +func select_device( + id: StringName +) -> void: + + _clear_values() + device_id = id + changed.emit() + + +func select_wall( + id: StringName +) -> void: + + _clear_values() + wall_id = id + changed.emit() + + +func select_logic_path( + new_cabinet_id: StringName, + new_event_id: StringName, + new_action_id: StringName, + new_device_id: StringName = &"" +) -> void: + + _clear_values() + + cabinet_id = new_cabinet_id + event_id = new_event_id + action_id = new_action_id + device_id = new_device_id + + changed.emit() + + +func _clear_values() -> void: + + cabinet_id = &"" + device_id = &"" + event_id = &"" + action_id = &"" + wall_id = &"" diff --git a/editor/workspace/escape_room_editor_selection.gd.uid b/editor/workspace/escape_room_editor_selection.gd.uid new file mode 100644 index 0000000..8615c28 --- /dev/null +++ b/editor/workspace/escape_room_editor_selection.gd.uid @@ -0,0 +1 @@ +uid://bab3rbavt5u1f diff --git a/editor/workspace/escape_room_setup_validator.gd b/editor/workspace/escape_room_setup_validator.gd new file mode 100644 index 0000000..3d58113 --- /dev/null +++ b/editor/workspace/escape_room_setup_validator.gd @@ -0,0 +1,25 @@ +class_name EscapeRoomSetupValidator + + +static func validate( + room: RoomDefinition, + scenario: ScenarioDefinition, + database: ProjectDatabase +) -> ValidationResult: + + var result := ValidationResult.new() + + result.merge( + RoomValidator.validate( + room, + database + ) + ) + + result.merge( + ScenarioValidator.validate( + scenario + ) + ) + + return result diff --git a/editor/workspace/escape_room_setup_validator.gd.uid b/editor/workspace/escape_room_setup_validator.gd.uid new file mode 100644 index 0000000..05c60ef --- /dev/null +++ b/editor/workspace/escape_room_setup_validator.gd.uid @@ -0,0 +1 @@ +uid://bjjc0dmjp54wr diff --git a/editor/workspace/escape_room_workspace.gd b/editor/workspace/escape_room_workspace.gd new file mode 100644 index 0000000..aee7ee0 --- /dev/null +++ b/editor/workspace/escape_room_workspace.gd @@ -0,0 +1,3003 @@ +@tool +class_name EscapeRoomWorkspace +extends Control + + +signal scenario_selected( + scenario: ScenarioDefinition +) + + +@onready var room_simulator: RoomSimulator = ( + $RoomSimulator +) + +@onready var scenario_select: OptionButton = ( + $VBoxContainer/Toolbar/ScenarioSelect +) + +@onready var status_label: Label = ( + $VBoxContainer/Toolbar/StatusLabel +) + +@onready var room_layout: RoomLayout = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + RoomLayoutPanel/RoomLayout +) + +@onready var logic_graph: LogicGraph = ( + $VBoxContainer/MainViews/ + Logic/VBoxContainer/LogicGraph +) + +@onready var test_cabinet_select: OptionButton = ( + $VBoxContainer/ + SimulationBar/TestCabinetSelect +) + +@onready var test_event_select: OptionButton = ( + $VBoxContainer/ + SimulationBar/TestEventSelect +) + +@onready var test_event_button: Button = ( + $VBoxContainer/ + SimulationBar/TestEventButton +) + +@onready var selected_label: Label = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/SelectedLabel +) + +@onready var x_spin_box: SpinBox = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/XContainer/XSpinBox +) + +@onready var y_spin_box: SpinBox = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/YContainer/YSpinBox +) + +@onready var rotation_spin_box: SpinBox = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/RotationContainer/RotationSpinBox +) + +@onready var snap_check_box: CheckBox = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/SnapCheckBox +) + +@onready var grid_spin_box: SpinBox = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/GridContainer/GridSpinBox +) + +@onready var room_dirty_label: Label = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/DirtyLabel +) + +@onready var scenario_dirty_label: Label = ( + $VBoxContainer/MainViews/ + Logic/VBoxContainer/ + EditorBar/ScenarioDirtyLabel +) + +@onready var show_connections_check_box: CheckBox = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/ShowConnectionsCheckBox +) + +@onready var edit_boundary_button: Button = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/ + PhysicalToolbar/EditBoundaryButton +) + +@onready var select_tool_button: Button = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/ + PhysicalToolbar/SelectButton +) + +@onready var draw_wall_button: Button = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/ + PhysicalToolbar/DrawWallButton +) + +@onready var add_boundary_point_button: Button = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/ + PhysicalToolbar/AddBoundaryPointButton +) + +@onready var delete_boundary_point_button: Button = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/ + PhysicalToolbar/DeleteBoundaryPointButton +) + +@onready var focus_room_button: Button = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/ + PhysicalToolbar/FocusRoomButton +) + +@onready var reset_view_button: Button = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/ + PhysicalToolbar/ResetViewButton +) + +@onready var zoom_label: Label = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/ + PhysicalToolbar/ZoomLabel +) + +@onready var wall_start_x_spin_box: SpinBox = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/WallStartXContainer/WallStartXSpinBox +) + +@onready var wall_start_y_spin_box: SpinBox = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/WallStartYContainer/WallStartYSpinBox +) + +@onready var wall_end_x_spin_box: SpinBox = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/WallEndXContainer/WallEndXSpinBox +) + +@onready var wall_end_y_spin_box: SpinBox = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/WallEndYContainer/WallEndYSpinBox +) + +@onready var wall_thickness_spin_box: SpinBox = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/WallThicknessContainer/WallThicknessSpinBox +) + +@onready var delete_wall_button: Button = ( + $VBoxContainer/MainViews/ + Physical/VBoxContainer/LayoutWorkspace/ + PropertyPanel/MarginContainer/ + VBoxContainer/DeleteWallButton +) + +@onready var main_views: TabContainer = ( + $VBoxContainer/MainViews +) + +@onready +var simulation_timeline: SimulationTimeline = ( + $VBoxContainer/SimulationTimeline +) + +# Physical controls use feet; model values and UndoRedo remain in meters. +const FEET_TO_METERS: float = 0.3048 +const METERS_TO_FEET: float = 1.0 / FEET_TO_METERS + +const TEST_SEQUENCE_DIRECTORY := ( + "res://data/test_sequences" +) + +var test_sequences: Array[TestSequence] = [] +var selected_test_sequence_index: int = -1 + +var undo_redo: EditorUndoRedoManager + +var test_cabinet_ids: Array[StringName] = [] +var test_event_ids: Array[StringName] = [] + +var context: EscapeRoomEditorContext + +var scenario_ids: Array[StringName] = [] + +var simulation_driver := EditorSimulationDriver.new() + +var editor_selection := EscapeRoomEditorSelection.new() + +func _ready() -> void: + + select_tool_button.button_pressed = true + + room_layout.set_edit_mode( + RoomLayout.EditMode.SELECT + ) + + if not logic_graph.scenario_mapping_changed.is_connected( + _on_logic_mapping_changed + ): + logic_graph.scenario_mapping_changed.connect( + _on_logic_mapping_changed + ) + + if not logic_graph.edit_rejected.is_connected( + _on_logic_edit_rejected + ): + logic_graph.edit_rejected.connect( + _on_logic_edit_rejected + ) + + if not room_layout.layout_changed.is_connected( + _on_room_layout_changed + ): + room_layout.layout_changed.connect( + _on_room_layout_changed + ) + + if not simulation_driver.action_activated.is_connected( + _on_editor_action_activated + ): + simulation_driver.action_activated.connect( + _on_editor_action_activated + ) + + if not simulation_driver.action_deactivated.is_connected( + _on_editor_action_deactivated + ): + simulation_driver.action_deactivated.connect( + _on_editor_action_deactivated + ) + + if not room_layout.item_selected.is_connected( + _on_room_layout_item_selected + ): + room_layout.item_selected.connect( + _on_room_layout_item_selected + ) + + wall_thickness_spin_box.min_value = 0.01 + wall_thickness_spin_box.max_value = 4.0 + wall_thickness_spin_box.step = 0.01 + wall_thickness_spin_box.suffix = " ft" + + if not x_spin_box.value_changed.is_connected( + _on_x_spin_box_value_changed + ): + x_spin_box.value_changed.connect( + _on_x_spin_box_value_changed + ) + + if not y_spin_box.value_changed.is_connected( + _on_y_spin_box_value_changed + ): + y_spin_box.value_changed.connect( + _on_y_spin_box_value_changed + ) + + if not rotation_spin_box.value_changed.is_connected( + _on_rotation_spin_box_value_changed + ): + rotation_spin_box.value_changed.connect( + _on_rotation_spin_box_value_changed + ) + + snap_check_box.set_pressed_no_signal( + room_layout.snap_enabled + ) + + grid_spin_box.set_value_no_signal( + room_layout.grid_size_meters * METERS_TO_FEET + ) + + if not snap_check_box.toggled.is_connected( + _on_snap_check_box_toggled + ): + snap_check_box.toggled.connect( + _on_snap_check_box_toggled + ) + + if not grid_spin_box.value_changed.is_connected( + _on_grid_spin_box_value_changed + ): + grid_spin_box.value_changed.connect( + _on_grid_spin_box_value_changed + ) + + show_connections_check_box.set_pressed_no_signal( + room_layout.show_connections + ) + + if not show_connections_check_box.toggled.is_connected( + _on_show_connections_check_box_toggled + ): + show_connections_check_box.toggled.connect( + _on_show_connections_check_box_toggled + ) + + if not test_cabinet_select.item_selected.is_connected( + _on_test_cabinet_select_item_selected + ): + test_cabinet_select.item_selected.connect( + _on_test_cabinet_select_item_selected + ) + + if not test_event_button.pressed.is_connected( + _on_test_event_button_pressed + ): + test_event_button.pressed.connect( + _on_test_event_button_pressed + ) + + if not room_layout.wall_creation_requested.is_connected( + _on_wall_creation_requested + ): + room_layout.wall_creation_requested.connect( + _on_wall_creation_requested + ) + + if not room_layout.wall_selected.is_connected( + _on_room_wall_selected + ): + room_layout.wall_selected.connect( + _on_room_wall_selected + ) + + if not room_layout.wall_geometry_edit_requested.is_connected( + _on_wall_geometry_edit_requested + ): + room_layout.wall_geometry_edit_requested.connect( + _on_wall_geometry_edit_requested + ) + + if not editor_selection.changed.is_connected( + _on_editor_selection_changed + ): + editor_selection.changed.connect( + _on_editor_selection_changed + ) + + if not scenario_select.item_selected.is_connected( + _on_scenario_select_item_selected + ): + scenario_select.item_selected.connect( + _on_scenario_select_item_selected + ) + + if not edit_boundary_button.pressed.is_connected( + _on_edit_boundary_button_pressed + ): + edit_boundary_button.pressed.connect( + _on_edit_boundary_button_pressed + ) + + if not room_layout.boundary_geometry_edit_requested.is_connected( + _on_boundary_geometry_edit_requested + ): + room_layout.boundary_geometry_edit_requested.connect( + _on_boundary_geometry_edit_requested + ) + + if not focus_room_button.pressed.is_connected( + _on_focus_room_button_pressed + ): + focus_room_button.pressed.connect( + _on_focus_room_button_pressed + ) + + if not add_boundary_point_button.pressed.is_connected( + _on_add_boundary_point_button_pressed + ): + add_boundary_point_button.pressed.connect( + _on_add_boundary_point_button_pressed + ) + + if not delete_boundary_point_button.pressed.is_connected( + _on_delete_boundary_point_button_pressed + ): + delete_boundary_point_button.pressed.connect( + _on_delete_boundary_point_button_pressed + ) + + if not room_layout.edit_rejected.is_connected( + _on_room_layout_edit_rejected + ): + room_layout.edit_rejected.connect( + _on_room_layout_edit_rejected + ) + + if not reset_view_button.pressed.is_connected( + _on_reset_view_button_pressed + ): + reset_view_button.pressed.connect( + _on_reset_view_button_pressed + ) + + if not room_layout.zoom_changed.is_connected( + _on_room_layout_zoom_changed + ): + room_layout.zoom_changed.connect( + _on_room_layout_zoom_changed + ) + + _on_room_layout_zoom_changed( + room_layout.zoom + ) + + if not room_layout.item_position_edit_requested.is_connected( + _on_room_layout_item_position_edit_requested + ): + room_layout.item_position_edit_requested.connect( + _on_room_layout_item_position_edit_requested + ) + + if not simulation_driver.trace_entry_created.is_connected( + _on_simulation_trace_entry_created + ): + simulation_driver.trace_entry_created.connect( + _on_simulation_trace_entry_created + ) + + if not simulation_timeline.pause_requested.is_connected( + _on_simulation_pause_requested + ): + simulation_timeline.pause_requested.connect( + _on_simulation_pause_requested + ) + + if not simulation_timeline.resume_requested.is_connected( + _on_simulation_resume_requested + ): + simulation_timeline.resume_requested.connect( + _on_simulation_resume_requested + ) + + if not simulation_timeline.step_requested.is_connected( + _on_simulation_step_requested + ): + simulation_timeline.step_requested.connect( + _on_simulation_step_requested + ) + + if not simulation_timeline.reset_requested.is_connected( + _on_simulation_reset_requested + ): + simulation_timeline.reset_requested.connect( + _on_simulation_reset_requested + ) + + if not simulation_timeline.sequence_selected.is_connected( + _on_test_sequence_selected + ): + simulation_timeline.sequence_selected.connect( + _on_test_sequence_selected + ) + + if not simulation_timeline.run_sequence_requested.is_connected( + _on_run_test_sequence_requested + ): + simulation_timeline.run_sequence_requested.connect( + _on_run_test_sequence_requested + ) + + if not simulation_timeline.stop_sequence_requested.is_connected( + _on_stop_test_sequence_requested + ): + simulation_timeline.stop_sequence_requested.connect( + _on_stop_test_sequence_requested + ) + + if not simulation_timeline.replay_sequence_requested.is_connected( + _on_replay_test_sequence_requested + ): + simulation_timeline.replay_sequence_requested.connect( + _on_replay_test_sequence_requested + ) + + if not simulation_driver.sequence_started.is_connected( + _on_test_sequence_started + ): + simulation_driver.sequence_started.connect( + _on_test_sequence_started + ) + + if not simulation_driver.sequence_finished.is_connected( + _on_test_sequence_finished + ): + simulation_driver.sequence_finished.connect( + _on_test_sequence_finished + ) + + if not simulation_driver.sequence_stopped.is_connected( + _on_test_sequence_stopped + ): + simulation_driver.sequence_stopped.connect( + _on_test_sequence_stopped + ) + + if not room_layout.refresh_requested.is_connected( + _on_room_layout_refresh_requested + ): + room_layout.refresh_requested.connect( + _on_room_layout_refresh_requested + ) + + if not room_layout.binding_route_edit_requested.is_connected( + _on_room_layout_binding_route_edit_requested + ): + room_layout.binding_route_edit_requested.connect( + _on_room_layout_binding_route_edit_requested + ) + + _load_test_sequences() + +func _process( + delta: float +) -> void: + + if simulation_driver == null: + return + + simulation_driver.advance( + delta + ) + +func configure( + project_database: ProjectDatabase, + room: RoomDefinition +) -> bool: + + context = EscapeRoomEditorContext.new() + + context.room_changed.connect( + _on_context_room_changed + ) + + context.scenario_changed.connect( + _on_context_scenario_changed + ) + + context.room_edit_session.dirty_changed.connect( + _on_room_dirty_changed + ) + + context.scenario_edit_session.dirty_changed.connect( + _on_scenario_dirty_changed + ) + + # + # Connect simulator output before configuring the context, + # because context.configure() may immediately emit room_changed. + # + _connect_simulation_signals() + + if not context.configure( + project_database, + room + ): + return false + + # + # Opening the first scenario will trigger + # _on_context_scenario_changed(), which configures + # the simulation driver, layout, and graph. + # + _populate_scenarios() + + return true + +func _populate_scenarios() -> void: + scenario_select.clear() + scenario_ids.clear() + + if context == null: + return + + for scenario in context.database.scenarios: + if scenario == null: + continue + + scenario_select.add_item( + scenario.display_name + ) + + scenario_ids.append( + scenario.scenario_id + ) + + if scenario_ids.is_empty(): + status_label.text = "No scenarios" + return + + scenario_select.set_block_signals( + true + ) + + scenario_select.select( + 0 + ) + + scenario_select.set_block_signals( + false + ) + + _open_selected_scenario() + +func _open_selected_scenario() -> void: + var index := scenario_select.selected + + if ( + index < 0 + or index >= scenario_ids.size() + ): + return + + var scenario := ( + context.database.get_scenario( + scenario_ids[index] + ) + ) + + if scenario == null: + return + + if context.open_scenario( + scenario + ): + scenario_selected.emit( + context.get_scenario() + ) + +func _on_scenario_select_item_selected( + _index: int +) -> void: + + _open_selected_scenario() + +func _on_context_scenario_changed( + scenario: ScenarioDefinition +) -> void: + + if scenario == null: + return + + var room := context.get_room() + + if simulation_timeline != null: + simulation_timeline.clear() + + simulation_driver.configure( + room, + scenario + ) + + room_layout.configure( + room, + scenario + ) + + _clear_property_panel() + + logic_graph.configure( + room, + scenario + ) + + status_label.text = ( + "%s v%d" + % [ + scenario.display_name, + scenario.version + ] + ) + + _populate_test_cabinets() + + print( + "[SIM UI] cabinets=%d events=%d" + % [ + test_cabinet_select.item_count, + test_event_select.item_count + ] + ) + +func _on_simulation_trace_entry_created( + entry: Dictionary +) -> void: + + if simulation_timeline == null: + return + + simulation_timeline.add_trace_entry( + entry + ) + +func _on_context_room_changed( + room: RoomDefinition +) -> void: + + if room == null: + return + + room_simulator.configure( + room + ) + + var scenario := context.get_scenario() + + simulation_driver.configure( + room, + scenario + ) + + if scenario != null: + room_layout.configure( + room, + scenario + ) + + _clear_property_panel() + + logic_graph.configure( + room, + scenario + ) + +func _on_logic_mapping_changed() -> void: + if context == null: + return + + context.scenario_edit_session.mark_dirty() + + validate_setup() + +func _on_room_layout_changed() -> void: + + if context == null: + return + + context.room_edit_session.mark_dirty() + + _refresh_selected_property_values() + +func _on_logic_edit_rejected( + message: String +) -> void: + + status_label.text = ( + "Edit rejected: %s" + % message + ) + +func validate_setup() -> ValidationResult: + if context == null: + var result := ValidationResult.new() + + result.add_error( + "Editor workspace is not configured." + ) + + return result + + if context.get_room() == null: + var result := ValidationResult.new() + + result.add_error( + "No room is currently open." + ) + + _display_validation_result( + result + ) + + return result + + if context.get_scenario() == null: + var result := ValidationResult.new() + + result.add_error( + "No scenario is currently open." + ) + + _display_validation_result( + result + ) + + return result + + var result := ( + EscapeRoomSetupValidator.validate( + context.get_room(), + context.get_scenario(), + context.database + ) + ) + + _display_validation_result( + result + ) + + return result + +func _display_validation_result( + result: ValidationResult +) -> void: + + if result.is_valid(): + status_label.text = ( + "✓ " + result.get_summary() + ) + else: + status_label.text = ( + "⚠ " + result.get_summary() + ) + + for error in result.errors: + print( + "[EDITOR VALIDATION] ERROR: ", + error + ) + + for warning in result.warnings: + print( + "[EDITOR VALIDATION] WARNING: ", + warning + ) + +func _on_save_room_button_pressed() -> void: + if context == null: + return + + if context.save_room(): + + if Engine.is_editor_hint(): + EditorInterface.get_resource_filesystem().scan() + + status_label.text = ( + "Room saved." + ) + +func _on_validate_button_pressed() -> void: + validate_setup() + +func _on_revert_room_button_pressed() -> void: + if context == null: + return + + context.revert_room() + + +func _on_revert_scenario_button_pressed() -> void: + if context == null: + return + + context.revert_scenario() + +func _on_save_scenario_button_pressed() -> void: + if context == null: + return + + if context.save_scenario(): + + if Engine.is_editor_hint(): + EditorInterface.get_resource_filesystem().scan() + + status_label.text = ( + "Scenario saved." + ) + +func _on_validate_scenario_button_pressed() -> void: + validate_setup() + +func _on_room_dirty_changed( + dirty: bool +) -> void: + + if dirty: + room_dirty_label.text = ( + "● UNSAVED ROOM CHANGES" + ) + else: + room_dirty_label.text = ( + "✓ ROOM SAVED" + ) + +func _on_scenario_dirty_changed( + dirty: bool +) -> void: + + if dirty: + scenario_dirty_label.text = ( + "● UNSAVED SCENARIO CHANGES" + ) + else: + scenario_dirty_label.text = ( + "✓ SCENARIO SAVED" + ) + +func _connect_simulation_signals() -> void: + + if not room_simulator.device_state_changed.is_connected( + _on_device_state_changed + ): + room_simulator.device_state_changed.connect( + _on_device_state_changed + ) + + if not room_simulator.binding_activated.is_connected( + _on_binding_activated + ): + room_simulator.binding_activated.connect( + _on_binding_activated + ) + + if not room_simulator.binding_deactivated.is_connected( + _on_binding_deactivated + ): + room_simulator.binding_deactivated.connect( + _on_binding_deactivated + ) + + if not room_simulator.path_activated.is_connected( + _on_path_activated + ): + room_simulator.path_activated.connect( + _on_path_activated + ) + + if not room_simulator.path_deactivated.is_connected( + _on_path_deactivated + ): + room_simulator.path_deactivated.connect( + _on_path_deactivated + ) + + if not logic_graph.logic_path_selected.is_connected( + _on_logic_path_selected + ): + logic_graph.logic_path_selected.connect( + _on_logic_path_selected + ) + +func _on_device_state_changed( + device_id: StringName, + active: bool +) -> void: + + room_layout.set_device_active( + device_id, + active + ) + +func _on_binding_activated( + cabinet_id: StringName, + action_id: StringName, + device_id: StringName +) -> void: + + room_layout.set_binding_active( + cabinet_id, + action_id, + device_id, + true + ) + +func _on_binding_deactivated( + cabinet_id: StringName, + action_id: StringName, + device_id: StringName +) -> void: + + room_layout.set_binding_active( + cabinet_id, + action_id, + device_id, + false + ) + +func _on_path_activated( + _path_id: StringName, + cabinet_id: StringName, + event_id: StringName, + action_id: StringName, + device_id: StringName +) -> void: + + print( + "[EDITOR PATH] %s :: %s -> %s -> %s" + % [ + cabinet_id, + event_id, + action_id, + device_id + ] + ) + + logic_graph.set_path_active( + cabinet_id, + event_id, + action_id, + device_id, + true + ) + +func _on_path_deactivated( + _path_id: StringName, + cabinet_id: StringName, + event_id: StringName, + action_id: StringName, + device_id: StringName +) -> void: + + logic_graph.set_path_active( + cabinet_id, + event_id, + action_id, + device_id, + false + ) + +func simulate_game_event( + cabinet_id: StringName, + event_id: StringName +) -> void: + + simulation_driver.simulate_game_event( + cabinet_id, + event_id + ) + +func _on_editor_action_activated( + cabinet_id: StringName, + action_id: StringName, + context_data: Dictionary +) -> void: + + room_simulator.simulate_action_activated( + cabinet_id, + action_id, + context_data + ) + +func _on_editor_action_deactivated( + cabinet_id: StringName, + action_id: StringName, + context_data: Dictionary +) -> void: + + room_simulator.simulate_action_deactivated( + cabinet_id, + action_id, + context_data + ) + +func _populate_test_cabinets() -> void: + + test_cabinet_select.clear() + test_cabinet_ids.clear() + + var scenario := context.get_scenario() + + if scenario == null: + return + + for assignment in scenario.assignments: + if assignment == null: + continue + + if assignment.cabinet == null: + continue + + var label := String( + assignment.cabinet.display_name + ) + + if label.is_empty(): + label = String( + assignment.cabinet.cabinet_id + ) + + test_cabinet_select.add_item( + label + ) + + test_cabinet_ids.append( + assignment.cabinet.cabinet_id + ) + + if test_cabinet_ids.is_empty(): + test_event_select.clear() + test_event_ids.clear() + return + + test_cabinet_select.set_block_signals( + true + ) + + test_cabinet_select.select( + 0 + ) + + test_cabinet_select.set_block_signals( + false + ) + + _populate_test_events( + 0 + ) + +func _populate_test_events( + cabinet_index: int +) -> void: + + test_event_select.clear() + test_event_ids.clear() + + if ( + cabinet_index < 0 + or cabinet_index >= test_cabinet_ids.size() + ): + return + + var scenario := context.get_scenario() + + if scenario == null: + return + + var cabinet_id := ( + test_cabinet_ids[ + cabinet_index + ] + ) + + var assignment := ( + scenario.get_assignment_for_cabinet( + cabinet_id + ) + ) + + if assignment == null: + return + + if assignment.game == null: + return + + for event_definition in assignment.game.events: + if event_definition == null: + continue + + var label := String( + event_definition.display_name + ) + + if label.is_empty(): + label = String( + event_definition.event_id + ) + + test_event_select.add_item( + label + ) + + test_event_ids.append( + event_definition.event_id + ) + + if not test_event_ids.is_empty(): + + test_event_select.set_block_signals( + true + ) + + test_event_select.select( + 0 + ) + + test_event_select.set_block_signals( + false + ) + +func _on_test_cabinet_select_item_selected( + index: int +) -> void: + + _populate_test_events( + index + ) + +func _on_test_event_button_pressed() -> void: + + var cabinet_index := ( + test_cabinet_select.selected + ) + + var event_index := ( + test_event_select.selected + ) + + # + # The editor can temporarily report -1 even when + # an OptionButton contains entries. Fall back to + # the first available item. + # + if ( + cabinet_index < 0 + and not test_cabinet_ids.is_empty() + ): + cabinet_index = 0 + + if ( + event_index < 0 + and not test_event_ids.is_empty() + ): + event_index = 0 + + if ( + cabinet_index < 0 + or cabinet_index >= test_cabinet_ids.size() + ): + return + + if ( + event_index < 0 + or event_index >= test_event_ids.size() + ): + return + + var cabinet_id := ( + test_cabinet_ids[ + cabinet_index + ] + ) + + var event_id := ( + test_event_ids[ + event_index + ] + ) + + print( + "[EDITOR SIMULATION] %s -> %s" + % [ + cabinet_id, + event_id + ] + ) + + simulate_game_event( + cabinet_id, + event_id + ) + +func _on_room_layout_item_selected( + item_id: StringName +) -> void: + + _refresh_property_panel( + item_id + ) + + if room_layout.selected_item_is_cabinet(): + editor_selection.select_cabinet( + item_id + ) + else: + editor_selection.select_device( + item_id + ) + +func _refresh_property_panel( + item_id: StringName +) -> void: + + if context == null: + return + + var room := context.get_room() + + if room == null: + return + + var position := ( + room_layout.get_selected_position() + ) + + x_spin_box.set_value_no_signal( + position.x * METERS_TO_FEET + ) + + y_spin_box.set_value_no_signal( + position.y * METERS_TO_FEET + ) + + if room_layout.selected_item_is_cabinet(): + var placement := ( + room.get_cabinet_placement( + item_id + ) + ) + + selected_label.text = ( + "Cabinet: %s" + % item_id + ) + + rotation_spin_box.editable = true + + if placement != null: + rotation_spin_box.set_value_no_signal( + placement.rotation_degrees + ) + + else: + var device := ( + room.get_device( + item_id + ) + ) + + if device != null: + selected_label.text = ( + "Device: %s" + % device.display_name + ) + else: + selected_label.text = ( + "Device: %s" + % item_id + ) + + # + # Room devices currently have position, + # but no rotation property. + # + rotation_spin_box.set_value_no_signal( + 0.0 + ) + + rotation_spin_box.editable = false + +func _on_x_spin_box_value_changed( + value_ft: float +) -> void: + + if room_layout.selected_item_id == &"": + return + + var old_position := ( + room_layout.get_selected_position() + ) + + var requested_position := Vector2( + value_ft * FEET_TO_METERS, + old_position.y + ) + + var new_position := ( + room_layout.snap_item_position_for_editor( + room_layout.selected_item_id, + requested_position + ) + ) + + _commit_position_change( + old_position, + new_position + ) + +func _on_y_spin_box_value_changed( + value_ft: float +) -> void: + + if room_layout.selected_item_id == &"": + return + + var old_position := ( + room_layout.get_selected_position() + ) + + var requested_position := Vector2( + old_position.x, + value_ft * FEET_TO_METERS + ) + + var new_position := ( + room_layout.snap_item_position_for_editor( + room_layout.selected_item_id, + requested_position + ) + ) + + _commit_position_change( + old_position, + new_position + ) + +func _on_rotation_spin_box_value_changed( + value: float +) -> void: + + if room_layout.selected_item_id == &"": + return + + if not room_layout.selected_item_is_cabinet(): + return + + var old_rotation := ( + room_layout.get_selected_rotation() + ) + + _commit_rotation_change( + old_rotation, + value + ) + +func _refresh_selected_property_values() -> void: + + if room_layout.selected_item_id == &"": + return + + var position := ( + room_layout.get_selected_position() + ) + + x_spin_box.set_value_no_signal( + position.x * METERS_TO_FEET + ) + + y_spin_box.set_value_no_signal( + position.y * METERS_TO_FEET + ) + + rotation_spin_box.set_value_no_signal( + room_layout.get_selected_rotation() + ) + +func _on_snap_check_box_toggled( + enabled: bool +) -> void: + + room_layout.snap_enabled = enabled + +func _on_grid_spin_box_value_changed( + value_ft: float +) -> void: + + room_layout.grid_size_meters = max( + value_ft * FEET_TO_METERS, + 0.01 + ) + room_layout.queue_redraw() + +func _on_show_connections_check_box_toggled( + enabled: bool +) -> void: + + room_layout.set_connections_visible( + enabled + ) + +func _clear_property_panel() -> void: + + selected_label.text = ( + "Nothing selected" + ) + + x_spin_box.set_value_no_signal( + 0.0 + ) + + y_spin_box.set_value_no_signal( + 0.0 + ) + + rotation_spin_box.set_value_no_signal( + 0.0 + ) + + rotation_spin_box.editable = false + +func set_undo_redo_manager( + manager: EditorUndoRedoManager +) -> void: + + undo_redo = manager + +func _apply_selected_position( + position: Vector2 +) -> void: + + room_layout.set_selected_position( + position + ) + + _refresh_selected_property_values() + +func _commit_position_change( + old_position: Vector2, + new_position: Vector2 +) -> void: + + if old_position == new_position: + _refresh_selected_property_values() + return + + var item_id := ( + room_layout.selected_item_id + ) + + if item_id == &"": + return + + if undo_redo == null: + _apply_item_position( + item_id, + new_position + ) + return + + undo_redo.create_action( + "Move Room Item", + UndoRedo.MERGE_ENDS, + context.get_room() + ) + + undo_redo.add_do_method( + self, + "_apply_item_position", + item_id, + new_position + ) + + undo_redo.add_undo_method( + self, + "_apply_item_position", + item_id, + old_position + ) + + undo_redo.commit_action() + +func _apply_selected_rotation( + rotation: float +) -> void: + + room_layout.set_selected_rotation( + rotation + ) + + _refresh_selected_property_values() + +func _commit_rotation_change( + old_rotation: float, + new_rotation: float +) -> void: + + if is_equal_approx( + old_rotation, + new_rotation + ): + return + + var cabinet_id := ( + room_layout.selected_item_id + ) + + if cabinet_id == &"": + return + + if undo_redo == null: + _apply_cabinet_rotation( + cabinet_id, + new_rotation + ) + return + + undo_redo.create_action( + "Rotate Cabinet", + UndoRedo.MERGE_ENDS, + context.get_room() + ) + + undo_redo.add_do_method( + self, + "_apply_cabinet_rotation", + cabinet_id, + new_rotation + ) + + undo_redo.add_undo_method( + self, + "_apply_cabinet_rotation", + cabinet_id, + old_rotation + ) + + undo_redo.commit_action() + +func _on_wall_creation_requested( + start: Vector2, + end: Vector2 +) -> void: + + if context == null: + return + + var target_room := ( + context.get_room() + ) + + if target_room == null: + return + + var wall := ( + RoomWallDefinition.new() + ) + + wall.wall_id = ( + _generate_wall_id( + target_room + ) + ) + + wall.start = start + wall.end = end + wall.thickness = 0.10 + + if undo_redo == null: + _add_wall( + target_room, + wall + ) + return + + undo_redo.create_action( + "Add Room Wall", + UndoRedo.MERGE_DISABLE, + target_room + ) + + undo_redo.add_do_method( + self, + "_add_wall", + target_room, + wall + ) + + undo_redo.add_undo_method( + self, + "_remove_wall", + target_room, + wall + ) + + undo_redo.commit_action() + +func _generate_wall_id( + room: RoomDefinition +) -> StringName: + + var index := 1 + + while true: + var candidate := StringName( + "wall_%03d" + % index + ) + + if not room.has_wall( + candidate + ): + return candidate + + index += 1 + + return &"wall" + +func _add_wall( + target_room: RoomDefinition, + wall: RoomWallDefinition +) -> void: + + if target_room == null: + return + + if wall == null: + return + + if target_room.walls.has( + wall + ): + return + + target_room.walls.append( + wall + ) + + context.room_edit_session.mark_dirty() + + room_layout.queue_redraw() + + +func _remove_wall( + target_room: RoomDefinition, + wall: RoomWallDefinition +) -> void: + + if target_room == null: + return + + if wall == null: + return + + target_room.walls.erase( + wall + ) + + context.room_edit_session.mark_dirty() + + if ( + room_layout.selected_wall_id + == wall.wall_id + ): + room_layout.selected_wall_id = &"" + _clear_property_panel() + + room_layout.queue_redraw() + +func _on_select_tool_button_pressed() -> void: + + select_tool_button.button_pressed = true + + room_layout.set_edit_mode( + RoomLayout.EditMode.SELECT + ) + + +func _on_draw_wall_button_pressed() -> void: + + draw_wall_button.button_pressed = true + + room_layout.set_edit_mode( + RoomLayout.EditMode.DRAW_WALL + ) + + +func _on_edit_boundary_button_pressed() -> void: + + edit_boundary_button.button_pressed = true + + room_layout.set_edit_mode( + RoomLayout.EditMode.EDIT_BOUNDARY + ) + +func _on_room_wall_selected( + wall_id: StringName +) -> void: + + _refresh_wall_property_panel( + wall_id + ) + + editor_selection.select_wall( + wall_id + ) + +func _refresh_wall_property_panel( + wall_id: StringName +) -> void: + + if context == null: + return + + var room := context.get_room() + + if room == null: + return + + var wall := room.get_wall( + wall_id + ) + + if wall == null: + return + + selected_label.text = ( + "Wall: %s" + % wall.wall_id + ) + + wall_start_x_spin_box.set_value_no_signal( + wall.start.x * METERS_TO_FEET + ) + + wall_start_y_spin_box.set_value_no_signal( + wall.start.y * METERS_TO_FEET + ) + + wall_end_x_spin_box.set_value_no_signal( + wall.end.x * METERS_TO_FEET + ) + + wall_end_y_spin_box.set_value_no_signal( + wall.end.y * METERS_TO_FEET + ) + + wall_thickness_spin_box.set_value_no_signal( + wall.thickness_ft + ) + +func _apply_wall_geometry( + target_room: RoomDefinition, + wall: RoomWallDefinition, + start: Vector2, + end: Vector2 +) -> void: + + if target_room == null: + return + + if wall == null: + return + + if not target_room.walls.has( + wall + ): + return + + wall.start = start + wall.end = end + + context.room_edit_session.mark_dirty() + + room_layout.queue_redraw() + + if ( + room_layout.selected_wall_id + == wall.wall_id + ): + _refresh_wall_property_panel( + wall.wall_id + ) + +func _commit_wall_geometry_change( + wall: RoomWallDefinition, + old_start: Vector2, + old_end: Vector2, + new_start: Vector2, + new_end: Vector2 +) -> void: + + if ( + old_start == new_start + and old_end == new_end + ): + _refresh_wall_property_panel(wall.wall_id) + return + + if context == null: + return + + var target_room := ( + context.get_room() + ) + + if target_room == null: + return + + if undo_redo == null: + _apply_wall_geometry( + target_room, + wall, + new_start, + new_end + ) + return + + undo_redo.create_action( + "Edit Wall", + UndoRedo.MERGE_ENDS, + target_room + ) + + undo_redo.add_do_method( + self, + "_apply_wall_geometry", + target_room, + wall, + new_start, + new_end + ) + + undo_redo.add_undo_method( + self, + "_apply_wall_geometry", + target_room, + wall, + old_start, + old_end + ) + + undo_redo.commit_action() + +func _on_wall_start_x_spin_box_value_changed( + value_ft: float +) -> void: + + var wall := room_layout.get_selected_wall() + + if wall == null: + return + + var old_start := wall.start + var old_end := wall.end + + var new_start := Vector2( + value_ft * FEET_TO_METERS, + old_start.y + ) + + new_start = room_layout.snap_position_for_editor( + new_start + ) + + _commit_wall_geometry_change( + wall, + old_start, + old_end, + new_start, + old_end + ) + +func _on_wall_start_y_spin_box_value_changed( + value_ft: float +) -> void: + + var wall := room_layout.get_selected_wall() + + if wall == null: + return + + var old_start := wall.start + var old_end := wall.end + + var new_start := Vector2( + old_start.x, + value_ft * FEET_TO_METERS + ) + + new_start = room_layout.snap_position_for_editor( + new_start + ) + + _commit_wall_geometry_change( + wall, + old_start, + old_end, + new_start, + old_end + ) + +func _on_wall_end_x_spin_box_value_changed( + value_ft: float +) -> void: + + var wall := room_layout.get_selected_wall() + + if wall == null: + return + + var old_start := wall.start + var old_end := wall.end + + var new_end := Vector2( + value_ft * FEET_TO_METERS, + old_end.y + ) + + new_end = room_layout.snap_position_for_editor( + new_end + ) + + _commit_wall_geometry_change( + wall, + old_start, + old_end, + old_start, + new_end + ) + +func _on_wall_end_y_spin_box_value_changed( + value_ft: float +) -> void: + + var wall := room_layout.get_selected_wall() + + if wall == null: + return + + var old_start := wall.start + var old_end := wall.end + + var new_end := Vector2( + old_end.x, + value_ft * FEET_TO_METERS + ) + + new_end = room_layout.snap_position_for_editor( + new_end + ) + + _commit_wall_geometry_change( + wall, + old_start, + old_end, + old_start, + new_end + ) + +func _apply_wall_thickness( + target_room: RoomDefinition, + wall: RoomWallDefinition, + thickness_ft: float +) -> void: + + if target_room == null: + return + + if wall == null: + return + + if not target_room.walls.has( + wall + ): + return + + wall.thickness_ft = thickness_ft + + context.room_edit_session.mark_dirty() + + room_layout.queue_redraw() + + if ( + room_layout.selected_wall_id + == wall.wall_id + ): + _refresh_wall_property_panel( + wall.wall_id + ) + +func _on_wall_thickness_spin_box_value_changed( + value: float +) -> void: + + var wall := ( + room_layout.get_selected_wall() + ) + + if wall == null: + return + + if context == null: + return + + var target_room := ( + context.get_room() + ) + + if target_room == null: + return + + var old_value_ft := ( + wall.thickness_ft + ) + + if is_equal_approx( + old_value_ft, + value + ): + return + + if undo_redo == null: + + _apply_wall_thickness( + target_room, + wall, + value + ) + + return + + undo_redo.create_action( + "Change Wall Thickness", + UndoRedo.MERGE_ENDS, + target_room + ) + + undo_redo.add_do_method( + self, + "_apply_wall_thickness", + target_room, + wall, + value + ) + + undo_redo.add_undo_method( + self, + "_apply_wall_thickness", + target_room, + wall, + old_value_ft + ) + + undo_redo.commit_action() + +func _on_delete_wall_button_pressed() -> void: + + if context == null: + return + + var target_room := ( + context.get_room() + ) + + if target_room == null: + return + + var wall := ( + room_layout.get_selected_wall() + ) + + if wall == null: + return + + if undo_redo == null: + _remove_wall( + target_room, + wall + ) + return + + undo_redo.create_action( + "Delete Room Wall", + UndoRedo.MERGE_DISABLE, + target_room + ) + + undo_redo.add_do_method( + self, + "_remove_wall", + target_room, + wall + ) + + undo_redo.add_undo_method( + self, + "_add_wall", + target_room, + wall + ) + + undo_redo.commit_action() + +func _on_wall_geometry_edit_requested( + wall_id: StringName, + old_start: Vector2, + old_end: Vector2, + new_start: Vector2, + new_end: Vector2 +) -> void: + + if context == null: + return + + var room := context.get_room() + + if room == null: + return + + var wall := room.get_wall( + wall_id + ) + + if wall == null: + return + + _commit_wall_geometry_change( + wall, + old_start, + old_end, + new_start, + new_end + ) + +func _on_editor_selection_changed() -> void: + + room_layout.apply_editor_selection( + editor_selection + ) + + logic_graph.apply_editor_selection( + editor_selection + ) + +func _on_logic_path_selected( + cabinet_id: StringName, + event_id: StringName, + action_id: StringName, + device_id: StringName +) -> void: + + editor_selection.select_logic_path( + cabinet_id, + event_id, + action_id, + device_id + ) + +func _apply_boundary_geometry( + points: PackedVector2Array +) -> void: + + if context == null: + return + + var room := context.get_room() + + if room == null: + return + + room.boundary_points = ( + points.duplicate() + ) + + context.room_edit_session.mark_dirty() + + room_layout.queue_redraw() + +func _on_boundary_geometry_edit_requested( + old_points: PackedVector2Array, + new_points: PackedVector2Array +) -> void: + + if old_points == new_points: + return + + if undo_redo == null: + _apply_boundary_geometry( + new_points + ) + return + + undo_redo.create_action( + "Edit Room Boundary", + UndoRedo.MERGE_ENDS, + context.get_room() + ) + + undo_redo.add_do_method( + self, + "_apply_boundary_geometry", + new_points + ) + + undo_redo.add_undo_method( + self, + "_apply_boundary_geometry", + old_points + ) + + undo_redo.commit_action() + +func _on_focus_room_button_pressed() -> void: + + room_layout.focus_room() + +func _on_add_boundary_point_button_pressed() -> void: + + add_boundary_point_button.button_pressed = true + + room_layout.set_edit_mode( + RoomLayout.EditMode.ADD_BOUNDARY_POINT + ) + +func _on_delete_boundary_point_button_pressed() -> void: + + delete_boundary_point_button.button_pressed = true + + room_layout.set_edit_mode( + RoomLayout.EditMode.DELETE_BOUNDARY_POINT + ) + +func _on_room_layout_edit_rejected( + message: String +) -> void: + + status_label.text = ( + "Edit rejected: %s" + % message + ) + +func _on_reset_view_button_pressed() -> void: + + room_layout.reset_view() + +func _on_room_layout_zoom_changed( + zoom_value: float +) -> void: + + zoom_label.text = ( + "%d%%" + % roundi( + zoom_value * 100.0 + ) + ) + +func _apply_cabinet_rotation( + cabinet_id: StringName, + rotation: float +) -> void: + + if context == null: + return + + var room := context.get_room() + + if room == null: + return + + var placement := ( + room.get_cabinet_placement( + cabinet_id + ) + ) + + if placement == null: + return + + placement.rotation_degrees = rotation + + context.room_edit_session.mark_dirty() + + room_layout.queue_redraw() + room_layout._reposition_items() + +func _unhandled_key_input( + event: InputEvent +) -> void: + + if not Engine.is_editor_hint(): + return + + if not ( + event is InputEventKey + ): + return + + if not event.pressed: + return + + if event.echo: + return + + var ctrl_pressed: bool = ( + event.ctrl_pressed + or event.meta_pressed + ) + + if not ctrl_pressed: + return + + if ( + event.keycode == KEY_Z + and event.shift_pressed + ): + redo_escape_room_action() + get_viewport().set_input_as_handled() + return + + if event.keycode == KEY_Z: + undo_escape_room_action() + get_viewport().set_input_as_handled() + return + + if event.keycode == KEY_Y: + redo_escape_room_action() + get_viewport().set_input_as_handled() + +func _get_active_escape_room_undo_redo() -> UndoRedo: + + if undo_redo == null: + return null + + if context == null: + return null + + var target: Object = null + + var current_tab_name := ( + main_views.get_tab_title( + main_views.current_tab + ) + ) + + if current_tab_name == "Logic": + target = context.get_scenario() + else: + target = context.get_room() + + if target == null: + return null + + var history_id := ( + undo_redo.get_object_history_id( + target + ) + ) + + return ( + undo_redo.get_history_undo_redo( + history_id + ) + ) + +func undo_escape_room_action() -> void: + + var history := ( + _get_active_escape_room_undo_redo() + ) + + if history == null: + print("[UNDO] No history") + return + + print( + "[UNDO] has_undo=", + history.has_undo() + ) + + if history.has_undo(): + history.undo() + + +func redo_escape_room_action() -> void: + + var history := ( + _get_active_escape_room_undo_redo() + ) + + if history == null: + return + + if not history.has_redo(): + return + + history.redo() + +func _on_room_layout_item_position_edit_requested( + item_id: StringName, + old_position: Vector2, + new_position: Vector2 +) -> void: + + if old_position == new_position: + return + + if undo_redo == null: + _apply_item_position( + item_id, + new_position + ) + return + + undo_redo.create_action( + "Move Room Item", + UndoRedo.MERGE_DISABLE, + context.get_room() + ) + + undo_redo.add_do_method( + self, + "_apply_item_position", + item_id, + new_position + ) + + undo_redo.add_undo_method( + self, + "_apply_item_position", + item_id, + old_position + ) + + undo_redo.commit_action() + +func _on_room_layout_binding_route_edit_requested( + cabinet_id: StringName, + action_id: StringName, + device_id: StringName, + old_points: PackedVector2Array, + new_points: PackedVector2Array +) -> void: + + if old_points == new_points: + return + + if context == null: + return + + if undo_redo == null: + + _apply_binding_route_points( + cabinet_id, + action_id, + device_id, + new_points + ) + + return + + undo_redo.create_action( + "Move Cable Route Point", + UndoRedo.MERGE_DISABLE, + context.get_room() + ) + + undo_redo.add_do_method( + self, + "_apply_binding_route_points", + cabinet_id, + action_id, + device_id, + new_points.duplicate() + ) + + undo_redo.add_undo_method( + self, + "_apply_binding_route_points", + cabinet_id, + action_id, + device_id, + old_points.duplicate() + ) + + undo_redo.commit_action() + + +func _apply_binding_route_points( + cabinet_id: StringName, + action_id: StringName, + device_id: StringName, + points: PackedVector2Array +) -> void: + + if context == null: + return + + var room := ( + context.get_room() + ) + + if room == null: + return + + var target_binding: RoomDeviceBinding = null + + for binding in room.bindings: + + if binding == null: + continue + + if ( + binding.cabinet_id == cabinet_id + and binding.hardware_action_id == action_id + and binding.device_id == device_id + ): + + target_binding = binding + break + + if target_binding == null: + return + + target_binding.route_points = ( + points.duplicate() + ) + + context.room_edit_session.mark_dirty() + + room_layout.refresh_item_positions() + +func _apply_item_position( + item_id: StringName, + position: Vector2 +) -> void: + + if context == null: + return + + var room := ( + context.get_room() + ) + + if room == null: + return + + var placement := ( + room.get_cabinet_placement( + item_id + ) + ) + + if placement != null: + + placement.position = ( + position + ) + + else: + + var device := ( + room.get_device( + item_id + ) + ) + + if device == null: + return + + device.position = ( + position + ) + + context.room_edit_session.mark_dirty() + + room_layout.refresh_item_positions() + + if room_layout.selected_item_id == item_id: + _refresh_selected_property_values() + +func _on_simulation_pause_requested() -> void: + + simulation_driver.set_paused( + true + ) + + simulation_timeline.set_paused( + true + ) + + +func _on_simulation_resume_requested() -> void: + + simulation_driver.set_paused( + false + ) + + simulation_timeline.set_paused( + false + ) + + +func _on_simulation_step_requested( + seconds: float +) -> void: + + simulation_driver.step( + seconds + ) + + +func _on_simulation_reset_requested() -> void: + + simulation_driver.reset_simulation() + + _reset_simulation_visuals() + + simulation_timeline.clear() + + simulation_timeline.set_paused( + false + ) + + simulation_timeline.set_sequence_running( + false + ) + +func _load_test_sequences() -> void: + + test_sequences.clear() + + var directory := DirAccess.open( + TEST_SEQUENCE_DIRECTORY + ) + + if directory == null: + + push_warning( + "Could not open test sequence directory: %s" + % TEST_SEQUENCE_DIRECTORY + ) + + simulation_timeline.set_sequences( + test_sequences + ) + + return + + directory.list_dir_begin() + + while true: + + var file_name := ( + directory.get_next() + ) + + if file_name.is_empty(): + break + + if directory.current_is_dir(): + continue + + if not file_name.ends_with( + ".tres" + ): + continue + + var path := ( + TEST_SEQUENCE_DIRECTORY + .path_join( + file_name + ) + ) + + var resource := ( + ResourceLoader.load( + path + ) + ) + + var sequence := ( + resource as TestSequence + ) + + if sequence == null: + + push_warning( + "Resource is not a TestSequence: %s" + % path + ) + + continue + + test_sequences.append( + sequence + ) + + directory.list_dir_end() + + test_sequences.sort_custom( + func( + a: TestSequence, + b: TestSequence + ) -> bool: + + return ( + a.display_name + < b.display_name + ) + ) + + simulation_timeline.set_sequences( + test_sequences + ) + + if test_sequences.is_empty(): + selected_test_sequence_index = -1 + else: + selected_test_sequence_index = 0 + +func _on_test_sequence_selected( + index: int +) -> void: + + if ( + index < 0 + or index >= test_sequences.size() + ): + selected_test_sequence_index = -1 + return + + selected_test_sequence_index = index + + +func _on_run_test_sequence_requested() -> void: + + if ( + selected_test_sequence_index < 0 + or selected_test_sequence_index + >= test_sequences.size() + ): + return + + var sequence := ( + test_sequences[ + selected_test_sequence_index + ] + ) + + if sequence == null: + return + + var current_scenario := ( + context.get_scenario() + ) + + if current_scenario == null: + + status_label.text = ( + "Cannot run rehearsal: no scenario is open." + ) + + return + + if ( + sequence.scenario_id != &"" + and sequence.scenario_id + != current_scenario.scenario_id + ): + + status_label.text = ( + "Rehearsal '%s' requires scenario '%s'." + % [ + sequence.display_name, + sequence.scenario_id + ] + ) + + push_warning( + ( + "Cannot run rehearsal '%s': " + + "current scenario is '%s', " + + "but rehearsal requires '%s'." + ) + % [ + sequence.display_name, + current_scenario.scenario_id, + sequence.scenario_id + ] + ) + + return + + _reset_simulation_visuals() + + simulation_timeline.clear() + + simulation_driver.start_sequence( + sequence + ) + + +func _on_stop_test_sequence_requested() -> void: + + simulation_driver.stop_sequence() + + +func _on_replay_test_sequence_requested() -> void: + + _on_run_test_sequence_requested() + + +func _on_test_sequence_started( + _sequence: TestSequence +) -> void: + + simulation_timeline.set_sequence_running( + true + ) + + +func _on_test_sequence_finished( + _sequence: TestSequence +) -> void: + + simulation_timeline.set_sequence_running( + false + ) + + +func _on_test_sequence_stopped() -> void: + + simulation_timeline.set_sequence_running( + false + ) + +func _reset_simulation_visuals() -> void: + + if context == null: + return + + var room := ( + context.get_room() + ) + + if room == null: + return + + room_simulator.configure( + room + ) + + for device in room.devices: + + if device == null: + continue + + room_layout.set_device_active( + device.device_id, + device.initial_active + ) + + for binding in room.bindings: + + if binding == null: + continue + + room_layout.set_binding_active( + binding.cabinet_id, + binding.hardware_action_id, + binding.device_id, + false + ) + +func _on_room_layout_refresh_requested() -> void: + + if context == null: + return + + if context.room_edit_session.is_dirty: + + status_label.text = ( + "Cannot refresh: save or revert " + + "unsaved room changes first." + ) + + return + + if not context.room_edit_session.reload_from_disk(): + + status_label.text = ( + "Failed to refresh room." + ) + + return + + status_label.text = ( + "Physical room refreshed." + ) diff --git a/editor/workspace/escape_room_workspace.gd.uid b/editor/workspace/escape_room_workspace.gd.uid new file mode 100644 index 0000000..d37dea5 --- /dev/null +++ b/editor/workspace/escape_room_workspace.gd.uid @@ -0,0 +1 @@ +uid://jxob25ucisxl diff --git a/editor/workspace/simulation_timeline.gd b/editor/workspace/simulation_timeline.gd new file mode 100644 index 0000000..4880bcb --- /dev/null +++ b/editor/workspace/simulation_timeline.gd @@ -0,0 +1,677 @@ +@tool +class_name SimulationTimeline +extends VBoxContainer + +signal pause_requested +signal resume_requested +signal step_requested( + seconds: float +) +signal reset_requested + +signal sequence_selected( + index: int +) + +signal run_sequence_requested +signal stop_sequence_requested +signal replay_sequence_requested + +var sequence_select: OptionButton +var run_button: Button +var stop_button: Button +var replay_button: Button + +var tree: Tree +var clear_button: Button +var count_label: Label + +var _first_trace_time: float = -1.0 +var _entry_count: int = 0 + +var pause_button: Button +var step_button: Button +var reset_button: Button + +var _paused: bool = false + +func _ready() -> void: + + if tree != null: + return + + _build_ui() + + +func _build_ui() -> void: + + var toolbar := HBoxContainer.new() + + add_child( + toolbar + ) + + + var title := Label.new() + + title.text = "Simulation Timeline" + + title.size_flags_horizontal = ( + Control.SIZE_EXPAND_FILL + ) + + toolbar.add_child( + title + ) + + + count_label = Label.new() + + count_label.text = "0 events" + + toolbar.add_child( + count_label + ) + + sequence_select = OptionButton.new() + + sequence_select.custom_minimum_size.x = 180.0 + + sequence_select.item_selected.connect( + func(index: int): + sequence_selected.emit( + index + ) + ) + + toolbar.add_child( + sequence_select + ) + + + run_button = Button.new() + + run_button.text = "Run" + + run_button.pressed.connect( + func(): + run_sequence_requested.emit() + ) + + toolbar.add_child( + run_button + ) + + + stop_button = Button.new() + + stop_button.text = "Stop" + + stop_button.disabled = true + + stop_button.pressed.connect( + func(): + stop_sequence_requested.emit() + ) + + toolbar.add_child( + stop_button + ) + + + replay_button = Button.new() + + replay_button.text = "Replay" + + replay_button.pressed.connect( + func(): + replay_sequence_requested.emit() + ) + + toolbar.add_child( + replay_button + ) + + pause_button = Button.new() + + pause_button.text = "Pause" + + pause_button.pressed.connect( + _on_pause_button_pressed + ) + + toolbar.add_child( + pause_button + ) + + + step_button = Button.new() + + step_button.text = "Step 0.1s" + + step_button.pressed.connect( + func(): + step_requested.emit( + 0.1 + ) + ) + + toolbar.add_child( + step_button + ) + + + reset_button = Button.new() + + reset_button.text = "Reset" + + reset_button.pressed.connect( + func(): + reset_requested.emit() + ) + + toolbar.add_child( + reset_button + ) + + clear_button = Button.new() + + clear_button.text = "Clear" + + clear_button.pressed.connect( + clear + ) + + toolbar.add_child( + clear_button + ) + + + tree = Tree.new() + + tree.size_flags_horizontal = ( + Control.SIZE_EXPAND_FILL + ) + + tree.size_flags_vertical = ( + Control.SIZE_EXPAND_FILL + ) + + tree.custom_minimum_size.y = 180.0 + + tree.columns = 4 + + tree.set_column_title( + 0, + "Time" + ) + + tree.set_column_title( + 1, + "Type" + ) + + tree.set_column_title( + 2, + "Source / Target" + ) + + tree.set_column_title( + 3, + "Detail" + ) + + tree.column_titles_visible = true + + tree.set_column_expand( + 0, + false + ) + + tree.set_column_custom_minimum_width( + 0, + 80 + ) + + tree.set_column_expand( + 1, + false + ) + + tree.set_column_custom_minimum_width( + 1, + 90 + ) + + tree.set_column_expand( + 2, + true + ) + + tree.set_column_expand( + 3, + true + ) + + add_child( + tree + ) + + tree.create_item() + set_paused( + false + ) + +func clear() -> void: + + _first_trace_time = -1.0 + _entry_count = 0 + + if count_label != null: + count_label.text = "0 events" + + if tree == null: + return + + tree.clear() + + tree.create_item() + + +func add_trace_entry( + entry: Dictionary +) -> void: + + if tree == null: + _build_ui() + + var absolute_time := float( + entry.get( + "time", + 0.0 + ) + ) + + if _first_trace_time < 0.0: + _first_trace_time = absolute_time + + var relative_time := ( + absolute_time + - _first_trace_time + ) + + var root := tree.get_root() + + if root == null: + root = tree.create_item() + + var item := tree.create_item( + root + ) + + item.set_text( + 0, + "%.3f" % relative_time + ) + + var entry_type := StringName( + entry.get( + "type", + &"unknown" + ) + ) + + item.set_text( + 1, + String(entry_type).to_upper() + ) + + item.set_text( + 2, + _get_subject_text( + entry + ) + ) + + item.set_text( + 3, + _get_detail_text( + entry + ) + ) + + _entry_count += 1 + + if count_label != null: + + count_label.text = ( + "%d events" + % _entry_count + ) + + tree.scroll_to_item( + item + ) + + +func _get_subject_text( + entry: Dictionary +) -> String: + + var entry_type := StringName( + entry.get( + "type", + &"" + ) + ) + + match entry_type: + + &"event": + return String( + entry.get( + "source_id", + &"" + ) + ) + + &"rule": + return String( + entry.get( + "rule_id", + &"" + ) + ) + + &"action": + return String( + entry.get( + "target_id", + &"" + ) + ) + + &"gpio": + return String( + entry.get( + "cabinet_id", + &"" + ) + ) + + &"device": + return String( + entry.get( + "device_id", + &"" + ) + ) + + &"input": + return String( + entry.get( + "source_id", + &"" + ) + ) + + &"sequence": + return String( + entry.get( + "display_name", + entry.get( + "sequence_id", + &"" + ) + ) + ) + _: + return "" + + +func _get_detail_text( + entry: Dictionary +) -> String: + + var entry_type := StringName( + entry.get( + "type", + &"" + ) + ) + + match entry_type: + + &"event": + + return String( + entry.get( + "event_id", + &"" + ) + ) + + + &"rule": + + var target_id := String( + entry.get( + "target_id", + &"" + ) + ) + + var action_id := String( + entry.get( + "action_id", + &"" + ) + ) + + var delay := float( + entry.get( + "delay_seconds", + 0.0 + ) + ) + + if delay > 0.0: + + return ( + "→ %s.%s after %.3fs" + % [ + target_id, + action_id, + delay + ] + ) + + return ( + "→ %s.%s" + % [ + target_id, + action_id + ] + ) + + + &"action": + + return String( + entry.get( + "action_id", + &"" + ) + ) + + + &"gpio": + + var pin := int( + entry.get( + "gpio_pin", + -1 + ) + ) + + var electrical_state := bool( + entry.get( + "electrical_state", + false + ) + ) + + var action_id := String( + entry.get( + "action_id", + &"" + ) + ) + + var level := "LOW" + + if electrical_state: + level = "HIGH" + + return ( + "GPIO %d %s (%s)" + % [ + pin, + level, + action_id + ] + ) + + &"device": + + var active := bool( + entry.get( + "active", + false + ) + ) + + if active: + return "ACTIVE" + + return "INACTIVE" + + &"input": + + var event_id := String( + entry.get( + "event_id", + &"" + ) + ) + + var note := String( + entry.get( + "note", + "" + ) + ) + + if not note.is_empty(): + + return ( + "%s — %s" + % [ + event_id, + note + ] + ) + + return event_id + + + &"sequence": + + var state := String( + entry.get( + "state", + &"" + ) + ) + + return state.to_upper() + _: + return str( + entry + ) + +func _on_pause_button_pressed() -> void: + + if _paused: + + resume_requested.emit() + + else: + + pause_requested.emit() + +func set_paused( + value: bool +) -> void: + + _paused = value + + if pause_button != null: + + if _paused: + pause_button.text = "Resume" + else: + pause_button.text = "Pause" + + if step_button != null: + step_button.disabled = not _paused + +func set_sequences( + sequences: Array[TestSequence] +) -> void: + + if sequence_select == null: + return + + sequence_select.clear() + + for sequence in sequences: + + if sequence == null: + continue + + var label := ( + sequence.display_name + ) + + if label.is_empty(): + label = String( + sequence.sequence_id + ) + + sequence_select.add_item( + label + ) + + if sequence_select.item_count > 0: + + sequence_select.select( + 0 + ) + + +func set_sequence_running( + running: bool +) -> void: + + if run_button != null: + run_button.disabled = running + + if stop_button != null: + stop_button.disabled = not running + + if replay_button != null: + replay_button.disabled = running + + if sequence_select != null: + sequence_select.disabled = running diff --git a/editor/workspace/simulation_timeline.gd.uid b/editor/workspace/simulation_timeline.gd.uid new file mode 100644 index 0000000..0031b0b --- /dev/null +++ b/editor/workspace/simulation_timeline.gd.uid @@ -0,0 +1 @@ +uid://b0tn15y67fac4 diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..d164dfb --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,121 @@ +[runnable_presets] + +Linux="Linux" +"Windows Desktop"="Windows Desktop" + +[preset.0] + +name="Linux" +platform="Linux" +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="../ArcadeEscape.arm64" +patches=PackedStringArray() +patch_delta_encoding=false +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=false +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +shader_baker/enabled=false +binary_format/architecture="arm64" +ssh_remote_deploy/enabled=false +ssh_remote_deploy/host="user@host_ip" +ssh_remote_deploy/port="22" +ssh_remote_deploy/extra_args_ssh="" +ssh_remote_deploy/extra_args_scp="" +ssh_remote_deploy/run_script="#!/usr/bin/env bash +export DISPLAY=:0 +unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\" +\"{temp_dir}/{exe_name}\" {cmd_args}" +ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash +pkill -x -f \"{temp_dir}/{exe_name} {cmd_args}\" +rm -rf \"{temp_dir}\"" + +[preset.1] + +name="Windows Desktop" +platform="Windows Desktop" +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="../ArcadeEscape.exe" +patches=PackedStringArray() +patch_delta_encoding=false +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.1.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=false +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +shader_baker/enabled=false +binary_format/architecture="x86_64" +codesign/enable=false +codesign/timestamp=true +codesign/timestamp_server_url="" +codesign/digest_algorithm=1 +codesign/description="" +codesign/custom_options=PackedStringArray() +application/modify_resources=true +application/icon="" +application/console_wrapper_icon="" +application/icon_interpolation=4 +application/file_version="" +application/product_version="" +application/company_name="" +application/product_name="" +application/file_description="" +application/copyright="" +application/trademarks="" +application/export_angle=0 +application/export_d3d12=0 +application/d3d12_agility_sdk_multiarch=true +ssh_remote_deploy/enabled=false +ssh_remote_deploy/host="user@host_ip" +ssh_remote_deploy/port="22" +ssh_remote_deploy/extra_args_ssh="" +ssh_remote_deploy/extra_args_scp="" +ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}' +$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}' +$trigger = New-ScheduledTaskTrigger -Once -At 00:00 +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries +$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings +Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true +Start-ScheduledTask -TaskName godot_remote_debug +while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 } +Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue" +ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue +Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue +Remove-Item -Recurse -Force '{temp_dir}'" diff --git a/games/frogger/Frogger.tscn b/games/frogger/Frogger.tscn new file mode 100644 index 0000000..5ec2dec --- /dev/null +++ b/games/frogger/Frogger.tscn @@ -0,0 +1,25 @@ +[gd_scene format=3 uid="uid://da7lj6k47rotp"] + +[ext_resource type="Script" uid="uid://fqff3d88cr1j" path="res://games/frogger/frogger.gd" id="1"] +[ext_resource type="Script" uid="uid://df5twn4utxg0n" path="res://games/frogger/frogger_view.gd" id="2"] +[ext_resource type="Script" uid="uid://blkjvi1sb7hmb" path="res://games/frogger/display.gd" id="3"] +[ext_resource type="Script" uid="uid://dh0mu01mar7nl" path="res://games/frogger/frogger_audio.gd" id="4"] + +[node name="Frogger" type="Node" unique_id=1686543748] +script = ExtResource("1") + +[node name="PixelScreen" type="SubViewport" parent="." unique_id=865659315] +disable_3d = true +size = Vector2i(224, 256) +render_target_update_mode = 4 + +[node name="Board" type="Node2D" parent="PixelScreen" unique_id=184374191] +texture_filter = 1 +script = ExtResource("2") + +[node name="Display" type="Node2D" parent="." unique_id=333917260] +texture_filter = 1 +script = ExtResource("3") + +[node name="Audio" type="Node" parent="." unique_id=712591344] +script = ExtResource("4") diff --git a/games/frogger/art/spr_frogger.png b/games/frogger/art/spr_frogger.png new file mode 100644 index 0000000..12bed27 Binary files /dev/null and b/games/frogger/art/spr_frogger.png differ diff --git a/games/frogger/art/spr_frogger.png.import b/games/frogger/art/spr_frogger.png.import new file mode 100644 index 0000000..549a2e5 --- /dev/null +++ b/games/frogger/art/spr_frogger.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b242bxbbuaj5o" +path="res://.godot/imported/spr_frogger.png-7ce0f59b8e564b24229faf1647204a8e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/frogger/art/spr_frogger.png" +dest_files=["res://.godot/imported/spr_frogger.png-7ce0f59b8e564b24229faf1647204a8e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/frogger/audio/frogger-music.mp3 b/games/frogger/audio/frogger-music.mp3 new file mode 100644 index 0000000..fa5b182 Binary files /dev/null and b/games/frogger/audio/frogger-music.mp3 differ diff --git a/games/frogger/audio/frogger-music.mp3.import b/games/frogger/audio/frogger-music.mp3.import new file mode 100644 index 0000000..a7a5bb8 --- /dev/null +++ b/games/frogger/audio/frogger-music.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://sp8rad7erusb" +path="res://.godot/imported/frogger-music.mp3-83a0af3950edf8e2ce0550d65f173319.mp3str" + +[deps] + +source_file="res://games/frogger/audio/frogger-music.mp3" +dest_files=["res://.godot/imported/frogger-music.mp3-83a0af3950edf8e2ce0550d65f173319.mp3str"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/games/frogger/audio/sound-frogger-coin-in.wav b/games/frogger/audio/sound-frogger-coin-in.wav new file mode 100644 index 0000000..8044974 Binary files /dev/null and b/games/frogger/audio/sound-frogger-coin-in.wav differ diff --git a/games/frogger/audio/sound-frogger-coin-in.wav.import b/games/frogger/audio/sound-frogger-coin-in.wav.import new file mode 100644 index 0000000..2fff9e7 --- /dev/null +++ b/games/frogger/audio/sound-frogger-coin-in.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://smtgenseeuju" +path="res://.godot/imported/sound-frogger-coin-in.wav-8633ca7444f8a7846bf6405524ad9b37.sample" + +[deps] + +source_file="res://games/frogger/audio/sound-frogger-coin-in.wav" +dest_files=["res://.godot/imported/sound-frogger-coin-in.wav-8633ca7444f8a7846bf6405524ad9b37.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/frogger/audio/sound-frogger-extra.wav b/games/frogger/audio/sound-frogger-extra.wav new file mode 100644 index 0000000..cba7a81 Binary files /dev/null and b/games/frogger/audio/sound-frogger-extra.wav differ diff --git a/games/frogger/audio/sound-frogger-extra.wav.import b/games/frogger/audio/sound-frogger-extra.wav.import new file mode 100644 index 0000000..9917a0e --- /dev/null +++ b/games/frogger/audio/sound-frogger-extra.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://jdgf84jxnjus" +path="res://.godot/imported/sound-frogger-extra.wav-158c017030f00ec8317ac46cfefa90a8.sample" + +[deps] + +source_file="res://games/frogger/audio/sound-frogger-extra.wav" +dest_files=["res://.godot/imported/sound-frogger-extra.wav-158c017030f00ec8317ac46cfefa90a8.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/frogger/audio/sound-frogger-hop.wav b/games/frogger/audio/sound-frogger-hop.wav new file mode 100644 index 0000000..6532641 Binary files /dev/null and b/games/frogger/audio/sound-frogger-hop.wav differ diff --git a/games/frogger/audio/sound-frogger-hop.wav.import b/games/frogger/audio/sound-frogger-hop.wav.import new file mode 100644 index 0000000..e8135f3 --- /dev/null +++ b/games/frogger/audio/sound-frogger-hop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dbeeno1vhd72o" +path="res://.godot/imported/sound-frogger-hop.wav-cf3b2064d4fb4c61b75fee957fe06e77.sample" + +[deps] + +source_file="res://games/frogger/audio/sound-frogger-hop.wav" +dest_files=["res://.godot/imported/sound-frogger-hop.wav-cf3b2064d4fb4c61b75fee957fe06e77.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/frogger/audio/sound-frogger-plunk.wav b/games/frogger/audio/sound-frogger-plunk.wav new file mode 100644 index 0000000..378f2df Binary files /dev/null and b/games/frogger/audio/sound-frogger-plunk.wav differ diff --git a/games/frogger/audio/sound-frogger-plunk.wav.import b/games/frogger/audio/sound-frogger-plunk.wav.import new file mode 100644 index 0000000..9cf5330 --- /dev/null +++ b/games/frogger/audio/sound-frogger-plunk.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c40g5wcvnh1a1" +path="res://.godot/imported/sound-frogger-plunk.wav-73a8475dfe1089b682fa96175c511864.sample" + +[deps] + +source_file="res://games/frogger/audio/sound-frogger-plunk.wav" +dest_files=["res://.godot/imported/sound-frogger-plunk.wav-73a8475dfe1089b682fa96175c511864.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/frogger/audio/sound-frogger-squash.wav b/games/frogger/audio/sound-frogger-squash.wav new file mode 100644 index 0000000..8e4cad1 Binary files /dev/null and b/games/frogger/audio/sound-frogger-squash.wav differ diff --git a/games/frogger/audio/sound-frogger-squash.wav.import b/games/frogger/audio/sound-frogger-squash.wav.import new file mode 100644 index 0000000..34fd96e --- /dev/null +++ b/games/frogger/audio/sound-frogger-squash.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cgy6c1rwyev5e" +path="res://.godot/imported/sound-frogger-squash.wav-1b686c87ff414930a009a0fcd27650d0.sample" + +[deps] + +source_file="res://games/frogger/audio/sound-frogger-squash.wav" +dest_files=["res://.godot/imported/sound-frogger-squash.wav-1b686c87ff414930a009a0fcd27650d0.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/frogger/audio/sound-frogger-time.wav b/games/frogger/audio/sound-frogger-time.wav new file mode 100644 index 0000000..32781c5 Binary files /dev/null and b/games/frogger/audio/sound-frogger-time.wav differ diff --git a/games/frogger/audio/sound-frogger-time.wav.import b/games/frogger/audio/sound-frogger-time.wav.import new file mode 100644 index 0000000..cc8b10a --- /dev/null +++ b/games/frogger/audio/sound-frogger-time.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://clqop5ekc2wkt" +path="res://.godot/imported/sound-frogger-time.wav-18d22f8e1a9342d25829a0bf05ddae73.sample" + +[deps] + +source_file="res://games/frogger/audio/sound-frogger-time.wav" +dest_files=["res://.godot/imported/sound-frogger-time.wav-18d22f8e1a9342d25829a0bf05ddae73.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/frogger/display.gd b/games/frogger/display.gd new file mode 100644 index 0000000..e62e425 --- /dev/null +++ b/games/frogger/display.gd @@ -0,0 +1,19 @@ +@tool +extends Node2D +const LOGICAL := Vector2(224,256) +var destination := Rect2(0,0,672,768) +func _process(_delta: float) -> void: + var available := get_viewport_rect().size if not Engine.is_editor_hint() else Vector2(672,768) + var factor := minf(available.x/7.0,available.y/8.0) + var size := Vector2(7,8)*factor + destination = Rect2((available-size)*.5,size) + queue_redraw() +func to_logical(point: Vector2) -> Vector2: + return (point-destination.position)/destination.size*LOGICAL +func to_logical_delta(delta: Vector2) -> Vector2: + return delta/destination.size*LOGICAL +func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO,get_viewport_rect().size),Color.BLACK) + if has_node("../PixelScreen"): + draw_texture_rect(get_node("../PixelScreen").get_texture(),destination,false) + diff --git a/games/frogger/display.gd.uid b/games/frogger/display.gd.uid new file mode 100644 index 0000000..b581d97 --- /dev/null +++ b/games/frogger/display.gd.uid @@ -0,0 +1 @@ +uid://blkjvi1sb7hmb diff --git a/games/frogger/frogger.gd b/games/frogger/frogger.gd new file mode 100644 index 0000000..758afae --- /dev/null +++ b/games/frogger/frogger.gd @@ -0,0 +1,329 @@ +@tool +extends ArcadeGame +@export_range(0,99) var boards_to_win: int = 1 +@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": + if not homes.has(false): + add_score(1000) + phase = "clear" + phase_time = 2.0 + else: + respawn() + phase = "play" + "clear": + if demo_active: + end_demo() + elif boards_to_win > 0 and board_number >= 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() diff --git a/games/frogger/frogger.gd.uid b/games/frogger/frogger.gd.uid new file mode 100644 index 0000000..3c6c2f5 --- /dev/null +++ b/games/frogger/frogger.gd.uid @@ -0,0 +1 @@ +uid://fqff3d88cr1j diff --git a/games/frogger/frogger_audio.gd b/games/frogger/frogger_audio.gd new file mode 100644 index 0000000..8d9eec8 --- /dev/null +++ b/games/frogger/frogger_audio.gd @@ -0,0 +1,35 @@ +extends Node +var voices: Array[AudioStreamPlayer] = [] +var tracks: Dictionary = {} +var tune: AudioStreamPlayer +var current := 0 +func _ready() -> void: + for name in ["hop","plunk","squash","time","extra","coin-in"]: + tracks[name] = load("res://games/frogger/audio/sound-frogger-"+name+".wav") + for i in range(6): + var voice := AudioStreamPlayer.new() + voice.volume_db = -8 + add_child(voice) + voices.append(voice) + tune = AudioStreamPlayer.new() + tune.stream = load("res://games/frogger/audio/frogger-music.mp3") + tune.volume_db = -12 + add_child(tune) + tune.finished.connect(_music_finished) +func effect(name: String) -> void: + if get_parent().demo_active: return + if not tracks.has(name): return + var voice := voices[current] + current = (current+1)%voices.size() + voice.stream = tracks[name] + voice.play() +func music() -> void: + if get_parent().demo_active: return + if not tune.playing: tune.play() +func _music_finished() -> void: + if get_parent().phase in ["play","home"] and get_parent().music_enabled: tune.play() +func stop_music() -> void: + if tune: tune.stop() +func stop_all() -> void: + stop_music() + for voice in voices: voice.stop() diff --git a/games/frogger/frogger_audio.gd.uid b/games/frogger/frogger_audio.gd.uid new file mode 100644 index 0000000..ee34291 --- /dev/null +++ b/games/frogger/frogger_audio.gd.uid @@ -0,0 +1 @@ +uid://dh0mu01mar7nl diff --git a/games/frogger/frogger_view.gd b/games/frogger/frogger_view.gd new file mode 100644 index 0000000..732ade4 --- /dev/null +++ b/games/frogger/frogger_view.gd @@ -0,0 +1,138 @@ +@tool +extends Node2D +const SHEET = preload("res://games/frogger/art/spr_frogger.png") +const CHARACTERS := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-" +var game: Node +var atlas: ImageTexture +var regions: Dictionary = {} +var source: Image +func _ready() -> void: + game = get_parent().get_parent() + source = SHEET.get_image() + source.convert(Image.FORMAT_RGBA8) + for i in range(8): register("frog%d"%i,Rect2i(1+i*18,1,16,16)) + for i in range(2): register("attract%d"%i,Rect2i(1+i*18,98,16,16)) + for i in range(7): register("death%d"%i,Rect2i(1+i*18,80,16,16)) + for i in range(5): register("car%d"%i,Rect2i(1+i*18,116,32 if i==4 else 16,16)) + for i in range(3): register("log%d"%i,Rect2i(1+i*18,134,16,16)) + for i in range(5): register("turtle%d"%i,Rect2i(1+i*18,152,16,16)) + for i in range(5): register("letter%d"%i,Rect2i(1+i*18,232,16,16)) + register("home",Rect2i(1,188,32,24)) + register("hedge",Rect2i(35,188,8,24)) + register("saved",Rect2i(45,196,16,16)) + register("bank",Rect2i(135,196,16,16)) + for color in range(5): + for i in range(CHARACTERS.length()): + register("font%d_%s"%[color,CHARACTERS[i]],Rect2i(1+(i%17)*9,250+color*28+int(i/17)*9,8,8)) + atlas = ImageTexture.create_from_image(source) + source = null + +func register(name: String,area: Rect2i) -> void: + regions[name] = Rect2(area) + # Flood only border-connected black/gray padding; preserve enclosed dark detail. + var pending: Array[Vector2i] = [] + for x in range(area.size.x): + pending.append(Vector2i(x,0)) + pending.append(Vector2i(x,area.size.y-1)) + for y in range(area.size.y): + pending.append(Vector2i(0,y)) + pending.append(Vector2i(area.size.x-1,y)) + var seen: Dictionary = {} + while not pending.is_empty(): + var p: Vector2i = pending.pop_back() + if p.x<0 or p.y<0 or p.x>=area.size.x or p.y>=area.size.y or seen.has(p): continue + seen[p] = true + var pixel := source.get_pixelv(area.position+p) + var key := pixel.is_equal_approx(Color.BLACK) or pixel.is_equal_approx(Color("404040")) or pixel.a == 0 + if not key: continue + source.set_pixelv(area.position+p,Color.TRANSPARENT) + for offset in [Vector2i.LEFT,Vector2i.RIGHT,Vector2i.UP,Vector2i.DOWN]: pending.append(p+offset) + +func sprite(name: String,point: Vector2) -> void: + var region: Rect2 = regions[name] + draw_texture_rect_region(atlas,Rect2(point.round(),region.size),region) +func text_at(value: String,point: Vector2,color: int = 0) -> void: + for i in range(value.length()): + var key := "font%d_%s"%[color,value[i]] + if regions.has(key): sprite(key,point+Vector2(i*8,0)) +func label(value: String,y: float,color: int = 0) -> void: + text_at(value,Vector2((224-value.length()*8)/2.0,y),color) +func title() -> void: + var t: float = game.attract_time + for i in range(7): + var age: float = t - .5 - i*4 + if age < 0: continue + var x: float = 28+i*24 + var y := 136.0 + if age < 3.5: + x = lerpf(232,x,floorf(age*12)/42.0) + sprite("attract1",Vector2(x,y)) + elif t < 29: + sprite("attract0",Vector2(x,y)) + elif t < 33: + y = lerpf(136,55,floorf((t-29)*12)/48.0) + sprite("frog%d"%(int(t*6)%2),Vector2(x,y)) + elif t < 33+i*.45: + sprite("attract0",Vector2(x,55)) + else: + sprite("letter%d"%[0,1,2,3,3,4,1][i],Vector2(x,55)) + if t >= 36: label("-POINT TABLE-",89) + if t >= 37: label("10 PTS FOR EACH STEP",115,1) + if t >= 38: + label("50 PTS FOR EVERY FROG",139,1) + label("ARRIVED HOME SAFELY",151,2) + if t >= 39: + label("1000 PTS FOR FIVE",177,1) + label("FROGS HOME",189,1) + if int(t*2)%2 == 0: label("PUSH START",219,4) + label("FREE PLAY",247,4) +func board() -> void: + for x in range(0,224,8): sprite("hedge",Vector2(x,24)) + for i in range(5): + draw_rect(Rect2(game.HOME_X[i]-16,24,32,24),Color("000047")) + sprite("home",Vector2(game.HOME_X[i]-16,24)) + if game.homes[i]: sprite("saved",Vector2(game.HOME_X[i]-8,32)) + for y in [128,224]: + for x in range(0,224,16): sprite("bank",Vector2(x,y)) + for lane in game.lanes: + var y: float = 232-lane.row*16-8 + for x in game.lane_objects(lane): + match lane.kind: + "car": sprite("car%d"%lane.sprite,Vector2(x,y)) + "log": + var tiles := int(lane.width/16) + for i in range(tiles): sprite("log%d"%(0 if i==0 else (2 if i==tiles-1 else 1)),Vector2(x+i*16,y)) + "turtle": + if game.submerged(lane): continue + var frame := int(game.clock*5)%3 + if lane.sprite == 1 and fposmod(game.clock,7.0)>5: + frame = 3 if fposmod(game.clock,7.0)<5.4 else 4 + for i in range(int(lane.width/16)): sprite("turtle%d"%frame,Vector2(x+i*16,y)) + if game.phase == "dead": + var frame := clampi(int((1.1-game.phase_time)*7),0,6) + sprite("death%d"%frame,game.frog-Vector2(8,8)) + elif game.phase not in ["home","clear","won","lost"]: + sprite("frog%d"%(game.direction*2+(1 if game.hop_left>0 else 0)),game.frog-Vector2(8,8)) + for i in range(mini(game.lives,6)): sprite("frog0",Vector2(i*12,239)) + var fraction: float = clampf(game.time_left/game.crossing_time,0,1) + draw_rect(Rect2(88,248,104*fraction,8),Color("e00000") if fraction<.167 else Color("00c000")) + text_at("TIME",Vector2(192,248),1) + if game.phase == "ready": label("PLAYER 1",135,2) + if game.phase == "clear": label("FIVE FROGS HOME",135,1) + if game.phase == "won": label("YOU WIN",135,1) + if game.phase == "lost": label("GAME OVER",135,2) + if game.demo_active and int(game.demo_time*2)%2 == 0: label("GAME OVER",135,2) +func _draw() -> void: + if game == null or atlas == null: return + draw_rect(Rect2(0,0,224,256),Color.BLACK) + draw_rect(Rect2(0,0,224,128),Color("000047")) + text_at("1-UP",Vector2(24,0)) + text_at("HI-SCORE",Vector2(104,0)) + text_at("%05d"%game.score,Vector2(16,8),2) + text_at("%05d"%game.high_score,Vector2(112,8),2) + if game.phase == "title": title() + elif game.phase == "scores": + label("HIGH SCORES",55,1) + for i in range(5): label("%d %05d"%[i+1,game.leaderboard[i]] if i void: + texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + if kind not in ["shot", "enemy_shot"]: + var sprite_material := ShaderMaterial.new() + sprite_material.shader = preload("res://games/galaga/actors/sprite.gdshader") + material = sprite_material + +func _process(_delta: float) -> void: + queue_redraw() + +func _draw() -> void: + # Quantize the rendered center in native 224x288 coordinates, before the + # view's integer enlargement. Keep simulation positions precise. + draw_set_transform(position.round()-position) + if kind in ["shot", "enemy_shot"]: + draw_rect(Rect2(-1, -3, 2, 6), Color.CYAN if kind == "shot" else Color.SALMON) + return + if kind == "explosion": + var frame := mini(3, int(animation_time * 12)) + draw_texture_rect_region(SHEET, Rect2(-16,-16,32,32), Rect2(145 + frame * 34, 1, 32,32)) + return + if kind == "enemy_explosion": + var frame := mini(4, int(animation_time * 15)) + draw_texture_rect_region(SHEET, Rect2(-16,-16,32,32), Rect2(289+frame*34,1,32,32)) + return + var source := Vector2(108,0) + var destination := Rect2(-8,-8,16,16) + if kind=="fighter" and spinning: + var facing := Vector2.UP.rotated(spin_time*TAU*2) + var column := 6-clampi(roundi(atan2(absf(facing.x),absf(facing.y))/(PI/12)),0,6) + source=Vector2(column*18,0) + if facing.x>0: destination.size.x=-16 + if facing.y>0: destination.size.y=-16 + if kind in ["boss", "butterfly", "bee"]: + # Seven authored angles from right to down, at 15-degree intervals. + # Mirror these pixels into the other quadrants; never rotate the texture. + var column := 6 - clampi(roundi(atan2(absf(heading.x),absf(heading.y))/(PI/12)),0,6) + # Adjacent rows are different sprites, not interchangeable wing frames. + var row: int = DIRECTION_ROWS["boss_damaged" if kind == "boss" and damaged else kind] + source = Vector2(column*18,row) + if heading.x < 0: destination.size.x = -16 + if heading.y < 0: destination.size.y = -16 + draw_texture_rect_region(SHEET, destination, Rect2(source+Vector2.ONE,Vector2(16,16))) + if Engine.is_editor_hint(): + draw_arc(Vector2.ZERO, radius, 0, TAU, 24, Color(0,1,0,0.5)) diff --git a/games/galaga/actors/actor.gd.uid b/games/galaga/actors/actor.gd.uid new file mode 100644 index 0000000..a2c131e --- /dev/null +++ b/games/galaga/actors/actor.gd.uid @@ -0,0 +1 @@ +uid://c8jf7ijduugag diff --git a/games/galaga/actors/sprite.gdshader b/games/galaga/actors/sprite.gdshader new file mode 100644 index 0000000..c895af9 --- /dev/null +++ b/games/galaga/actors/sprite.gdshader @@ -0,0 +1,7 @@ +shader_type canvas_item; +render_mode unshaded; +void fragment() { + vec4 pixel = texture(TEXTURE, UV); + if (max(pixel.r, max(pixel.g, pixel.b)) < 0.02) { discard; } + COLOR *= pixel; +} diff --git a/games/galaga/actors/sprite.gdshader.uid b/games/galaga/actors/sprite.gdshader.uid new file mode 100644 index 0000000..ce1e902 --- /dev/null +++ b/games/galaga/actors/sprite.gdshader.uid @@ -0,0 +1 @@ +uid://m8sm8co7m5hs diff --git a/games/galaga/art/spr_galaga.png b/games/galaga/art/spr_galaga.png new file mode 100644 index 0000000..f394193 Binary files /dev/null and b/games/galaga/art/spr_galaga.png differ diff --git a/games/galaga/art/spr_galaga.png.import b/games/galaga/art/spr_galaga.png.import new file mode 100644 index 0000000..2d6d799 --- /dev/null +++ b/games/galaga/art/spr_galaga.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c8tq40qfjbj0j" +path="res://.godot/imported/spr_galaga.png-14ddb3514e589ecbada54d6f3d3d9c6a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/galaga/art/spr_galaga.png" +dest_files=["res://.godot/imported/spr_galaga.png-14ddb3514e589ecbada54d6f3d3d9c6a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/galaga/art/spr_galaga_screens_fonts.png b/games/galaga/art/spr_galaga_screens_fonts.png new file mode 100644 index 0000000..30528bf Binary files /dev/null and b/games/galaga/art/spr_galaga_screens_fonts.png differ diff --git a/games/galaga/art/spr_galaga_screens_fonts.png.import b/games/galaga/art/spr_galaga_screens_fonts.png.import new file mode 100644 index 0000000..6b504e6 --- /dev/null +++ b/games/galaga/art/spr_galaga_screens_fonts.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b73fvvkpj1n0m" +path="res://.godot/imported/spr_galaga_screens_fonts.png-903d74438a2f25937f64b812fa39ec5f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/galaga/art/spr_galaga_screens_fonts.png" +dest_files=["res://.godot/imported/spr_galaga_screens_fonts.png-903d74438a2f25937f64b812fa39ec5f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/galaga/audio/boss_damage.wav b/games/galaga/audio/boss_damage.wav new file mode 100644 index 0000000..b4260b3 Binary files /dev/null and b/games/galaga/audio/boss_damage.wav differ diff --git a/games/galaga/audio/boss_damage.wav.import b/games/galaga/audio/boss_damage.wav.import new file mode 100644 index 0000000..57c3ed5 --- /dev/null +++ b/games/galaga/audio/boss_damage.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dncbkjieopw2j" +path="res://.godot/imported/boss_damage.wav-78e99d1a33486a6035ee91fa662ba934.sample" + +[deps] + +source_file="res://games/galaga/audio/boss_damage.wav" +dest_files=["res://.godot/imported/boss_damage.wav-78e99d1a33486a6035ee91fa662ba934.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/boss_destroy.wav b/games/galaga/audio/boss_destroy.wav new file mode 100644 index 0000000..21abce1 Binary files /dev/null and b/games/galaga/audio/boss_destroy.wav differ diff --git a/games/galaga/audio/boss_destroy.wav.import b/games/galaga/audio/boss_destroy.wav.import new file mode 100644 index 0000000..3ac10e6 --- /dev/null +++ b/games/galaga/audio/boss_destroy.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://daxutfef332d0" +path="res://.godot/imported/boss_destroy.wav-e354d8227cc7537a700b34f5fe73f561.sample" + +[deps] + +source_file="res://games/galaga/audio/boss_destroy.wav" +dest_files=["res://.godot/imported/boss_destroy.wav-e354d8227cc7537a700b34f5fe73f561.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/captured_ship_destroy.wav b/games/galaga/audio/captured_ship_destroy.wav new file mode 100644 index 0000000..1a7238f Binary files /dev/null and b/games/galaga/audio/captured_ship_destroy.wav differ diff --git a/games/galaga/audio/captured_ship_destroy.wav.import b/games/galaga/audio/captured_ship_destroy.wav.import new file mode 100644 index 0000000..8785a24 --- /dev/null +++ b/games/galaga/audio/captured_ship_destroy.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cx3ne7dset50h" +path="res://.godot/imported/captured_ship_destroy.wav-00ca325f65364f942d197e896286ff9e.sample" + +[deps] + +source_file="res://games/galaga/audio/captured_ship_destroy.wav" +dest_files=["res://.godot/imported/captured_ship_destroy.wav-00ca325f65364f942d197e896286ff9e.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/capturing.wav b/games/galaga/audio/capturing.wav new file mode 100644 index 0000000..dc695ff Binary files /dev/null and b/games/galaga/audio/capturing.wav differ diff --git a/games/galaga/audio/capturing.wav.import b/games/galaga/audio/capturing.wav.import new file mode 100644 index 0000000..99607f5 --- /dev/null +++ b/games/galaga/audio/capturing.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dj5w28f8vfe11" +path="res://.godot/imported/capturing.wav-31e55e3bb216de5d977b1eb2a70fa55e.sample" + +[deps] + +source_file="res://games/galaga/audio/capturing.wav" +dest_files=["res://.godot/imported/capturing.wav-31e55e3bb216de5d977b1eb2a70fa55e.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/challenging_stage_perfect.wav b/games/galaga/audio/challenging_stage_perfect.wav new file mode 100644 index 0000000..55c21a8 Binary files /dev/null and b/games/galaga/audio/challenging_stage_perfect.wav differ diff --git a/games/galaga/audio/challenging_stage_perfect.wav.import b/games/galaga/audio/challenging_stage_perfect.wav.import new file mode 100644 index 0000000..696ff90 --- /dev/null +++ b/games/galaga/audio/challenging_stage_perfect.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://hkxrmuwhnjm8" +path="res://.godot/imported/challenging_stage_perfect.wav-108af1f9129534a9994485cfa520c75c.sample" + +[deps] + +source_file="res://games/galaga/audio/challenging_stage_perfect.wav" +dest_files=["res://.godot/imported/challenging_stage_perfect.wav-108af1f9129534a9994485cfa520c75c.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/challenging_stage_results.wav b/games/galaga/audio/challenging_stage_results.wav new file mode 100644 index 0000000..f16afb1 Binary files /dev/null and b/games/galaga/audio/challenging_stage_results.wav differ diff --git a/games/galaga/audio/challenging_stage_results.wav.import b/games/galaga/audio/challenging_stage_results.wav.import new file mode 100644 index 0000000..c762dc9 --- /dev/null +++ b/games/galaga/audio/challenging_stage_results.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://14q74uylektm" +path="res://.godot/imported/challenging_stage_results.wav-dab64a66c50b7f1993c4c2c3acd961b7.sample" + +[deps] + +source_file="res://games/galaga/audio/challenging_stage_results.wav" +dest_files=["res://.godot/imported/challenging_stage_results.wav-dab64a66c50b7f1993c4c2c3acd961b7.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/challenging_stage_start.wav b/games/galaga/audio/challenging_stage_start.wav new file mode 100644 index 0000000..b7d6b56 Binary files /dev/null and b/games/galaga/audio/challenging_stage_start.wav differ diff --git a/games/galaga/audio/challenging_stage_start.wav.import b/games/galaga/audio/challenging_stage_start.wav.import new file mode 100644 index 0000000..e2f8c18 --- /dev/null +++ b/games/galaga/audio/challenging_stage_start.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bdhprhh6occ5v" +path="res://.godot/imported/challenging_stage_start.wav-73d928e3ae25ec568dc7063663edd538.sample" + +[deps] + +source_file="res://games/galaga/audio/challenging_stage_start.wav" +dest_files=["res://.godot/imported/challenging_stage_start.wav-73d928e3ae25ec568dc7063663edd538.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/credit.wav b/games/galaga/audio/credit.wav new file mode 100644 index 0000000..df5fad2 Binary files /dev/null and b/games/galaga/audio/credit.wav differ diff --git a/games/galaga/audio/credit.wav.import b/games/galaga/audio/credit.wav.import new file mode 100644 index 0000000..413ddde --- /dev/null +++ b/games/galaga/audio/credit.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dxrhspw3c16qk" +path="res://.godot/imported/credit.wav-700cc7936bb34b0b15042241f97b45ed.sample" + +[deps] + +source_file="res://games/galaga/audio/credit.wav" +dest_files=["res://.godot/imported/credit.wav-700cc7936bb34b0b15042241f97b45ed.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/dive.wav b/games/galaga/audio/dive.wav new file mode 100644 index 0000000..616663f Binary files /dev/null and b/games/galaga/audio/dive.wav differ diff --git a/games/galaga/audio/dive.wav.import b/games/galaga/audio/dive.wav.import new file mode 100644 index 0000000..08c5aee --- /dev/null +++ b/games/galaga/audio/dive.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cs3pmp26xbwhk" +path="res://.godot/imported/dive.wav-5a74a6bfe1592a6aaea72d344d9ca9e3.sample" + +[deps] + +source_file="res://games/galaga/audio/dive.wav" +dest_files=["res://.godot/imported/dive.wav-5a74a6bfe1592a6aaea72d344d9ca9e3.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/explosion.wav b/games/galaga/audio/explosion.wav new file mode 100644 index 0000000..939ceaa Binary files /dev/null and b/games/galaga/audio/explosion.wav differ diff --git a/games/galaga/audio/explosion.wav.import b/games/galaga/audio/explosion.wav.import new file mode 100644 index 0000000..a04a6cd --- /dev/null +++ b/games/galaga/audio/explosion.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cpyt1s7qanl8q" +path="res://.godot/imported/explosion.wav-6c4c21ff43ef8c0e71bcf43356fc5b5a.sample" + +[deps] + +source_file="res://games/galaga/audio/explosion.wav" +dest_files=["res://.godot/imported/explosion.wav-6c4c21ff43ef8c0e71bcf43356fc5b5a.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/extend.wav b/games/galaga/audio/extend.wav new file mode 100644 index 0000000..97c2073 Binary files /dev/null and b/games/galaga/audio/extend.wav differ diff --git a/games/galaga/audio/extend.wav.import b/games/galaga/audio/extend.wav.import new file mode 100644 index 0000000..d0c930f --- /dev/null +++ b/games/galaga/audio/extend.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bvquw0oosnxqh" +path="res://.godot/imported/extend.wav-6f07b0f7aff14e3922701a3466431816.sample" + +[deps] + +source_file="res://games/galaga/audio/extend.wav" +dest_files=["res://.godot/imported/extend.wav-6f07b0f7aff14e3922701a3466431816.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/fighter_captured.wav b/games/galaga/audio/fighter_captured.wav new file mode 100644 index 0000000..9395675 Binary files /dev/null and b/games/galaga/audio/fighter_captured.wav differ diff --git a/games/galaga/audio/fighter_captured.wav.import b/games/galaga/audio/fighter_captured.wav.import new file mode 100644 index 0000000..6e6ef0b --- /dev/null +++ b/games/galaga/audio/fighter_captured.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://ddwn4qvfxdksy" +path="res://.godot/imported/fighter_captured.wav-de09c8e5ce22fdac89b516e365c9b080.sample" + +[deps] + +source_file="res://games/galaga/audio/fighter_captured.wav" +dest_files=["res://.godot/imported/fighter_captured.wav-de09c8e5ce22fdac89b516e365c9b080.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/fighter_rescued.wav b/games/galaga/audio/fighter_rescued.wav new file mode 100644 index 0000000..4f2fad5 Binary files /dev/null and b/games/galaga/audio/fighter_rescued.wav differ diff --git a/games/galaga/audio/fighter_rescued.wav.import b/games/galaga/audio/fighter_rescued.wav.import new file mode 100644 index 0000000..9882b61 --- /dev/null +++ b/games/galaga/audio/fighter_rescued.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://33yiax8gm27x" +path="res://.godot/imported/fighter_rescued.wav-104f1a0d3458b6b0c5fe2b4988a05239.sample" + +[deps] + +source_file="res://games/galaga/audio/fighter_rescued.wav" +dest_files=["res://.godot/imported/fighter_rescued.wav-104f1a0d3458b6b0c5fe2b4988a05239.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/fire.wav b/games/galaga/audio/fire.wav new file mode 100644 index 0000000..5c10f22 Binary files /dev/null and b/games/galaga/audio/fire.wav differ diff --git a/games/galaga/audio/fire.wav.import b/games/galaga/audio/fire.wav.import new file mode 100644 index 0000000..e985eae --- /dev/null +++ b/games/galaga/audio/fire.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://be1e74l1loh7n" +path="res://.godot/imported/fire.wav-5f3244d6c331a676322539de15b7dd8a.sample" + +[deps] + +source_file="res://games/galaga/audio/fire.wav" +dest_files=["res://.godot/imported/fire.wav-5f3244d6c331a676322539de15b7dd8a.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/goei_destroy.wav b/games/galaga/audio/goei_destroy.wav new file mode 100644 index 0000000..6980619 Binary files /dev/null and b/games/galaga/audio/goei_destroy.wav differ diff --git a/games/galaga/audio/goei_destroy.wav.import b/games/galaga/audio/goei_destroy.wav.import new file mode 100644 index 0000000..519102e --- /dev/null +++ b/games/galaga/audio/goei_destroy.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://btk1xrnbeclk5" +path="res://.godot/imported/goei_destroy.wav-8823f818c7ebeab88cbc5bbce5e497a6.sample" + +[deps] + +source_file="res://games/galaga/audio/goei_destroy.wav" +dest_files=["res://.godot/imported/goei_destroy.wav-8823f818c7ebeab88cbc5bbce5e497a6.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/name_entry.wav b/games/galaga/audio/name_entry.wav new file mode 100644 index 0000000..3f1c3d0 Binary files /dev/null and b/games/galaga/audio/name_entry.wav differ diff --git a/games/galaga/audio/name_entry.wav.import b/games/galaga/audio/name_entry.wav.import new file mode 100644 index 0000000..dbf21a7 --- /dev/null +++ b/games/galaga/audio/name_entry.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://caafa28sxwha2" +path="res://.godot/imported/name_entry.wav-ac9a4ac5de170d13f1cf215b46ecf4be.sample" + +[deps] + +source_file="res://games/galaga/audio/name_entry.wav" +dest_files=["res://.godot/imported/name_entry.wav-ac9a4ac5de170d13f1cf215b46ecf4be.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/name_entry_top_end.wav b/games/galaga/audio/name_entry_top_end.wav new file mode 100644 index 0000000..2cec68e Binary files /dev/null and b/games/galaga/audio/name_entry_top_end.wav differ diff --git a/games/galaga/audio/name_entry_top_end.wav.import b/games/galaga/audio/name_entry_top_end.wav.import new file mode 100644 index 0000000..5a9faf5 --- /dev/null +++ b/games/galaga/audio/name_entry_top_end.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bwveio4bx3bcg" +path="res://.godot/imported/name_entry_top_end.wav-746289f541bb0b67aa212f88d14d1e29.sample" + +[deps] + +source_file="res://games/galaga/audio/name_entry_top_end.wav" +dest_files=["res://.godot/imported/name_entry_top_end.wav-746289f541bb0b67aa212f88d14d1e29.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/name_entry_top_firstloop.wav b/games/galaga/audio/name_entry_top_firstloop.wav new file mode 100644 index 0000000..4087ad5 Binary files /dev/null and b/games/galaga/audio/name_entry_top_firstloop.wav differ diff --git a/games/galaga/audio/name_entry_top_firstloop.wav.import b/games/galaga/audio/name_entry_top_firstloop.wav.import new file mode 100644 index 0000000..8d4f0ee --- /dev/null +++ b/games/galaga/audio/name_entry_top_firstloop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://b2pbd5dqivcjh" +path="res://.godot/imported/name_entry_top_firstloop.wav-9dd53a38e65854032fde87dfd496ebf4.sample" + +[deps] + +source_file="res://games/galaga/audio/name_entry_top_firstloop.wav" +dest_files=["res://.godot/imported/name_entry_top_firstloop.wav-9dd53a38e65854032fde87dfd496ebf4.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/name_entry_top_loop.wav b/games/galaga/audio/name_entry_top_loop.wav new file mode 100644 index 0000000..6455acf Binary files /dev/null and b/games/galaga/audio/name_entry_top_loop.wav differ diff --git a/games/galaga/audio/name_entry_top_loop.wav.import b/games/galaga/audio/name_entry_top_loop.wav.import new file mode 100644 index 0000000..3ce4d44 --- /dev/null +++ b/games/galaga/audio/name_entry_top_loop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://7vox07aqyd22" +path="res://.godot/imported/name_entry_top_loop.wav-ec1fa871accf7a1b3d2bb99e5301f70a.sample" + +[deps] + +source_file="res://games/galaga/audio/name_entry_top_loop.wav" +dest_files=["res://.godot/imported/name_entry_top_loop.wav-ec1fa871accf7a1b3d2bb99e5301f70a.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/stage_count.wav b/games/galaga/audio/stage_count.wav new file mode 100644 index 0000000..eea83e4 Binary files /dev/null and b/games/galaga/audio/stage_count.wav differ diff --git a/games/galaga/audio/stage_count.wav.import b/games/galaga/audio/stage_count.wav.import new file mode 100644 index 0000000..51386e1 --- /dev/null +++ b/games/galaga/audio/stage_count.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://8tpl8twpsre2" +path="res://.godot/imported/stage_count.wav-5dd59d6a7f3b9370e4221a44921f54ff.sample" + +[deps] + +source_file="res://games/galaga/audio/stage_count.wav" +dest_files=["res://.godot/imported/stage_count.wav-5dd59d6a7f3b9370e4221a44921f54ff.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/start.wav b/games/galaga/audio/start.wav new file mode 100644 index 0000000..d51a316 Binary files /dev/null and b/games/galaga/audio/start.wav differ diff --git a/games/galaga/audio/start.wav.import b/games/galaga/audio/start.wav.import new file mode 100644 index 0000000..ca4ae04 --- /dev/null +++ b/games/galaga/audio/start.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dhwcq3grnk6ex" +path="res://.godot/imported/start.wav-b157900079964c24f391d1f6cfcfba00.sample" + +[deps] + +source_file="res://games/galaga/audio/start.wav" +dest_files=["res://.godot/imported/start.wav-b157900079964c24f391d1f6cfcfba00.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/tractor_beam.wav b/games/galaga/audio/tractor_beam.wav new file mode 100644 index 0000000..e605ed1 Binary files /dev/null and b/games/galaga/audio/tractor_beam.wav differ diff --git a/games/galaga/audio/tractor_beam.wav.import b/games/galaga/audio/tractor_beam.wav.import new file mode 100644 index 0000000..7647ea8 --- /dev/null +++ b/games/galaga/audio/tractor_beam.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://7iue027ylkmd" +path="res://.godot/imported/tractor_beam.wav-81aae0e2d4adf83ca0a76ed52762592a.sample" + +[deps] + +source_file="res://games/galaga/audio/tractor_beam.wav" +dest_files=["res://.godot/imported/tractor_beam.wav-81aae0e2d4adf83ca0a76ed52762592a.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/transform.wav b/games/galaga/audio/transform.wav new file mode 100644 index 0000000..9aeb9e0 Binary files /dev/null and b/games/galaga/audio/transform.wav differ diff --git a/games/galaga/audio/transform.wav.import b/games/galaga/audio/transform.wav.import new file mode 100644 index 0000000..f6e1a70 --- /dev/null +++ b/games/galaga/audio/transform.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bvh5e81wnestw" +path="res://.godot/imported/transform.wav-20248f23471e9e0a259acc1e86aba9be.sample" + +[deps] + +source_file="res://games/galaga/audio/transform.wav" +dest_files=["res://.godot/imported/transform.wav-20248f23471e9e0a259acc1e86aba9be.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/audio/zako_destroy.wav b/games/galaga/audio/zako_destroy.wav new file mode 100644 index 0000000..187069c Binary files /dev/null and b/games/galaga/audio/zako_destroy.wav differ diff --git a/games/galaga/audio/zako_destroy.wav.import b/games/galaga/audio/zako_destroy.wav.import new file mode 100644 index 0000000..7aad1d0 --- /dev/null +++ b/games/galaga/audio/zako_destroy.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c8lhi71x0bxk" +path="res://.godot/imported/zako_destroy.wav-d927ad0cf990543c87414951c2478a97.sample" + +[deps] + +source_file="res://games/galaga/audio/zako_destroy.wav" +dest_files=["res://.godot/imported/zako_destroy.wav-d927ad0cf990543c87414951c2478a97.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/galaga/galaga.gd b/games/galaga/galaga.gd new file mode 100644 index 0000000..668d4e8 --- /dev/null +++ b/games/galaga/galaga.gd @@ -0,0 +1,523 @@ +@tool +extends ArcadeGame +const ACTOR = preload("res://games/galaga/actors/actor.gd") +const FIGHTER = preload("res://games/galaga/actors/Fighter.tscn") +const ENEMY = preload("res://games/galaga/actors/Enemy.tscn") +const PROJECTILE = preload("res://games/galaga/actors/Projectile.tscn") +const EXPLOSION = preload("res://games/galaga/actors/Explosion.tscn") +@export_group("Room Objective") +@export_range(0,100,1) var stages_to_win := 1 +@export_range(0,1000000,100) var score_to_win := 0 +@export_range(1,9,1) var starting_lives := 3 +@export var extra_life_score := 20000 +@export var second_extra_life_score := 70000 +@export var repeat_extra_life_interval := 70000 +var next_extra_life := 20000 +var stage := 1 +var score := 0 +var lives := 3 +var shots_fired := 0 +var hits := 0 +var state := "idle" +var timer := 0.0 +var clock := 0.0 +var cooldown := 0.0 +var dive_timer := 2.0 +var invulnerable := 0.0 +var awarded := false +var player: Node2D +var enemies: Array[Dictionary] = [] +var bullets: Array[Dictionary] = [] +var effects: Array[Dictionary] = [] +var voices: Array[AudioStreamPlayer] = [] +var streams := {} +var field: Node2D +var hud: Node2D +var rng := RandomNumberGenerator.new() +@export_group("Opening Timing") +@export var intro_seconds := 6.0 +@export var stage_card_seconds := 1.0 +@export var player_card_seconds := 1.0 +@export_group("Cabinet") +@export var credits := 1 +var wingman: Node2D +var captive: Node2D +var captor: Dictionary = {} +var dual := false +var capture_origin := Vector2.ZERO +var capture_cooldown := 8.0 +var beam: Dictionary = {} + +func _ready() -> void: + pass + field = preload("res://games/galaga/galaga_view.gd").new() + add_child(field) + player = FIGHTER.instantiate() + field.add_child(player) + player.position = Vector2(112,256) + wingman = FIGHTER.instantiate() + field.add_child(wingman) + wingman.visible = false + captive = FIGHTER.instantiate() + field.add_child(captive) + captive.visible = false + captive.spinning = false + captive.modulate = Color(1,0.4,0.4) + _spawn_wave(true) + if not Engine.is_editor_hint(): + for i in range(8): + var voice := AudioStreamPlayer.new() + add_child(voice) + voices.append(voice) + for name in ["start","fire","dive","explosion","boss_damage","boss_destroy","goei_destroy","zako_destroy","extend","stage_count","tractor_beam","capturing","fighter_captured","fighter_rescued","captured_ship_destroy"]: + streams[name] = load("res://games/galaga/audio/%s.wav" % name) + +func start_game() -> bool: + if is_running or Engine.is_editor_hint(): return false + _clear_actors() + score = 0 + stage = 1 + lives = clampi(int(run_config.get("starting_lives",starting_lives)),1,9) + shots_fired = 0 + hits = 0 + awarded = false + next_extra_life = extra_life_score + clock = 0 + credits = maxi(0,credits-1) + cooldown = 0 + invulnerable = 0 + player.position = Vector2(112,256) + player.visible = false + state = "intro" + timer = intro_seconds + dual = false + wingman.visible = false + captive.visible = false + captive.spinning = false + captor = {} + beam = {} + capture_cooldown = 8.0 + _spawn_wave() + _sound("start") + return super.start_game() + +func _exit_tree() -> void: + for voice in voices: + voice.stop() + voice.stream = null + +func reset_game() -> void: + _clear_actors() + for voice in voices: voice.stop() + state = "idle" + dual = false + beam = {} + captor = {} + captive.visible = false + wingman.visible = false + player.visible = true + player.position = Vector2(112,256) + _spawn_wave(true) + super.reset_game() + +func win_game() -> void: + if not is_running or state=="victory": return + for voice in voices: voice.stop() + state = "victory" + timer = 1.5 + beam = {} + +func lose_game() -> void: + if not is_running: return + for voice in voices: voice.stop() + state = "lost" + super.lose_game() + +func _clear_actors() -> void: + for collection in [enemies,bullets,effects]: + for item in collection: item.node.queue_free() + collection.clear() + +func _spawn_wave(preview := false) -> void: + dive_timer = 2.0 + for row in range(5): + var count := 4 if row == 0 else (8 if row < 3 else 10) + for col in range(count): + var node = ENEMY.instantiate() + node.kind = "boss" if row == 0 else ("butterfly" if row < 3 else "bee") + field.add_child(node) + var home := Vector2(112 + (col - (count-1)/2.0)*18,48+row*18) + node.position = home if preview else Vector2(-24,-24) + node.visible = preview + var index := enemies.size() + enemies.append({"node":node,"home":home,"mode":"formation" if preview else "enter","time":-(index/8)*1.1-(index%8)*0.075,"side":-1.0 if (index/8)%2==0 else 1.0,"hp":2 if row==0 else 1,"origin":Vector2.ZERO,"target":112.0,"fired":false}) + +func _input(event: InputEvent) -> void: + if Engine.is_editor_hint(): return + if event.is_action_pressed("arcade_start"): + start_game() + +func _held(action: String) -> bool: + return Input.is_action_pressed(action) + +func _physics_process(dt: float) -> void: + if Engine.is_editor_hint(): return + if state=="won": + clock += dt + return + if not is_running: return + _tick(dt) + +func _tick(dt: float) -> void: + clock += dt + for i in range(effects.size()-1,-1,-1): + var effect: Dictionary = effects[i] + effect.node.animation_time += dt + if effect.node.animation_time >= 0.34: + effect.node.queue_free() + effects.remove_at(i) + if state != "playing": + timer -= dt + if state=="victory": + _update_peaceful_formation() + for enemy in enemies: + if enemy.mode!="formation" and enemy.node.visible: + enemy.node.position += enemy.node.heading*70*dt + for i in range(bullets.size()-1,-1,-1): + var bullet: Dictionary = bullets[i] + bullet.node.position += bullet.velocity*dt + if not Rect2(-16,-16,256,320).has_point(bullet.node.position): + bullet.node.queue_free() + bullets.remove_at(i) + if timer<=0: + _clear_bullets() + state="won" + super.win_game() + return + if state == "capturing": + var t := clampf(1-timer/1.5,0,1) + player.position = capture_origin.lerp(captor.node.position-Vector2(0,16),t) + if timer<=0: _finish_capture() + return + if state in ["captured_return", "capture_ready", "rescue_spin", "rescue_center", "rescue_align", "rescue_descend"]: + _update_peaceful_formation() + match state: + "captured_return": + captor.node.position = captor.node.position.move_toward(_formation_home(captor),100*dt) + captive.position = captor.node.position-Vector2(0,16) + if timer<=0 and captor.node.position.distance_to(_formation_home(captor))<1: + captor.mode="formation" + if lives<=0: + lose_game() + else: + player.position=Vector2(112,256) + player.visible=true + state="capture_ready" + timer=1.5 + "capture_ready": + if timer<=0: _resume_action() + "rescue_spin": + captive.spin_time += dt + if timer<=0: + captive.spinning=false + state="rescue_center" + "rescue_center": + player.position = player.position.move_toward(Vector2(104,256),100*dt) + if player.position.distance_to(Vector2(104,256))<0.1: state="rescue_align" + "rescue_align": + captive.position.x = move_toward(captive.position.x,120,100*dt) + if absf(captive.position.x-120)<0.1: state="rescue_descend" + "rescue_descend": + captive.position = captive.position.move_toward(Vector2(120,256),110*dt) + if captive.position.distance_to(Vector2(120,256))<0.1: + dual=true + captive.visible=false + wingman.position=Vector2(120,256) + wingman.visible=true + _resume_action() + return + if timer <= 0: + if state == "intro": + state = "stage_card" + timer = stage_card_seconds + _sound("stage_count") + elif state == "stage_card": + state = "ready" + timer = player_card_seconds + player.visible = true + elif state == "ready": state = "playing" + elif state == "death": + if lives <= 0: lose_game() + else: + player.position = Vector2(112,256) + player.visible = true + invulnerable = 2.5 + state = "playing" + elif state == "clear": + stage += 1 + _spawn_wave() + state = "stage_card" + player.visible = false + wingman.visible = false + timer = stage_card_seconds + _sound("stage_count") + return + cooldown = maxf(0,cooldown-dt) + invulnerable = maxf(0,invulnerable-dt) + player.visible = invulnerable == 0 or int(clock*12)%2==0 + var movement := float(_held("arcade_right"))-float(_held("arcade_left")) + player.position.x = clampf(player.position.x+movement*110*dt,8,216) + if dual: player.position.x = minf(player.position.x,200) + wingman.position = player.position+Vector2(16,0) + wingman.visible = dual and player.visible + if _held("arcade_action_1"): _fire() + _update_enemies(dt) + if state != "playing": return + _update_bullets(dt) + if state != "playing": return + for enemy in enemies: + if (enemy.node.position.distance_to(player.position) < 11 or (dual and enemy.node.position.distance_to(wingman.position)<11)) and invulnerable <= 0: + _die() + return + if enemies.is_empty(): + var target := int(run_config.get("stages_to_win", stages_to_win)) + if target > 0 and stage >= target: win_game() + else: + state = "clear" + timer = 2 + _clear_bullets() + +func _update_enemies(dt: float) -> void: + dive_timer -= dt + capture_cooldown -= dt + var entering := false + for e in enemies: + if e.mode=="enter": entering=true + for enemy in enemies: + var previous_position: Vector2 = enemy.node.position + enemy.time += dt + var expansion := 1.0+0.10*(0.5+0.5*sin(clock*1.7)) + var home: Vector2 = _formation_home(enemy) + match enemy.mode: + "enter": + if enemy.time < 0: + enemy.node.visible = false + continue + enemy.node.visible = true + var t := clampf(enemy.time/2.0,0,1) + var start := Vector2(112+enemy.side*140,160) + if t<0.8: + # Every member samples one shared route; its slot affects only docking. + var u := t/0.8 + enemy.node.position = start.lerp(Vector2(112,90),u) + Vector2(-enemy.side*sin(u*TAU)*72,-sin(u*PI)*105) + else: + enemy.node.position = Vector2(112,90).lerp(home,(t-0.8)/0.2) + if t>=1: enemy.mode = "formation" + "formation": + enemy.node.position = home + if not entering and capture_cooldown<=0 and enemy.node.kind=="boss" and captor.is_empty() and beam.is_empty() and not dual: + enemy.mode="beam_approach" + enemy.origin=home + enemy.target=player.position.x + enemy.time=0 + beam=enemy + capture_cooldown=15 + elif dive_timer <= 0 and not entering: + enemy.mode = "dive" + enemy.time = 0 + enemy.origin = home + enemy.target = player.position.x + enemy.fired = false + dive_timer = maxf(0.45,2.0-stage*0.12) + _sound("dive") + "dive": + var t: float = enemy.time/(3.0/maxf(1,1+stage*0.04)) + enemy.node.position = Vector2(lerpf(enemy.origin.x,enemy.target,minf(t,1))+sin(t*TAU)*25,enemy.origin.y+t*250) + if t>0.35 and not enemy.fired: + enemy.fired = true + _bullet(enemy.node.position,(player.position-enemy.node.position).normalized()*95,false) + if enemy.node.position.y>310: + enemy.mode = "return" + enemy.time = 0 + "return": + var t := minf(1,enemy.time/1.0) + enemy.node.position = Vector2(home.x,lerpf(-20,home.y,t)) + if t>=1: enemy.mode = "formation" + "beam_approach": + var t := minf(enemy.time/1.3,1) + enemy.node.position=enemy.origin.lerp(Vector2(enemy.target,172),t) + if t>=1: + enemy.mode="beam" + enemy.time=0 + _sound("tractor_beam") + "beam": + if invulnerable<=0 and absf(player.position.x-enemy.node.position.x)<18: + captor=enemy + capture_origin=player.position + state="capturing" + timer=1.5 + _clear_bullets() + _stop_sounds() + _sound("capturing") + return + if enemy.time>=3: + enemy.mode="return" + enemy.time=0 + beam={} + enemy.node.animation_time += dt + if enemy.mode in ["dive","enter","return"]: + var travel: Vector2 = enemy.node.position-previous_position + if travel.length_squared()>0.01: enemy.node.heading = travel.normalized() + else: enemy.node.heading = Vector2.DOWN + if not captor.is_empty() and captive.visible: + captive.position=captor.node.position-Vector2(0,16) + +func _fire() -> void: + if cooldown>0: return + var count := 0 + for bullet in bullets: + if bullet.friendly: count += 1 + if count>=(4 if dual else 2): return + cooldown = 0.18 + shots_fired += 1 + _bullet(player.position-Vector2(0,9),Vector2(0,-230),true) + if dual: _bullet(wingman.position-Vector2(0,9),Vector2(0,-230),true) + _sound("fire") + +func _bullet(at: Vector2, velocity: Vector2, friendly: bool) -> void: + var node = PROJECTILE.instantiate() + node.kind = "shot" if friendly else "enemy_shot" + node.position = at + field.add_child(node) + bullets.append({"node":node,"velocity":velocity,"friendly":friendly}) + +func _update_bullets(dt: float) -> void: + for i in range(bullets.size()-1,-1,-1): + var bullet: Dictionary = bullets[i] + bullet.node.position += bullet.velocity*dt + var remove: bool = bullet.node.position.y < -8 or bullet.node.position.y>296 + if bullet.friendly: + if captive.visible and state=="playing" and bullet.node.position.distance_to(captive.position)<8: + captive.visible=false + captor={} + _sound("captured_ship_destroy") + bullet.node.queue_free() + bullets.remove_at(i) + continue + for j in range(enemies.size()-1,-1,-1): + var enemy: Dictionary = enemies[j] + if bullet.node.position.distance_to(enemy.node.position)>9: continue + hits += 1 + enemy.hp -= 1 + remove = true + if enemy.hp <= 0: + var kind: String = enemy.node.kind + var points: int = {"boss":150,"butterfly":80,"bee":50}[kind] + if enemy.mode == "dive": points *= 2 + _explode(enemy.node.position,true) + if enemy==beam: beam={} + if enemy==captor: + captor={} + state="rescue_spin" + timer=1.5 + captive.spinning=true + captive.spin_time=0 + _stop_sounds() + _sound("fighter_rescued") + enemy.node.queue_free() + enemies.remove_at(j) + _sound({"boss":"boss_destroy","butterfly":"goei_destroy","bee":"zako_destroy"}[kind]) + _add_score(points) + else: + enemy.node.damaged = true + _sound("boss_damage") + break + elif invulnerable<=0 and (bullet.node.position.distance_to(player.position)<8 or (dual and bullet.node.position.distance_to(wingman.position)<8)): + _die() + return + if remove: + bullet.node.queue_free() + bullets.remove_at(i) + if state != "playing": return + +func _add_score(points: int) -> void: + score += points + while next_extra_life>0 and score>=next_extra_life: + var first := not awarded + awarded = true + lives += 1 + _sound("extend") + if first and second_extra_life_score>next_extra_life: + next_extra_life = second_extra_life_score + elif repeat_extra_life_interval>0: + next_extra_life += repeat_extra_life_interval + else: next_extra_life = 0 + var target := int(run_config.get("score_to_win",score_to_win)) + if target>0 and score>=target: win_game() + +func _clear_bullets() -> void: + for bullet in bullets: bullet.node.queue_free() + bullets.clear() + +func _die() -> void: + if dual: + dual=false + _explode(wingman.position) + wingman.visible=false + invulnerable=2.5 + _sound("explosion") + return + _explode(player.position) + player.visible = false + lives -= 1 + state = "death" + timer = 1.5 + _clear_bullets() + _sound("explosion") + +func _explode(at: Vector2, enemy := false) -> void: + var node = EXPLOSION.instantiate() + if enemy: node.kind="enemy_explosion" + node.position = at + field.add_child(node) + effects.append({"node":node}) + +func _finish_capture() -> void: + beam={} + lives-=1 + captive.visible=true + captive.position=player.position + player.visible=false + captor.mode="captured_return" + captor.time=0 + _stop_sounds() + state="captured_return" + _sound("fighter_captured") + timer=1.5 + +func _sound(name: String) -> void: + if state=="capturing" and name!="capturing": return + if state.begins_with("rescue_") and name!="fighter_rescued": return + if not streams.has(name): return + for voice in voices: + if not voice.playing: + voice.stream = streams[name] + voice.play() + return + +func _stop_sounds() -> void: + for voice in voices: voice.stop() + +func _formation_home(enemy: Dictionary) -> Vector2: + var expansion := 1.0+0.10*(0.5+0.5*sin(clock*1.7)) + return Vector2(112,48)+(enemy.home-Vector2(112,48))*expansion + +func _update_peaceful_formation() -> void: + for enemy in enemies: + if enemy.mode=="formation": enemy.node.position=_formation_home(enemy) + +func _resume_action() -> void: + state="playing" + invulnerable=2.5 + dive_timer=2.0 + _clear_bullets() diff --git a/games/galaga/galaga.gd.uid b/games/galaga/galaga.gd.uid new file mode 100644 index 0000000..a3243cb --- /dev/null +++ b/games/galaga/galaga.gd.uid @@ -0,0 +1 @@ +uid://b6ui86lx5kkpa diff --git a/games/galaga/galaga_edge_mask.gd b/games/galaga/galaga_edge_mask.gd new file mode 100644 index 0000000..cca2436 --- /dev/null +++ b/games/galaga/galaga_edge_mask.gd @@ -0,0 +1,14 @@ +@tool +extends Node2D +## Draw above actors in the same native-pixel coordinate system. +const FRAME := Rect2(0, 0, 224, 288) + +func _process(_delta: float) -> void: + queue_redraw() + +func _draw() -> void: + var outer := (get_global_transform_with_canvas().affine_inverse() * get_viewport_rect()).merge(FRAME) + draw_rect(Rect2(outer.position, Vector2(FRAME.position.x-outer.position.x, outer.size.y)), Color.BLACK) + draw_rect(Rect2(Vector2(FRAME.end.x,outer.position.y),Vector2(outer.end.x-FRAME.end.x,outer.size.y)), Color.BLACK) + draw_rect(Rect2(Vector2(0,outer.position.y),Vector2(FRAME.size.x,-outer.position.y)), Color.BLACK) + draw_rect(Rect2(Vector2(0,FRAME.end.y),Vector2(FRAME.size.x,outer.end.y-FRAME.end.y)), Color.BLACK) diff --git a/games/galaga/galaga_edge_mask.gd.uid b/games/galaga/galaga_edge_mask.gd.uid new file mode 100644 index 0000000..5ca6091 --- /dev/null +++ b/games/galaga/galaga_edge_mask.gd.uid @@ -0,0 +1 @@ +uid://clnm0uio7etas diff --git a/games/galaga/galaga_view.gd b/games/galaga/galaga_view.gd new file mode 100644 index 0000000..d710e54 --- /dev/null +++ b/games/galaga/galaga_view.gd @@ -0,0 +1,91 @@ +@tool +extends Node2D +const FONT = preload("res://games/galaga/art/spr_galaga_screens_fonts.png") +const SHEET = preload("res://games/galaga/art/spr_galaga.png") +var attract_time := 0.0 +var previous_state := "" +var high_score := 20000 +var edge_mask: Node2D + +func _ready() -> void: + texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + edge_mask = preload("res://games/galaga/galaga_edge_mask.gd").new() + edge_mask.z_index = 100 + add_child(edge_mask) + +func _process(delta: float) -> void: + var game = get_parent() + if game.state == "idle": + if previous_state != "idle": attract_time = 0.0 + attract_time += delta + for actor in get_children(): + if actor == edge_mask: continue + if game.state == "idle" and not Engine.is_editor_hint(): actor.visible = false + previous_state = game.state + high_score = maxi(high_score,game.score) + var size := get_viewport_rect().size + var factor := maxf(0.1,minf(size.x/224.0,size.y/288.0)) + if factor >= 1: factor = floorf(factor) + scale = Vector2.ONE*factor + position = ((size-Vector2(224,288)*factor)/2).floor() + queue_redraw() + +func _draw() -> void: + draw_rect(Rect2(-1000,-1000,2224,2288),Color.BLACK) + var star_clock: float = attract_time if get_parent().state=="idle" else get_parent().clock + for i in range(90): + var x := fposmod(i*73.0,224) + var y := fposmod(i*113.0+star_clock*(12+i%3*8),288) + if (int(star_clock*2)+i)%7!=0: + draw_rect(Rect2(Vector2(x,y).round(),Vector2.ONE),[Color.CYAN,Color.RED,Color.YELLOW,Color.BLUE,Color.MAGENTA,Color.WHITE][i%6]*0.7) + if get_parent().state == "idle" and not Engine.is_editor_hint(): + _attract() + return + _text("1UP %06d" % get_parent().score,Vector2(8,4)) + if get_parent().state != "intro": + for i in range(mini(5,maxi(0,get_parent().lives-1))): + draw_texture_rect_region(SHEET,Rect2(8+i*16,270,16,16),Rect2(109,1,16,16)) + _text("STAGE %d" % get_parent().stage,Vector2(144,278)) + var text: String = {"intro":"PLAYER 1","stage_card":"STAGE %d" % get_parent().stage,"ready":"PLAYER 1 STAGE %d" % get_parent().stage,"won":"YOU WIN!","lost":"GAME OVER","clear":"STAGE CLEAR"}.get(get_parent().state,"") + if text != "": _text(text,Vector2((224-text.length()*8)/2,180),Color.CYAN) + if get_parent().state=="captured_return": _center("FIGHTER CAPTURED",180,Color.CYAN) + elif get_parent().state=="capture_ready": _center("READY",180,Color.CYAN) + var game = get_parent() + if not game.beam.is_empty() and game.beam.mode=="beam": + var source_x := 289+int(game.clock*8)%3*50 + draw_texture_rect_region(SHEET,Rect2(game.beam.node.position.round()+Vector2(-24,8),Vector2(48,79)),Rect2(source_x,36,48,79)) + +func _text(message: String, at: Vector2, tint := Color.WHITE) -> void: + var chars := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for i in range(message.length()): + var index := chars.find(message[i]) + if index<0: continue + var row := 0 if index<25 else 1 + var col := index if row==0 else index-25 + draw_texture_rect_region(FONT,Rect2(at+Vector2(i*8,0),Vector2(7,7)),Rect2(454+col*9,443+row*9,7,7),tint) + +func _center(message: String, y: float, tint := Color.WHITE) -> void: + _text(message,Vector2((224-message.length()*8)/2,y),tint) + +func _attract() -> void: + _text("1UP",Vector2(16,0),Color.RED) + _text("HIGH SCORE",Vector2(72,0),Color.RED) + _text("2UP",Vector2(184,0),Color.RED) + _center(str(high_score).pad_zeros(6),10) + if int(attract_time / 8.0) % 2 == 0: + _center("PUSH START BUTTON",104,Color.CYAN) + var bonus: int = get_parent().extra_life_score + var messages := ["1ST BONUS FOR %d PTS" % bonus,"2ND BONUS FOR %d PTS" % get_parent().second_extra_life_score,"AND FOR EVERY %d PTS" % get_parent().repeat_extra_life_interval] + for i in range(3): + _text(messages[i],Vector2(24,136+i*24),Color.YELLOW) + draw_texture_rect_region(SHEET,Rect2(8,132+i*24,16,16),Rect2(109,1,16,16)) + else: + _center("THE GALACTIC HEROES",56,Color.BLUE) + _center("BEST 5",112,Color.RED) + _text("SCORE NAME",Vector2(64,152),Color.CYAN) + for i in range(5): + _text(["1ST","2ND","3RD","4TH","5TH"][i],Vector2(24,176+i*16),Color.CYAN) + _text(str(high_score if i==0 else (5-i)*1000).pad_zeros(6),Vector2(64,176+i*16),Color.CYAN) + _text(["AAA","BBB","CCC","DDD","EEE"][i],Vector2(144,176+i*16),Color.CYAN) + _text("CREDIT %d" % get_parent().credits,Vector2(8,278)) + diff --git a/games/galaga/galaga_view.gd.uid b/games/galaga/galaga_view.gd.uid new file mode 100644 index 0000000..405374d --- /dev/null +++ b/games/galaga/galaga_view.gd.uid @@ -0,0 +1 @@ +uid://yjuxbruqjkbf diff --git a/games/launcher/ArcadeLauncher.tscn b/games/launcher/ArcadeLauncher.tscn new file mode 100644 index 0000000..11f2d52 --- /dev/null +++ b/games/launcher/ArcadeLauncher.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://du56pbkblp1bx"] + +[ext_resource type="Script" uid="uid://c7fye4iaedapj" path="res://games/launcher/arcade_launcher.gd" id="1"] + +[node name="ArcadeLauncher" type="Node" unique_id=3868404] +script = ExtResource("1") diff --git a/games/launcher/README.md b/games/launcher/README.md new file mode 100644 index 0000000..bb5e39b --- /dev/null +++ b/games/launcher/README.md @@ -0,0 +1,65 @@ +# Arcade launcher and cabinet controls + +For central staff operation, see the separate [Arcade room controller guide](../../tools/room_controller/README.md). Open the controller URL in a browser for the multi-cabinet operator on Mac/iPad, or use `-- --operator` for Cabinet 01 quick controls. Run `-- --managed-cabinet --cabinet=cabinet_01 --gpio=real` on the appropriate Pi. These modes bypass the standalone launcher; managed mode uses central scenario mappings instead of local rewards. + +For physical Raspberry Pi outputs, follow [the GPIO setup and LED test guide](../../tools/pi_gpio/README.md). The launcher forwards matching Hardware Scenario events; its default scenario connects Pac-Man's win to cabinet 01's GPIO17 coin-release pulse. Start the local helper and use `--gpio=real` to enable electrical outputs. + +Open `res://games/launcher/ArcadeLauncher.tscn` and press F6. It lists all seven games with their icons. Move the joystick up/down and press Start to open a game's attract screen; press Start again to begin. Select returns to the list and unloads the game, including its sound players. Mouse selection and double-click also work. + +This is a standalone arcade frontend, separate from the room/scenario runtime. To boot directly into it in an export, use this scene as **Project Settings > Application > Run > Main Scene**. + +## Map your cabinet + +The exported game opens **Control Setup** automatically until a local profile is saved. You can reopen it from the last entry in the game list, or press **F1** on a keyboard as a recovery shortcut. No Godot editor is needed on the Pi. + +1. Connect both controllers before beginning. Press any controller button / Enter, or click **Begin Setup**. +2. Follow the prompts for player-one Start, Select, joystick directions and the three game buttons. Release each button or center the joystick before the next prompt. Small axis drift is ignored. +3. For Robotron firing, use four face buttons or move the second joystick in each requested direction. Player-one movement is automatically reused for Robotron movement. +4. Review the list and test inputs: the monitor shows which controller/input was detected and the actions assigned to it. An unassigned player-two button should show no actions. Move the trackball to confirm mouse motion is arriving. +5. Press the learned **Start** button or click **Save Controls**. Learned Select cancels from review. The Cancel button cancels at any point without altering the previous profile. **Escape quits the entire application**, including from setup; unfinished changes are not saved. + +Saving replaces the default bindings for the configured actions instead of adding more overlapping bindings. The saved controls load for standalone game scenes and the room runtime as well as the launcher. Project Settings Input Map remains the fallback until a local profile exists; afterwards, the local profile takes precedence. + +Profiles are saved outside the exported game as `user://arcade_controls.cfg`, usually `~/.local/share/godot/app_userdata/ArcadeEscape/arcade_controls.cfg` on Linux and `%APPDATA%/Godot/app_userdata/ArcadeEscape/arcade_controls.cfg` on Windows. They survive replacing the game export. Configure each machine independently; do not copy Windows bindings onto the Pi. If saving fails, the screen reports it and keeps the previous profile. + +## Two controllers + +The shipped default bindings use All Devices, which lets either gamepad activate the same action. Setup records each joypad's identity and specific input, then binds only that device. Different encoders are resolved by name/GUID when device numbers change. A disconnected controller's inputs are disabled rather than assigned to another controller. + +Identical encoders can have the same name/GUID and no distinguishable serial identity through Godot. These are separated by their instance order, with a visible warning to check player one after reconnecting. Keep them in consistent USB ports and rerun setup if the players swap. If the count of identical encoders changes, affected bindings are disabled until all are connected again or setup is repeated. Changing connections during setup restarts the wizard. + +Keyboard-mode encoders are supported, but two encoders emitting the same keyboard key cannot be distinguished by this setup. Give their players different key codes in the encoder configuration, or use gamepad mode. F1 and Escape are reserved for setup and quitting and cannot be learned as gameplay buttons. + +## Action reference (project defaults) + +| Action | Cabinet use | Keyboard default | +| --- | --- | --- | +| `arcade_up/down/left/right` | Joystick, menu and most games | Arrow keys | +| `arcade_start` | Start | Enter | +| `arcade_menu` | Select: leave game | Use your learned Select button; Escape quits the application | +| `arcade_action_1` | Fire; Missile Command left battery | Space / left mouse | +| `arcade_action_2` | Tempest Superzapper; Missile Command center battery | Z / middle mouse | +| `arcade_action_3` | Missile Command right battery | X / right mouse | +| `robotron_move_left/right/up/down` | Bind the same movement joystick | A / D / W / S | +| `robotron_fire_left/right/up/down` | Four face buttons arranged directionally | Arrow keys | +| `arcade_rotate_left/right` | Optional Tempest spinner pulses | Mouse wheel down/up | + +The default gamepad bindings remain in Project Settings. Use Control Setup to learn the actual cabinet buttons rather than assuming printed button numbers match the operating system. Four face buttons cover Robotron, including diagonal shots by holding two buttons; the second joystick can also handle firing. The remaining games need at most three face buttons. + +The trackball must report as a mouse. The launcher enables Missile Command's **Relative Mouse Aim** and captures the pointer for Missile Command and Tempest, so trackball travel continues beyond desktop edges. Select releases the pointer when returning to the menu. Tempest accepts horizontal mouse motion with **Trackball Pixels Per Lane** sensitivity. Continuous mouse motion is handled as motion, not a button action. + +## Raspberry Pi 4/5 + +Input Map settings ship with the project/export. Use Linux ARM64 for a 64-bit Raspberry Pi OS installation and retain the Compatibility renderer. There are no Windows-only input APIs in these controls. Keyboard/mouse USB encoders and recognized gamepads use the same actions, but a generic encoder may report different button/axis mappings on Linux: test the actual hardware on the Pi before final deployment. This change does not add a GPIO input driver. + +Godot references: [Linux export](https://docs.godotengine.org/en/stable/tutorials/export/exporting_for_linux.html), [controller mappings](https://docs.godotengine.org/en/stable/tutorials/inputs/controllers_gamepads_joysticks.html). + +## Quick check + +1. Navigate all seven entries with the joystick, open each with Start, begin play, and return with Select. +2. Test Missile Command's three batteries independently with the trackball; test Tempest fire and Superzapper separately. +3. Test Robotron movement independently of all four fire buttons and their diagonals. +4. Save setup, restart the game, and verify the learned bindings persist and unassigned player-two buttons have no effect. +5. Repeat on the Pi after export and reconnecting the USB encoders. Check player-one identity if the encoders are identical. + +Automated checks: run `tests/launcher/check_launcher.gd` and `tests/launcher/check_controls.gd` using Godot's headless script runner. The latter uses a temporary profile in `.godot`, not your real saved controls. diff --git a/games/launcher/arcade_launcher.gd b/games/launcher/arcade_launcher.gd new file mode 100644 index 0000000..cfb39a1 --- /dev/null +++ b/games/launcher/arcade_launcher.gd @@ -0,0 +1,175 @@ +extends Node +@export var hardware_scenario: ScenarioDefinition = preload("res://data/scenarios/proof_of_concept.tres") +## Standalone arcade selector. Room/scenario startup remains separate. +const GAMES := [ + ["Pac-Man", "pacman/Pacman", "pacman", "JOYSTICK"], + ["Galaga", "galaga/Galaga", "galaga", "JOYSTICK / FIRE"], + ["Tempest", "tempest/Tempest", "tempest", "JOYSTICK OR TRACKBALL / FIRE / SUPERZAPPER"], + ["Missile Command", "missile_command/MissileCommand", "missilecommand", "TRACKBALL / THREE LAUNCH BUTTONS"], + ["Frogger", "frogger/Frogger", "frogger", "JOYSTICK"], + ["Space Invaders", "space_invaders/SpaceInvaders", "spaceinvaders", "JOYSTICK / FIRE"], + ["Robotron 2084", "robotron/Robotron", "robotron", "JOYSTICK / FOUR DIRECTIONAL FIRE BUTTONS"], +] +var selected := 0 +var game: Node +var menu: Control +var list: ItemList +var description: Label +var switching := false +var setup: CanvasLayer +var control_status: Label +var gpio_status: Label + +func _ready() -> void: + if "--managed-cabinet" in OS.get_cmdline_user_args(): + set_process_input(false) + add_child(preload("res://core/network/managed_cabinet.gd").new()) + return + if "--operator" in OS.get_cmdline_user_args(): + set_process_input(false) + add_child(preload("res://operator/ArcadeRoomOperator.tscn").instantiate()) + return + var layer := CanvasLayer.new() + add_child(layer) + menu = Control.new() + menu.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + layer.add_child(menu) + var background := ColorRect.new() + background.color = Color("080d18") + background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + menu.add_child(background) + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + menu.add_child(center) + var column := VBoxContainer.new() + column.custom_minimum_size = Vector2(700, 0) + column.add_theme_constant_override("separation", 18) + center.add_child(column) + var title := Label.new() + title.text = "ARCADE GALLERY" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 46) + title.modulate = Color("75e8ef") + column.add_child(title) + list = ItemList.new() + list.custom_minimum_size = Vector2(700, 520) + list.fixed_icon_size = Vector2i(48, 48) + list.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + list.add_theme_font_size_override("font_size", 28) + list.add_theme_constant_override("v_separation", 12) + list.focus_mode = Control.FOCUS_NONE + column.add_child(list) + for entry in GAMES: + list.add_item(entry[0], load("res://games/launcher/icons/ico_%s.png" % entry[2])) + list.add_item("Control Setup") + list.item_selected.connect(_select) + list.item_activated.connect(func(index: int): _select(index); launch.call_deferred()) + description = Label.new() + description.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + description.add_theme_font_size_override("font_size", 18) + column.add_child(description) + var hint := Label.new() + hint.text = "JOYSTICK: CHOOSE START: PLAY SELECT: RETURN TO MENU" + hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + hint.modulate = Color("91a1bb") + column.add_child(hint) + control_status = Label.new() + control_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + control_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + control_status.add_theme_font_size_override("font_size", 16) + column.add_child(control_status) + ArcadeControls.changed.connect(_refresh_control_status) + _refresh_control_status() + gpio_status = Label.new() + gpio_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + gpio_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + gpio_status.text = HardwareManager.status + column.add_child(gpio_status) + HardwareManager.status_changed.connect(func(message: String): gpio_status.text = message) + _select(0) + if ArcadeControls.bindings.is_empty(): open_setup.call_deferred() + +func _refresh_control_status() -> void: + control_status.text = ArcadeControls.problem if not ArcadeControls.problem.is_empty() else "CONTROL SETUP: LAST MENU ENTRY / F1" + +func _select(index: int) -> void: + selected = wrapi(index, 0, GAMES.size() + 1) + list.select(selected) + list.ensure_current_is_visible() + description.text = GAMES[selected][3] if selected < GAMES.size() else "CONFIGURE THIS CABINET'S JOYSTICKS AND BUTTONS" + +func _input(event: InputEvent) -> void: + if ArcadeControls.handle_quit(event): return + if is_instance_valid(setup): return + if event.is_echo() or switching: return + if event is InputEventKey and event.pressed and event.physical_keycode == KEY_F1: + get_viewport().set_input_as_handled() + if is_instance_valid(game): return_to_menu() + open_setup.call_deferred() + return + if is_instance_valid(game): + if event.is_action_pressed("arcade_menu"): + get_viewport().set_input_as_handled() + switching = true + return_to_menu.call_deferred() + return + if event.is_action_pressed("arcade_down"): + _select(selected + 1) + elif event.is_action_pressed("arcade_up"): + _select(selected - 1) + elif event.is_action_pressed("arcade_start"): + switching = true + launch.call_deferred() + else: + return + get_viewport().set_input_as_handled() + +func launch() -> void: + if selected == GAMES.size(): + open_setup() + switching = false + return + if is_instance_valid(game): return + var scene := load("res://games/%s.tscn" % GAMES[selected][1]) as PackedScene + if scene == null: + switching = false + return + game = scene.instantiate() + RoomRuntime.active_assignment = null + if hardware_scenario != null: + for assignment in hardware_scenario.assignments: + if assignment.game != null and assignment.game.scene == scene: + RoomRuntime.configure(assignment) + game.game_started.connect(func(): RoomRuntime.fire_game_event(GameEvents.STARTED)) + game.game_won.connect(func(): RoomRuntime.fire_game_event(GameEvents.WON)) + game.game_lost.connect(func(): RoomRuntime.fire_game_event(GameEvents.LOST)) + game.game_reset.connect(func(): RoomRuntime.fire_game_event(GameEvents.RESET)) + break + if GAMES[selected][2] == "missilecommand": + game.set("relative_mouse_aim", true) + if GAMES[selected][2] in ["missilecommand", "tempest"]: + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + menu.hide() + add_child(game) + # Present the game's attract screen; Start begins its own opening sequence. + switching = false + +func open_setup() -> void: + if is_instance_valid(setup): return + setup = preload("res://games/launcher/control_setup.gd").new() + menu.hide() + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + setup.closed.connect(func(): menu.show(); _select(selected)) + add_child(setup) + +func return_to_menu() -> void: + if is_instance_valid(game): + remove_child(game) + game.queue_free() + game = null + RoomRuntime.active_assignment = null + HardwareManager.active_cabinet = null + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + menu.show() + _select(selected) + switching = false diff --git a/games/launcher/arcade_launcher.gd.uid b/games/launcher/arcade_launcher.gd.uid new file mode 100644 index 0000000..1af1a5f --- /dev/null +++ b/games/launcher/arcade_launcher.gd.uid @@ -0,0 +1 @@ +uid://c7fye4iaedapj diff --git a/games/launcher/control_setup.gd b/games/launcher/control_setup.gd new file mode 100644 index 0000000..9d0db06 --- /dev/null +++ b/games/launcher/control_setup.gd @@ -0,0 +1,177 @@ +extends CanvasLayer +signal closed +var panel: Control +var heading: Label +var prompt: Label +var monitor: Label +var details: Label +var buttons: HBoxContainer +var draft: Dictionary = {} +var step := -1 +var waiting: InputEvent +var armed_at := 0 + +func _ready() -> void: + layer = 10 + panel = ColorRect.new() + panel.color = Color("080d18") + panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(panel) + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + panel.add_child(center) + var column := VBoxContainer.new() + column.custom_minimum_size = Vector2(1100, 0) + column.add_theme_constant_override("separation", 18) + center.add_child(column) + heading = label(column, 36) + prompt = label(column, 26) + details = label(column, 18) + monitor = label(column, 20) + monitor.modulate = Color("75e8ef") + buttons = HBoxContainer.new() + buttons.alignment = BoxContainer.ALIGNMENT_CENTER + buttons.add_theme_constant_override("separation", 24) + column.add_child(buttons) + welcome() + Input.joy_connection_changed.connect(_controller_changed) + +func _controller_changed(_id: int, _connected: bool) -> void: + waiting = null + draft.clear() + welcome() + prompt.text = "Controllers changed. Begin again with all controllers connected." + +func label(parent: Node, font_size: int) -> Label: + var result := Label.new() + result.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + result.custom_minimum_size.x = 1100 + result.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + result.add_theme_font_size_override("font_size", font_size) + parent.add_child(result) + return result + +func clear_buttons() -> void: + for child in buttons.get_children(): + buttons.remove_child(child) + child.queue_free() + +func button(text: String, callback: Callable) -> void: + var control := Button.new() + control.text = text + control.add_theme_font_size_override("font_size", 24) + control.focus_mode = Control.FOCUS_NONE + control.pressed.connect(callback) + buttons.add_child(control) + +func welcome() -> void: + step = -1 + heading.text = "CONTROL SETUP" + prompt.text = "Configure this cabinet — no editor needed" + details.text = "Use PLAYER ONE for movement, Start, Select and game buttons.\nFor Robotron firing, use four face buttons OR your second joystick.\nEach controller is recorded separately. Release each input when asked.\n\nThe trackball works as a mouse; move it to see it detected below.\nKeyboard encoders work too. Escape quits the application. F1 opens setup.\n\nPress any controller button / Enter to begin, or click Begin Setup.\nCancel keeps your saved controls. Escape quits without saving." + if not ArcadeControls.problem.is_empty(): details.text += "\n\n" + ArcadeControls.problem + monitor.text = "Waiting for input…" + clear_buttons() + button("Begin Setup", begin) + button("Cancel / Keep Current Controls", close) + armed_at = Time.get_ticks_msec() + 350 + +func begin() -> void: + draft.clear() + waiting = null + step = 0 + armed_at = Time.get_ticks_msec() + 350 + show_step() + +func show_step() -> void: + clear_buttons() + if step >= ArcadeControls.STEPS.size(): + heading.text = "REVIEW & TEST CONTROLS" + prompt.text = "Try both joysticks and buttons; watch the detected actions below." + details.text = "" + for entry in ArcadeControls.STEPS: + details.text += "%s: %s\n" % [entry[0].replace("arcade_", "").replace("robotron_", "Robotron ").replace("_", " ").capitalize(), ArcadeControls.describe(draft[entry[0]])] + details.text += "\nPress your learned START to save, SELECT to cancel, or click a button.\nTrackball motion needs no button binding. Identical USB encoders may swap after reconnecting." + button("Save Controls", save) + button("Start Over", begin) + button("Cancel", close) + return + heading.text = "CONTROL SETUP %d / %d" % [step + 1, ArcadeControls.STEPS.size()] + prompt.text = ArcadeControls.STEPS[step][1] + details.text = "Move one direction or press one button, then release it.\nUse the actual cabinet controls. Nothing is saved until you finish.\nCancel keeps your saved controls; Escape quits. Trackball motion is shown below but is not bound as a button." + button("Start Over", begin) + button("Cancel", close) + +func close() -> void: + closed.emit() + queue_free() + +func save() -> void: + var error := ArcadeControls.save_profile(draft) + if error != OK: + prompt.text = "Could not save controls (%s). Your previous settings are unchanged." % error_string(error) + return + close() + +func released(event: InputEvent) -> bool: + if waiting == null or event.device != waiting.device: return false + if waiting is InputEventJoypadMotion and event is InputEventJoypadMotion: + return event.axis == waiting.axis and absf(event.axis_value) < .25 + if waiting is InputEventJoypadButton and event is InputEventJoypadButton: + return event.button_index == waiting.button_index and not event.pressed + if waiting is InputEventKey and event is InputEventKey: + return event.physical_keycode == waiting.physical_keycode and event.keycode == waiting.keycode and not event.pressed + return false + +func conflict(action: String, binding: Dictionary) -> String: + for other in draft: + if draft[other] != binding: continue + # Reusing game fire buttons for Robotron is deliberate; within each game it isn't. + if action.begins_with("robotron_") != other.begins_with("robotron_") and other not in ["arcade_start", "arcade_menu"]: + continue + return other + return "" + +func _input(event: InputEvent) -> void: + if ArcadeControls.handle_quit(event): return + if event.is_echo(): return + if event is InputEventMouseMotion: + monitor.text = "Trackball / mouse motion: %s" % event.relative + return + if event is InputEventMouseButton: return # Leave setup buttons clickable. + if not (event is InputEventKey or event is InputEventJoypadButton or event is InputEventJoypadMotion): return + get_viewport().set_input_as_handled() + if waiting != null: + if released(event): + waiting = null + step += 1 + armed_at = Time.get_ticks_msec() + 200 + show_step() + return + if Time.get_ticks_msec() < armed_at: return + if event is InputEventJoypadMotion: + if absf(event.axis_value) < .65: return + elif not event.is_pressed(): return + var binding: Dictionary = ArcadeControls.encode(event, ArcadeControls.devices()) + if binding.is_empty(): return + monitor.text = "Detected: " + ArcadeControls.describe(binding) + if step < 0: + if event is InputEventJoypadButton or (event is InputEventKey and event.physical_keycode == KEY_ENTER): + begin() + return + if step >= ArcadeControls.STEPS.size(): + var actions: Array[String] = [] + for action in draft: + if draft[action] == binding: actions.append(action) + monitor.text += "\nActions: " + (", ".join(actions) if not actions.is_empty() else "unassigned — will not control gameplay") + if binding == draft.arcade_start: save() + elif binding == draft.arcade_menu: close() + return + var action: String = ArcadeControls.STEPS[step][0] + var duplicate := conflict(action, binding) + if not duplicate.is_empty(): + monitor.text = "Already used for %s. Choose a different input." % duplicate + return + draft[action] = binding + waiting = event.duplicate() + prompt.text = "Recorded. Release the button / center the joystick to continue." diff --git a/games/launcher/control_setup.gd.uid b/games/launcher/control_setup.gd.uid new file mode 100644 index 0000000..a7bb172 --- /dev/null +++ b/games/launcher/control_setup.gd.uid @@ -0,0 +1 @@ +uid://diw7s6drttvyq diff --git a/games/launcher/icons/ico_frogger.png b/games/launcher/icons/ico_frogger.png new file mode 100644 index 0000000..04fd6c7 Binary files /dev/null and b/games/launcher/icons/ico_frogger.png differ diff --git a/games/launcher/icons/ico_frogger.png.import b/games/launcher/icons/ico_frogger.png.import new file mode 100644 index 0000000..003d06f --- /dev/null +++ b/games/launcher/icons/ico_frogger.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b8bqn824u85vl" +path="res://.godot/imported/ico_frogger.png-b57a3a61500c7a320cf15c673d6eb4d6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/launcher/icons/ico_frogger.png" +dest_files=["res://.godot/imported/ico_frogger.png-b57a3a61500c7a320cf15c673d6eb4d6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/launcher/icons/ico_galaga.png b/games/launcher/icons/ico_galaga.png new file mode 100644 index 0000000..d52c09d Binary files /dev/null and b/games/launcher/icons/ico_galaga.png differ diff --git a/games/launcher/icons/ico_galaga.png.import b/games/launcher/icons/ico_galaga.png.import new file mode 100644 index 0000000..7c833e8 --- /dev/null +++ b/games/launcher/icons/ico_galaga.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dac8qj4ricufo" +path="res://.godot/imported/ico_galaga.png-36d03105f6c4a933b9c92d2d355e468c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/launcher/icons/ico_galaga.png" +dest_files=["res://.godot/imported/ico_galaga.png-36d03105f6c4a933b9c92d2d355e468c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/launcher/icons/ico_missilecommand.png b/games/launcher/icons/ico_missilecommand.png new file mode 100644 index 0000000..61979bf Binary files /dev/null and b/games/launcher/icons/ico_missilecommand.png differ diff --git a/games/launcher/icons/ico_missilecommand.png.import b/games/launcher/icons/ico_missilecommand.png.import new file mode 100644 index 0000000..1c736bd --- /dev/null +++ b/games/launcher/icons/ico_missilecommand.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://85qgr4tqlnqh" +path="res://.godot/imported/ico_missilecommand.png-eb97b766183d581f71965f33acf01b65.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/launcher/icons/ico_missilecommand.png" +dest_files=["res://.godot/imported/ico_missilecommand.png-eb97b766183d581f71965f33acf01b65.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/launcher/icons/ico_pacman.png b/games/launcher/icons/ico_pacman.png new file mode 100644 index 0000000..ea17750 Binary files /dev/null and b/games/launcher/icons/ico_pacman.png differ diff --git a/games/launcher/icons/ico_pacman.png.import b/games/launcher/icons/ico_pacman.png.import new file mode 100644 index 0000000..60ff8c6 --- /dev/null +++ b/games/launcher/icons/ico_pacman.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cqo136wb6s53w" +path="res://.godot/imported/ico_pacman.png-b602b4a9bac1a4babcfeec75859abcff.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/launcher/icons/ico_pacman.png" +dest_files=["res://.godot/imported/ico_pacman.png-b602b4a9bac1a4babcfeec75859abcff.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/launcher/icons/ico_robotron.png b/games/launcher/icons/ico_robotron.png new file mode 100644 index 0000000..5528259 Binary files /dev/null and b/games/launcher/icons/ico_robotron.png differ diff --git a/games/launcher/icons/ico_robotron.png.import b/games/launcher/icons/ico_robotron.png.import new file mode 100644 index 0000000..ef67b5a --- /dev/null +++ b/games/launcher/icons/ico_robotron.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://nqduggsfhj00" +path="res://.godot/imported/ico_robotron.png-f9c39bc84b1f503f3d58b52dd3a17ef9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/launcher/icons/ico_robotron.png" +dest_files=["res://.godot/imported/ico_robotron.png-f9c39bc84b1f503f3d58b52dd3a17ef9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/launcher/icons/ico_spaceinvaders.png b/games/launcher/icons/ico_spaceinvaders.png new file mode 100644 index 0000000..8894485 Binary files /dev/null and b/games/launcher/icons/ico_spaceinvaders.png differ diff --git a/games/launcher/icons/ico_spaceinvaders.png.import b/games/launcher/icons/ico_spaceinvaders.png.import new file mode 100644 index 0000000..3570edb --- /dev/null +++ b/games/launcher/icons/ico_spaceinvaders.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1kd33ac3lilj" +path="res://.godot/imported/ico_spaceinvaders.png-16def212c976698b79583e1a2fe208eb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/launcher/icons/ico_spaceinvaders.png" +dest_files=["res://.godot/imported/ico_spaceinvaders.png-16def212c976698b79583e1a2fe208eb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/launcher/icons/ico_tempest.png b/games/launcher/icons/ico_tempest.png new file mode 100644 index 0000000..24fb63a Binary files /dev/null and b/games/launcher/icons/ico_tempest.png differ diff --git a/games/launcher/icons/ico_tempest.png.import b/games/launcher/icons/ico_tempest.png.import new file mode 100644 index 0000000..b3152be --- /dev/null +++ b/games/launcher/icons/ico_tempest.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://druv26hjagfnl" +path="res://.godot/imported/ico_tempest.png-5c3137c1b36eaca1076a9d0dde7b4414.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/launcher/icons/ico_tempest.png" +dest_files=["res://.godot/imported/ico_tempest.png-5c3137c1b36eaca1076a9d0dde7b4414.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/missile_command/MissileCommand.tscn b/games/missile_command/MissileCommand.tscn new file mode 100644 index 0000000..9fb1eb0 --- /dev/null +++ b/games/missile_command/MissileCommand.tscn @@ -0,0 +1,24 @@ +[gd_scene format=3 uid="uid://crtp0cdh3x55h"] + +[ext_resource type="Script" uid="uid://dxkm5iqysxkyy" path="res://games/missile_command/missile_command.gd" id="1"] +[ext_resource type="Script" uid="uid://ed0yaf45xgff" path="res://games/missile_command/pixel_board.gd" id="2"] +[ext_resource type="Script" uid="uid://ci3wofmlmkbbg" path="res://games/missile_command/display.gd" id="3"] +[ext_resource type="Script" uid="uid://dsft8skyj7r8x" path="res://games/missile_command/arcade_audio.gd" id="4"] + +[node name="MissileCommand" type="Node" unique_id=2072674313] +script = ExtResource("1") + +[node name="PixelScreen" type="SubViewport" parent="." unique_id=1549979971] +disable_3d = true +size = Vector2i(256, 231) +render_target_update_mode = 4 + +[node name="Board" type="Node2D" parent="PixelScreen" unique_id=1583118688] +script = ExtResource("2") + +[node name="Display" type="Node2D" parent="." unique_id=1661990548] +texture_filter = 1 +script = ExtResource("3") + +[node name="Audio" type="Node" parent="." unique_id=2862985] +script = ExtResource("4") diff --git a/games/missile_command/arcade_audio.gd b/games/missile_command/arcade_audio.gd new file mode 100644 index 0000000..2dc8cf3 --- /dev/null +++ b/games/missile_command/arcade_audio.gd @@ -0,0 +1,42 @@ +extends Node +## Small synthesized arcade effects; no external audio assets required. +var voices: Array[AudioStreamPlayer] = [] +var sounds: Dictionary = {} +var voice_index := 0 +func _ready() -> void: + for i in range(6): + var voice := AudioStreamPlayer.new() + voice.volume_db = -17.0 + add_child(voice) + voices.append(voice) + sounds["fire"] = tone(1300.0, 220.0, 0.09) + sounds["hit"] = tone(170.0, 40.0, 0.18) + sounds["zap"] = tone(1700.0, 70.0, 0.55) + sounds["death"] = tone(440.0, 35.0, 0.7) + sounds["level"] = tone(220.0, 880.0, 0.4) +func tone(start: float, finish: float, duration: float) -> AudioStreamWAV: + var samples := PackedByteArray() + var count := int(22050 * duration) + samples.resize(count * 2) + var phase := 0.0 + for i in range(count): + var t := float(i) / count + phase += lerpf(start, finish, t) * TAU / 22050.0 + var envelope := minf(t * 50.0, 1.0) * pow(1.0 - t, 1.5) + var value := int((sin(phase) + sin(phase * 2.0) * 0.25) * envelope * 18000) + samples.encode_s16(i * 2, value) + var stream := AudioStreamWAV.new() + stream.format = AudioStreamWAV.FORMAT_16_BITS + stream.mix_rate = 22050 + stream.data = samples + return stream +func play(effect: String) -> void: + if not sounds.has(effect) or voices.is_empty(): + return + var voice := voices[voice_index] + voice_index = (voice_index + 1) % voices.size() + voice.stream = sounds[effect] + voice.play() +func stop() -> void: + for voice in voices: + voice.stop() diff --git a/games/missile_command/arcade_audio.gd.uid b/games/missile_command/arcade_audio.gd.uid new file mode 100644 index 0000000..21fae8c --- /dev/null +++ b/games/missile_command/arcade_audio.gd.uid @@ -0,0 +1 @@ +uid://dsft8skyj7r8x diff --git a/games/missile_command/display.gd b/games/missile_command/display.gd new file mode 100644 index 0000000..d3a7960 --- /dev/null +++ b/games/missile_command/display.gd @@ -0,0 +1,18 @@ +@tool +extends Node2D +const LOGICAL := Vector2(256,231) +var destination := Rect2(0,0,768,576) +func _process(_delta: float) -> void: + var available := get_viewport_rect().size if not Engine.is_editor_hint() else Vector2(768,576) + var factor := minf(available.x/4.0,available.y/3.0) + var size := Vector2(4,3)*factor + destination = Rect2((available-size)*.5,size) + queue_redraw() +func to_logical(point: Vector2) -> Vector2: + return (point-destination.position)/destination.size*LOGICAL +func to_logical_delta(delta: Vector2) -> Vector2: + return delta/destination.size*LOGICAL +func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO,get_viewport_rect().size),Color.BLACK) + if has_node("../PixelScreen"): + draw_texture_rect(get_node("../PixelScreen").get_texture(),destination,false) diff --git a/games/missile_command/display.gd.uid b/games/missile_command/display.gd.uid new file mode 100644 index 0000000..acebce6 --- /dev/null +++ b/games/missile_command/display.gd.uid @@ -0,0 +1 @@ +uid://ci3wofmlmkbbg diff --git a/games/missile_command/missile_command.gd b/games/missile_command/missile_command.gd new file mode 100644 index 0000000..8d61ab5 --- /dev/null +++ b/games/missile_command/missile_command.gd @@ -0,0 +1,283 @@ +@tool +extends ArcadeGame +## All positions use the 256 x 231 logical arcade screen. +@export_range(0,99) var waves_to_win: int = 3 +@export_range(.5,2.0) var difficulty: float = 1.0 +@export_range(1000,50000,1000) var bonus_city_interval: int = 10000 +@export var relative_mouse_aim: bool = false +@export_range(.1,4.0) var trackball_sensitivity: float = 1.0 +const CITY_X := [43,68,93,163,188,213] +const BASE_X := [16,128,240] +var phase := "title" +var phase_time := 0.0 +var clock := 0.0 +var attract_time := 0.0 +var wave := 1 +var score := 0 +var high_score := 0 +var bonus_bank := 0 +var next_bonus := 10000 +var cities: Array[bool] = [true,true,true,true,true,true] +var bases: Array[bool] = [true,true,true] +var ammo: Array[int] = [10,10,10] +var cooldown: Array[float] = [0.0,0.0,0.0] +var aim := Vector2(128,100) +var incoming: Array[Dictionary] = [] +var interceptors: Array[Dictionary] = [] +var blasts: Array[Dictionary] = [] +var aircraft: Array[Dictionary] = [] +var leaderboard: Array[int] = [] +var remaining := 0 +var spawn_time := 0.0 +var aircraft_time := 0.0 +var wave_bonus := 0 +var recorded := false +var rng := RandomNumberGenerator.new() +@onready var board: Node2D = $PixelScreen/Board +@onready var display: Node2D = $Display + +func _ready() -> void: + if not Engine.is_editor_hint(): rng.randomize() + +func start_game() -> bool: + if not super.start_game(): return false + wave = 1 + score = 0 + bonus_bank = 0 + next_bonus = bonus_city_interval + recorded = false + cities.fill(true) + incoming.clear() + interceptors.clear() + blasts.clear() + aircraft.clear() + _begin_wave() + return true + +func reset_game() -> void: + super.reset_game() + $Audio.stop() + phase = "title" + attract_time = 0 + phase_time = 0 + score = 0 + wave = 1 + cities.fill(true) + bases.fill(true) + ammo.fill(10) + cooldown.fill(0) + incoming.clear() + interceptors.clear() + blasts.clear() + aircraft.clear() + bonus_bank = 0 + +func _begin_wave() -> void: + bases.fill(true) + ammo.fill(10) + cooldown.fill(0) + remaining = 12 + mini(wave,20)*3 + spawn_time = 1.0 + aircraft_time = 9.0 + phase = "ready" + phase_time = 2.2 + wave_bonus = 0 + +func add_trackball_motion(motion: Vector2) -> void: + set_aim(aim+motion*trackball_sensitivity) + +func set_aim(point: Vector2) -> void: + aim = point.clamp(Vector2(1,25),Vector2(254,199)) + +func _action(event: InputEvent, action: String) -> bool: + return event.is_action_pressed(action) + +func _unhandled_input(event: InputEvent) -> void: + if Engine.is_editor_hint(): return + if event is InputEventMouseMotion: + if relative_mouse_aim: + add_trackball_motion(display.to_logical_delta(event.relative)) + else: + set_aim(display.to_logical(event.position)) + if event is InputEventKey and event.echo: return + if _action(event,"arcade_start"): + if not is_running: start_game() + return + if phase != "play": return + if event is InputEventMouseButton and not relative_mouse_aim: + set_aim(display.to_logical(event.position)) + for i in range(3): + if _action(event,"arcade_action_%d" % (i+1)): + fire(i) + return + +func fire(base: int) -> bool: + if phase != "play" or base < 0 or base > 2 or not bases[base] or ammo[base] <= 0 or cooldown[base] > 0: return false + ammo[base] -= 1 + cooldown[base] = .12 + var origin := Vector2(BASE_X[base],202) + interceptors.append({"origin":origin,"pos":origin,"target":aim,"speed":180.0 if base == 1 else 120.0}) + $Audio.play("fire") + return true + +func multiplier() -> int: + return mini(6,1+int((wave-1)/2)) + +func add_score(amount: int) -> void: + score += amount + high_score = maxi(high_score,score) + while score >= next_bonus: + bonus_bank += 1 + next_bonus += bonus_city_interval + +func _record_score() -> void: + if recorded: return + recorded = true + leaderboard.append(score) + leaderboard.sort() + leaderboard.reverse() + if leaderboard.size() > 8: leaderboard.resize(8) + +func _physics_process(delta: float) -> void: + clock += delta + if Engine.is_editor_hint(): + board.queue_redraw() + return + for i in range(3): cooldown[i] = maxf(0,cooldown[i]-delta) + match phase: + "title","leaderboard": + attract_time += delta + if phase == "title" and attract_time >= 9.0: + phase = "leaderboard" + attract_time = 0.0 + elif phase == "leaderboard" and attract_time >= 7.0: + phase = "title" + attract_time = 0.0 + "ready": + phase_time -= delta + if phase_time <= 0: phase = "play" + "play": _step(delta) + "bonus": + phase_time -= delta + if phase_time <= 0: + if waves_to_win > 0 and wave >= waves_to_win: + phase = "won" + _record_score() + win_game() + else: + wave += 1 + _begin_wave() + "lost": + phase_time -= delta + if phase_time <= 0: + phase = "title" + attract_time = 0 + board.queue_redraw() + +func target_point() -> Vector2: + var candidates: Array[Vector2] = [] + for i in range(6): + if cities[i]: candidates.append(Vector2(CITY_X[i],211)) + for i in range(3): + if bases[i]: candidates.append(Vector2(BASE_X[i],204)) + return candidates[rng.randi_range(0,candidates.size()-1)] if not candidates.is_empty() else Vector2(128,212) + +func spawn_missile(origin: Vector2, kind: String = "missile") -> void: + incoming.append({"origin":origin,"pos":origin,"target":target_point(),"kind":kind,"speed":(11.0+mini(wave,25)*1.8)*difficulty,"split":kind == "splitter","alive":true}) + +func explosion(point: Vector2, hostile: bool = false, radius: float = 18.0) -> void: + blasts.append({"pos":point,"age":0.0,"radius":0.0,"max":radius,"hostile":hostile}) + if phase == "play": $Audio.play("hit") + +func _restore_cities() -> void: + for i in range(6): + if not cities[i] and bonus_bank > 0: + cities[i] = true + bonus_bank -= 1 + +func _impact(point: Vector2) -> void: + for i in range(6): + if absf(point.x-CITY_X[i]) < 9: cities[i] = false + for i in range(3): + if absf(point.x-BASE_X[i]) < 12: + bases[i] = false + ammo[i] = 0 + explosion(point,true,14) + +func _step(delta: float) -> void: + spawn_time -= delta + if remaining > 0 and spawn_time <= 0: + var kind := "missile" + if wave >= 3 and remaining%5 == 0: kind = "splitter" + if wave >= 5 and remaining%7 == 0: kind = "smart" + spawn_missile(Vector2(rng.randf_range(4,252),24),kind) + remaining -= 1 + spawn_time = maxf(.24,1.1-wave*.045)/difficulty + if wave >= 3 and remaining > 0: + aircraft_time -= delta + if aircraft_time <= 0: + aircraft.append({"pos":Vector2(-10,55+rng.randf()*35),"drop":1.0,"alive":true}) + aircraft_time = 15 + for shot in interceptors: + shot.pos = shot.pos.move_toward(shot.target,shot.speed*delta) + if shot.pos == shot.target: explosion(shot.target) + interceptors = interceptors.filter(func(s: Dictionary) -> bool: return s.pos != s.target) + for blast in blasts: + blast.age += delta + var age: float = blast.age + blast.radius = blast.max * (minf(1,age/.45) if age < 1.0 else maxf(0,(1.8-age)/.8)) + blasts = blasts.filter(func(b: Dictionary) -> bool: return b.age < 1.8) + var children: Array[Vector2] = [] + for missile in incoming: + var previous: Vector2 = missile.pos + var destination: Vector2 = missile.target + if missile.kind == "smart": + for blast in blasts: + if not blast.hostile and missile.pos.distance_to(blast.pos) < blast.radius+15: + destination.x += 60 if missile.pos.x >= blast.pos.x else -60 + missile.pos = missile.pos.move_toward(destination,missile.speed*delta).clamp(Vector2(0,24),Vector2(255,214)) + for blast in blasts: + if blast.hostile: continue + var nearest := Geometry2D.get_closest_point_to_segment(blast.pos,previous,missile.pos) + if nearest.distance_to(blast.pos) <= blast.radius+1: + missile.alive = false + add_score((125 if missile.kind == "smart" else 25)*multiplier()) + break + if not missile.alive: + explosion(missile.pos,false,13) + elif missile.pos.y >= 202 and missile.pos.distance_to(missile.target) < 12: + missile.alive = false + _impact(missile.target) + elif missile.split and missile.pos.y > 90: + missile.split = false + children.append(missile.pos) + incoming = incoming.filter(func(m: Dictionary) -> bool: return m.alive) + for point in children: + spawn_missile(point) + spawn_missile(point) + for plane in aircraft: + plane.pos.x += delta*(18+wave) + plane.drop -= delta + if plane.drop <= 0: + spawn_missile(plane.pos) + plane.drop = 3 + for blast in blasts: + if not blast.hostile and plane.pos.distance_to(blast.pos) < blast.radius+4: + plane.alive = false + add_score(100*multiplier()) + explosion(plane.pos) + break + aircraft = aircraft.filter(func(p: Dictionary) -> bool: return p.alive and p.pos.x < 268) + if not cities.has(true) and bonus_bank == 0: + phase = "lost" + phase_time = 5 + _record_score() + lose_game() + elif remaining == 0 and incoming.is_empty() and interceptors.is_empty() and blasts.is_empty() and aircraft.is_empty(): + wave_bonus = (cities.count(true)*100+(ammo[0]+ammo[1]+ammo[2])*5)*multiplier() + add_score(wave_bonus) + _restore_cities() + phase = "bonus" + phase_time = 3.0 + $Audio.play("level") + diff --git a/games/missile_command/missile_command.gd.uid b/games/missile_command/missile_command.gd.uid new file mode 100644 index 0000000..d1524b9 --- /dev/null +++ b/games/missile_command/missile_command.gd.uid @@ -0,0 +1 @@ +uid://dxkm5iqysxkyy diff --git a/games/missile_command/pixel_board.gd b/games/missile_command/pixel_board.gd new file mode 100644 index 0000000..71b1204 --- /dev/null +++ b/games/missile_command/pixel_board.gd @@ -0,0 +1,211 @@ +@tool +extends Node2D +const SIZE := Vector2(256,231) +const FONT := { +"A":[14,17,17,31,17,17,17],"B":[30,17,17,30,17,17,30],"C":[14,17,16,16,16,17,14],"D":[30,17,17,17,17,17,30],"E":[31,16,16,30,16,16,31],"F":[31,16,16,30,16,16,16],"G":[14,17,16,23,17,17,15],"H":[17,17,17,31,17,17,17],"I":[31,4,4,4,4,4,31],"J":[7,2,2,2,18,18,12],"K":[17,18,20,24,20,18,17],"L":[16,16,16,16,16,16,31],"M":[17,27,21,21,17,17,17],"N":[17,25,21,19,17,17,17],"O":[14,17,17,17,17,17,14],"P":[30,17,17,30,16,16,16],"Q":[14,17,17,17,21,18,13],"R":[30,17,17,30,20,18,17],"S":[15,16,16,14,1,1,30],"T":[31,4,4,4,4,4,4],"U":[17,17,17,17,17,17,14],"V":[17,17,17,17,17,10,4],"W":[17,17,17,21,21,21,10],"X":[17,17,10,4,10,17,17],"Y":[17,17,10,4,4,4,4],"Z":[31,1,2,4,8,16,31], +"0":[14,17,19,21,25,17,14],"1":[4,12,4,4,4,4,14],"2":[14,17,1,2,4,8,31],"3":[30,1,1,14,1,1,30],"4":[2,6,10,18,31,2,2],"5":[31,16,16,30,1,1,30],"6":[14,16,16,30,17,17,14],"7":[31,1,2,4,8,8,8],"8":[14,17,17,14,17,17,14],"9":[14,17,17,15,1,1,14],"!":[4,4,4,4,4,0,4],"-":[0,0,0,31,0,0,0],".":[0,0,0,0,0,12,12],":":[0,12,12,0,12,12,0] +} +var game: Node +var mesh := ArrayMesh.new() +var vertices := PackedVector3Array() +var colors := PackedColorArray() +var indices := PackedInt32Array() +var logo_pixels: Array[Vector2i] = [] +var title_craters: Array[Dictionary] = [] +var title_rows: Dictionary = {} + +func _ready() -> void: + game = get_parent().get_parent() + _build_logo() + +func _build_logo() -> void: + for row in range(2): + var word := "MISSILE" if row == 0 else "COMMAND" + for letter in range(word.length()): + for y in range(7): + for x in range(5): + if FONT[word[letter]][y] & (1 << (4-x)): + for sy in range(4): + for sx in range(4): + logo_pixels.append(Vector2i(46+letter*24+x*4+sx,65+row*43+y*4+sy)) + # Each crater has a deterministic impact time and persists as missing pixels. + for i in range(42): + title_craters.append({"pos":Vector2(42+fposmod(i*59.7,174),62+fposmod(i*37.3,79)),"time":1.0+i*.145,"radius":9.0+float(i%5)}) + + for row in range(2): + for letter in range(7): + title_craters.append({"pos":Vector2(56+letter*24,79+row*43),"time":6.2+letter*.1,"radius":20.0}) + # Precompute erosion times once, not a pixel/crater search every frame. + for point in logo_pixels: + var erased_at := 7.7 + for crater in title_craters: + var distance := Vector2(point).distance_to(crater.pos) + if distance < crater.radius: + erased_at = minf(erased_at,crater.time+distance/30.0) + if not title_rows.has(point.y): title_rows[point.y] = [] + title_rows[point.y].append(Vector2(point.x,erased_at)) + for y in title_rows: + title_rows[y].sort_custom(func(a: Vector2,b: Vector2) -> bool: return a.x < b.x) + +func rect(position: Vector2, size: Vector2, color: Color) -> void: + var start := vertices.size() + for point in [position,position+Vector2(size.x,0),position+size,position+Vector2(0,size.y)]: + vertices.append(Vector3(floorf(point.x),floorf(point.y),0)) + colors.append(color) + indices.append_array(PackedInt32Array([start,start+1,start+2,start,start+2,start+3])) + +func segment(a: Vector2,b: Vector2,color: Color) -> void: + var count := maxi(1,int(maxf(absf(b.x-a.x),absf(b.y-a.y)))) + for i in range(count+1): rect(a.lerp(b,float(i)/count).round(),Vector2.ONE,color) + +func disk(center: Vector2,radius: float,color: Color) -> void: + var r := int(radius) + for y in range(-r,r+1): + var extent := int(sqrt(maxf(0,r*r-y*y))) + rect(center+Vector2(-extent,y),Vector2(extent*2+1,1),color) + +func text_at(value: String,at: Vector2,color: Color,size: int = 1) -> void: + for i in range(value.length()): + if not FONT.has(value[i]): continue + for y in range(7): + var run := -1 + for x in range(6): + var filled: bool = x < 5 and (FONT[value[i]][y] & (1 << (4-x))) != 0 + if filled and run < 0: run = x + if not filled and run >= 0: + rect(at+Vector2((i*6+run)*size,y*size),Vector2((x-run)*size,size),color) + run = -1 + +func label(value: String,y: float,color: Color,size: int = 1) -> void: + text_at(value,Vector2((256-(value.length()*6-1)*size)/2.0,y),color,size) + +func palette() -> Array: + return [[Color("ffff00"),Color("203cff"),Color("ff2525")],[Color("20e5ff"),Color("ff3030"),Color("ffffff")],[Color("ff3030"),Color("24ff35"),Color("ffff00")]][int((game.wave-1)/2)%3] + +func landscape() -> void: + var p := palette() + rect(Vector2(0,214),Vector2(256,17),p[0]) + for i in range(3): + var x: float = game.BASE_X[i] + for y in range(12): rect(Vector2(x-8-y,202+y),Vector2(16+y*2,1),p[0]) + if game.bases[i]: + var slots := [Vector2(0,-9),Vector2(-2,-6),Vector2(2,-6),Vector2(-4,-3),Vector2(0,-3),Vector2(4,-3),Vector2(-6,0),Vector2(-2,0),Vector2(2,0),Vector2(6,0)] + for missile in range(game.ammo[i]): + var at: Vector2 = Vector2(x,213)+slots[missile] + rect(at-Vector2(0,2),Vector2(1,3),p[1]) + rect(at-Vector2(1,0),Vector2(3,1),p[1]) + else: rect(Vector2(x-7,207),Vector2(14,7),Color.BLACK) + for i in range(6): + var x: float = game.CITY_X[i] + if game.cities[i]: + for building in range(7): + var h := 2+(building*3+i)%7 + rect(Vector2(x-9+building*3,214-h),Vector2(2,h),p[1]) + rect(Vector2(x-9,212),Vector2(20,2),p[1]) + else: + rect(Vector2(x-9,213),Vector2(19,2),Color.BLACK) + +func hud() -> void: + var p := palette() + text_at(str(game.score),Vector2(14,7),p[2]) + text_at(str(game.high_score),Vector2(106,7),p[2]) + text_at("1UP",Vector2(17,18),p[1]) + text_at("HIGH",Vector2(112,18),p[1]) + text_at("WAVE %d" % game.wave,Vector2(204,7),p[2]) + +func title() -> void: + var t: float = game.attract_time + var color := Color.from_hsv(fposmod(floor(t*5)/7,1),1,1) + for y in title_rows: + var run_start := -1 + var run_end := -1 + for pixel in title_rows[y]: + var x := int(pixel.x) + var visible: bool = t < pixel.y + if run_start >= 0 and (not visible or x > run_end+1): + rect(Vector2(run_start,y),Vector2(run_end-run_start+1,1),color) + run_start = -1 + if visible: + if run_start < 0: run_start = x + run_end = x + if run_start >= 0: rect(Vector2(run_start,y),Vector2(run_end-run_start+1,1),color) + for crater in title_craters: + var age: float = t-crater.time + if age > 0 and age < .65: + disk(crater.pos,minf(9,age*25)*(1-age/.65),Color.from_hsv(fposmod(t*.7+.33,1),1,1)) + label("PUSH START",174,Color("24ff35")) + label("MISSILE COMMAND",193,Color("2040ff")) + label("FREE PLAY",222,Color("ffff00")) + +func _draw() -> void: + if game == null: return + vertices.clear() + colors.clear() + indices.clear() + draw_rect(Rect2(Vector2.ZERO,SIZE),Color.BLACK) + if game.phase == "title": + title() + elif game.phase == "leaderboard": + landscape() + hud() + label("HIGH SCORES",37,Color("2040ff")) + for i in range(8): + var value := "%06d" % game.leaderboard[i] if i < game.leaderboard.size() else "------" + text_at("P1" if i < game.leaderboard.size() else "--",Vector2(70,56+i*12),Color.YELLOW) + text_at(value,Vector2(126,56+i*12),Color.YELLOW) + label("BONUS CITY EVERY",163,Color("2040ff")) + label("%d POINTS" % game.bonus_city_interval,176,Color.YELLOW) + label("PUSH START",195,Color("2040ff")) + else: + landscape() + hud() + var p := palette() + for missile in game.incoming: + segment(missile.origin,missile.pos,p[2]) + if missile.kind == "smart": + var at: Vector2 = missile.pos + segment(at+Vector2(0,-3),at+Vector2(2,0),Color.WHITE) + segment(at+Vector2(2,0),at+Vector2(0,3),Color.WHITE) + segment(at+Vector2(0,3),at+Vector2(-2,0),Color.WHITE) + segment(at+Vector2(-2,0),at+Vector2(0,-3),Color.WHITE) + else: rect(missile.pos,Vector2(2,2),Color.WHITE) + for shot in game.interceptors: + segment(shot.origin,shot.pos,p[1]) + rect(shot.pos,Vector2(2,2),Color.WHITE) + for plane in game.aircraft: + var at: Vector2 = plane.pos + segment(at-Vector2(6,0),at+Vector2(6,0),p[2]) + segment(at-Vector2(2,4),at+Vector2(2,4),p[2]) + segment(at-Vector2(5,2),at-Vector2(5,-2),p[2]) + for blast in game.blasts: + var color: Color = p[2] if int(game.clock*12)%3 != 0 else Color.WHITE + disk(blast.pos,blast.radius,color) + if blast.age > 1.0: disk(blast.pos,blast.radius*.5,Color.BLACK) + if game.phase in ["play","ready"]: + var at: Vector2 = game.aim.round() + segment(at-Vector2(3,0),at+Vector2(3,0),p[1]) + segment(at-Vector2(0,3),at+Vector2(0,3),p[1]) + if game.phase == "ready": label("DEFEND CITIES",181,p[1]) + if game.phase == "bonus": + label("BONUS POINTS",85,p[1]) + label(str(game.wave_bonus),102,p[0],2) + label("%d CITIES SAVED" % game.cities.count(true),131,p[1]) + if game.phase == "lost": + disk(Vector2(128,114),minf(75,(5-game.phase_time)*24),p[2]) + label("THE END",110,Color.BLACK,2) + if game.phase == "won": + label("YOU WIN!",100,p[0],2) + label("CITIES DEFENDED",123,p[1]) + label("PUSH START",178,p[1]) + mesh.clear_surfaces() + if not vertices.is_empty(): + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_COLOR] = colors + arrays[Mesh.ARRAY_INDEX] = indices + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES,arrays) + draw_mesh(mesh,null) + + + diff --git a/games/missile_command/pixel_board.gd.uid b/games/missile_command/pixel_board.gd.uid new file mode 100644 index 0000000..4d435af --- /dev/null +++ b/games/missile_command/pixel_board.gd.uid @@ -0,0 +1 @@ +uid://ed0yaf45xgff diff --git a/games/pacman/Pacman.tscn b/games/pacman/Pacman.tscn new file mode 100644 index 0000000..c36f9f0 --- /dev/null +++ b/games/pacman/Pacman.tscn @@ -0,0 +1,22 @@ +[gd_scene format=3 uid="uid://y0xg0i8yacqd"] + +[ext_resource type="Script" uid="uid://c375fg6kitvex" path="res://games/pacman/pacman.gd" id="1"] +[ext_resource type="Script" uid="uid://c0kalctx5x8nf" path="res://games/pacman/pacman_editor_preview.gd" id="2"] + +[node name="Pacman" type="Node2D" unique_id=1012196150] +script = ExtResource("1") + +[node name="Background" type="ColorRect" parent="." unique_id=1925270229] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0, 0, 0, 1) + +[node name="EditorPreview" type="Node2D" parent="." unique_id=452219297] +texture_filter = 1 +position = Vector2(624, 108) +scale = Vector2(3, 3) +script = ExtResource("2") diff --git a/games/pacman/art/sprites.png b/games/pacman/art/sprites.png new file mode 100644 index 0000000..5e21786 Binary files /dev/null and b/games/pacman/art/sprites.png differ diff --git a/games/pacman/art/sprites.png.import b/games/pacman/art/sprites.png.import new file mode 100644 index 0000000..fd83236 --- /dev/null +++ b/games/pacman/art/sprites.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://np8iifxlrof5" +path="res://.godot/imported/sprites.png-6a0c66632a3c198b081d2b7b7452fab9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/pacman/art/sprites.png" +dest_files=["res://.godot/imported/sprites.png-6a0c66632a3c198b081d2b7b7452fab9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/pacman/art/text.png b/games/pacman/art/text.png new file mode 100644 index 0000000..a36f0c4 Binary files /dev/null and b/games/pacman/art/text.png differ diff --git a/games/pacman/art/text.png.import b/games/pacman/art/text.png.import new file mode 100644 index 0000000..4dfbd3c --- /dev/null +++ b/games/pacman/art/text.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cbq03kf3nual3" +path="res://.godot/imported/text.png-d0d1c131f1ae1bb662e9ed4b6ddfead5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/pacman/art/text.png" +dest_files=["res://.godot/imported/text.png-d0d1c131f1ae1bb662e9ed4b6ddfead5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/pacman/audio/credit.wav b/games/pacman/audio/credit.wav new file mode 100644 index 0000000..38d46bd Binary files /dev/null and b/games/pacman/audio/credit.wav differ diff --git a/games/pacman/audio/credit.wav.import b/games/pacman/audio/credit.wav.import new file mode 100644 index 0000000..3566a8b --- /dev/null +++ b/games/pacman/audio/credit.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://b54r4c60urcai" +path="res://.godot/imported/credit.wav-92f0d7ae9a7bc6bec91c661f1ee37d4b.sample" + +[deps] + +source_file="res://games/pacman/audio/credit.wav" +dest_files=["res://.godot/imported/credit.wav-92f0d7ae9a7bc6bec91c661f1ee37d4b.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/death_0.wav b/games/pacman/audio/death_0.wav new file mode 100644 index 0000000..7fdd9b3 Binary files /dev/null and b/games/pacman/audio/death_0.wav differ diff --git a/games/pacman/audio/death_0.wav.import b/games/pacman/audio/death_0.wav.import new file mode 100644 index 0000000..6f4ed05 --- /dev/null +++ b/games/pacman/audio/death_0.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://ovd85rcstsbj" +path="res://.godot/imported/death_0.wav-9cac572d18aa62fc6445fd773e82e8fe.sample" + +[deps] + +source_file="res://games/pacman/audio/death_0.wav" +dest_files=["res://.godot/imported/death_0.wav-9cac572d18aa62fc6445fd773e82e8fe.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/death_1.wav b/games/pacman/audio/death_1.wav new file mode 100644 index 0000000..228c080 Binary files /dev/null and b/games/pacman/audio/death_1.wav differ diff --git a/games/pacman/audio/death_1.wav.import b/games/pacman/audio/death_1.wav.import new file mode 100644 index 0000000..3fface6 --- /dev/null +++ b/games/pacman/audio/death_1.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c4htnok4dd2h2" +path="res://.godot/imported/death_1.wav-3480eee71db390581fe6b7c8f5b1ab41.sample" + +[deps] + +source_file="res://games/pacman/audio/death_1.wav" +dest_files=["res://.godot/imported/death_1.wav-3480eee71db390581fe6b7c8f5b1ab41.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/eat_dot_0.wav b/games/pacman/audio/eat_dot_0.wav new file mode 100644 index 0000000..305ce53 Binary files /dev/null and b/games/pacman/audio/eat_dot_0.wav differ diff --git a/games/pacman/audio/eat_dot_0.wav.import b/games/pacman/audio/eat_dot_0.wav.import new file mode 100644 index 0000000..dae886f --- /dev/null +++ b/games/pacman/audio/eat_dot_0.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bwtevq2kndkjy" +path="res://.godot/imported/eat_dot_0.wav-e785d924a8ad68c8cad22f77721e8d3f.sample" + +[deps] + +source_file="res://games/pacman/audio/eat_dot_0.wav" +dest_files=["res://.godot/imported/eat_dot_0.wav-e785d924a8ad68c8cad22f77721e8d3f.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/eat_dot_1.wav b/games/pacman/audio/eat_dot_1.wav new file mode 100644 index 0000000..3aa4937 Binary files /dev/null and b/games/pacman/audio/eat_dot_1.wav differ diff --git a/games/pacman/audio/eat_dot_1.wav.import b/games/pacman/audio/eat_dot_1.wav.import new file mode 100644 index 0000000..cb4d57a --- /dev/null +++ b/games/pacman/audio/eat_dot_1.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bhai4c6nel4hr" +path="res://.godot/imported/eat_dot_1.wav-589291afc9dc717182937ec5a490b116.sample" + +[deps] + +source_file="res://games/pacman/audio/eat_dot_1.wav" +dest_files=["res://.godot/imported/eat_dot_1.wav-589291afc9dc717182937ec5a490b116.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/eat_fruit.wav b/games/pacman/audio/eat_fruit.wav new file mode 100644 index 0000000..fe3035f Binary files /dev/null and b/games/pacman/audio/eat_fruit.wav differ diff --git a/games/pacman/audio/eat_fruit.wav.import b/games/pacman/audio/eat_fruit.wav.import new file mode 100644 index 0000000..f2a3fde --- /dev/null +++ b/games/pacman/audio/eat_fruit.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bltx7wcc4rxas" +path="res://.godot/imported/eat_fruit.wav-a61a6e480dfc9456d45ca90aab280a2d.sample" + +[deps] + +source_file="res://games/pacman/audio/eat_fruit.wav" +dest_files=["res://.godot/imported/eat_fruit.wav-a61a6e480dfc9456d45ca90aab280a2d.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/eat_ghost.wav b/games/pacman/audio/eat_ghost.wav new file mode 100644 index 0000000..e2040ad Binary files /dev/null and b/games/pacman/audio/eat_ghost.wav differ diff --git a/games/pacman/audio/eat_ghost.wav.import b/games/pacman/audio/eat_ghost.wav.import new file mode 100644 index 0000000..aba0bc3 --- /dev/null +++ b/games/pacman/audio/eat_ghost.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bi6eyjbt8nkbh" +path="res://.godot/imported/eat_ghost.wav-6928c0d767e65ac3b8dc104f903ce44e.sample" + +[deps] + +source_file="res://games/pacman/audio/eat_ghost.wav" +dest_files=["res://.godot/imported/eat_ghost.wav-6928c0d767e65ac3b8dc104f903ce44e.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/extend.wav b/games/pacman/audio/extend.wav new file mode 100644 index 0000000..9cf27aa Binary files /dev/null and b/games/pacman/audio/extend.wav differ diff --git a/games/pacman/audio/extend.wav.import b/games/pacman/audio/extend.wav.import new file mode 100644 index 0000000..57680ed --- /dev/null +++ b/games/pacman/audio/extend.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cn4y8jnq27rkf" +path="res://.godot/imported/extend.wav-22067b1829b78c441ada74f3450451a7.sample" + +[deps] + +source_file="res://games/pacman/audio/extend.wav" +dest_files=["res://.godot/imported/extend.wav-22067b1829b78c441ada74f3450451a7.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/eyes.wav b/games/pacman/audio/eyes.wav new file mode 100644 index 0000000..f918b54 Binary files /dev/null and b/games/pacman/audio/eyes.wav differ diff --git a/games/pacman/audio/eyes.wav.import b/games/pacman/audio/eyes.wav.import new file mode 100644 index 0000000..33ce45b --- /dev/null +++ b/games/pacman/audio/eyes.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cy433r2abm2qm" +path="res://.godot/imported/eyes.wav-6339bb3cfd78ee9fb7c65c8d14fae1b4.sample" + +[deps] + +source_file="res://games/pacman/audio/eyes.wav" +dest_files=["res://.godot/imported/eyes.wav-6339bb3cfd78ee9fb7c65c8d14fae1b4.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/eyes_firstloop.wav b/games/pacman/audio/eyes_firstloop.wav new file mode 100644 index 0000000..e856922 Binary files /dev/null and b/games/pacman/audio/eyes_firstloop.wav differ diff --git a/games/pacman/audio/eyes_firstloop.wav.import b/games/pacman/audio/eyes_firstloop.wav.import new file mode 100644 index 0000000..8c69519 --- /dev/null +++ b/games/pacman/audio/eyes_firstloop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://d4ljtyavowqbk" +path="res://.godot/imported/eyes_firstloop.wav-135f00eb080c2f8bf64b2349457c055e.sample" + +[deps] + +source_file="res://games/pacman/audio/eyes_firstloop.wav" +dest_files=["res://.godot/imported/eyes_firstloop.wav-135f00eb080c2f8bf64b2349457c055e.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/fright.wav b/games/pacman/audio/fright.wav new file mode 100644 index 0000000..45fde41 Binary files /dev/null and b/games/pacman/audio/fright.wav differ diff --git a/games/pacman/audio/fright.wav.import b/games/pacman/audio/fright.wav.import new file mode 100644 index 0000000..2d545e9 --- /dev/null +++ b/games/pacman/audio/fright.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cw8kuxiv30fh4" +path="res://.godot/imported/fright.wav-0174c816078cec81baabdba1e8269e91.sample" + +[deps] + +source_file="res://games/pacman/audio/fright.wav" +dest_files=["res://.godot/imported/fright.wav-0174c816078cec81baabdba1e8269e91.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/fright_firstloop.wav b/games/pacman/audio/fright_firstloop.wav new file mode 100644 index 0000000..bb7a59a Binary files /dev/null and b/games/pacman/audio/fright_firstloop.wav differ diff --git a/games/pacman/audio/fright_firstloop.wav.import b/games/pacman/audio/fright_firstloop.wav.import new file mode 100644 index 0000000..1d97e6e --- /dev/null +++ b/games/pacman/audio/fright_firstloop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://drsvbhyuv3yp4" +path="res://.godot/imported/fright_firstloop.wav-f882fcff985ed68cb9a48727df0588e7.sample" + +[deps] + +source_file="res://games/pacman/audio/fright_firstloop.wav" +dest_files=["res://.godot/imported/fright_firstloop.wav-f882fcff985ed68cb9a48727df0588e7.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/intermission.wav b/games/pacman/audio/intermission.wav new file mode 100644 index 0000000..a8028ae Binary files /dev/null and b/games/pacman/audio/intermission.wav differ diff --git a/games/pacman/audio/intermission.wav.import b/games/pacman/audio/intermission.wav.import new file mode 100644 index 0000000..302ecf9 --- /dev/null +++ b/games/pacman/audio/intermission.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://nqaa736y0aa2" +path="res://.godot/imported/intermission.wav-9454a9c38b8a759357091a745dfdacc8.sample" + +[deps] + +source_file="res://games/pacman/audio/intermission.wav" +dest_files=["res://.godot/imported/intermission.wav-9454a9c38b8a759357091a745dfdacc8.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/siren0.wav b/games/pacman/audio/siren0.wav new file mode 100644 index 0000000..2ce8173 Binary files /dev/null and b/games/pacman/audio/siren0.wav differ diff --git a/games/pacman/audio/siren0.wav.import b/games/pacman/audio/siren0.wav.import new file mode 100644 index 0000000..076e451 --- /dev/null +++ b/games/pacman/audio/siren0.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://ceg1xejgjr70f" +path="res://.godot/imported/siren0.wav-c81bb85bf12b08da36d0bfd788c39b44.sample" + +[deps] + +source_file="res://games/pacman/audio/siren0.wav" +dest_files=["res://.godot/imported/siren0.wav-c81bb85bf12b08da36d0bfd788c39b44.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/siren0_firstloop.wav b/games/pacman/audio/siren0_firstloop.wav new file mode 100644 index 0000000..1be885a Binary files /dev/null and b/games/pacman/audio/siren0_firstloop.wav differ diff --git a/games/pacman/audio/siren0_firstloop.wav.import b/games/pacman/audio/siren0_firstloop.wav.import new file mode 100644 index 0000000..432f2b9 --- /dev/null +++ b/games/pacman/audio/siren0_firstloop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dn36vo2x0ub13" +path="res://.godot/imported/siren0_firstloop.wav-812fe5df64e7c2d782c1107cee0cb3ec.sample" + +[deps] + +source_file="res://games/pacman/audio/siren0_firstloop.wav" +dest_files=["res://.godot/imported/siren0_firstloop.wav-812fe5df64e7c2d782c1107cee0cb3ec.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/siren1.wav b/games/pacman/audio/siren1.wav new file mode 100644 index 0000000..926ce9b Binary files /dev/null and b/games/pacman/audio/siren1.wav differ diff --git a/games/pacman/audio/siren1.wav.import b/games/pacman/audio/siren1.wav.import new file mode 100644 index 0000000..f898bf3 --- /dev/null +++ b/games/pacman/audio/siren1.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://djv5wb36s67eb" +path="res://.godot/imported/siren1.wav-cbe72c5a62b545c10c2aa59940f52d3d.sample" + +[deps] + +source_file="res://games/pacman/audio/siren1.wav" +dest_files=["res://.godot/imported/siren1.wav-cbe72c5a62b545c10c2aa59940f52d3d.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/siren1_firstloop.wav b/games/pacman/audio/siren1_firstloop.wav new file mode 100644 index 0000000..3ff19ee Binary files /dev/null and b/games/pacman/audio/siren1_firstloop.wav differ diff --git a/games/pacman/audio/siren1_firstloop.wav.import b/games/pacman/audio/siren1_firstloop.wav.import new file mode 100644 index 0000000..596d30d --- /dev/null +++ b/games/pacman/audio/siren1_firstloop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c5oq5f1ly4ifq" +path="res://.godot/imported/siren1_firstloop.wav-166981f88f13d9478d3107ed333295d3.sample" + +[deps] + +source_file="res://games/pacman/audio/siren1_firstloop.wav" +dest_files=["res://.godot/imported/siren1_firstloop.wav-166981f88f13d9478d3107ed333295d3.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/siren2.wav b/games/pacman/audio/siren2.wav new file mode 100644 index 0000000..2e7f933 Binary files /dev/null and b/games/pacman/audio/siren2.wav differ diff --git a/games/pacman/audio/siren2.wav.import b/games/pacman/audio/siren2.wav.import new file mode 100644 index 0000000..ea8ab6a --- /dev/null +++ b/games/pacman/audio/siren2.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bdb6mkd0vbbe5" +path="res://.godot/imported/siren2.wav-c006186d3fd86cbc8d3312d6a077819e.sample" + +[deps] + +source_file="res://games/pacman/audio/siren2.wav" +dest_files=["res://.godot/imported/siren2.wav-c006186d3fd86cbc8d3312d6a077819e.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/siren2_firstloop.wav b/games/pacman/audio/siren2_firstloop.wav new file mode 100644 index 0000000..36e61bd Binary files /dev/null and b/games/pacman/audio/siren2_firstloop.wav differ diff --git a/games/pacman/audio/siren2_firstloop.wav.import b/games/pacman/audio/siren2_firstloop.wav.import new file mode 100644 index 0000000..668717d --- /dev/null +++ b/games/pacman/audio/siren2_firstloop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dx6gucejy14ii" +path="res://.godot/imported/siren2_firstloop.wav-c5cd8e073ceeb2cf1d5f2a52b1591395.sample" + +[deps] + +source_file="res://games/pacman/audio/siren2_firstloop.wav" +dest_files=["res://.godot/imported/siren2_firstloop.wav-c5cd8e073ceeb2cf1d5f2a52b1591395.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/siren3.wav b/games/pacman/audio/siren3.wav new file mode 100644 index 0000000..f918c12 Binary files /dev/null and b/games/pacman/audio/siren3.wav differ diff --git a/games/pacman/audio/siren3.wav.import b/games/pacman/audio/siren3.wav.import new file mode 100644 index 0000000..8720e79 --- /dev/null +++ b/games/pacman/audio/siren3.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bk86t2emh1t0v" +path="res://.godot/imported/siren3.wav-8a120be99cb0e0f3dfa8ad4aef35bd9c.sample" + +[deps] + +source_file="res://games/pacman/audio/siren3.wav" +dest_files=["res://.godot/imported/siren3.wav-8a120be99cb0e0f3dfa8ad4aef35bd9c.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/siren3_firstloop.wav b/games/pacman/audio/siren3_firstloop.wav new file mode 100644 index 0000000..80224b9 Binary files /dev/null and b/games/pacman/audio/siren3_firstloop.wav differ diff --git a/games/pacman/audio/siren3_firstloop.wav.import b/games/pacman/audio/siren3_firstloop.wav.import new file mode 100644 index 0000000..c0513c3 --- /dev/null +++ b/games/pacman/audio/siren3_firstloop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bksxvwormaye0" +path="res://.godot/imported/siren3_firstloop.wav-78226539db9aee757f5df5e53e8e99e0.sample" + +[deps] + +source_file="res://games/pacman/audio/siren3_firstloop.wav" +dest_files=["res://.godot/imported/siren3_firstloop.wav-78226539db9aee757f5df5e53e8e99e0.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/siren4.wav b/games/pacman/audio/siren4.wav new file mode 100644 index 0000000..3db307d Binary files /dev/null and b/games/pacman/audio/siren4.wav differ diff --git a/games/pacman/audio/siren4.wav.import b/games/pacman/audio/siren4.wav.import new file mode 100644 index 0000000..c27caaa --- /dev/null +++ b/games/pacman/audio/siren4.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://08e4iamm6wor" +path="res://.godot/imported/siren4.wav-c19a24ba9d0aa6a0dbdea17d8bc123e5.sample" + +[deps] + +source_file="res://games/pacman/audio/siren4.wav" +dest_files=["res://.godot/imported/siren4.wav-c19a24ba9d0aa6a0dbdea17d8bc123e5.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/siren4_firstloop.wav b/games/pacman/audio/siren4_firstloop.wav new file mode 100644 index 0000000..5ef2af3 Binary files /dev/null and b/games/pacman/audio/siren4_firstloop.wav differ diff --git a/games/pacman/audio/siren4_firstloop.wav.import b/games/pacman/audio/siren4_firstloop.wav.import new file mode 100644 index 0000000..25a4f1b --- /dev/null +++ b/games/pacman/audio/siren4_firstloop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cln07kvdnwb4e" +path="res://.godot/imported/siren4_firstloop.wav-f3e6af1140d2a0e60d5bce993412e499.sample" + +[deps] + +source_file="res://games/pacman/audio/siren4_firstloop.wav" +dest_files=["res://.godot/imported/siren4_firstloop.wav-f3e6af1140d2a0e60d5bce993412e499.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/audio/start.wav b/games/pacman/audio/start.wav new file mode 100644 index 0000000..b222b54 Binary files /dev/null and b/games/pacman/audio/start.wav differ diff --git a/games/pacman/audio/start.wav.import b/games/pacman/audio/start.wav.import new file mode 100644 index 0000000..5f6a4f7 --- /dev/null +++ b/games/pacman/audio/start.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://di0ql7bdioby4" +path="res://.godot/imported/start.wav-03500820d755c9b2b3fd094e7dac60a6.sample" + +[deps] + +source_file="res://games/pacman/audio/start.wav" +dest_files=["res://.godot/imported/start.wav-03500820d755c9b2b3fd094e7dac60a6.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/pacman/pacman.gd b/games/pacman/pacman.gd new file mode 100644 index 0000000..2d2306c --- /dev/null +++ b/games/pacman/pacman.gd @@ -0,0 +1,82 @@ +extends ArcadeGame +const Model = preload("res://games/pacman/pacman_model.gd") +const View = preload("res://games/pacman/pacman_view.gd") +const Sound = preload("res://games/pacman/pacman_audio.gd") + +@export_group("Room Objective") +## Zero means unlimited boards. Set a score target or use arcade mode. +@export_range(0, 100, 1) var boards_to_win: int = 1 +@export_range(0, 1000000, 100) var score_to_win: int = 0 +@export_range(1, 9, 1) var starting_lives: int = 3 +@export var extra_life_score: int = 10000 +var model = Model.new() +var view +var audio +var accumulator := 0.0 + +func _ready() -> void: + view = View.new() + view.model = model + add_child(view) + audio = Sound.new() + add_child(audio) + model.sound_requested.connect(audio.play_effect) + model.finished.connect(_on_finished) + _apply_config() + +func configure(config: Dictionary = {}) -> void: + super.configure(config) + _apply_config() + +func _apply_config() -> void: + model.boards_to_win = maxi(0, int(run_config.get("boards_to_win", boards_to_win))) + model.score_to_win = maxi(0, int(run_config.get("score_to_win", score_to_win))) + model.initial_lives = clampi(int(run_config.get("starting_lives", starting_lives)), 1, 9) + 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 + _apply_config() + audio.stop_all() + model.start() + accumulator = 0.0 + return super.start_game() + +func reset_game() -> void: + audio.stop_all() + model.reset() + accumulator = 0.0 + super.reset_game() + +func win_game() -> void: + if not is_running: return + model.state = "won" + audio.stop_all() + super.win_game() + +func lose_game() -> void: + if not is_running: return + model.state = "lost" + audio.stop_all() + super.lose_game() + +func _on_finished(won: bool) -> void: + if won: win_game() + else: lose_game() + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("arcade_start"): + start_game() + get_viewport().set_input_as_handled() + for pair in [["arcade_up", Vector2i.UP], ["arcade_left", Vector2i.LEFT], ["arcade_down", Vector2i.DOWN], ["arcade_right", Vector2i.RIGHT]]: + if event.is_action_pressed(pair[0]): model.requested = pair[1] + +func _physics_process(delta: float) -> void: + if not is_running: + model.clock += delta + if is_running: + accumulator += minf(delta, 0.25) + while accumulator >= 1.0 / 60.0 and is_running: + model.update(1.0 / 60.0) + accumulator -= 1.0 / 60.0 + if audio != null: audio.set_background(model.background_sound() if is_running else "") diff --git a/games/pacman/pacman.gd.uid b/games/pacman/pacman.gd.uid new file mode 100644 index 0000000..b952e6b --- /dev/null +++ b/games/pacman/pacman.gd.uid @@ -0,0 +1 @@ +uid://c375fg6kitvex diff --git a/games/pacman/pacman_audio.gd b/games/pacman/pacman_audio.gd new file mode 100644 index 0000000..56d4de0 --- /dev/null +++ b/games/pacman/pacman_audio.gd @@ -0,0 +1,96 @@ +extends Node +## Separate effects from ambience so pellet sounds never restart the siren. +## Tune this to the end of the fourth wah in death_0.wav. +const DEATH_INTRO_SECONDS := 1.35 +var death_cutoff: Timer +var effects: Array[AudioStreamPlayer] = [] +var ambience: AudioStreamPlayer +var streams := {} +var background := "" +var effect_cursor := 0 +var death_tail := false +var intro_pending := false + +func _ready() -> void: + death_cutoff = Timer.new() + death_cutoff.one_shot = true + add_child(death_cutoff) + death_cutoff.timeout.connect(_death_finished) + for i in range(4): + var voice := AudioStreamPlayer.new() + voice.volume_db = -10.0 + add_child(voice) + effects.append(voice) + ambience = AudioStreamPlayer.new() + ambience.volume_db = -15.0 + add_child(ambience) + ambience.finished.connect(_ambient_finished) + effects[3].finished.connect(_death_finished) + +func stream(name: String) -> AudioStream: + if not streams.has(name): + var path := "res://games/pacman/audio/%s.wav" % name + if not ResourceLoader.exists(path): return null + streams[name] = load(path) + return streams[name] + +func play_effect(name: String) -> void: + if name == "death": + stop_all() + death_tail = true + effects[3].stream = stream("death_0") + effects[3].play() + death_cutoff.start(DEATH_INTRO_SECONDS) + return + var voice: AudioStreamPlayer = effects[0] if name.begins_with("eat_dot") else effects[1 + effect_cursor % 2] + if not name.begins_with("eat_dot"): effect_cursor += 1 + voice.stream = stream(name) + if voice.stream != null: voice.play() + +func _death_finished() -> void: + if not death_tail: return + death_tail = false + death_cutoff.stop() + effects[3].stop() + effects[3].stream = stream("death_1") + effects[3].play() + +func set_background(name: String) -> void: + if name == background: return + background = name + ambience.stop() + intro_pending = false + if name.is_empty(): return + var intro := stream(name + "_firstloop") + # Join PCM samples before playback: no finished-signal gap at the intro seam. + ambience.stream = _loop_stream(name, intro) + if ambience.stream != null: ambience.play() + +func _loop_stream(name: String, intro: AudioStream = null) -> AudioStream: + var source := stream(name) + if source is AudioStreamWAV: + var loop: AudioStreamWAV = source.duplicate() + loop.loop_mode = AudioStreamWAV.LOOP_FORWARD + loop.loop_begin = 0 + loop.loop_end = roundi(loop.get_length() * loop.mix_rate) + if intro is AudioStreamWAV and intro.format == loop.format and intro.mix_rate == loop.mix_rate and intro.stereo == loop.stereo: + var intro_frames := roundi(intro.get_length() * intro.mix_rate) + loop.data = intro.data + loop.data + loop.loop_begin = intro_frames + loop.loop_end += intro_frames + return loop + return source + +func _ambient_finished() -> void: + if background.is_empty(): return + intro_pending = false + ambience.stream = _loop_stream(background) + if ambience.stream != null: ambience.play() + +func stop_all() -> void: + background = "" + death_tail = false + intro_pending = false + if death_cutoff != null: death_cutoff.stop() + if ambience != null: ambience.stop() + for voice in effects: voice.stop() diff --git a/games/pacman/pacman_audio.gd.uid b/games/pacman/pacman_audio.gd.uid new file mode 100644 index 0000000..a33dc92 --- /dev/null +++ b/games/pacman/pacman_audio.gd.uid @@ -0,0 +1 @@ +uid://bs1fyngribtup diff --git a/games/pacman/pacman_editor_preview.gd b/games/pacman/pacman_editor_preview.gd new file mode 100644 index 0000000..af91176 --- /dev/null +++ b/games/pacman/pacman_editor_preview.gd @@ -0,0 +1,42 @@ +@tool +extends "res://games/pacman/pacman_view.gd" +## A static design-time board, using the same renderer as the running game. +## No gameplay model, audio, input handlers, or room events are instantiated. + +func _ready() -> void: + super._ready() + if not Engine.is_editor_hint(): + hide() + set_process(false) + return + var preview_ghosts: Array[Dictionary] = [] + for i in range(4): + var ghost := Maze.actor([Maze.DOOR, Vector2i(13, 14), Vector2i(11, 14), Vector2i(15, 14)][i], Vector2i.LEFT) + ghost["status"] = "active" if i == 0 else "house" + ghost["frightened"] = false + preview_ghosts.append(ghost) + model = { + "state": "ready", "phase_time": 2.0, "clock": 0.0, + "score": 0, "high_score": 0, "level": 1, "lives": 3, + "dots": Maze.pellets(), "player": Maze.actor(Maze.PLAYER_START, Vector2i.LEFT), + "ghosts": preview_ghosts, "fruit_time": 0.0, "fright_time": 0.0, + "popup_time": 0.0, + } + _update_preview() + +func _process(_delta: float) -> void: + if Engine.is_editor_hint(): _update_preview() + +func _update_preview() -> void: + # Match runtime placement inside the configured game viewport, independently + # of the editor's current panel size, zoom, and pan. + var screen_size := Vector2( + ProjectSettings.get_setting("display/window/size/viewport_width", 1920), + ProjectSettings.get_setting("display/window/size/viewport_height", 1080) + ) + var factor := minf(screen_size.x / GAME_RECT.size.x, screen_size.y / GAME_RECT.size.y) + if factor >= 1.0: factor = floorf(factor) + scale = Vector2.ONE * factor + position = ((screen_size - GAME_RECT.size * factor) * 0.5).floor() + model.lives = int(get_parent().get("starting_lives")) + queue_redraw() diff --git a/games/pacman/pacman_editor_preview.gd.uid b/games/pacman/pacman_editor_preview.gd.uid new file mode 100644 index 0000000..94d1f39 --- /dev/null +++ b/games/pacman/pacman_editor_preview.gd.uid @@ -0,0 +1 @@ +uid://c0kalctx5x8nf diff --git a/games/pacman/pacman_maze.gd b/games/pacman/pacman_maze.gd new file mode 100644 index 0000000..4c910a6 --- /dev/null +++ b/games/pacman/pacman_maze.gd @@ -0,0 +1,78 @@ +extends RefCounted +## Logical map: artwork is never used as collision geometry. +const ROWS := [ + "############################", + "#............##............#", + "#.####.#####.##.#####.####.#", + "#o####.#####.##.#####.####o#", + "#.####.#####.##.#####.####.#", + "#..........................#", + "#.####.##.########.##.####.#", + "#.####.##.########.##.####.#", + "#......##....##....##......#", + "######.##### ## #####.######", + " #.##### ## #####.# ", + " #.## ##.# ", + " #.## ###--### ##.# ", + "######.## # # ##.######", + " . # # . ", + "######.## # # ##.######", + " #.## ######## ##.# ", + " #.## ##.# ", + " #.## ######## ##.# ", + "######.## ######## ##.######", + "#............##............#", + "#.####.#####.##.#####.####.#", + "#.####.#####.##.#####.####.#", + "#o..##....... .......##..o#", + "###.##.##.########.##.##.###", + "###.##.##.########.##.##.###", + "#......##....##....##......#", + "#.##########.##.##########.#", + "#.##########.##.##########.#", + "#..........................#", + "############################", +] +const DIRS := [Vector2i.UP, Vector2i.LEFT, Vector2i.DOWN, Vector2i.RIGHT] +const PLAYER_START := Vector2i(13, 23) +const DOOR := Vector2i(13, 11) + +static func neighbor(tile: Vector2i, direction: Vector2i) -> Vector2i: + var result := tile + direction + if result.y == 14: + result.x = posmod(result.x, 28) + return result + +static func walkable(tile: Vector2i, eyes := false) -> bool: + if tile.x < 0 or tile.x >= 28 or tile.y < 0 or tile.y >= 31: + return false + # Blank regions outside the maze are not corridors. + if tile.y != 14 and tile.y >= 9 and tile.y <= 19 and (tile.x < 5 or tile.x > 22): + return false + var cell: String = ROWS[tile.y][tile.x] + return cell != "#" and (cell != "-" or eyes) + +static func pellets() -> Dictionary: + var result := {} + for y in range(31): + for x in range(28): + if ROWS[y][x] in [".", "o"]: + result[Vector2i(x, y)] = ROWS[y][x] == "o" + return result + +static func actor(tile: Vector2i, direction: Vector2i) -> Dictionary: + return { + "tile": tile, + "next": tile, + "progress": 0.0, + "dir": direction, + "spawn_offset": Vector2.ZERO, + } + +static func position(actor_data: Dictionary) -> Vector2: + var offset := Vector2(actor_data.next - actor_data.tile) + if abs(offset.x) > 1.0: + offset.x = float(actor_data.dir.x) + # Actor centers use the logical maze lane. Rendering and collision share + # this exact position so a sprite cannot be shifted into a neighboring wall. + return Vector2(actor_data.tile) + actor_data.get("spawn_offset", Vector2.ZERO) + offset * actor_data.progress diff --git a/games/pacman/pacman_maze.gd.uid b/games/pacman/pacman_maze.gd.uid new file mode 100644 index 0000000..6606dcf --- /dev/null +++ b/games/pacman/pacman_maze.gd.uid @@ -0,0 +1 @@ +uid://ccswn2ej4ufva diff --git a/games/pacman/pacman_model.gd b/games/pacman/pacman_model.gd new file mode 100644 index 0000000..fddc825 --- /dev/null +++ b/games/pacman/pacman_model.gd @@ -0,0 +1,378 @@ +extends RefCounted +const Maze = preload("res://games/pacman/pacman_maze.gd") +const Rules = preload("res://games/pacman/pacman_rules.gd") +signal sound_requested(name: String) +signal finished(won: bool) + +var state := "idle" +var phase_time := 0.0 +var clock := 0.0 +var player_anim_time := 0.0 +var score := 0 +var high_score := 0 +var lives := 3 +var level := 1 +var boards_cleared := 0 +var boards_to_win := 1 +var score_to_win := 0 +var initial_lives := 3 +var extra_life_score := 10000 +var extra_awarded := false +var dots := {} +var player := {} +var ghosts: Array[Dictionary] = [] +var requested := Vector2i.LEFT +var fright_time := 0.0 +var ghost_chain := 0 +var mode_index := 0 +var mode_time := 0.0 +var pellet_pause := 0.0 +var release_idle := 0.0 +var release_counts := [0, 0, 0, 0] +var after_death := false +var global_dots := 0 +var fruit_time := 0.0 +var fruit_spawned := 0 +var popup_time := 0.0 +var popup_text := "" +var popup_position := Vector2.ZERO +var rng := RandomNumberGenerator.new() +const DEATH_PAUSE_SECONDS := 1.5 +const DEATH_ANIMATION_SECONDS := 1.55 +const DEATH_POP_SECONDS := 0.24 + +func _init() -> void: + reset() + +func reset() -> void: + state = "idle" + player_anim_time = 0.0 + score = 0 + lives = initial_lives + level = 1 + boards_cleared = 0 + extra_awarded = false + dots = Maze.pellets() + fruit_spawned = 0 + after_death = false + _reset_actors() + +func start() -> void: + reset() + state = "ready" + phase_time = 4.25 + sound_requested.emit("start") + +func _reset_actors() -> void: + player = Maze.actor(Maze.PLAYER_START, Vector2i.LEFT) + requested = Vector2i.LEFT + ghosts.clear() + for i in range(4): + var g := Maze.actor([Maze.DOOR, Vector2i(13, 14), Vector2i(11, 14), Vector2i(15, 14)][i], Vector2i.LEFT) + # Center the opening formation like the arcade cabinet. This is cleared + # on the first gameplay movement so normal lane centers stay unchanged. + g["spawn_offset"] = Vector2(0.5, 0.0) + g["status"] = "active" if i == 0 else "house" + g["frightened"] = false + g["reverse"] = false + ghosts.append(g) + mode_index = 0 + mode_time = 0.0 + fright_time = 0.0 + ghost_chain = 0 + pellet_pause = 0.0 + release_idle = 0.0 + release_counts = [0, 0, 0, 0] + global_dots = 0 + fruit_time = 0.0 + popup_time = 0.0 + rng.seed = 12345 + +func update(dt: float) -> void: + # Keep every clock-driven visual frozen during the pre-death pause. The + # phase timer still advances below, so the death animation starts on time. + if state != "death_pause": + clock += dt + popup_time = maxf(0.0, popup_time - dt) + if state in ["idle", "won", "lost"]: + return + if state != "playing": + phase_time -= dt + if phase_time <= 0.0: + match state: + "ready", "ghost_pause": state = "playing" + "death_pause": + state = "dying" + phase_time = DEATH_ANIMATION_SECONDS + sound_requested.emit("death") + "intermission": _next_level() + "dying": + state = "death_pop" + phase_time = DEATH_POP_SECONDS + "death_pop": + lives -= 1 + if lives <= 0: + _end(false) + else: + after_death = true + _reset_actors() + state = "ready" + phase_time = 2.0 + "clear": + boards_cleared += 1 + if boards_to_win > 0 and boards_cleared >= boards_to_win: + _end(true) + elif level in [2, 5] or (level >= 9 and (level - 9) % 4 == 0): + state = "intermission" + phase_time = 5.28 + sound_requested.emit("intermission") + else: + _next_level() + return + if fright_time > 0: + fright_time = maxf(0.0, fright_time - dt) + if fright_time == 0.0: + for g in ghosts: g.frightened = false + else: + mode_time += dt + var schedule: Array = Rules.schedule(level) + if mode_time >= schedule[mode_index]: + mode_time = 0.0 + mode_index = mini(mode_index + 1, 7) + for g in ghosts: + if g.status == "active": g.reverse = true + fruit_time = maxf(0.0, fruit_time - dt) + release_idle += dt + _release_ghosts() + if pellet_pause > 0.0: + pellet_pause -= dt + else: + _move_player(_player_speed() * dt) + if state != "playing": return + _check_collisions() + if state != "playing": return + # Animate Pac-Man only while he is actually travelling. This keeps the + # mouth cadence steady when the global clock is affected by pauses or + # unrelated game state changes. + if pellet_pause <= 0.0 and player.tile != player.next: + player_anim_time += dt + elif player.tile == player.next: + player_anim_time = 0.0 + for i in range(4): + _move_ghost(i, _ghost_speed(i) * dt) + _check_collisions() + if state != "playing": return + +func _player_speed() -> float: + var ratio := 0.8 if level == 1 else (0.9 if level < 5 or level >= 21 else 1.0) + if fright_time > 0.0: ratio = 0.9 if level == 1 else (0.95 if level < 5 else 1.0) + return 9.46875 * ratio + +func _ghost_speed(index: int) -> float: + var g: Dictionary = ghosts[index] + if g.status == "eyes": return 15.0 + if g.status in ["house", "leaving", "entering"]: return 4.0 + if g.tile.y == 14 and (g.tile.x < 6 or g.tile.x > 21): + return 9.46875 * (0.4 if level == 1 else (0.45 if level < 5 else 0.5)) + if g.frightened: return 9.46875 * (0.5 if level == 1 else (0.55 if level < 5 else 0.6)) + var ratio := 0.75 if level == 1 else (0.85 if level < 5 else 0.95) + if index == 0 and _elroy_active(): + ratio += 0.05 if dots.size() > Rules.elroy_threshold(level) / 2 else 0.1 + return 9.46875 * ratio + +func _elroy_active() -> bool: + return dots.size() <= Rules.elroy_threshold(level) and (not after_death or ghosts[3].status != "house") + +func _move_player(distance: float) -> void: + # Reversing is legal between tile centers. Other turns are buffered. + if player.tile != player.next and requested == -player.dir: + var previous: Vector2i = player.tile + player.tile = player.next + player.next = previous + player.progress = 1.0 - player.progress + player.dir = requested + while distance > 0.000001: + if player.tile == player.next: + _consume() + if state != "playing" or pellet_pause > 0.0: return + if Maze.walkable(Maze.neighbor(player.tile, requested)): + player.dir = requested + var destination: Vector2i = Maze.neighbor(player.tile, player.dir) + if not Maze.walkable(destination): return + player.next = destination + var step := minf(distance, 1.0 - player.progress) + player.progress += step + distance -= step + if player.progress >= 0.999999: + player.tile = player.next + player.progress = 0.0 + _consume() + if pellet_pause > 0.0 or state != "playing": return + +func _consume() -> void: + if dots.has(player.tile): + var power: bool = dots[player.tile] + dots.erase(player.tile) + _add_score(50 if power else 10) + if state != "playing": return + pellet_pause = (3.0 if power else 1.0) / 60.0 + sound_requested.emit("eat_dot_%d" % (dots.size() % 2)) + release_idle = 0.0 + if after_death: + global_dots += 1 + else: + for i in range(1, 4): + if ghosts[i].status == "house": + release_counts[i] += 1 + break + if power: + fright_time = Rules.frightened_seconds(level) + ghost_chain = 0 + for g in ghosts: + if g.status not in ["eyes", "entering"]: + g.frightened = fright_time > 0.0 + if g.status == "active": g.reverse = true + var eaten := 244 - dots.size() + if (eaten >= 70 and fruit_spawned == 0) or (eaten >= 170 and fruit_spawned == 1): + fruit_spawned += 1 + fruit_time = rng.randf_range(9.0, 10.0) + if dots.is_empty(): + state = "clear" + phase_time = 2.5 + if fruit_time > 0 and player.tile in [Vector2i(13, 17), Vector2i(14, 17)]: + fruit_time = 0.0 + var points: int = Rules.fruit_score(level) + _add_score(points) + _popup(str(points), Maze.position(player)) + sound_requested.emit("eat_fruit") + +func _add_score(points: int) -> void: + score += points + high_score = maxi(high_score, score) + if not extra_awarded and extra_life_score > 0 and score >= extra_life_score: + extra_awarded = true + lives += 1 + sound_requested.emit("extend") + if score_to_win > 0 and score >= score_to_win: _end(true) + +func _release_ghosts() -> void: + # Release one ghost at a time; returning ghosts take the same door route. + for g in ghosts: + if g.status in ["leaving", "entering"]: return + for i in range(1, 4): + if ghosts[i].status != "house": continue + var threshold: int = [0, 0, 30 if level == 1 else 0, 60 if level == 1 else (50 if level == 2 else 0)][i] + var ready: bool = release_counts[i] >= threshold + if after_death: ready = global_dots >= [0, 7, 17, 32][i] + if ready or release_idle >= (4.0 if level < 5 else 3.0): + ghosts[i].status = "leaving" + release_idle = 0.0 + return + +func _move_ghost(index: int, distance: float) -> void: + var g: Dictionary = ghosts[index] + g["spawn_offset"] = Vector2.ZERO + if g.status == "house": return + while distance > 0.000001: + if g.tile == g.next: + if g.status == "eyes" and g.tile == Maze.DOOR: + g.status = "entering" + if g.status == "entering" and g.tile == Vector2i(13, 14): + g.status = "leaving" + g.frightened = false + if g.status == "leaving" and g.tile == Maze.DOOR: + g.status = "active" + g.dir = Vector2i.LEFT + var direction := Vector2i.ZERO + if g.status == "leaving": + direction = Vector2i(signi(13 - g.tile.x), 0) if g.tile.x != 13 else Vector2i.UP + elif g.status == "entering": direction = Vector2i.DOWN + elif g.reverse: + direction = -g.dir + g.reverse = false + else: direction = _ghost_direction(index) + if direction == Vector2i.ZERO: return + g.dir = direction + g.next = Maze.neighbor(g.tile, direction) + var step := minf(distance, 1.0 - g.progress) + g.progress += step + distance -= step + if g.progress >= 0.999999: + g.tile = g.next + g.progress = 0.0 + +func _ghost_direction(index: int) -> Vector2i: + var g: Dictionary = ghosts[index] + var target: Vector2i = Rules.SCATTER[index] + if g.status == "eyes": target = Maze.DOOR + elif mode_index % 2 == 1 or (index == 0 and _elroy_active()): + target = Rules.target(index, player.tile, player.dir, ghosts[0].tile, g.tile) + var candidates: Array[Vector2i] = [] + for direction in Maze.DIRS: + if direction == -g.dir: continue + if not Maze.walkable(Maze.neighbor(g.tile, direction)): continue + # Original maze disallows upward turns at these junctions in normal modes. + if g.status == "active" and not g.frightened and direction == Vector2i.UP and g.tile in [Vector2i(12, 11), Vector2i(15, 11), Vector2i(12, 23), Vector2i(15, 23)]: continue + candidates.append(direction) + if candidates.is_empty(): + return -g.dir if Maze.walkable(Maze.neighbor(g.tile, -g.dir)) else Vector2i.ZERO + if g.frightened and g.status == "active": return candidates[rng.randi_range(0, candidates.size() - 1)] + var chosen: Vector2i = candidates[0] + var best := INF + for direction in candidates: + var test: Vector2i = Maze.neighbor(g.tile, direction) + var distance := Vector2(test).distance_squared_to(Vector2(target)) + if distance < best: + best = distance + chosen = direction + return chosen + +func _check_collisions() -> void: + for g in ghosts: + if g.status != "active": continue + var difference: Vector2 = Maze.position(g) - Maze.position(player) + if absf(difference.x) > 14.0: difference.x = 28.0 - absf(difference.x) + if difference.length_squared() > 0.45 * 0.45: continue + if g.frightened: + g.status = "eyes" + g.frightened = false + g.reverse = false + var points := 200 * (1 << mini(ghost_chain, 3)) + ghost_chain += 1 + _add_score(points) + if state != "playing": return + _popup(str(points), Maze.position(g)) + state = "ghost_pause" + phase_time = 0.6 + sound_requested.emit("eat_ghost") + else: + state = "death_pause" + phase_time = DEATH_PAUSE_SECONDS + fruit_time = 0.0 + return + +func _next_level() -> void: + level += 1 + dots = Maze.pellets() + fruit_spawned = 0 + after_death = false + _reset_actors() + state = "ready" + phase_time = 2.0 + +func _popup(text: String, pos: Vector2) -> void: + popup_text = text + popup_position = pos + popup_time = 1.0 + +func _end(won: bool) -> void: + if state in ["won", "lost"]: return + state = "won" if won else "lost" + finished.emit(won) + +func background_sound() -> String: + if state != "playing": return "" + for g in ghosts: + if g.status in ["eyes", "entering"]: return "eyes" + if fright_time > 0.0: return "fright" + return "siren%d" % mini(4, int((244 - dots.size()) / 50.0)) diff --git a/games/pacman/pacman_model.gd.uid b/games/pacman/pacman_model.gd.uid new file mode 100644 index 0000000..4bce487 --- /dev/null +++ b/games/pacman/pacman_model.gd.uid @@ -0,0 +1 @@ +uid://1wwerfcb54mj diff --git a/games/pacman/pacman_rules.gd b/games/pacman/pacman_rules.gd new file mode 100644 index 0000000..3e93ab1 --- /dev/null +++ b/games/pacman/pacman_rules.gd @@ -0,0 +1,37 @@ +extends RefCounted +## Tables and target rules are based on https://pacman.holenet.info/. +const SCATTER := [Vector2i(25, -3), Vector2i(2, -3), Vector2i(27, 32), Vector2i(0, 32)] +const FRIGHT := [6.0, 5.0, 4.0, 3.0, 2.0, 5.0, 2.0, 2.0, 1.0, 5.0, 2.0, 1.0, 1.0, 3.0, 1.0, 1.0, 0.0, 1.0] + +static func frightened_seconds(level: int) -> float: + return FRIGHT[level - 1] if level <= FRIGHT.size() else 0.0 + +static func schedule(level: int) -> Array: + if level == 1: + return [7.0, 20.0, 7.0, 20.0, 5.0, 20.0, 5.0, INF] + return [7.0 if level < 5 else 5.0, 20.0, 7.0 if level < 5 else 5.0, 20.0, 5.0, 1033.0 if level < 5 else 1037.0, 1.0 / 60.0, INF] + +static func target(index: int, player_tile: Vector2i, direction: Vector2i, blinky: Vector2i, ghost_tile: Vector2i) -> Vector2i: + match index: + 0: return player_tile + 1: return player_tile + direction * 4 + (Vector2i.LEFT * 4 if direction == Vector2i.UP else Vector2i.ZERO) + 2: + var ahead := player_tile + direction * 2 + (Vector2i.LEFT * 2 if direction == Vector2i.UP else Vector2i.ZERO) + return ahead * 2 - blinky + _: return player_tile if Vector2(ghost_tile).distance_squared_to(Vector2(player_tile)) >= 64.0 else SCATTER[3] + +static func elroy_threshold(level: int) -> int: + if level == 1: return 20 + if level == 2: return 30 + if level <= 5: return 40 + if level <= 8: return 50 + if level <= 11: return 60 + if level <= 14: return 80 + if level <= 18: return 100 + return 120 + +static func fruit_index(level: int) -> int: + return mini(7, int(level / 2.0)) + +static func fruit_score(level: int) -> int: + return [100, 300, 500, 700, 1000, 2000, 3000, 5000][fruit_index(level)] diff --git a/games/pacman/pacman_rules.gd.uid b/games/pacman/pacman_rules.gd.uid new file mode 100644 index 0000000..44400fc --- /dev/null +++ b/games/pacman/pacman_rules.gd.uid @@ -0,0 +1 @@ +uid://c5qe5vdxboj22 diff --git a/games/pacman/pacman_view.gd b/games/pacman/pacman_view.gd new file mode 100644 index 0000000..ebde2f5 --- /dev/null +++ b/games/pacman/pacman_view.gd @@ -0,0 +1,147 @@ +@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 + +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 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)) + for i in range(mini(model.lives - 1, 5)): + _sprite(Rect2(600, 16, 16, 16), Vector2(16 + i * 16, 280)) + _text("L%d" % model.level, Vector2(192, 276), Color("ffb852")) + match model.state: + "idle": + _banner("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 == "!": region = Rect2(88, 8, 8, 8) + else: continue + draw_texture_rect_region(FONT, Rect2(at + Vector2(i * 8, 0), Vector2(8, 8)), region, color) diff --git a/games/pacman/pacman_view.gd.uid b/games/pacman/pacman_view.gd.uid new file mode 100644 index 0000000..800c170 --- /dev/null +++ b/games/pacman/pacman_view.gd.uid @@ -0,0 +1 @@ +uid://df6mgds15wbar diff --git a/games/robotron/README.md b/games/robotron/README.md new file mode 100644 index 0000000..9e235e3 --- /dev/null +++ b/games/robotron/README.md @@ -0,0 +1,58 @@ +# Robotron 2084 setup and fidelity notes + +Robotron is installed in the project and registered in the Games database. Open Robotron.tscn and run the current scene to play. The game uses the framework's ArcadeGame start, win, loss, and reset signals. Existing cabinet assignments remain unchanged. + +## Controls + +- Enter, arcade_start, or gamepad Start begins a fresh game, including during a demonstration. +- WASD moves; arrow keys fire independently in eight directions. Diagonal speed is normalized. +- On a gamepad, the left stick moves and the right stick fires. Releasing the firing stick stops shooting. +- The cabinet requires dual_joystick_8way and start_button capabilities. Add these to a cabinet profile only when that cabinet provides the matching controls. + +The game installs default input actions at runtime only when they do not already exist. For a cabinet encoder, define robotron_move_left/right/up/down and robotron_fire_left/right/up/down in Project Settings, Input Map, with the desired bindings. Existing definitions take precedence. These use their own names so Robotron's arrow-key firing does not change the other games' movement actions. Physical cabinet/gamepad testing remains necessary. + +## Scene settings + +Select the Robotron root to change Waves To Win (default 1; set 0 for endless), Starting Lives (3), Difficulty (1), Extra Life Interval (25000; 0 disables), Sound Enabled, CRT Aspect, and Reduced Flashes. Save the scene before running through a cabinet. Settings are scene exports, not run_config overrides. + +Waves To Win defaults to one for escape-room completion. Set it to 0 to keep playing through brain waves starting at 5 and tank waves starting at 7. Completing the configured wave emits game_won and holds the victory screen. Losing all lives emits game_lost, then returns to the idle sequence. Reset preserves only the session high score. + +## Included gameplay + +Independent eight-way movement and rapid directional fire, four simultaneous player shots, human rescue chains worth 1000 through 5000, repeat extra-life thresholds, electrodes, and increasingly crowded waves are implemented. Shooting does not hurt humans. Player respawn preserves surviving entities and grants a short protective interval. + +Grunts chase the player and can destroy themselves on electrodes. Hulks survive shots, can be pushed back, and kill humans. Spheroids spawn enforcers; enforcers roam and fire. Quarks spawn tanks whose shells bounce off the arena boundaries. Brains take three hits, seek humans, convert them into hostile Progs, and launch homing cruise missiles. Enemy projectiles can be shot. Remaining hulks, electrodes, and humans do not prevent a wave from ending. + +The idle loop has an animated title, instruction screen, enemy roster, silent gameplay demonstration, and session high-score screen. Demonstration scores cannot change the real high score, and the demo never emits room start/win/loss events. Start interrupts any idle phase and initializes a fresh run. + +## Art and display + +All four supplied PNGs are included unchanged. Runtime sprite crops preserve existing transparency and opaque white details. The title uses the supplied logo/credit artwork, with extracted Williams emblems moving around its perimeter and a cycling logo palette. The screenshot's fixed credit count is replaced with FREE PLAY. HUD and instruction lettering use an approximate pixel font because no complete font sheet was supplied. + +The logical viewport is 292 by 240 with nearest-neighbor presentation and black margins. CRT Aspect presents it at 4:3; disable it for square source pixels. The renderer also provides an editor preview. Reduced Flashes stops the title movement/palette cycling and uses a steady accent color. Transition geometry still animates. + +## Sound mapping + +All 23 supplied WAVs are copied unchanged and loaded by the audio controller. Startup plays once when the runtime scene opens; game-start plays for a new run. Player-lazer accompanies firing, grunt-walk follows active grunts, and the human-save/human-die clips accompany rescue or loss. Robot-explode is the generic enemy destruction sound, with dedicated electrode, tank, brain, and spheroid destruction clips. Spheroid-spawn and quark-spawn accompany spawning. Enforcer-fire and tank-fire accompany projectiles, with tank-projectile-bounce on reflection. Human-to-prog and prog-transformation mark the two conversion stages. Player-death, extra-life, level-transition, and brain-wave-start follow their corresponding events. High-score-top plays when a completed losing run meets the session high score. + +Sound effects are muted during demo play. A single audio channel plays effects: each accepted new sound stops the previous sound before starting, preventing overlapping echoes. Repeated effects remain rate limited to avoid rapid retriggering in crowded waves. Audio timing approximates the original sound hardware. + +## Fidelity boundaries + +This is a playable recreation, not ROM emulation. Wave populations and placements, spawn quotas, shot limits, movement speeds, collision radii, projectile paths, respawn protection, and timing are approximations that still need side-by-side tuning. In particular, the original Brain target-human bug and exact Enforcer firing behavior are not reproduced. Sprite variants are drawn from the supplied sheets but not every original palette/animation state is implemented. The story, roster, transition effects, and demo are recreations rather than exact playback of the original sequences. Play is one-player free play; coin accounting, alternating two-player games, initials entry, and persistent high scores are not implemented. + +Visual reference: [supplied arcade footage](https://www.youtube.com/watch?v=l800GL6NQPY&t=28s). The native display dimensions and 4:3 presentation are corroborated by the [FinalBurn Neo machine definition](https://wiki.neo-source.com/database/view/arcade/robotron). Original manual references are catalogued in the [Robotron document archive](https://www.robotron-2084.co.uk/williams/documents/robotron). + +## Validation + +Automated checks pass for start guards, independent input actions, diagonal movement, firing release, rescue chains, extra-life thresholds, hulk knockback, human bullet immunity, electrode destruction, hulk kills, brain conversion, three-hit brains, spawners, shell bounces, brain/tank waves, wave completion, endless progression, room events, reset, and demo isolation. Tests ran in Godot 4.6.1 Compatibility; the project remains configured for 4.7. Title, roster, and gameplay captures were visually inspected. A desktop dense-wave render check reached 120 entities without a game-script failure; Raspberry Pi performance is unverified. Sandbox certificate-store and shader-cache warnings occurred, and desktop test shutdown reported a retained resource. + +Run tests/robotron/check_robotron.gd with Godot --headless --path . --script. The same directory includes render_preview.gd and benchmark.gd. + +Play checklist: +- Move diagonally while firing in another direction, then release the firing control. +- Rescue several humans and check increasing awards; lose a life and verify the chain resets. +- Shoot a hulk and an electrode; confirm the hulk survives and the electrode disappears. +- In endless mode, check brain conversion on wave 5 and tank shells on wave 7. +- Start during the demo; confirm clean score/lives and no demo-triggered room events. +- Verify cabinet input, sound levels, room completion, reset, and display scaling on the target machine. diff --git a/games/robotron/Robotron.tscn b/games/robotron/Robotron.tscn new file mode 100644 index 0000000..fc0e625 --- /dev/null +++ b/games/robotron/Robotron.tscn @@ -0,0 +1,33 @@ +[gd_scene format=3 uid="uid://bbe38558vhtp1"] + +[ext_resource type="Script" uid="uid://dllelyq8hgx07" path="res://games/robotron/robotron.gd" id="1"] +[ext_resource type="Script" uid="uid://dhxkitgihoy3d" path="res://games/robotron/board.gd" id="2"] +[ext_resource type="Script" uid="uid://dxycwontqumst" path="res://games/robotron/display.gd" id="3"] +[ext_resource type="Script" uid="uid://c1xbioehp2ccr" path="res://games/robotron/audio.gd" id="4"] +[ext_resource type="Shader" uid="uid://bowtwv6ih8lj" path="res://games/robotron/title_palette.gdshader" id="5"] + +[sub_resource type="ShaderMaterial" id="TitlePalette"] +shader = ExtResource("5") +shader_parameter/animate_title = true +shader_parameter/palette_step = 0 + +[node name="Robotron" type="Node" unique_id=593921370] +script = ExtResource("1") +waves_to_win = 0 + +[node name="PixelScreen" type="SubViewport" parent="." unique_id=230356224] +disable_3d = true +size = Vector2i(292, 240) +render_target_update_mode = 4 + +[node name="Board" type="Node2D" parent="PixelScreen" unique_id=1006156591] +texture_filter = 1 +script = ExtResource("2") + +[node name="Display" type="Node2D" parent="." unique_id=97971978] +texture_filter = 1 +material = SubResource("TitlePalette") +script = ExtResource("3") + +[node name="Audio" type="Node" parent="." unique_id=342326582] +script = ExtResource("4") diff --git a/games/robotron/art/humans.png b/games/robotron/art/humans.png new file mode 100644 index 0000000..4837e52 Binary files /dev/null and b/games/robotron/art/humans.png differ diff --git a/games/robotron/art/humans.png.import b/games/robotron/art/humans.png.import new file mode 100644 index 0000000..faba5db --- /dev/null +++ b/games/robotron/art/humans.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://h72sgu6d22pv" +path="res://.godot/imported/humans.png-17e546e10e75756c93aca223d85bac7b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/robotron/art/humans.png" +dest_files=["res://.godot/imported/humans.png-17e546e10e75756c93aca223d85bac7b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/robotron/art/player.png b/games/robotron/art/player.png new file mode 100644 index 0000000..0a9b0a0 Binary files /dev/null and b/games/robotron/art/player.png differ diff --git a/games/robotron/art/player.png.import b/games/robotron/art/player.png.import new file mode 100644 index 0000000..9aea74b --- /dev/null +++ b/games/robotron/art/player.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bgd3y37w5ea5q" +path="res://.godot/imported/player.png-7bd78229e4ea71b047460c0ee57e74cb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/robotron/art/player.png" +dest_files=["res://.godot/imported/player.png-7bd78229e4ea71b047460c0ee57e74cb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/robotron/art/robotrons.png b/games/robotron/art/robotrons.png new file mode 100644 index 0000000..25456ed Binary files /dev/null and b/games/robotron/art/robotrons.png differ diff --git a/games/robotron/art/robotrons.png.import b/games/robotron/art/robotrons.png.import new file mode 100644 index 0000000..35756bc --- /dev/null +++ b/games/robotron/art/robotrons.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cpd7qsb1dffdp" +path="res://.godot/imported/robotrons.png-2115096d401f70a24af3c3b7502e23bf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/robotron/art/robotrons.png" +dest_files=["res://.godot/imported/robotrons.png-2115096d401f70a24af3c3b7502e23bf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/robotron/art/titlescreen_example.png b/games/robotron/art/titlescreen_example.png new file mode 100644 index 0000000..872f019 Binary files /dev/null and b/games/robotron/art/titlescreen_example.png differ diff --git a/games/robotron/art/titlescreen_example.png.import b/games/robotron/art/titlescreen_example.png.import new file mode 100644 index 0000000..1af51ff --- /dev/null +++ b/games/robotron/art/titlescreen_example.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://vnrsif2q6d5r" +path="res://.godot/imported/titlescreen_example.png-60f0e70647e7cef015516ffef23c2f0f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/robotron/art/titlescreen_example.png" +dest_files=["res://.godot/imported/titlescreen_example.png-60f0e70647e7cef015516ffef23c2f0f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/robotron/audio.gd b/games/robotron/audio.gd new file mode 100644 index 0000000..bd22c08 --- /dev/null +++ b/games/robotron/audio.gd @@ -0,0 +1,21 @@ +extends Node +var tracks: Dictionary = {} +var voice := AudioStreamPlayer.new() +var gates: Dictionary = {} +func _ready() -> void: + for name in ["human-die","startup","electrode-explode","level-transition","spheroid-spawn","enforcer-fire","player-lazer","grunt-walk","human-save","robot-explode","player-death","game-start","tank-explode","tank-projectile-bounce","tank-fire","quark-spawn","brain-explode","prog-transformation","human-to-prog","extra-life","high-score-top","brain-wave-start","spheroid-pop"]: + tracks[name] = load("res://games/robotron/audio/"+name+".wav") + voice.volume_db = -15 + add_child(voice) +func effect(name: String) -> void: + if get_parent().demo or not get_parent().sound_enabled or not tracks.has(name): return + var now := Time.get_ticks_msec() + if now void: + voice.stop() + gates.clear() diff --git a/games/robotron/audio.gd.uid b/games/robotron/audio.gd.uid new file mode 100644 index 0000000..161600f --- /dev/null +++ b/games/robotron/audio.gd.uid @@ -0,0 +1 @@ +uid://c1xbioehp2ccr diff --git a/games/robotron/audio/brain-explode.wav b/games/robotron/audio/brain-explode.wav new file mode 100644 index 0000000..a6610c4 Binary files /dev/null and b/games/robotron/audio/brain-explode.wav differ diff --git a/games/robotron/audio/brain-explode.wav.import b/games/robotron/audio/brain-explode.wav.import new file mode 100644 index 0000000..bda15b7 --- /dev/null +++ b/games/robotron/audio/brain-explode.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://d1f8hmol234b7" +path="res://.godot/imported/brain-explode.wav-eb257aa4582e409968f11cff0dfaecb2.sample" + +[deps] + +source_file="res://games/robotron/audio/brain-explode.wav" +dest_files=["res://.godot/imported/brain-explode.wav-eb257aa4582e409968f11cff0dfaecb2.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/brain-wave-start.wav b/games/robotron/audio/brain-wave-start.wav new file mode 100644 index 0000000..f4a6a42 Binary files /dev/null and b/games/robotron/audio/brain-wave-start.wav differ diff --git a/games/robotron/audio/brain-wave-start.wav.import b/games/robotron/audio/brain-wave-start.wav.import new file mode 100644 index 0000000..de42729 --- /dev/null +++ b/games/robotron/audio/brain-wave-start.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dev6i7giud6ks" +path="res://.godot/imported/brain-wave-start.wav-d5d31728a09fe9adc41f02c9be62d889.sample" + +[deps] + +source_file="res://games/robotron/audio/brain-wave-start.wav" +dest_files=["res://.godot/imported/brain-wave-start.wav-d5d31728a09fe9adc41f02c9be62d889.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/electrode-explode.wav b/games/robotron/audio/electrode-explode.wav new file mode 100644 index 0000000..0db21c6 Binary files /dev/null and b/games/robotron/audio/electrode-explode.wav differ diff --git a/games/robotron/audio/electrode-explode.wav.import b/games/robotron/audio/electrode-explode.wav.import new file mode 100644 index 0000000..dcd2272 --- /dev/null +++ b/games/robotron/audio/electrode-explode.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c510aq3wmxfr8" +path="res://.godot/imported/electrode-explode.wav-94deded81f87387aa01d2cf8aaf59b0b.sample" + +[deps] + +source_file="res://games/robotron/audio/electrode-explode.wav" +dest_files=["res://.godot/imported/electrode-explode.wav-94deded81f87387aa01d2cf8aaf59b0b.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/enforcer-fire.wav b/games/robotron/audio/enforcer-fire.wav new file mode 100644 index 0000000..bff0bdb Binary files /dev/null and b/games/robotron/audio/enforcer-fire.wav differ diff --git a/games/robotron/audio/enforcer-fire.wav.import b/games/robotron/audio/enforcer-fire.wav.import new file mode 100644 index 0000000..2efa211 --- /dev/null +++ b/games/robotron/audio/enforcer-fire.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://fnwq6lxfkd64" +path="res://.godot/imported/enforcer-fire.wav-fbdf919096441f6fdd3dc412c82b809f.sample" + +[deps] + +source_file="res://games/robotron/audio/enforcer-fire.wav" +dest_files=["res://.godot/imported/enforcer-fire.wav-fbdf919096441f6fdd3dc412c82b809f.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/extra-life.wav b/games/robotron/audio/extra-life.wav new file mode 100644 index 0000000..83738d8 Binary files /dev/null and b/games/robotron/audio/extra-life.wav differ diff --git a/games/robotron/audio/extra-life.wav.import b/games/robotron/audio/extra-life.wav.import new file mode 100644 index 0000000..5a6b797 --- /dev/null +++ b/games/robotron/audio/extra-life.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c2lbp0mfeodwk" +path="res://.godot/imported/extra-life.wav-19a70d7388e345af9f7f730bc4b90652.sample" + +[deps] + +source_file="res://games/robotron/audio/extra-life.wav" +dest_files=["res://.godot/imported/extra-life.wav-19a70d7388e345af9f7f730bc4b90652.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/game-start.wav b/games/robotron/audio/game-start.wav new file mode 100644 index 0000000..9114dd0 Binary files /dev/null and b/games/robotron/audio/game-start.wav differ diff --git a/games/robotron/audio/game-start.wav.import b/games/robotron/audio/game-start.wav.import new file mode 100644 index 0000000..5a625dc --- /dev/null +++ b/games/robotron/audio/game-start.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c6typpwkisj8m" +path="res://.godot/imported/game-start.wav-4b865d843701ab928493a76855499efb.sample" + +[deps] + +source_file="res://games/robotron/audio/game-start.wav" +dest_files=["res://.godot/imported/game-start.wav-4b865d843701ab928493a76855499efb.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/grunt-walk.wav b/games/robotron/audio/grunt-walk.wav new file mode 100644 index 0000000..614e9d6 Binary files /dev/null and b/games/robotron/audio/grunt-walk.wav differ diff --git a/games/robotron/audio/grunt-walk.wav.import b/games/robotron/audio/grunt-walk.wav.import new file mode 100644 index 0000000..b00dec1 --- /dev/null +++ b/games/robotron/audio/grunt-walk.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bcehmbcjwptsx" +path="res://.godot/imported/grunt-walk.wav-faf58376d15b68b3a815bb279e447006.sample" + +[deps] + +source_file="res://games/robotron/audio/grunt-walk.wav" +dest_files=["res://.godot/imported/grunt-walk.wav-faf58376d15b68b3a815bb279e447006.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/high-score-top.wav b/games/robotron/audio/high-score-top.wav new file mode 100644 index 0000000..9c398bc Binary files /dev/null and b/games/robotron/audio/high-score-top.wav differ diff --git a/games/robotron/audio/high-score-top.wav.import b/games/robotron/audio/high-score-top.wav.import new file mode 100644 index 0000000..db6258e --- /dev/null +++ b/games/robotron/audio/high-score-top.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bwn6jkq0xpuu0" +path="res://.godot/imported/high-score-top.wav-8e6de3f4bbf1fece31c5fa6282adf209.sample" + +[deps] + +source_file="res://games/robotron/audio/high-score-top.wav" +dest_files=["res://.godot/imported/high-score-top.wav-8e6de3f4bbf1fece31c5fa6282adf209.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/human-die.wav b/games/robotron/audio/human-die.wav new file mode 100644 index 0000000..3c274e5 Binary files /dev/null and b/games/robotron/audio/human-die.wav differ diff --git a/games/robotron/audio/human-die.wav.import b/games/robotron/audio/human-die.wav.import new file mode 100644 index 0000000..54fb9fa --- /dev/null +++ b/games/robotron/audio/human-die.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cn2fw55nvynf5" +path="res://.godot/imported/human-die.wav-99fd3bed7e7e5c07dd59719c2228724a.sample" + +[deps] + +source_file="res://games/robotron/audio/human-die.wav" +dest_files=["res://.godot/imported/human-die.wav-99fd3bed7e7e5c07dd59719c2228724a.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/human-save.wav b/games/robotron/audio/human-save.wav new file mode 100644 index 0000000..996759e Binary files /dev/null and b/games/robotron/audio/human-save.wav differ diff --git a/games/robotron/audio/human-save.wav.import b/games/robotron/audio/human-save.wav.import new file mode 100644 index 0000000..7733397 --- /dev/null +++ b/games/robotron/audio/human-save.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://b3ww2vonrhhkd" +path="res://.godot/imported/human-save.wav-dee20155e9e26ff4bd95b65e30c1d026.sample" + +[deps] + +source_file="res://games/robotron/audio/human-save.wav" +dest_files=["res://.godot/imported/human-save.wav-dee20155e9e26ff4bd95b65e30c1d026.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/human-to-prog.wav b/games/robotron/audio/human-to-prog.wav new file mode 100644 index 0000000..2f0588f Binary files /dev/null and b/games/robotron/audio/human-to-prog.wav differ diff --git a/games/robotron/audio/human-to-prog.wav.import b/games/robotron/audio/human-to-prog.wav.import new file mode 100644 index 0000000..4ce1524 --- /dev/null +++ b/games/robotron/audio/human-to-prog.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://qx72ejxfca08" +path="res://.godot/imported/human-to-prog.wav-7089b66bf4dcb3d404cb16fcc2dd6529.sample" + +[deps] + +source_file="res://games/robotron/audio/human-to-prog.wav" +dest_files=["res://.godot/imported/human-to-prog.wav-7089b66bf4dcb3d404cb16fcc2dd6529.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/level-transition.wav b/games/robotron/audio/level-transition.wav new file mode 100644 index 0000000..eed646b Binary files /dev/null and b/games/robotron/audio/level-transition.wav differ diff --git a/games/robotron/audio/level-transition.wav.import b/games/robotron/audio/level-transition.wav.import new file mode 100644 index 0000000..4921d41 --- /dev/null +++ b/games/robotron/audio/level-transition.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dimmu61s3m5ew" +path="res://.godot/imported/level-transition.wav-6fc33834b681710f4c9cbb77b6f5f3cb.sample" + +[deps] + +source_file="res://games/robotron/audio/level-transition.wav" +dest_files=["res://.godot/imported/level-transition.wav-6fc33834b681710f4c9cbb77b6f5f3cb.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/player-death.wav b/games/robotron/audio/player-death.wav new file mode 100644 index 0000000..ce0c54b Binary files /dev/null and b/games/robotron/audio/player-death.wav differ diff --git a/games/robotron/audio/player-death.wav.import b/games/robotron/audio/player-death.wav.import new file mode 100644 index 0000000..4866744 --- /dev/null +++ b/games/robotron/audio/player-death.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://beqa8vnny763m" +path="res://.godot/imported/player-death.wav-55b9d5daa99d827e04bb3f544c18dada.sample" + +[deps] + +source_file="res://games/robotron/audio/player-death.wav" +dest_files=["res://.godot/imported/player-death.wav-55b9d5daa99d827e04bb3f544c18dada.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/player-lazer.wav b/games/robotron/audio/player-lazer.wav new file mode 100644 index 0000000..31ab929 Binary files /dev/null and b/games/robotron/audio/player-lazer.wav differ diff --git a/games/robotron/audio/player-lazer.wav.import b/games/robotron/audio/player-lazer.wav.import new file mode 100644 index 0000000..d857693 --- /dev/null +++ b/games/robotron/audio/player-lazer.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dr78jauiuuhnl" +path="res://.godot/imported/player-lazer.wav-05da460a1294a0fe996d94cf88c81bf2.sample" + +[deps] + +source_file="res://games/robotron/audio/player-lazer.wav" +dest_files=["res://.godot/imported/player-lazer.wav-05da460a1294a0fe996d94cf88c81bf2.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/prog-transformation.wav b/games/robotron/audio/prog-transformation.wav new file mode 100644 index 0000000..4a65183 Binary files /dev/null and b/games/robotron/audio/prog-transformation.wav differ diff --git a/games/robotron/audio/prog-transformation.wav.import b/games/robotron/audio/prog-transformation.wav.import new file mode 100644 index 0000000..5a2204b --- /dev/null +++ b/games/robotron/audio/prog-transformation.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://qfgui3ep0118" +path="res://.godot/imported/prog-transformation.wav-bb6c2bd0e6e8c190e9882d119dde61c2.sample" + +[deps] + +source_file="res://games/robotron/audio/prog-transformation.wav" +dest_files=["res://.godot/imported/prog-transformation.wav-bb6c2bd0e6e8c190e9882d119dde61c2.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/quark-spawn.wav b/games/robotron/audio/quark-spawn.wav new file mode 100644 index 0000000..5b8b069 Binary files /dev/null and b/games/robotron/audio/quark-spawn.wav differ diff --git a/games/robotron/audio/quark-spawn.wav.import b/games/robotron/audio/quark-spawn.wav.import new file mode 100644 index 0000000..5c2e56d --- /dev/null +++ b/games/robotron/audio/quark-spawn.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cr35y8iwvy7ck" +path="res://.godot/imported/quark-spawn.wav-9c0f31ab2f22bfe191e0be8f03b0b11b.sample" + +[deps] + +source_file="res://games/robotron/audio/quark-spawn.wav" +dest_files=["res://.godot/imported/quark-spawn.wav-9c0f31ab2f22bfe191e0be8f03b0b11b.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/robot-explode.wav b/games/robotron/audio/robot-explode.wav new file mode 100644 index 0000000..31e58ba Binary files /dev/null and b/games/robotron/audio/robot-explode.wav differ diff --git a/games/robotron/audio/robot-explode.wav.import b/games/robotron/audio/robot-explode.wav.import new file mode 100644 index 0000000..90c9fe8 --- /dev/null +++ b/games/robotron/audio/robot-explode.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cmlyl3e33eh4l" +path="res://.godot/imported/robot-explode.wav-8eea8ea0a6c79e087ab9c54c1b6454dd.sample" + +[deps] + +source_file="res://games/robotron/audio/robot-explode.wav" +dest_files=["res://.godot/imported/robot-explode.wav-8eea8ea0a6c79e087ab9c54c1b6454dd.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/spheroid-pop.wav b/games/robotron/audio/spheroid-pop.wav new file mode 100644 index 0000000..4a85d18 Binary files /dev/null and b/games/robotron/audio/spheroid-pop.wav differ diff --git a/games/robotron/audio/spheroid-pop.wav.import b/games/robotron/audio/spheroid-pop.wav.import new file mode 100644 index 0000000..a95559c --- /dev/null +++ b/games/robotron/audio/spheroid-pop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bh3mjr3r6nnft" +path="res://.godot/imported/spheroid-pop.wav-77f9dcf5365d5202df99a46f1492107a.sample" + +[deps] + +source_file="res://games/robotron/audio/spheroid-pop.wav" +dest_files=["res://.godot/imported/spheroid-pop.wav-77f9dcf5365d5202df99a46f1492107a.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/spheroid-spawn.wav b/games/robotron/audio/spheroid-spawn.wav new file mode 100644 index 0000000..e7e4b1b Binary files /dev/null and b/games/robotron/audio/spheroid-spawn.wav differ diff --git a/games/robotron/audio/spheroid-spawn.wav.import b/games/robotron/audio/spheroid-spawn.wav.import new file mode 100644 index 0000000..17f673c --- /dev/null +++ b/games/robotron/audio/spheroid-spawn.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://6hq1t3mexcyw" +path="res://.godot/imported/spheroid-spawn.wav-7681b106e9d24fa45a1a55c0eddb90c5.sample" + +[deps] + +source_file="res://games/robotron/audio/spheroid-spawn.wav" +dest_files=["res://.godot/imported/spheroid-spawn.wav-7681b106e9d24fa45a1a55c0eddb90c5.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/startup.wav b/games/robotron/audio/startup.wav new file mode 100644 index 0000000..0e7a06e Binary files /dev/null and b/games/robotron/audio/startup.wav differ diff --git a/games/robotron/audio/startup.wav.import b/games/robotron/audio/startup.wav.import new file mode 100644 index 0000000..4c478b4 --- /dev/null +++ b/games/robotron/audio/startup.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c33524qlyohrh" +path="res://.godot/imported/startup.wav-09f3a543c3cc1641263246bb8a2e314b.sample" + +[deps] + +source_file="res://games/robotron/audio/startup.wav" +dest_files=["res://.godot/imported/startup.wav-09f3a543c3cc1641263246bb8a2e314b.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/tank-explode.wav b/games/robotron/audio/tank-explode.wav new file mode 100644 index 0000000..1e9f3cd Binary files /dev/null and b/games/robotron/audio/tank-explode.wav differ diff --git a/games/robotron/audio/tank-explode.wav.import b/games/robotron/audio/tank-explode.wav.import new file mode 100644 index 0000000..be75ce2 --- /dev/null +++ b/games/robotron/audio/tank-explode.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://fh5303842hua" +path="res://.godot/imported/tank-explode.wav-06ef0590bf491e2fa992dd03e3e11cb9.sample" + +[deps] + +source_file="res://games/robotron/audio/tank-explode.wav" +dest_files=["res://.godot/imported/tank-explode.wav-06ef0590bf491e2fa992dd03e3e11cb9.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/tank-fire.wav b/games/robotron/audio/tank-fire.wav new file mode 100644 index 0000000..08a7cb8 Binary files /dev/null and b/games/robotron/audio/tank-fire.wav differ diff --git a/games/robotron/audio/tank-fire.wav.import b/games/robotron/audio/tank-fire.wav.import new file mode 100644 index 0000000..fb08a54 --- /dev/null +++ b/games/robotron/audio/tank-fire.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://6xpa0q7yeb7y" +path="res://.godot/imported/tank-fire.wav-aa436e73a9bdf6b01d0c6684a52ffd1a.sample" + +[deps] + +source_file="res://games/robotron/audio/tank-fire.wav" +dest_files=["res://.godot/imported/tank-fire.wav-aa436e73a9bdf6b01d0c6684a52ffd1a.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/audio/tank-projectile-bounce.wav b/games/robotron/audio/tank-projectile-bounce.wav new file mode 100644 index 0000000..1bc1c34 Binary files /dev/null and b/games/robotron/audio/tank-projectile-bounce.wav differ diff --git a/games/robotron/audio/tank-projectile-bounce.wav.import b/games/robotron/audio/tank-projectile-bounce.wav.import new file mode 100644 index 0000000..8daf46a --- /dev/null +++ b/games/robotron/audio/tank-projectile-bounce.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dgucnurwckx8t" +path="res://.godot/imported/tank-projectile-bounce.wav-8189827f0f7c9a2573cc9caad22d14e6.sample" + +[deps] + +source_file="res://games/robotron/audio/tank-projectile-bounce.wav" +dest_files=["res://.godot/imported/tank-projectile-bounce.wav-8189827f0f7c9a2573cc9caad22d14e6.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/robotron/board.gd b/games/robotron/board.gd new file mode 100644 index 0000000..b618c61 --- /dev/null +++ b/games/robotron/board.gd @@ -0,0 +1,188 @@ +@tool +extends Node2D +const PLAYER = preload("res://games/robotron/art/player.png") +const HUMANS = preload("res://games/robotron/art/humans.png") +const ROBOTS = preload("res://games/robotron/art/robotrons.png") +const TITLE = preload("res://games/robotron/art/titlescreen_example.png") +const COLORS := [Color.WHITE,Color.YELLOW,Color("ff8000"),Color.MAGENTA,Color.GREEN,Color.RED,Color.BLUE] +# Small pixel font for HUD and instructions; artwork remains in supplied sheets. +const FONT := { +"A":"01110100011000111111100011000110001","B":"11110100011000111110100011000111110","C":"01111100001000010000100001000001111","D":"11110100011000110001100011000111110", +"E":"11111100001000011110100001000011111","F":"11111100001000011110100001000010000","G":"01111100001000010111100011000101111","H":"10001100011000111111100011000110001", +"I":"11111001000010000100001000010011111","J":"00111000100001000010000101001001100","K":"10001100101010011000101001001010001","L":"10000100001000010000100001000011111", +"M":"10001110111010110101100011000110001","N":"10001110011010110011100011000110001","O":"01110100011000110001100011000101110","P":"11110100011000111110100001000010000", +"Q":"01110100011000110001101011001001101","R":"11110100011000111110101001001010001","S":"01111100001000001110000010000111110","T":"11111001000010000100001000010000100", +"U":"10001100011000110001100011000101110","V":"10001100011000110001100010101000100","W":"10001100011000110101101011101110001","X":"10001100010101000100010101000110001", +"Y":"10001100010101000100001000010000100","Z":"11111000010001000100010001000011111","0":"01110100011001110101110011000101110","1":"00100011000010000100001000010001110", +"2":"01110100010000100010001000100011111","3":"11110000010000101110000010000111110","4":"00010001100101010010111110001000010","5":"11111100001000011110000010000111110", +"6":"01110100001000011110100011000101110","7":"11111000010001000100010000100001000","8":"01110100011000101110100011000101110","9":"01110100011000101111000010000101110", +"-":"00000000000000011111000000000000000",":":"00000001000010000000001000010000000","!":"00100001000010000100001000000000100",".":"00000000000000000000000000010000100"} +var sprites: Dictionary = {} +var game: Node +var badge: ImageTexture + +func _ready() -> void: + game = get_parent().get_parent() + var player := PLAYER.get_image() + for f in range(3): + for d in range(4): + register("player%d_%d"%[d,f],player,Rect2i([0,7,14,23][d],[0,12,25][f],[5,5,7,7][d],[11,12,10][f]),Vector2i(8,12)) + var human := HUMANS.get_image() + for f in range(3): + for d in range(4): + register("human0_%d_%d"%[d,f],human,Rect2i([0,9,19,30][d],[0,13,27][f],[8,8,9,8][d],12),Vector2i(10,14)) + register("human1_%d_%d"%[d,f],human,Rect2i([0,9,18,28][d],[41,54,70][f],[8,8,10,10][d],12 if f==0 else 14),Vector2i(10,14)) + register("human2_%d_%d"%[d,f],human,Rect2i([1,7,12,18][d],[85,96,108][f],[4,4,5,4][d],10 if f==0 else 11),Vector2i(6,12)) + register("prog",human,Rect2i(0,120,11,11)) + var robots := ROBOTS.get_image() + for f in range(3): + register("grunt%d"%f,robots,Rect2i(f*12,0,9,13),Vector2i(10,14)) + register("hulk%d"%f,robots,Rect2i(14,[14,30,46][f],11,15),Vector2i(14,16)) + register("brain%d"%f,robots,Rect2i(1,[141,157,174][f],12,15),Vector2i(14,16)) + register("spheroid",robots,Rect2i(45,65,11,11)) + register("enforcer",robots,Rect2i(39,79,11,11)) + register("quark",robots,Rect2i(27,103,9,9)) + register("tank0",robots,Rect2i(29,116,14,17)) + register("tank1",robots,Rect2i(44,116,14,17)) + register("tank2",robots,Rect2i(59,116,14,17)) + register("shell",robots,Rect2i(0,134,7,7)) + for i in range(6): + var key := "electrode%d"%i + register(key,robots,[Rect2i(1,220,7,7),Rect2i(11,220,7,7),Rect2i(21,220,7,7),Rect2i(32,220,7,7),Rect2i(49,220,7,7),Rect2i(84,220,7,7)][i]) + var mask: Image = sprites[key].get_image() + for y in range(mask.get_height()): + for x in range(mask.get_width()): + var alpha := mask.get_pixel(x,y).a + mask.set_pixel(x,y,Color(1,1,1,alpha)) + sprites[key] = ImageTexture.create_from_image(mask) + # Reuse the supplied title's exact Williams emblem as a tintable mask. + var emblem := TITLE.get_image().get_region(Rect2i(48,16,56,54)) + emblem.convert(Image.FORMAT_RGBA8) + for y in range(emblem.get_height()): + for x in range(emblem.get_width()): + var c := emblem.get_pixel(x,y) + emblem.set_pixel(x,y,Color.WHITE if c.r>.8 and c.g>.8 and c.b>.8 else Color.TRANSPARENT) + badge = ImageTexture.create_from_image(emblem) + +func register(key: String, image: Image, rect: Rect2i, canvas: Vector2i = Vector2i.ZERO) -> void: + var piece := image.get_region(rect) + if canvas!=Vector2i.ZERO: + var padded := Image.create(canvas.x,canvas.y,false,Image.FORMAT_RGBA8) + piece.convert(Image.FORMAT_RGBA8) + padded.blit_rect(piece,Rect2i(Vector2i.ZERO,piece.get_size()),Vector2i((canvas.x-piece.get_width())/2,canvas.y-piece.get_height())) + piece = padded + sprites[key] = ImageTexture.create_from_image(piece) + +func sprite(key: String, point: Vector2, tint: Color = Color.WHITE) -> void: + var texture: Texture2D = sprites[key] + draw_texture(texture,(point-texture.get_size()*.5).round(),tint) + +func text(value: String, point: Vector2, color: Color = Color.WHITE) -> void: + for i in range(value.length()): + if not FONT.has(value[i]): continue + var bits: String = FONT[value[i]] + for pixel in range(35): + if bits[pixel]=="1": draw_rect(Rect2(point+Vector2(i*6+pixel%5,pixel/5),Vector2.ONE),color) + +func center(value: String,y: float,color: Color = Color.WHITE) -> void: + text(value,Vector2(roundf((292-value.length()*6)/2.0),y),color) + +func direction_index(v: Vector2) -> int: + if absf(v.x)>absf(v.y): return 0 if v.x<0 else 1 + return 3 if v.y<0 else 2 + +func draw_title() -> void: + draw_texture_rect(TITLE,Rect2(0,0,292,240),false) + # Animated emblems move around the perimeter, rather than freezing the screenshot. + draw_rect(Rect2(0,0,292,44),Color.BLACK) + draw_rect(Rect2(0,200,292,40),Color.BLACK) + draw_rect(Rect2(0,44,36,164),Color.BLACK) + draw_rect(Rect2(256,44,36,164),Color.BLACK) + var perimeter := 2.0*(252+200) + var shift: float = 0.0 if game.reduced_flashes else game.phase_time*24 + for i in range(28): + var distance := fposmod(i*perimeter/28+shift,perimeter) + var p: Vector2 + if distance<252: p=Vector2(20+distance,20) + elif distance<452: p=Vector2(272,20+distance-252) + elif distance<704: p=Vector2(272-(distance-452),220) + else: p=Vector2(20,220-(distance-704)) + draw_texture_rect(badge,Rect2((p-Vector2(13,13)).round(),Vector2(26,26)),false,COLORS[(i+int(game.phase_time*2))%COLORS.size()] if not game.reduced_flashes else COLORS[i%COLORS.size()]) + draw_rect(Rect2(108,158,76,10),Color.BLACK) + center("FREE PLAY",160,Color.BLUE) + +func _draw() -> void: + if game==null: return + draw_rect(Rect2(0,0,292,240),Color.BLACK) + if game.phase=="title": + draw_title() + return + var accent: Color = COLORS[int(game.clock*3)%COLORS.size()] if not game.reduced_flashes else Color.RED + draw_rect(Rect2(4,16,284,216),Color.BLUE if game.phase in ["story","roster","scores"] else Color.RED,false,1) + text(str(game.last_score if game.demo else game.score),Vector2(52,4)) + if game.phase in ["story","roster","scores"]: + center("FREE PLAY",233,Color.GREEN) + else: + for i in range(mini(maxi(0,game.lives-1),7)): sprite("player2_0",Vector2(134+i*10,8)) + center("%d WAVE"%game.wave,233,Color.BLUE) + if game.phase=="story": + center("ROBOTRON: 2084",30,accent) + var lines := ["YOU ARE THE LAST HOPE OF MANKIND.","SAVE THE LAST HUMAN FAMILY!","MOVE AND FIRE IN EIGHT DIRECTIONS.","WASD MOVES ARROWS FIRE","RESCUE HUMANS FOR 1000 TO 5000","EXTRA LIFE EVERY %d POINTS"%game.extra_life_interval if game.extra_life_interval>0 else "BONUS LIVES DISABLED"] + for i in range(lines.size()): + if game.phase_time>i*.8: center(lines[i],56+i*22,COLORS[(i+2)%7]) + sprite("player2_0",Vector2(114,205)) + sprite("human0_2_0",Vector2(146,205)) + return + if game.phase=="roster": + center("THE ROBOTRONS",30,accent) + var names := ["GRUNT","HULK","SPHEROID","ENFORCER","BRAIN","QUARK","TANK","PROG"] + var keys := ["grunt0","hulk0","spheroid","enforcer","brain0","quark","tank0","prog"] + for i in range(8): + sprite(keys[i],Vector2(48,54+i*20)) + text(names[i],Vector2(76,50+i*20),COLORS[i%7]) + return + if game.phase=="scores": + center("ROBOTRON HERO",60,accent) + center("HIGH SCORE",92) + center(str(game.high_score),110,Color.YELLOW) + center("PUSH START",176,accent) + return + for entity in game.entities: + if not entity.alive: continue + var frame := int(game.clock*8+entity.frame)%3 + var key: String = entity.kind + match key: + "human": key="human%d_%d_%d"%[entity.human_type,direction_index(entity.velocity),frame] + "grunt","hulk","brain","tank": key+=str(frame) + "electrode": key+=str((game.wave+entity.id)%6) + "converting": key="prog" + var tint := Color.WHITE + if entity.kind=="converting": tint = accent + elif entity.kind=="electrode": tint = [Color.GREEN,Color.RED,Color.BLUE,Color.YELLOW,Color.MAGENTA,Color.CYAN][(game.wave-1)%6] + sprite(key,entity.position,tint) + if game.phase=="dead": + for i in range(8): + var offset: Vector2 = Vector2.from_angle(i*TAU/8)*(1.2-game.phase_time)*30 + sprite("player%d_%d"%[direction_index(game.facing),i%3],game.player+offset,Color(1,1,1,clampf(game.phase_time,0,1))) + elif game.phase not in ["won","lost","transition"]: + if game.invulnerable<=0 or int(game.clock*12)%2==0: + sprite("player%d_%d"%[direction_index(game.facing),int(game.clock*10)%3 if game.moving else 0],game.player) + for shot in game.shots: + var tail: Vector2 = shot.position-shot.velocity.normalized()*5 + draw_line(tail.round(),shot.position.round(),Color.WHITE,1) + for shot in game.enemy_shots: + if shot.kind=="shell": sprite("shell",shot.position) + else: draw_rect(Rect2(shot.position.round()-Vector2(1,1),Vector2(3,3)),accent) + for effect in game.effects: + if effect.label!="": text(effect.label,effect.position.round()-Vector2(10,8),accent) + else: + for i in range(8): + var p: Vector2 = effect.position+Vector2.from_angle(i*TAU/8)*(1-effect.life/.25)*12 + draw_rect(Rect2(p.round(),Vector2(2,2)),accent) + if game.phase=="ready": center("GET READY",110,accent) + if game.phase=="transition": + for i in range(8): + var expansion: float = fposmod((1.8-game.phase_time)*150+i*18,150) + draw_rect(Rect2(Vector2(146,124)-Vector2(expansion,expansion*.7),Vector2(expansion*2,expansion*1.4)),accent,false,1) + if game.phase=="won": center("YOU WIN!",114,accent) + if game.phase=="lost" or game.demo: center("GAME OVER",114,accent) diff --git a/games/robotron/board.gd.uid b/games/robotron/board.gd.uid new file mode 100644 index 0000000..6652316 --- /dev/null +++ b/games/robotron/board.gd.uid @@ -0,0 +1 @@ +uid://dhxkitgihoy3d diff --git a/games/robotron/display.gd b/games/robotron/display.gd new file mode 100644 index 0000000..d6a8731 --- /dev/null +++ b/games/robotron/display.gd @@ -0,0 +1,12 @@ +@tool +extends Node2D +func _process(_delta: float) -> void: + material.set_shader_parameter("animate_title",get_parent().phase=="title" and not get_parent().reduced_flashes) + material.set_shader_parameter("palette_step",int(get_parent().phase_time*2)%7) + queue_redraw() +func _draw() -> void: + var available := get_viewport_rect().size if not Engine.is_editor_hint() else Vector2(960,720) + var aspect := Vector2(4,3) if get_parent().crt_aspect else Vector2(292,240) + var size := aspect*minf(available.x/aspect.x,available.y/aspect.y) + draw_rect(Rect2(Vector2.ZERO,available),Color.BLACK) + draw_texture_rect($"../PixelScreen".get_texture(),Rect2(((available-size)*.5).floor(),size),false) diff --git a/games/robotron/display.gd.uid b/games/robotron/display.gd.uid new file mode 100644 index 0000000..7207ad1 --- /dev/null +++ b/games/robotron/display.gd.uid @@ -0,0 +1 @@ +uid://dxycwontqumst diff --git a/games/robotron/robotron.gd b/games/robotron/robotron.gd new file mode 100644 index 0000000..e39b1c4 --- /dev/null +++ b/games/robotron/robotron.gd @@ -0,0 +1,429 @@ +@tool +extends ArcadeGame + +@export_range(0,99) var waves_to_win: int = 1 +@export_range(1,9) var starting_lives: int = 3 +@export_range(.5,2,.1) var difficulty: float = 1.0 +@export_range(0,100000,5000) var extra_life_interval: int = 25000 +@export var sound_enabled: bool = true +@export var crt_aspect: bool = true +@export var reduced_flashes: bool = false + +const ARENA := Rect2(8,20,276,208) +const POINTS := {"grunt":100,"hulk":0,"spheroid":1000,"enforcer":150,"quark":1000,"tank":200,"brain":500,"prog":100,"electrode":0} +var phase := "title" +var phase_time := 0.0 +var clock := 0.0 +var demo := false +var demo_time := 0.0 +var score := 0 +var high_score := 0 +var last_score := 0 +var lives := 3 +var wave := 1 +var rescue_chain := 0 +var next_extra := 25000 +var player := Vector2(146,124) +var facing := Vector2.DOWN +var moving := false +var fire_delay := 0.0 +var invulnerable := 0.0 +var grunt_delay := 0.0 +var entities: Array[Dictionary] = [] +var shots: Array[Dictionary] = [] +var enemy_shots: Array[Dictionary] = [] +var effects: Array[Dictionary] = [] +var rng := RandomNumberGenerator.new() +var next_id := 0 +@onready var board: Node2D = $PixelScreen/Board + +func _ready() -> void: + lives = starting_lives + rng.seed = 2084 + setup_wave() + if not Engine.is_editor_hint(): + $Audio.effect("startup") + +func _unhandled_input(event: InputEvent) -> void: + if Engine.is_editor_hint() or event.is_echo(): return + if event.is_action_pressed("arcade_start"): + start_game() + +func eight_way(vector: Vector2) -> Vector2: + if vector.length()<.25: return Vector2.ZERO + return Vector2.from_angle(roundf(vector.angle()/(PI/4))*(PI/4)).normalized() + +func control_vector(prefix: String) -> Vector2: + return eight_way(Input.get_vector("robotron_"+prefix+"left","robotron_"+prefix+"right","robotron_"+prefix+"up","robotron_"+prefix+"down")) + +func start_game() -> bool: + if is_running: return false + demo = false + score = 0 + lives = starting_lives + wave = 1 + next_extra = extra_life_interval + rng.randomize() + setup_wave() + phase = "ready" + phase_time = 2.0 + $Audio.stop_all() + $Audio.effect("game-start") + return super.start_game() + +func reset_game() -> void: + demo = false + score = 0 + last_score = 0 + wave = 1 + lives = starting_lives + setup_wave() + phase = "title" + phase_time = 0 + $Audio.stop_all() + super.reset_game() + +func spawn(kind: String, position: Vector2) -> Dictionary: + next_id += 1 + var entity := {"id":next_id,"kind":kind,"position":position,"velocity":Vector2.from_angle(rng.randf()*TAU),"timer":rng.randf_range(.6,2.0),"age":0.0,"frame":rng.randi_range(0,2),"alive":true,"spawns":0,"human_type":rng.randi_range(0,2),"hp":3 if kind=="brain" else 1} + entities.append(entity) + return entity + +func spawn_point() -> Vector2: + for attempt in range(80): + var p := Vector2(rng.randf_range(15,277),rng.randf_range(28,220)) + if p.distance_to(player)<=48: continue + var occupied := false + for e in entities: + if e.alive and p.distance_squared_to(e.position)<100: + occupied = true + break + if not occupied: return p + return Vector2(20,30) + +func setup_wave() -> void: + entities.clear() + shots.clear() + enemy_shots.clear() + effects.clear() + player = Vector2(146,124) + facing = Vector2.DOWN + rescue_chain = 0 + fire_delay = 0 + invulnerable = 1.5 + var brain_wave := wave%5==0 + var tank_wave := wave>=7 and wave%5==2 + for i in range(mini(15+wave*3,65)): spawn("grunt",spawn_point()) + for i in range(5+mini(wave/3,5)): spawn("hulk",spawn_point()) + for i in range(15 if not brain_wave else 5): spawn("electrode",spawn_point()) + for i in range(20 if brain_wave else 3+mini(wave,5)): spawn("human",spawn_point()) + if brain_wave: + for i in range(8+mini(wave,12)): spawn("brain",spawn_point()) + elif tank_wave: + for i in range(5+mini(wave/5,5)): spawn("quark",spawn_point()) + elif wave>=2: + for i in range(mini(wave,8)): spawn("spheroid",spawn_point()) + +func _physics_process(delta: float) -> void: + clock += delta + if Engine.is_editor_hint(): + phase_time = fmod(clock,12) + board.queue_redraw() + return + if phase in ["title","story","roster","scores"]: + phase_time += delta + if phase_time>12: + phase_time = 0 + match phase: + "title": phase = "story" + "story": phase = "roster" + "roster": begin_demo() + "scores": phase = "title" + elif phase in ["ready","dead","transition","lost"]: + phase_time -= delta + if phase_time<=0: + match phase: + "ready": phase = "play" + "dead": + if lives<=0: finish(false) + else: respawn() + "transition": + if not demo and waves_to_win>0 and wave>=waves_to_win: finish(true) + else: + wave += 1 + setup_wave() + phase = "ready" + phase_time = 1.5 + if wave%5==0: $Audio.effect("brain-wave-start") + "lost": + phase = "scores" + phase_time = 0 + elif phase == "play": + var movement := Vector2.ZERO + var firing := Vector2.ZERO + if demo: + var target := Vector2(146+cos(demo_time*.8)*88,124+sin(demo_time*.8)*70) + movement = eight_way(target-player) + var enemy := nearest(player,false) + if not enemy.is_empty(): firing = eight_way(enemy.position-player) + else: + movement = control_vector("move_") + firing = control_vector("fire_") + step_world(delta,movement,firing) + if demo: + demo_time += delta + if demo_time>25: end_demo() + board.queue_redraw() + +func begin_demo() -> void: + demo = true + demo_time = 0 + score = 0 + lives = 3 + wave = 3 + rng.seed = 2084 + setup_wave() + phase = "play" + $Audio.stop_all() + +func end_demo() -> void: + demo = false + score = last_score + phase = "scores" + phase_time = 0 + $Audio.stop_all() + +func clamp_arena(p: Vector2, margin: float = 4) -> Vector2: + return p.clamp(ARENA.position+Vector2.ONE*margin,ARENA.end-Vector2.ONE*margin) + +func nearest(p: Vector2, human: bool) -> Dictionary: + var best: Dictionary = {} + var distance := INF + for e in entities: + if not e.alive or (e.kind=="human")!=human or e.kind in ["electrode","hulk"]: continue + var d: float = p.distance_squared_to(e.position) + if d bool: + if phase!="play" or direction==Vector2.ZERO or fire_delay>0 or shots.size()>=4: return false + shots.append({"position":player+direction*5,"velocity":direction*260}) + fire_delay = .085 + $Audio.effect("player-lazer") + return true + +func radius(kind: String) -> float: + return 7.0 if kind in ["brain","hulk","tank"] else (5.0 if kind in ["spheroid","quark"] else 3.5) + +func projectile(kind: String, origin: Vector2, target: Vector2) -> void: + if enemy_shots.size()>=70: return + var velocity := (target-origin).normalized()*(90 if kind=="shell" else 65) + enemy_shots.append({"kind":kind,"position":origin,"velocity":velocity,"life":9.0,"bounces":0}) + $Audio.effect("tank-fire" if kind=="shell" else "enforcer-fire") + +func step_world(delta: float, movement: Vector2, firing: Vector2) -> void: + moving = movement!=Vector2.ZERO + if moving: facing = movement + player = clamp_arena(player+movement*86*delta) + invulnerable = maxf(0,invulnerable-delta) + fire_delay = maxf(0,fire_delay-delta) + grunt_delay -= delta + if grunt_delay<=0: + grunt_delay = .6 + if entities.any(func(e): return e.alive and e.kind=="grunt"): $Audio.effect("grunt-walk") + fire(firing) + for i in range(effects.size()-1,-1,-1): + effects[i].life -= delta + if effects[i].life<=0: effects.remove_at(i) + # Spawners append enemies; the snapshot prevents updating new enemies twice. + for e in entities.duplicate(): + if not e.alive: continue + e.age += delta + e.timer -= delta + var direction: Vector2 = (player-e.position).normalized() + match e.kind: + "human": + if e.timer<=0: + e.velocity = eight_way(Vector2(rng.randf_range(-1,1),rng.randf_range(-1,1))) + e.timer = rng.randf_range(.4,1.4) + e.position = clamp_arena(e.position+e.velocity*12*delta) + if player.distance_to(e.position)<7: save_human(e) + "grunt","prog": + e.position += eight_way(direction)*(32 if e.kind=="grunt" else 58)*difficulty*delta + if e.kind=="grunt": + for obstacle in entities: + if obstacle.alive and obstacle.kind=="electrode" and e.position.distance_to(obstacle.position)<6: + e.alive = false + obstacle.alive = false + burst(e.position) + break + "hulk","brain": + var human := nearest(e.position,true) + var destination: Vector2 = human.position if not human.is_empty() else player + if e.timer<=0: + e.velocity = eight_way(destination-e.position) + e.timer = .6 if e.kind=="hulk" else .2 + e.position = clamp_arena(e.position+e.velocity*(17 if e.kind=="hulk" else 22)*difficulty*delta) + if not human.is_empty() and e.position.distance_to(human.position)<8: + human.alive = false + if e.kind=="brain": + var prog := spawn("converting",human.position) + prog.timer = .7 + $Audio.effect("human-to-prog") + else: + burst(human.position) + $Audio.effect("human-die") + if e.kind=="brain" and int(e.age/2.2)!=int((e.age-delta)/2.2): projectile("cruise",e.position,player) + "converting": + if e.timer<=0: + e.kind = "prog" + $Audio.effect("prog-transformation") + "spheroid","quark": + var next: Vector2 = e.position+e.velocity*(45 if e.kind=="spheroid" else 28)*delta + if next.x<14 or next.x>278: e.velocity.x *= -1 + if next.y<26 or next.y>222: e.velocity.y *= -1 + e.position = clamp_arena(e.position+e.velocity*40*delta) + if e.timer<=0: + spawn("enforcer" if e.kind=="spheroid" else "tank",e.position) + e.spawns += 1 + e.timer = 1.4 + $Audio.effect("spheroid-spawn" if e.kind=="spheroid" else "quark-spawn") + if e.spawns>=4: + e.alive = false + burst(e.position) + "enforcer","tank": + if e.timer<=0: + e.velocity = Vector2.from_angle(rng.randf()*TAU) + e.timer = rng.randf_range(.8,1.7)/difficulty + projectile("spark" if e.kind=="enforcer" else "shell",e.position,player) + e.position = clamp_arena(e.position+e.velocity*(30 if e.kind=="enforcer" else 18)*delta) + if e.alive and e.kind!="human" and e.kind!="converting" and player.distance_to(e.position) void: + for i in range(shots.size()-1,-1,-1): + var shot: Dictionary = shots[i] + var distance: float = shot.velocity.length()*delta + var direction: Vector2 = shot.velocity.normalized() + var hit := false + while distance>0 and not hit: + var step := minf(2,distance) + shot.position += direction*step + distance -= step + if not ARENA.has_point(shot.position): hit = true + for enemy in entities: + if hit: break + if not enemy.alive or enemy.kind in ["human","converting"]: continue + if shot.position.distance_to(enemy.position)0 and shot.position.distance_to(bullet.position)<4: + bullet.life = 0 + hit = true + add_score(50 if bullet.kind=="shell" else (75 if bullet.kind=="cruise" else 25)) + if hit: shots.remove_at(i) + +func update_enemy_shots(delta: float) -> void: + for i in range(enemy_shots.size()-1,-1,-1): + var b: Dictionary = enemy_shots[i] + b.life -= delta + if b.kind=="cruise": b.velocity = b.velocity.move_toward((player-b.position).normalized()*78,delta*45) + b.position += b.velocity*delta + if not ARENA.has_point(b.position): + if b.kind=="shell" and b.bounces<4: + if b.position.xARENA.end.x: b.velocity.x *= -1 + if b.position.yARENA.end.y: b.velocity.y *= -1 + b.position = clamp_arena(b.position,1) + b.bounces += 1 + $Audio.effect("tank-projectile-bounce") + else: b.life = 0 + if b.life>0 and b.position.distance_to(player)<5 and invulnerable<=0: + die() + return + if b.life<=0: enemy_shots.remove_at(i) + +func damage(enemy: Dictionary) -> void: + enemy.hp -= 1 + if enemy.hp>0: return + enemy.alive = false + add_score(POINTS.get(enemy.kind,0)) + burst(enemy.position) + var sounds := {"brain":"brain-explode","tank":"tank-explode","electrode":"electrode-explode","spheroid":"spheroid-pop"} + $Audio.effect(sounds.get(enemy.kind,"robot-explode")) + +func save_human(human: Dictionary) -> void: + if not human.alive: return + human.alive = false + rescue_chain = mini(5,rescue_chain+1) + add_score(rescue_chain*1000) + burst(human.position,str(rescue_chain*1000)) + $Audio.effect("human-save") + +func add_score(value: int) -> void: + score += value + if demo: return + high_score = maxi(high_score,score) + while extra_life_interval>0 and score>=next_extra: + lives += 1 + next_extra += extra_life_interval + $Audio.effect("extra-life") + +func burst(position: Vector2, label: String = "") -> void: + effects.append({"position":position,"life":.55 if label!="" else .25,"label":label}) + +func remaining_enemies() -> int: + var count := 0 + for e in entities: + if e.alive and e.kind not in ["human","hulk","electrode"]: count += 1 + return count + +func die() -> void: + if phase!="play": return + lives -= 1 + rescue_chain = 0 + phase = "dead" + phase_time = 1.2 + $Audio.stop_all() + $Audio.effect("player-death") + +func respawn() -> void: + player = Vector2(146,124) + shots.clear() + enemy_shots.clear() + invulnerable = 2 + for e in entities: + if e.alive and e.kind!="human" and e.position.distance_to(player)<48: e.position = spawn_point() + phase = "ready" + phase_time = 1 + +func finish(won: bool) -> void: + if demo: + end_demo() + return + if not is_running: return + last_score = score + phase = "won" if won else "lost" + phase_time = 5 + $Audio.stop_all() + if won: win_game() + else: + lose_game() + if score>0 and score>=high_score: $Audio.effect("high-score-top") diff --git a/games/robotron/robotron.gd.uid b/games/robotron/robotron.gd.uid new file mode 100644 index 0000000..8dd1309 --- /dev/null +++ b/games/robotron/robotron.gd.uid @@ -0,0 +1 @@ +uid://dllelyq8hgx07 diff --git a/games/robotron/title_palette.gdshader b/games/robotron/title_palette.gdshader new file mode 100644 index 0000000..64f19e5 --- /dev/null +++ b/games/robotron/title_palette.gdshader @@ -0,0 +1,14 @@ +shader_type canvas_item; +render_mode unshaded; +uniform bool animate_title = false; +uniform int palette_step = 0; +void fragment() { + vec4 pixel = texture(TEXTURE, UV) * COLOR; + // Only the supplied title lettering cycles; credits and sprites keep their palette. + if (animate_title && UV.x>0.13 && UV.x<0.86 && UV.y>0.20 && UV.y<0.49 && pixel.r>0.8 && pixel.b<0.1) { + vec3 palette[7] = vec3[7](vec3(1,0,0),vec3(1,0.5,0),vec3(1,1,0),vec3(0,1,0),vec3(0,1,1),vec3(1,0,1),vec3(1,1,1)); + int index = (palette_step + (pixel.g>0.8 ? 2 : 0)) % 7; + pixel.rgb = palette[index]; + } + COLOR = pixel; +} diff --git a/games/robotron/title_palette.gdshader.uid b/games/robotron/title_palette.gdshader.uid new file mode 100644 index 0000000..b4f5317 --- /dev/null +++ b/games/robotron/title_palette.gdshader.uid @@ -0,0 +1 @@ +uid://bowtwv6ih8lj diff --git a/games/space_invaders/README.md b/games/space_invaders/README.md new file mode 100644 index 0000000..89c1dbc --- /dev/null +++ b/games/space_invaders/README.md @@ -0,0 +1,59 @@ +# Space Invaders setup and fidelity notes + +Space Invaders is registered in the project database and uses the existing ArcadeGame start, win, loss, and reset events. Open `SpaceInvaders.tscn` and run the current scene to play, or select the Space Invaders game definition in a cabinet scenario. + +## Controls and room configuration + +- Enter or `arcade_start`: start a fresh game, including during the attract/demo loop. +- Left/right arrows or `arcade_left`/`arcade_right`: move the laser base. +- Space or `arcade_action_1`: fire. Holding fire repeats when the previous shot finishes; only one player shot can exist at once. +- Cabinet capabilities: `joystick_horizontal`, `button_1`, and `start_button`. + +Select the scene root to configure Waves To Win (default 1; 0 means endless), Starting Lives (3), Bonus Life Score (1500; 0 disables), Color Overlay, and Sound Enabled. These are scene exports, not run_config overrides. Save the scene after changing them. Existing cabinet scenario assignments were not changed. + +Winning the configured number of waves emits game_won. Invasion or loss of the last base emits game_lost. The loss screen returns to attract after five seconds; the room win screen stays visible. A reset returns to attract while keeping the session high score. + +## Included behavior + +The game renders at 224 by 256 pixels, with nearest-neighbor scaling and integer enlargement whenever the available space permits. A screen-space overlay colors the upper band red and lower band green; sprites crossing a band change color at the band boundary. Black margins contain off-screen objects. Disable Color Overlay for monochrome. + +There are 55 invaders in five rows. A scan moves one living invader per simulation tick, producing a ripple through the formation and a faster sweep as the population falls. Invaders alternate the two supplied poses on movement, reverse and descend at an edge, and destroy shield sections they overlap. Successive waves start lower. The last invader has a larger horizontal step. + +Player shots, enemy shots, and invaders erode four shields. Shield holes affect collisions immediately. Projectiles advance in one-pixel collision substeps so they cannot skip thin remaining shield sections. Enemy fire comes from the lowest surviving invader in a column, with three separately animated shot types; one type aims its launch column toward the player. Opposing projectiles can collide. + +The saucer crosses above the fleet, has a shot-count-based score, and displays its award when destroyed. The 15-entry score cycle includes 50, 100, 150, and 300. Invaders award 10, 20, or 30 by type. A single extra life is awarded at the configured score threshold. + +Attract mode types the title and score table, includes the inverted-Y repair animation, shows a free-play start screen, then runs a silent gameplay demo. The demo uses the live game simulation but never starts a room run, changes the high score, or emits win/loss events. Start interrupts it and resets the board. + +## Supplied sound mapping + +| File | Use | +| --- | --- | +| shoot.wav | Player shot | +| invaderkilled.wav | Invader destroyed | +| explosion.wav | Player base destroyed | +| fastinvader1.wav through fastinvader4.wav | Repeating four-note fleet march | +| ufo_lowpitch.wav | Repeating saucer flight sound | +| ufo_highpitch.wav | Saucer hit | + +Audio is silent during attract/demo and stops on reset or game completion. The source sprite sheet and WAV files are copied unchanged from the supplied assets. White pixels are extracted into transparent sprite masks at runtime; those same masks are used for sprite hit detection. + +## Fidelity boundaries + +This is a recreation, not ROM emulation. The current cabinet flow is one-player free play; SCORE<2> and CREDIT 00 are visual elements, with no coin accounting or alternating two-player game. There is no persistent high-score storage. Attract choreography and delays are approximated from the reference, and the extra attract skits are not all reproduced. Enemy reload timing and column selection approximate the original rather than reproduce every firing table and hardware interrupt quirk. Shield crater patterns are deterministic approximations. Ship movement, projectile speeds, the final-alien behavior, and sound cadence still benefit from side-by-side arcade tuning. + +Reference visuals: [supplied gameplay video](https://www.youtube.com/watch?v=S-CLMRQvItE). The first segment is Space Invaders; the video also contains other games. Fleet processing, alien scores, saucer scoring, and subsequent wave heights were informed by the [annotated original program](https://computerarcheology.com/Arcade/SpaceInvaders/Code.html) and [technical analysis](https://computerarcheology.com/Arcade/SpaceInvaders/). + +## Validation and play checklist + +Automated checks cover fleet count/ripple/descent, shot limits, shield holes, alien and saucer hits, projectile interception, player death/respawn, invasion, bonus life, wave progression, start/reset, demo event and score isolation, and database registration. They pass with Godot 4.6.1 Compatibility. The project remains configured for 4.7; test in that version before deployment. Desktop title and gameplay renders were inspected. A short uncapped desktop render benchmark completed without a game script error; it is not a Raspberry Pi performance guarantee. The sandbox reports certificate-store and shader-cache access errors during desktop validation. + +Run `tests/space_invaders/check_space_invaders.gd` with Godot's `--headless --path . --script` options. `render_preview.gd` generates title/gameplay captures, and `benchmark.gd` runs a short desktop render check. + +- Start during both the title animation and demo; confirm a fresh score, lives, and shields. +- Fire through a shield repeatedly; verify the shot eventually passes through the hole. +- Watch the fleet accelerate and descend as columns disappear. +- Check the four march sounds, firing, deaths, and both saucer sounds at cabinet volume. +- Complete a wave and verify the room event; set Waves To Win to 0 to check continued play. +- Lose the last life or allow invasion; confirm the room loss event and return to attract. +- Resize the game view and inspect equal pixel sizes, overlay bands, and black margins. diff --git a/games/space_invaders/SpaceInvaders.tscn b/games/space_invaders/SpaceInvaders.tscn new file mode 100644 index 0000000..723bab8 --- /dev/null +++ b/games/space_invaders/SpaceInvaders.tscn @@ -0,0 +1,31 @@ +[gd_scene format=3 uid="uid://75kpoxbn0gv7"] + +[ext_resource type="Script" uid="uid://bs0afkx0eduyh" path="res://games/space_invaders/space_invaders.gd" id="1"] +[ext_resource type="Script" uid="uid://b4dgfnl4jboyh" path="res://games/space_invaders/board.gd" id="2"] +[ext_resource type="Script" uid="uid://cy0h6sxqsvped" path="res://games/space_invaders/display.gd" id="3"] +[ext_resource type="Script" uid="uid://b4xajrp7m3nv5" path="res://games/space_invaders/audio.gd" id="4"] +[ext_resource type="Shader" uid="uid://iw11px3gsupb" path="res://games/space_invaders/overlay.gdshader" id="5"] + +[sub_resource type="ShaderMaterial" id="Overlay"] +shader = ExtResource("5") +shader_parameter/overlay = true + +[node name="SpaceInvaders" type="Node" unique_id=469852513] +script = ExtResource("1") + +[node name="PixelScreen" type="SubViewport" parent="." unique_id=1436969421] +disable_3d = true +size = Vector2i(224, 256) +render_target_update_mode = 4 + +[node name="Board" type="Node2D" parent="PixelScreen" unique_id=2075363350] +texture_filter = 1 +script = ExtResource("2") + +[node name="Display" type="Node2D" parent="." unique_id=675158066] +texture_filter = 1 +material = SubResource("Overlay") +script = ExtResource("3") + +[node name="Audio" type="Node" parent="." unique_id=1599851911] +script = ExtResource("4") diff --git a/games/space_invaders/art/sprites.png b/games/space_invaders/art/sprites.png new file mode 100644 index 0000000..8afdc4c Binary files /dev/null and b/games/space_invaders/art/sprites.png differ diff --git a/games/space_invaders/art/sprites.png.import b/games/space_invaders/art/sprites.png.import new file mode 100644 index 0000000..d5b187f --- /dev/null +++ b/games/space_invaders/art/sprites.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0n0f2eqrqpuh" +path="res://.godot/imported/sprites.png-5aa86391f2c5b6dcb3cfc96492778056.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://games/space_invaders/art/sprites.png" +dest_files=["res://.godot/imported/sprites.png-5aa86391f2c5b6dcb3cfc96492778056.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/games/space_invaders/audio.gd b/games/space_invaders/audio.gd new file mode 100644 index 0000000..b84915a --- /dev/null +++ b/games/space_invaders/audio.gd @@ -0,0 +1,33 @@ +extends Node +const FILES := {"shot":"shoot", "invader":"invaderkilled", "death":"explosion", "march0":"fastinvader1", "march1":"fastinvader2", "march2":"fastinvader3", "march3":"fastinvader4", "ufo_hit":"ufo_highpitch"} +var tracks: Dictionary = {} +var voices: Array[AudioStreamPlayer] = [] +var next_voice := 0 +var saucer: AudioStreamPlayer +func _ready() -> void: + for key in FILES: tracks[key] = load("res://games/space_invaders/audio/%s.wav"%FILES[key]) + for i in range(8): + var voice := AudioStreamPlayer.new() + voice.volume_db = -12 + add_child(voice) + voices.append(voice) + saucer = AudioStreamPlayer.new() + saucer.stream = load("res://games/space_invaders/audio/ufo_lowpitch.wav") + saucer.volume_db = -16 + add_child(saucer) + saucer.finished.connect(func(): + if get_parent().saucer_active and not get_parent().demo and get_parent().phase=="play": saucer.play()) +func effect(key: String) -> void: + if get_parent().demo or not get_parent().sound_enabled or not tracks.has(key): return + var voice := voices[next_voice] + next_voice = (next_voice+1)%voices.size() + voice.stream = tracks[key] + voice.play() +func ufo(active: bool) -> void: + if not active: + saucer.stop() + elif not get_parent().demo and get_parent().sound_enabled: + saucer.play() +func stop_all() -> void: + for voice in voices: voice.stop() + if saucer: saucer.stop() diff --git a/games/space_invaders/audio.gd.uid b/games/space_invaders/audio.gd.uid new file mode 100644 index 0000000..0bf79d1 --- /dev/null +++ b/games/space_invaders/audio.gd.uid @@ -0,0 +1 @@ +uid://b4xajrp7m3nv5 diff --git a/games/space_invaders/audio/explosion.wav b/games/space_invaders/audio/explosion.wav new file mode 100644 index 0000000..279e97c Binary files /dev/null and b/games/space_invaders/audio/explosion.wav differ diff --git a/games/space_invaders/audio/explosion.wav.import b/games/space_invaders/audio/explosion.wav.import new file mode 100644 index 0000000..9e06fcf --- /dev/null +++ b/games/space_invaders/audio/explosion.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://bwe5etevdqklk" +path="res://.godot/imported/explosion.wav-9901c75dd3de8a2026fa8bde27440b0f.sample" + +[deps] + +source_file="res://games/space_invaders/audio/explosion.wav" +dest_files=["res://.godot/imported/explosion.wav-9901c75dd3de8a2026fa8bde27440b0f.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/space_invaders/audio/fastinvader1.wav b/games/space_invaders/audio/fastinvader1.wav new file mode 100644 index 0000000..e2dac5f Binary files /dev/null and b/games/space_invaders/audio/fastinvader1.wav differ diff --git a/games/space_invaders/audio/fastinvader1.wav.import b/games/space_invaders/audio/fastinvader1.wav.import new file mode 100644 index 0000000..455f094 --- /dev/null +++ b/games/space_invaders/audio/fastinvader1.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://dbl1isaefpkho" +path="res://.godot/imported/fastinvader1.wav-c01935dea3c9461078e7be2197824cfe.sample" + +[deps] + +source_file="res://games/space_invaders/audio/fastinvader1.wav" +dest_files=["res://.godot/imported/fastinvader1.wav-c01935dea3c9461078e7be2197824cfe.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/space_invaders/audio/fastinvader2.wav b/games/space_invaders/audio/fastinvader2.wav new file mode 100644 index 0000000..34ee7ac Binary files /dev/null and b/games/space_invaders/audio/fastinvader2.wav differ diff --git a/games/space_invaders/audio/fastinvader2.wav.import b/games/space_invaders/audio/fastinvader2.wav.import new file mode 100644 index 0000000..7d4ff3c --- /dev/null +++ b/games/space_invaders/audio/fastinvader2.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://c6naet4vmkv4l" +path="res://.godot/imported/fastinvader2.wav-50e6d71fcb4f0882c996ce114cf1d72f.sample" + +[deps] + +source_file="res://games/space_invaders/audio/fastinvader2.wav" +dest_files=["res://.godot/imported/fastinvader2.wav-50e6d71fcb4f0882c996ce114cf1d72f.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/space_invaders/audio/fastinvader3.wav b/games/space_invaders/audio/fastinvader3.wav new file mode 100644 index 0000000..40a5508 Binary files /dev/null and b/games/space_invaders/audio/fastinvader3.wav differ diff --git a/games/space_invaders/audio/fastinvader3.wav.import b/games/space_invaders/audio/fastinvader3.wav.import new file mode 100644 index 0000000..ec2eeb8 --- /dev/null +++ b/games/space_invaders/audio/fastinvader3.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cqpgb2oqqksu4" +path="res://.godot/imported/fastinvader3.wav-8701d0aaf6df70ac55ba0c292c858bf0.sample" + +[deps] + +source_file="res://games/space_invaders/audio/fastinvader3.wav" +dest_files=["res://.godot/imported/fastinvader3.wav-8701d0aaf6df70ac55ba0c292c858bf0.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/space_invaders/audio/fastinvader4.wav b/games/space_invaders/audio/fastinvader4.wav new file mode 100644 index 0000000..00e5a79 Binary files /dev/null and b/games/space_invaders/audio/fastinvader4.wav differ diff --git a/games/space_invaders/audio/fastinvader4.wav.import b/games/space_invaders/audio/fastinvader4.wav.import new file mode 100644 index 0000000..182b1b4 --- /dev/null +++ b/games/space_invaders/audio/fastinvader4.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cs2e7tb4ss60d" +path="res://.godot/imported/fastinvader4.wav-461a7445c1780502161b5194db9b44a6.sample" + +[deps] + +source_file="res://games/space_invaders/audio/fastinvader4.wav" +dest_files=["res://.godot/imported/fastinvader4.wav-461a7445c1780502161b5194db9b44a6.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/space_invaders/audio/invaderkilled.wav b/games/space_invaders/audio/invaderkilled.wav new file mode 100644 index 0000000..769f817 Binary files /dev/null and b/games/space_invaders/audio/invaderkilled.wav differ diff --git a/games/space_invaders/audio/invaderkilled.wav.import b/games/space_invaders/audio/invaderkilled.wav.import new file mode 100644 index 0000000..58e040c --- /dev/null +++ b/games/space_invaders/audio/invaderkilled.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://chhb7wtwfmpdq" +path="res://.godot/imported/invaderkilled.wav-a2b9ae3e15ff35186379d60f53760a98.sample" + +[deps] + +source_file="res://games/space_invaders/audio/invaderkilled.wav" +dest_files=["res://.godot/imported/invaderkilled.wav-a2b9ae3e15ff35186379d60f53760a98.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/space_invaders/audio/shoot.wav b/games/space_invaders/audio/shoot.wav new file mode 100644 index 0000000..0222b03 Binary files /dev/null and b/games/space_invaders/audio/shoot.wav differ diff --git a/games/space_invaders/audio/shoot.wav.import b/games/space_invaders/audio/shoot.wav.import new file mode 100644 index 0000000..e9f3140 --- /dev/null +++ b/games/space_invaders/audio/shoot.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://b34upsqqnv23f" +path="res://.godot/imported/shoot.wav-04a9da97b02f69f32dc83caa94bacc5a.sample" + +[deps] + +source_file="res://games/space_invaders/audio/shoot.wav" +dest_files=["res://.godot/imported/shoot.wav-04a9da97b02f69f32dc83caa94bacc5a.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/space_invaders/audio/ufo_highpitch.wav b/games/space_invaders/audio/ufo_highpitch.wav new file mode 100644 index 0000000..cf79889 Binary files /dev/null and b/games/space_invaders/audio/ufo_highpitch.wav differ diff --git a/games/space_invaders/audio/ufo_highpitch.wav.import b/games/space_invaders/audio/ufo_highpitch.wav.import new file mode 100644 index 0000000..8ba96d5 --- /dev/null +++ b/games/space_invaders/audio/ufo_highpitch.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cy2fqvuyhox7q" +path="res://.godot/imported/ufo_highpitch.wav-a3fa17d7a70964b5de281fbe549b376f.sample" + +[deps] + +source_file="res://games/space_invaders/audio/ufo_highpitch.wav" +dest_files=["res://.godot/imported/ufo_highpitch.wav-a3fa17d7a70964b5de281fbe549b376f.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/space_invaders/audio/ufo_lowpitch.wav b/games/space_invaders/audio/ufo_lowpitch.wav new file mode 100644 index 0000000..dc14830 Binary files /dev/null and b/games/space_invaders/audio/ufo_lowpitch.wav differ diff --git a/games/space_invaders/audio/ufo_lowpitch.wav.import b/games/space_invaders/audio/ufo_lowpitch.wav.import new file mode 100644 index 0000000..71f0da5 --- /dev/null +++ b/games/space_invaders/audio/ufo_lowpitch.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cru6woa2ca1fs" +path="res://.godot/imported/ufo_lowpitch.wav-1b827f7d56eb25dfcfd6ea3342e26e7e.sample" + +[deps] + +source_file="res://games/space_invaders/audio/ufo_lowpitch.wav" +dest_files=["res://.godot/imported/ufo_lowpitch.wav-1b827f7d56eb25dfcfd6ea3342e26e7e.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=2 diff --git a/games/space_invaders/board.gd b/games/space_invaders/board.gd new file mode 100644 index 0000000..d88a4e8 --- /dev/null +++ b/games/space_invaders/board.gd @@ -0,0 +1,119 @@ +@tool +extends Node2D + +const SHEET = preload("res://games/space_invaders/art/sprites.png") +const GLYPHS := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>=*?-" +var masks: Dictionary = {} +var textures: Dictionary = {} +var game: Node + +func _ready() -> void: + game = get_parent().get_parent() + var sheet := SHEET.get_image() + for kind in range(3): + for frame in range(2): register("alien%d_%d"%[kind,frame],sheet,Rect2i(1+kind*18,1+frame*10,16,8)) + register("burst",sheet,Rect2i(55,1,16,8)) + register("ufo",sheet,Rect2i(1,39,16,8)) + register("ufo_burst",sheet,Rect2i(19,39,24,8)) + register("shield",sheet,Rect2i(45,31,24,16)) + register("player",sheet,Rect2i(1,49,16,8)) + register("death0",sheet,Rect2i(19,49,16,8)) + register("death1",sheet,Rect2i(37,49,16,8)) + register("shot_burst",sheet,Rect2i(55,49,10,8)) + for i in range(12): register("bomb%d"%i,sheet,Rect2i(1+i*5,21,3,8)) + for i in range(GLYPHS.length()): register(GLYPHS[i],sheet,Rect2i(1+(i%8)*10,69+int(i/8)*10,8,8)) + +func register(key: String, sheet: Image, region: Rect2i) -> void: + var pixels := sheet.get_region(region) + pixels.convert(Image.FORMAT_RGBA8) + for y in range(pixels.get_height()): + for x in range(pixels.get_width()): + var value := pixels.get_pixel(x,y) + pixels.set_pixel(x,y,Color.WHITE if value.r>.8 and value.g>.8 and value.b>.8 else Color.TRANSPARENT) + masks[key] = pixels + textures[key] = ImageTexture.create_from_image(pixels) + +func sprite(key: String, point: Vector2) -> void: + draw_texture(textures[key],point.round()) + +func text(value: String, point: Vector2, revealed: int = 999) -> void: + for i in range(mini(value.length(),revealed)): + if textures.has(value[i]): sprite(value[i],point+Vector2(i*8,0)) + +func centered(value: String, y: float, revealed: int = 999) -> void: + text(value,Vector2((224-value.length()*8)/2.0,y),revealed) + +func title_repair(t: float) -> void: + var letter_x := 120.0 + var invader_x := 232.0 + # Carry the letter beside the invader, fully beyond the right edge, + # then bring it back upright before leaving it in its original position. + if t < 12: + invader_x = lerpf(232,128,clampf((t-9)/3,0,1)) + elif t < 15: + invader_x = lerpf(128,232,(t-12)/3) + letter_x = invader_x-8 + elif t < 15.5: + letter_x = 224 + elif t < 18.5: + invader_x = lerpf(232,128,(t-15.5)/3) + letter_x = invader_x-8 + else: + invader_x = lerpf(128,232,clampf((t-18.5)/3,0,1)) + if t > .5: + if t < 15.5: + draw_set_transform(Vector2(roundf(letter_x),72),0,Vector2(1,-1)) + sprite("Y",Vector2.ZERO) + draw_set_transform(Vector2.ZERO) + else: + sprite("Y",Vector2(letter_x,64)) + if t >= 9 and t < 21.5: + sprite("alien0_%d"%(int(t*5)%2),Vector2(invader_x,64)) + +func attract() -> void: + var t: float = game.phase_time + if t<22: + # The little invader repairs the upside-down Y in PLAY. + text("PLA",Vector2(96,64),int(t*8)) + title_repair(t) + centered("SPACE INVADERS",88,int((t-1)*12)) + if t>=2.5: centered("*SCORE ADVANCE TABLE*",120,int((t-2.5)*16)) + for i in range(4): + if t<4+i*.6: continue + sprite(["ufo","alien0_0","alien1_0","alien2_0"][i],Vector2(56,136+i*16)) + text(["=? MYSTERY","=30 POINTS","=20 POINTS","=10 POINTS"][i],Vector2(72,136+i*16),int((t-4-i*.6)*15)) + else: + centered("PUSH START",104) + centered("<1 PLAYER>",136) + centered("*FREE PLAY*",160) + centered("BONUS AT %04d"%game.bonus_life_score,192) + +func _draw() -> void: + if game == null: return + draw_rect(Rect2(0,0,224,256),Color.BLACK) + text("SCORE<1> HI-SCORE SCORE<2>",Vector2(8,8)) + text("%04d"%(game.last_score if game.demo else game.score),Vector2(24,24)) + text("%04d"%game.high_score,Vector2(88,24)) + text("0000",Vector2(176,24)) + text("CREDIT 00",Vector2(144,248)) + if game.phase == "attract": + attract() + return + for alien in game.aliens: + if alien.alive: sprite("alien%d_%d"%[alien.kind,alien.frame],alien.position-Vector2(8,4)) + for shield in game.shields: draw_texture(shield.texture,Vector2(shield.position)) + if game.saucer_active: sprite("ufo",Vector2(game.saucer_x-8,40)) + if game.phase == "dead": sprite("death%d"%(int(game.phase_time*10)%2),Vector2(game.player_x-8,216)) + elif game.phase in ["play","clear"]: sprite("player",Vector2(game.player_x-8,216)) + if game.shot_active: draw_rect(Rect2(game.player_shot.round()-Vector2(0,1),Vector2(1,4)),Color.WHITE) + for bomb in game.bombs: + sprite("bomb%d"%(bomb.kind*4+int(game.clock*12)%4),bomb.position-Vector2(1,4)) + for effect in game.effects: + if effect.text != "" and effect.time<.4: text(effect.text,effect.position-Vector2(12,4)) + else: sprite(effect.name,effect.position-Vector2(8,4)) + draw_line(Vector2(8,239),Vector2(216,239),Color.WHITE,1) + text(str(game.lives),Vector2(8,248)) + for i in range(mini(maxi(0,game.lives-1),6)): sprite("player",Vector2(24+i*16,248)) + if game.phase == "ready": centered("PLAY PLAYER<1>",160) + if game.phase == "won": centered("YOU WIN",160) + if game.phase == "lost" or (game.demo and int(game.demo_time)%2==0): centered("GAME OVER",160) diff --git a/games/space_invaders/board.gd.uid b/games/space_invaders/board.gd.uid new file mode 100644 index 0000000..ccf3f57 --- /dev/null +++ b/games/space_invaders/board.gd.uid @@ -0,0 +1 @@ +uid://b4dgfnl4jboyh diff --git a/games/space_invaders/display.gd b/games/space_invaders/display.gd new file mode 100644 index 0000000..665f370 --- /dev/null +++ b/games/space_invaders/display.gd @@ -0,0 +1,13 @@ +@tool +extends Node2D +func _process(_delta: float) -> void: + material.set_shader_parameter("overlay",get_parent().color_overlay) + queue_redraw() +func _draw() -> void: + var available := get_viewport_rect().size if not Engine.is_editor_hint() else Vector2(672,768) + var scale_factor := minf(available.x/224,available.y/256) + # Whole-number enlargement preserves equal pixel widths whenever it fits. + if scale_factor>=1: scale_factor = floorf(scale_factor) + var size := Vector2(224,256)*scale_factor + draw_rect(Rect2(Vector2.ZERO,available),Color.BLACK) + draw_texture_rect($"../PixelScreen".get_texture(),Rect2(((available-size)*.5).floor(),size),false) diff --git a/games/space_invaders/display.gd.uid b/games/space_invaders/display.gd.uid new file mode 100644 index 0000000..be7aba1 --- /dev/null +++ b/games/space_invaders/display.gd.uid @@ -0,0 +1 @@ +uid://cy0h6sxqsvped diff --git a/games/space_invaders/overlay.gdshader b/games/space_invaders/overlay.gdshader new file mode 100644 index 0000000..e357b84 --- /dev/null +++ b/games/space_invaders/overlay.gdshader @@ -0,0 +1,13 @@ +shader_type canvas_item; +render_mode unshaded; +uniform bool overlay = true; +void fragment() { + vec4 pixel = texture(TEXTURE,UV) * COLOR; + vec2 p = UV * vec2(224.0,256.0); + vec3 tint = vec3(1.0); + if (overlay) { + if (p.y>=32.0 && p.y<64.0) tint=vec3(1.0,0.05,0.03); + if ((p.y>=184.0 && p.y<240.0) || (p.y>=240.0 && p.x>=24.0 && p.x<136.0)) tint=vec3(0.1,1.0,0.05); + } + COLOR=vec4(pixel.rgb*tint,pixel.a); +} diff --git a/games/space_invaders/overlay.gdshader.uid b/games/space_invaders/overlay.gdshader.uid new file mode 100644 index 0000000..cfe0191 --- /dev/null +++ b/games/space_invaders/overlay.gdshader.uid @@ -0,0 +1 @@ +uid://iw11px3gsupb diff --git a/games/space_invaders/space_invaders.gd b/games/space_invaders/space_invaders.gd new file mode 100644 index 0000000..e34b1d2 --- /dev/null +++ b/games/space_invaders/space_invaders.gd @@ -0,0 +1,402 @@ +@tool +extends ArcadeGame + +@export_range(0, 99) var waves_to_win: int = 1 +@export_range(1, 6) var starting_lives: int = 3 +@export_range(0, 10000, 100) var bonus_life_score: int = 1500 +@export var color_overlay: bool = true +@export var sound_enabled: bool = true + +const SAUCER_SCORES := [100,50,50,100,150,100,100,50,300,100,100,100,50,150,100] +const WAVE_HEIGHTS := [96,104,112,112,112,120,120,120] +var phase := "attract" +var phase_time := 0.0 +var clock := 0.0 +var demo := false +var demo_time := 0.0 +var score := 0 +var high_score := 0 +var last_score := 0 +var lives := 3 +var wave := 1 +var extra_awarded := false +var player_x := 24.0 +var player_shot := Vector2(-100,-100) +var shot_active := false +var shot_count := 0 +var shot_delay := 0.0 +var aliens: Array[Dictionary] = [] +var bombs: Array[Dictionary] = [] +var effects: Array[Dictionary] = [] +var shields: Array[Dictionary] = [] +var fleet_direction := 1 +var fleet_cursor := 0 +var fleet_delta := Vector2(2,0) +var fleet_ticks := 0.0 +var march_note := 0 +var march_sound_delay := 0.0 +var hit_pause := 0.0 +var bomb_timer := 1.0 +var bomb_kind := 0 +var bomb_column := 0 +var saucer_x := -32.0 +var saucer_direction := 1 +var saucer_active := false +var saucer_timer := 20.0 +var demo_target := 112.0 +@onready var board: Node2D = $PixelScreen/Board + +func _ready() -> void: + new_wave() + lives = starting_lives + +func new_wave() -> void: + aliens.clear() + var top: int = 72 if wave == 1 else WAVE_HEIGHTS[(wave-2)%WAVE_HEIGHTS.size()] + # Bottom-to-top scan order gives the original rippling fleet movement. + for row in range(4,-1,-1): + for column in range(11): + aliens.append({"position":Vector2(24+column*16,top+row*16),"kind":0 if row==0 else (1 if row<3 else 2),"frame":0,"alive":true,"column":column}) + fleet_direction = 1 + fleet_cursor = 0 + fleet_ticks = 0 + march_sound_delay = 0 + march_note = 0 + fleet_delta = Vector2(2,0) + bombs.clear() + effects.clear() + shot_active = false + shot_delay = 0 + bomb_timer = 1 + saucer_active = false + saucer_timer = 20 + hit_pause = 0 + player_x = 24 + build_shields() + +func build_shields() -> void: + shields.clear() + var pattern: Image = board.masks["shield"] + for x in [32,77,122,167]: + var pixels := pattern.duplicate() as Image + shields.append({"position":Vector2i(x,192),"image":pixels,"texture":ImageTexture.create_from_image(pixels),"dirty":false}) + +func start_game() -> bool: + if is_running: return false + demo = false + $Audio.stop_all() + score = 0 + lives = starting_lives + wave = 1 + shot_count = 0 + extra_awarded = false + clock = 0 + new_wave() + phase = "ready" + phase_time = 2 + return super.start_game() + +func reset_game() -> void: + demo = false + $Audio.stop_all() + score = 0 + last_score = 0 + wave = 1 + lives = starting_lives + new_wave() + phase = "attract" + phase_time = 0 + clock = 0 + super.reset_game() + +func begin_demo() -> void: + demo = true + demo_time = 0 + score = 0 + lives = starting_lives + wave = 1 + shot_count = 0 + new_wave() + phase = "play" + +func end_demo() -> void: + demo = false + score = last_score + phase = "attract" + phase_time = 0 + $Audio.stop_all() + +func _unhandled_input(event: InputEvent) -> void: + if Engine.is_editor_hint() or event.is_echo(): return + if event.is_action_pressed("arcade_start"): + start_game() + +func held(action: StringName) -> bool: + return Input.is_action_pressed(action) + +func _physics_process(delta: float) -> void: + clock += delta + if Engine.is_editor_hint(): + phase_time = fmod(clock,22) + board.queue_redraw() + return + if phase == "attract": + phase_time += delta + if phase_time >= 28: begin_demo() + elif phase == "ready": + phase_time -= delta + if phase_time <= 0: + phase = "play" + if saucer_active: $Audio.ufo(true) + elif phase == "lost": + phase_time -= delta + if phase_time <= 0: + phase = "attract" + phase_time = 0 + elif phase == "dead": + phase_time -= delta + if phase_time <= 0: + if lives <= 0: finish(false) + else: + player_x = 24 + bombs.clear() + shot_active = false + phase = "ready" + phase_time = 1.0 + elif phase == "clear": + phase_time -= delta + if phase_time <= 0: + if demo: end_demo() + elif waves_to_win > 0 and wave >= waves_to_win: finish(true) + else: + wave += 1 + new_wave() + phase = "ready" + phase_time = 1.5 + elif phase == "play": + var move := 0.0 + if demo: + demo_target = 112+sin(demo_time*.7)*88 + move = signf(demo_target-player_x) if absf(demo_target-player_x)>1 else 0.0 + else: + move = float(held(&"arcade_right"))-float(held(&"arcade_left")) + player_x = clampf(player_x+move*60*delta,16,208) + shot_delay = maxf(0,shot_delay-delta) + if demo or held(&"arcade_action_1"): fire() + step_world(delta) + if demo: + demo_time += delta + if demo_time >= 25: end_demo() + for shield in shields: + if shield.dirty: + shield.texture.update(shield.image) + shield.dirty = false + board.queue_redraw() + +func fire() -> bool: + if phase != "play" or shot_active or shot_delay > 0: return false + shot_active = true + player_shot = Vector2(roundf(player_x),216) + $Audio.effect("shot") + return true + +func remove_shot() -> void: + if not shot_active: return + shot_active = false + shot_count += 1 + shot_delay = .08 + +func alive_count() -> int: + var count := 0 + for alien in aliens: + if alien.alive: count += 1 + return count + +func march() -> void: + var count := alive_count() + if count == 0: return + if fleet_cursor == 0: + var edge := false + for alien in aliens: + if alien.alive and (alien.position.x+fleet_direction*2 > 208 or alien.position.x+fleet_direction*2 < 16): edge = true + if edge: + fleet_direction *= -1 + fleet_delta = Vector2(0,8) + else: fleet_delta = Vector2(fleet_direction*(3 if count==1 else 2),0) + if march_sound_delay <= 0: + $Audio.effect("march%d"%march_note) + march_note = (march_note+1)%4 + march_sound_delay = .08 + while fleet_cursor < aliens.size() and not aliens[fleet_cursor].alive: fleet_cursor += 1 + if fleet_cursor < aliens.size(): + var alien: Dictionary = aliens[fleet_cursor] + alien.position += fleet_delta + alien.frame = 1-alien.frame + var bounds := Rect2(alien.position-Vector2(8,4),Vector2(16,8)) + for shield in shields: + if bounds.intersects(Rect2(Vector2(shield.position),Vector2(shield.image.get_size()))): + for y in range(shield.image.get_height()): + for x in range(shield.image.get_width()): + if bounds.has_point(Vector2(shield.position+Vector2i(x,y))): shield.image.set_pixel(x,y,Color.TRANSPARENT) + shield.dirty = true + if alien.position.y >= 216: finish(false) + fleet_cursor += 1 + while fleet_cursor < aliens.size() and not aliens[fleet_cursor].alive: fleet_cursor += 1 + if fleet_cursor >= aliens.size(): fleet_cursor = 0 + +func launch_bomb() -> void: + var kind := bomb_kind + bomb_kind = (bomb_kind+1)%3 + for bomb in bombs: + if bomb.kind == kind: return + if kind == 1 and alive_count() == 1: return + var candidates: Array[Dictionary] = [] + var columns: Dictionary = {} + for alien in aliens: + if alien.alive and not columns.has(alien.column): + columns[alien.column] = true + candidates.append(alien) + if candidates.is_empty(): return + var source: Dictionary = candidates[bomb_column%candidates.size()] + bomb_column += 3 + if kind == 0: + for alien in candidates: + if absf(alien.position.x-player_x) < absf(source.position.x-player_x): source = alien + bombs.append({"position":source.position+Vector2(0,8),"kind":kind}) + +func pixel_hit(name: String, origin: Vector2, point: Vector2) -> bool: + var image: Image = board.masks[name] + var p := Vector2i(point.round()-origin.round()) + return p.x>=0 and p.y>=0 and p.x.5 + +func shield_hit(point: Vector2, upward: bool) -> bool: + for shield in shields: + var p := Vector2i(point.round())-Vector2i(shield.position) + var image: Image = shield.image + if p.x<0 or p.y<0 or p.x>=image.get_width() or p.y>=image.get_height(): continue + if image.get_pixelv(p).a < .5: continue + for dy in range(-4,5): + for dx in range(-3,4): + var q := p+Vector2i(dx,dy) + if q.x<0 or q.y<0 or q.x>=image.get_width() or q.y>=image.get_height(): continue + var depth := -dy if upward else dy + if dx*dx+dy*dy<=12 and depth>=-1 and (absi(dx)<2 or (q.x+q.y)%3!=0): image.set_pixelv(q,Color.TRANSPARENT) + shield.dirty = true + return true + return false + +func blast(position: Vector2, name: String = "burst", duration: float = .16, text: String = "") -> void: + effects.append({"position":position,"name":name,"time":duration,"text":text}) + +func step_world(delta: float) -> void: + march_sound_delay = maxf(0,march_sound_delay-delta) + for i in range(effects.size()-1,-1,-1): + effects[i].time -= delta + if effects[i].time <= 0: effects.remove_at(i) + hit_pause = maxf(0,hit_pause-delta) + if hit_pause == 0: + fleet_ticks += delta*60 + while fleet_ticks >= 1 and phase == "play": + fleet_ticks -= 1 + march() + if phase != "play": return + bomb_timer -= delta + if bomb_timer <= 0: + launch_bomb() + bomb_timer = maxf(.25,.75-score/20000.0) + saucer_timer -= delta + if saucer_timer <= 0 and not saucer_active: + saucer_active = true + saucer_direction = 1 if shot_count%2==0 else -1 + saucer_x = -12 if saucer_direction==1 else 236 + $Audio.ufo(true) + if saucer_active: + saucer_x += saucer_direction*30*delta + if saucer_x < -16 or saucer_x > 240: + saucer_active = false + saucer_timer = 23 + $Audio.ufo(false) + # One-pixel substeps prevent fast projectiles tunneling through shields/sprites. + if shot_active: + var travel := delta*240 + while travel > 0 and shot_active: + var amount := minf(1,travel) + player_shot.y -= amount + travel -= amount + if shield_hit(player_shot,true): remove_shot() + elif player_shot.y <= 32: + blast(player_shot,"shot_burst") + remove_shot() + elif saucer_active and pixel_hit("ufo",Vector2(saucer_x-8,40),player_shot): + var value: int = SAUCER_SCORES[shot_count%15] + add_score(value) + blast(Vector2(saucer_x,44),"ufo_burst",.65,str(value)) + saucer_active = false + saucer_timer = 23 + $Audio.ufo(false) + $Audio.effect("ufo_hit") + remove_shot() + else: + for alien in aliens: + if alien.alive and pixel_hit("alien%d_%d"%[alien.kind,alien.frame],alien.position-Vector2(8,4),player_shot): + alien.alive = false + add_score([30,20,10][alien.kind]) + blast(alien.position) + hit_pause = .10 + $Audio.effect("invader") + remove_shot() + break + for i in range(bombs.size()-1,-1,-1): + var bomb: Dictionary = bombs[i] + var destroyed := false + var travel := delta*(90 if alive_count()<9 else 60) + while travel>0 and not destroyed: + var amount := minf(1,travel) + bomb.position.y += amount + travel -= amount + if shield_hit(bomb.position+Vector2(0,3),false): destroyed = true + elif shot_active and absf(bomb.position.x-player_shot.x)<3 and absf(bomb.position.y-player_shot.y)<7: + destroyed = true + remove_shot() + blast(bomb.position,"shot_burst") + elif pixel_hit("player",Vector2(roundf(player_x)-8,216),bomb.position+Vector2(0,3)): + die() + destroyed = true + elif bomb.position.y>=238: + destroyed = true + blast(Vector2(bomb.position.x,237),"shot_burst") + if destroyed: bombs.remove_at(i) + if phase != "play": return + if alive_count()==0: + phase = "clear" + phase_time = 1.5 + $Audio.stop_all() + +func add_score(value: int) -> void: + score += value + if demo: return + high_score = maxi(high_score,score) + if bonus_life_score>0 and score>=bonus_life_score and not extra_awarded: + extra_awarded = true + lives += 1 + $Audio.effect("bonus") + +func die() -> void: + if phase != "play": return + lives -= 1 + phase = "dead" + phase_time = 1.5 + $Audio.stop_all() + $Audio.effect("death") + +func finish(won: bool) -> void: + if demo: + end_demo() + return + if not is_running: return + last_score = score + phase = "won" if won else "lost" + phase_time = 5 + $Audio.stop_all() + if won: win_game() + else: lose_game() diff --git a/games/space_invaders/space_invaders.gd.uid b/games/space_invaders/space_invaders.gd.uid new file mode 100644 index 0000000..4d3bb8d --- /dev/null +++ b/games/space_invaders/space_invaders.gd.uid @@ -0,0 +1 @@ +uid://bs0afkx0eduyh diff --git a/games/tempest/Tempest.tscn b/games/tempest/Tempest.tscn new file mode 100644 index 0000000..a792a87 --- /dev/null +++ b/games/tempest/Tempest.tscn @@ -0,0 +1,14 @@ +[gd_scene format=3 uid="uid://bochyg70b5eig"] + +[ext_resource type="Script" uid="uid://d00n1vs51ma48" path="res://games/tempest/tempest.gd" id="1"] +[ext_resource type="Script" uid="uid://b150rg646gh6x" path="res://games/tempest/vector_board.gd" id="2"] +[ext_resource type="Script" uid="uid://bd2kidcvk2qgy" path="res://games/tempest/vector_audio.gd" id="3"] + +[node name="Tempest" type="Node" unique_id=125397583] +script = ExtResource("1") + +[node name="VectorBoard" type="Node2D" parent="." unique_id=156840381] +script = ExtResource("2") + +[node name="VectorAudio" type="Node" parent="." unique_id=536021095] +script = ExtResource("3") diff --git a/games/tempest/tempest.gd b/games/tempest/tempest.gd new file mode 100644 index 0000000..d1b61f7 --- /dev/null +++ b/games/tempest/tempest.gd @@ -0,0 +1,361 @@ +@tool +extends ArcadeGame +@export_range(1, 9) var starting_lives: int = 3 +## Number of tubes to clear from the chosen starting level; zero means endless. +@export_range(0, 99) var stages_to_win: int = 3 +@export_range(0.5, 2.0) var difficulty: float = 1.0 +@export_range(0.5, 2.5) var glow_strength: float = 1.35 +@export var show_level_select: bool = true +const LANES := 16 +const START_LEVELS := [1, 3, 5, 7, 9] +const START_BONUSES := [0, 6000, 16000, 32000, 54000] +var lane: int = 0 +var stage: int = 1 +var score: int = 0 +var high_score: int = 0 +var lives: int = 3 +var phase: String = "title" +var phase_time: float = 0.0 +var clock: float = 0.0 +var attract_clock: float = 0.0 +var selection: int = 0 +var cleared: int = 0 +var pending_bonus: int = 0 +var awarded_bonus: int = 0 +var next_extra_life: int = 20000 +var enemies: Array[Dictionary] = [] +var shots: Array[Dictionary] = [] +var sparks: Array[Dictionary] = [] +var spikes: Dictionary = {} +var remaining: int = 0 +var spawn_timer: float = 0.0 +var fire_timer: float = 0.0 +var move_timer: float = 0.0 +var zap_available: bool = true +var zap_uses: int = 0 +var zap_flash: float = 0.0 +var rng := RandomNumberGenerator.new() +@onready var view: Node2D = $VectorBoard + +func _ready() -> void: + if not Engine.is_editor_hint(): + rng.randomize() + lives = starting_lives + +func start_game() -> bool: + if not super.start_game(): + return false + stage = 1 + score = 0 + cleared = 0 + pending_bonus = 0 + awarded_bonus = 0 + selection = 0 + next_extra_life = 20000 + lives = starting_lives + lane = 0 + enemies.clear() + shots.clear() + sparks.clear() + spikes.clear() + zap_flash = 0.0 + move_timer = 0.0 + phase = "select" if show_level_select else "ready" + phase_time = 10.0 + if not show_level_select: + _begin_stage() + return true + +func confirm_level() -> void: + if phase != "select": + return + stage = START_LEVELS[selection] + pending_bonus = START_BONUSES[selection] + _begin_stage() + +func reset_game() -> void: + super.reset_game() + $VectorAudio.stop() + phase = "title" + attract_clock = 0.0 + enemies.clear() + shots.clear() + sparks.clear() + spikes.clear() + stage = 1 + score = 0 + lives = starting_lives + zap_uses = 0 + zap_available = true + zap_flash = 0.0 + pending_bonus = 0 + awarded_bonus = 0 + cleared = 0 + +func _begin_stage() -> void: + move_timer = 0.0 + fire_timer = 0.15 + zap_flash = 0.0 + enemies.clear() + shots.clear() + spikes.clear() + remaining = 10 + stage * 4 + spawn_timer = 0.5 + zap_uses = 0 + zap_available = true + phase = "ready" + phase_time = 1.4 + +func _pressed(action: String) -> bool: + return Input.is_action_pressed(action) + +func _event_action(event: InputEvent, action: String) -> bool: + return event.is_action_pressed(action) + +## Horizontal trackball travel per lane, measured at the game's logical width. +@export_range(1.0, 80.0) var trackball_pixels_per_lane: float = 12.0 +var trackball_remainder := 0.0 + +func _unhandled_input(event: InputEvent) -> void: + if Engine.is_editor_hint() or (event is InputEventKey and event.echo): + return + if event is InputEventMouseMotion: + trackball_remainder += event.relative.x * 640.0 / maxf(1.0, get_viewport().get_visible_rect().size.x) + var steps := int(trackball_remainder / trackball_pixels_per_lane) + if steps != 0: + rotate_steps(steps) + trackball_remainder -= steps * trackball_pixels_per_lane + if _event_action(event, "arcade_start"): + if not is_running: + start_game() + elif phase == "select": + confirm_level() + elif _event_action(event, "arcade_action_1") and phase == "select": + confirm_level() + elif _event_action(event, "arcade_action_2"): + superzap() + if event.is_action_pressed("arcade_rotate_right"): + rotate_steps(1) + elif event.is_action_pressed("arcade_rotate_left"): + rotate_steps(-1) + +func rotate_steps(steps: int) -> void: + if phase == "select": + selection = clampi(selection + steps, 0, START_LEVELS.size() - 1) + elif phase in ["play", "dive"]: + lane = bounded_lane(lane + steps) + +func is_open_tube() -> bool: + return (stage - 1) % 9 == 8 + +func bounded_lane(value: int) -> int: + return clampi(value, 0, LANES - 1) if is_open_tube() else posmod(value, LANES) + +func superzap() -> void: + if phase != "play" or zap_uses >= 2 or zap_flash > 0: + return + zap_flash = 0.35 + $VectorAudio.play("zap") + if zap_uses == 0: + for enemy in enemies: + _destroy(enemy, false) + enemies.clear() + elif not enemies.is_empty(): + var index := rng.randi_range(0, enemies.size() - 1) + _destroy(enemies[index], false) + enemies.remove_at(index) + zap_uses += 1 + zap_available = zap_uses < 2 + +func _physics_process(delta: float) -> void: + clock += delta + if phase == "title": + attract_clock += delta + zap_flash = maxf(0.0, zap_flash - delta) + for spark in sparks: + spark.life -= delta + sparks = sparks.filter(func(s: Dictionary) -> bool: return s.life > 0.0) + if Engine.is_editor_hint(): + view.queue_redraw() + return + if phase in ["play", "select", "dive"]: + move_timer -= delta + var direction := int(_pressed("arcade_right")) - int(_pressed("arcade_left")) + if direction == 0: + move_timer = 0.0 + elif move_timer <= 0.0: + rotate_steps(direction) + move_timer = 0.18 if phase == "select" else 0.075 + if phase == "play": + fire_timer -= delta + if _pressed("arcade_action_1"): + fire() + _step_play(delta) + elif phase in ["select", "ready", "death", "dive", "space", "arrive", "victory"]: + phase_time -= delta + if phase == "dive" and spikes.get(lane, 0.0) > 0.0 and spikes.get(lane, 0.0) >= dive_depth(): + _die() + elif phase_time <= 0.0: + match phase: + "select": confirm_level() + "ready": phase = "play" + "death": + if lives <= 0: + phase = "lost" + lose_game() + else: + enemies.clear() + shots.clear() + spikes.clear() + phase = "ready" + phase_time = 1.4 + "dive": + phase = "space" + phase_time = 1.2 + "space": + if stages_to_win > 0 and cleared >= stages_to_win: + phase = "victory" + phase_time = 1.0 + else: + stage += 1 + phase = "arrive" + phase_time = 1.6 + "arrive": _begin_stage() + "victory": + phase = "won" + win_game() + view.queue_redraw() + +func dive_depth() -> float: + return clampf(phase_time / 1.6, 0.0, 1.0) + +func fire() -> void: + if phase != "play" or fire_timer > 0.0: + return + shots.append({"lane": lane, "depth": 1.0}) + fire_timer = 0.11 + $VectorAudio.play("fire") + +func make_enemy(kind: String, at_lane: int, depth: float = 0.0) -> Dictionary: + return {"lane": at_lane, "depth": depth, "kind": kind, "hp": 1, "flip": rng.randf_range(0.7, 1.5), "rim": 0.3, "roll": 0.0, "from_lane": at_lane, "direction": 1, "retreat": false} + +func _step_play(delta: float) -> void: + spawn_timer -= delta + if remaining > 0 and spawn_timer <= 0: + var kind := "flipper" + if stage >= 3 and remaining % 5 == 0: + kind = "tanker" + elif stage >= 4 and remaining % 4 == 0: + kind = "spiker" + enemies.append(make_enemy(kind, rng.randi_range(0, LANES - 1))) + remaining -= 1 + spawn_timer = maxf(0.28, 0.95 - stage * 0.045) / difficulty + var newborn: Array[Dictionary] = [] + for shot in shots: + var old_depth: float = shot.depth + shot.depth -= delta * 2.3 + var hit: Dictionary = {} + for enemy in enemies: + if enemy.hp > 0 and collision_lane(enemy) == shot.lane and enemy.depth <= old_depth + 0.055 and enemy.depth >= shot.depth - 0.055: + if hit.is_empty() or enemy.depth > hit.depth: + hit = enemy + var spike_tip: float = spikes.get(int(shot.lane), 0.0) + if spike_tip > 0 and old_depth >= spike_tip and shot.depth <= spike_tip and (hit.is_empty() or spike_tip > hit.depth): + spikes[int(shot.lane)] = maxf(0.0, spike_tip - 0.10) + _add_score(1) + shot.depth = -1.0 + elif not hit.is_empty(): + hit.hp -= 1 + shot.depth = -1.0 + if hit.hp <= 0: + _destroy(hit) + if hit.kind == "tanker": + for offset in [-1, 1]: + newborn.append(make_enemy("flipper", bounded_lane(int(hit.lane) + offset), hit.depth)) + shots = shots.filter(func(s: Dictionary) -> bool: return s.depth >= 0.0) + enemies = enemies.filter(func(e: Dictionary) -> bool: return e.hp > 0) + enemies.append_array(newborn) + newborn.clear() + for enemy in enemies: + enemy.roll = maxf(0.0, float(enemy.get("roll", 0.0)) - delta) + var speed := (0.10 + stage * 0.012) * difficulty + if enemy.kind == "spiker": + if enemy.retreat: + enemy.depth -= delta * speed * 1.8 + if enemy.depth <= 0: + enemy.hp = 0 + else: + enemy.depth += delta * speed + spikes[int(enemy.lane)] = maxf(spikes.get(int(enemy.lane), 0.0), enemy.depth) + if enemy.depth > 0.65: + enemy.retreat = true + continue + enemy.depth = minf(1.0, enemy.depth + delta * speed) + if enemy.kind == "tanker" and enemy.depth > 0.75: + enemy.hp = 0 + for offset in [-1, 1]: + newborn.append(make_enemy("flipper", bounded_lane(int(enemy.lane) + offset), enemy.depth)) + continue + if enemy.depth >= 1.0: + enemy.rim -= delta + if enemy.rim <= 0 and enemy.roll <= 0: + var difference := lane - int(enemy.lane) if is_open_tube() else posmod(lane - int(enemy.lane), LANES) + if difference != 0: + _start_roll(enemy, (1 if difference > 0 else -1) if is_open_tube() else (1 if difference <= LANES / 2 else -1)) + enemy.rim = 0.32 + if collision_lane(enemy) == lane: + _die() + return + elif enemy.kind == "flipper" and enemy.depth > 0.28 and stage >= 2: + enemy.flip -= delta + if enemy.flip <= 0 and enemy.roll <= 0: + _start_roll(enemy, 1 if rng.randf() > 0.5 else -1) + enemy.flip = maxf(0.4, 1.4 - stage * 0.05) + enemies.append_array(newborn) + enemies = enemies.filter(func(e: Dictionary) -> bool: return e.hp > 0) + if remaining == 0 and enemies.is_empty(): + cleared += 1 + awarded_bonus = pending_bonus + _add_score(pending_bonus) + pending_bonus = 0 + phase = "dive" + phase_time = 1.6 + $VectorAudio.play("level") + shots.clear() + +func _start_roll(enemy: Dictionary, direction: int) -> void: + enemy.from_lane = enemy.lane + enemy.direction = direction + if is_open_tube() and (int(enemy.lane) + direction < 0 or int(enemy.lane) + direction >= LANES): + return + enemy.lane = bounded_lane(int(enemy.lane) + direction) + enemy.roll = 0.24 + +func collision_lane(enemy: Dictionary) -> int: + return int(enemy.get("from_lane", enemy.lane)) if float(enemy.get("roll", 0.0)) > 0.12 else int(enemy.lane) + +func _die() -> void: + lives -= 1 + $VectorAudio.play("death") + sparks.append({"lane": lane, "depth": 1.0, "life": 0.7}) + if phase == "dive": + # A failed warp resumes the cleared tube without counting it twice. + cleared -= 1 + phase = "death" + phase_time = 1.2 + +func _add_score(points: int) -> void: + score += points + high_score = maxi(high_score, score) + while score >= next_extra_life: + lives += 1 + next_extra_life += 20000 + +func _destroy(enemy: Dictionary, _split: bool = true) -> void: + if zap_flash <= 0.0: + $VectorAudio.play("hit") + _add_score({"tanker": 100, "spiker": 50}.get(enemy.kind, 150)) + sparks.append({"lane": enemy.lane, "depth": enemy.depth, "life": 0.45}) + + + diff --git a/games/tempest/tempest.gd.uid b/games/tempest/tempest.gd.uid new file mode 100644 index 0000000..648c168 --- /dev/null +++ b/games/tempest/tempest.gd.uid @@ -0,0 +1 @@ +uid://d00n1vs51ma48 diff --git a/games/tempest/vector_audio.gd b/games/tempest/vector_audio.gd new file mode 100644 index 0000000..2dc8cf3 --- /dev/null +++ b/games/tempest/vector_audio.gd @@ -0,0 +1,42 @@ +extends Node +## Small synthesized arcade effects; no external audio assets required. +var voices: Array[AudioStreamPlayer] = [] +var sounds: Dictionary = {} +var voice_index := 0 +func _ready() -> void: + for i in range(6): + var voice := AudioStreamPlayer.new() + voice.volume_db = -17.0 + add_child(voice) + voices.append(voice) + sounds["fire"] = tone(1300.0, 220.0, 0.09) + sounds["hit"] = tone(170.0, 40.0, 0.18) + sounds["zap"] = tone(1700.0, 70.0, 0.55) + sounds["death"] = tone(440.0, 35.0, 0.7) + sounds["level"] = tone(220.0, 880.0, 0.4) +func tone(start: float, finish: float, duration: float) -> AudioStreamWAV: + var samples := PackedByteArray() + var count := int(22050 * duration) + samples.resize(count * 2) + var phase := 0.0 + for i in range(count): + var t := float(i) / count + phase += lerpf(start, finish, t) * TAU / 22050.0 + var envelope := minf(t * 50.0, 1.0) * pow(1.0 - t, 1.5) + var value := int((sin(phase) + sin(phase * 2.0) * 0.25) * envelope * 18000) + samples.encode_s16(i * 2, value) + var stream := AudioStreamWAV.new() + stream.format = AudioStreamWAV.FORMAT_16_BITS + stream.mix_rate = 22050 + stream.data = samples + return stream +func play(effect: String) -> void: + if not sounds.has(effect) or voices.is_empty(): + return + var voice := voices[voice_index] + voice_index = (voice_index + 1) % voices.size() + voice.stream = sounds[effect] + voice.play() +func stop() -> void: + for voice in voices: + voice.stop() diff --git a/games/tempest/vector_audio.gd.uid b/games/tempest/vector_audio.gd.uid new file mode 100644 index 0000000..6992d05 --- /dev/null +++ b/games/tempest/vector_audio.gd.uid @@ -0,0 +1 @@ +uid://bd2kidcvk2qgy diff --git a/games/tempest/vector_board.gd b/games/tempest/vector_board.gd new file mode 100644 index 0000000..9bf7574 --- /dev/null +++ b/games/tempest/vector_board.gd @@ -0,0 +1,344 @@ +@tool +extends Node2D +const SIZE := Vector2(640, 800) +const VANISH := Vector2(320, 535) +const GREEN := Color("30ff35") +const YELLOW := Color("fff633") +const BLUE := Color("3044ff") +const CYAN := Color("24edff") +# Stroke alphabet: XY pairs on a 4 x 6 grid. Every glyph is drawn as vectors. +const GLYPHS := { +"0":"00 40 46 06 00", "1":"10 20 26;06 46", "2":"00 40 43 03 06 46", "3":"00 40 46 06;03 43", "4":"00 03 43;40 46", "5":"40 00 03 43 46 06", "6":"40 00 06 46 43 03", "7":"00 40 16", "8":"00 40 46 06 00;03 43", "9":"46 40 00 03 43", +"A":"06 20 46;14 34", "B":"00 30 41 42 33 03;33 44 45 36 06 00", "C":"40 00 06 46", "D":"00 30 42 44 36 06 00", "E":"40 00 06 46;03 33", "F":"40 00 06;03 33", "G":"40 00 06 46 43 23", "H":"00 06;40 46;03 43", "I":"00 40;20 26;06 46", "J":"00 40 46 06 04", "K":"00 06;40 03 46", "L":"00 06 46", "M":"06 00 23 40 46", "N":"06 00 46 40", "O":"00 40 46 06 00", "P":"06 00 40 43 03", "Q":"00 40 46 06 00;24 46", "R":"06 00 40 43 03;23 46", "S":"40 00 03 43 46 06", "T":"00 40;20 26", "U":"00 06 46 40", "V":"00 26 40", "W":"00 06 23 46 40", "X":"00 46;40 06", "Y":"00 23 40;23 26", "Z":"00 40 06 46", "!":"20 24;25 26", "-":"03 43", "/":"06 40", ":":"21 22;24 25", ".":"25 26", "<":"40 03 46", ">":"00 43 06" +} +# Stroke coordinates traced from the supplied logo reference. Shared baselines +# and open strokes are intentional; these are not outlined block letters. +const LOGO_STROKES := [ + [Vector2(87,61),Vector2(205,61)], [Vector2(147,61),Vector2(147,153)], + [Vector2(272,61),Vector2(216,61),Vector2(186,153),Vector2(305,153)], + [Vector2(201,107),Vector2(280,107)], + [Vector2(284,153),Vector2(320,61),Vector2(330,61),Vector2(354,121),Vector2(377,61),Vector2(389,61),Vector2(422,153)], + [Vector2(400,153),Vector2(436,153)], + [Vector2(428,153),Vector2(428,61),Vector2(491,61),Vector2(512,111),Vector2(428,111)], + [Vector2(606,61),Vector2(550,61),Vector2(520,153),Vector2(620,153)], + [Vector2(535,107),Vector2(615,107)], + [Vector2(632,125),Vector2(619,153),Vector2(724,153),Vector2(724,112),Vector2(644,89),Vector2(655,61),Vector2(728,61),Vector2(717,83)], + [Vector2(728,61),Vector2(846,61)], [Vector2(787,61),Vector2(787,153)] +] +var game: Node +var beam_texture: ImageTexture +var beam_mesh := ArrayMesh.new() +var beam_vertices := PackedVector3Array() +var beam_uvs := PackedVector2Array() +var beam_colors := PackedColorArray() +var beam_indices := PackedInt32Array() +var glyph_cache: Dictionary = {} + +func _make_beam_texture() -> void: + # Build the phosphor falloff once. UVs stretch its central column along a + # segment and use its two halves as rounded end caps. + var image := Image.create(128,128,false,Image.FORMAT_RGBA8) + for y in range(128): + for x in range(128): + var r := Vector2((x-63.5)/63.5,(y-63.5)/63.5).length() + var alpha := (.07*exp(-r*r*3.0)+.16*exp(-r*r*15.0)+.75*exp(-r*r*200.0)) * (1.0-smoothstep(.85,1.0,r)) + image.set_pixel(x,y,Color(1,1,1,alpha)) + beam_texture = ImageTexture.create_from_image(image) + for character in GLYPHS: + var strokes: Array[PackedVector2Array] = [] + for stroke in GLYPHS[character].split(";"): + var points := PackedVector2Array() + for pair in stroke.split(" "): + points.append(Vector2(float(pair[0]),float(pair[1]))) + strokes.append(points) + glyph_cache[character] = strokes + +func _beam_quad(a: Vector2, b: Vector2, normal: Vector2, u0: float, u1: float, tint: Color) -> void: + var base := beam_vertices.size() + for point in [a-normal,b-normal,b+normal,a+normal]: + beam_vertices.append(Vector3(point.x,point.y,0)) + beam_colors.append(tint) + beam_uvs.append_array(PackedVector2Array([Vector2(u0,0),Vector2(u1,0),Vector2(u1,1),Vector2(u0,1)])) + beam_indices.append_array(PackedInt32Array([base,base+1,base+2,base,base+2,base+3])) + +func _flush_beams() -> void: + beam_mesh.clear_surfaces() + if beam_vertices.is_empty(): + return + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = beam_vertices + arrays[Mesh.ARRAY_TEX_UV] = beam_uvs + arrays[Mesh.ARRAY_COLOR] = beam_colors + arrays[Mesh.ARRAY_INDEX] = beam_indices + beam_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES,arrays) + draw_mesh(beam_mesh,beam_texture) + +func _ready() -> void: + game = get_parent() + _make_beam_texture() + +func _process(_delta: float) -> void: + if not Engine.is_editor_hint(): + var available := get_viewport_rect().size + scale = Vector2.ONE * minf(available.x / SIZE.x, available.y / SIZE.y) + position = (available - SIZE * scale) * 0.5 + queue_redraw() + +func profile(index: float, level: int) -> Vector2: + # Hand-authored outlines; level nine is an open V with seventeen vertices. + if (level - 1) % 9 == 8: + # The arcade-like open V has a short flat bottom, not a collapsed point. + if index <= 7.0: + return Vector2(-1,-1).lerp(Vector2(-.14,1),index/7.0) + if index <= 9.0: + return Vector2(-.14,1).lerp(Vector2(.14,1),(index-7.0)/2.0) + return Vector2(.14,1).lerp(Vector2(1,-1),(index-9.0)/7.0) + var angle := index * TAU / 16.0 - PI / 2.0 + var p := Vector2(cos(angle), sin(angle)) + match (level - 1) % 9: + 1: p /= maxf(absf(p.x), absf(p.y)) + 2: + var cross := [Vector2(0,-1),Vector2(.5,-1),Vector2(.5,-.5),Vector2(1,-.5),Vector2(1,0),Vector2(1,.5),Vector2(.5,.5),Vector2(.5,1),Vector2(0,1),Vector2(-.5,1),Vector2(-.5,.5),Vector2(-1,.5),Vector2(-1,0),Vector2(-1,-.5),Vector2(-.5,-.5),Vector2(-.5,-1)] + var i := int(floor(index)) + p = cross[posmod(i,16)].lerp(cross[posmod(i+1,16)], fposmod(index,1.0)) + 3: p *= Vector2(1.0, 0.72) + 4: p *= 0.76 + 0.24 * cos(angle * 4.0) + 5: p /= absf(p.x) + absf(p.y) + 6: p *= 0.73 + 0.27 * cos(angle * 4.0 + PI) + 7: p *= Vector2(0.72, 1.0) + 8: p *= 0.8 + 0.2 * cos(angle * 3.0) + return p + +func perspective(depth: float) -> float: + return (1.0 / (1.0 + 4.0 * (1.0 - clampf(depth,0,1))) - 0.2) / 0.8 + +func tunnel_focus() -> Vector2: + # Open V lanes collapse visually if they use the low circular-tube focus. + return Vector2(320,345) if game.is_open_tube() else VANISH + +func rim(index: float, depth: float) -> Vector2: + var p := profile(index, game.stage) + return (tunnel_focus() + p * Vector2(34,43 if game.is_open_tube() else 34)).lerp(Vector2(320,410) + p * 265.0, perspective(depth)) + +func glow(points: PackedVector2Array, color: Color, strength: float = 1.0) -> void: + if points.size() < 2: + return + var tint := Color(color,minf(1.0,strength*game.glow_strength)) + var radius := 10.0 * sqrt(maxf(.1,game.glow_strength)) + for i in range(points.size()-1): + var a := points[i] + var b := points[i+1] + if a.distance_squared_to(b) < .0001: + continue + var direction := (b-a).normalized() + var normal := direction.orthogonal()*radius + _beam_quad(a,b,normal,.5,.5,tint) + _beam_quad(a-direction*radius,a,normal,0,.5,tint) + _beam_quad(b,b+direction*radius,normal,.5,1,tint) +func line(a: Vector2, b: Vector2, color: Color, strength: float = 1.0) -> void: + glow(PackedVector2Array([a,b]),color,strength) + +func text_at(value: String, origin: Vector2, height: float, color: Color, strength: float = 0.8) -> void: + var unit := height / 6.0 + var x := origin.x + for character in value: + for stroke in glyph_cache.get(character,[]): + var points := PackedVector2Array() + for point in stroke: + points.append(Vector2(x,origin.y)+point*unit) + glow(points,color,strength) + x += unit*6.0 +func label_at(value: String, y: float, height: float, color: Color) -> void: + text_at(value,Vector2((640 - value.length() * height + height / 3.0)/2,y),height,color) + +func actor(which_lane: float, depth: float, kind: String, roll: float = 0.0, roll_direction: int = 1) -> void: + var center := rim(which_lane + .5,depth) + var tangent := (rim(which_lane+1,depth)-rim(which_lane,depth)).normalized() + var inward := (tunnel_focus()-center).normalized() + var s := lerpf(3.0,17.0,perspective(depth)) + var color := Color("ff2929") + var shape := [Vector2(-1,-.6),Vector2(1,.6),Vector2(1,-.6),Vector2(-1,.6),Vector2(-1,-.6)] + if kind == "player": + color = YELLOW + s = lerpf(4.0,26.0,perspective(depth)) + shape = [Vector2(-1,.9),Vector2(-.85,-.25),Vector2(0,-.7),Vector2(.85,-.25),Vector2(1,.9),Vector2(.45,.1),Vector2(0,-.15),Vector2(-.45,.1),Vector2(-1,.9)] + elif kind == "tanker": + color = Color("df36ff") + shape = [Vector2(-1,0),Vector2(-.45,-.75),Vector2(.45,-.75),Vector2(1,0),Vector2(.45,.75),Vector2(-.45,.75),Vector2(-1,0),Vector2(1,0)] + elif kind == "spiker": + color = GREEN + shape = [] + for i in range(17): + shape.append(Vector2.from_angle(i * TAU / 8.0 + game.clock * 7.0) * (1.0 if i % 2 == 0 else .3)) + var points := PackedVector2Array() + for point in shape: + var p: Vector2 = point + if roll > 0: + p = p.rotated((1.0-roll/.24)*PI*roll_direction) + points.append(center + (tangent*p.x+inward*p.y)*s) + glow(points,color) + +func draw_tube(zoom: float = 1.0, strength: float = 1.0) -> void: + var pivot := tunnel_focus() + for i in range(17 if game.is_open_tube() else 16): + var selected: bool = i == game.lane or i == (game.lane+1 if game.is_open_tube() else (game.lane+1)%16) + line(pivot+(rim(i,0)-pivot)*zoom,pivot+(rim(i,1)-pivot)*zoom,YELLOW if selected else BLUE,strength) + for d in [0.0,1.0]: + var ring := PackedVector2Array() + for i in range(17): + ring.append(pivot+(rim(i,d)-pivot)*zoom) + glow(ring,BLUE,strength) + +func stars(strength: float = 1.0) -> void: + for i in range(95): + var angle := i * 2.399963 + var t := fposmod(sin(i * 127.1 + 17.3) * 43758.5453 + game.clock * .43,1.0) + var radius := pow(t,2.3)*700.0 + var direction := Vector2.from_angle(angle) + line(tunnel_focus()+direction*radius,tunnel_focus()+direction*(radius+2+t*24),[CYAN,BLUE,Color.WHITE][i%3],strength*t*.75) + +func hud() -> void: + text_at(str(game.score),Vector2(45,43),32,GREEN) + text_at("HIGH SCORE",Vector2(323,28),13,GREEN) + text_at(str(game.high_score),Vector2(342,52),19,GREEN) + text_at(str(game.stage),Vector2(342,83),16,BLUE) + for i in range(mini(game.lives,9)): + var x := 46.0 + i * 27 + glow(PackedVector2Array([Vector2(x,104),Vector2(x+10,98),Vector2(x+20,104),Vector2(x+14,111),Vector2(x+10,105),Vector2(x+6,111),Vector2(x,104)]),YELLOW,.7) + +func title_logo() -> void: + # The white leading copy grows from the distance. Each older copy is a + # smaller scale of exactly the same strokes, converging below the logo. + var time: float = fmod(game.attract_clock,28.0) + var front_scale := lerpf(.05,1.0,smoothstep(0.0,3.0,time)) + var pivot := Vector2(320,408) + for layer in range(14,-1,-1): + var age := 0.0 if layer == 0 else float(layer)-fposmod(game.clock*6.0,1.0) + var copy_scale := front_scale * (1.0-age*.027) + var tint := Color.WHITE if layer == 0 else Color.from_hsv(fposmod(game.clock*.16-age*.055,1.0),1.0,1.0) + for stroke in LOGO_STROKES: + var points := PackedVector2Array() + for point in stroke: + var front: Vector2 = Vector2(320,286)+(point-Vector2(466.5,107))*.71 + points.append(pivot+(front-pivot)*copy_scale) + glow(points,tint,.72 if layer > 0 else 1.0) +func level_select() -> void: + label_at("PLAYER 1",182,36,Color.WHITE) + label_at("RATE YOURSELF",245,19,GREEN) + label_at("TURN TO CHANGE",275,18,CYAN) + label_at("PRESS FIRE TO SELECT",304,18,YELLOW) + text_at("NOVICE",Vector2(102,368),14,Color.RED) + text_at("EXPERT",Vector2(463,368),14,Color.RED) + for i in range(5): + var x := 123.0+i*98 + text_at(str(game.START_LEVELS[i]),Vector2(x,405),21,GREEN) + var shape := PackedVector2Array() + for j in range(17): + shape.append(Vector2(x+8,465)+profile(j,game.START_LEVELS[i])*24) + glow(shape,BLUE) + text_at(str(game.START_BONUSES[i]),Vector2(x-25,518),13,Color("ff3030")) + if i == game.selection: + glow(PackedVector2Array([Vector2(x-37,390),Vector2(x+48,390),Vector2(x+48,550),Vector2(x-37,550),Vector2(x-37,390)]),Color.WHITE,.7) + text_at("LEVEL",Vector2(14,410),13,GREEN) + text_at("HOLE",Vector2(14,465),13,GREEN) + text_at("BONUS",Vector2(14,520),13,GREEN) + label_at("TIME %d" % ceili(game.phase_time),651,20,GREEN) + +func _draw() -> void: + beam_vertices.clear() + beam_uvs.clear() + beam_colors.clear() + beam_indices.clear() + _draw_board() + _flush_beams() + +func _draw_board() -> void: + if game == null: return + if Engine.is_editor_hint(): + draw_rect(Rect2(Vector2.ZERO,SIZE),Color.BLACK) + else: + draw_rect(Rect2(-position/scale,get_viewport_rect().size/scale),Color.BLACK) + if game.phase == "select": + level_select() + return + if game.phase == "title" and not Engine.is_editor_hint(): + var page: float = fmod(game.attract_clock,28.0) + if page < 9.0: + title_logo() + elif page < 15.0: + label_at("HIGH SCORES",222,29,Color("ff3030")) + label_at("1 PLAYER %06d" % game.high_score,316,22,BLUE) + label_at("THIS SESSION",370,14,CYAN) + elif page < 23.0: + draw_tube() + actor(floor(game.clock*1.5),1.0,"player") + for i in range(6): + actor(i*3,fposmod(game.clock*.08+i*.16,1.0),"flipper") + label_at("GAME OVER",130,21,GREEN) + else: + label_at("DESTROY THE ENEMIES",270,23,Color.RED) + label_at("FIRE DOWN THE TUBE",330,20,YELLOW) + label_at("SUPERZAPPER",401,24,CYAN) + label_at("FIRST BLAST - ALL",451,18,GREEN) + label_at("SECOND BLAST - ONE",486,18,GREEN) + label_at("PRESS START",697,23,GREEN) + label_at("BONUS EVERY 20000",740,15,CYAN) + label_at("FREE PLAY",774,14,GREEN) + return + if game.phase in ["space","victory","won"]: + stars() + if game.phase in ["victory","won"]: + label_at("YOU WIN!",350,40,YELLOW) + elif game.awarded_bonus > 0: + label_at("BONUS %d" % game.awarded_bonus,310,25,YELLOW) + elif game.phase == "arrive": + var t := clampf(1.0-game.phase_time/1.6,0,1) + stars(1.0-t) + draw_tube(lerpf(.025,1.0,t*t*(3.0-2.0*t))) + elif game.phase == "dive": + var t: float = 1.0-game.dive_depth() + stars(t) + draw_tube(1.0+t*t*7,1.0-t) + for spike_lane in game.spikes: + line(rim(spike_lane+.5,0),rim(spike_lane+.5,game.spikes[spike_lane]),GREEN,1.0-t) + actor(game.lane,game.dive_depth(),"player") + if not game.spikes.is_empty(): label_at("AVOID SPIKES",720,18,Color.RED) + else: + draw_tube() + for spike_lane in game.spikes: + line(rim(spike_lane+.5,0),rim(spike_lane+.5,game.spikes[spike_lane]),GREEN) + for enemy in game.enemies: + var visible_lane: float = enemy.lane + var rolling: float = enemy.get("roll",0.0) + if rolling > 0: + visible_lane = float(enemy.from_lane)+(1.0-rolling/.24)*enemy.direction + actor(visible_lane,enemy.depth,enemy.kind,rolling,enemy.get("direction",1)) + for shot in game.shots: + line(rim(shot.lane+.5,shot.depth),rim(shot.lane+.5,maxf(0,shot.depth-.08)),YELLOW) + if game.phase not in ["death","lost"]: + actor(game.lane,1,"player") + for spark in game.sparks: + var origin := rim(spark.lane+.5,spark.depth) + for i in range(8): + var direction := Vector2.from_angle(i*TAU/8+game.clock) + var r: float = (0.7-spark.life)*65 + line(origin+direction*r*.5,origin+direction*r,YELLOW,minf(1,spark.life*3)) + if game.phase == "ready": label_at("PLAYER 1",345,29,Color.WHITE) + if game.phase == "lost": label_at("GAME OVER",345,29,Color.RED) + if game.zap_flash > 0: + for i in range(16): line(rim(i+.5,0),rim(i+.5,1),Color.WHITE,game.zap_flash*3) + hud() + if game.phase in ["play","ready"]: + label_at("SUPERZAPPER %d" % (2-game.zap_uses),749,14,CYAN) + label_at("ARROWS MOVE SPACE FIRE Z ZAP",779,11,GREEN) + elif game.phase in ["won","lost"]: + label_at("PRESS START",749,22,GREEN) + + + + + + + + diff --git a/games/tempest/vector_board.gd.uid b/games/tempest/vector_board.gd.uid new file mode 100644 index 0000000..3058c33 --- /dev/null +++ b/games/tempest/vector_board.gd.uid @@ -0,0 +1 @@ +uid://b150rg646gh6x diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..2c9f7a0 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cuwa3ns07hkd8" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/main/Main.tscn b/main/Main.tscn new file mode 100644 index 0000000..884e71c --- /dev/null +++ b/main/Main.tscn @@ -0,0 +1,63 @@ +[gd_scene format=3 uid="uid://b0ojyvkcwkvpr"] + +[ext_resource type="Script" uid="uid://bm2wpeevmxudu" path="res://main/main.gd" id="1_qv31u"] +[ext_resource type="Resource" uid="uid://8qqlypjp3hd4" path="res://data/project_database.tres" id="2_5kcm4"] +[ext_resource type="Script" uid="uid://cp6novpbgl1uk" path="res://core/runtime/game_host.gd" id="3_5kcm4"] + +[node name="Main" type="Control" unique_id=12370566] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_qv31u") +database = ExtResource("2_5kcm4") + +[node name="GameHost" type="Node" parent="." unique_id=832996101] +script = ExtResource("3_5kcm4") + +[node name="DebugUI" type="Node" parent="." unique_id=28531604] + +[node name="VBoxContainer" type="VBoxContainer" parent="DebugUI" unique_id=1495913667] +anchors_preset = 3 +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -219.0 +offset_top = -190.0 +grow_horizontal = 0 +grow_vertical = 0 + +[node name="TitleLabel" type="Label" parent="DebugUI/VBoxContainer" unique_id=619442521] +layout_mode = 2 + +[node name="StatusLabel" type="Label" parent="DebugUI/VBoxContainer" unique_id=818182868] +layout_mode = 2 + +[node name="StartButton" type="Button" parent="DebugUI/VBoxContainer" unique_id=1410664970] +layout_mode = 2 +focus_mode = 0 +text = "Start" + +[node name="ForceWinButton" type="Button" parent="DebugUI/VBoxContainer" unique_id=2070309557] +layout_mode = 2 +focus_mode = 0 +text = "SIMULATE WIN" + +[node name="ForceLoseButton" type="Button" parent="DebugUI/VBoxContainer" unique_id=1760144315] +layout_mode = 2 +focus_mode = 0 +text = "SIMULATE LOSE" + +[node name="ResetButton" type="Button" parent="DebugUI/VBoxContainer" unique_id=657055303] +layout_mode = 2 +focus_mode = 0 +text = "Reset +" + +[connection signal="pressed" from="DebugUI/VBoxContainer/StartButton" to="." method="_on_start_button_pressed"] +[connection signal="pressed" from="DebugUI/VBoxContainer/ForceWinButton" to="." method="_on_force_win_button_pressed"] +[connection signal="pressed" from="DebugUI/VBoxContainer/ForceLoseButton" to="." method="_on_force_lose_button_pressed"] +[connection signal="pressed" from="DebugUI/VBoxContainer/ResetButton" to="." method="_on_reset_button_pressed"] diff --git a/main/main.gd b/main/main.gd new file mode 100644 index 0000000..4f43e16 --- /dev/null +++ b/main/main.gd @@ -0,0 +1,233 @@ +extends Control + + +@export_group("Database") +@export var database: ProjectDatabase + + +@export_group("Editor Defaults") +@export var default_cabinet_id: StringName = &"cabinet_01" +@export var default_scenario_id: StringName = &"proof_of_concept" + + +@onready var game_host: GameHost = $GameHost +@onready var status_label: Label = ( + $DebugUI/VBoxContainer/StatusLabel +) + + +var active_scenario: ScenarioDefinition +var active_assignment: ScenarioAssignment +var launch_config: LaunchConfig + + +func _ready() -> void: + if database == null: + push_error( + "Main has no ProjectDatabase assigned." + ) + return + + launch_config = LaunchConfig.from_command_line( + default_cabinet_id, + default_scenario_id + ) + + launch_config.print_debug() + + if not _resolve_scenario(): + return + + if not _resolve_assignment(): + return + + if not _validate_assignment(): + return + + _start_assignment() + + +func _resolve_scenario() -> bool: + active_scenario = database.get_scenario( + launch_config.scenario_id + ) + + if active_scenario == null: + push_error( + "Scenario '%s' was not found in ProjectDatabase." + % launch_config.scenario_id + ) + + return false + + print( + "[LAUNCHER] Scenario resolved: %s v%d" + % [ + active_scenario.display_name, + active_scenario.version + ] + ) + + return true + + +func _resolve_assignment() -> bool: + active_assignment = ( + active_scenario.get_assignment_for_cabinet( + launch_config.cabinet_id + ) + ) + + if active_assignment == null: + push_error( + "Scenario '%s' has no assignment for cabinet '%s'." + % [ + active_scenario.scenario_id, + launch_config.cabinet_id + ] + ) + + return false + + print( + "[LAUNCHER] Assignment: %s -> %s" + % [ + active_assignment.cabinet.display_name, + active_assignment.game.display_name + ] + ) + + return true + + +func _validate_assignment() -> bool: + if active_assignment.cabinet == null: + push_error( + "Scenario assignment has no CabinetProfile." + ) + return false + + if active_assignment.game == null: + push_error( + "Scenario assignment has no GameDefinition." + ) + return false + + if ( + active_assignment.cabinet.cabinet_id + != launch_config.cabinet_id + ): + push_error( + "Assignment cabinet ID does not match " + + "requested cabinet." + ) + + return false + + if not active_assignment.game.can_run_on( + active_assignment.cabinet + ): + push_error( + "Game '%s' is incompatible with cabinet '%s'." + % [ + active_assignment.game.game_id, + active_assignment.cabinet.cabinet_id + ] + ) + + return false + + print("[LAUNCHER] Assignment validated.") + + return true + + +func _start_assignment() -> void: + RoomRuntime.configure( + active_assignment + ) + + var game := game_host.load_game( + active_assignment.game + ) + + if game == null: + return + + game.game_started.connect( + _update_status_started + ) + + game.game_won.connect( + _update_status_won + ) + + game.game_lost.connect( + _update_status_lost + ) + + game.game_reset.connect( + _update_status_reset + ) + + status_label.text = ( + "%s\n%s\n%s" + % [ + active_scenario.display_name, + active_assignment.cabinet.display_name, + active_assignment.game.display_name + ] + ) + + print("") + print("=========== READY ===========") + print( + "Scenario: ", + active_scenario.display_name + ) + print( + "Cabinet: ", + active_assignment.cabinet.display_name + ) + print( + "Game: ", + active_assignment.game.display_name + ) + print("=============================") + print("") + + +func _on_start_button_pressed() -> void: + if game_host.current_game: + game_host.current_game.start_game() + + +func _on_force_win_button_pressed() -> void: + if game_host.current_game: + game_host.current_game.win_game() + + +func _on_force_lose_button_pressed() -> void: + if game_host.current_game: + game_host.current_game.lose_game() + + +func _on_reset_button_pressed() -> void: + if game_host.current_game: + game_host.current_game.reset_game() + + +func _update_status_started() -> void: + status_label.text = "GAME RUNNING" + + +func _update_status_won() -> void: + status_label.text = "GAME WON" + + +func _update_status_lost() -> void: + status_label.text = "GAME LOST" + + +func _update_status_reset() -> void: + status_label.text = "READY" diff --git a/main/main.gd.uid b/main/main.gd.uid new file mode 100644 index 0000000..ce72ec8 --- /dev/null +++ b/main/main.gd.uid @@ -0,0 +1 @@ +uid://bm2wpeevmxudu diff --git a/operator/ArcadeRoomOperator.tscn b/operator/ArcadeRoomOperator.tscn new file mode 100644 index 0000000..5c6dd9b --- /dev/null +++ b/operator/ArcadeRoomOperator.tscn @@ -0,0 +1,10 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://operator/arcade_room_operator.gd" id="1"] +[node name="ArcadeRoomOperator" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") diff --git a/operator/OperatorConsole.tscn b/operator/OperatorConsole.tscn new file mode 100644 index 0000000..f921183 --- /dev/null +++ b/operator/OperatorConsole.tscn @@ -0,0 +1,67 @@ +[gd_scene format=3 uid="uid://duvm1qwhoqq2t"] + +[ext_resource type="Script" uid="uid://c5k1co6ww5flo" path="res://operator/operator_console.gd" id="1_k8li0"] +[ext_resource type="Script" uid="uid://dbe24sbmbh6tt" path="res://core/runtime/room_controller.gd" id="1_vinxr"] +[ext_resource type="Resource" uid="uid://8qqlypjp3hd4" path="res://data/project_database.tres" id="2_qhjp7"] +[ext_resource type="Resource" uid="uid://dgc35ind56yxm" path="res://data/rooms/arcade_room.tres" id="3_prnev"] +[ext_resource type="PackedScene" uid="uid://d0c4psurpl2f2" path="res://editor/workspace/EscapeRoomWorkspace.tscn" id="5_prnev"] + +[node name="OperatorConsole" type="Control" unique_id=1959910901] +propagate_maximum_size = true +clip_contents = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_k8li0") +database = ExtResource("2_qhjp7") +room = ExtResource("3_prnev") + +[node name="RoomController" type="Node" parent="." unique_id=314194706] +script = ExtResource("1_vinxr") + +[node name="MarginContainer" type="MarginContainer" parent="." unique_id=1366109145] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer" unique_id=361139616] +layout_mode = 2 + +[node name="OperatorBar" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=812304594] +layout_mode = 2 + +[node name="RuntimeScenarioSelect" type="OptionButton" parent="MarginContainer/VBoxContainer/OperatorBar" unique_id=1671860760] +layout_mode = 2 + +[node name="ValidateRoomButton" type="Button" parent="MarginContainer/VBoxContainer/OperatorBar" unique_id=505623115] +layout_mode = 2 +text = "VALIDATE" + +[node name="PrepRoomButton" type="Button" parent="MarginContainer/VBoxContainer/OperatorBar" unique_id=1689610592] +layout_mode = 2 +text = "PREP" + +[node name="RoomStatusLabel" type="Label" parent="MarginContainer/VBoxContainer/OperatorBar" unique_id=1574831891] +layout_mode = 2 +text = "STATUS" + +[node name="CabinetStatusContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=1276724977] +layout_mode = 2 +size_flags_vertical = 0 + +[node name="EditorArea" type="Control" parent="MarginContainer/VBoxContainer" unique_id=851988818] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="EscapeRoomWorkspace" parent="MarginContainer/VBoxContainer/EditorArea" unique_id=1163239148 instance=ExtResource("5_prnev")] +layout_mode = 1 + +[connection signal="item_selected" from="MarginContainer/VBoxContainer/OperatorBar/RuntimeScenarioSelect" to="." method="_on_runtime_scenario_select_item_selected"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/OperatorBar/ValidateRoomButton" to="." method="_on_validate_room_button_pressed"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/OperatorBar/PrepRoomButton" to="." method="_on_prep_room_button_pressed"] diff --git a/operator/OperatorConsole.tscn3575318359.tmp b/operator/OperatorConsole.tscn3575318359.tmp new file mode 100644 index 0000000..39e4446 --- /dev/null +++ b/operator/OperatorConsole.tscn3575318359.tmp @@ -0,0 +1,243 @@ +[gd_scene format=3 uid="uid://duvm1qwhoqq2t"] + +[ext_resource type="Script" uid="uid://c5k1co6ww5flo" path="res://operator/operator_console.gd" id="1_k8li0"] +[ext_resource type="Script" uid="uid://dbe24sbmbh6tt" path="res://core/runtime/room_controller.gd" id="1_vinxr"] +[ext_resource type="Resource" uid="uid://8qqlypjp3hd4" path="res://data/project_database.tres" id="2_qhjp7"] +[ext_resource type="Resource" uid="uid://dgc35ind56yxm" path="res://data/rooms/arcade_room.tres" id="3_prnev"] +[ext_resource type="Script" uid="uid://bfb7sad2mjnlf" path="res://core/simulation/room_simulator.gd" id="4_4ar2l"] +[ext_resource type="Script" uid="uid://ca320x55f2h3y" path="res://ui/room_layout.gd" id="6_dicmx"] +[ext_resource type="PackedScene" uid="uid://c5c0mnwwuqruw" path="res://ui/RoomLayoutItem.tscn" id="7_ncki6"] +[ext_resource type="Script" uid="uid://dm5remdyomnc4" path="res://ui/logic_graph.gd" id="8_gu74o"] + +[node name="OperatorConsole" type="Control" unique_id=1959910901] +propagate_maximum_size = true +clip_contents = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_k8li0") +database = ExtResource("2_qhjp7") +room = ExtResource("3_prnev") + +[node name="RoomController" type="Node" parent="." unique_id=314194706] +script = ExtResource("1_vinxr") + +[node name="RoomSimulator" type="Node" parent="." unique_id=1394335779] +script = ExtResource("4_4ar2l") + +[node name="MarginContainer" type="MarginContainer" parent="." unique_id=1014582732] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer" unique_id=835801638] +layout_mode = 2 + +[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer" unique_id=535818399] +layout_mode = 2 + +[node name="ScenarioBar" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=219831870] +layout_mode = 2 + +[node name="ScenarioLabel" type="Label" parent="MarginContainer/VBoxContainer/ScenarioBar" unique_id=1395073146] +layout_mode = 2 + +[node name="ScenarioSelect" type="OptionButton" parent="MarginContainer/VBoxContainer/ScenarioBar" unique_id=1960308578] +layout_mode = 2 + +[node name="ActionBar" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=525952292] +layout_mode = 2 + +[node name="LoadButton" type="Button" parent="MarginContainer/VBoxContainer/ActionBar" unique_id=433362026] +layout_mode = 2 +text = "Load" + +[node name="ValidateButton" type="Button" parent="MarginContainer/VBoxContainer/ActionBar" unique_id=1164666775] +layout_mode = 2 +text = "Validate" + +[node name="PrepButton" type="Button" parent="MarginContainer/VBoxContainer/ActionBar" unique_id=847661533] +layout_mode = 2 +text = "Prep" + +[node name="HSeparator" type="HSeparator" parent="MarginContainer/VBoxContainer" unique_id=1151207993] +layout_mode = 2 + +[node name="RoomStatusLabel" type="Label" parent="MarginContainer/VBoxContainer" unique_id=745890717] +layout_mode = 2 + +[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/VBoxContainer" unique_id=128786743] +custom_minimum_size = Vector2(1920, 200) +propagate_maximum_size = true +layout_mode = 2 + +[node name="CabinetList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/ScrollContainer" unique_id=1328206568] +layout_mode = 2 + +[node name="HSeparator2" type="HSeparator" parent="MarginContainer/VBoxContainer" unique_id=2024560039] +layout_mode = 2 + +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer" unique_id=2017763907] +layout_mode = 2 +text = "ROOM DEVICES" + +[node name="MainViews" type="TabContainer" parent="MarginContainer/VBoxContainer" unique_id=2035710526] +propagate_maximum_size = false +layout_mode = 2 +size_flags_vertical = 3 +current_tab = 1 + +[node name="Physical" type="Control" parent="MarginContainer/VBoxContainer/MainViews" unique_id=1709144739] +visible = false +layout_mode = 2 +metadata/_tab_index = 0 + +[node name="LayoutWorkspace" type="HBoxContainer" parent="MarginContainer/VBoxContainer/MainViews/Physical" unique_id=165485620] +propagate_maximum_size = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_bottom = 61.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="RoomLayoutPanel" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace" unique_id=1890208727] +custom_minimum_size = Vector2(0, 300) +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="RoomLayout" type="Control" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/RoomLayoutPanel" unique_id=1755582596] +layout_mode = 2 +script = ExtResource("6_dicmx") +item_scene = ExtResource("7_ncki6") + +[node name="PropertyPanel" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace" unique_id=1130312040] +custom_minimum_size = Vector2(300, 0) +custom_maximum_size = Vector2(300, -1) +layout_mode = 2 + +[node name="MarginContainer" type="MarginContainer" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel" unique_id=1503925230] +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer" unique_id=1844850543] +layout_mode = 2 + +[node name="PropertiesTitle" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1868518001] +layout_mode = 2 + +[node name="SelectedLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=425349153] +layout_mode = 2 + +[node name="HSeparator" type="HSeparator" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=540873004] +layout_mode = 2 + +[node name="XLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1395648922] +layout_mode = 2 + +[node name="XSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=212457245] +layout_mode = 2 +min_value = -2000.0 +max_value = 5000.0 + +[node name="YLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=449512311] +layout_mode = 2 + +[node name="YSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=539657259] +layout_mode = 2 +min_value = -2000.0 +max_value = 5000.0 + +[node name="RotationLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1558308543] +layout_mode = 2 + +[node name="RotationSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=300843311] +layout_mode = 2 +min_value = -360.0 +max_value = 360.0 + +[node name="SnapCheckBox" type="CheckBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=83220102] +layout_mode = 2 +button_pressed = true +text = "Snap to Grid" + +[node name="GridLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1014679681] +layout_mode = 2 + +[node name="GridSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1237291196] +layout_mode = 2 +min_value = 1.0 +max_value = 200.0 +value = 1.0 + +[node name="HSeparator2" type="HSeparator" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=200431526] +layout_mode = 2 + +[node name="DirtyLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1190553142] +layout_mode = 2 + +[node name="SaveButton" type="Button" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=839924948] +layout_mode = 2 +text = "SAVE ROOM" + +[node name="RevertButton" type="Button" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1355534065] +layout_mode = 2 +text = "REVERT" + +[node name="ShowConnectionsCheckBox" type="CheckBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=309406547] +layout_mode = 2 +button_pressed = true +text = "Show Connections" + +[node name="Logic" type="Control" parent="MarginContainer/VBoxContainer/MainViews" unique_id=1771194381] +layout_mode = 2 +metadata/_tab_index = 1 + +[node name="AutoArrangeButton" type="Button" parent="MarginContainer/VBoxContainer/MainViews/Logic" unique_id=621962605] +layout_mode = 0 +offset_left = 1786.0 +offset_top = -29.0 +offset_right = 1917.0 +offset_bottom = 2.0 +text = "AUTO ARRANGE" + +[node name="LogicGraph" type="GraphEdit" parent="MarginContainer/VBoxContainer/MainViews/Logic" unique_id=40506474] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +scroll_offset = Vector2(-40, -40) +script = ExtResource("8_gu74o") + +[node name="DeviceList" type="VBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=1469097889] +custom_minimum_size = Vector2(0, 150) +layout_mode = 2 + +[node name="HSeparator3" type="HSeparator" parent="MarginContainer/VBoxContainer" unique_id=1833639349] +layout_mode = 2 + +[node name="ValidationOutput" type="RichTextLabel" parent="MarginContainer/VBoxContainer" unique_id=287181262] +custom_minimum_size = Vector2(0, 100) +custom_maximum_size = Vector2(-1, 100) +layout_mode = 2 + +[connection signal="pressed" from="MarginContainer/VBoxContainer/ActionBar/LoadButton" to="." method="_on_load_button_pressed"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/ActionBar/ValidateButton" to="." method="_on_validate_button_pressed"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/ActionBar/PrepButton" to="." method="_on_prep_button_pressed"] +[connection signal="value_changed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/XSpinBox" to="." method="_on_x_spinbox_value_changed"] +[connection signal="value_changed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/YSpinBox" to="." method="_on_y_spinbox_value_changed"] +[connection signal="value_changed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/RotationSpinBox" to="." method="_on_rotation_spinbox_value_changed"] +[connection signal="toggled" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/SnapCheckBox" to="." method="_on_snap_check_box_toggled"] +[connection signal="value_changed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/GridSpinBox" to="." method="_on_grid_spinbox_value_changed"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/SaveButton" to="." method="_on_save_button_pressed"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/RevertButton" to="." method="_on_revert_button_pressed"] +[connection signal="toggled" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/ShowConnectionsCheckBox" to="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/RoomLayoutPanel/RoomLayout" method="_on_show_connections_check_box_toggled"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/MainViews/Logic/AutoArrangeButton" to="." method="_on_auto_arrange_button_pressed"] diff --git a/operator/OperatorConsole.tscn3708401958.tmp b/operator/OperatorConsole.tscn3708401958.tmp new file mode 100644 index 0000000..39e4446 --- /dev/null +++ b/operator/OperatorConsole.tscn3708401958.tmp @@ -0,0 +1,243 @@ +[gd_scene format=3 uid="uid://duvm1qwhoqq2t"] + +[ext_resource type="Script" uid="uid://c5k1co6ww5flo" path="res://operator/operator_console.gd" id="1_k8li0"] +[ext_resource type="Script" uid="uid://dbe24sbmbh6tt" path="res://core/runtime/room_controller.gd" id="1_vinxr"] +[ext_resource type="Resource" uid="uid://8qqlypjp3hd4" path="res://data/project_database.tres" id="2_qhjp7"] +[ext_resource type="Resource" uid="uid://dgc35ind56yxm" path="res://data/rooms/arcade_room.tres" id="3_prnev"] +[ext_resource type="Script" uid="uid://bfb7sad2mjnlf" path="res://core/simulation/room_simulator.gd" id="4_4ar2l"] +[ext_resource type="Script" uid="uid://ca320x55f2h3y" path="res://ui/room_layout.gd" id="6_dicmx"] +[ext_resource type="PackedScene" uid="uid://c5c0mnwwuqruw" path="res://ui/RoomLayoutItem.tscn" id="7_ncki6"] +[ext_resource type="Script" uid="uid://dm5remdyomnc4" path="res://ui/logic_graph.gd" id="8_gu74o"] + +[node name="OperatorConsole" type="Control" unique_id=1959910901] +propagate_maximum_size = true +clip_contents = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_k8li0") +database = ExtResource("2_qhjp7") +room = ExtResource("3_prnev") + +[node name="RoomController" type="Node" parent="." unique_id=314194706] +script = ExtResource("1_vinxr") + +[node name="RoomSimulator" type="Node" parent="." unique_id=1394335779] +script = ExtResource("4_4ar2l") + +[node name="MarginContainer" type="MarginContainer" parent="." unique_id=1014582732] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer" unique_id=835801638] +layout_mode = 2 + +[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer" unique_id=535818399] +layout_mode = 2 + +[node name="ScenarioBar" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=219831870] +layout_mode = 2 + +[node name="ScenarioLabel" type="Label" parent="MarginContainer/VBoxContainer/ScenarioBar" unique_id=1395073146] +layout_mode = 2 + +[node name="ScenarioSelect" type="OptionButton" parent="MarginContainer/VBoxContainer/ScenarioBar" unique_id=1960308578] +layout_mode = 2 + +[node name="ActionBar" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=525952292] +layout_mode = 2 + +[node name="LoadButton" type="Button" parent="MarginContainer/VBoxContainer/ActionBar" unique_id=433362026] +layout_mode = 2 +text = "Load" + +[node name="ValidateButton" type="Button" parent="MarginContainer/VBoxContainer/ActionBar" unique_id=1164666775] +layout_mode = 2 +text = "Validate" + +[node name="PrepButton" type="Button" parent="MarginContainer/VBoxContainer/ActionBar" unique_id=847661533] +layout_mode = 2 +text = "Prep" + +[node name="HSeparator" type="HSeparator" parent="MarginContainer/VBoxContainer" unique_id=1151207993] +layout_mode = 2 + +[node name="RoomStatusLabel" type="Label" parent="MarginContainer/VBoxContainer" unique_id=745890717] +layout_mode = 2 + +[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/VBoxContainer" unique_id=128786743] +custom_minimum_size = Vector2(1920, 200) +propagate_maximum_size = true +layout_mode = 2 + +[node name="CabinetList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/ScrollContainer" unique_id=1328206568] +layout_mode = 2 + +[node name="HSeparator2" type="HSeparator" parent="MarginContainer/VBoxContainer" unique_id=2024560039] +layout_mode = 2 + +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer" unique_id=2017763907] +layout_mode = 2 +text = "ROOM DEVICES" + +[node name="MainViews" type="TabContainer" parent="MarginContainer/VBoxContainer" unique_id=2035710526] +propagate_maximum_size = false +layout_mode = 2 +size_flags_vertical = 3 +current_tab = 1 + +[node name="Physical" type="Control" parent="MarginContainer/VBoxContainer/MainViews" unique_id=1709144739] +visible = false +layout_mode = 2 +metadata/_tab_index = 0 + +[node name="LayoutWorkspace" type="HBoxContainer" parent="MarginContainer/VBoxContainer/MainViews/Physical" unique_id=165485620] +propagate_maximum_size = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_bottom = 61.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="RoomLayoutPanel" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace" unique_id=1890208727] +custom_minimum_size = Vector2(0, 300) +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="RoomLayout" type="Control" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/RoomLayoutPanel" unique_id=1755582596] +layout_mode = 2 +script = ExtResource("6_dicmx") +item_scene = ExtResource("7_ncki6") + +[node name="PropertyPanel" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace" unique_id=1130312040] +custom_minimum_size = Vector2(300, 0) +custom_maximum_size = Vector2(300, -1) +layout_mode = 2 + +[node name="MarginContainer" type="MarginContainer" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel" unique_id=1503925230] +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer" unique_id=1844850543] +layout_mode = 2 + +[node name="PropertiesTitle" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1868518001] +layout_mode = 2 + +[node name="SelectedLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=425349153] +layout_mode = 2 + +[node name="HSeparator" type="HSeparator" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=540873004] +layout_mode = 2 + +[node name="XLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1395648922] +layout_mode = 2 + +[node name="XSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=212457245] +layout_mode = 2 +min_value = -2000.0 +max_value = 5000.0 + +[node name="YLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=449512311] +layout_mode = 2 + +[node name="YSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=539657259] +layout_mode = 2 +min_value = -2000.0 +max_value = 5000.0 + +[node name="RotationLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1558308543] +layout_mode = 2 + +[node name="RotationSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=300843311] +layout_mode = 2 +min_value = -360.0 +max_value = 360.0 + +[node name="SnapCheckBox" type="CheckBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=83220102] +layout_mode = 2 +button_pressed = true +text = "Snap to Grid" + +[node name="GridLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1014679681] +layout_mode = 2 + +[node name="GridSpinBox" type="SpinBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1237291196] +layout_mode = 2 +min_value = 1.0 +max_value = 200.0 +value = 1.0 + +[node name="HSeparator2" type="HSeparator" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=200431526] +layout_mode = 2 + +[node name="DirtyLabel" type="Label" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1190553142] +layout_mode = 2 + +[node name="SaveButton" type="Button" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=839924948] +layout_mode = 2 +text = "SAVE ROOM" + +[node name="RevertButton" type="Button" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=1355534065] +layout_mode = 2 +text = "REVERT" + +[node name="ShowConnectionsCheckBox" type="CheckBox" parent="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer" unique_id=309406547] +layout_mode = 2 +button_pressed = true +text = "Show Connections" + +[node name="Logic" type="Control" parent="MarginContainer/VBoxContainer/MainViews" unique_id=1771194381] +layout_mode = 2 +metadata/_tab_index = 1 + +[node name="AutoArrangeButton" type="Button" parent="MarginContainer/VBoxContainer/MainViews/Logic" unique_id=621962605] +layout_mode = 0 +offset_left = 1786.0 +offset_top = -29.0 +offset_right = 1917.0 +offset_bottom = 2.0 +text = "AUTO ARRANGE" + +[node name="LogicGraph" type="GraphEdit" parent="MarginContainer/VBoxContainer/MainViews/Logic" unique_id=40506474] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +scroll_offset = Vector2(-40, -40) +script = ExtResource("8_gu74o") + +[node name="DeviceList" type="VBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=1469097889] +custom_minimum_size = Vector2(0, 150) +layout_mode = 2 + +[node name="HSeparator3" type="HSeparator" parent="MarginContainer/VBoxContainer" unique_id=1833639349] +layout_mode = 2 + +[node name="ValidationOutput" type="RichTextLabel" parent="MarginContainer/VBoxContainer" unique_id=287181262] +custom_minimum_size = Vector2(0, 100) +custom_maximum_size = Vector2(-1, 100) +layout_mode = 2 + +[connection signal="pressed" from="MarginContainer/VBoxContainer/ActionBar/LoadButton" to="." method="_on_load_button_pressed"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/ActionBar/ValidateButton" to="." method="_on_validate_button_pressed"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/ActionBar/PrepButton" to="." method="_on_prep_button_pressed"] +[connection signal="value_changed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/XSpinBox" to="." method="_on_x_spinbox_value_changed"] +[connection signal="value_changed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/YSpinBox" to="." method="_on_y_spinbox_value_changed"] +[connection signal="value_changed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/RotationSpinBox" to="." method="_on_rotation_spinbox_value_changed"] +[connection signal="toggled" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/SnapCheckBox" to="." method="_on_snap_check_box_toggled"] +[connection signal="value_changed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/GridSpinBox" to="." method="_on_grid_spinbox_value_changed"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/SaveButton" to="." method="_on_save_button_pressed"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/RevertButton" to="." method="_on_revert_button_pressed"] +[connection signal="toggled" from="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/PropertyPanel/MarginContainer/VBoxContainer/ShowConnectionsCheckBox" to="MarginContainer/VBoxContainer/MainViews/Physical/LayoutWorkspace/RoomLayoutPanel/RoomLayout" method="_on_show_connections_check_box_toggled"] +[connection signal="pressed" from="MarginContainer/VBoxContainer/MainViews/Logic/AutoArrangeButton" to="." method="_on_auto_arrange_button_pressed"] diff --git a/operator/OperatorConsole.tscn739617758.tmp b/operator/OperatorConsole.tscn739617758.tmp new file mode 100644 index 0000000..ecbae12 --- /dev/null +++ b/operator/OperatorConsole.tscn739617758.tmp @@ -0,0 +1,28 @@ +[gd_scene format=3 uid="uid://duvm1qwhoqq2t"] + +[ext_resource type="Script" uid="uid://c5k1co6ww5flo" path="res://operator/operator_console.gd" id="1_k8li0"] +[ext_resource type="Script" uid="uid://dbe24sbmbh6tt" path="res://core/runtime/room_controller.gd" id="1_vinxr"] +[ext_resource type="Resource" uid="uid://8qqlypjp3hd4" path="res://data/project_database.tres" id="2_qhjp7"] +[ext_resource type="Resource" uid="uid://dgc35ind56yxm" path="res://data/rooms/arcade_room.tres" id="3_prnev"] +[ext_resource type="PackedScene" uid="uid://d0c4psurpl2f2" path="res://editor/workspace/EscapeRoomWorkspace.tscn" id="5_prnev"] + +[node name="OperatorConsole" type="Control" unique_id=1959910901] +propagate_maximum_size = true +clip_contents = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_k8li0") +database = ExtResource("2_qhjp7") +room = ExtResource("3_prnev") + +[node name="RoomController" type="Node" parent="." unique_id=314194706] +script = ExtResource("1_vinxr") + +[node name="OperatorControls" type="Node" parent="." unique_id=475021737] + +[node name="EscapeRoomWorkspace" parent="." unique_id=1163239148 instance=ExtResource("5_prnev")] +layout_mode = 1 diff --git a/operator/arcade_room_operator.gd b/operator/arcade_room_operator.gd new file mode 100644 index 0000000..6f56e3e --- /dev/null +++ b/operator/arcade_room_operator.gd @@ -0,0 +1,103 @@ +extends Control +var connection: Node +var busy := false +var status: Label +var history: RichTextLabel +var prepare: Button +var start: Button +var reset: Button +var message: Label +var confirm: ConfirmationDialog + +func _ready() -> void: + connection = preload("res://core/network/room_connection.gd").new() + add_child(connection) + connection.configure("operator") + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(center) + var column := VBoxContainer.new() + column.custom_minimum_size = Vector2(1050, 0) + column.add_theme_constant_override("separation", 18) + center.add_child(column) + var title := Label.new() + title.text = "ARCADE ROOM CONTROL" + title.add_theme_font_size_override("font_size", 42) + column.add_child(title) + status = Label.new() + status.add_theme_font_size_override("font_size", 25) + column.add_child(status) + var row := HBoxContainer.new() + column.add_child(row) + prepare = add_button(row, "Prepare Pac-Man", func(): command("prepare")) + start = add_button(row, "Start Game", func(): command("start")) + reset = add_button(row, "Reset Cabinet…", func(): confirm.popup_centered()) + confirm = ConfirmationDialog.new() + confirm.dialog_text = "Unload the current game and reset this cabinet's room session?" + confirm.confirmed.connect(func(): command("reset")) + add_child(confirm) + message = Label.new() + message.text = "Cabinet 01 quick controls • Open the controller URL in a browser for all cabinets" + column.add_child(message) + history = RichTextLabel.new() + history.custom_minimum_size = Vector2(1050, 380) + history.add_theme_font_size_override("normal_font_size", 20) + column.add_child(history) + var hint := Label.new() + hint.text = "Accepted GPIO output does not confirm physical coin delivery.\nClosing this window leaves the room controller running. Escape closes this app." + column.add_child(hint) + set_buttons(false, false, false) + var timer := Timer.new() + timer.wait_time = 1.0 + timer.timeout.connect(refresh) + add_child(timer) + timer.start() + refresh() + +func add_button(parent: Node, text: String, callback: Callable) -> Button: + var button := Button.new() + button.text = text + button.add_theme_font_size_override("font_size", 24) + button.pressed.connect(callback) + parent.add_child(button) + return button + +func set_buttons(can_prepare: bool, can_start: bool, can_reset: bool) -> void: + prepare.disabled = not can_prepare + start.disabled = not can_start + reset.disabled = not can_reset + +func display(state: Dictionary) -> void: + if state.has("error"): + status.text = "CONTROLLER UNAVAILABLE\n" + str(state.error) + set_buttons(false, false, false) + return + var pending := false + for item in state.commands: + if item.status == "queued": pending = true + var available: bool = state.online and not pending + set_buttons(available and state.phase in ["idle", "won", "lost", "needs_prepare"], available and state.phase == "ready", available) + status.text = "Cabinet 01: %s | %s\nGame: %s | GPIO mode: %s | Agent: %s\nReward: %s" % ["ONLINE" if state.online else "OFFLINE", str(state.phase).to_upper(), state.get("game", "pacman"), state.gpio, state.version, state.reward] + history.text = "EVENT HISTORY\n" + for item in state.history: + history.text += "%s %s\n" % [Time.get_datetime_string_from_unix_time(int(item.time)).replace("T", " ") + " UTC", item.message] + history.scroll_to_line(history.get_line_count()) + +func refresh() -> void: + if busy: return + busy = true + var state: Dictionary = await connection.post("/operator/state") + busy = false + display(state) + +func command(action: String) -> void: + if busy: return + busy = true + set_buttons(false, false, false) + var payload := {"action": action, "cabinet": "cabinet_01"} + if action == "prepare": payload = {"action": "prepare_scenario", "scenario": "proof_of_concept"} + var state: Dictionary = await connection.post("/operator/command", payload) + busy = false + if state.has("error"): message.text = str(state.error) + else: message.text = "Command queued; waiting for cabinet acknowledgement" + display(state) diff --git a/operator/arcade_room_operator.gd.uid b/operator/arcade_room_operator.gd.uid new file mode 100644 index 0000000..47aec7d --- /dev/null +++ b/operator/arcade_room_operator.gd.uid @@ -0,0 +1 @@ +uid://dxiyqt7t5kys3 diff --git a/operator/operator_console.gd b/operator/operator_console.gd new file mode 100644 index 0000000..842eedc --- /dev/null +++ b/operator/operator_console.gd @@ -0,0 +1,355 @@ +extends Control + + +@export_group("Project") +@export var database: ProjectDatabase +@export var room: RoomDefinition + + +@export_group("Runtime Defaults") +@export var default_scenario_id: StringName = &"two_cabinet_test" + + +@onready var room_controller: RoomController = ( + $RoomController +) + +@onready var editor_workspace: EscapeRoomWorkspace = ( + $MarginContainer/VBoxContainer/ + EditorArea/EscapeRoomWorkspace +) + +@onready var runtime_scenario_select: OptionButton = ( + $MarginContainer/VBoxContainer/ + OperatorBar/RuntimeScenarioSelect +) + +@onready var room_status_label: Label = ( + $MarginContainer/VBoxContainer/ + OperatorBar/RoomStatusLabel +) + +@onready var cabinet_status_container: VBoxContainer = ( + $MarginContainer/VBoxContainer/ + CabinetStatusContainer +) + + +var runtime_scenario_ids: Array[StringName] = [] +var cabinet_status_labels: Dictionary = {} + + +func _ready() -> void: + if database == null: + push_error( + "OperatorConsole has no ProjectDatabase assigned." + ) + return + + if room == null: + push_error( + "OperatorConsole has no RoomDefinition assigned." + ) + return + + room_controller.configure( + database, + room + ) + + _connect_room_controller_signals() + + _setup_editor_workspace() + + _populate_runtime_scenarios() + + +func _connect_room_controller_signals() -> void: + room_controller.scenario_loaded.connect( + _on_runtime_scenario_loaded + ) + + room_controller.validation_completed.connect( + _on_runtime_validation_completed + ) + + room_controller.cabinet_status_changed.connect( + _on_cabinet_status_changed + ) + + room_controller.room_status_changed.connect( + _on_room_status_changed + ) + + +func _setup_editor_workspace() -> void: + if editor_workspace == null: + return + + if not editor_workspace.configure( + database, + room + ): + push_error( + "Failed to configure EscapeRoomWorkspace." + ) + + +func _populate_runtime_scenarios() -> void: + runtime_scenario_select.clear() + runtime_scenario_ids.clear() + + for scenario in database.scenarios: + if scenario == null: + continue + + runtime_scenario_select.add_item( + scenario.display_name + ) + + runtime_scenario_ids.append( + scenario.scenario_id + ) + + if runtime_scenario_ids.is_empty(): + room_status_label.text = ( + "ROOM STATUS: NO SCENARIOS" + ) + return + + var default_index := 0 + + for index in range( + runtime_scenario_ids.size() + ): + if ( + runtime_scenario_ids[index] + == default_scenario_id + ): + default_index = index + break + + runtime_scenario_select.select( + default_index + ) + + _load_selected_runtime_scenario() + + +func _load_selected_runtime_scenario() -> void: + var index := runtime_scenario_select.selected + + if ( + index < 0 + or + index >= runtime_scenario_ids.size() + ): + return + + var scenario_id := ( + runtime_scenario_ids[index] + ) + + room_controller.load_scenario( + scenario_id + ) + + +func _on_runtime_scenario_select_item_selected( + _index: int +) -> void: + _load_selected_runtime_scenario() + + +func _on_validate_room_button_pressed() -> void: + room_controller.validate_active_scenario() + + +func _on_prep_room_button_pressed() -> void: + room_controller.prep_room() + + +func _on_runtime_scenario_loaded( + scenario: ScenarioDefinition +) -> void: + _rebuild_cabinet_status_list( + scenario + ) + + room_status_label.text = ( + "ROOM STATUS: LOADED — %s v%d" + % [ + scenario.display_name, + scenario.version + ] + ) + + +func _rebuild_cabinet_status_list( + scenario: ScenarioDefinition +) -> void: + for child in cabinet_status_container.get_children(): + child.queue_free() + + cabinet_status_labels.clear() + + for assignment in scenario.assignments: + if assignment == null: + continue + + _add_cabinet_status_row( + assignment + ) + + +func _add_cabinet_status_row( + assignment: ScenarioAssignment +) -> void: + var panel := PanelContainer.new() + var margin := MarginContainer.new() + var row := HBoxContainer.new() + var details := VBoxContainer.new() + + var cabinet_label := Label.new() + var game_label := Label.new() + var variant_label := Label.new() + var status_label := Label.new() + + if assignment.cabinet != null: + cabinet_label.text = ( + assignment.cabinet.display_name + ) + else: + cabinet_label.text = ( + "MISSING CABINET" + ) + + if assignment.game != null: + game_label.text = ( + "Game: %s" + % assignment.game.display_name + ) + else: + game_label.text = ( + "Game: MISSING" + ) + + variant_label.text = ( + "Variant: %s" + % assignment.variant + ) + + status_label.text = ( + "NOT PREPARED" + ) + + status_label.horizontal_alignment = ( + HORIZONTAL_ALIGNMENT_RIGHT + ) + + status_label.custom_minimum_size.x = ( + 160.0 + ) + + details.add_child( + cabinet_label + ) + + details.add_child( + game_label + ) + + details.add_child( + variant_label + ) + + var spacer := Control.new() + + spacer.size_flags_horizontal = ( + Control.SIZE_EXPAND_FILL + ) + + row.add_child( + details + ) + + row.add_child( + spacer + ) + + row.add_child( + status_label + ) + + margin.add_child( + row + ) + + panel.add_child( + margin + ) + + cabinet_status_container.add_child( + panel + ) + + if assignment.cabinet != null: + cabinet_status_labels[ + assignment.cabinet.cabinet_id + ] = status_label + + +func _on_runtime_validation_completed( + result: ValidationResult +) -> void: + if result.is_valid(): + room_status_label.text = ( + "ROOM STATUS: %s" + % result.get_summary() + ) + else: + room_status_label.text = ( + "ROOM STATUS: INVALID — %s" + % result.get_summary() + ) + + for error in result.errors: + print( + "[RUNTIME VALIDATION] ERROR: ", + error + ) + + for warning in result.warnings: + print( + "[RUNTIME VALIDATION] WARNING: ", + warning + ) + + +func _on_cabinet_status_changed( + cabinet_id: StringName, + status: StringName +) -> void: + if not cabinet_status_labels.has( + cabinet_id + ): + return + + var label: Label = ( + cabinet_status_labels[ + cabinet_id + ] + ) + + label.text = String( + status + ) + + +func _on_room_status_changed( + status: StringName +) -> void: + room_status_label.text = ( + "ROOM STATUS: %s" + % status + ) diff --git a/operator/operator_console.gd.uid b/operator/operator_console.gd.uid new file mode 100644 index 0000000..8e70152 --- /dev/null +++ b/operator/operator_console.gd.uid @@ -0,0 +1 @@ +uid://c5k1co6ww5flo diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..299e2e8 --- /dev/null +++ b/project.godot @@ -0,0 +1,171 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="ArcadeEscape" +run/main_scene="uid://du56pbkblp1bx" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[autoload] + +ArcadeControls="*res://core/games/arcade_controls.gd" +HardwareManager="*uid://5d4k46jwlo4y" +RoomRuntime="*uid://eelj57byt0su" + +[display] + +window/size/viewport_width=1920 +window/size/viewport_height=1080 +window/size/mode=3 +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" + +[editor] + +run/main_run_args="-- --scenario=two_cabinet_test --cabinet=cabinet_02" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/escape_room/plugin.cfg") + +[input] + +arcade_left={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null) +] +} +arcade_right={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null) +] +} +arcade_up={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null) +] +} +arcade_down={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null) +] +} +arcade_action_1={ +"deadzone": 0.2, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":true,"script":null) +] +} +arcade_start={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":9,"pressure":0.0,"pressed":true,"script":null) +] +} +arcade_action_2={ +"deadzone": 0.35, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":90,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":4,"pressure":0.0,"pressed":true,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":3,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +arcade_action_3={ +"deadzone": 0.35, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":5,"pressure":0.0,"pressed":true,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +arcade_menu={ +"deadzone": 0.35, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":8,"pressure":0.0,"pressed":true,"script":null) +] +} +arcade_rotate_left={ +"deadzone": 0.35, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +arcade_rotate_right={ +"deadzone": 0.35, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +robotron_move_left={ +"deadzone": 0.35, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null) +] +} +robotron_move_right={ +"deadzone": 0.35, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null) +] +} +robotron_move_up={ +"deadzone": 0.35, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null) +] +} +robotron_move_down={ +"deadzone": 0.35, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null) +] +} +robotron_fire_left={ +"deadzone": 0.35, +"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":true,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":-1.0,"script":null) +] +} +robotron_fire_right={ +"deadzone": 0.35, +"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":true,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":1.0,"script":null) +] +} +robotron_fire_up={ +"deadzone": 0.35, +"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":true,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":-1.0,"script":null) +] +} +robotron_fire_down={ +"deadzone": 0.35, +"events": [Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":4,"pressure":0.0,"pressed":true,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":1.0,"script":null) +] +} + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +rendering_device/driver.windows="d3d12" +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +environment/defaults/default_clear_color=Color(0, 0, 0, 1) diff --git a/tests/check_attract.gd b/tests/check_attract.gd new file mode 100644 index 0000000..4738310 --- /dev/null +++ b/tests/check_attract.gd @@ -0,0 +1,43 @@ +extends SceneTree +var starts := 0 +var wins := 0 +var losses := 0 +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var g = load("res://games/frogger/Frogger.tscn").instantiate() + root.add_child(g) + g.set_physics_process(false) + g.music_enabled = false + g.game_started.connect(func(): starts += 1) + g.game_won.connect(func(): wins += 1) + g.game_lost.connect(func(): losses += 1) + g.high_score = 12345 + g.score = 1230 + for cycle in range(2): + g._physics_process(44) + assert(g.demo_active and not g.is_running) + var highest := 232.0 + var arrivals := 0 + for frame in range(1921): + g._physics_process(1.0/60) + highest = minf(highest,g.frog.y) + arrivals = maxi(arrivals,g.homes.count(true)) + assert(not g.demo_active and g.phase == "scores") + assert(g.score == 1230 and g.high_score == 12345 and g.leaderboard.is_empty()) + assert(starts == 0 and wins == 0 and losses == 0) + assert(highest <= 104, "Demo never reached river") + print("Demo cycle ",cycle," highest y=",highest," homes=",arrivals) + g._physics_process(7.1) + assert(g.phase == "title" and g.attract_time == 0) + for state in ["title","scores","demo"]: + g.phase = state + if state == "demo": g.begin_demo() + assert(g.start_game()) + assert(not g.demo_active and g.phase == "ready" and g.score == 0) + assert(g.lives == g.starting_lives and not g.homes.has(true)) + g.reset_game() + assert(starts == 3 and wins == 0 and losses == 0) + print("Attract isolation, cycling, interruption: PASS") + g.free() + quit() diff --git a/tests/check_attract.gd.uid b/tests/check_attract.gd.uid new file mode 100644 index 0000000..c49282e --- /dev/null +++ b/tests/check_attract.gd.uid @@ -0,0 +1 @@ +uid://b2xuro2dta84x diff --git a/tests/check_frogger.gd b/tests/check_frogger.gd new file mode 100644 index 0000000..ac32810 --- /dev/null +++ b/tests/check_frogger.gd @@ -0,0 +1,63 @@ +extends SceneTree +var failures := 0 +func check(value: bool, message: String) -> void: + if not value: + failures += 1 + push_error(message) +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var g = load("res://games/frogger/Frogger.tscn").instantiate() + root.add_child(g) + g.set_physics_process(false) + g.music_enabled = false + check(g.start_game(),"Start rejected") + check(not g.start_game(),"Duplicate start accepted") + g._physics_process(1.6) + check(g.phase == "play","Ready transition") + g.lanes.clear() + check(g.hop(Vector2i.UP),"Hop rejected") + check(not g.hop(Vector2i.RIGHT),"Overlapping hop accepted") + g.step_world(.12) + check(g.frog == Vector2(112,216) and g.score == 10,"Hop distance/score") + g.make_lanes() + g.frog = Vector2(25,216) + g.step_world(0) + check(g.phase == "dead" and g.lives == 2,"Road collision") + g._physics_process(1.2) + check(g.phase == "play" and g.frog == Vector2(112,232),"Respawn") + g.frog = Vector2(50,104) + g.step_world(.01) + check(g.phase == "play" and g.frog.x > 50,"Log carry") + g.frog = Vector2(90,104) + g.step_world(0) + check(g.phase == "dead" and g.death_kind == "plunk","Water collision") + g._physics_process(1.2) + g.clock = 6 + g.frog = Vector2(24,72) + g.step_world(0) + check(g.phase == "dead","Submerged turtle collision") + g._physics_process(1.2) + check(g.phase == "lost" and not g.is_running,"Lose event state") + g.start_game() + g.phase = "play" + for i in range(5): + g.frog = Vector2(g.HOME_X[i],40) + g.step_world(0) + check(g.homes[i] and g.phase == "home","Home entry") + g._physics_process(.9) + check(g.phase == "clear","Five homes transition") + g._physics_process(2.1) + check(g.phase == "won" and not g.is_running,"Win state") + g.start_game() + g.boards_to_win = 0 + g.phase = "clear" + g.phase_time = 0 + g._physics_process(.01) + check(g.board_number == 2 and g.phase == "ready","Endless progression") + g.reset_game() + check(g.phase == "title" and not g.is_running and not g.homes.has(true),"Reset") + print("Frogger checks: ",failures," failures") + g.queue_free() + await process_frame + quit(failures) diff --git a/tests/check_frogger.gd.uid b/tests/check_frogger.gd.uid new file mode 100644 index 0000000..813dce5 --- /dev/null +++ b/tests/check_frogger.gd.uid @@ -0,0 +1 @@ +uid://1hmcrcrsvulb diff --git a/tests/gpio/.gitignore b/tests/gpio/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/tests/gpio/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/tests/gpio/check_gpio.gd b/tests/gpio/check_gpio.gd new file mode 100644 index 0000000..294ff1f --- /dev/null +++ b/tests/gpio/check_gpio.gd @@ -0,0 +1,25 @@ +extends SceneTree +var activated := false +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var hardware = root.get_node("HardwareManager") + hardware.action_activated.connect(func(_cabinet, _action, _context): activated = true) + var launcher = load("res://games/launcher/ArcadeLauncher.tscn").instantiate() + root.add_child(launcher) + await process_frame + await process_frame + if is_instance_valid(launcher.setup): + launcher.setup.close() + await process_frame + launcher._select(0) + launcher.launch() + launcher.game.start_game() + launcher.game.win_game() + await create_timer(1.0).timeout + if not activated: + push_error("Pac-Man win did not reach GPIO bridge: " + hardware.status) + quit(1) + else: + print("Pac-Man win reached GPIO bridge") + quit() diff --git a/tests/gpio/check_gpio.gd.uid b/tests/gpio/check_gpio.gd.uid new file mode 100644 index 0000000..9329b39 --- /dev/null +++ b/tests/gpio/check_gpio.gd.uid @@ -0,0 +1 @@ +uid://cohmnr1oklto5 diff --git a/tests/gpio/check_integration.py b/tests/gpio/check_integration.py new file mode 100644 index 0000000..fc815db --- /dev/null +++ b/tests/gpio/check_integration.py @@ -0,0 +1,28 @@ +"""Run with the Godot console executable as the first argument. No physical GPIO.""" +import subprocess +import sys +import threading +from pathlib import Path +from http.server import HTTPServer +from test_bridge import FakePin, bridge + +outputs = bridge.Outputs(FakePin, "cabinet_01_gpio", [17]) +server = HTTPServer(("127.0.0.1", 8765), bridge.handler_for(outputs)) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() +try: + root = Path(__file__).resolve().parents[2] + result = subprocess.run([ + sys.argv[1], "--headless", "--path", str(root), + "--log-file", str(root / ".godot/gpio_integration.log"), + "--script", "tests/gpio/check_gpio.gd", "--", "--gpio=real", + ], timeout=15) + assert result.returncode == 0, "Godot integration failed" + assert 17 in outputs.pins, "Bridge never received BCM17" + assert outputs.pins[17]["polarity"] is True, "Expected active-high output" + print("Godot -> HTTP -> GPIO backend integration passed (fake pin)") +finally: + server.shutdown() + thread.join() + outputs.close() + server.server_close() diff --git a/tests/gpio/test_bridge.py b/tests/gpio/test_bridge.py new file mode 100644 index 0000000..53d90b3 --- /dev/null +++ b/tests/gpio/test_bridge.py @@ -0,0 +1,105 @@ +import importlib.util +from pathlib import Path +import unittest +import tempfile + +source = Path(__file__).resolve().parents[2] / "tools/pi_gpio/pi_gpio_bridge.py" +spec = importlib.util.spec_from_file_location("bridge", source) +bridge = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bridge) + + +class FakePin: + def __init__(self, pin, active_high, initial_value): + self.pin, self.active_high, self.active = pin, active_high, initial_value + self.closed = False + self.activations = 0 + + def on(self): + self.active = True + self.activations += 1 + def off(self): self.active = False + def close(self): self.closed = True + + +class BridgeTests(unittest.TestCase): + def setUp(self): + self.now = 0 + self.outputs = bridge.Outputs(FakePin, "cabinet_01_gpio", [17], lambda: self.now) + self.request = dict(controller="cabinet_01_gpio", pin=17, mode="pulse", active_high=True, duration_ms=600) + + def test_pulse_and_retrigger(self): + self.outputs.command(self.request) + pin = self.outputs.pins[17]["device"] + self.assertTrue(pin.active) + self.now = .4 + self.outputs.command(self.request) + self.now = .7 + self.outputs.tick() + self.assertTrue(pin.active) + self.now = 1.01 + self.outputs.tick() + self.assertFalse(pin.active) + + def test_active_low_and_shutdown(self): + self.outputs.command(dict(self.request, active_high=False)) + pin = self.outputs.pins[17]["device"] + self.assertFalse(pin.active_high) + self.assertTrue(pin.active) + self.outputs.close() + self.assertFalse(pin.active) + self.assertTrue(pin.closed) + + def test_digital_off_and_watchdog(self): + self.outputs.command(dict(self.request, mode="set")) + self.now = 2 + self.outputs.heartbeat() + self.now = 4 + self.outputs.tick() + self.assertTrue(self.outputs.pins[17]["device"].active) + self.now = 5.1 + self.outputs.tick() + self.assertFalse(self.outputs.pins[17]["device"].active) + self.outputs.command(dict(self.request, mode="set")) + self.outputs.command(dict(self.request, mode="set", active=False)) + self.assertFalse(self.outputs.pins[17]["device"].active) + + def test_reject_wrong_controller_pin_and_duration(self): + for changes in [dict(controller="cabinet_02_gpio"), dict(pin=18), dict(duration_ms=0), dict(duration_ms=10001), dict(active_high="false")]: + with self.assertRaises(ValueError): + self.outputs.command(dict(self.request, **changes)) + self.assertFalse(self.outputs.pins) + + def test_polarity_cannot_change_while_owned(self): + self.outputs.command(self.request) + with self.assertRaises(ValueError): + self.outputs.command(dict(self.request, active_high=False)) + + def test_reward_dedup_survives_restart(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "requests.sqlite3" + outputs = bridge.Outputs(FakePin, "cabinet_01_gpio", [17], ledger=path) + request = dict(self.request, request_id="reward-1") + self.assertTrue(outputs.command(request)["ok"]) + self.assertTrue(outputs.command(request)["ok"]) + self.assertEqual(outputs.pins[17]["device"].activations, 1) + outputs.close() + outputs = bridge.Outputs(FakePin, "cabinet_01_gpio", [17], ledger=path) + self.assertTrue(outputs.command(request)["ok"]) + self.assertFalse(outputs.pins, "Duplicate must not even reopen output") + with self.assertRaises(ValueError): + outputs.command(dict(request, duration_ms=900)) + outputs.close() + + def test_incomplete_reward_is_not_replayed(self): + request = dict(self.request, request_id="uncertain") + import hashlib, json + digest = hashlib.sha256(json.dumps(request, sort_keys=True).encode()).hexdigest() + with self.outputs.ledger: + self.outputs.ledger.execute("INSERT INTO requests VALUES (?, ?, NULL)", ("uncertain", digest)) + self.assertFalse(self.outputs.command(request)["ok"]) + self.assertFalse(self.outputs.pins) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/launcher/check_controls.gd b/tests/launcher/check_controls.gd new file mode 100644 index 0000000..c7f78bf --- /dev/null +++ b/tests/launcher/check_controls.gd @@ -0,0 +1,81 @@ +extends SceneTree +var failures := 0 +func _initialize() -> void: + call_deferred("run") +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + push_error(label) +func run() -> void: + var controls = root.get_node("ArcadeControls") + controls.path = "res://.godot/test_controls.cfg" + var pads := [{"id": 2, "guid": "one", "name": "Encoder A"}, {"id": 5, "guid": "two", "name": "Encoder B"}] + var press := InputEventJoypadButton.new() + press.device = 2 + press.button_index = 3 + press.pressed = true + var binding: Dictionary = controls.encode(press, pads) + var decoded: InputEvent = controls.decode(binding, pads) + check(decoded.device == 2, "Binding uses a specific device") + InputMap.action_erase_events("arcade_action_1") + InputMap.action_add_event("arcade_action_1", decoded) + check(InputMap.event_is_action(press, "arcade_action_1"), "Player one matches") + press.device = 5 + check(not InputMap.event_is_action(press, "arcade_action_1"), "Player two does not match the same button") + var reordered := [{"id": 0, "guid": "two", "name": "Encoder B"}, {"id": 4, "guid": "one", "name": "Encoder A"}] + check(controls.decode(binding, reordered).device == 4, "Identity survives changed device index") + check(controls.decode(binding, []) == null, "Missing device never falls back to all devices") + var twins := [{"id": 0, "guid": "same", "name": "USB"}, {"id": 1, "guid": "same", "name": "USB"}] + press.device = 1 + var twin: Dictionary = controls.encode(press, twins) + check(controls.decode(twin, twins).device == 1, "Identical encoders isolated by slot") + check(controls.decode(twin, [twins[1]]) == null, "Unplugged twin does not reassign inputs") + var draft := {} + for i in range(controls.STEPS.size()): + draft[controls.STEPS[i][0]] = {"kind": "key", "code": KEY_A + i, "logical": 0} + check(controls.save_profile(draft) == OK, "Save profile") + controls.bindings.clear() + check(controls.load_profile(), "Reload profile") + check(controls.bindings == draft, "Bindings survive reload") + check(InputMap.action_get_events("arcade_up")[0].physical_keycode == KEY_C, "Saved override applied") + check(InputMap.action_get_events("robotron_move_up")[0].physical_keycode == KEY_C, "Robotron reuses player one movement") + draft.arcade_start.code = KEY_P + check(controls.save_profile(draft) == OK and controls.load_profile(), "Replace existing profile") + check(controls.bindings.arcade_start.code == KEY_P, "Replacement persists") + var before: Dictionary = controls.bindings.duplicate(true) + controls.path = "res://.godot/missing_directory/controls.cfg" + check(controls.save_profile(draft) != OK and controls.bindings == before, "Failed save leaves active bindings intact") + check(not controls.valid({"arcade_start": {"kind": "axis"}}), "Incomplete profile rejected") + var setup = load("res://games/launcher/control_setup.gd").new() + root.add_child(setup) + setup.begin() + setup.armed_at = 0 + var key := InputEventKey.new() + key.physical_keycode = KEY_ENTER + key.pressed = true + setup._input(key) + check(setup.step == 0 and setup.waiting != null, "Capture waits for release") + setup._input(key) + check(setup.step == 0, "Held input cannot fill next step") + key.pressed = false + setup._input(key) + check(setup.step == 1 and setup.waiting == null, "Release advances wizard") + check(not setup.conflict("arcade_menu", setup.draft.arcade_start).is_empty(), "Duplicate Start/Select rejected") + setup.draft.arcade_action_1 = {"kind": "key", "code": KEY_SPACE, "logical": 0} + check(setup.conflict("robotron_fire_up", setup.draft.arcade_action_1).is_empty(), "Game fire button can be reused for Robotron") + setup.begin() + for i in range(controls.STEPS.size()): + setup.armed_at = 0 + key.physical_keycode = KEY_A + i + key.pressed = true + setup._input(key) + key.pressed = false + setup._input(key) + check(setup.step == controls.STEPS.size() and setup.draft.size() == 13, "Complete wizard reaches review") + check(controls.bindings == before, "Draft does not change live bindings") + controls.path = "res://.godot/test_controls.cfg" + setup.save() + await process_frame + check(controls.load_profile() and controls.bindings.arcade_start.code == KEY_A, "Wizard save survives restart") + print("Control setup checks: %d failures" % failures) + quit(1 if failures else 0) diff --git a/tests/launcher/check_controls.gd.uid b/tests/launcher/check_controls.gd.uid new file mode 100644 index 0000000..c3d867f --- /dev/null +++ b/tests/launcher/check_controls.gd.uid @@ -0,0 +1 @@ +uid://swmu6kexenhl diff --git a/tests/launcher/check_launcher.gd b/tests/launcher/check_launcher.gd new file mode 100644 index 0000000..1927313 --- /dev/null +++ b/tests/launcher/check_launcher.gd @@ -0,0 +1,61 @@ +extends SceneTree +var failures := 0 +func _initialize() -> void: + call_deferred("run") +func check(ok: bool, label: String) -> void: + if not ok: + failures += 1 + push_error(label) +func run() -> void: + var launcher = load("res://games/launcher/ArcadeLauncher.tscn").instantiate() + root.add_child(launcher) + await process_frame + if is_instance_valid(launcher.setup): + launcher.setup.close() + await process_frame + check(launcher.list.item_count == 8, "Seven games and control setup listed") + launcher._select(-1) + check(launcher.selected == 7, "Menu wraps to setup") + for i in range(7): + launcher._select(i) + launcher.launch() + check(launcher.game is ArcadeGame, "Game loads: %s" % i) + check(not launcher.menu.visible, "Menu hides") + check(launcher.game.start_game(), "Game starts: %s" % i) + await process_frame + launcher.return_to_menu() + await process_frame + check(launcher.game == null and launcher.menu.visible, "Game unloads: %s" % i) + # Rebind a battery to a different key, then verify the former key no longer fires. + InputMap.action_erase_events("arcade_action_2") + var key := InputEventKey.new() + key.physical_keycode = KEY_F9 + InputMap.action_add_event("arcade_action_2", key) + launcher._select(3) + launcher.launch() + var missile = launcher.game + missile.start_game() + missile.phase = "play" + var old := InputEventKey.new() + old.physical_keycode = KEY_S + old.pressed = true + missile._unhandled_input(old) + check(missile.ammo[1] == 10, "Old hard-coded key does not fire") + key.pressed = true + missile._unhandled_input(key) + check(missile.ammo[1] == 9, "Remapped key fires middle battery") + launcher.return_to_menu() + await process_frame + launcher._select(6) + launcher.launch() + Input.action_press("robotron_fire_up") + Input.action_press("robotron_fire_right") + var direction: Vector2 = launcher.game.control_vector("fire_") + check(direction.x > 0.6 and direction.y < -0.6, "Two fire buttons produce diagonal") + Input.action_release("robotron_fire_up") + Input.action_release("robotron_fire_right") + launcher.return_to_menu() + launcher.queue_free() + await process_frame + print("Launcher/input checks: %d failures" % failures) + quit(1 if failures else 0) diff --git a/tests/launcher/check_launcher.gd.uid b/tests/launcher/check_launcher.gd.uid new file mode 100644 index 0000000..fd13651 --- /dev/null +++ b/tests/launcher/check_launcher.gd.uid @@ -0,0 +1 @@ +uid://bb1cbfxyd5pif diff --git a/tests/robotron/.gitignore b/tests/robotron/.gitignore new file mode 100644 index 0000000..ac535c7 --- /dev/null +++ b/tests/robotron/.gitignore @@ -0,0 +1,3 @@ +*.log +*.png +*.png.import diff --git a/tests/robotron/benchmark.gd b/tests/robotron/benchmark.gd new file mode 100644 index 0000000..f24e551 --- /dev/null +++ b/tests/robotron/benchmark.gd @@ -0,0 +1,30 @@ +extends SceneTree +func _initialize() -> void: + call_deferred("run") +func run() -> void: + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) + root.size=Vector2i(960,720) + root.content_scale_size=Vector2i(960,720) + var g=load("res://games/robotron/Robotron.tscn").instantiate() + root.add_child(g) + g.set_physics_process(false) + g.sound_enabled=false + g.demo=true + g.wave=20 + g.setup_wave() + g.phase="play" + for i in range(30): await process_frame + var started := Time.get_ticks_usec() + var peak := 0 + for i in range(360): + g.invulnerable=10 + g.step_world(1.0/60,Vector2.ZERO,Vector2.from_angle(i*.04)) + g.clock+=1.0/60 + g.board.queue_redraw() + peak=maxi(peak,g.entities.size()) + await process_frame + await RenderingServer.frame_post_draw + var elapsed := (Time.get_ticks_usec()-started)/1000000.0 + print("360 dense-wave frames; peak entities ",peak,"; ",snappedf(elapsed,.001)," seconds; ",snappedf(360/elapsed,1)," FPS desktop uncapped") + g.free() + quit() diff --git a/tests/robotron/benchmark.gd.uid b/tests/robotron/benchmark.gd.uid new file mode 100644 index 0000000..03fab37 --- /dev/null +++ b/tests/robotron/benchmark.gd.uid @@ -0,0 +1 @@ +uid://gptkt4d8d3sy diff --git a/tests/robotron/check_robotron.gd b/tests/robotron/check_robotron.gd new file mode 100644 index 0000000..3bfb301 --- /dev/null +++ b/tests/robotron/check_robotron.gd @@ -0,0 +1,141 @@ +extends SceneTree +var failures := 0 +var started := 0 +var won := 0 +var lost := 0 +func check(value: bool, message: String) -> void: + if not value: + failures += 1 + push_error(message) +func _initialize() -> void: + call_deferred("run") +func clean(g) -> void: + g.entities.clear() + g.shots.clear() + g.enemy_shots.clear() + g.effects.clear() + g.phase="play" + g.fire_delay=0 + g.invulnerable=10 + g.player=Vector2(146,124) +func run() -> void: + var g = load("res://games/robotron/Robotron.tscn").instantiate() + root.add_child(g) + g.set_physics_process(false) + g.sound_enabled=false + g.game_started.connect(func(): started+=1) + g.game_won.connect(func(): won+=1) + g.game_lost.connect(func(): lost+=1) + check(g.start_game() and not g.start_game(),"Start guard") + g._physics_process(2.1) + check(g.phase=="play" and started==1,"Ready event") + clean(g) + g.spawn("grunt",Vector2(260,210)) + g.step_world(.05,Vector2.LEFT,Vector2.UP) + check(g.player.x<146 and g.shots.size()==1 and g.shots[0].velocity.y<0,"Independent movement/fire") + check(is_equal_approx(g.eight_way(Vector2(1,1)).length(),1),"Normalized diagonal") + Input.action_press("robotron_move_left") + Input.action_press("robotron_move_up") + Input.action_press("robotron_fire_right") + check(g.control_vector("move_").x<0 and g.control_vector("move_").y<0 and g.control_vector("fire_").x>0,"Independent mapped sticks") + Input.action_release("robotron_move_left") + Input.action_release("robotron_move_up") + Input.action_release("robotron_fire_right") + check(g.control_vector("fire_")==Vector2.ZERO,"Release stops fire") + clean(g) + var human = g.spawn("human",Vector2(146,124)) + for i in range(6): + human.alive=true + g.save_human(human) + check(g.score==20000 and g.rescue_chain==5,"Escalating rescue chain") + var old_lives: int=g.lives + g.add_score(55000) + check(g.lives==old_lives+3,"Multiple bonus thresholds") + clean(g) + var hulk = g.spawn("hulk",Vector2(160,124)) + g.fire(Vector2.RIGHT) + g.update_shots(.1) + check(hulk.alive and hulk.position.x>160 and g.shots.is_empty(),"Hulk survives and is pushed") + clean(g) + human=g.spawn("human",Vector2(160,124)) + g.fire(Vector2.RIGHT) + g.update_shots(.1) + check(human.alive,"Player bullets spare humans") + clean(g) + var obstacle=g.spawn("electrode",Vector2(160,124)) + g.fire(Vector2.RIGHT) + g.update_shots(.1) + check(not obstacle.alive,"Electrode shot destruction") + clean(g) + g.spawn("hulk",Vector2(200,100)) + var doomed=g.spawn("human",Vector2(201,100)) + g.spawn("grunt",Vector2(270,210)) + g.step_world(.01,Vector2.ZERO,Vector2.ZERO) + check(not doomed.alive,"Hulk kills human") + clean(g) + var brain=g.spawn("brain",Vector2(170,124)) + var victim=g.spawn("human",Vector2(171,124)) + g.step_world(.01,Vector2.ZERO,Vector2.ZERO) + check(not victim.alive and g.entities.any(func(e): return e.kind=="converting"),"Brain captures human") + g.step_world(.8,Vector2.ZERO,Vector2.ZERO) + check(g.entities.any(func(e): return e.kind=="prog"),"Human transforms into Prog") + g.damage(brain) + g.damage(brain) + check(brain.alive,"Brain takes three hits") + g.damage(brain) + check(not brain.alive,"Brain third hit") + clean(g) + var spawner=g.spawn("spheroid",Vector2(230,100)) + spawner.timer=0 + g.step_world(.01,Vector2.ZERO,Vector2.ZERO) + check(g.entities.any(func(e): return e.kind=="enforcer"),"Spheroid creates enforcers") + clean(g) + spawner=g.spawn("quark",Vector2(230,100)) + spawner.timer=0 + g.step_world(.01,Vector2.ZERO,Vector2.ZERO) + check(g.entities.any(func(e): return e.kind=="tank"),"Quark creates tanks") + g.projectile("shell",Vector2(282,100),Vector2(300,100)) + g.update_enemy_shots(.1) + check(g.enemy_shots[0].velocity.x<0 and g.enemy_shots[0].bounces==1,"Tank shell bounce") + g.wave=5 + g.setup_wave() + check(g.entities.any(func(e): return e.kind=="brain"),"Brain wave") + g.wave=7 + g.setup_wave() + check(g.entities.any(func(e): return e.kind=="quark"),"Tank wave") + clean(g) + g.wave=1 + g.spawn("hulk",Vector2(250,100)) + g.spawn("human",Vector2(50,50)) + g.spawn("electrode",Vector2(20,200)) + g.step_world(.01,Vector2.ZERO,Vector2.ZERO) + check(g.phase=="transition","Only killable enemies gate wave") + g._physics_process(2) + check(won==1 and not g.is_running,"Room win") + g.start_game() + g.waves_to_win=0 + g.phase="transition" + g.phase_time=0 + g._physics_process(.01) + check(g.wave==2 and g.phase=="ready" and g.is_running,"Endless wave progression") + g.reset_game() + g.start_game() + g.phase="play" + g.lives=1 + g.die() + g._physics_process(1.3) + check(lost==1 and not g.is_running,"Room loss") + g.reset_game() + g.high_score=999999 + g.last_score=420 + var events := Vector3i(started,won,lost) + g.begin_demo() + for i in range(1600): g._physics_process(1.0/60) + check(not g.demo and not g.is_running and g.score==420 and g.high_score==999999,"Demo score isolation") + check(events==Vector3i(started,won,lost),"Demo event isolation") + g.begin_demo() + check(g.start_game() and not g.demo and g.score==0,"Start interrupts demo") + check(load("res://data/project_database.tres").get_game(&"robotron")!=null,"Database") + print("Robotron checks: ",failures," failures") + g.free() + quit(failures) diff --git a/tests/robotron/check_robotron.gd.uid b/tests/robotron/check_robotron.gd.uid new file mode 100644 index 0000000..a123ee4 --- /dev/null +++ b/tests/robotron/check_robotron.gd.uid @@ -0,0 +1 @@ +uid://by2ur06o802rn diff --git a/tests/robotron/render_preview.gd b/tests/robotron/render_preview.gd new file mode 100644 index 0000000..88866d8 --- /dev/null +++ b/tests/robotron/render_preview.gd @@ -0,0 +1,29 @@ +extends SceneTree +func _initialize() -> void: + call_deferred("run") +func run() -> void: + root.size=Vector2i(960,720) + root.content_scale_size=Vector2i(960,720) + var g=load("res://games/robotron/Robotron.tscn").instantiate() + root.add_child(g) + g.set_physics_process(false) + g.sound_enabled=false + for state in ["title","play","brain","roster"]: + g.phase_time=3 + g.phase=state + if state=="play": + g.wave=3 + g.setup_wave() + g.invulnerable=0 + if state=="brain": + g.wave=5 + g.setup_wave() + g.phase="play" + g.invulnerable=0 + g.board.queue_redraw() + await process_frame + await process_frame + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png("res://tests/robotron/"+state+".png") + g.free() + quit() diff --git a/tests/robotron/render_preview.gd.uid b/tests/robotron/render_preview.gd.uid new file mode 100644 index 0000000..608ecd9 --- /dev/null +++ b/tests/robotron/render_preview.gd.uid @@ -0,0 +1 @@ +uid://b8t02quve6wu1 diff --git a/tests/room_network/.gitignore b/tests/room_network/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/tests/room_network/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/tests/room_network/check_capabilities.gd b/tests/room_network/check_capabilities.gd new file mode 100644 index 0000000..b717124 --- /dev/null +++ b/tests/room_network/check_capabilities.gd @@ -0,0 +1,22 @@ +extends SceneTree + +func _initialize() -> void: + var cabinet := CabinetProfile.new() + var tempest: GameDefinition = load("res://data/games/tempest.tres") + cabinet.capabilities.assign([&"spinner", &"button_1", &"start_button"]) + assert(not tempest.can_run_on(cabinet), "Superzapper requires a second button") + cabinet.capabilities.append(&"button_2") + assert(tempest.can_run_on(cabinet)) + cabinet.capabilities.erase(&"spinner") + assert(not tempest.can_run_on(cabinet)) + cabinet.capabilities.append(&"trackball") + assert(tempest.can_run_on(cabinet)) + var robotron: GameDefinition = load("res://data/games/robotron.tres") + cabinet.capabilities.assign([&"dual_joystick_8way", &"start_button"]) + assert(robotron.can_run_on(cabinet)) + cabinet.capabilities.assign([&"joystick_8way", &"button_1", &"button_2", &"button_3", &"button_4", &"start_button"]) + assert(robotron.can_run_on(cabinet)) + cabinet.capabilities.erase(&"button_4") + assert(not robotron.can_run_on(cabinet)) + print("PASS: cabinet control alternatives and common requirements") + quit() diff --git a/tests/room_network/check_capabilities.gd.uid b/tests/room_network/check_capabilities.gd.uid new file mode 100644 index 0000000..4668cd1 --- /dev/null +++ b/tests/room_network/check_capabilities.gd.uid @@ -0,0 +1 @@ +uid://c28svvvfj8ndh diff --git a/tests/room_network/check_integration.py b/tests/room_network/check_integration.py new file mode 100644 index 0000000..8c33ceb --- /dev/null +++ b/tests/room_network/check_integration.py @@ -0,0 +1,83 @@ +"""Start real HTTP services with fake GPIO, then a real headless Godot cabinet.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +import json +import urllib.request +import urllib.error +from http.server import HTTPServer +from test_room import ROOT, server +sys.path.insert(0, str(ROOT / "tests/gpio")) +from test_bridge import bridge, FakePin + +OP = "test-operator-token-1234" +CAB = "test-cabinet-token-5678" + +def post(path, data=None, token=OP): + request = urllib.request.Request("http://127.0.0.1:8766" + path, data=json.dumps(data or {}).encode(), + headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"}) + with urllib.request.urlopen(request, timeout=3) as response: return json.load(response) + +def wait_for(predicate): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + state = post("/operator/state") + if predicate(state): return state + time.sleep(.1) + raise AssertionError("Timed out waiting for room state: " + str(state)) + +with tempfile.TemporaryDirectory() as temp: + room = server.Fleet(Path(temp) / "room.sqlite3", json.loads((ROOT / "tools/room_controller/catalog.json").read_text())) + gpio = bridge.Outputs(FakePin, "cabinet_01_gpio", [17], ledger=Path(temp) / "gpio.sqlite3") + services = [HTTPServer(("127.0.0.1", 8766), server.handler_for(room, OP, {"cabinet_01": CAB, "cabinet_02": CAB + "-two"})), + HTTPServer(("127.0.0.1", 8765), bridge.handler_for(gpio))] + threads = [threading.Thread(target=s.serve_forever, daemon=True) for s in services] + for thread in threads: thread.start() + process = None + second = None + try: + try: + post("/operator/state", token=CAB) + raise AssertionError("Cabinet token accessed operator API") + except urllib.error.HTTPError as exc: + assert exc.code == 401 + env = dict(os.environ, ARCADE_ROOM_URL="http://127.0.0.1:8766", ARCADE_CABINET_TOKEN=CAB) + process = subprocess.Popen([sys.argv[1], "--headless", "--path", str(ROOT), + "--log-file", str(ROOT / ".godot/managed_integration.log"), + "--script", "tests/room_network/managed_agent.gd", "--", "--gpio=real"], env=env) + second = subprocess.Popen([sys.argv[1], "--headless", "--path", str(ROOT), + "--log-file", str(ROOT / ".godot/managed_second.log"), + "--script", "tests/room_network/managed_agent.gd", "--", "--cabinet=cabinet_02", "--test-no-reward"], + env=dict(env, ARCADE_CABINET_TOKEN=CAB + "-two")) + wait_for(lambda s: s["online"] and s["cabinets"]["cabinet_02"]["online"]) + state = post("/operator/command", {"action": "prepare_scenario", "scenario": "proof_of_concept"}) + post("/operator/command", {"action": "prepare", "cabinet": "cabinet_02", "game": "galaga"}) + assert state["phase"] == "preparing" + wait_for(lambda s: s["phase"] == "ready" and s["cabinets"]["cabinet_02"]["phase"] == "ready") + post("/operator/command", {"action": "start"}) + post("/operator/command", {"action": "start", "cabinet": "cabinet_02"}) + final = wait_for(lambda s: s["reward"].startswith("GPIO accepted")) + wait_for(lambda s: s["cabinets"]["cabinet_02"]["phase"] == "won") + assert gpio.pins[17]["device"].activations == 1 + assert sum(c["type"] == "reward" for c in final["commands"]) == 1 + assert process.wait(timeout=8) == 0 + assert second.wait(timeout=8) == 0 + assert not any(c["type"] == "reward" for c in post("/operator/state")["cabinets"]["cabinet_02"]["commands"]) + print("PASS: two real Godot cabinets -> Pac-Man scenario + Galaga manual -> ready/start/win -> exactly one mapped GPIO acceptance") + finally: + if process and process.poll() is None: + process.terminate() + process.wait(timeout=5) + if second and second.poll() is None: + second.terminate() + second.wait(timeout=5) + for service in services: + service.shutdown() + service.server_close() + for thread in threads: thread.join() + gpio.close() + for cabinet in room.rooms.values(): cabinet.db.close() diff --git a/tests/room_network/managed_agent.gd b/tests/room_network/managed_agent.gd new file mode 100644 index 0000000..d2fdac5 --- /dev/null +++ b/tests/room_network/managed_agent.gd @@ -0,0 +1,17 @@ +extends SceneTree +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var agent = load("res://core/network/managed_cabinet.gd").new() + root.add_child(agent) + # Only the test harness forces a win; production has no remote force-win endpoint. + for i in range(140): + await create_timer(.1).timeout + if agent.phase == "playing": + agent.game.win_game() + if agent.phase == "won" and ("--test-no-reward" in OS.get_cmdline_user_args() or agent.outcomes.values().any(func(r): return str(r.detail).contains("GPIO bridge accepted"))): + await create_timer(1.0).timeout + quit() + return + push_error("Managed cabinet integration timed out") + quit(1) diff --git a/tests/room_network/managed_agent.gd.uid b/tests/room_network/managed_agent.gd.uid new file mode 100644 index 0000000..925f017 --- /dev/null +++ b/tests/room_network/managed_agent.gd.uid @@ -0,0 +1 @@ +uid://da2ysnuinbmh6 diff --git a/tests/room_network/test_fleet.py b/tests/room_network/test_fleet.py new file mode 100644 index 0000000..dfd3cc2 --- /dev/null +++ b/tests/room_network/test_fleet.py @@ -0,0 +1,145 @@ +import json +import tempfile +import sqlite3 +from pathlib import Path +import threading +import urllib.request +import urllib.error +from http.server import HTTPServer +from test_room import ROOT, server +import unittest + + +class FleetTests(unittest.TestCase): + def setUp(self): + self.now = 100 + self.catalog = json.loads((ROOT / 'tools/room_controller/catalog.json').read_text()) + self.fleet = server.Fleet(':memory:', self.catalog, lambda: self.now) + for cabinet in self.fleet.rooms: self.poll(cabinet) + + def tearDown(self): + for room in self.fleet.rooms.values(): room.db.close() + + def poll(self, cabinet, reports=None): + return self.fleet.poll(dict(cabinet=cabinet, protocol=2, boot=cabinet, reports=reports or [])) + + def ack(self, cabinet): + commands = self.poll(cabinet)['commands'] + self.poll(cabinet, [dict(kind='ack', id=c['id'], session=c['session'], ok=True) for c in commands]) + + def test_scenario_routes_games_and_events_to_correct_cabinets(self): + self.fleet.operator('prepare_scenario', {'scenario': 'two_cabinet_test'}) + self.assertEqual(self.poll('cabinet_01')['commands'][0]['game'], 'pacman') + self.assertEqual(self.poll('cabinet_02')['commands'][0]['game'], 'galaga') + self.assertFalse(self.poll('cabinet_03')['commands']) + self.ack('cabinet_01') + self.assertEqual(self.fleet.rooms['cabinet_02'].state['phase'], 'preparing') + self.ack('cabinet_02') + self.assertEqual(self.poll('cabinet_02')['commands'][0]['action_id'], 'glitch_code') + self.assertFalse(self.poll('cabinet_01')['commands']) + + def test_offline_assignment_prevents_any_scenario_dispatch(self): + self.now += 5 + self.poll('cabinet_01') + with self.assertRaisesRegex(ValueError, 'offline'): + self.fleet.operator('prepare_scenario', {'scenario': 'two_cabinet_test'}) + self.assertFalse(self.fleet.rooms['cabinet_01'].state['commands']) + + def test_manual_assignment_does_not_inherit_reward(self): + self.fleet.operator('prepare', {'cabinet': 'cabinet_01', 'game': 'galaga'}) + self.ack('cabinet_01') + room = self.fleet.rooms['cabinet_01'] + self.poll('cabinet_01', [dict(kind='event', id=event, event=event, session=room.state['session']) for event in ['started', 'won']]) + self.assertEqual(room.state['phase'], 'won') + self.assertFalse(self.poll('cabinet_01')['commands']) + + def test_compatibility_rejects_missing_controls_and_accepts_alternatives(self): + with self.assertRaisesRegex(ValueError, 'hardware'): + self.fleet.operator('prepare', {'cabinet': 'cabinet_01', 'game': 'missile_command'}) + cabinet = self.catalog['cabinets']['cabinet_03'] + self.assertFalse(self.fleet.compatible('cabinet_03', 'tempest')) + cabinet['capabilities'].append('button_2') + self.assertTrue(self.fleet.compatible('cabinet_03', 'tempest')) + cabinet['capabilities'].remove('spinner') + self.assertFalse(self.fleet.compatible('cabinet_03', 'tempest')) + cabinet['capabilities'].append('trackball') + self.assertTrue(self.fleet.compatible('cabinet_03', 'tempest')) + + def test_old_export_cannot_silently_load_wrong_game(self): + with self.assertRaisesRegex(ValueError, 'protocol 2'): + self.fleet.poll(dict(cabinet='cabinet_01', protocol=1, boot='old')) + + def test_invalid_mapping_does_not_dispatch(self): + self.catalog['scenarios']['two_cabinet_test']['assignments'][1]['mappings']['unknown_event'] = ['coin_release'] + with self.assertRaisesRegex(ValueError, 'not implemented'): + self.fleet.operator('prepare_scenario', {'scenario': 'two_cabinet_test'}) + self.assertFalse(self.poll('cabinet_01')['commands']) + + def test_output_timeout_does_not_report_a_reward(self): + room = self.fleet.rooms['cabinet_01'] + room.command('output') + self.now += 16 + room.expire() + self.assertEqual(room.state['reward'], 'not requested') + self.assertIn('unknown', room.state['last_output']) + + def test_one_accepted_output_cannot_hide_another_failed_reward(self): + room = self.fleet.rooms['cabinet_01'] + first, second = room.command('reward'), room.command('reward') + room.ack(dict(id=first['id'], ok=False)) + room.ack(dict(id=second['id'], ok=True)) + self.assertIn('failed', room.state['reward']) + + def test_migration_and_independent_persistence(self): + with tempfile.TemporaryDirectory() as temp: + path = str(Path(temp) / 'migration.db') + legacy = sqlite3.connect(path) + legacy.execute('CREATE TABLE room (id INTEGER PRIMARY KEY, value TEXT NOT NULL)') + old = dict(self.fleet.rooms['cabinet_01'].state, session='old-session', phase='won') + legacy.execute('INSERT INTO room VALUES (1,?)', (json.dumps(old),)) + legacy.commit() + legacy.close() + fleet = server.Fleet(path, self.catalog) + try: + self.assertEqual(fleet.rooms['cabinet_01'].state['session'], 'old-session') + self.assertEqual(fleet.rooms['cabinet_02'].state['session'], '') + fleet.rooms['cabinet_02'].state['game'] = 'galaga' + fleet.rooms['cabinet_02'].save() + finally: + for room in fleet.rooms.values(): room.db.close() + restored = server.Fleet(path, self.catalog) + try: + self.assertEqual(restored.rooms['cabinet_01'].state['phase'], 'won') + self.assertEqual(restored.rooms['cabinet_02'].state['game'], 'galaga') + self.assertFalse(restored.rooms['cabinet_01'].online()) + finally: + for room in restored.rooms.values(): room.db.close() + + def test_http_browser_and_identity_bound_credentials(self): + service = HTTPServer(('127.0.0.1', 0), server.handler_for(self.fleet, 'operator-secret', {'cabinet_01': 'cabinet-one', 'cabinet_02': 'cabinet-two'})) + thread = threading.Thread(target=service.serve_forever, daemon=True) + thread.start() + base = 'http://127.0.0.1:' + str(service.server_port) + try: + with urllib.request.urlopen(base) as response: + self.assertIn(b'Room control', response.read()) + def request(path, token, data, origin=base): + req = urllib.request.Request(base + path, data=json.dumps(data).encode(), headers={'Authorization': 'Bearer '+token, 'Origin': origin}) + return urllib.request.urlopen(req) + with request('/operator/state', 'operator-secret', {}) as response: + self.assertEqual(len(json.load(response)['cabinets']), 3) + for path, token, data, code, origin in [ + ('/operator/state', 'cabinet-one', {}, 401, base), + ('/cabinet/poll', 'cabinet-one', {'cabinet':'cabinet_02','protocol':2,'boot':'x'}, 401, base), + ('/operator/state', 'operator-secret', {}, 400, 'http://unrelated.example'), + ]: + with self.assertRaises(urllib.error.HTTPError) as caught: + request(path, token, data, origin) + self.assertEqual(caught.exception.code, code) + finally: + service.shutdown() + service.server_close() + thread.join() + + +if __name__ == '__main__': unittest.main() diff --git a/tests/room_network/test_room.py b/tests/room_network/test_room.py new file mode 100644 index 0000000..d0e0743 --- /dev/null +++ b/tests/room_network/test_room.py @@ -0,0 +1,80 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("room_server", ROOT / "tools/room_controller/server.py") +server = importlib.util.module_from_spec(spec) +spec.loader.exec_module(server) + + +class RoomTests(unittest.TestCase): + def setUp(self): + self.now = 100 + self.room = server.Room(":memory:", lambda: self.now) + self.poll() + + def tearDown(self): self.room.db.close() + + def poll(self, reports=None, boot="boot1"): + return self.room.poll(dict(cabinet="cabinet_01", protocol=1, boot=boot, reports=reports or [])) + + def ready(self): + self.room.operator("prepare") + command = self.poll()["commands"][0] + self.assertEqual(self.room.state["phase"], "preparing") + self.poll([dict(kind="ack", id=command["id"], session=command["session"], ok=True)]) + self.assertEqual(self.room.state["phase"], "ready") + + def event(self, name, event_id): + return dict(kind="event", event=name, id=event_id, session=self.room.state["session"]) + + def test_ready_then_win_queues_one_reward(self): + self.ready() + self.poll([self.event("started", "1"), self.event("won", "2")]) + reward = self.poll()["commands"] + self.assertEqual(len(reward), 1) + self.assertEqual(reward[0]["type"], "reward") + self.poll([self.event("won", "2"), self.event("won", "3")]) + self.assertEqual(len(self.poll()["commands"]), 1) + + def test_stale_session_cannot_reward_after_reset(self): + self.ready() + self.poll([self.event("started", "1")]) + old_win = self.event("won", "2") + self.room.operator("reset") + self.poll([old_win]) + self.assertEqual(self.room.state["reward"], "not requested") + + def test_offline_and_expiry(self): + self.now += 5 + self.assertFalse(self.room.snapshot()["online"]) + with self.assertRaises(ValueError): self.room.operator("prepare") + self.poll() + self.room.operator("prepare") + self.now += 16 + self.assertEqual(self.poll()["commands"], []) + self.assertEqual(self.room.state["phase"], "needs_prepare") + + def test_new_agent_boot_requires_prepare(self): + self.ready() + self.poll(boot="boot2") + self.assertEqual(self.room.state["session"], "") + self.assertEqual(self.room.state["phase"], "needs_prepare") + + def test_persistence(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "room.db" + room = server.Room(path, lambda: self.now) + room.poll(dict(cabinet="cabinet_01", protocol=1, boot="a")) + room.operator("prepare") + session = room.state["session"] + room.db.close() + restored = server.Room(path, lambda: self.now) + self.assertEqual(restored.state["session"], session) + self.assertFalse(restored.online()) + restored.db.close() + + +if __name__ == "__main__": unittest.main() diff --git a/tests/space_invaders/.gitignore b/tests/space_invaders/.gitignore new file mode 100644 index 0000000..ac535c7 --- /dev/null +++ b/tests/space_invaders/.gitignore @@ -0,0 +1,3 @@ +*.log +*.png +*.png.import diff --git a/tests/space_invaders/benchmark.gd b/tests/space_invaders/benchmark.gd new file mode 100644 index 0000000..7fc506d --- /dev/null +++ b/tests/space_invaders/benchmark.gd @@ -0,0 +1,22 @@ +extends SceneTree +func _initialize() -> void: + call_deferred("run") +func run() -> void: + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) + root.size=Vector2i(672,768) + root.content_scale_size=Vector2i(672,768) + var g = load("res://games/space_invaders/SpaceInvaders.tscn").instantiate() + root.add_child(g) + g.sound_enabled=false + g.set_physics_process(false) + g.begin_demo() + for i in range(30): await process_frame + var begin := Time.get_ticks_usec() + for i in range(240): + g._physics_process(1.0/60) + await process_frame + await RenderingServer.frame_post_draw + var seconds := (Time.get_ticks_usec()-begin)/1000000.0 + print("240 rendered frames: ",snappedf(seconds,.001)," seconds; average ",snappedf(240/seconds,1)," FPS (desktop, uncapped)") + g.free() + quit() diff --git a/tests/space_invaders/benchmark.gd.uid b/tests/space_invaders/benchmark.gd.uid new file mode 100644 index 0000000..6ba2201 --- /dev/null +++ b/tests/space_invaders/benchmark.gd.uid @@ -0,0 +1 @@ +uid://dtqlkdhpcr15e diff --git a/tests/space_invaders/check_space_invaders.gd b/tests/space_invaders/check_space_invaders.gd new file mode 100644 index 0000000..9207712 --- /dev/null +++ b/tests/space_invaders/check_space_invaders.gd @@ -0,0 +1,122 @@ +extends SceneTree +var failures := 0 +var starts := 0 +var wins := 0 +var losses := 0 +func check(value: bool, message: String) -> void: + if not value: + failures += 1 + push_error(message) +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var g = load("res://games/space_invaders/SpaceInvaders.tscn").instantiate() + root.add_child(g) + g.set_physics_process(false) + g.sound_enabled = false + g.game_started.connect(func(): starts += 1) + g.game_won.connect(func(): wins += 1) + g.game_lost.connect(func(): losses += 1) + check(g.aliens.size()==55 and g.shields.size()==4,"Initial fleet/shields") + check(g.start_game() and not g.start_game(),"Start guard") + g._physics_process(2.1) + check(g.phase=="play" and starts==1,"Ready and event") + check(g.fire() and not g.fire(),"One player shot") + g.remove_shot() + check(g.shot_count==1,"Shot sequence") + var a: Vector2 = g.aliens[0].position + var b: Vector2 = g.aliens[1].position + g.march() + check(g.aliens[0].position==a+Vector2(2,0) and g.aliens[1].position==b,"Ripple movement") + for alien in g.aliens: alien.alive=false + g.aliens[0].alive=true + g.aliens[0].position=Vector2(208,100) + g.fleet_cursor=0 + g.march() + check(g.fleet_direction==-1 and g.aliens[0].position.y==108,"Edge descent") + var shield: Dictionary = g.shields[0] + var point := Vector2(shield.position)+Vector2(12,4) + check(g.shield_hit(point,true),"Shield collision") + check(shield.image.get_pixel(12,4).a==0,"Destructible shield pixel") + check(not g.shield_hit(point,true),"Hole allows shot through") + g.new_wave() + for alien in g.aliens: alien.alive=false + g.aliens[0].alive=true + g.aliens[0].position=Vector2(100,100) + g.hit_pause=1 + g.shot_active=true + g.player_shot=Vector2(100,110) + g.step_world(.05) + check(not g.aliens[0].alive and g.score==10 and g.phase=="clear","Alien hit/clear") + g._physics_process(1.6) + check(wins==1 and not g.is_running,"Win event") + g.start_game() + g.waves_to_win=0 + g.phase="clear" + g.phase_time=0 + g._physics_process(.01) + check(g.wave==2 and g.aliens[44].position.y==96,"Next wave height") + g.phase="play" + g.lives=1 + g.die() + g._physics_process(1.6) + check(losses==1 and g.phase=="lost","Loss event") + g.start_game() + g.add_score(1500) + var lives: int = g.lives + g.add_score(2000) + check(lives==g.starting_lives+1 and g.lives==lives,"One bonus life") + g.reset_game() + g.high_score=9999 + g.last_score=420 + var original_starts := starts + var original_wins := wins + var original_losses := losses + g.begin_demo() + for i in range(1600): g._physics_process(1.0/60) + check(not g.demo and not g.is_running and g.phase=="attract","Demo exit") + check(g.high_score==9999 and g.score==420,"Demo score isolation") + check(starts==original_starts and wins==original_wins and losses==original_losses,"Demo event isolation") + g.begin_demo() + check(g.start_game() and not g.demo and g.score==0,"Start interrupts demo") + g.reset_game() + check(g.phase=="attract" and not g.is_running,"Reset") + g.start_game() + g.phase="play" + g.hit_pause=1 + g.saucer_active=true + g.saucer_x=100 + g.shot_count=8 + g.shot_active=true + g.player_shot=Vector2(100,50) + g.step_world(.025) + check(g.score==300 and not g.saucer_active and not g.shot_active,"Saucer score sequence and collision") + g.hit_pause=1 + g.shot_delay=0 + g.fire() + g.player_shot=Vector2(16,180) + g.bombs.clear() + g.bombs.append({"position":Vector2(16,174),"kind":0}) + g.step_world(.01) + check(not g.shot_active and g.bombs.is_empty(),"Projectile interception") + g.bombs.append({"position":Vector2(g.player_x,214),"kind":0}) + g.step_world(.025) + check(g.phase=="dead","Enemy projectile hits player") + g._physics_process(1.6) + g._physics_process(1.1) + check(g.phase=="play" and g.bombs.is_empty(),"Respawn clears threats") + for alien in g.aliens: alien.alive=false + g.aliens[0].alive=true + g.aliens[0].position=Vector2(100,216) + g.fleet_cursor=0 + g.march() + check(g.phase=="lost" and not g.is_running,"Invasion is an immediate loss") + g._physics_process(5.1) + check(g.phase=="attract","Game over returns to attract") + var definition = load("res://data/games/space_invaders.tres") + check(definition.game_id==&"space_invaders" and definition.scene!=null,"Game resource") + var database = load("res://data/project_database.tres") + check(database.get_game(&"space_invaders")!=null,"Database registration") + print("Space Invaders checks: ",failures," failures") + g.free() + quit(failures) diff --git a/tests/space_invaders/check_space_invaders.gd.uid b/tests/space_invaders/check_space_invaders.gd.uid new file mode 100644 index 0000000..d2b8bb0 --- /dev/null +++ b/tests/space_invaders/check_space_invaders.gd.uid @@ -0,0 +1 @@ +uid://chgwbac0t6h7u diff --git a/tests/space_invaders/render_preview.gd b/tests/space_invaders/render_preview.gd new file mode 100644 index 0000000..3604c47 --- /dev/null +++ b/tests/space_invaders/render_preview.gd @@ -0,0 +1,26 @@ +extends SceneTree +func _initialize() -> void: + call_deferred("run") +func run() -> void: + root.size = Vector2i(672,768) + root.content_scale_size = Vector2i(672,768) + var g = load("res://games/space_invaders/SpaceInvaders.tscn").instantiate() + root.add_child(g) + g.set_physics_process(false) + g.sound_enabled = false + for state in ["attract","play","demo"]: + g.phase=state + g.phase_time=8 + if state=="play": + g.start_game() + g.phase="play" + if state=="demo": + g.begin_demo() + for i in range(720): g._physics_process(1.0/60) + g.board.queue_redraw() + await process_frame + await process_frame + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png("res://tests/space_invaders/"+state+".png") + g.free() + quit() diff --git a/tests/space_invaders/render_preview.gd.uid b/tests/space_invaders/render_preview.gd.uid new file mode 100644 index 0000000..6026ad3 --- /dev/null +++ b/tests/space_invaders/render_preview.gd.uid @@ -0,0 +1 @@ +uid://0olmyxb3i7gq diff --git a/tests/test_capture.gd b/tests/test_capture.gd new file mode 100644 index 0000000..2826be1 --- /dev/null +++ b/tests/test_capture.gd @@ -0,0 +1,67 @@ +extends SceneTree +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var game = load("res://games/galaga/Galaga.tscn").instantiate() + root.add_child(game) + game.set_physics_process(false) + game.start_game() + assert(game.state=="intro" and not game.player.visible) + game.field._process(0) + assert(not game.player.visible) + game._tick(game.intro_seconds+0.01) + assert(game.state=="stage_card" and not game.player.visible) + game._tick(game.stage_card_seconds+0.01) + assert(game.state=="ready" and game.player.visible) + game._tick(game.player_card_seconds+0.01) + assert(game.state=="playing") + # Different formation slots must share the same pre-docking entrance curve. + game.enemies[0].time=0.4 + game.enemies[1].time=0.4 + game._update_enemies(0) + assert(game.enemies[0].node.position.is_equal_approx(game.enemies[1].node.position)) + for enemy in game.enemies: assert(enemy.node.scale==Vector2.ONE) + var boss: Dictionary = game.enemies[0] + boss.mode="beam" + boss.time=0 + boss.node.position=Vector2(112,172) + game.beam=boss + game._update_enemies(0.01) + assert(game.state=="capturing") + game._sound("fire") + for voice in game.voices: + if voice.playing: assert(voice.stream==game.streams["capturing"]) + game._tick(1.6) + assert(game.lives==2 and game.captive.visible and not game.player.visible) + for i in range(400): + if game.state=="playing": break + game._tick(1.0/60.0) + assert(game.state=="playing" and game.player.visible) + boss.node.position=Vector2(112,100) + game.captive.position=Vector2(112,84) + boss.hp=1 + game._bullet(Vector2(112,100),Vector2.ZERO,true) + game._update_bullets(0) + assert(game.state=="rescue_spin" and game.captor.is_empty()) + var frozen: Vector2=game.enemies[0].node.position + game._tick(0.1) + assert(game.enemies[0].node.position==frozen) + for i in range(600): + if game.state=="playing": break + game._tick(1.0/60.0) + assert(game.dual and not game.captive.visible) + game._tick(0.01) + game._clear_bullets() + game.cooldown=0 + game._fire() + assert(game.bullets.size()==2) + assert(absf(game.bullets[0].node.position.x-game.bullets[1].node.position.x)==16) + game._die() + assert(not game.dual and game.lives==2 and game.state=="playing") + game.reset_game() + assert(game.captor.is_empty() and not game.captive.visible and not game.dual) + print("CAPTURE AND INTRO TESTS PASS") + game.queue_free() + await process_frame + quit() + diff --git a/tests/test_capture.gd.uid b/tests/test_capture.gd.uid new file mode 100644 index 0000000..be63dae --- /dev/null +++ b/tests/test_capture.gd.uid @@ -0,0 +1 @@ +uid://bly0tpogxcin0 diff --git a/tests/test_galaga.gd b/tests/test_galaga.gd new file mode 100644 index 0000000..e8dfc58 --- /dev/null +++ b/tests/test_galaga.gd @@ -0,0 +1,79 @@ +extends SceneTree +var game +func _initialize() -> void: + call_deferred("run") +func run() -> void: + game = load("res://games/galaga/Galaga.tscn").instantiate() + root.add_child(game) + game.set_physics_process(false) + assert(game.enemies.size()==40) + assert(game.start_game()) + game.field.previous_state="idle" + game.field._process(0.0) + for enemy in game.enemies: assert(not enemy.node.visible) + game._update_enemies(0.01) + assert(game.enemies[0].node.visible) + assert(not game.enemies[1].node.visible) + assert(not game.start_game()) + game._tick(game.intro_seconds+0.1) + game._tick(game.stage_card_seconds+0.1) + game._tick(game.player_card_seconds+0.1) + assert(game.state=="playing") + game._fire() + game.cooldown=0 + game._fire() + game.cooldown=0 + game._fire() + assert(game.bullets.size()==2 and game.shots_fired==2) + game._clear_bullets() + var boss: Dictionary = game.enemies[0] + boss.node.position=Vector2(100,100) + game._bullet(Vector2(100,100),Vector2.ZERO,true) + game._update_bullets(0) + assert(boss.hp==1 and game.score==0) + game._bullet(Vector2(100,100),Vector2.ZERO,true) + game._update_bullets(0) + assert(game.enemies.size()==39 and game.score==150) + game._die() + assert(game.lives==2 and game.state=="death") + game._tick(1.6) + assert(game.state=="playing" and game.invulnerable>0) + game.run_config={"score_to_win":200} + game._add_score(50) + assert(game.state=="victory") + game._tick(1.51) + assert(game.state=="won" and not game.is_running) + game.run_config={} + game.start_game() + game._add_score(140000) + assert(game.lives==6 and game.next_extra_life==210000) + game.reset_game() + assert(game.enemies.size()==40 and game.bullets.is_empty()) + game.run_config={"stages_to_win":0} + game.start_game() + game._tick(game.intro_seconds+0.1) + game._tick(game.stage_card_seconds+0.1) + game._tick(game.player_card_seconds+0.1) + game.invulnerable=100 + for i in range(1200): game._tick(1.0/60.0) + for enemy in game.enemies: + assert(is_finite(enemy.node.position.x)) + assert(enemy.node.rotation==0) + game.state="playing" + game._clear_actors() + game._tick(0.01) + assert(game.state=="clear") + game._tick(2.1) + assert(game.stage==2 and game.enemies.size()==40) + game.state="playing" + game.lives=1 + game._die() + game._tick(1.6) + assert(game.state=="lost" and not game.is_running) + game.queue_free() + await process_frame + await create_timer(0.1).timeout + print("GALAGA TESTS PASS") + quit() + + diff --git a/tests/test_galaga.gd.uid b/tests/test_galaga.gd.uid new file mode 100644 index 0000000..562fd7f --- /dev/null +++ b/tests/test_galaga.gd.uid @@ -0,0 +1 @@ +uid://clahhchgclvpb diff --git a/tests/test_missile_command.gd b/tests/test_missile_command.gd new file mode 100644 index 0000000..c799d82 --- /dev/null +++ b/tests/test_missile_command.gd @@ -0,0 +1,104 @@ +extends SceneTree +var failures := 0 +var wins := 0 +var losses := 0 +func check(value: bool,message: String) -> void: + if not value: + push_error(message) + failures += 1 +func _initialize() -> void: call_deferred("run") +func run() -> void: + var definition = load("res://data/games/missile_command.tres") + check(definition != null and definition.game_id == &"missile_command","game definition loads") + var game = load("res://games/missile_command/MissileCommand.tscn").instantiate() + root.add_child(game) + game.set_physics_process(false) + game.game_won.connect(func(): wins += 1) + game.game_lost.connect(func(): losses += 1) + game._physics_process(9.1) + check(game.phase == "leaderboard","title destruction leads to leaderboard") + game._physics_process(7.1) + check(game.phase == "title","attract loops") + check(game.start_game() and not game.start_game(),"single start") + check(game.phase == "ready" and not game.fire(1),"DEFEND CITIES before combat") + game._physics_process(2.3) + game.set_aim(Vector2(100,100)) + check(game.fire(0) and game.fire(1) and game.fire(2),"three independent batteries") + check(game.ammo == [9,9,9] and not game.fire(0),"ammo and cooldown") + check(game.interceptors[1].speed > game.interceptors[0].speed,"center battery faster") + game.cooldown.fill(0) + var had_action := InputMap.has_action("arcade_action_1") + if not had_action: InputMap.add_action("arcade_action_1") + var space := InputEventKey.new() + space.physical_keycode = KEY_SPACE + InputMap.action_add_event("arcade_action_1",space) + space.pressed = true + var count: int = game.interceptors.size() + game._unhandled_input(space) + check(game.interceptors.size() == count+1,"mapped Space fires only one battery") + InputMap.action_erase_event("arcade_action_1",space) + if not had_action: InputMap.erase_action("arcade_action_1") + game.display.destination = Rect2(50,20,512,462) + check(game.display.to_logical(Vector2(306,251)) == Vector2(128,115.5),"letterboxed mouse coordinates") + game.incoming.clear() + game.remaining = 0 + game.interceptors.clear() + game.explosion(Vector2(100,100)) + game.spawn_missile(Vector2(100,100)) + game.incoming[0].target = Vector2(100,211) + game._step(.1) + check(game.incoming.is_empty() and game.score == 25,"explosion destroys incoming and scores") + game.blasts.clear() + game._impact(Vector2(game.CITY_X[0],211)) + check(not game.cities[0],"city impact") + game._impact(Vector2(game.BASE_X[1],204)) + check(not game.bases[1] and game.ammo[1] == 0 and not game.fire(1),"battery impact disables firing") + game.add_score(game.bonus_city_interval) + check(game.bonus_bank == 1,"bonus city banked") + game._restore_cities() + check(game.cities[0] and game.bonus_bank == 0,"lost city restored") + game.blasts.clear() + game._step(.1) + check(game.phase == "bonus" and game.wave_bonus > 0,"wave completion awards bonus once") + game._physics_process(3.1) + check(game.wave == 2 and game.ammo == [10,10,10] and game.bases == [true,true,true],"new wave restores batteries") + game.wave = game.waves_to_win + game.phase = "bonus" + game.phase_time = .1 + game._physics_process(.2) + game._physics_process(.2) + check(game.phase == "won" and wins == 1 and not game.is_running,"single room win") + game.reset_game() + game.start_game() + game.phase = "play" + game.cities.fill(false) + game._step(.01) + check(game.phase == "lost" and losses == 1 and not game.is_running,"all cities lost") + game._physics_process(5.1) + check(game.phase == "title","loss returns attract") + game.set_aim(Vector2(-10,999)) + check(game.aim == Vector2(1,199),"aim clamp") + game.add_trackball_motion(Vector2(10,-10)) + check(game.aim == Vector2(11,189),"relative trackball API") + game.reset_game() + game.start_game() + game.phase = "play" + game.wave = 5 + game.spawn_time = 999 + game.remaining = 1 + game.spawn_missile(Vector2(128,91),"splitter") + game._step(.01) + check(game.incoming.size() == 3,"missile splits") + for frame in range(3600): + if game.phase == "play": + game.set_aim(Vector2(20+(frame%210),100)) + if frame%9 == 0: game.fire((frame/9)%3) + game._physics_process(1.0/60) + print("Missile Command tests: ",failures," failures") + game.get_node("Audio").stop() + await create_timer(.2).timeout + game.queue_free() + await process_frame + quit(failures) + + diff --git a/tests/test_missile_command.gd.uid b/tests/test_missile_command.gd.uid new file mode 100644 index 0000000..f47440d --- /dev/null +++ b/tests/test_missile_command.gd.uid @@ -0,0 +1 @@ +uid://c2ikw4y71sl30 diff --git a/tests/test_pacman.gd b/tests/test_pacman.gd new file mode 100644 index 0000000..af67838 --- /dev/null +++ b/tests/test_pacman.gd @@ -0,0 +1,180 @@ +extends SceneTree +const Model = preload("res://games/pacman/pacman_model.gd") +const Maze = preload("res://games/pacman/pacman_maze.gd") +const Rules = preload("res://games/pacman/pacman_rules.gd") +var checks := 0 +var failures := 0 + +func check(condition: bool, label: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error("FAIL: " + label) + +func _initialize() -> void: + call_deferred("run") + +func run() -> void: + for row in Maze.ROWS: check(row.length() == 28, "28-column maze") + check(Maze.pellets().size() == 244, "244 pellets") + var powers := 0 + for value in Maze.pellets().values(): + if value: powers += 1 + check(powers == 4, "four energizers") + var seen := {Maze.PLAYER_START: true} + var queue := [Maze.PLAYER_START] + while not queue.is_empty(): + var tile: Vector2i = queue.pop_front() + for direction in Maze.DIRS: + var next: Vector2i = Maze.neighbor(tile, direction) + if Maze.walkable(next) and not seen.has(next): + seen[next] = true + queue.append(next) + for tile in Maze.pellets(): check(seen.has(tile), "reachable pellet %s" % tile) + check(not Maze.walkable(Vector2i(13, 12)), "player cannot enter ghost door") + check(Maze.neighbor(Vector2i(0,14), Vector2i.LEFT) == Vector2i(27,14), "tunnel wrap") + check(Rules.target(0, Vector2i(5,5), Vector2i.UP, Vector2i.ZERO, Vector2i.ZERO) == Vector2i(5,5), "Blinky target") + check(Rules.target(1, Vector2i(5,5), Vector2i.UP, Vector2i.ZERO, Vector2i.ZERO) == Vector2i(1,1), "Pinky upward quirk") + check(Rules.target(2, Vector2i(5,5), Vector2i.RIGHT, Vector2i(3,5), Vector2i.ZERO) == Vector2i(11,5), "Inky target") + check(Rules.target(3, Vector2i(5,5), Vector2i.RIGHT, Vector2i.ZERO, Vector2i(6,5)) == Rules.SCATTER[3], "Clyde retreats") + var m = Model.new() + m.start() + m.update(5.0) + check(m.state == "playing", "ready becomes playing") + # Movement stops at the corridor wall, despite a held direction. + for i in range(180): + m._move_player(0.1) + m.pellet_pause = 0.0 + check(Maze.walkable(m.player.tile), "movement stays in corridor") + # Energizer changes active ghosts; capture returns eyes and adds score. + m.player = Maze.actor(Vector2i(1,3), Vector2i.DOWN) + m._consume() + check(m.fright_time > 0.0, "energizer starts frightened timer") + var before: int = m.score + m.ghosts[0].tile = m.player.tile + m.ghosts[0].next = m.player.tile + m.ghosts[0].progress = 0.0 + m._check_collisions() + check(m.ghosts[0].status == "eyes" and m.score == before + 200, "ghost capture score and eyes") + m.reset() + check(m.state == "idle" and m.score == 0 and m.dots.size() == 244, "reset restores board") + m.start() + m.state = "playing" + m.dots = {m.player.tile: false} + m._consume() + check(m.state == "clear", "last pellet clears level") + m.update(3.0) + check(m.state == "won", "default room objective wins") + m.boards_to_win = 0 + m.start() + m.state = "clear" + m.phase_time = 0.0 + m.update(0.1) + check(m.level == 2 and m.state == "ready" and m.dots.size() == 244, "arcade progression") + m.state = "clear" + m.phase_time = 0.0 + m.update(0.1) + check(m.state == "intermission", "second board starts intermission") + m.update(6.0) + check(m.state == "ready" and m.level == 3, "intermission continues to next level") + m.lives = 1 + m.state = "dying" + m.phase_time = 0.0 + m.update(0.1) + assert(m.state == "death_pop", "death pop before final loss") + m.phase_time = 0.0 + m.update(0.1) + check(m.state == "lost", "final life loses") + m.start() + m.state = "playing" + m.score_to_win = 100 + m._add_score(100) + check(m.state == "won", "score objective") + # Traverse the entire board through the actual movement and consumption code. + m.score_to_win = 0 + m.boards_to_win = 1 + m.start() + m.state = "playing" + var moves := 0 + while not m.dots.is_empty() and moves < 3000: + var frontier: Array = [m.player.tile] + var parent := {m.player.tile: null} + var destination: Vector2i = m.player.tile + while not frontier.is_empty(): + var current: Vector2i = frontier.pop_front() + if m.dots.has(current): + destination = current + break + for direction in Maze.DIRS: + var next: Vector2i = Maze.neighbor(current, direction) + if Maze.walkable(next) and not parent.has(next): + parent[next] = current + frontier.append(next) + var path: Array[Vector2i] = [] + while destination != m.player.tile: + path.push_front(destination) + destination = parent[destination] + if path.is_empty(): + m._consume() + for tile in path: + var direction: Vector2i = tile - m.player.tile + if abs(direction.x) > 1: direction.x = -signi(direction.x) + m.requested = direction + for step in range(50): + m.pellet_pause = 0.0 + m._move_player(0.1) + if m.player.tile == tile: break + check(m.player.tile == tile, "buffered turn reaches next corridor tile") + moves += 1 + check(m.dots.is_empty() and m.state == "clear", "full maze cleared through movement") + # Every returning ghost must be able to reach the house and emerge again. + m.start() + m.state = "playing" + for tile in seen: + var g: Dictionary = m.ghosts[0] + g.tile = tile + g.next = tile + g.progress = 0.0 + g.dir = Vector2i.LEFT + g.status = "eyes" + g.reverse = false + for step in range(1600): + m._move_ghost(0, 0.5) + if g.status == "active": break + check(g.status == "active", "eyes return from %s" % tile) + # Headless random walk checks ghost movement stays in legal corridors. + m.score_to_win = 0 + m.start() + m.state = "playing" + for frame in range(12000): + m.requested = Maze.DIRS[(frame / 30) % 4] + m.update(1.0 / 60.0) + if m.state == "lost": m.start() + for g in m.ghosts: + if g.status == "active": + check(Maze.walkable(g.tile), "ghost stays on map") + var game = load("res://games/pacman/Pacman.tscn").instantiate() + root.add_child(game) + var counts := {"start": 0, "won": 0, "lost": 0, "reset": 0} + game.game_started.connect(func(): counts.start += 1) + game.game_won.connect(func(): counts.won += 1) + game.game_lost.connect(func(): counts.lost += 1) + game.game_reset.connect(func(): counts.reset += 1) + check(game.start_game(), "ArcadeGame starts") + check(not game.start_game(), "duplicate start ignored") + game.win_game() + game.win_game() + check(counts.won == 1 and counts.start == 1, "win event exactly once") + game.reset_game() + game.start_game() + game.lose_game() + game.lose_game() + check(counts.lost == 1 and counts.reset == 1, "loss and reset events") + var loop = game.audio._loop_stream("siren0", game.audio.stream("siren0_firstloop")) + check(loop.loop_begin > 0 and loop.loop_end > loop.loop_begin, "intro plays once before PCM loop") + game.audio.set_background("fright") + game.reset_game() + check(game.audio.background == "" and not game.audio.ambience.playing, "reset stops ambience") + game.queue_free() + print("PACMAN: %d checks, %d failures" % [checks, failures]) + quit(1 if failures else 0) diff --git a/tests/test_pacman.gd.uid b/tests/test_pacman.gd.uid new file mode 100644 index 0000000..e7444c7 --- /dev/null +++ b/tests/test_pacman.gd.uid @@ -0,0 +1 @@ +uid://by4wo7vr2sxx0 diff --git a/tests/test_pixel_render.gd b/tests/test_pixel_render.gd new file mode 100644 index 0000000..3ce90d3 --- /dev/null +++ b/tests/test_pixel_render.gd @@ -0,0 +1,25 @@ +extends SceneTree +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var game = load("res://games/galaga/Galaga.tscn").instantiate() + root.add_child(game) + game.set_physics_process(false) + game.state="playing" + game.player.position=Vector2(112.1,256.1) + await process_frame + await process_frame + await RenderingServer.frame_post_draw + var first := root.get_texture().get_image().get_data() + game.player.position=Vector2(112.4,256.4) + await process_frame + await process_frame + await RenderingServer.frame_post_draw + assert(first==root.get_texture().get_image().get_data(),"Fractional positions within the same native pixel must render identically") + game.player.position=Vector2(112.7,256.7) + await process_frame + await process_frame + await RenderingServer.frame_post_draw + assert(first!=root.get_texture().get_image().get_data(),"Crossing a native pixel boundary must move the rendered sprite") + print("NATIVE PIXEL RENDER TEST PASS") + quit() diff --git a/tests/test_pixel_render.gd.uid b/tests/test_pixel_render.gd.uid new file mode 100644 index 0000000..a38705d --- /dev/null +++ b/tests/test_pixel_render.gd.uid @@ -0,0 +1 @@ +uid://cbuq7cc4x2jyr diff --git a/tests/test_room_logic_engine.gd b/tests/test_room_logic_engine.gd new file mode 100644 index 0000000..b2aed47 --- /dev/null +++ b/tests/test_room_logic_engine.gd @@ -0,0 +1,146 @@ +extends Node + + +func _ready() -> void: + + var room := RoomDefinition.new() + + var immediate_rule := RoomLogicRule.new() + + immediate_rule.rule_id = ( + &"win_turns_on_light" + ) + + immediate_rule.source_id = ( + &"Cabinet01" + ) + + immediate_rule.event_id = ( + &"game_won" + ) + + immediate_rule.target_id = ( + &"StartLight01" + ) + + immediate_rule.action_id = ( + &"turn_on" + ) + + immediate_rule.delay_seconds = ( + 0.0 + ) + + + var delayed_rule := RoomLogicRule.new() + + delayed_rule.rule_id = ( + &"win_unlocks_drawer" + ) + + delayed_rule.source_id = ( + &"Cabinet01" + ) + + delayed_rule.event_id = ( + &"game_won" + ) + + delayed_rule.target_id = ( + &"DrawerLock01" + ) + + delayed_rule.action_id = ( + &"unlock" + ) + + delayed_rule.delay_seconds = ( + 2.0 + ) + + + room.logic_rules = [ + immediate_rule, + delayed_rule, + ] + + + var engine := RoomLogicEngine.new() + + engine.configure( + room + ) + + engine.event_received.connect( + func( + source_id: StringName, + event_id: StringName + ) -> void: + + print( + "[LOGIC TEST] EVENT ", + source_id, + ".", + event_id + ) + ) + + engine.rule_triggered.connect( + func( + rule_id: StringName, + _source_id: StringName, + _event_id: StringName, + _target_id: StringName, + _action_id: StringName + ) -> void: + + print( + "[LOGIC TEST] RULE ", + rule_id + ) + ) + + engine.action_requested.connect( + func( + target_id: StringName, + action_id: StringName, + rule_id: StringName + ) -> void: + + print( + "[LOGIC TEST] ACTION ", + target_id, + ".", + action_id, + " from ", + rule_id, + " at ", + engine.get_simulation_time() + ) + ) + + + print( + "--- FIRE GAME WON ---" + ) + + engine.emit_event( + &"Cabinet01", + &"game_won" + ) + + print( + "--- ADVANCE 1 SECOND ---" + ) + + engine.advance( + 1.0 + ) + + print( + "--- ADVANCE 1 MORE SECOND ---" + ) + + engine.advance( + 1.0 + ) diff --git a/tests/test_room_logic_engine.gd.uid b/tests/test_room_logic_engine.gd.uid new file mode 100644 index 0000000..7d5cb5f --- /dev/null +++ b/tests/test_room_logic_engine.gd.uid @@ -0,0 +1 @@ +uid://i3jtfiim12je diff --git a/tests/test_room_logic_engine.tscn b/tests/test_room_logic_engine.tscn new file mode 100644 index 0000000..044c225 --- /dev/null +++ b/tests/test_room_logic_engine.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://dfit3ateu8q3u"] + +[ext_resource type="Script" uid="uid://i3jtfiim12je" path="res://tests/test_room_logic_engine.gd" id="1_0g6lg"] + +[node name="Node2D" type="Node2D" unique_id=224727458] +script = ExtResource("1_0g6lg") diff --git a/tests/test_start_death_tune.gd b/tests/test_start_death_tune.gd new file mode 100644 index 0000000..7b04bae --- /dev/null +++ b/tests/test_start_death_tune.gd @@ -0,0 +1,50 @@ +extends SceneTree +const Model = preload("res://games/pacman/pacman_model.gd") +const Maze = preload("res://games/pacman/pacman_maze.gd") + +func _initialize() -> void: + call_deferred("run") + +func run() -> void: + var model = Model.new() + model.start() + model.update(4.3) + assert(model.state == "playing") + model.state = "playing" + model.ghosts[0].tile = model.player.tile + model.ghosts[0].next = model.player.tile + model.ghosts[0].progress = 0.0 + model._check_collisions() + assert(model.state == "death_pause") + assert(is_equal_approx(model.phase_time, 1.5)) + var paused_clock: float = model.clock + model.update(0.5) + assert(is_equal_approx(model.clock, paused_clock)) + assert(model.state == "death_pause") + var sounds := [] + model.sound_requested.connect(func(name: String): sounds.append(name)) + model.update(1.51) + assert(model.state == "dying") + assert(sounds == ["death"]) + assert(is_equal_approx(model.phase_time, 1.55)) + model.update(1.56) + assert(model.state == "death_pop") + assert(is_equal_approx(model.phase_time, 0.24)) + model.update(0.25) + assert(model.lives == 2) + assert(model.state == "ready") + assert(Maze.position(model.player) == Vector2(13, 23)) + assert(Maze.position(model.ghosts[0]) == Vector2(13.5, 11)) + model.state = "playing" + model._move_ghost(0, 0.0) + assert(Maze.position(model.ghosts[0]) == Vector2(13, 11)) + var scene = load("res://games/pacman/Pacman.tscn").instantiate() + root.add_child(scene) + var preview = scene.get_node("EditorPreview") + await process_frame + if Engine.is_editor_hint(): + assert(preview.model.dots.size() == 244) + assert(preview.model.state == "ready") + scene.free() + print("START/DEATH TUNING PASS") + quit() diff --git a/tests/test_start_death_tune.gd.uid b/tests/test_start_death_tune.gd.uid new file mode 100644 index 0000000..ccf1658 --- /dev/null +++ b/tests/test_start_death_tune.gd.uid @@ -0,0 +1 @@ +uid://v5anjrmi1rpe diff --git a/tests/test_tempest.gd b/tests/test_tempest.gd new file mode 100644 index 0000000..63f6eef --- /dev/null +++ b/tests/test_tempest.gd @@ -0,0 +1,130 @@ +extends SceneTree +var failures := 0 +var won_count := 0 +var lost_count := 0 +func check(value: bool, message: String) -> void: + if not value: + push_error(message) + failures += 1 +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var game = load("res://games/tempest/Tempest.tscn").instantiate() + root.add_child(game) + game.set_physics_process(false) + game.game_won.connect(func(): won_count += 1) + game.game_lost.connect(func(): lost_count += 1) + check(game.start_game() and not game.start_game(),"single start") + check(game.phase == "select","start opens selection") + game.rotate_steps(2) + game.confirm_level() + check(game.stage == 5 and game.score == 0 and game.pending_bonus == 16000,"level five bonus is deferred") + game._physics_process(1.5) + check(game.phase == "play","ready transitions") + game.rotate_steps(-1) + check(game.lane == 15,"lane wrapping") + game.fire_timer = 0.0 + game.enemies.assign([game.make_enemy("flipper",15,.9)]) + game.fire() + game._step_play(.1) + check(game.score == 150,"swept shot scores flipper") + game.enemies.assign([game.make_enemy("tanker",15,.6)]) + game.fire_timer = 0 + game.shots.clear() + game.fire() + game._step_play(.2) + check(game.enemies.size() == 2 and game.enemies[0].kind == "flipper","tanker splits on hit") + game.superzap() + check(game.enemies.is_empty() and game.zap_uses == 1,"first zap clears") + game.enemies.assign([game.make_enemy("flipper",0,.1),game.make_enemy("flipper",1,.2)]) + game.zap_flash = 0 + game.superzap() + check(game.enemies.size() == 1 and game.zap_uses == 2,"second zap kills one") + game.zap_flash = 0 + game.superzap() + check(game.enemies.size() == 1,"third zap unavailable") + game.enemies.clear() + game.remaining = 0 + var prior_score: int = game.score + game._step_play(.01) + check(game.phase == "dive" and game.score == prior_score+16000 and game.cleared == 1,"first clear earns bonus") + game._physics_process(1.7) + check(game.phase == "space","dive enters starfield") + game._physics_process(1.3) + check(game.phase == "arrive" and game.stage == 6,"next tube arrives") + game._physics_process(1.7) + check(game.phase == "ready" and game.pending_bonus == 0,"bonus not repeated") + game.cleared = game.stages_to_win + game.phase = "space" + game.phase_time = .1 + game._physics_process(.2) + game._physics_process(1.1) + game._physics_process(1.1) + check(game.phase == "won" and won_count == 1 and not game.is_running,"single room win counts clears not level number") + game.reset_game() + game.start_game() + game._physics_process(10.1) + check(game.phase == "ready" and game.stage == 1,"selection timeout starts default") + game.phase = "play" + game.lives = 1 + game.remaining = 10 + game.enemies.assign([game.make_enemy("flipper",game.lane,1.0)]) + game._step_play(.01) + game._physics_process(1.3) + check(game.phase == "lost" and lost_count == 1,"last life loses") + game.reset_game() + game.start_game() + game.confirm_level() + game.phase = "play" + var enemy: Dictionary = game.make_enemy("flipper",15,.6) + game._start_roll(enemy,1) + check(game.collision_lane(enemy) == 15,"roll collision starts in source lane") + enemy.roll = .1 + check(game.collision_lane(enemy) == 0,"roll collision ends in destination lane") + game.enemies.assign([game.make_enemy("spiker",3,.64)]) + game._step_play(.2) + check(game.spikes.has(3) and game.enemies[0].retreat,"spiker builds and retreats") + game.spikes[3] = .4 + game.phase = "dive" + game.phase_time = .6 + game.lane = 3 + game.cleared = 1 + var prior_lives: int = game.lives + game._physics_process(.01) + check(game.phase == "death" and game.lives == prior_lives-1 and game.cleared == 0,"warp spike collision") + var board = game.get_node("VectorBoard") + var center: Vector2 = (board.rim(0,0)+board.rim(8,0))*.5 + check(is_equal_approx(center.x,320) and center.y > 410,"vanishing point centered and low") + game.stage = 9 + game.phase = "play" + game.lane = 0 + game.rotate_steps(-1) + check(game.lane == 0,"open tube does not wrap") + game.lane = 15 + game.rotate_steps(1) + check(game.lane == 15,"open tube right boundary") + game.stages_to_win = 0 + game.phase = "space" + game.phase_time = .1 + game._physics_process(.2) + check(game.phase == "arrive","endless mode keeps progressing") + # Simulate a longer random play sequence to exercise rendering data and AI. + game.reset_game() + game.start_game() + game.selection = 4 + game.confirm_level() + game.phase = "play" + game.lives = 999 + for i in range(3600): + if i%6 == 0: game.rotate_steps(1) + game.fire_timer = maxf(0,game.fire_timer-1.0/60) + game.fire() + game._physics_process(1.0/60) + check(game.score >= 0,"minute of simulation completes") + print("Tempest v2 checks complete: ",failures," failures") + game.get_node("VectorAudio").stop() + await create_timer(.2).timeout + game.queue_free() + await process_frame + quit(failures) + diff --git a/tests/test_tempest.gd.uid b/tests/test_tempest.gd.uid new file mode 100644 index 0000000..b91391a --- /dev/null +++ b/tests/test_tempest.gd.uid @@ -0,0 +1 @@ +uid://c7pqujf6xdhln diff --git a/tests/test_victory.gd b/tests/test_victory.gd new file mode 100644 index 0000000..5861a56 --- /dev/null +++ b/tests/test_victory.gd @@ -0,0 +1,29 @@ +extends SceneTree +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var game=load("res://games/galaga/Galaga.tscn").instantiate() + root.add_child(game) + game.set_physics_process(false) + game.start_game() + var events := [] + game.game_won.connect(func(): events.append("won")) + game.state="playing" + game._bullet(Vector2(100,200),Vector2(0,-100),true) + game.win_game() + assert(game.state=="victory" and events.is_empty()) + game._tick(0.5) + assert(game.bullets[0].node.position.y==150) + assert(game.state=="victory") + game.win_game() + game._tick(1.01) + assert(game.state=="won" and not game.is_running and events.size()==1) + var before: float=game.clock + game._physics_process(0.5) + assert(game.clock>before) + game.reset_game() + assert(game.state=="idle") + print("VICTORY TRANSITION PASS") + game.queue_free() + await process_frame + quit() diff --git a/tests/test_victory.gd.uid b/tests/test_victory.gd.uid new file mode 100644 index 0000000..7558954 --- /dev/null +++ b/tests/test_victory.gd.uid @@ -0,0 +1 @@ +uid://dv2jpcurcud0l diff --git a/tools/pi_gpio/.gitignore b/tools/pi_gpio/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/tools/pi_gpio/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/tools/pi_gpio/README.md b/tools/pi_gpio/README.md new file mode 100644 index 0000000..e87b025 --- /dev/null +++ b/tools/pi_gpio/README.md @@ -0,0 +1,81 @@ +# Real GPIO on the Raspberry Pi + +The former HardwareManager only logged `[MOCK GPIO]`; it could not light an LED. The standalone launcher also did not forward game events to hardware. Both paths are now connected. GPIO numbers in HardwareAction resources are **BCM numbers**, not physical header positions. + +## 1. Wire the LED (Pi powered off) + +For a typical small red LED: + +```text +physical pin 11 (BCM GPIO17) -> 330–470 ohm resistor -> LED anode (long leg) +LED cathode (short leg / flat side) -> physical pin 6 (GND) +``` + +Keep the LED's legs in different breadboard rows. The resistor must be in series, not bypassed by another wire. Physical pin **17 is 3.3V power**, not GPIO17. Do not use the 5V rail for this test. GPIO is 3.3V logic; coin dispensers, relay coils and other powered loads require an appropriate driver, not a direct GPIO connection. + +Pinout and LED reference: [GPIO Zero recipes](https://gpiozero.readthedocs.io/en/stable/recipes.html#pin-numbering). + +## 2. Install dependencies on the Pi + +Use a current Raspberry Pi OS installation and its packaged GPIO Zero/lgpio support (Pi 4 or Pi 5): + +```sh +sudo apt update +sudo apt install python3-gpiozero python3-lgpio +``` + +The account running the helper needs access to `/dev/gpiochip*`. Raspberry Pi OS normally provides this via the `gpio` group. If you get a permission error, inspect `ls -l /dev/gpiochip*` and `groups`; if needed, run `sudo usermod -aG gpio "$USER"`, then log out and back in. The game itself does not need to run as root. + +## 3. Test the wiring independently + +Before running the bridge, run this on the Pi. The LED should remain lit for two seconds, then turn off: + +```sh +GPIOZERO_PIN_FACTORY=lgpio python3 -c 'from gpiozero import LED; from time import sleep; led=LED(17); led.on(); sleep(2); led.off(); led.close()' +``` + +If this fails, fix the reported GPIO permissions/package error or check the resistor, LED orientation, breadboard connections and pin numbering before testing the game. A successful command without light does not prove the wiring is correct. + +## 4. Start the GPIO helper + +Copy **pi_gpio_bridge.py** from this folder alongside the exported game on the Pi. It is a separate Python program, not an embedded Godot resource. Keep it running in a terminal: + +```sh +GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17 +``` + +It listens only on `127.0.0.1:8765` and owns the allowed pins. Do not run another LED script at the same time. It accepts only the configured controller ID and listed BCM pins. Add `,20` to the pin list later if you wire the configured Start Light as well. Do not enable other pins without checking their wiring. + +## 5. Run the exported game with real outputs + +In another terminal, replacing `ArcadeEscape.arm64` with your actual executable filename: + +```sh +./ArcadeEscape.arm64 -- --gpio=real --gpio-test +``` + +`--gpio-test` sends a two-second pulse on cabinet 01's coin-release output (BCM17) at startup. No game win is required. The launcher shows GPIO status and the helper terminal logs each accepted command. Once that works, omit the test flag: + +```sh +./ArcadeEscape.arm64 -- --gpio=real +``` + +Choose Pac-Man and win its configured objective. The launcher's **Hardware Scenario** defaults to `proof_of_concept.tres`: `game_won` -> cabinet 01 `coin_release` -> BCM17, active-high, **600 milliseconds**. This is only a short flash. Other launcher games only send hardware events if they have a matching assignment in the selected Hardware Scenario. Add their event mappings in your resources as needed. + +Without `--gpio=real`, runtime outputs remain simulated, including on Windows. Real mode does not silently fall back to simulation if the helper fails. Connection failures, wrong-controller requests and rejected pins appear in the launcher status/log. The editor's room simulation should be run without real mode. This bridge controls the Pi running the game, not GPIO on a different Pi across the network. + +## Timing and stopping + +Managed room rewards include a unique `request_id`. The updated helper records these in `~/.local/state/arcade_gpio/requests.sqlite3` (override with `--ledger`). Repeated IDs return the earlier result without driving the pin again. An interrupted request with no recorded outcome is marked uncertain and will not be replayed. Keep this ledger across restarts; standalone requests without an ID retain the original retrigger behavior. + +The helper controls pulse timing; repeated pulses restart that pin's duration. Digital outputs accept `context.active=false` to switch off. Active-low polarity is supported by GPIO Zero. Conflicting polarity on the same pin is rejected until the helper restarts. + +The game sends a heartbeat every second. If it exits or disconnects, outputs become inactive within roughly three seconds; short pulses still finish at their own deadline. Ctrl+C/SIGTERM shuts down the helper and deactivates its outputs. This is a software watchdog, not a hardware safety circuit; power loss or forcibly killing the helper cannot guarantee pin state. Test with the LED before attaching real actuators. + +Keep the helper running while restarting games with Escape. For unattended boot, launch the helper under your Pi's service manager using the same account/environment and start the game with `--gpio=real`. + +## Validation performed off-Pi + +Bridge tests cover pulse timing/retriggering, active-low configuration, digital off, watchdog expiry, rejected pins/controllers, and shutdown. The Godot integration test sends a real HTTP request from a Pac-Man win to a test bridge with fake GPIO devices. Physical voltage and your wiring must still be verified on the Pi. + +References: [GPIO Zero installation](https://gpiozero.readthedocs.io/en/stable/installing.html), [output device API](https://gpiozero.readthedocs.io/en/stable/api_output.html). diff --git a/tools/pi_gpio/pi_gpio_bridge.py b/tools/pi_gpio/pi_gpio_bridge.py new file mode 100644 index 0000000..c2a66c5 --- /dev/null +++ b/tools/pi_gpio/pi_gpio_bridge.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Local Godot -> gpiozero bridge. BCM pins; run on the Pi, not the PC.""" +import argparse +import json +import signal +import threading +import time +import hashlib +import sqlite3 +from pathlib import Path +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class Outputs: + def __init__(self, factory, controller, allowed, clock=time.monotonic, ledger=":memory:"): + self.factory, self.controller, self.allowed = factory, controller, set(allowed) + self.clock = clock + self.last_seen = clock() + self.pins = {} + self.lock = threading.RLock() + self.ledger = sqlite3.connect(ledger, check_same_thread=False) + self.ledger.execute("CREATE TABLE IF NOT EXISTS requests (id TEXT PRIMARY KEY, digest TEXT NOT NULL, result TEXT)") + + def heartbeat(self): + with self.lock: + self.last_seen = self.clock() + + def command(self, data): + if not isinstance(data, dict): raise ValueError("Expected a JSON object") + request_id = data.get("request_id") + if request_id is None: return self._apply_command(data) + if not isinstance(request_id, str) or not 1 <= len(request_id) <= 100: + raise ValueError("Invalid request_id") + digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest() + with self.lock: + row = self.ledger.execute("SELECT digest, result FROM requests WHERE id=?", (request_id,)).fetchone() + if row: + if row[0] != digest: raise ValueError("request_id reused with a different output") + return json.loads(row[1]) if row[1] else {"ok": False, "error": "Prior output outcome uncertain; refusing to pulse again"} + # Commit before touching the pin. A crash can miss a reward, but cannot replay it. + with self.ledger: + self.ledger.execute("INSERT INTO requests VALUES (?, ?, NULL)", (request_id, digest)) + try: + result = self._apply_command(data) + except Exception as exc: + result = {"ok": False, "error": str(exc)} + with self.ledger: + self.ledger.execute("UPDATE requests SET result=? WHERE id=?", (json.dumps(result), request_id)) + return result + + def _apply_command(self, data): + if not isinstance(data, dict): + raise ValueError("Expected a JSON object") + if data.get("controller") != self.controller: + raise ValueError("Wrong controller_id for this Pi") + pin = data.get("pin") + if type(pin) is not int or pin not in self.allowed: + raise ValueError("BCM pin is not enabled in the bridge --pins list") + polarity = data.get("active_high") + if type(polarity) is not bool: + raise ValueError("active_high must be boolean") + mode = data.get("mode") + if mode not in ("pulse", "set"): + raise ValueError("mode must be pulse or set") + duration = data.get("duration_ms", 0) + if mode == "pulse" and (type(duration) is not int or not 1 <= duration <= 10000): + raise ValueError("Pulse duration must be 1..10000 ms") + active = data.get("active", True) + if type(active) is not bool: + raise ValueError("active must be boolean") + with self.lock: + if pin not in self.pins: + device = self.factory(pin, active_high=polarity, initial_value=False) + self.pins[pin] = {"device": device, "polarity": polarity, "deadline": None} + entry = self.pins[pin] + if entry["polarity"] != polarity: + raise ValueError("Pin polarity differs from its earlier configuration; restart bridge to change") + self.last_seen = self.clock() + if mode == "pulse": + entry["device"].on() + # Latest pulse restarts the duration; an older timer cannot switch it off. + entry["deadline"] = self.clock() + duration / 1000 + else: + entry["device"].on() if active else entry["device"].off() + entry["deadline"] = None + return {"ok": True, "pin": pin, "active": mode == "pulse" or active} + + def tick(self): + with self.lock: + now = self.clock() + for entry in self.pins.values(): + if now - self.last_seen > 3 or (entry["deadline"] is not None and now >= entry["deadline"]): + entry["device"].off() + entry["deadline"] = None + + def close(self): + with self.lock: + for entry in self.pins.values(): + try: + entry["device"].off() + finally: + entry["device"].close() + self.ledger.close() + + +def handler_for(outputs): + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + try: + if self.headers.get("Origin"): + raise ValueError("Browser requests are not supported") + length = int(self.headers.get("Content-Length", "0")) + if not 0 <= length <= 4096: + raise ValueError("Request is too large") + data = json.loads(self.rfile.read(length) or b"{}") + if self.path == "/heartbeat": + outputs.heartbeat() + result = {"ok": True} + elif self.path == "/output": + result = outputs.command(data) + print("GPIO:", data, flush=True) + else: + raise ValueError("Unknown endpoint") + code = 200 + except Exception as exc: + result, code = {"ok": False, "error": str(exc)}, 400 + print("GPIO ERROR:", exc, flush=True) + body = json.dumps(result).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + return Handler + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--controller", default="cabinet_01_gpio") + parser.add_argument("--pins", default="17", help="BCM pin allowlist, comma separated") + parser.add_argument("--ledger", default=str(Path.home() / ".local/state/arcade_gpio/requests.sqlite3")) + args = parser.parse_args() + pins = [int(n) for n in args.pins.split(",")] + if not pins or any(pin < 0 or pin > 27 for pin in pins): + parser.error("Use BCM header GPIO numbers 0..27") + from gpiozero import OutputDevice + Path(args.ledger).parent.mkdir(parents=True, exist_ok=True) + outputs = Outputs(OutputDevice, args.controller, pins, ledger=args.ledger) + server = HTTPServer(("127.0.0.1", 8765), handler_for(outputs)) + server.timeout = .05 + stop = threading.Event() + signal.signal(signal.SIGTERM, lambda *_: stop.set()) + signal.signal(signal.SIGINT, lambda *_: stop.set()) + + def watchdog(): + while not stop.wait(.02): + outputs.tick() + + worker = threading.Thread(target=watchdog, daemon=True) + worker.start() + print(f"GPIO bridge ready: controller={args.controller}, BCM pins={pins}, localhost:8765", flush=True) + try: + while not stop.is_set(): + server.handle_request() + finally: + stop.set() + worker.join() + outputs.close() + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tools/room_controller/.gitignore b/tools/room_controller/.gitignore new file mode 100644 index 0000000..b2e3bd6 --- /dev/null +++ b/tools/room_controller/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.sqlite3* diff --git a/tools/room_controller/README.md b/tools/room_controller/README.md new file mode 100644 index 0000000..9014b3b --- /dev/null +++ b/tools/room_controller/README.md @@ -0,0 +1,139 @@ +# Arcade room controller and browser operator + +This controller is independent of Odin. It uses Python's standard library, authenticated HTTP, and SQLite; no Odin services or dependencies are needed. + +## Available now + +- A touch-friendly browser operator for the Mac mini and iPads, served by the controller itself. +- Room boundary, walls, cabinet positions, and reference device positions exported from the existing editor resources. +- Independent cabinet connections, game sessions, readiness, win/loss states, and command history. +- Scenario preparation across multiple cabinets. The entire assignment list is validated before commands are queued. Each cabinet acknowledges separately; this is not an atomic room-wide load. +- Compatible game lists and server-side and cabinet-side hardware checks. Games load from the cabinet's installed export; this does not transfer game binaries. +- Designer event mappings for `game_activated`, `game_started`, `game_won`, and `game_lost`. Other event mappings and nonstandard variants are rejected instead of silently ignored. +- Manual game assignments without scenario hardware rewards. +- Idempotent GPIO commands through the existing local bridge. Accepted commands do not prove a coin was delivered or a physical device changed. + +External sensor inputs, detailed designer-defined puzzle steps, room timers/hints, and coordinated finale game variants are future work. The map's device dots are reference positions, not live sensor indicators. Runtime phases and hardware command acknowledgements are live. + +## Deployment layout + +```text +Mac mini / PC: Python controller :8766 + persistent SQLite database + ^ browser operator (Mac / iPad, same controller URL) + ^ managed Godot cabinet clients (one identity per Pi) + -> local GPIO bridge 127.0.0.1:8765 -> cabinet hardware +``` + +Use a trusted isolated room LAN. This HTTP configuration does not encrypt traffic; use TLS termination before using an untrusted network. Do not expose it directly to the internet. The GPIO bridge stays on each Pi's loopback address, not the controller address. + +## 1. Export the designer catalog + +The source of truth remains the existing Godot resources: + +- `data/project_database.tres`: registered cabinets, games, and scenarios. +- `data/rooms/arcade_room.tres`: room layout and cabinet/device placements. +- `data/cabinets/*.tres`: cabinet IDs, capabilities, and hardware actions. +- `data/games/*.tres`: requirements and alternative control sets. + +After saving designer changes, run this on the development computer (replace `godot` with your Godot executable): + +```sh +godot --headless --path . --script tools/room_controller/export_catalog.gd +``` + +This writes `tools/room_controller/catalog.json`. Copy **server.py, operator.html, and catalog.json together** onto the controller machine. Restart the controller after updating the catalog. Re-export/redeploy Godot when game, cabinet, or hardware resources change; its own compatibility check uses those packaged resources. + +Capabilities describe physical controls; they do not replace the per-device control mapping wizard. Configure both. Common requirements must all be present; when a game has control alternatives, one entire alternative must also be present. Tempest accepts horizontal joystick, spinner, or trackball plus two buttons and Start. Robotron accepts dual 8-way joysticks or one 8-way joystick plus four firing buttons, with Start. Missile Command requires trackball, three fire buttons, and Start. + +The sample cabinet profiles are intentionally not expanded to claim hardware they may not have. Cabinet 01/02 currently list one action button; Cabinet 03 has no room placement. Update these resources to match the physical cabinets before expecting all seven games to appear in their compatible lists. + +## 2. Start the controller on the Mac mini (or PC) + +Python 3 is needed on the controller; Godot is not. Generate a different random token of at least 16 characters for the operator and for each cabinet. Keep tokens outside source control. + +macOS / Linux: + +```sh +export ARCADE_OPERATOR_TOKEN='your-generated-operator-token' +export ARCADE_CABINET_TOKENS='{"cabinet_01":"your-generated-cabinet-one-token","cabinet_02":"your-generated-cabinet-two-token"}' +python3 server.py --bind 0.0.0.0 --database /absolute/path/arcade_room.sqlite3 +``` + +Windows PowerShell, from the project directory: + +```powershell +$env:ARCADE_OPERATOR_TOKEN = 'your-generated-operator-token' +$env:ARCADE_CABINET_TOKENS = '{"cabinet_01":"your-generated-cabinet-one-token","cabinet_02":"your-generated-cabinet-two-token"}' +py -3 tools/room_controller/server.py --bind 0.0.0.0 --database arcade_room.sqlite3 +``` + +The existing `ARCADE_CABINET_TOKEN` environment variable still provisions `cabinet_01` if that ID is not already in the JSON object. Cabinets without provisioned tokens cannot connect. Each cabinet token is bound to its ID and cannot control other cabinets or use the operator API. + +Keep the database path stable. Allow inbound TCP 8766 on the room LAN. Existing single-cabinet state is migrated into cabinet 01's record; do not delete the database to upgrade. Back it up before deployment. Connectivity always starts offline until a cabinet polls again. + +## 3. Start each Pi cabinet + +Deploy the updated Godot export: the fleet controller requires protocol 2 (`arcade-cabinet-2`). Old cabinet exports are rejected so they cannot incorrectly load Pac-Man for a different assignment. + +First run the normal launcher to map that Pi's physical controls. The managed client uses the saved mappings. Escape still quits. + +Configure `user://arcade_network.cfg` on each Pi: + +```ini +[network] +url="http://CONTROLLER_IP:8766" +cabinet_id="cabinet_01" +cabinet_token="your-generated-cabinet-one-token" +``` + +Godot CFG strings use double quotes. The file belongs in the app's user data folder, not beside the executable: normally `~/.local/share/godot/app_userdata/ArcadeEscape/` on Linux and `%APPDATA%/Godot/app_userdata/ArcadeEscape/` on Windows. URL/token environment variables override the file. `--cabinet=cabinet_02` overrides the cabinet ID. + +Start the [GPIO bridge](../pi_gpio/README.md) on each Pi with that cabinet's controller ID and configured BCM pin allowlist. For the Cabinet 01 coin-release LED test: + +```sh +GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17 +./ArcadeEscape.arm64 -- --managed-cabinet --cabinet=cabinet_01 --gpio=real +``` + +Run those in separate terminals/services. Additional mapped actions require their actual pins in the bridge's allowlist. Do not use the cabinet 01 bridge identity on cabinet 02. Only run one managed game process per cabinet ID. The GPIO helper uses port **8765 locally**, while the room URL uses **8766**. + +For desktop game/network testing, omit `--gpio=real`. Hardware actions report failure in simulation mode; they do not claim physical success. + +## 4. Use the operator + +Open `http://CONTROLLER_IP:8766/` in the Mac or iPad browser and enter the operator token. No iPad app or Godot installation is needed. The token stays in page memory and is cleared by Lock, reload, or closing the page. + +1. Verify the expected cabinets show ONLINE. +2. Select a scenario and **Prepare assigned cabinets**. Each cabinet changes from PREPARING to READY only after its game loads. An offline or incompatible assignment prevents the entire dispatch. +3. Select a cabinet on the map or card, then **Start game**, or use the cabinet's mapped Start button. +4. Watch its runtime phase, hardware command statuses, and activity history. Win outputs follow that scenario's event mappings. +5. **Reset cabinet…** ends its current session. Reset active cabinets before preparing another scenario. +6. For an individual game override, select a compatible game and **Prepare game**. This deliberately removes scenario event mappings for that cabinet; no automatic coin reward is inherited. + +The room map uses the saved designer positions. Cabinets without placements remain selectable through their cards. This is an operator view; continue editing layout/scenarios in Godot, then re-export the catalog. + +The older exported `--operator` Godot window remains a Cabinet 01 quick-control view. Its Prepare Pac-Man button prepares the `proof_of_concept` scenario and keeps that scenario's reward. Use the browser for multiple cabinets and other game assignments. + +## Reconnects and uncertain hardware results + +- Cabinets become offline after roughly four seconds without a poll. The browser disables actions when the controller connection is unavailable. +- Commands expire after 15 seconds. Expired commands are not automatically reissued. +- Every command carries its cabinet session and unique ID. Old-session reports are ignored after Reset/Prepare. +- Duplicate command deliveries reuse the agent outcome; the GPIO bridge persists request IDs across helper restarts to prevent replayed pulses. +- A cabinet process restart requires preparation again. A reconnect by the same process can deliver its buffered reports. +- An accepted GPIO command confirms the bridge API call, not physical delivery. A timeout/failure is uncertain; inspect the hardware before trying again. There is no automatic reward retry. +- Keep the GPIO request ledger at its configured path, default `~/.local/state/arcade_gpio/requests.sqlite3`. Do not delete it to replay a command. +- Reset unloads the game and clears the controller session; it is not a physical hardware reset. Existing bridge pulse timers/watchdog still apply. + +## Verification + +```sh +python3 -m unittest discover -s tests/room_network -v +python3 -m unittest discover -s tests/gpio -v +python3 tests/room_network/check_integration.py /path/to/godot +godot --headless --path . --script tests/room_network/check_capabilities.gd +``` + +The integration test starts temporary localhost services on 8765/8766 and two actual headless Godot clients. It prepares Pac-Man via a scenario and Galaga via a manual assignment, forces wins only in the test harness, and verifies exactly one fake GPIO activation. Do not run while real services occupy those ports. + +Deployment checklist: verify both cabinet IDs/tokens, test physical controls on each Pi, prepare a scenario and confirm both game names, win and check the intended LED/output only, reset, then unplug/reconnect one cabinet and confirm its offline/recovery behavior. Finally test the browser on the actual iPad; desktop browser checks do not replace device testing. diff --git a/tools/room_controller/catalog.json b/tools/room_controller/catalog.json new file mode 100644 index 0000000..db63a48 --- /dev/null +++ b/tools/room_controller/catalog.json @@ -0,0 +1,306 @@ +{ + "cabinets": { + "cabinet_01": { + "actions": [ + "coin_release", + "start_light" + ], + "capabilities": [ + "joystick_4way", + "button_1", + "start_button", + "joystick_horizontal" + ], + "name": "Cabinet 01", + "position": [ + -5.94849967956543, + 0.147500038146973 + ] + }, + "cabinet_02": { + "actions": [ + "coin_release", + "start_light", + "glitch_code" + ], + "capabilities": [ + "joystick_4way", + "button_1", + "start_button", + "joystick_horizontal" + ], + "name": "Cabinet 02", + "position": [ + -3.51539182662964, + 0.142745211720467 + ] + }, + "cabinet_03": { + "actions": [ + "coin_release" + ], + "capabilities": [ + "spinner", + "button_1", + "start_button" + ], + "name": "Cabinet 03", + "position": null + } + }, + "games": { + "frogger": { + "alternatives": [], + "name": "Frogger", + "requirements": [ + "joystick_4way", + "start_button" + ] + }, + "galaga": { + "alternatives": [], + "name": "Galaga", + "requirements": [ + "joystick_horizontal", + "button_1", + "start_button" + ] + }, + "missile_command": { + "alternatives": [], + "name": "Missile Command", + "requirements": [ + "trackball", + "button_1", + "button_2", + "button_3", + "start_button" + ] + }, + "pacman": { + "alternatives": [], + "name": "Pac-Man", + "requirements": [ + "joystick_4way", + "start_button" + ] + }, + "robotron": { + "alternatives": [ + [ + "dual_joystick_8way" + ], + [ + "joystick_8way", + "button_1", + "button_2", + "button_3", + "button_4" + ] + ], + "name": "Robotron 2084", + "requirements": [ + "start_button" + ] + }, + "space_invaders": { + "alternatives": [], + "name": "Space Invaders", + "requirements": [ + "joystick_horizontal", + "button_1", + "start_button" + ] + }, + "tempest": { + "alternatives": [ + [ + "joystick_horizontal" + ], + [ + "spinner" + ], + [ + "trackball" + ] + ], + "name": "Tempest", + "requirements": [ + "button_1", + "button_2", + "start_button" + ] + } + }, + "layout": { + "boundary": [ + [ + -7.31519985198975, + -0.609600007534027 + ], + [ + -2.13359999656677, + -0.609600007534027 + ], + [ + -2.13359999656677, + 7.61999988555908 + ], + [ + -7.31519985198975, + 7.61999988555908 + ] + ], + "devices": [ + { + "id": "coin_release_a", + "name": "Coin Release A", + "position": [ + -6.0149998664856, + 0.555000007152557 + ] + }, + { + "id": "coin_release_b", + "name": "Coin Release B", + "position": [ + -3.57996416091919, + 0.568386673927307 + ] + }, + { + "id": "light_a", + "name": "Light A", + "position": [ + -6.46999979019165, + 0.560000002384186 + ] + }, + { + "id": "light_b", + "name": "Light B", + "position": [ + -4.0322380065918, + 0.576531648635864 + ] + }, + { + "id": "glitch_code", + "name": "Glitch Code", + "position": [ + -2.75500297546387, + 1.51234555244446 + ] + } + ], + "walls": [ + [ + [ + -7.01039981842041, + -0.304800003767014 + ], + [ + -2.43840003013611, + -0.304800003767014 + ] + ], + [ + [ + -2.43840003013611, + -0.304800003767014 + ], + [ + -2.43840003013611, + 7.31519985198975 + ] + ], + [ + [ + -2.43840003013611, + 7.31519985198975 + ], + [ + -7.01039981842041, + 7.31519985198975 + ] + ], + [ + [ + -7.01039981842041, + 7.31519985198975 + ], + [ + -7.01039981842041, + -0.304800003767014 + ] + ] + ] + }, + "scenarios": { + "galaga_proof_of_concept": { + "assignments": [ + { + "cabinet": "cabinet_01", + "game": "galaga", + "mappings": { + "game_won": [ + "coin_release" + ] + }, + "variant": "standard" + } + ], + "name": "Galaga Proof of Concept" + }, + "proof_of_concept": { + "assignments": [ + { + "cabinet": "cabinet_01", + "game": "pacman", + "mappings": { + "game_won": [ + "coin_release" + ] + }, + "variant": "standard" + } + ], + "name": "Proof of Concept" + }, + "two_cabinet_test": { + "assignments": [ + { + "cabinet": "cabinet_01", + "game": "pacman", + "mappings": { + "game_started": [ + "start_light" + ], + "game_won": [ + "coin_release" + ] + }, + "variant": "standard" + }, + { + "cabinet": "cabinet_02", + "game": "galaga", + "mappings": { + "game_activated": [ + "glitch_code" + ], + "game_started": [ + "start_light" + ], + "game_won": [ + "coin_release" + ] + }, + "variant": "standard" + } + ], + "name": "Two Cabinet Test" + } + }, + "version": 1 +} \ No newline at end of file diff --git a/tools/room_controller/export_catalog.gd b/tools/room_controller/export_catalog.gd new file mode 100644 index 0000000..22556eb --- /dev/null +++ b/tools/room_controller/export_catalog.gd @@ -0,0 +1,38 @@ +extends SceneTree +## Export designer resources to the controller's deployment catalog. +func _initialize() -> void: + call_deferred("run") +func run() -> void: + var database: ProjectDatabase = load("res://data/project_database.tres") + var room: RoomDefinition = load("res://data/rooms/arcade_room.tres") + var catalog := {"version": 1, "games": {}, "cabinets": {}, "scenarios": {}, "layout": {"boundary": [], "walls": [], "devices": []}} + for game in database.games: + catalog.games[str(game.game_id)] = {"name": game.display_name, "requirements": Array(game.required_capabilities), "alternatives": Array(game.control_alternatives)} + for cabinet in database.cabinets: + var actions: Array = [] + if cabinet.hardware_config != null: + for action in cabinet.hardware_config.actions: actions.append(str(action.action_id)) + var placement := room.get_cabinet_placement(cabinet.cabinet_id) + catalog.cabinets[str(cabinet.cabinet_id)] = {"name": cabinet.display_name, "capabilities": Array(cabinet.capabilities), "actions": actions, "position": [placement.position.x, placement.position.y] if placement else null} + for scenario in database.scenarios: + var assignments: Array = [] + for assignment in scenario.assignments: + if assignment.cabinet == null or assignment.game == null: continue + var mappings := {} + for mapping in assignment.event_mappings: + if not mappings.has(str(mapping.game_event)): mappings[str(mapping.game_event)] = [] + mappings[str(mapping.game_event)].append_array(Array(mapping.hardware_actions)) + assignments.append({"cabinet": str(assignment.cabinet.cabinet_id), "game": str(assignment.game.game_id), "variant": str(assignment.variant), "mappings": mappings}) + catalog.scenarios[str(scenario.scenario_id)] = {"name": scenario.display_name, "assignments": assignments} + for point in room.boundary_points: catalog.layout.boundary.append([point.x, point.y]) + for wall in room.walls: catalog.layout.walls.append([[wall.start.x, wall.start.y], [wall.end.x, wall.end.y]]) + for device in room.devices: + catalog.layout.devices.append({"id": str(device.device_id), "name": device.display_name, "position": [device.position.x, device.position.y]}) + var file := FileAccess.open("res://tools/room_controller/catalog.json", FileAccess.WRITE) + if file == null: + push_error("Cannot write controller catalog") + quit(1) + return + file.store_string(JSON.stringify(catalog, "\t")) + print("Exported controller catalog from editor resources") + quit() diff --git a/tools/room_controller/export_catalog.gd.uid b/tools/room_controller/export_catalog.gd.uid new file mode 100644 index 0000000..fd06d0b --- /dev/null +++ b/tools/room_controller/export_catalog.gd.uid @@ -0,0 +1 @@ +uid://bj7g63dhd8r1t diff --git a/tools/room_controller/operator.html b/tools/room_controller/operator.html new file mode 100644 index 0000000..5152ae5 --- /dev/null +++ b/tools/room_controller/operator.html @@ -0,0 +1,28 @@ + + +Arcade · Room operator + +
ARCADE ESCAPE / OPERATIONS

Room control

Not connected
+
+

Connect to this room

Use the operator token configured on this controller. It stays in memory until this page closes or locks.

+
diff --git a/tools/room_controller/server.py b/tools/room_controller/server.py new file mode 100644 index 0000000..2e16d08 --- /dev/null +++ b/tools/room_controller/server.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Arcade room controller and browser operator; Python standard library only.""" +import argparse +import hmac +import json +import os +from pathlib import Path +import sqlite3 +import time +import uuid +from urllib.parse import urlparse +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class Room: + def __init__(self, database, clock=time.time, cabinet_id="cabinet_01"): + self.clock = clock + self.cabinet_id = cabinet_id + self.db = sqlite3.connect(database, check_same_thread=False) + self.db.execute("CREATE TABLE IF NOT EXISTS room (id INTEGER PRIMARY KEY, value TEXT NOT NULL)") + self.db.execute("CREATE TABLE IF NOT EXISTS cabinet_rooms (id TEXT PRIMARY KEY, value TEXT NOT NULL)") + row = self.db.execute("SELECT value FROM cabinet_rooms WHERE id=?", (cabinet_id,)).fetchone() + if row is None and cabinet_id == "cabinet_01": + row = self.db.execute("SELECT value FROM room WHERE id=1").fetchone() + self.state = json.loads(row[0]) if row else dict( + session="", phase="idle", reward="not requested", commands=[], seen=[], history=[]) + # Never carry live connectivity across a controller restart. + self.last_seen = 0 + self.boot = self.state.get("boot", "") + self.version = "unknown" + self.gpio = "unknown" + self.state.setdefault("game", "pacman") + self.state.setdefault("mappings", {"game_won": ["coin_release"]}) + self.save() + + def save(self): + with self.db: + self.db.execute("INSERT OR REPLACE INTO cabinet_rooms VALUES (?, ?)", (self.cabinet_id, json.dumps(self.state))) + + def log(self, message): + self.state["history"].append(dict(time=self.clock(), message=message)) + self.state["history"] = self.state["history"][-100:] + + def online(self): + return self.last_seen > 0 and self.clock() - self.last_seen < 4 + + def snapshot(self): + self.expire() + return dict(self.state, online=self.online(), cabinet=self.cabinet_id, + version=self.version, gpio=self.gpio, + last_seen=self.last_seen, protocol=2) + + def expire(self): + for command in self.state["commands"]: + if command["status"] == "queued" and self.clock() > command["expires"]: + command["status"] = "unknown" + if command["type"] in ("reward", "output"): + self.state["reward" if command["type"] == "reward" else "last_output"] = "unknown — inspect hardware; not automatically retried" + else: + self.state["phase"] = "needs_prepare" + self.log(command["type"] + " acknowledgement timed out") + self.save() + + def command(self, kind): + command = dict(id=str(uuid.uuid4()), session=self.state["session"], type=kind, + status="queued", expires=self.clock() + 15) + if kind == "prepare": + command.update(game=self.state["game"], variant=self.state.get("variant", "standard")) + self.state["commands"].append(command) + self.log("Queued " + kind) + return command + + def operator(self, action): + self.expire() + if not self.online(): raise ValueError("Cabinet is offline; no command sent") + if any(c["status"] == "queued" and c["type"] not in ("output", "reward") for c in self.state["commands"]): + raise ValueError("Wait for the pending command or its timeout") + if action in ("prepare", "reset"): + if action == "prepare" and self.state["phase"] not in ("idle", "won", "lost", "needs_prepare"): + raise ValueError("Reset the active session before preparing again") + self.state.update(session=str(uuid.uuid4()), phase="preparing" if action == "prepare" else "resetting", + reward="not requested", commands=[], seen=[]) + self.command(action) + elif action == "start": + if self.state["phase"] != "ready": raise ValueError("Prepare the cabinet first") + self.state["phase"] = "starting" + self.command("start") + else: + raise ValueError("Unknown operator action") + self.save() + return self.snapshot() + + def poll(self, data): + if data.get("cabinet") != self.cabinet_id or data.get("protocol") not in (1, 2): + raise ValueError("Wrong cabinet or protocol version") + boot = data.get("boot") + if not isinstance(boot, str) or not 1 <= len(boot) <= 100: + raise ValueError("Missing cabinet boot identity") + if self.boot and boot != self.boot: + self.state.update(session="", phase="needs_prepare", commands=[], seen=[], reward="not requested") + self.log("Cabinet restarted; operator must prepare a new session") + self.boot = boot + self.state["boot"] = boot + self.last_seen = self.clock() + self.version = str(data.get("version", "unknown"))[:80] + self.gpio = str(data.get("gpio", "unknown"))[:100] + self.expire() + reports = data.get("reports", []) + if not isinstance(reports, list) or len(reports) > 50: raise ValueError("Invalid reports") + for report in reports: + if not isinstance(report, dict): raise ValueError("Invalid report") + if report.get("session") != self.state["session"]: continue + if report.get("kind") == "ack": + self.ack(report) + elif report.get("kind") == "event": + self.event(report) + self.save() + return dict(ok=True, session=self.state["session"], + commands=[c for c in self.state["commands"] if c["status"] == "queued"]) + + def ack(self, report): + command = next((c for c in self.state["commands"] if c["id"] == report.get("id")), None) + if command is None or command["status"] != "queued": return + success = report.get("ok") is True + command["status"] = "accepted" if success else "failed" + self.log("%s: %s" % (command["type"], str(report.get("detail", command["status"]))[:200])) + if command["type"] == "output": + self.state["last_output"] = "accepted (physical state unconfirmed)" if success else "failed or uncertain" + elif command["type"] == "reward": + statuses = [c["status"] for c in self.state["commands"] if c["type"] == "reward"] + if any(s in ("failed", "unknown") for s in statuses): + self.state["reward"] = "failed or uncertain — inspect cabinet" + elif all(s == "accepted" for s in statuses): + self.state["reward"] = "GPIO accepted (physical delivery unconfirmed)" + else: + self.state["reward"] = "requested" + elif success: + self.state["phase"] = {"prepare": "ready", "reset": "idle", "start": "playing"}[command["type"]] + if command["type"] == "prepare": + for action in self.state["mappings"].get("game_activated", []): + self.command("output")["action_id"] = action + else: + self.state["phase"] = "needs_prepare" + + def event(self, report): + event_id = report.get("id") + if not isinstance(event_id, str) or not 1 <= len(event_id) <= 100: return + if event_id in self.state["seen"]: return + self.state["seen"].append(event_id) + self.state["seen"] = self.state["seen"][-500:] + name = report.get("event") + if name == "started" and self.state["phase"] in ("ready", "starting"): + self.state["phase"] = "playing" + elif name == "won" and self.state["phase"] == "playing": + self.state["phase"] = "won" + if self.state["reward"] == "not requested" and self.state["mappings"].get("game_won"): + self.state["reward"] = "requested" + elif name == "lost" and self.state["phase"] == "playing": + self.state["phase"] = "lost" + else: + return + self.log(self.state["game"] + " " + name) + for action in self.state["mappings"].get("game_" + name, []): + command = self.command("reward" if name == "won" else "output") + command["action_id"] = action + + +class Fleet: + """Editor catalog plus independently acknowledged cabinet sessions.""" + def __init__(self, database, catalog, clock=time.time): + self.catalog = catalog + self.rooms = {key: Room(database, clock, key) for key in catalog["cabinets"]} + + def compatible(self, cabinet, game): + definition = self.catalog["games"][game] + capabilities = set(self.catalog["cabinets"][cabinet]["capabilities"]) + alternatives = definition.get("alternatives", []) + return set(definition["requirements"]) <= capabilities and (not alternatives or any(set(a) <= capabilities for a in alternatives)) + + def snapshot(self): + cabinets = {} + for key, room in self.rooms.items(): + cabinets[key] = dict(room.snapshot(), definition=self.catalog["cabinets"][key], + compatible_games=[g for g in self.catalog["games"] if self.compatible(key, g)]) + # Retain the single-cabinet desktop UI response while the browser offers the fleet view. + first = cabinets.get("cabinet_01", next(iter(cabinets.values()))) + return dict(first, cabinets=cabinets, catalog=self.catalog) + + def validate_assignment(self, item): + cabinet, game = item.get("cabinet"), item.get("game") + if cabinet not in self.rooms or game not in self.catalog["games"]: + raise ValueError("Unknown cabinet or game") + if not self.compatible(cabinet, game): raise ValueError(f"{game} cannot run on {cabinet}: hardware requirements not met") + if item.get("variant", "standard") != "standard": raise ValueError("Only standard variants are supported by this runtime") + allowed = set(self.catalog["cabinets"][cabinet]["actions"]) + for event, actions in item.get("mappings", {}).items(): + if event not in ("game_activated", "game_started", "game_won", "game_lost"): + raise ValueError(f"Scenario event {event} is not implemented in managed runtime") + if not set(actions) <= allowed: raise ValueError("Scenario refers to an unknown hardware action") + room = self.rooms[cabinet] + room.expire() + if not room.online(): raise ValueError(f"{cabinet} is offline") + if room.state["phase"] not in ("idle", "won", "lost", "needs_prepare"): + raise ValueError(f"Reset {cabinet} before preparing a new assignment") + if any(c["status"] == "queued" for c in room.state["commands"]): + raise ValueError(f"{cabinet} has a pending command") + + def operator(self, action, data=None): + data = data or {} + if action == "prepare_scenario": + scenario_id = data.get("scenario") + scenario = self.catalog["scenarios"].get(scenario_id) + if not scenario: raise ValueError("Unknown scenario") + assignments = scenario["assignments"] + if not assignments or len({a["cabinet"] for a in assignments}) != len(assignments): + raise ValueError("Scenario must have unique, nonempty cabinet assignments") + # Validate the whole plan before sending anything to any cabinet. + for item in assignments: self.validate_assignment(item) + for item in assignments: self.prepare(item, scenario_id) + elif action == "prepare": + cabinet = data.get("cabinet", "cabinet_01") + item = dict(cabinet=cabinet, game=data.get("game", "pacman"), mappings={}) + self.validate_assignment(item) + # Manual overrides do not inherit a reward from the previous scenario. + self.prepare(item, "manual override") + else: + cabinet = data.get("cabinet", "cabinet_01") + if cabinet not in self.rooms: raise ValueError("Unknown cabinet") + self.rooms[cabinet].operator(action) + return self.snapshot() + + def prepare(self, item, scenario): + room = self.rooms[item["cabinet"]] + room.state.update(game=item["game"], mappings=item.get("mappings", {}), + variant=item.get("variant", "standard"), scenario=scenario) + room.operator("prepare") + room.log("Assignment from " + scenario) + room.save() + + def poll(self, data): + cabinet = data.get("cabinet") + if data.get("protocol") != 2: raise ValueError("Update cabinet export: controller requires protocol 2") + if cabinet not in self.rooms: raise ValueError("Unknown cabinet") + return self.rooms[cabinet].poll(data) + + +def handler_for(room, operator_token, cabinet_token): + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path not in ("/", "/index.html"): + self.respond(404, {"error": "Not found"}) + return + body = (Path(__file__).parent / "operator.html").read_bytes() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Frame-Options", "DENY") + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + try: + length = int(self.headers.get("Content-Length", "0")) + if not 0 <= length <= 65536: raise ValueError("Request too large") + data = json.loads(self.rfile.read(length) or b"{}") + if not isinstance(data, dict): raise ValueError("Expected object") + expected = operator_token + if self.path == "/cabinet/poll": + expected = cabinet_token.get(data.get("cabinet"), "") if isinstance(cabinet_token, dict) else cabinet_token + if not expected: + self.respond(401, {"error": "Cabinet credentials not provisioned"}) + return + if not hmac.compare_digest(self.headers.get("Authorization", ""), "Bearer " + expected): + self.respond(401, {"error": "Unauthorized"}) + return + origin = self.headers.get("Origin") + if origin and urlparse(origin).netloc != self.headers.get("Host"): + raise ValueError("Cross-origin requests are disabled") + if self.path == "/cabinet/poll": result = room.poll(data) + elif self.path == "/operator/state": result = room.snapshot() + elif self.path == "/operator/command": + result = room.operator(data.get("action"), data) if isinstance(room, Fleet) else room.operator(data.get("action")) + else: raise ValueError("Unknown endpoint") + self.respond(200, result) + except (ValueError, TypeError, KeyError) as exc: + self.respond(400, {"error": str(exc)}) + + def setup(self): + super().setup() + self.connection.settimeout(3) + + def respond(self, code, data): + body = json.dumps(data).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): pass + return Handler + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8766) + parser.add_argument("--database", default="arcade_room.sqlite3") + parser.add_argument("--catalog", default=str(Path(__file__).with_name("catalog.json"))) + args = parser.parse_args() + operator = os.environ.get("ARCADE_OPERATOR_TOKEN", "") + cabinet = os.environ.get("ARCADE_CABINET_TOKEN", "") + try: + tokens = json.loads(os.environ.get("ARCADE_CABINET_TOKENS", "{}")) + except ValueError: + parser.error("ARCADE_CABINET_TOKENS must be a JSON object") + if not isinstance(tokens, dict): parser.error("ARCADE_CABINET_TOKENS must be a JSON object") + if cabinet: tokens.setdefault("cabinet_01", cabinet) + if not isinstance(tokens, dict) or not tokens or len(operator) < 16 or any(not isinstance(t, str) or len(t) < 16 or t == operator for t in tokens.values()) or len(set(tokens.values())) != len(tokens): + parser.error("Set an operator token and unique cabinet tokens (16+ characters); use ARCADE_CABINET_TOKEN for cabinet_01 or ARCADE_CABINET_TOKENS JSON for multiple cabinets") + catalog = json.loads(Path(args.catalog).read_text(encoding="utf-8")) + room = Fleet(Path(args.database), catalog) + server = HTTPServer((args.bind, args.port), handler_for(room, operator, tokens)) + print(f"Arcade controller ready at http://{args.bind}:{args.port}/ — {len(room.rooms)} cabinets", flush=True) + try: server.serve_forever() + except KeyboardInterrupt: pass + finally: + server.server_close() + for cabinet_room in room.rooms.values(): cabinet_room.db.close() + + +if __name__ == "__main__": main() diff --git a/ui/RoomLayoutItem.tscn b/ui/RoomLayoutItem.tscn new file mode 100644 index 0000000..7fe106c --- /dev/null +++ b/ui/RoomLayoutItem.tscn @@ -0,0 +1,19 @@ +[gd_scene format=3 uid="uid://c5c0mnwwuqruw"] + +[ext_resource type="Script" uid="uid://bqg3ykn8mappl" path="res://ui/room_layout_item.gd" id="1_xfo2i"] + +[node name="RoomLayoutItem" type="PanelContainer" unique_id=994544570] +custom_minimum_size = Vector2(160, 80) +script = ExtResource("1_xfo2i") + +[node name="Margin" type="MarginContainer" parent="." unique_id=384953227] +layout_mode = 2 + +[node name="VBox" type="VBoxContainer" parent="Margin" unique_id=628913497] +layout_mode = 2 + +[node name="TitleLabel" type="Label" parent="Margin/VBox" unique_id=1892519480] +layout_mode = 2 + +[node name="SubtitleLabel" type="Label" parent="Margin/VBox" unique_id=213851015] +layout_mode = 2 diff --git a/ui/logic_graph.gd b/ui/logic_graph.gd new file mode 100644 index 0000000..b5315ae --- /dev/null +++ b/ui/logic_graph.gd @@ -0,0 +1,1276 @@ +@tool +class_name LogicGraph +extends GraphEdit + +signal scenario_mapping_changed + +signal edit_rejected( + message: String +) + +signal logic_path_selected( + cabinet_id: StringName, + event_id: StringName, + action_id: StringName, + device_id: StringName +) + +var room: RoomDefinition +var scenario: ScenarioDefinition + +var graph_nodes: Dictionary = {} + +var game_port_events: Dictionary = {} + +var hardware_node_data: Dictionary = {} +var mapping_node_data: Dictionary = {} + +func _ready() -> void: + minimap_enabled = true + show_grid = true + snapping_enabled = true + snapping_distance = 20 + connection_lines_thickness = 4.0 + + add_theme_color_override( + "activity", + Color(0.0, 1.0, 0.278, 1.0) + ) + + if not connection_request.is_connected( + _on_connection_request + ): + connection_request.connect( + _on_connection_request + ) + + if not disconnection_request.is_connected( + _on_disconnection_request + ): + disconnection_request.connect( + _on_disconnection_request + ) + + +func configure( + room_definition: RoomDefinition, + scenario_definition: ScenarioDefinition +) -> void: + + room = room_definition + scenario = scenario_definition + + rebuild() + +func rebuild() -> void: + clear_connections() + + var children_to_remove: Array[Node] = [] + + for child in get_children(): + if child is GraphNode: + children_to_remove.append(child) + + for child in children_to_remove: + # + # Remove it from the tree immediately so its + # node name becomes available before we rebuild. + # + remove_child(child) + child.queue_free() + + graph_nodes.clear() + game_port_events.clear() + hardware_node_data.clear() + mapping_node_data.clear() + + if room == null: + return + + if scenario == null: + return + + _build_graph() + +func _create_graph_node( + node_name: StringName, + title: String, + position: Vector2 +) -> GraphNode: + + var node := GraphNode.new() + + node.name = node_name + node.title = title + node.position_offset = position + + node.custom_minimum_size = Vector2( + 220.0, + 100.0 + ) + + add_child(node) + + # + # Always register using the actual Node name + # after it enters the tree. + # + graph_nodes[node.name] = node + + return node + +func _add_row( + node: GraphNode, + text: String +) -> Label: + + var label := Label.new() + + label.text = text + label.custom_minimum_size.y = 28.0 + + node.add_child(label) + + return label + +func _set_ports( + node: GraphNode, + row_index: int, + left_enabled: bool, + right_enabled: bool +) -> void: + + node.set_slot( + row_index, + left_enabled, + 0, + Color.WHITE, + right_enabled, + 0, + Color.WHITE + ) + +func _build_graph() -> void: + var row_index := 0 + + for assignment in scenario.assignments: + if assignment == null: + continue + + if assignment.cabinet == null: + continue + + if assignment.game == null: + continue + + _build_assignment( + assignment, + row_index + ) + + row_index += 1 + +func _build_assignment( + assignment: ScenarioAssignment, + row_index: int +) -> void: + + var base_y := ( + float(row_index) * 500.0 + ) + + var game_node := _create_game_node( + assignment, + Vector2( + 50.0, + base_y + ) + ) + + var hardware_nodes := ( + _build_available_hardware( + assignment, + base_y + ) + ) + + var mapping_number := 0 + + for mapping in assignment.event_mappings: + if mapping == null: + continue + + for action_id in mapping.hardware_actions: + + if not hardware_nodes.has( + action_id + ): + continue + + var offset_y := ( + base_y + + float(mapping_number) * 150.0 + ) + + var mapping_node := ( + _create_mapping_node( + assignment, + mapping, + action_id, + Vector2( + 380.0, + offset_y + ) + ) + ) + + var hardware_node: GraphNode = ( + hardware_nodes[ + action_id + ] + ) + + var device := ( + room.get_device_for_action( + assignment.cabinet.cabinet_id, + action_id + ) + ) + + var device_node: GraphNode = null + + if device != null: + device_node = ( + _create_device_node( + device, + assignment.cabinet.cabinet_id, + action_id, + Vector2( + 1050.0, + hardware_node.position_offset.y + ) + ) + ) + + _connect_chain( + game_node, + mapping, + mapping_node, + hardware_node, + device_node + ) + + mapping_number += 1 + +func _create_game_node( + assignment: ScenarioAssignment, + position: Vector2 +) -> GraphNode: + + var node_name := StringName( + "game__%s" + % assignment.cabinet.cabinet_id + ) + + var existing := graph_nodes.get( + node_name + ) as GraphNode + + if existing != null: + return existing + + var node := _create_graph_node( + node_name, + "GAME — %s" + % assignment.cabinet.display_name, + position + ) + + _add_row( + node, + assignment.game.display_name + ) + + var port_events: Array[StringName] = [] + + for event_definition in assignment.game.events: + if event_definition == null: + continue + + var event_row := _add_row( + node, + "%s [%s]" + % [ + event_definition.display_name, + event_definition.event_id + ] + ) + + _set_ports( + node, + event_row.get_index(), + false, + true + ) + + port_events.append( + event_definition.event_id + ) + + game_port_events[ + node.name + ] = port_events + + return node + +func _create_mapping_node( + assignment: ScenarioAssignment, + mapping: ScenarioEventMapping, + action_id: StringName, + position: Vector2 +) -> GraphNode: + + var node_name := StringName( + "mapping__%s__%s__%s" + % [ + assignment.cabinet.cabinet_id, + mapping.game_event, + action_id + ] + ) + + var node := _create_graph_node( + node_name, + "SCENARIO MAPPING", + position + ) + + mapping_node_data[ + node.name + ] = { + "cabinet_id": assignment.cabinet.cabinet_id, + "event_id": mapping.game_event, + "action_id": action_id + } + + var event_row := _add_row( + node, + String(mapping.game_event) + ) + + _set_ports( + node, + event_row.get_index(), + true, + false + ) + + var action_row := _add_row( + node, + String(action_id) + ) + + _set_ports( + node, + action_row.get_index(), + false, + true + ) + + node.node_selected.connect( + func() -> void: + _emit_mapping_selection( + node + ) + ) + + return node + + +func _create_hardware_node( + assignment: ScenarioAssignment, + action_id: StringName, + position: Vector2 +) -> GraphNode: + + var cabinet := assignment.cabinet + var hardware := cabinet.hardware_config + + var action: HardwareAction = null + + if hardware != null: + action = hardware.get_action( + action_id + ) + + var node_name := StringName( + "hardware__%s__%s" + % [ + cabinet.cabinet_id, + action_id + ] + ) + + var node := _create_graph_node( + node_name, + "HARDWARE", + position + ) + + hardware_node_data[ + node.name + ] = { + "cabinet_id": cabinet.cabinet_id, + "action_id": action_id + } + + var input_row := _add_row( + node, + String(action_id) + ) + + _set_ports( + node, + input_row.get_index(), + true, + false + ) + + var description := ( + "Cabinet: %s" + % cabinet.display_name + ) + + _add_row( + node, + description + ) + + var gpio_text := "GPIO: ?" + + if action != null: + gpio_text = ( + "GPIO: %d" + % action.gpio_pin + ) + + var output_row := _add_row( + node, + gpio_text + ) + + _set_ports( + node, + output_row.get_index(), + false, + true + ) + + return node + +func _create_device_node( + device: RoomDeviceDefinition, + cabinet_id: StringName, + action_id: StringName, + position: Vector2 +) -> GraphNode: + + var node_name := StringName( + "device__%s__%s__%s" + % [ + cabinet_id, + action_id, + device.device_id + ] + ) + + var node := _create_graph_node( + node_name, + "ROOM DEVICE", + position + ) + + var row := _add_row( + node, + device.display_name + ) + + _set_ports( + node, + row.get_index(), + true, + false + ) + + return node + +func _connect_chain( + game_node: GraphNode, + mapping: ScenarioEventMapping, + mapping_node: GraphNode, + hardware_node: GraphNode, + device_node: GraphNode +) -> void: + + var game_port := _get_game_event_port( + game_node.name, + mapping.game_event + ) + + if game_port >= 0: + connect_node( + game_node.name, + game_port, + mapping_node.name, + 0 + ) + + connect_node( + mapping_node.name, + 0, + hardware_node.name, + 0 + ) + + if device_node != null: + connect_node( + hardware_node.name, + 0, + device_node.name, + 0 + ) + +func _get_game_event_port( + game_node_name: StringName, + event_id: StringName +) -> int: + + if not game_port_events.has( + game_node_name + ): + return -1 + + var events: Array = ( + game_port_events[ + game_node_name + ] + ) + + for index in range( + events.size() + ): + if events[index] == event_id: + return index + + return -1 + +func set_path_active( + cabinet_id: StringName, + event_id: StringName, + action_id: StringName, + device_id: StringName, + active: bool +) -> void: + + var game_name := StringName( + "game__%s" + % cabinet_id + ) + + var mapping_name := StringName( + "mapping__%s__%s__%s" + % [ + cabinet_id, + event_id, + action_id + ] + ) + + var hardware_name := StringName( + "hardware__%s__%s" + % [ + cabinet_id, + action_id + ] + ) + + var device_name := StringName( + "device__%s__%s__%s" + % [ + cabinet_id, + action_id, + device_id + ] + ) + + var amount := ( + 1.0 + if active + else 0.0 + ) + + # + # The game node has one output port per GameEventDefinition, + # so find the correct one for this event. + # + var game_port := _get_game_event_port( + game_name, + event_id + ) + + if ( + game_port >= 0 + and graph_nodes.has(game_name) + and graph_nodes.has(mapping_name) + ): + print( + "[LOGIC PATH] cabinet=%s event=%s action=%s device=%s game_port=%d active=%s" + % [ + cabinet_id, + event_id, + action_id, + device_id, + game_port, + active + ] + ) + + set_connection_activity( + game_name, + game_port, + mapping_name, + 0, + amount + ) + + print("") + print("======= PATH HIGHLIGHT DEBUG =======") + print("game_name: ", game_name) + print("mapping_name: ", mapping_name) + print("hardware_name: ", hardware_name) + print("device_name: ", device_name) + print("game_port: ", game_port) + + print( + "game->mapping exists: ", + is_node_connected( + game_name, + game_port, + mapping_name, + 0 + ) + ) + + print( + "mapping->hardware exists: ", + is_node_connected( + mapping_name, + 0, + hardware_name, + 0 + ) + ) + + print( + "hardware->device exists: ", + is_node_connected( + hardware_name, + 0, + device_name, + 0 + ) + ) + + print("activity: ", amount) + print("====================================") + print("") + + if ( + graph_nodes.has(mapping_name) + and graph_nodes.has(hardware_name) + ): + set_connection_activity( + mapping_name, + 0, + hardware_name, + 0, + amount + ) + + if ( + graph_nodes.has(hardware_name) + and graph_nodes.has(device_name) + ): + set_connection_activity( + hardware_name, + 0, + device_name, + 0, + amount + ) + +func _build_available_hardware( + assignment: ScenarioAssignment, + base_y: float +) -> Dictionary: + + var result: Dictionary = {} + + var cabinet := assignment.cabinet + + if cabinet.hardware_config == null: + return result + + var index := 0 + + for action in cabinet.hardware_config.actions: + if action == null: + continue + + var position := Vector2( + 720.0, + base_y + float(index) * 140.0 + ) + + var node := _create_hardware_node( + assignment, + action.action_id, + position + ) + + result[ + action.action_id + ] = node + + index += 1 + + return result + +func _on_connection_request( + from_node: StringName, + from_port: int, + to_node: StringName, + to_port: int +) -> void: + + print("") + print("======= CONNECTION REQUEST =======") + print("FROM NODE: ", from_node) + print("FROM PORT: ", from_port) + print("TO NODE: ", to_node) + print("TO PORT: ", to_port) + print("Game nodes known: ", game_port_events.keys()) + print("Hardware nodes known: ", hardware_node_data.keys()) + print("==================================") + print("") + + # ... existing function continues ... + + if scenario == null: + return + + if not game_port_events.has( + from_node + ): + _reject_edit( + "Connections must start from a game event." + ) + return + + if not hardware_node_data.has( + to_node + ): + _reject_edit( + "Game events can only connect to cabinet hardware actions." + ) + return + + var events: Array = ( + game_port_events[from_node] + ) + + if ( + from_port < 0 + or + from_port >= events.size() + ): + _reject_edit( + "Invalid game event port." + ) + return + + var event_id: StringName = ( + events[from_port] + ) + + var hardware_data: Dictionary = ( + hardware_node_data[to_node] + ) + + var cabinet_id: StringName = ( + hardware_data["cabinet_id"] + ) + + var action_id: StringName = ( + hardware_data["action_id"] + ) + + var source_cabinet_id := ( + _get_game_node_cabinet_id( + from_node + ) + ) + + if source_cabinet_id != cabinet_id: + _reject_edit( + "Cannot connect events between different cabinets." + ) + return + + if _mapping_exists( + cabinet_id, + event_id, + action_id + ): + _reject_edit( + "This scenario mapping already exists." + ) + return + + _add_mapping( + cabinet_id, + event_id, + action_id + ) + +func _get_game_node_cabinet_id( + game_node_name: StringName +) -> StringName: + + for assignment in scenario.assignments: + if assignment == null: + continue + + if assignment.cabinet == null: + continue + + var expected_name := StringName( + "game__%s" + % assignment.cabinet.cabinet_id + ) + + if expected_name == game_node_name: + return assignment.cabinet.cabinet_id + + return &"" + +func _mapping_exists( + cabinet_id: StringName, + event_id: StringName, + action_id: StringName +) -> bool: + + var assignment := ( + scenario.get_assignment_for_cabinet( + cabinet_id + ) + ) + + if assignment == null: + return false + + for mapping in assignment.event_mappings: + if mapping == null: + continue + + if mapping.game_event != event_id: + continue + + if mapping.hardware_actions.has( + action_id + ): + return true + + return false + +func _add_mapping( + cabinet_id: StringName, + event_id: StringName, + action_id: StringName +) -> void: + + var assignment := ( + scenario.get_assignment_for_cabinet( + cabinet_id + ) + ) + + if assignment == null: + _reject_edit( + "Cabinet assignment could not be found." + ) + return + + if assignment.game == null: + return + + if not assignment.game.has_event( + event_id + ): + _reject_edit( + "Game '%s' does not define event '%s'." + % [ + assignment.game.game_id, + event_id + ] + ) + return + + if assignment.cabinet.hardware_config == null: + return + + if not assignment.cabinet.hardware_config.has_action( + action_id + ): + _reject_edit( + "Cabinet '%s' does not define hardware action '%s'." + % [ + cabinet_id, + action_id + ] + ) + return + + var target_mapping: ScenarioEventMapping = null + + for mapping in assignment.event_mappings: + if mapping == null: + continue + + if mapping.game_event == event_id: + target_mapping = mapping + break + + if target_mapping == null: + target_mapping = ( + ScenarioEventMapping.new() + ) + + target_mapping.game_event = event_id + + assignment.event_mappings.append( + target_mapping + ) + + target_mapping.hardware_actions.append( + action_id + ) + + print( + "[LOGIC EDITOR] Added mapping: %s :: %s -> %s" + % [ + cabinet_id, + event_id, + action_id + ] + ) + + scenario_mapping_changed.emit() + + rebuild() + +func _reject_edit( + message: String +) -> void: + + print( + "[LOGIC EDITOR] REJECTED: ", + message + ) + + edit_rejected.emit( + message + ) + +func _on_disconnection_request( + from_node: StringName, + from_port: int, + to_node: StringName, + to_port: int +) -> void: + + var mapping_node_name: StringName = &"" + + if mapping_node_data.has( + from_node + ): + mapping_node_name = from_node + + elif mapping_node_data.has( + to_node + ): + mapping_node_name = to_node + + else: + _reject_edit( + "This connection belongs to the RoomDefinition and cannot be removed here." + ) + return + + var data: Dictionary = ( + mapping_node_data[ + mapping_node_name + ] + ) + + _remove_mapping( + data["cabinet_id"], + data["event_id"], + data["action_id"] + ) + +func _remove_mapping( + cabinet_id: StringName, + event_id: StringName, + action_id: StringName +) -> void: + + var assignment := ( + scenario.get_assignment_for_cabinet( + cabinet_id + ) + ) + + if assignment == null: + return + + for mapping in assignment.event_mappings: + if mapping == null: + continue + + if mapping.game_event != event_id: + continue + + mapping.hardware_actions.erase( + action_id + ) + + # + # Remove empty mapping Resources. + # + if mapping.hardware_actions.is_empty(): + assignment.event_mappings.erase( + mapping + ) + + print( + "[LOGIC EDITOR] Removed mapping: %s :: %s -> %s" + % [ + cabinet_id, + event_id, + action_id + ] + ) + + scenario_mapping_changed.emit() + + rebuild() + + return + +func _emit_mapping_selection( + node: GraphNode +) -> void: + + if node == null: + return + + if not mapping_node_data.has( + node.name + ): + return + + var data: Dictionary = ( + mapping_node_data[ + node.name + ] + ) + + var cabinet_id: StringName = ( + data["cabinet_id"] + ) + + var event_id: StringName = ( + data["event_id"] + ) + + var action_id: StringName = ( + data["action_id"] + ) + + var device_id: StringName = &"" + + if room != null: + var device := ( + room.get_device_for_action( + cabinet_id, + action_id + ) + ) + + if device != null: + device_id = device.device_id + + logic_path_selected.emit( + cabinet_id, + event_id, + action_id, + device_id + ) + +func apply_editor_selection( + selection: EscapeRoomEditorSelection +) -> void: + + if selection == null: + return + + _clear_editor_selection_highlight() + + if selection.cabinet_id != &"": + _highlight_cabinet_logic( + selection.cabinet_id + ) + + if ( + selection.event_id != &"" + and selection.action_id != &"" + ): + _highlight_specific_logic_path( + selection.cabinet_id, + selection.event_id, + selection.action_id, + selection.device_id + ) + +func _highlight_cabinet_logic( + cabinet_id: StringName +) -> void: + + for node_name in graph_nodes: + var node: GraphNode = ( + graph_nodes[ + node_name + ] + ) + + if node == null: + continue + + var node_cabinet_id := ( + _get_graph_node_cabinet_id( + node_name + ) + ) + + if node_cabinet_id == cabinet_id: + node.modulate = Color.WHITE + + else: + node.modulate = Color( + 0.5, + 0.5, + 0.5, + 1.0 + ) + +func _clear_editor_selection_highlight() -> void: + + for node_name in graph_nodes: + var node: GraphNode = graph_nodes[node_name] + + if node == null: + continue + + node.modulate = Color.WHITE + +func _highlight_specific_logic_path( + _cabinet_id: StringName, + _event_id: StringName, + _action_id: StringName, + _device_id: StringName +) -> void: + + # + # Exact editor-selection connection styling + # will be separate from simulation activity. + # + pass + +func _get_graph_node_cabinet_id( + node_name: StringName +) -> StringName: + + # + # Game nodes already have a reliable lookup. + # + if game_port_events.has( + node_name + ): + return _get_game_node_cabinet_id( + node_name + ) + + # + # Scenario mapping nodes. + # + if mapping_node_data.has( + node_name + ): + var mapping_data: Dictionary = ( + mapping_node_data[ + node_name + ] + ) + + return mapping_data[ + "cabinet_id" + ] + + # + # Hardware nodes. + # + if hardware_node_data.has( + node_name + ): + var hardware_data: Dictionary = ( + hardware_node_data[ + node_name + ] + ) + + return hardware_data[ + "cabinet_id" + ] + + # + # Device node names are: + # device__cabinet_id__action_id__device_id + # + var node_text := String( + node_name + ) + + if node_text.begins_with( + "device__" + ): + var parts := node_text.split( + "__" + ) + + if parts.size() >= 4: + return StringName( + parts[1] + ) + + return &"" diff --git a/ui/logic_graph.gd.uid b/ui/logic_graph.gd.uid new file mode 100644 index 0000000..e07c4a6 --- /dev/null +++ b/ui/logic_graph.gd.uid @@ -0,0 +1 @@ +uid://dm5remdyomnc4 diff --git a/ui/room_layout.gd b/ui/room_layout.gd new file mode 100644 index 0000000..92dfca0 --- /dev/null +++ b/ui/room_layout.gd @@ -0,0 +1,3989 @@ +@tool +class_name RoomLayout +extends Control + +signal item_selected( + item_id: StringName +) + +signal wall_selected( + wall_id: StringName +) + +signal layout_changed + +signal wall_geometry_edit_requested( + wall_id: StringName, + old_start: Vector2, + old_end: Vector2, + new_start: Vector2, + new_end: Vector2 +) + +signal boundary_geometry_edit_requested( + old_points: PackedVector2Array, + new_points: PackedVector2Array +) + +signal edit_rejected(message: String) + +signal zoom_changed( + zoom_value: float +) + +signal item_position_edit_requested( + item_id: StringName, + old_position: Vector2, + new_position: Vector2 +) + +signal refresh_requested + +signal binding_route_edit_requested( + cabinet_id: StringName, + action_id: StringName, + device_id: StringName, + old_points: PackedVector2Array, + new_points: PackedVector2Array +) + +@export var item_scene: PackedScene +@export var snap_enabled: bool = true + +@export_group("View") +@export_range(10.0, 500.0, 1.0) +var pixels_per_meter: float = 100.0 + +@export_range(0.01, 2.0, 0.001) +var grid_size_meters: float = 0.3048 + +var view_offset: Vector2 = Vector2( + 80.0, + 80.0 +) + +var selected_item_id: StringName = &"" +var selected_wall_id: StringName = &"" + +var room: RoomDefinition +var scenario: ScenarioDefinition + +var item_nodes: Dictionary = {} + +# +# Runtime/editor simulation activity. +# +var active_bindings: Dictionary = {} + +# +# Editor selection only. +# +var highlighted_bindings: Dictionary = {} + +var binding_labels: Array[Label] = [] +var show_connections: bool = true + +var selected_binding_key: String = "" + +var hovered_binding_route_point: int = -1 +var active_binding_route_point: int = -1 + +var selected_binding_route_point: int = -1 + +var binding_route_drag_original_points := ( + PackedVector2Array() +) + + + +enum EditMode { + SELECT, + DRAW_WALL, + EDIT_BOUNDARY, + ADD_BOUNDARY_POINT, + DELETE_BOUNDARY_POINT +} + +var edit_mode: EditMode = EditMode.SELECT + +enum WallHandle { + NONE, + START, + END, +} + +var active_wall_handle: WallHandle = WallHandle.NONE +var hovered_wall_handle: WallHandle = WallHandle.NONE + +var wall_handle_drag_wall_id: StringName = &"" +var wall_handle_drag_original_start: Vector2 +var wall_handle_drag_original_end: Vector2 + +var wall_draw_start: Vector2 +var wall_draw_current: Vector2 +var is_drawing_wall: bool = false + +var zoom: float = 1.0 + +var is_panning: bool = false +var pan_last_mouse_position: Vector2 + +var hovered_boundary_vertex: int = -1 +var active_boundary_vertex: int = -1 + +var boundary_drag_original_points: PackedVector2Array = ( + PackedVector2Array() +) + +var hovered_boundary_edge: int = -1 + +func _ready() -> void: + focus_mode = Control.FOCUS_ALL + +func configure( + room_definition: RoomDefinition, + scenario_definition: ScenarioDefinition +) -> void: + + room = room_definition + scenario = scenario_definition + + _rebuild() + +func _rebuild() -> void: + for child in get_children(): + child.queue_free() + + item_nodes.clear() + binding_labels.clear() + active_bindings.clear() + highlighted_bindings.clear() + + selected_binding_key = "" + + hovered_binding_route_point = -1 + active_binding_route_point = -1 + + selected_binding_route_point = -1 + + binding_route_drag_original_points = ( + PackedVector2Array() + ) + + selected_item_id = &"" + + if room == null: + return + + _build_cabinets() + _build_devices() + _build_binding_labels() + + queue_redraw() + +func _build_binding_labels() -> void: + if room == null: + return + + for binding in room.bindings: + if binding == null: + continue + + if not item_nodes.has( + binding.cabinet_id + ): + continue + + if not item_nodes.has( + binding.device_id + ): + continue + + var label := Label.new() + + label.text = String( + binding.hardware_action_id + ) + + label.mouse_filter = ( + Control.MOUSE_FILTER_IGNORE + ) + + add_child(label) + + binding_labels.append(label) + + _position_binding_label( + binding, + label + ) + + label.move_to_front() + +func _position_binding_label( + binding: RoomDeviceBinding, + label: Label +) -> void: + + var route_points := ( + _get_binding_route_points( + binding + ) + ) + + if route_points.size() < 2: + return + + var midpoint := ( + _get_polyline_midpoint( + route_points + ) + ) + + label.position = ( + midpoint + - Vector2( + label.size.x * 0.5, + 18.0 + ) + ) + +func _get_polyline_midpoint( + points: PackedVector2Array +) -> Vector2: + + if points.is_empty(): + return Vector2.ZERO + + if points.size() == 1: + return points[0] + + var total_length: float = 0.0 + + for index in range( + points.size() - 1 + ): + + total_length += ( + points[index].distance_to( + points[index + 1] + ) + ) + + if total_length <= 0.0: + return points[0] + + var target_length := ( + total_length * 0.5 + ) + + var travelled: float = 0.0 + + for index in range( + points.size() - 1 + ): + + var start := points[index] + var end := points[index + 1] + + var segment_length := ( + start.distance_to( + end + ) + ) + + if ( + travelled + segment_length + >= target_length + ): + + if segment_length <= 0.0: + return start + + var amount := ( + ( + target_length + - travelled + ) + / segment_length + ) + + return start.lerp( + end, + amount + ) + + travelled += segment_length + + return points[ + points.size() - 1 + ] + +func _build_cabinets() -> void: + if scenario == null: + return + + for assignment in scenario.assignments: + if assignment == null: + continue + + if assignment.cabinet == null: + continue + + if assignment.game == null: + continue + + var placement := room.get_cabinet_placement( + assignment.cabinet.cabinet_id + ) + + if placement == null: + continue + + var item := _create_item() + + if item == null: + continue + + # IMPORTANT: + # Add it to the scene tree before calling configure(). + add_child(item) + + item.configure( + assignment.cabinet.cabinet_id, + assignment.cabinet.display_name, + assignment.game.display_name + ) + + var footprint_width_pixels := ( + assignment.cabinet.footprint_width_m + * pixels_per_meter + * zoom + ) + + var footprint_depth_pixels := ( + assignment.cabinet.footprint_depth_m + * pixels_per_meter + * zoom + ) + + item.configure_cabinet_footprint( + assignment.cabinet.footprint_width_m + * pixels_per_meter + * zoom, + assignment.cabinet.footprint_depth_m + * pixels_per_meter + * zoom + ) + + item.configure_editor_texture( + assignment.cabinet.editor_texture, + assignment.cabinet.editor_texture_scale + ) + + var center_screen := ( + world_to_screen( + placement.position + ) + ) + + var center_offset := ( + item.get_physical_center_offset() + ) + + item.position = ( + center_screen + - center_offset + ) + + item.pivot_offset = ( + center_offset + ) + + item.rotation_degrees = ( + placement.rotation_degrees + ) + + item.moved.connect( + _on_cabinet_moved + ) + + item.selected.connect( + _on_item_selected + ) + + item_nodes[ + assignment.cabinet.cabinet_id + ] = item + + item.moving.connect( + _on_item_moving + ) + +func _update_binding_labels() -> void: + if room == null: + return + + var label_index := 0 + + for binding in room.bindings: + if binding == null: + continue + + if label_index >= binding_labels.size(): + break + + var label := binding_labels[ + label_index + ] + + _position_binding_label( + binding, + label + ) + + label_index += 1 + +func _build_devices() -> void: + for device in room.devices: + if device == null: + continue + + var item := _create_item() + + if item == null: + continue + + # Same thing here. + add_child(item) + + item.configure( + device.device_id, + device.display_name, + _device_type_to_text( + device.device_type + ) + ) + + # + # Device dimensions are authored in meters. + # + item.configure_device_footprint( + device.physical_size_m.x + * pixels_per_meter + * zoom, + device.physical_size_m.y + * pixels_per_meter + * zoom + ) + + item.configure_editor_texture( + device.editor_texture, + device.editor_texture_scale + ) + + # + # Device.position currently represents the + # top-left physical position in room space. + # + item.position = world_to_screen( + device.position + ) + + item.moved.connect( + _on_device_moved + ) + + item.selected.connect( + _on_item_selected + ) + + item_nodes[ + device.device_id + ] = item + + item.moving.connect( + _on_item_moving + ) + +func _on_item_selected( + item_id: StringName +) -> void: + + selected_binding_key = "" + hovered_binding_route_point = -1 + active_binding_route_point = -1 + + selected_binding_route_point = -1 + + selected_item_id = item_id + selected_wall_id = &"" + + for id in item_nodes: + var item: RoomLayoutItem = ( + item_nodes[id] + ) + + item.set_selected( + id == selected_item_id + ) + + item_selected.emit( + item_id + ) + + _update_relationship_highlight() + +func _on_item_moving( + _item_id: StringName, + _new_position: Vector2 +) -> void: + + _update_binding_labels() + queue_redraw() + +func _create_item() -> RoomLayoutItem: + if item_scene == null: + push_error( + "RoomLayout has no item_scene assigned." + ) + return null + + var instance := item_scene.instantiate() + + if not instance is RoomLayoutItem: + push_error( + "RoomLayout item_scene root must inherit from " + + "RoomLayoutItem. Got: %s" + % instance.get_class() + ) + + instance.queue_free() + return null + + return instance as RoomLayoutItem + + +func set_device_active( + device_id: StringName, + active: bool +) -> void: + + if not item_nodes.has(device_id): + return + + var item: RoomLayoutItem = ( + item_nodes[device_id] + ) + + item.set_active(active) + + +func _on_cabinet_moved( + cabinet_id: StringName, + _old_screen_position: Vector2, + new_screen_position: Vector2 +) -> void: + + if room == null: + return + + var placement := ( + room.get_cabinet_placement( + cabinet_id + ) + ) + + if placement == null: + return + + var item := ( + item_nodes[ + cabinet_id + ] as RoomLayoutItem + ) + + var center_offset := ( + item.get_physical_center_offset() + ) + + var new_center_screen := ( + new_screen_position + + center_offset + ) + + # Preserve the exact stored position for Undo, including off-grid placements. + var old_world_position := placement.position + + var new_world_position := ( + screen_to_world( + new_center_screen + ) + ) + + new_world_position = ( + snap_item_position_for_editor( + cabinet_id, + new_world_position + ) + ) + + # + # Restore the visual item to the original position. + # Workspace will apply the actual change through UndoRedo. + # + if item_nodes.has( + cabinet_id + ): + var cabinet_item: RoomLayoutItem = ( + item_nodes[ + cabinet_id + ] + ) + + var restored_center_screen := ( + world_to_screen( + old_world_position + ) + ) + + cabinet_item.position = ( + restored_center_screen + - cabinet_item.get_physical_center_offset() + ) + + item_position_edit_requested.emit( + cabinet_id, + old_world_position, + new_world_position + ) + + _update_binding_labels() + + queue_redraw() + + +func _on_device_moved( + device_id: StringName, + old_screen_position: Vector2, + new_screen_position: Vector2 +) -> void: + + if room == null: + return + + var device := ( + room.get_device( + device_id + ) + ) + + if device == null: + return + + var old_world_position := ( + screen_to_world( + old_screen_position + ) + ) + + var new_world_position := ( + screen_to_world( + new_screen_position + ) + ) + + old_world_position = ( + _snap_world_position( + old_world_position + ) + ) + + new_world_position = ( + _snap_world_position( + new_world_position + ) + ) + + if item_nodes.has( + device_id + ): + var item: RoomLayoutItem = ( + item_nodes[ + device_id + ] + ) + + item.position = ( + world_to_screen( + old_world_position + ) + ) + + item_position_edit_requested.emit( + device_id, + old_world_position, + new_world_position + ) + + _update_binding_labels() + + queue_redraw() + +func get_selected_position() -> Vector2: + if selected_item_id == &"": + return Vector2.ZERO + + var placement := ( + room.get_cabinet_placement( + selected_item_id + ) + ) + + if placement != null: + return placement.position + + var device := room.get_device( + selected_item_id + ) + + if device != null: + return device.position + + return Vector2.ZERO + +func get_selected_rotation() -> float: + if selected_item_id == &"": + return 0.0 + + var placement := ( + room.get_cabinet_placement( + selected_item_id + ) + ) + + if placement != null: + return placement.rotation_degrees + + return 0.0 + +func selected_item_is_cabinet() -> bool: + if room == null: + return false + + return ( + room.get_cabinet_placement( + selected_item_id + ) + != null + ) + +func set_selected_position( + new_position: Vector2 +) -> void: + + if selected_item_id == &"": + return + + var final_position := ( + snap_item_position_for_editor( + selected_item_id, + new_position + ) + ) + + var placement := ( + room.get_cabinet_placement( + selected_item_id + ) + ) + + if placement != null: + + placement.position = ( + final_position + ) + + else: + + var device := ( + room.get_device( + selected_item_id + ) + ) + + if device == null: + return + + device.position = ( + final_position + ) + + if item_nodes.has( + selected_item_id + ): + var item: RoomLayoutItem = ( + item_nodes[ + selected_item_id + ] + ) + + if placement != null: + + var center_screen := ( + world_to_screen( + final_position + ) + ) + + item.position = ( + center_screen + - item.get_physical_center_offset() + ) + + else: + + item.position = ( + world_to_screen( + final_position + ) + ) + + layout_changed.emit() + + _update_binding_labels() + + queue_redraw() + +func set_selected_rotation( + new_rotation: float +) -> void: + + if selected_item_id == &"": + return + + var placement := ( + room.get_cabinet_placement( + selected_item_id + ) + ) + + if placement == null: + return + + placement.rotation_degrees = ( + new_rotation + ) + + if item_nodes.has( + selected_item_id + ): + var item: RoomLayoutItem = ( + item_nodes[ + selected_item_id + ] + ) + + item.rotation_degrees = ( + new_rotation + ) + + layout_changed.emit() + +func _device_type_to_text( + device_type: RoomDeviceDefinition.DeviceType +) -> String: + + match device_type: + + RoomDeviceDefinition.DeviceType.COIN_RELEASE: + return "Coin Release" + + RoomDeviceDefinition.DeviceType.LIGHT: + return "Light" + + RoomDeviceDefinition.DeviceType.DOOR_LOCK: + return "Door Lock" + + RoomDeviceDefinition.DeviceType.DRAWER_LOCK: + return "Drawer Lock" + + RoomDeviceDefinition.DeviceType.BUZZER: + return "Buzzer" + + _: + return "Device" + +func _make_binding_key( + cabinet_id: StringName, + action_id: StringName, + device_id: StringName +) -> String: + + return "%s::%s::%s" % [ + cabinet_id, + action_id, + device_id + ] + +func set_binding_active( + cabinet_id: StringName, + action_id: StringName, + device_id: StringName, + active: bool +) -> void: + + var key := _make_binding_key( + cabinet_id, + action_id, + device_id + ) + + if active: + active_bindings[key] = true + else: + active_bindings.erase(key) + + queue_redraw() + +func _draw() -> void: + + _draw_grid() + _draw_room_boundary() + _draw_walls() + + if not show_connections: + return + + if room == null: + return + + for binding in room.bindings: + if binding == null: + continue + + _draw_binding( + binding + ) + + if is_drawing_wall: + _draw_wall_preview() + +func set_connections_visible( + visible: bool +) -> void: + + show_connections = visible + + for label in binding_labels: + label.visible = visible + + queue_redraw() + +func _draw_binding( + binding: RoomDeviceBinding +) -> void: + + var route_points := ( + _get_binding_route_points( + binding + ) + ) + + if route_points.size() < 2: + return + + var key := _make_binding_key( + binding.cabinet_id, + binding.hardware_action_id, + binding.device_id + ) + + var is_active := active_bindings.has( + key + ) + + var is_highlighted := ( + highlighted_bindings.has( + key + ) + ) + + var is_selected := ( + key == selected_binding_key + ) + + var line_color := Color( + 0.45, + 0.45, + 0.45, + 0.8 + ) + + var line_width: float = 2.0 + + # + # Editor selection. + # + if ( + is_highlighted + or is_selected + ): + + line_color = Color( + 0.35, + 0.7, + 1.0, + 1.0 + ) + + line_width = 4.0 + + # + # Live simulation always wins visually. + # + if is_active: + + line_color = Color( + 1.0, + 0.8, + 0.2, + 1.0 + ) + + line_width = 6.0 + + # + # Draw every segment of the authored cable route. + # + for index in range( + route_points.size() - 1 + ): + + draw_line( + route_points[index], + route_points[index + 1], + line_color, + line_width, + true + ) + + # + # Keep the endpoint marker on the device end. + # + var end_position := ( + route_points[ + route_points.size() - 1 + ] + ) + + _draw_connection_endpoint( + end_position, + line_color, + is_active + or is_highlighted + or is_selected + ) + + if is_selected: + _draw_binding_route_handles( + binding + ) + +func _draw_binding_route_handles( + binding: RoomDeviceBinding +) -> void: + + if binding == null: + return + + for index in range( + binding.route_points.size() + ): + + var screen_position := ( + world_to_screen( + binding.route_points[index] + ) + ) + + var radius: float = 7.0 + + var color := Color( + 0.35, + 0.7, + 1.0, + 1.0 + ) + + if index == hovered_binding_route_point: + + radius = 10.0 + + color = Color( + 0.65, + 0.85, + 1.0, + 1.0 + ) + + if index == selected_binding_route_point: + + radius = 9.0 + + color = Color( + 1.0, + 1.0, + 1.0, + 1.0 + ) + + if index == active_binding_route_point: + + radius = 11.0 + + color = Color( + 1.0, + 0.7, + 0.2, + 1.0 + ) + + draw_circle( + screen_position, + radius, + color + ) + + draw_circle( + screen_position, + radius, + Color.WHITE, + false, + 2.0 + ) + +func _get_binding_route_points( + binding: RoomDeviceBinding +) -> PackedVector2Array: + + var points := PackedVector2Array() + + if binding == null: + return points + + if not item_nodes.has( + binding.cabinet_id + ): + return points + + if not item_nodes.has( + binding.device_id + ): + return points + + var cabinet_item: RoomLayoutItem = ( + item_nodes[ + binding.cabinet_id + ] + ) + + var device_item: RoomLayoutItem = ( + item_nodes[ + binding.device_id + ] + ) + + # + # Start at the visual center of the cabinet. + # + var start_position := ( + cabinet_item.position + + cabinet_item.get_physical_center_offset() + ) + + points.append( + start_position + ) + + # + # Authored route points are stored in room/world meters, + # so convert them to screen coordinates here. + # + for room_point in binding.route_points: + + points.append( + world_to_screen( + room_point + ) + ) + + # + # Finish at the visual center of the device. + # + var end_position := ( + device_item.position + + device_item.get_physical_center_offset() + ) + + points.append( + end_position + ) + + return points + +func _get_selected_binding() -> RoomDeviceBinding: + + if room == null: + return null + + if selected_binding_key.is_empty(): + return null + + for binding in room.bindings: + + if binding == null: + continue + + var key := _make_binding_key( + binding.cabinet_id, + binding.hardware_action_id, + binding.device_id + ) + + if key == selected_binding_key: + return binding + + return null + + +func _find_binding_at_screen_position( + screen_position: Vector2 +) -> RoomDeviceBinding: + + if room == null: + return null + + if not show_connections: + return null + + var hit_radius: float = 10.0 + + for binding in room.bindings: + + if binding == null: + continue + + var route_points := ( + _get_binding_route_points( + binding + ) + ) + + if route_points.size() < 2: + continue + + for index in range( + route_points.size() - 1 + ): + + var closest := ( + Geometry2D.get_closest_point_to_segment( + screen_position, + route_points[index], + route_points[index + 1] + ) + ) + + if ( + closest.distance_to( + screen_position + ) + <= hit_radius + ): + return binding + + return null + + +func _select_binding( + binding: RoomDeviceBinding +) -> void: + + if binding == null: + return + + selected_binding_key = ( + _make_binding_key( + binding.cabinet_id, + binding.hardware_action_id, + binding.device_id + ) + ) + + selected_item_id = &"" + selected_wall_id = &"" + + hovered_binding_route_point = -1 + active_binding_route_point = -1 + + selected_binding_route_point = -1 + + for id in item_nodes: + + var item: RoomLayoutItem = ( + item_nodes[id] + ) + + item.set_selected( + false + ) + + queue_redraw() + + +func _find_binding_route_point_at_screen_position( + screen_position: Vector2 +) -> int: + + var binding := ( + _get_selected_binding() + ) + + if binding == null: + return -1 + + var best_index: int = -1 + var best_distance: float = 12.0 + + for index in range( + binding.route_points.size() + ): + + var point_screen := ( + world_to_screen( + binding.route_points[index] + ) + ) + + var distance := ( + point_screen.distance_to( + screen_position + ) + ) + + if distance <= best_distance: + + best_distance = distance + best_index = index + + return best_index + +func _begin_binding_route_point_drag( + point_index: int +) -> void: + + var binding := ( + _get_selected_binding() + ) + + if binding == null: + return + + if ( + point_index < 0 + or point_index >= binding.route_points.size() + ): + return + + active_binding_route_point = ( + point_index + ) + + selected_binding_route_point = ( + point_index + ) + + binding_route_drag_original_points = ( + binding.route_points.duplicate() + ) + + queue_redraw() + + +func _update_binding_route_point_drag( + screen_position: Vector2 +) -> void: + + var binding := ( + _get_selected_binding() + ) + + if binding == null: + return + + if active_binding_route_point < 0: + return + + if ( + active_binding_route_point + >= binding.route_points.size() + ): + return + + var world_position := ( + screen_to_world( + screen_position + ) + ) + + var snapped_position := ( + _snap_world_position( + world_position + ) + ) + + var points := ( + binding.route_points.duplicate() + ) + + points[ + active_binding_route_point + ] = snapped_position + + binding.route_points = points + + _update_binding_labels() + + queue_redraw() + + +func _finish_binding_route_point_drag() -> void: + + var binding := ( + _get_selected_binding() + ) + + if binding == null: + _cancel_binding_route_point_drag() + return + + if active_binding_route_point < 0: + return + + var new_points := ( + binding.route_points.duplicate() + ) + + # + # Restore the original model before handing + # the completed change to Workspace UndoRedo. + # + binding.route_points = ( + binding_route_drag_original_points.duplicate() + ) + + var old_points := ( + binding_route_drag_original_points.duplicate() + ) + + binding_route_edit_requested.emit( + binding.cabinet_id, + binding.hardware_action_id, + binding.device_id, + old_points, + new_points + ) + + active_binding_route_point = -1 + + binding_route_drag_original_points = ( + PackedVector2Array() + ) + + _update_binding_labels() + + queue_redraw() + + +func _cancel_binding_route_point_drag() -> void: + + var binding := ( + _get_selected_binding() + ) + + if binding != null: + + binding.route_points = ( + binding_route_drag_original_points.duplicate() + ) + + active_binding_route_point = -1 + + binding_route_drag_original_points = ( + PackedVector2Array() + ) + + _update_binding_labels() + + queue_redraw() + +func _draw_connection_endpoint( + position: Vector2, + color: Color, + is_active: bool +) -> void: + + var radius := 5.0 + + if is_active: + radius = 9.0 + + draw_circle( + position, + radius, + color + ) + +func _request_selected_binding_route_point_delete() -> void: + + var binding := ( + _get_selected_binding() + ) + + if binding == null: + return + + if selected_binding_route_point < 0: + return + + if ( + selected_binding_route_point + >= binding.route_points.size() + ): + return + + var old_points := ( + binding.route_points.duplicate() + ) + + var new_points := ( + binding.route_points.duplicate() + ) + + new_points.remove_at( + selected_binding_route_point + ) + + binding_route_edit_requested.emit( + binding.cabinet_id, + binding.hardware_action_id, + binding.device_id, + old_points, + new_points + ) + + selected_binding_route_point = -1 + hovered_binding_route_point = -1 + active_binding_route_point = -1 + + queue_redraw() + +func _update_relationship_highlight() -> void: + for id in item_nodes: + var item: RoomLayoutItem = ( + item_nodes[id] + ) + + item.modulate = Color.WHITE + + if selected_item_id == &"": + return + + var related_ids: Dictionary = {} + related_ids[selected_item_id] = true + + for binding in room.bindings: + if binding == null: + continue + + if ( + binding.cabinet_id + == selected_item_id + ): + related_ids[ + binding.device_id + ] = true + + elif ( + binding.device_id + == selected_item_id + ): + related_ids[ + binding.cabinet_id + ] = true + + for id in item_nodes: + + if related_ids.has(id): + continue + + var item: RoomLayoutItem = ( + item_nodes[id] + ) + + item.modulate = Color( + 0.55, + 0.55, + 0.55, + 1.0 + ) + +func snap_position_for_editor( + position: Vector2 +) -> Vector2: + + return _snap_world_position( + position + ) + +func world_to_screen( + world_position: Vector2 +) -> Vector2: + + return ( + world_position + * pixels_per_meter + * zoom + + view_offset + ) + +func screen_to_world( + screen_position: Vector2 +) -> Vector2: + + return ( + screen_position + - view_offset + ) / ( + pixels_per_meter + * zoom + ) + +func _meters_to_feet_inches( + meters: float +) -> String: + + var total_inches := roundi( + absf(meters) / 0.0254 + ) + + var feet := ( + total_inches / 12 + ) + + var inches := ( + total_inches % 12 + ) + + if inches == 0: + return "%d'" % feet + + if feet == 0: + return "%d\"" % inches + + return "%d' %d\"" % [ + feet, + inches + ] + + +func _draw_dimension_label( + screen_position: Vector2, + text: String +) -> void: + + if text.is_empty(): + return + + var font := ( + get_theme_default_font() + ) + + var font_size := 14 + + var text_size := ( + font.get_string_size( + text, + HORIZONTAL_ALIGNMENT_LEFT, + -1, + font_size + ) + ) + + var padding := Vector2( + 6.0, + 3.0 + ) + + var background_rect := Rect2( + screen_position + - text_size * 0.5 + - padding, + text_size + + padding * 2.0 + ) + + draw_rect( + background_rect, + Color( + 0.08, + 0.08, + 0.08, + 0.90 + ), + true + ) + + draw_string( + font, + screen_position + - Vector2( + text_size.x * 0.5, + -text_size.y * 0.25 + ), + text, + HORIZONTAL_ALIGNMENT_LEFT, + -1, + font_size, + Color( + 0.95, + 0.95, + 0.95, + 1.0 + ) + ) + +func snap_item_position_for_editor( + item_id: StringName, + position: Vector2 +) -> Vector2: + + if not snap_enabled or room == null: + return position + + var placement := room.get_cabinet_placement(item_id) + var cabinet := _get_cabinet_profile(item_id) + if placement == null or cabinet == null: + return _snap_world_position(position) + + # Positions remain centers in meters. Snap the rotated footprint's + # minimum corner, then restore its center offset. + var angle := deg_to_rad(placement.rotation_degrees) + var half_size := Vector2( + cabinet.footprint_width_m, + cabinet.footprint_depth_m + ) * 0.5 + var half_bounds := Vector2( + abs(cos(angle)) * half_size.x + abs(sin(angle)) * half_size.y, + abs(sin(angle)) * half_size.x + abs(cos(angle)) * half_size.y + ) + return _snap_world_position(position - half_bounds) + half_bounds + +func _snap_world_position( + position: Vector2 +) -> Vector2: + + if not snap_enabled: + return position + + return Vector2( + round( + position.x / grid_size_meters + ) * grid_size_meters, + + round( + position.y / grid_size_meters + ) * grid_size_meters + ) + +func _draw_room_boundary() -> void: + + if room == null: + return + + if room.boundary_points.size() < 2: + return + + # + # Approximate visual center. We use this only to decide + # which side of each wall is "outside" for the dimension. + # + var boundary_center := Vector2.ZERO + + for point in room.boundary_points: + boundary_center += world_to_screen( + point + ) + + boundary_center /= float( + room.boundary_points.size() + ) + + for index in range( + room.boundary_points.size() + ): + + var next_index := ( + (index + 1) + % room.boundary_points.size() + ) + + var start_world := ( + room.boundary_points[index] + ) + + var end_world := ( + room.boundary_points[next_index] + ) + + var start := world_to_screen( + start_world + ) + + var end := world_to_screen( + end_world + ) + + var line_color := Color( + 0.85, + 0.85, + 0.85, + 1.0 + ) + + var line_width := 3.0 + + if ( + edit_mode == EditMode.ADD_BOUNDARY_POINT + and index == hovered_boundary_edge + ): + + line_color = Color( + 0.35, + 0.7, + 1.0, + 1.0 + ) + + line_width = 6.0 + + draw_line( + start, + end, + line_color, + line_width, + true + ) + + # + # Boundary dimension. + # + var edge_length_m := ( + start_world.distance_to( + end_world + ) + ) + + var midpoint := ( + (start + end) * 0.5 + ) + + var direction := ( + end - start + ) + + if direction.length() > 0.001: + + var normal := Vector2( + -direction.y, + direction.x + ).normalized() + + var offset_distance := 28.0 + + var candidate_a := ( + midpoint + + normal * offset_distance + ) + + var candidate_b := ( + midpoint + - normal * offset_distance + ) + + var label_position := candidate_a + + # + # Choose whichever candidate is farther + # from the room's center. + # + if ( + candidate_b.distance_to( + boundary_center + ) + > + candidate_a.distance_to( + boundary_center + ) + ): + label_position = candidate_b + + _draw_dimension_label( + label_position, + _meters_to_feet_inches( + edge_length_m + ) + ) + + # + # Existing boundary editing handles. + # + if ( + edit_mode != EditMode.EDIT_BOUNDARY + and edit_mode != EditMode.ADD_BOUNDARY_POINT + and edit_mode != EditMode.DELETE_BOUNDARY_POINT + ): + return + + for index in range( + room.boundary_points.size() + ): + + var screen_position := world_to_screen( + room.boundary_points[index] + ) + + var radius := 6.0 + + var color := Color( + 0.85, + 0.85, + 0.85, + 1.0 + ) + + if index == hovered_boundary_vertex: + + radius = 9.0 + + color = Color( + 0.35, + 0.7, + 1.0, + 1.0 + ) + + if index == active_boundary_vertex: + + radius = 10.0 + + color = Color( + 1.0, + 0.7, + 0.2, + 1.0 + ) + + draw_circle( + screen_position, + radius, + color + ) + +func _draw_grid() -> void: + + if grid_size_meters <= 0.0: + return + + var spacing := ( + grid_size_meters + * pixels_per_meter + * zoom + ) + + if spacing < 8.0: + return + + var viewport_size := size + + var start_x := fmod( + view_offset.x, + spacing + ) + + var start_y := fmod( + view_offset.y, + spacing + ) + + var grid_color := Color( + 0.25, + 0.25, + 0.25, + 0.35 + ) + + var x := start_x + + while x < viewport_size.x: + draw_line( + Vector2( + x, + 0.0 + ), + Vector2( + x, + viewport_size.y + ), + grid_color, + 1.0 + ) + + x += spacing + + var y := start_y + + while y < viewport_size.y: + draw_line( + Vector2( + 0.0, + y + ), + Vector2( + viewport_size.x, + y + ), + grid_color, + 1.0 + ) + + y += spacing + +func _draw_walls() -> void: + + if room == null: + return + + for wall in room.walls: + if wall == null: + continue + + var start := world_to_screen( + wall.start + ) + + var end := world_to_screen( + wall.end + ) + + var width: float = maxf( + wall.thickness + * pixels_per_meter + * zoom, + 2.0 + ) + + var wall_color := Color( + 0.75, + 0.75, + 0.75, + 1.0 + ) + + if wall.wall_id == selected_wall_id: + wall_color = Color( + 1.0, + 0.65, + 0.15, + 1.0 + ) + + width += 3.0 + + draw_line( + start, + end, + wall_color, + width, + true + ) + + # + # Wall dimension. + # + var wall_length_m := ( + wall.start.distance_to( + wall.end + ) + ) + + var midpoint := ( + (start + end) * 0.5 + ) + + var direction := ( + end - start + ) + + var label_position := ( + midpoint + + Vector2( + 0.0, + -18.0 + ) + ) + + if direction.length() > 0.001: + + var normal := Vector2( + -direction.y, + direction.x + ).normalized() + + # + # Keep the label beside the wall instead + # of directly over the line. + # + if normal.y > 0.0: + normal = -normal + + label_position = ( + midpoint + + normal * 18.0 + ) + + _draw_dimension_label( + label_position, + _meters_to_feet_inches( + wall_length_m + ) + ) + + if wall.wall_id == selected_wall_id: + + var start_radius: float = 7.0 + var end_radius: float = 7.0 + + if hovered_wall_handle == WallHandle.START: + start_radius = 10.0 + + if hovered_wall_handle == WallHandle.END: + end_radius = 10.0 + + if active_wall_handle == WallHandle.START: + start_radius = 11.0 + + if active_wall_handle == WallHandle.END: + end_radius = 11.0 + + draw_circle( + start, + start_radius, + Color.WHITE + ) + + draw_circle( + end, + end_radius, + Color.WHITE + ) + +func set_edit_mode( + mode: EditMode +) -> void: + + edit_mode = mode + + hovered_boundary_vertex = -1 + hovered_boundary_edge = -1 + + if active_boundary_vertex >= 0: + _cancel_boundary_vertex_drag() + + _update_boundary_add_cursor() + + queue_redraw() + +signal wall_creation_requested( + start: Vector2, + end: Vector2 +) + +func _gui_input( + event: InputEvent +) -> void: + + if ( + event is InputEventMouseButton + and event.pressed + ): + grab_focus() + + if ( + event is InputEventKey + and event.pressed + and not event.echo + ): + if event.keycode == KEY_F: + focus_room() + accept_event() + return + + if event.keycode == KEY_0: + reset_view() + accept_event() + return + + if event.keycode == KEY_R: + + refresh_requested.emit() + + accept_event() + return + + if ( + event.keycode == KEY_DELETE + or event.keycode == KEY_BACKSPACE + ): + + if ( + edit_mode == EditMode.SELECT + and selected_binding_route_point >= 0 + ): + + _request_selected_binding_route_point_delete() + + accept_event() + return + + # + # Middle-mouse pan. + # + if ( + event is InputEventMouseButton + and event.button_index == MOUSE_BUTTON_MIDDLE + ): + if event.pressed: + _begin_pan( + event.position + ) + else: + _end_pan() + + accept_event() + return + + if ( + event is InputEventMouseMotion + and is_panning + ): + _update_pan( + event.position + ) + + accept_event() + return + + # + # Mouse-wheel zoom. + # + if event is InputEventMouseButton: + if ( + event.button_index == MOUSE_BUTTON_WHEEL_UP + or event.button_index == MOUSE_BUTTON_WHEEL_DOWN + ): + _handle_zoom( + event + ) + + accept_event() + return + + # + # SELECT mode + # + if edit_mode == EditMode.SELECT: + + if ( + event is InputEventMouseButton + and event.button_index == MOUSE_BUTTON_LEFT + ): + + if event.pressed: + + if event.double_click: + + var double_click_binding := ( + _find_binding_at_screen_position( + event.position + ) + ) + + if double_click_binding != null: + + _request_binding_route_point_insert( + double_click_binding, + event.position + ) + + accept_event() + return + + var route_point_index := ( + _find_binding_route_point_at_screen_position( + event.position + ) + ) + + if route_point_index >= 0: + + _begin_binding_route_point_drag( + route_point_index + ) + + accept_event() + return + + + var binding := ( + _find_binding_at_screen_position( + event.position + ) + ) + + if binding != null: + + _select_binding( + binding + ) + + accept_event() + return + + var handle := ( + _find_wall_handle_at_screen_position( + event.position + ) + ) + + if handle != WallHandle.NONE: + _begin_wall_handle_drag( + handle + ) + + accept_event() + return + + var wall := ( + _find_wall_at_screen_position( + event.position + ) + ) + + if wall != null: + _select_wall( + wall.wall_id + ) + + accept_event() + return + + else: + + if active_binding_route_point >= 0: + + _finish_binding_route_point_drag() + + accept_event() + return + + if ( + active_wall_handle + != WallHandle.NONE + ): + _finish_wall_handle_drag() + + accept_event() + return + + elif event is InputEventMouseMotion: + + if active_binding_route_point >= 0: + + _update_binding_route_point_drag( + event.position + ) + + accept_event() + return + + if active_wall_handle != WallHandle.NONE: + _update_wall_handle_drag( + event.position + ) + + accept_event() + return + + var new_route_hover := ( + _find_binding_route_point_at_screen_position( + event.position + ) + ) + + if ( + new_route_hover + != hovered_binding_route_point + ): + + hovered_binding_route_point = ( + new_route_hover + ) + + queue_redraw() + + var new_hover := ( + _find_wall_handle_at_screen_position( + event.position + ) + ) + + if new_hover != hovered_wall_handle: + hovered_wall_handle = new_hover + queue_redraw() + + return + + # + # ADD BOUNDARY POINT mode + # + if edit_mode == EditMode.ADD_BOUNDARY_POINT: + + if ( + event is InputEventMouseButton + and event.button_index == MOUSE_BUTTON_LEFT + and event.pressed + ): + var edge_index := ( + _find_boundary_edge_at_screen_position( + event.position + ) + ) + + if edge_index >= 0: + _request_boundary_point_insert( + edge_index, + event.position + ) + + accept_event() + + elif event is InputEventMouseMotion: + + var new_hover_edge := ( + _find_boundary_edge_at_screen_position( + event.position + ) + ) + + var new_hover_vertex := ( + _find_boundary_vertex_at_screen_position( + event.position + ) + ) + + if ( + new_hover_edge != hovered_boundary_edge + or new_hover_vertex != hovered_boundary_vertex + ): + hovered_boundary_edge = ( + new_hover_edge + ) + + hovered_boundary_vertex = ( + new_hover_vertex + ) + + _update_boundary_add_cursor() + + queue_redraw() + + return + + # + # EDIT BOUNDARY mode + # + if edit_mode == EditMode.EDIT_BOUNDARY: + + if ( + event is InputEventMouseButton + and event.button_index == MOUSE_BUTTON_LEFT + ): + if event.pressed: + + var vertex_index := ( + _find_boundary_vertex_at_screen_position( + event.position + ) + ) + + if vertex_index >= 0: + _begin_boundary_vertex_drag( + vertex_index + ) + + accept_event() + return + + else: + if active_boundary_vertex >= 0: + _finish_boundary_vertex_drag() + + accept_event() + return + + elif event is InputEventMouseMotion: + + if active_boundary_vertex >= 0: + _update_boundary_vertex_drag( + event.position + ) + + accept_event() + return + + var new_hover := ( + _find_boundary_vertex_at_screen_position( + event.position + ) + ) + + if ( + new_hover + != hovered_boundary_vertex + ): + hovered_boundary_vertex = ( + new_hover + ) + + queue_redraw() + + return + + # + # DELETE BOUNDARY POINT mode + # + if edit_mode == EditMode.DELETE_BOUNDARY_POINT: + + if ( + event is InputEventMouseButton + and event.button_index == MOUSE_BUTTON_LEFT + and event.pressed + ): + var vertex_index := ( + _find_boundary_vertex_at_screen_position( + event.position + ) + ) + + if vertex_index >= 0: + _request_boundary_point_delete( + vertex_index + ) + + accept_event() + + elif event is InputEventMouseMotion: + + var new_hover := ( + _find_boundary_vertex_at_screen_position( + event.position + ) + ) + + if new_hover != hovered_boundary_vertex: + hovered_boundary_vertex = new_hover + queue_redraw() + + return + + # + # DRAW WALL mode + # + if edit_mode != EditMode.DRAW_WALL: + return + + if ( + event is InputEventMouseButton + and event.button_index == MOUSE_BUTTON_LEFT + ): + if event.pressed: + var world_position := screen_to_world( + event.position + ) + + wall_draw_start = ( + _snap_world_position( + world_position + ) + ) + + wall_draw_current = wall_draw_start + is_drawing_wall = true + + queue_redraw() + + accept_event() + + else: + if not is_drawing_wall: + return + + var world_position := screen_to_world( + event.position + ) + + var wall_end := ( + _snap_world_position( + world_position + ) + ) + + is_drawing_wall = false + + if ( + wall_draw_start.distance_to( + wall_end + ) + > 0.01 + ): + wall_creation_requested.emit( + wall_draw_start, + wall_end + ) + + queue_redraw() + accept_event() + + elif ( + event is InputEventMouseMotion + and is_drawing_wall + ): + var world_position := screen_to_world( + event.position + ) + + wall_draw_current = ( + _snap_world_position( + world_position + ) + ) + + queue_redraw() + accept_event() + +func _draw_wall_preview() -> void: + + draw_line( + world_to_screen( + wall_draw_start + ), + world_to_screen( + wall_draw_current + ), + Color( + 1.0, + 0.7, + 0.2, + 1.0 + ), + 4.0, + true + ) + +func _handle_zoom( + event: InputEventMouseButton +) -> void: + + var old_world_position := ( + screen_to_world( + event.position + ) + ) + + if event.button_index == MOUSE_BUTTON_WHEEL_UP: + zoom *= 1.1 + + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + zoom /= 1.1 + + zoom = clamp( + zoom, + 0.25, + 4.0 + ) + + var new_screen_position := ( + world_to_screen( + old_world_position + ) + ) + + view_offset += ( + event.position + - new_screen_position + ) + + _reposition_items() + + zoom_changed.emit( + zoom + ) + + queue_redraw() + +func _reposition_items() -> void: + + if room == null: + return + + # + # Cabinets. + # + for placement in room.cabinet_placements: + + if placement == null: + continue + + if not item_nodes.has( + placement.cabinet_id + ): + continue + + var item: RoomLayoutItem = ( + item_nodes[ + placement.cabinet_id + ] + ) + + var cabinet := ( + _get_cabinet_profile( + placement.cabinet_id + ) + ) + + if cabinet != null: + + item.configure_cabinet_footprint( + cabinet.footprint_width_m + * pixels_per_meter + * zoom, + cabinet.footprint_depth_m + * pixels_per_meter + * zoom + ) + + item.configure_editor_texture( + cabinet.editor_texture, + cabinet.editor_texture_scale + ) + + var center_screen := ( + world_to_screen( + placement.position + ) + ) + + var center_offset := ( + item.get_physical_center_offset() + ) + + item.position = ( + center_screen + - center_offset + ) + + item.pivot_offset = ( + center_offset + ) + + item.rotation_degrees = ( + placement.rotation_degrees + ) + + # + # Devices. + # + for device in room.devices: + + if device == null: + continue + + if not item_nodes.has( + device.device_id + ): + continue + + var item: RoomLayoutItem = ( + item_nodes[ + device.device_id + ] + ) + + item.configure_device_footprint( + device.physical_size_m.x + * pixels_per_meter + * zoom, + device.physical_size_m.y + * pixels_per_meter + * zoom + ) + + item.position = ( + world_to_screen( + device.position + ) + ) + + _update_binding_labels() + +func _find_wall_at_screen_position( + screen_position: Vector2 +) -> RoomWallDefinition: + + if room == null: + return null + + var hit_radius_pixels: float = 10.0 + + for wall in room.walls: + if wall == null: + continue + + var start_screen := world_to_screen( + wall.start + ) + + var end_screen := world_to_screen( + wall.end + ) + + var closest := Geometry2D.get_closest_point_to_segment( + screen_position, + start_screen, + end_screen + ) + + if ( + closest.distance_to(screen_position) + <= hit_radius_pixels + ): + return wall + + return null + +func _find_binding_segment_at_screen_position( + binding: RoomDeviceBinding, + screen_position: Vector2 +) -> int: + + if binding == null: + return -1 + + var route_points := ( + _get_binding_route_points( + binding + ) + ) + + if route_points.size() < 2: + return -1 + + var best_segment: int = -1 + var best_distance: float = 12.0 + + for index in range( + route_points.size() - 1 + ): + + var closest := ( + Geometry2D.get_closest_point_to_segment( + screen_position, + route_points[index], + route_points[index + 1] + ) + ) + + var distance := ( + closest.distance_to( + screen_position + ) + ) + + if distance <= best_distance: + + best_distance = distance + best_segment = index + + return best_segment + +func _request_binding_route_point_insert( + binding: RoomDeviceBinding, + screen_position: Vector2 +) -> void: + + if binding == null: + return + + var segment_index := ( + _find_binding_segment_at_screen_position( + binding, + screen_position + ) + ) + + if segment_index < 0: + return + + var old_points := ( + binding.route_points.duplicate() + ) + + var new_points := ( + binding.route_points.duplicate() + ) + + var world_position := ( + screen_to_world( + screen_position + ) + ) + + var snapped_position := ( + _snap_world_position( + world_position + ) + ) + + # + # The complete polyline is: + # + # cabinet endpoint + # route_points[0] + # route_points[1] + # ... + # device endpoint + # + # Therefore the polyline segment index is also + # the correct insertion index in route_points. + # + var insertion_index := clampi( + segment_index, + 0, + new_points.size() + ) + + new_points.insert( + insertion_index, + snapped_position + ) + + selected_binding_key = ( + _make_binding_key( + binding.cabinet_id, + binding.hardware_action_id, + binding.device_id + ) + ) + + hovered_binding_route_point = ( + insertion_index + ) + + binding_route_edit_requested.emit( + binding.cabinet_id, + binding.hardware_action_id, + binding.device_id, + old_points, + new_points + ) + + queue_redraw() + +func _select_wall( + wall_id: StringName +) -> void: + + selected_binding_key = "" + hovered_binding_route_point = -1 + active_binding_route_point = -1 + + selected_binding_route_point = -1 + + selected_item_id = &"" + selected_wall_id = wall_id + + for id in item_nodes: + var item: RoomLayoutItem = ( + item_nodes[id] + ) + + item.set_selected(false) + + wall_selected.emit( + wall_id + ) + + queue_redraw() + +func get_selected_wall() -> RoomWallDefinition: + + if room == null: + return null + + if selected_wall_id == &"": + return null + + return room.get_wall( + selected_wall_id + ) + +func _find_wall_handle_at_screen_position( + screen_position: Vector2 +) -> WallHandle: + + var wall := get_selected_wall() + + if wall == null: + return WallHandle.NONE + + var start_screen := world_to_screen( + wall.start + ) + + var end_screen := world_to_screen( + wall.end + ) + + var hit_radius: float = 12.0 + + if ( + start_screen.distance_to( + screen_position + ) + <= hit_radius + ): + return WallHandle.START + + if ( + end_screen.distance_to( + screen_position + ) + <= hit_radius + ): + return WallHandle.END + + return WallHandle.NONE + +func _begin_wall_handle_drag( + handle: WallHandle +) -> void: + + var wall := get_selected_wall() + + if wall == null: + return + + active_wall_handle = handle + + wall_handle_drag_wall_id = wall.wall_id + + wall_handle_drag_original_start = wall.start + wall_handle_drag_original_end = wall.end + +func _update_wall_handle_drag( + screen_position: Vector2 +) -> void: + + if active_wall_handle == WallHandle.NONE: + return + + if room == null: + return + + var wall := room.get_wall( + wall_handle_drag_wall_id + ) + + if wall == null: + return + + var world_position := screen_to_world( + screen_position + ) + + var snapped_position := ( + _snap_world_position( + world_position + ) + ) + + match active_wall_handle: + WallHandle.START: + wall.start = snapped_position + + WallHandle.END: + wall.end = snapped_position + + _: + return + + wall_selected.emit( + wall.wall_id + ) + + queue_redraw() + +func _finish_wall_handle_drag() -> void: + + if active_wall_handle == WallHandle.NONE: + return + + if room == null: + _cancel_wall_handle_drag() + return + + var wall := room.get_wall( + wall_handle_drag_wall_id + ) + + if wall == null: + _cancel_wall_handle_drag() + return + + var new_start := wall.start + var new_end := wall.end + + # + # Restore the original state before handing the command + # to the Workspace. UndoRedo will apply the final state. + # + wall.start = wall_handle_drag_original_start + wall.end = wall_handle_drag_original_end + + wall_geometry_edit_requested.emit( + wall.wall_id, + wall_handle_drag_original_start, + wall_handle_drag_original_end, + new_start, + new_end + ) + + active_wall_handle = WallHandle.NONE + wall_handle_drag_wall_id = &"" + + queue_redraw() + +func _cancel_wall_handle_drag() -> void: + + if room != null: + var wall := room.get_wall( + wall_handle_drag_wall_id + ) + + if wall != null: + wall.start = ( + wall_handle_drag_original_start + ) + + wall.end = ( + wall_handle_drag_original_end + ) + + active_wall_handle = WallHandle.NONE + wall_handle_drag_wall_id = &"" + + queue_redraw() + +func _begin_pan( + mouse_position: Vector2 +) -> void: + + is_panning = true + pan_last_mouse_position = mouse_position + +func _update_pan( + mouse_position: Vector2 +) -> void: + + if not is_panning: + return + + var delta := ( + mouse_position + - pan_last_mouse_position + ) + + view_offset += delta + + pan_last_mouse_position = mouse_position + + _reposition_items() + queue_redraw() + +func _end_pan() -> void: + + is_panning = false + +func apply_editor_selection( + selection: EscapeRoomEditorSelection +) -> void: + + if selection == null: + return + + clear_editor_highlight() + + if ( + selection.cabinet_id != &"" + and selection.action_id != &"" + ): + selected_wall_id = &"" + selected_item_id = selection.cabinet_id + + for id in item_nodes: + var item: RoomLayoutItem = ( + item_nodes[id] + ) + + item.set_selected( + id == selection.cabinet_id + ) + + highlight_logic_path( + selection.cabinet_id, + selection.action_id, + selection.device_id + ) + + return + + if selection.wall_id != &"": + selected_item_id = &"" + selected_wall_id = selection.wall_id + queue_redraw() + return + + selected_wall_id = &"" + + if selection.cabinet_id != &"": + selected_item_id = selection.cabinet_id + + elif selection.device_id != &"": + selected_item_id = selection.device_id + + else: + selected_item_id = &"" + + for id in item_nodes: + var item: RoomLayoutItem = ( + item_nodes[id] + ) + + item.set_selected( + id == selected_item_id + ) + + _update_relationship_highlight() + queue_redraw() + +func highlight_logic_path( + cabinet_id: StringName, + action_id: StringName, + device_id: StringName +) -> void: + + for id in item_nodes: + var item: RoomLayoutItem = ( + item_nodes[id] + ) + + item.modulate = Color( + 0.45, + 0.45, + 0.45, + 1.0 + ) + + if item_nodes.has(cabinet_id): + var cabinet_item: RoomLayoutItem = ( + item_nodes[cabinet_id] + ) + + cabinet_item.modulate = Color.WHITE + + if ( + device_id != &"" + and item_nodes.has(device_id) + ): + var device_item: RoomLayoutItem = ( + item_nodes[device_id] + ) + + device_item.modulate = Color.WHITE + + for binding in room.bindings: + if binding == null: + continue + + if ( + binding.cabinet_id == cabinet_id + and binding.hardware_action_id == action_id + ): + if ( + device_id == &"" + or binding.device_id == device_id + ): + set_binding_highlighted( + binding.cabinet_id, + binding.hardware_action_id, + binding.device_id, + true + ) + +func clear_editor_highlight() -> void: + + for id in item_nodes: + var item: RoomLayoutItem = ( + item_nodes[id] + ) + + item.modulate = Color.WHITE + item.set_selected(false) + + selected_binding_key = "" + hovered_binding_route_point = -1 + active_binding_route_point = -1 + + selected_binding_route_point = -1 + + selected_item_id = &"" + selected_wall_id = &"" + + clear_binding_highlights() + + queue_redraw() + +func set_binding_highlighted( + cabinet_id: StringName, + action_id: StringName, + device_id: StringName, + highlighted: bool +) -> void: + + var key := _make_binding_key( + cabinet_id, + action_id, + device_id + ) + + if highlighted: + highlighted_bindings[ + key + ] = true + + else: + highlighted_bindings.erase( + key + ) + + queue_redraw() + +func clear_binding_highlights() -> void: + + highlighted_bindings.clear() + queue_redraw() + +func _find_boundary_vertex_at_screen_position( + screen_position: Vector2 +) -> int: + + if room == null: + return -1 + + var best_index := -1 + var best_distance := 12.0 + + for index in range( + room.boundary_points.size() + ): + var vertex_screen_position := ( + world_to_screen( + room.boundary_points[index] + ) + ) + + var distance := ( + vertex_screen_position.distance_to( + screen_position + ) + ) + + if distance <= best_distance: + best_distance = distance + best_index = index + + return best_index + +func _begin_boundary_vertex_drag( + vertex_index: int +) -> void: + + if room == null: + return + + if ( + vertex_index < 0 + or vertex_index >= room.boundary_points.size() + ): + return + + active_boundary_vertex = vertex_index + + boundary_drag_original_points = ( + room.boundary_points.duplicate() + ) + + queue_redraw() + + +func _update_boundary_vertex_drag( + screen_position: Vector2 +) -> void: + + if room == null: + return + + if active_boundary_vertex < 0: + return + + if ( + active_boundary_vertex + >= room.boundary_points.size() + ): + return + + var world_position := screen_to_world( + screen_position + ) + + var snapped_position := ( + _snap_world_position( + world_position + ) + ) + + var points := ( + room.boundary_points.duplicate() + ) + + points[ + active_boundary_vertex + ] = snapped_position + + room.boundary_points = points + + queue_redraw() + + +func _finish_boundary_vertex_drag() -> void: + + if room == null: + _cancel_boundary_vertex_drag() + return + + if active_boundary_vertex < 0: + return + + var new_points := ( + room.boundary_points.duplicate() + ) + + # + # Restore the original before handing the change + # to Workspace UndoRedo. + # + room.boundary_points = ( + boundary_drag_original_points.duplicate() + ) + + boundary_geometry_edit_requested.emit( + boundary_drag_original_points.duplicate(), + new_points + ) + + active_boundary_vertex = -1 + boundary_drag_original_points = ( + PackedVector2Array() + ) + + queue_redraw() + + +func _cancel_boundary_vertex_drag() -> void: + + if room != null: + room.boundary_points = ( + boundary_drag_original_points.duplicate() + ) + + active_boundary_vertex = -1 + boundary_drag_original_points = ( + PackedVector2Array() + ) + + queue_redraw() + +func focus_room() -> void: + + if room == null: + return + + var points: Array[Vector2] = [] + + # + # Room boundary. + # + for point in room.boundary_points: + points.append( + point + ) + + # + # Walls. + # + for wall in room.walls: + if wall == null: + continue + + points.append( + wall.start + ) + + points.append( + wall.end + ) + + # + # Cabinet placements. + # + for placement in room.cabinet_placements: + if placement == null: + continue + + points.append( + placement.position + ) + + # + # Room devices. + # + for device in room.devices: + if device == null: + continue + + points.append( + device.position + ) + + if points.is_empty(): + return + + var min_point := points[0] + var max_point := points[0] + + for point in points: + + min_point.x = minf( + min_point.x, + point.x + ) + + min_point.y = minf( + min_point.y, + point.y + ) + + max_point.x = maxf( + max_point.x, + point.x + ) + + max_point.y = maxf( + max_point.y, + point.y + ) + + var world_size := ( + max_point + - min_point + ) + + # + # Prevent divide-by-zero for extremely + # small or single-point layouts. + # + world_size.x = maxf( + world_size.x, + 0.5 + ) + + world_size.y = maxf( + world_size.y, + 0.5 + ) + + var margin_pixels := 80.0 + + var available_size := Vector2( + maxf( + size.x + - margin_pixels * 2.0, + 1.0 + ), + maxf( + size.y + - margin_pixels * 2.0, + 1.0 + ) + ) + + var zoom_x := ( + available_size.x + / ( + world_size.x + * pixels_per_meter + ) + ) + + var zoom_y := ( + available_size.y + / ( + world_size.y + * pixels_per_meter + ) + ) + + zoom = clampf( + minf( + zoom_x, + zoom_y + ), + 0.25, + 4.0 + ) + + var world_center := ( + min_point + + max_point + ) * 0.5 + + view_offset = ( + size * 0.5 + - world_center + * pixels_per_meter + * zoom + ) + + _reposition_items() + + zoom_changed.emit( + zoom + ) + + queue_redraw() + +func _closest_point_on_segment( + point: Vector2, + start: Vector2, + end: Vector2 +) -> Vector2: + + var segment := ( + end - start + ) + + var length_squared := ( + segment.length_squared() + ) + + if length_squared <= 0.000001: + return start + + var t := ( + (point - start).dot( + segment + ) + / length_squared + ) + + t = clampf( + t, + 0.0, + 1.0 + ) + + return ( + start + + segment * t + ) + +func _find_boundary_edge_at_screen_position( + screen_position: Vector2 +) -> int: + + if room == null: + return -1 + + if room.boundary_points.size() < 2: + return -1 + + var best_index := -1 + var best_distance := 12.0 + + for index in range( + room.boundary_points.size() + ): + var next_index := ( + (index + 1) + % room.boundary_points.size() + ) + + var start_screen := world_to_screen( + room.boundary_points[index] + ) + + var end_screen := world_to_screen( + room.boundary_points[next_index] + ) + + var closest := ( + _closest_point_on_segment( + screen_position, + start_screen, + end_screen + ) + ) + + var distance := ( + screen_position.distance_to( + closest + ) + ) + + if distance <= best_distance: + best_distance = distance + best_index = index + + return best_index + +func _request_boundary_point_insert( + edge_index: int, + screen_position: Vector2 +) -> void: + + if room == null: + return + + if ( + edge_index < 0 + or edge_index >= room.boundary_points.size() + ): + return + + var next_index := ( + (edge_index + 1) + % room.boundary_points.size() + ) + + var start_screen := world_to_screen( + room.boundary_points[ + edge_index + ] + ) + + var end_screen := world_to_screen( + room.boundary_points[ + next_index + ] + ) + + var closest_screen := ( + _closest_point_on_segment( + screen_position, + start_screen, + end_screen + ) + ) + + var new_world_point := ( + screen_to_world( + closest_screen + ) + ) + + new_world_point = ( + _snap_world_position( + new_world_point + ) + ) + + var old_points := ( + room.boundary_points.duplicate() + ) + + var new_points := PackedVector2Array() + + for index in range( + old_points.size() + ): + new_points.append( + old_points[index] + ) + + if index == edge_index: + new_points.append( + new_world_point + ) + + boundary_geometry_edit_requested.emit( + old_points, + new_points + ) + + hovered_boundary_vertex = -1 + hovered_boundary_edge = -1 + + _update_boundary_add_cursor() + + queue_redraw() + +func _request_boundary_point_delete( + vertex_index: int +) -> void: + + if room == null: + return + + if room.boundary_points.size() <= 3: + edit_rejected.emit( + "A room boundary must contain at least 3 points." + ) + return + + if ( + vertex_index < 0 + or vertex_index >= room.boundary_points.size() + ): + return + + var old_points := ( + room.boundary_points.duplicate() + ) + + var new_points := PackedVector2Array() + + for index in range( + old_points.size() + ): + if index == vertex_index: + continue + + new_points.append( + old_points[index] + ) + + boundary_geometry_edit_requested.emit( + old_points, + new_points + ) + + hovered_boundary_vertex = -1 + queue_redraw() + +func _update_boundary_add_cursor() -> void: + + if ( + edit_mode == EditMode.ADD_BOUNDARY_POINT + and hovered_boundary_edge >= 0 + ): + mouse_default_cursor_shape = ( + Control.CURSOR_POINTING_HAND + ) + else: + mouse_default_cursor_shape = ( + Control.CURSOR_ARROW + ) + +func reset_view() -> void: + + zoom = 1.0 + + view_offset = Vector2( + 80.0, + 80.0 + ) + + _reposition_items() + + zoom_changed.emit( + zoom + ) + + queue_redraw() + +func refresh_item_positions() -> void: + + _reposition_items() + + _update_binding_labels() + + queue_redraw() + +func refresh_from_model() -> void: + + _rebuild() + +func _get_cabinet_profile( + cabinet_id: StringName +) -> CabinetProfile: + + if scenario == null: + return null + + for assignment in scenario.assignments: + + if assignment == null: + continue + + if assignment.cabinet == null: + continue + + if ( + assignment.cabinet.cabinet_id + == cabinet_id + ): + return assignment.cabinet + + return null diff --git a/ui/room_layout.gd.uid b/ui/room_layout.gd.uid new file mode 100644 index 0000000..3f6f4d3 --- /dev/null +++ b/ui/room_layout.gd.uid @@ -0,0 +1 @@ +uid://ca320x55f2h3y diff --git a/ui/room_layout_item.gd b/ui/room_layout_item.gd new file mode 100644 index 0000000..57bac1f --- /dev/null +++ b/ui/room_layout_item.gd @@ -0,0 +1,472 @@ +@tool +class_name RoomLayoutItem +extends PanelContainer + + +signal selected( + item_id: StringName +) + +signal moved( + item_id: StringName, + old_position: Vector2, + new_position: Vector2 +) + +signal moving( + item_id: StringName, + new_position: Vector2 +) + + +var item_id: StringName = &"" + +var is_dragging: bool = false +var drag_offset: Vector2 = Vector2.ZERO +var drag_start_position: Vector2 = Vector2.ZERO + +var base_title: String = "" +var base_subtitle: String = "" +var is_selected: bool = false + +var is_active: bool = false + +var is_cabinet: bool = false +var is_device: bool = false + +var editor_texture: Texture2D = null +var editor_texture_scale: float = 1.0 + +var footprint_size_pixels: Vector2 = Vector2( + 80.0, + 75.0 +) + +@onready var title_label: Label = ( + $Margin/VBox/TitleLabel +) + +@onready var subtitle_label: Label = ( + $Margin/VBox/SubtitleLabel +) + +func get_physical_center_offset() -> Vector2: + + if ( + is_cabinet + or is_device + ): + return ( + footprint_size_pixels * 0.5 + ) + + return ( + size * 0.5 + ) + +func configure( + new_item_id: StringName, + title: String, + subtitle: String +) -> void: + + item_id = new_item_id + base_title = title + base_subtitle = subtitle + + title_label.text = title + subtitle_label.text = subtitle + +func configure_editor_texture( + texture: Texture2D, + texture_scale: float = 1.0 +) -> void: + + editor_texture = texture + + editor_texture_scale = clampf( + texture_scale, + 0.1, + 1.0 + ) + + queue_redraw() + +func _draw_editor_texture( + physical_rect: Rect2 +) -> void: + + if editor_texture == null: + return + + var texture_size := ( + editor_texture.get_size() + ) + + if ( + texture_size.x <= 0.0 + or texture_size.y <= 0.0 + ): + return + + var available_size := ( + physical_rect.size + ) + + if ( + available_size.x <= 0.0 + or available_size.y <= 0.0 + ): + return + + # + # Fit the image inside the physical footprint + # while preserving its original aspect ratio. + # + var fit_scale := minf( + available_size.x + / texture_size.x, + available_size.y + / texture_size.y + ) + + fit_scale *= ( + editor_texture_scale + ) + + var draw_size := ( + texture_size + * fit_scale + ) + + var draw_position := ( + physical_rect.position + + ( + physical_rect.size + - draw_size + ) * 0.5 + ) + + var texture_rect := Rect2( + draw_position, + draw_size + ) + + draw_texture_rect( + editor_texture, + texture_rect, + false + ) + +func _draw() -> void: + + if is_cabinet: + + var rect := Rect2( + Vector2.ZERO, + footprint_size_pixels + ) + + # + # Cabinet footprint background. + # + draw_rect( + rect, + Color( + 0.25, + 0.55, + 0.95, + 0.18 + ), + true + ) + + # + # Optional cabinet artwork. + # + _draw_editor_texture( + rect + ) + + # + # Cabinet footprint outline. + # + draw_rect( + rect, + Color( + 0.45, + 0.75, + 1.0, + 1.0 + ), + false, + 3.0 + ) + + # + # Front/player-facing edge. + # + var front_y := ( + footprint_size_pixels.y + ) + + draw_line( + Vector2( + 0.0, + front_y + ), + Vector2( + footprint_size_pixels.x, + front_y + ), + Color( + 1.0, + 0.85, + 0.2, + 1.0 + ), + 6.0 + ) + + # + # Center marker on front edge. + # + var center_x := ( + footprint_size_pixels.x + * 0.5 + ) + + draw_line( + Vector2( + center_x, + front_y + ), + Vector2( + center_x, + front_y - 14.0 + ), + Color( + 1.0, + 0.85, + 0.2, + 1.0 + ), + 4.0 + ) + + return + + + if is_device: + + var rect := Rect2( + Vector2.ZERO, + footprint_size_pixels + ) + + var fill_color := Color( + 0.25, + 0.75, + 0.45, + 0.18 + ) + + var outline_color := Color( + 0.4, + 0.9, + 0.6, + 1.0 + ) + + if is_active: + + fill_color = Color( + 1.0, + 0.8, + 0.2, + 0.18 + ) + + outline_color = Color( + 1.0, + 0.85, + 0.25, + 1.0 + ) + + # + # Physical footprint background. + # + draw_rect( + rect, + fill_color, + true + ) + + # + # Optional device artwork. + # + _draw_editor_texture( + rect + ) + + # + # Physical footprint outline. + # + draw_rect( + rect, + outline_color, + false, + 3.0 + ) + +func set_selected( + value: bool +) -> void: + + is_selected = value + + if is_selected: + title_label.text = ( + "▶ " + base_title + ) + else: + title_label.text = base_title + + +func set_active( + active: bool +) -> void: + + is_active = active + + if active: + subtitle_label.text = ( + "● ACTIVE" + ) + else: + subtitle_label.text = ( + base_subtitle + ) + + queue_redraw() + +func _gui_input( + event: InputEvent +) -> void: + + if event is InputEventMouseButton: + + if ( + event.button_index + == MOUSE_BUTTON_LEFT + ): + + if event.pressed: + + selected.emit( + item_id + ) + + is_dragging = true + + drag_start_position = ( + position + ) + + drag_offset = ( + get_local_mouse_position() + ) + + else: + + if is_dragging: + + is_dragging = false + + moved.emit( + item_id, + drag_start_position, + position + ) + + elif event is InputEventMouseMotion: + + if is_dragging: + + position += ( + event.relative + ) + + moving.emit( + item_id, + position + ) + +func configure_cabinet_footprint( + width_pixels: float, + depth_pixels: float +) -> void: + + is_cabinet = true + + footprint_size_pixels = Vector2( + maxf(width_pixels, 10.0), + maxf(depth_pixels, 10.0) + ) + + custom_minimum_size = ( + footprint_size_pixels + ) + + size = ( + footprint_size_pixels + ) + + pivot_offset = ( + get_physical_center_offset() + ) + + queue_redraw() + +func configure_device_footprint( + width_pixels: float, + height_pixels: float +) -> void: + + is_device = true + is_cabinet = false + + footprint_size_pixels = Vector2( + maxf( + width_pixels, + 6.0 + ), + maxf( + height_pixels, + 6.0 + ) + ) + + custom_minimum_size = ( + footprint_size_pixels + ) + + size = ( + footprint_size_pixels + ) + + pivot_offset = ( + get_physical_center_offset() + ) + + print( + "[DEVICE FOOTPRINT] ", + item_id, + " pixels=", + footprint_size_pixels, + " size=", + size + ) + + queue_redraw() diff --git a/ui/room_layout_item.gd.uid b/ui/room_layout_item.gd.uid new file mode 100644 index 0000000..a874562 --- /dev/null +++ b/ui/room_layout_item.gd.uid @@ -0,0 +1 @@ +uid://bqg3ykn8mappl