From d414ccd6e47b7996cc071fbc6cd4421c326f9e9f Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 3 Aug 2026 17:51:00 +1000 Subject: [PATCH] Phase 0 grey-box: playable room, one schema, 19-check headless playthrough Exposable (the one state-machine schema) + ElementField (distance-based emitters) drive everything: wall components, the droid's shell/core, the leaky bucket. Room chain: wheel donation -> be-the-wire -> electrified puddle/relay -> leaky-bucket ferry -> rotating cargo drum (bonus) -> solar-gated exit on a 60s day/night cycle. Calculator-green HUD with PORT 00 tease. test/selfcheck.gd: 11 schema checks. test/integration.gd: full-room headless playthrough, 19 checks, all passing. Co-Authored-By: Claude Fable 5 --- README.md | 20 +- project.godot | 13 ++ scenes/main.tscn | 6 + src/droid.gd | 162 ++++++++++++++ src/droid.gd.uid | 1 + src/element_field.gd | 51 +++++ src/element_field.gd.uid | 1 + src/exposable.gd | 94 ++++++++ src/exposable.gd.uid | 1 + src/interactables.gd | 205 ++++++++++++++++++ src/interactables.gd.uid | 1 + src/main.gd | 452 +++++++++++++++++++++++++++++++++++++++ src/main.gd.uid | 1 + test/integration.gd | 127 +++++++++++ test/selfcheck.gd | 80 +++++++ test/selfcheck.gd.uid | 1 + 16 files changed, 1215 insertions(+), 1 deletion(-) create mode 100644 project.godot create mode 100644 scenes/main.tscn create mode 100644 src/droid.gd create mode 100644 src/droid.gd.uid create mode 100644 src/element_field.gd create mode 100644 src/element_field.gd.uid create mode 100644 src/exposable.gd create mode 100644 src/exposable.gd.uid create mode 100644 src/interactables.gd create mode 100644 src/interactables.gd.uid create mode 100644 src/main.gd create mode 100644 src/main.gd.uid create mode 100644 test/integration.gd create mode 100644 test/selfcheck.gd create mode 100644 test/selfcheck.gd.uid diff --git a/README.md b/README.md index 3315dc9..df38a24 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,22 @@ changes state under elemental exposure. Route the elements. Wake the ship. - Design canon: [GDD.md](GDD.md) -- Engine: Godot 4 +- Engine: Godot 4.6 + +## Phase 0 grey-box (playable) + +One hall, five gates, three elements. Proves the GDD's four pillars: +exposure routing, carry-decay, symmetric self-exposure (be the wire, +self-shock), and donation (leave a wheel in a door). + +``` +/Applications/Godot.app/Contents/MacOS/Godot --path . +``` + +**Controls:** WASD move · mouse look · E interact · Esc release mouse + +**Tests:** +``` +godot --headless --path . -s test/selfcheck.gd # schema state machine +godot --headless --path . -s test/integration.gd # full-room playthrough +``` diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..5cc5274 --- /dev/null +++ b/project.godot @@ -0,0 +1,13 @@ +; Engine configuration file. +config_version=5 + +[application] + +config/name="EXPOSURE (Phase 0)" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.6") + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=720 diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..0eafbe2 --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/main.gd" id="1"] + +[node name="Main" type="Node3D"] +script = ExtResource("1") diff --git a/src/droid.gd b/src/droid.gd new file mode 100644 index 0000000..30cb9ec --- /dev/null +++ b/src/droid.gd @@ -0,0 +1,162 @@ +class_name Droid +extends CharacterBody3D +## UNIT-77. Its shell and core are Exposables — the same class as every wall +## component (GDD pillar 4: symmetric exposure). Charged core = mobile +## electricity ElementField (be the wire). Wet + charged = self-shock reboot. + +const BASE_SPEED := 5.0 +const SLOW_SPEED := 2.4 +const MOUSE_SENS := 0.0025 + +var wheels := 2 +var spare_wheels := 0 +var held: Node3D = null +var stun := 0.0 +var toast_text := "" +var toast_t := 0.0 + +var shell: Exposable +var core: Exposable +var emitter: ElementField +var cam: Camera3D +var hold_point: Node3D +var _pitch := 0.0 + + +func _ready() -> void: + var col := CollisionShape3D.new() + var cap := CapsuleShape3D.new() + cap.radius = 0.45 + cap.height = 1.5 + col.shape = cap + col.position.y = 0.85 + add_child(col) + + var mi := MeshInstance3D.new() + var m := CapsuleMesh.new() + m.radius = 0.45 + m.height = 1.5 + mi.mesh = m + mi.position.y = 0.85 + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(0.9, 0.55, 0.15) + mi.material_override = mat + add_child(mi) + + cam = Camera3D.new() + cam.position = Vector3(0, 1.45, 0) + add_child(cam) + cam.current = true + + hold_point = Node3D.new() + hold_point.position = Vector3(0.4, -0.35, -0.9) + cam.add_child(hold_point) + + shell = Exposable.new().setup("UNIT-77 SHELL", "dry", [ + {"from": "dry", "to": "wet", "element": "water", + "duration": 0.4, "decay_to": "dry", "decay_s": 12.0}, + ]) + add_child(shell) + + core = Exposable.new().setup("UNIT-77 CORE", "idle", [ + {"from": "idle", "to": "charged", "element": "electricity", + "duration": 0.6, "decay_to": "idle", "decay_s": 10.0}, + ]) + add_child(core) + + emitter = ElementField.make("electricity", 1.2, Color(0.3, 1.0, 1.0, 0.10)) + emitter.enabled = false + emitter.exclude_root = self + emitter.position.y = 0.9 + add_child(emitter) + + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + + +func speed() -> float: + return BASE_SPEED if wheels >= 2 else SLOW_SPEED + + +func toast(msg: String, t := 3.0) -> void: + toast_text = msg + toast_t = t + + +func _unhandled_input(e: InputEvent) -> void: + if e is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + rotate_y(-e.relative.x * MOUSE_SENS) + _pitch = clampf(_pitch - e.relative.y * MOUSE_SENS, -1.25, 1.25) + cam.rotation.x = _pitch + elif e.is_action_pressed("interact"): + var t := best_target(["interactable"], 3.0, 0.7) + if t and t.has_method("interact"): + t.interact(self) + elif e.is_action_pressed("ui_cancel"): + if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + else: + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + + +func best_target(groups: Array, max_d: float, min_dot := 0.9) -> Node3D: + var fwd := -cam.global_transform.basis.z + var best: Node3D = null + var best_dot := min_dot + for g in groups: + for n in get_tree().get_nodes_in_group(g): + if n == self or is_ancestor_of(n): + continue + var to_n: Vector3 = n.global_position - cam.global_position + var d := to_n.length() + if d > max_d or d < 0.01: + continue + var dot := fwd.dot(to_n / d) + if dot > best_dot: + best_dot = dot + best = n + return best + + +func take(item: Node3D) -> void: + if held: + var drop := held + hold_point.remove_child(drop) + get_parent().add_child(drop) + drop.global_position = global_position - global_transform.basis.z * 1.2 + Vector3(0, 0.35, 0) + drop.rotation = Vector3.ZERO + held = null + item.get_parent().remove_child(item) + hold_point.add_child(item) + item.position = Vector3.ZERO + item.rotation = Vector3.ZERO + held = item + + +func _physics_process(delta: float) -> void: + # self-shock: wet shell + charged core = dump charge, reboot (GDD) + if shell.state == "wet" and core.state == "charged" and stun <= 0.0: + core.force_state("idle") + stun = 1.5 + toast("!! DISCHARGE THROUGH CHASSIS — REBOOTING — MEMORY CHECK... OK. YOU ARE UNIT-77.") + if stun > 0.0: + stun -= delta + core.force_state("idle") # no recharging mid-reboot + velocity.x = 0.0 + velocity.z = 0.0 + else: + var dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back") + var wish := (transform.basis * Vector3(dir.x, 0, dir.y)) + if wish.length() > 1.0: + wish = wish.normalized() + velocity.x = wish.x * speed() + velocity.z = wish.z * speed() + + if not is_on_floor(): + velocity.y -= 14.0 * delta + else: + velocity.y = -0.5 + + emitter.enabled = core.state == "charged" + if toast_t > 0.0: + toast_t -= delta + move_and_slide() diff --git a/src/droid.gd.uid b/src/droid.gd.uid new file mode 100644 index 0000000..fbf0dbd --- /dev/null +++ b/src/droid.gd.uid @@ -0,0 +1 @@ +uid://wcp3odib25ya diff --git a/src/element_field.gd b/src/element_field.gd new file mode 100644 index 0000000..1c2a8a6 --- /dev/null +++ b/src/element_field.gd @@ -0,0 +1,51 @@ +class_name ElementField +extends Node3D +## A volume that emits an element. Static (battery post, sunbeam, puddle) +## or mobile (the droid's charged core). Distance-based, no physics needed. +# ponytail: sphere-radius exposure, real Area3D shapes if levels ever need them + +var element := "light" +var radius := 2.0 +var intensity := 1.0 +var exclude_root: Node3D = null +var marker: MeshInstance3D = null + +var enabled := true: + set(v): + enabled = v + if marker: + marker.visible = v + + +static func make(el: String, r: float, col: Color) -> ElementField: + var f := ElementField.new() + f.element = el + f.radius = r + f.marker = MeshInstance3D.new() + var m := SphereMesh.new() + m.radius = r + m.height = r * 2.0 + f.marker.mesh = m + var mat := StandardMaterial3D.new() + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.albedo_color = col + f.marker.material_override = mat + f.add_child(f.marker) + return f + + +func _ready() -> void: + add_to_group("elem_field") + if marker: + marker.visible = enabled + + +func _physics_process(delta: float) -> void: + if not enabled: + return + for n in get_tree().get_nodes_in_group("exposable"): + if exclude_root and (n == exclude_root or exclude_root.is_ancestor_of(n)): + continue + if global_position.distance_to(n.global_position) <= radius: + n.expose(element, delta * intensity) diff --git a/src/element_field.gd.uid b/src/element_field.gd.uid new file mode 100644 index 0000000..e8f1658 --- /dev/null +++ b/src/element_field.gd.uid @@ -0,0 +1 @@ +uid://dtgwhdgx8s7mo diff --git a/src/exposable.gd b/src/exposable.gd new file mode 100644 index 0000000..76ce8f5 --- /dev/null +++ b/src/exposable.gd @@ -0,0 +1,94 @@ +class_name Exposable +extends Node3D +## THE one schema (see GDD.md). Wall components, droid body parts, buckets — +## everything runs on this. Transition dict keys: +## from, to, element, duration, [decay_to, decay_s], [permanent] + +signal state_changed(from: String, to: String, permanent: bool) + +var display_name := "component" +var state := "" +var transitions: Array = [] +var locked := false + +var _progress := 0.0 +var _progress_key := "" +var _since_expose := 999.0 +var _decay_left := -1.0 +var _decay_to := "" + + +func setup(dname: String, initial: String, trans: Array) -> Exposable: + display_name = dname + state = initial + transitions = trans + return self + + +func _ready() -> void: + add_to_group("exposable") + + +func expose(element: String, amount: float) -> void: + if locked: + return + for t in transitions: + if t.from == state and t.element == element: + var key := "%s|%s" % [state, element] + if _progress_key != key: + _progress_key = key + _progress = 0.0 + _progress += amount + _since_expose = 0.0 + if _progress >= float(t.get("duration", 0.0)): + _enter(t) + return + # sustain: exposure to the element that produced the current state + # refreshes its decay timer (solar stays powered while lit, etc.) + for t in transitions: + if t.to == state and t.element == element and t.has("decay_to"): + _decay_left = float(t.get("decay_s", 5.0)) + return + + +func force_state(s: String) -> void: + if state == s or locked: + return + var old := state + state = s + _progress = 0.0 + _progress_key = "" + _decay_left = -1.0 + state_changed.emit(old, s, false) + + +func decay_remaining() -> float: + return _decay_left + + +func _enter(t: Dictionary) -> void: + var old := state + state = t.to + _progress = 0.0 + _progress_key = "" + locked = bool(t.get("permanent", false)) + if t.has("decay_to"): + _decay_to = t.decay_to + _decay_left = float(t.get("decay_s", 5.0)) + else: + _decay_left = -1.0 + state_changed.emit(old, state, locked) + + +func _process(delta: float) -> void: + _since_expose += delta + if _since_expose > 0.8 and _progress > 0.0: + _progress = 0.0 + _progress_key = "" + if _decay_left > 0.0: + _decay_left -= delta + if _decay_left <= 0.0: + var old := state + state = _decay_to + _decay_left = -1.0 + state_changed.emit(old, state, false) diff --git a/src/exposable.gd.uid b/src/exposable.gd.uid new file mode 100644 index 0000000..6770381 --- /dev/null +++ b/src/exposable.gd.uid @@ -0,0 +1 @@ +uid://dvutyjdnumoi1 diff --git a/src/interactables.gd b/src/interactables.gd new file mode 100644 index 0000000..755efb6 --- /dev/null +++ b/src/interactables.gd @@ -0,0 +1,205 @@ +extends Node +## Inner classes for everything you can press E on. Preloaded by main as `I`. + + +class Door extends StaticBody3D: + var is_open := false + var height := 3.0 + var width := 2.4 + var tint := Color(0.95, 0.5, 0.1) + var base_y := 0.0 + var _tw: Tween + + func _ready() -> void: + base_y = position.y + var mi := MeshInstance3D.new() + var m := BoxMesh.new() + m.size = Vector3(width, height, 0.3) + mi.mesh = m + mi.position.y = height / 2.0 + var mat := StandardMaterial3D.new() + mat.albedo_color = tint + mi.material_override = mat + add_child(mi) + var col := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(width, height, 0.3) + col.shape = box + col.position.y = height / 2.0 + add_child(col) + + func open() -> void: + if is_open: + return + is_open = true + _slide(base_y + height - 0.25) + + func close() -> void: + if not is_open: + return + is_open = false + _slide(base_y) + + func _slide(target: float) -> void: + if _tw: + _tw.kill() + _tw = create_tween() + _tw.tween_property(self, "position:y", target, 1.1) + + +class Pickup extends Node3D: + var kind := "wheel" + var prompt_text := "TAKE WHEEL" + var tint := Color(0.2, 0.2, 0.25) + var size := Vector3(0.5, 0.5, 0.5) + + func _ready() -> void: + add_to_group("interactable") + var mi := MeshInstance3D.new() + var m := BoxMesh.new() + m.size = size + mi.mesh = m + var mat := StandardMaterial3D.new() + mat.albedo_color = tint + mi.material_override = mat + add_child(mi) + + func interact(droid: Node3D) -> void: + if kind == "wheel": + if droid.wheels < 2: + droid.wheels += 1 + droid.toast("WHEEL REINSTALLED — MOBILITY NOMINAL") + else: + droid.spare_wheels += 1 + droid.toast("SPARE WHEEL STOWED") + queue_free() + else: + droid.take(self) + + +class Bucket extends Node3D: + var kind := "bucket" + var prompt_text := "TAKE BUCKET" + var ex: Exposable + + func _ready() -> void: + add_to_group("interactable") + var mi := MeshInstance3D.new() + var m := CylinderMesh.new() + m.top_radius = 0.28 + m.bottom_radius = 0.2 + m.height = 0.42 + mi.mesh = m + mi.position.y = 0.21 + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(0.35, 0.4, 0.5) + mi.material_override = mat + add_child(mi) + # the leaky bucket IS just a decaying exposable state (GDD) + ex = Exposable.new().setup("GC BUCKET (LEAKS)", "empty", [ + {"from": "empty", "to": "full", "element": "water", + "duration": 3.0, "decay_to": "empty", "decay_s": 18.0}, + ]) + add_child(ex) + + func interact(droid: Node3D) -> void: + droid.take(self) + + +class Socket extends Node3D: + var door: Door + var filled := false + var prompt_text := "DONATE WHEEL TO DOOR BUS" + + func _ready() -> void: + add_to_group("interactable") + var mi := MeshInstance3D.new() + var m := BoxMesh.new() + m.size = Vector3(0.5, 0.5, 0.25) + mi.mesh = m + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(0.1, 0.1, 0.12) + mi.material_override = mat + add_child(mi) + + func interact(droid: Node3D) -> void: + if filled: + droid.toast("SOCKET OCCUPIED (LATCHED UNTIL SYSTEM RESTORED)") + return + if droid.wheels >= 2: + droid.wheels = 1 + filled = true + prompt_text = "SOCKET OCCUPIED (LATCHED)" + var mi := MeshInstance3D.new() + var m := CylinderMesh.new() + m.top_radius = 0.18 + m.bottom_radius = 0.18 + m.height = 0.12 + mi.mesh = m + mi.rotation.x = PI / 2.0 + mi.position.z = 0.18 + add_child(mi) + door.open() + droid.toast("WHEEL DONATED. DOOR BUS COMPLETE. MOBILITY REDUCED.") + else: + droid.toast("CANNOT COMPLY — MOBILITY CRITICAL") + + +class Tank extends Node3D: + var door: Door + var count := 0 + var need := 2 + var label: Label3D + var prompt_text := "POUR COOLANT" + + func _ready() -> void: + add_to_group("interactable") + var mi := MeshInstance3D.new() + var m := BoxMesh.new() + m.size = Vector3(1.2, 1.2, 1.2) + mi.mesh = m + mi.position.y = 0.6 + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(0.2, 0.3, 0.45) + mi.material_override = mat + add_child(mi) + + func interact(droid: Node3D) -> void: + var b: Node3D = droid.held + if b and b.get("kind") == "bucket" and b.ex.state == "full": + b.ex.force_state("empty") + count += 1 + if label: + label.text = "GRAV TANK %d/%d" % [count, need] + if count >= need: + door.open() + droid.toast("TANK FULL — SLUICE DOOR RELEASED") + else: + droid.toast("POURED. TANK %d/%d" % [count, need]) + else: + droid.toast("NEED: FULL BUCKET") + + +class Plate extends Node3D: + var door: Door + var prompt_text := "PLACE CARGO ON PLATE" + + func _ready() -> void: + add_to_group("interactable") + var mi := MeshInstance3D.new() + var m := BoxMesh.new() + m.size = Vector3(1.4, 0.08, 1.4) + mi.mesh = m + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(0.6, 0.55, 0.2) + mi.material_override = mat + add_child(mi) + + func interact(droid: Node3D) -> void: + if droid.held and droid.held.get("kind") == "crate": + droid.held.queue_free() + droid.held = null + door.open() + droid.toast("CARGO ACCEPTED. BONUS BAY OPEN.") + else: + droid.toast("PLATE WANTS: MAG-CLAMPED CARGO") diff --git a/src/interactables.gd.uid b/src/interactables.gd.uid new file mode 100644 index 0000000..30a32ed --- /dev/null +++ b/src/interactables.gd.uid @@ -0,0 +1 @@ +uid://blfbrrvhetj53 diff --git a/src/main.gd b/src/main.gd new file mode 100644 index 0000000..5009bef --- /dev/null +++ b/src/main.gd @@ -0,0 +1,452 @@ +extends Node3D +## Phase 0 grey-box: one hall, five gates, three elements (light/water/electricity). +## Everything below is fields + exposables + doors — no bespoke puzzle logic +## beyond wiring. See GDD.md build plan. + +const I := preload("res://src/interactables.gd") + +var droid: Droid +var hud_status: Label +var hud_toast: Label + +var door1: Node3D +var door2: Node3D +var door3: Node3D +var door4: Node3D +var exit_door: Node3D +var bonus_door: Node3D + +var contact_a: Exposable +var contact_b: Exposable +var relay: Exposable +var solar: Exposable +var drum_switch: Exposable + +var puddle_water: ElementField +var puddle_live: ElementField +var sun_field: ElementField +var sun_light: DirectionalLight3D + +var drum: Node3D +var drum_turned := false + +var t_sol := 10.0 # start mid-day +var is_day := true +var _live_t := 0.0 +var won := false + +var _mats := {} + + +func _ready() -> void: + _init_input() + _build_env() + _build_geometry() + _build_objects() + _build_droid() + _build_hud() + + +# ---------- input ---------- + +func _init_input() -> void: + var keys := { + "move_forward": KEY_W, "move_back": KEY_S, + "move_left": KEY_A, "move_right": KEY_D, + "interact": KEY_E, + } + for action in keys: + if not InputMap.has_action(action): + InputMap.add_action(action) + var ev := InputEventKey.new() + ev.physical_keycode = keys[action] + InputMap.action_add_event(action, ev) + + +# ---------- construction helpers ---------- + +func mat(col: Color) -> StandardMaterial3D: + if not _mats.has(col): + var m := StandardMaterial3D.new() + m.albedo_color = col + _mats[col] = m + return _mats[col] + + +func box(pos: Vector3, size: Vector3, col: Color, parent: Node3D = null) -> CSGBox3D: + var b := CSGBox3D.new() + b.size = size + b.position = pos + b.use_collision = true + b.material = mat(col) + (parent if parent else self).add_child(b) + return b + + +func sign3d(text: String, pos: Vector3, rot_y := PI, size := 30, col := Color(0.55, 1.0, 0.6)) -> Label3D: + var l := Label3D.new() + l.text = text + l.position = pos + l.rotation.y = rot_y + l.font_size = size + l.modulate = col + add_child(l) + return l + + +func divider(z: float) -> void: + var c := Color(0.3, 0.3, 0.34) + box(Vector3(-2.6, 2, z), Vector3(2.8, 4, 0.3), c) + box(Vector3(2.6, 2, z), Vector3(2.8, 4, 0.3), c) + box(Vector3(0, 3.5, z), Vector3(2.4, 1, 0.3), c) + + +func make_door(z: float) -> Node3D: + var d := I.Door.new() + d.position = Vector3(0, 0, z) + add_child(d) + return d + + +func make_exposable(dname: String, initial: String, trans: Array, pos: Vector3, parent: Node3D = null) -> Exposable: + var e := Exposable.new().setup(dname, initial, trans) + e.position = pos + (parent if parent else self).add_child(e) + return e + + +# ---------- world ---------- + +func _build_env() -> void: + var we := WorldEnvironment.new() + var env := Environment.new() + env.background_mode = Environment.BG_COLOR + env.background_color = Color(0.02, 0.03, 0.07) + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.7, 0.75, 0.85) + env.ambient_light_energy = 0.7 + we.environment = env + add_child(we) + + sun_light = DirectionalLight3D.new() + sun_light.rotation = Vector3(-0.9, 0.5, 0) + sun_light.light_energy = 1.2 + add_child(sun_light) + + +func _build_geometry() -> void: + var wall := Color(0.28, 0.29, 0.33) + # floor + box(Vector3(3, -0.15, 22.5), Vector3(18.6, 0.3, 46), Color(0.36, 0.36, 0.38)) + # left wall x=-4.15, niche gap z 23.4..24.6 + box(Vector3(-4.15, 2, 11.45), Vector3(0.3, 4, 23.9), wall) + box(Vector3(-4.15, 2, 33.55), Vector3(0.3, 4, 17.9), wall) + # relay niche (behind left wall) + bars + box(Vector3(-5.8, 2, 24), Vector3(0.3, 4, 1.6), wall) + box(Vector3(-5.0, 2, 23.4), Vector3(1.6, 4, 0.3), wall) + box(Vector3(-5.0, 2, 24.6), Vector3(1.6, 4, 0.3), wall) + box(Vector3(-4.15, 1, 23.8), Vector3(0.1, 2, 0.1), Color(0.15, 0.15, 0.15)) + box(Vector3(-4.15, 1, 24.2), Vector3(0.1, 2, 0.1), Color(0.15, 0.15, 0.15)) + # right wall x=4.15: gaps for drum bay (z 20..24) and bonus bay (z 25..26.6) + box(Vector3(4.15, 2, 9.75), Vector3(0.3, 4, 20.5), wall) + box(Vector3(4.15, 2, 24.5), Vector3(0.3, 4, 1.0), wall) + box(Vector3(4.15, 2, 34.55), Vector3(0.3, 4, 15.9), wall) + # bonus bay shell + box(Vector3(8.65, 2, 25.8), Vector3(0.3, 4, 3.0), wall) + box(Vector3(6.4, 2, 24.9), Vector3(4.4, 4, 0.3), wall) + box(Vector3(6.4, 2, 26.7), Vector3(4.4, 4, 0.3), wall) + # drum bay outer shell + box(Vector3(12.15, 3.5, 22), Vector3(0.3, 8, 6), wall) + box(Vector3(8, 3.5, 19.3), Vector3(8, 8, 0.3), wall) + box(Vector3(8, 3.5, 24.7), Vector3(8, 8, 0.3), wall) + # end walls + box(Vector3(0, 2, -0.5), Vector3(8.6, 4, 0.3), wall) + box(Vector3(0, 2, 44.5), Vector3(8.6, 4, 0.3), wall) + # dividers + doors + for z in [8.0, 16.0, 26.0, 34.0, 40.0]: + divider(z) + + +func _build_objects() -> void: + door1 = make_door(8.0) + door2 = make_door(16.0) + door3 = make_door(26.0) + door4 = make_door(34.0) + exit_door = make_door(40.0) + bonus_door = I.Door.new() + bonus_door.width = 1.6 + bonus_door.position = Vector3(4.15, 0, 25.8) + bonus_door.rotation.y = PI / 2.0 + add_child(bonus_door) + + # --- segment A: donation socket --- + var socket := I.Socket.new() + socket.door = door1 + socket.position = Vector3(1.8, 1.2, 7.6) + add_child(socket) + sign3d("DOOR BUS — COMPONENT MISSING", Vector3(1.8, 2.6, 7.55)) + sign3d("GC ORIENTATION: YOU ARE A VALUED GENERAL COMPONENT COMPONENT.", + Vector3(-2.0, 2.2, 4.0), PI, 22, Color(1.0, 0.75, 0.3)) + + # --- segment B: charge post + be-the-wire gap --- + box(Vector3(-2.5, 0.9, 10.5), Vector3(0.5, 1.8, 0.5), Color(0.2, 0.5, 0.6)) + var post1 := ElementField.make("electricity", 1.3, Color(0.3, 1.0, 1.0, 0.12)) + post1.position = Vector3(-2.5, 0.9, 10.5) + add_child(post1) + sign3d("CHARGE POST", Vector3(-2.5, 2.3, 10.2)) + + for x in [-1.0, 1.0]: + box(Vector3(x, 1.0, 15.3), Vector3(0.25, 2.0, 0.25), Color(0.55, 0.55, 0.6)) + var contact_trans := [{"from": "idle", "to": "hot", "element": "electricity", + "duration": 0.15, "decay_to": "idle", "decay_s": 0.6}] + contact_a = make_exposable("BUS CONTACT A", "idle", contact_trans.duplicate(true), Vector3(-1.0, 1.0, 15.3)) + contact_b = make_exposable("BUS CONTACT B", "idle", contact_trans.duplicate(true), Vector3(1.0, 1.0, 15.3)) + sign3d("BUS GAP — CONDUCTOR REQUIRED. STAND IN THE GAP, CHARGED.", Vector3(0, 2.7, 15.2)) + + var wheel := I.Pickup.new() + wheel.kind = "wheel" + wheel.prompt_text = "TAKE WHEEL" + wheel.position = Vector3(2.5, 0.4, 17.5) + add_child(wheel) + sign3d("SPARE MOBILITY UNIT — GC CAT. 6601-W", Vector3(2.5, 1.4, 17.8)) + + # --- segment C: post 2, drum bay, puddle + relay --- + box(Vector3(-2.8, 0.9, 20.5), Vector3(0.5, 1.8, 0.5), Color(0.2, 0.5, 0.6)) + var post2 := ElementField.make("electricity", 1.3, Color(0.3, 1.0, 1.0, 0.12)) + post2.position = Vector3(-2.8, 0.9, 20.5) + add_child(post2) + sign3d("CHARGE POST", Vector3(-2.8, 2.3, 20.2)) + + # rotating drum, opening starts facing UP; +90° about z turns it to face the corridor + drum = Node3D.new() + drum.position = Vector3(8, 3.5, 22) + add_child(drum) + var dc := Color(0.42, 0.38, 0.3) + box(Vector3(0, -3.2, 0), Vector3(6.8, 0.4, 4.4), dc, drum) + box(Vector3(3.2, 0, 0), Vector3(0.4, 6.8, 4.4), dc, drum) + box(Vector3(-3.2, 0, 0), Vector3(0.4, 6.8, 4.4), dc, drum) + box(Vector3(0, 0, 2.2), Vector3(6.8, 6.8, 0.4), dc, drum) + box(Vector3(0, 0, -2.2), Vector3(6.8, 6.8, 0.4), dc, drum) + box(Vector3(-2.6, 2.3, 0), Vector3(1.0, 0.15, 1.0), dc, drum) # shelf + var crate := I.Pickup.new() + crate.kind = "crate" + crate.prompt_text = "TAKE MAG-CLAMPED CARGO" + crate.tint = Color(0.55, 0.4, 0.2) + crate.size = Vector3(0.6, 0.5, 0.6) + crate.position = Vector3(-2.6, 2.65, 0) + drum.add_child(crate) + + box(Vector3(3.7, 0.7, 18.6), Vector3(0.3, 1.4, 0.3), Color(0.5, 0.4, 0.2)) + drum_switch = make_exposable("DRUM MOTOR BUS", "idle", + [{"from": "idle", "to": "active", "element": "electricity", + "duration": 0.8, "permanent": true}], + Vector3(3.7, 1.2, 18.6)) + drum_switch.state_changed.connect(_on_drum_switch) + sign3d("CARGO DRUM STUCK. MOTOR BUS DEAD — APPLY CURRENT.", Vector3(3.4, 2.6, 18.6), PI, 26) + + var pm := MeshInstance3D.new() + var pmesh := CylinderMesh.new() + pmesh.top_radius = 1.6 + pmesh.bottom_radius = 1.6 + pmesh.height = 0.05 + pm.mesh = pmesh + pm.position = Vector3(-2.6, 0.03, 24) + pm.material_override = mat(Color(0.15, 0.3, 0.6)) + add_child(pm) + puddle_water = ElementField.make("water", 1.6, Color(0.2, 0.4, 1.0, 0.10)) + puddle_water.position = Vector3(-2.6, 0.4, 24) + add_child(puddle_water) + puddle_live = ElementField.make("electricity", 2.6, Color(0.3, 1.0, 1.0, 0.10)) + puddle_live.position = Vector3(-2.6, 0.4, 24) + puddle_live.enabled = false + add_child(puddle_live) + + box(Vector3(-5.0, 1.0, 24), Vector3(0.4, 0.4, 0.4), Color(0.6, 0.3, 0.3)) + relay = make_exposable("SLUICE RELAY", "open", + [{"from": "open", "to": "tripped", "element": "electricity", + "duration": 1.0, "permanent": true}], + Vector3(-5.0, 1.0, 24)) + relay.state_changed.connect(func(_f, to, _p): if to == "tripped": door3.open()) + sign3d("SLUICE RELAY — SEALED BAY. CURRENT REQUIRED AT RELAY.", Vector3(-3.9, 2.6, 23.0), PI / 2.0, 24) + sign3d("CAUTION: COOLANT SPILL. DO NOT INTRODUCE CURRENT TO STANDING COOLANT.", + Vector3(-2.6, 1.9, 22.6), PI, 22, Color(1.0, 0.75, 0.3)) + + var plate := I.Plate.new() + plate.door = bonus_door + plate.position = Vector3(2.0, 0.02, 25.0) + add_child(plate) + sign3d("CARGO PLATE", Vector3(2.0, 1.0, 25.3)) + + # bonus bay contents + var wheel2 := I.Pickup.new() + wheel2.kind = "wheel" + wheel2.prompt_text = "TAKE SPARE WHEEL" + wheel2.position = Vector3(6.5, 0.4, 25.8) + add_child(wheel2) + sign3d("GC SAFETY BULLETIN #7:\nDO NOT EXPOSE G-9 COOLANT TO REACTOR FLUX.\nCRYSTALLIZATION HAZARD.", + Vector3(7.2, 2.0, 25.8), PI / 2.0, 24, Color(1.0, 0.75, 0.3)) + + # --- segment D: valve, bucket, tank --- + box(Vector3(-3.8, 1.4, 28), Vector3(0.5, 0.5, 0.5), Color(0.3, 0.5, 0.7)) + box(Vector3(-3.4, 0.9, 28), Vector3(0.12, 1.6, 0.12), Color(0.3, 0.5, 0.9)) # stream + var valve := ElementField.make("water", 1.3, Color(0.2, 0.4, 1.0, 0.10)) + valve.position = Vector3(-3.3, 0.8, 28) + add_child(valve) + sign3d("COOLANT VALVE — DRIPPING", Vector3(-3.4, 2.4, 27.7)) + + var bucket := I.Bucket.new() + bucket.position = Vector3(-2.8, 0, 29) + add_child(bucket) + + var tank := I.Tank.new() + tank.door = door4 + tank.position = Vector3(3.2, 0, 32.5) + add_child(tank) + tank.label = sign3d("GRAV TANK 0/2", Vector3(3.2, 1.9, 32.2)) + sign3d("SLUICE DOOR OPENS AT TANK 2/2. BUCKET LEAKS. HUSTLE.", Vector3(0, 2.7, 33.2), PI, 24) + + # --- segment E: skylight + solar cell + exit --- + sun_field = ElementField.make("light", 3.2, Color(1.0, 0.95, 0.4, 0.08)) + sun_field.position = Vector3(0, 2.2, 37) + add_child(sun_field) + var panel := box(Vector3(1.5, 1.5, 37.3), Vector3(1.2, 0.8, 0.1), Color(0.1, 0.15, 0.4)) + panel.rotation.x = -0.5 + solar = make_exposable("SOLAR CELL", "unpowered", + [{"from": "unpowered", "to": "powered", "element": "light", + "duration": 0.5, "decay_to": "unpowered", "decay_s": 1.5}], + Vector3(1.5, 1.5, 37.3)) + sign3d("SKYLIGHT — SHUTTER STUCK OPEN. EXIT BUS FED BY SOLAR CELL.\nEXIT OPERATES IN DAYLIGHT ONLY.", Vector3(0, 2.8, 36.2), PI, 24) + + # --- end pad --- + sign3d("ROOM RESTORED. PHASE 0 COMPLETE.", Vector3(0, 2.6, 43.9), PI, 48) + sign3d("GENERAL COMPONENT THANKS UNIT-77 FOR ITS CONTINUED COMPONENTHOOD.", + Vector3(0, 1.9, 43.9), PI, 22, Color(1.0, 0.75, 0.3)) + + +func _build_droid() -> void: + droid = Droid.new() + droid.position = Vector3(0, 0.2, 2) + droid.rotation.y = PI + add_child(droid) + + +func _build_hud() -> void: + var cl := CanvasLayer.new() + add_child(cl) + hud_status = Label.new() + hud_status.position = Vector2(14, 10) + hud_status.add_theme_font_size_override("font_size", 15) + hud_status.add_theme_color_override("font_color", Color(0.55, 1.0, 0.6)) + cl.add_child(hud_status) + + hud_toast = Label.new() + hud_toast.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE) + hud_toast.offset_top = -90 + hud_toast.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + hud_toast.add_theme_font_size_override("font_size", 19) + hud_toast.add_theme_color_override("font_color", Color(1.0, 0.75, 0.3)) + cl.add_child(hud_toast) + + var cross := Label.new() + cross.text = "+" + cross.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + cross.add_theme_font_size_override("font_size", 20) + cross.add_theme_color_override("font_color", Color(0.55, 1.0, 0.6, 0.7)) + cl.add_child(cross) + + +# ---------- rules ---------- + +func _on_drum_switch(_from: String, to: String, _perm: bool) -> void: + if to == "active" and not drum_turned: + drum_turned = true + droid.toast("DRUM MOTOR LIVE — ROTATING CARGO DRUM") + create_tween().tween_property(drum, "rotation:z", PI / 2.0, 3.0) + + +func _physics_process(delta: float) -> void: + # day/night + t_sol += delta + var phase := fmod(t_sol, 60.0) + is_day = phase < 40.0 + sun_field.enabled = is_day + sun_light.light_energy = 1.2 if is_day else 0.08 + + # be-the-wire + if not door2.is_open and contact_a.state == "hot" and contact_b.state == "hot": + door2.open() + droid.toast("CIRCUIT COMPLETE — YOU WERE THE WIRE") + + # conductive puddle: any live electricity field near it goes live for a while + var live := false + for f in get_tree().get_nodes_in_group("elem_field"): + if f == puddle_live or f == puddle_water: + continue + if f.enabled and f.element == "electricity" \ + and f.global_position.distance_to(puddle_water.global_position) <= puddle_water.radius + 0.6: + live = true + break + if live: + puddle_live.enabled = true + _live_t = 4.0 + elif puddle_live.enabled: + _live_t -= delta + if _live_t <= 0.0: + puddle_live.enabled = false + + # exit door follows solar cell state + if solar.state == "powered" and not exit_door.is_open: + exit_door.open() + elif solar.state != "powered" and exit_door.is_open: + exit_door.close() + + # win + if not won and droid.global_position.z > 41.5: + won = true + droid.toast("ASSET STATUS: ROOM RESTORED. PHASE 0 COMPLETE. GC IS PROUD-ADJACENT.", 10.0) + + +func _process(_delta: float) -> void: + var phase := fmod(t_sol, 60.0) + var sol := "DAY (%ds)" % int(40.0 - phase) if is_day else "NIGHT (%ds)" % int(60.0 - phase) + + var core_s := droid.core.state.to_upper() + if droid.core.decay_remaining() > 0: + core_s += " (%ds)" % ceil(droid.core.decay_remaining()) + var shell_s := droid.shell.state.to_upper() + if droid.shell.decay_remaining() > 0: + shell_s += " (%ds)" % ceil(droid.shell.decay_remaining()) + + var holding := "—" + if droid.held: + holding = str(droid.held.get("kind")).to_upper() + var ex: Exposable = droid.held.get("ex") if droid.held.get("kind") == "bucket" else null + if ex: + holding += " [%s%s]" % [ex.state.to_upper(), + " %ds" % ceil(ex.decay_remaining()) if ex.decay_remaining() > 0 else ""] + + var lines := [ + "GENERAL COMPONENT™ — UNIT-77 — MISSION: RESTORE ASSET. RETURN ASSET.", + "SOL: %s%s" % [sol, " ★ ROOM RESTORED — PHASE 0 COMPLETE" if won else ""], + "SHELL: %s | CORE: %s | WHEELS: %d | SPARES: %d | PORT 00: NO COMPONENT SPECIFIED" + % [shell_s, core_s, droid.wheels, droid.spare_wheels], + "HOLDING: %s" % holding, + ] + + var scan := droid.best_target(["exposable"], 8.0, 0.93) + if scan: + var d := "" + if scan.decay_remaining() > 0: + d = " (%ds)" % ceil(scan.decay_remaining()) + lines.append("SCAN: %s — %s%s" % [scan.display_name, scan.state.to_upper(), d]) + else: + lines.append("SCAN: —") + + var target := droid.best_target(["interactable"], 3.0, 0.7) + if target: + lines.append("[E] %s" % target.get("prompt_text")) + if droid.stun > 0: + lines.append(">>> REBOOTING <<<") + + hud_status.text = "\n".join(lines) + hud_toast.text = droid.toast_text if droid.toast_t > 0 else "" diff --git a/src/main.gd.uid b/src/main.gd.uid new file mode 100644 index 0000000..f671f63 --- /dev/null +++ b/src/main.gd.uid @@ -0,0 +1 @@ +uid://be7105kwteioj diff --git a/test/integration.gd b/test/integration.gd new file mode 100644 index 0000000..722adee --- /dev/null +++ b/test/integration.gd @@ -0,0 +1,127 @@ +extends SceneTree +## Headless playthrough of the whole Phase 0 room. +## Run: godot --headless --path . -s test/integration.gd + +var fails := 0 + + +func check(cond: bool, msg: String) -> void: + if cond: + print(" ok: " + msg) + else: + fails += 1 + printerr(" FAIL: " + msg) + + +# exposure accrues on physics ticks (real-time), so waits must count +# physics frames — process frames run uncapped-fast in headless +func wait(frames: int) -> void: + for i in frames: + await physics_frame + + +func wait_until(cond: Callable, max_frames: int) -> bool: + for i in max_frames: + if cond.call(): + return true + await physics_frame + return cond.call() + + +func find_i(kind_or_prompt: String) -> Node3D: + for n in get_nodes_in_group("interactable"): + if str(n.get("kind")) == kind_or_prompt or str(n.get("prompt_text")) == kind_or_prompt: + return n + return null + + +func _initialize() -> void: + root.add_child(load("res://scenes/main.tscn").instantiate()) + _run() + + +func _run() -> void: + Engine.time_scale = 3.0 # ponytail: same sim, less wall-clock + await wait(10) + var main := root.get_node("Main") + var droid: Droid = main.droid + + print("== donation ==") + var socket := find_i("DONATE WHEEL TO DOOR BUS") + socket.interact(droid) + check(main.door1.is_open and droid.wheels == 1, "wheel donated, door1 open, droid slow") + + print("== be the wire ==") + droid.global_position = Vector3(0, 0.2, 15.3) + droid.core.force_state("charged") + check(await wait_until(func(): return main.door2.is_open, 120), + "charged droid in the bus gap closes the circuit") + + var wheel := find_i("TAKE WHEEL") + wheel.interact(droid) + check(droid.wheels == 2, "replacement wheel reinstalled") + + print("== electrified puddle ==") + droid.global_position = Vector3(-2.6, 0.2, 24) + droid.core.force_state("charged") + check(await wait_until(func(): return main.puddle_live.enabled, 120), + "puddle goes live from droid charge") + check(await wait_until(func(): return main.relay.state == "tripped", 300), + "live puddle trips sluice relay") + check(main.door3.is_open, "relay opens door3") + check(droid.shell.state == "wet" or droid.stun > 0, "droid got wet/shocked in the process") + droid.global_position = Vector3(0, 0.2, 25) + await wait_until(func(): return droid.stun <= 0 and main.puddle_live.enabled == false, 600) + + print("== leaky bucket ==") + var bucket := find_i("bucket") + bucket.interact(droid) + check(droid.held == bucket, "bucket taken") + var tank := find_i("POUR COOLANT") + for trip in 2: + droid.global_position = Vector3(-2.7, 0.2, 28) + check(await wait_until(func(): return bucket.ex.state == "full", 400), + "bucket fills at valve (trip %d)" % (trip + 1)) + droid.global_position = Vector3(2.4, 0.2, 32.0) + await wait(5) + tank.interact(droid) + check(tank.count == 2 and main.door4.is_open, "two pours open the sluice door") + + print("== drum rotation ==") + # droid is wet from the valve; wet+charged self-shocks (by design) — dry off first + droid.shell.force_state("dry") + droid.global_position = Vector3(2.9, 0.2, 18.6) + droid.core.force_state("charged") + check(await wait_until(func(): return main.drum_turned, 180), "charged touch starts the drum") + await wait(200) # tween is 3s + var crate := find_i("crate") + check(crate != null and crate.global_position.y < 2.0 and crate.global_position.x < 6.5, + "rotation brought the crate within reach") + droid.global_position = Vector3(4.2, 0.2, 22) + crate.interact(droid) + check(droid.held == crate, "crate taken (bucket dropped)") + var plate := find_i("PLACE CARGO ON PLATE") + droid.global_position = Vector3(2.0, 0.2, 24.2) + plate.interact(droid) + check(main.bonus_door.is_open, "cargo plate opens bonus bay") + + print("== solar exit ==") + main.t_sol = 5.0 # daytime + droid.global_position = Vector3(0, 0.2, 37) + check(await wait_until(func(): return main.exit_door.is_open, 120), + "daylight powers solar cell, exit opens") + main.t_sol = 45.0 # nightfall + check(await wait_until(func(): return not main.exit_door.is_open, 300), + "night kills solar cell, exit closes") + main.t_sol = 5.0 + + print("== win ==") + await wait_until(func(): return main.exit_door.is_open, 120) + droid.global_position = Vector3(0, 0.2, 42) + check(await wait_until(func(): return main.won, 60), "crossing the exit wins Phase 0") + + if fails == 0: + print("INTEGRATION PASS — full room playthrough OK") + else: + printerr("INTEGRATION: %d FAILURES" % fails) + quit(1 if fails > 0 else 0) diff --git a/test/selfcheck.gd b/test/selfcheck.gd new file mode 100644 index 0000000..76e9d5d --- /dev/null +++ b/test/selfcheck.gd @@ -0,0 +1,80 @@ +extends SceneTree +## Headless check of the one schema everything runs on. +## Run: godot --headless --path . -s test/selfcheck.gd + +var fails := 0 + + +func check(cond: bool, msg: String) -> void: + if not cond: + fails += 1 + printerr("FAIL: " + msg) + + +func _initialize() -> void: + var Ex = load("res://src/exposable.gd") + + # solar-style: transition after duration, not before; sustain; decay + var solar = Ex.new().setup("solar", "unpowered", [ + {"from": "unpowered", "to": "powered", "element": "light", + "duration": 0.5, "decay_to": "unpowered", "decay_s": 1.5}, + ]) + solar.expose("light", 0.3) + check(solar.state == "unpowered", "no transition before duration met") + solar.expose("light", 0.3) + check(solar.state == "powered", "transition at duration") + # sustain: keep exposing, decay must not fire + for i in 3: + solar._process(1.0) + solar.expose("light", 0.016) + check(solar.state == "powered", "sustained exposure refreshes decay") + # stop exposing: decays back + solar._process(1.6) + check(solar.state == "unpowered", "decays back after exposure stops") + + # wrong element is ignored + solar.expose("water", 5.0) + check(solar.state == "unpowered", "wrong element ignored") + + # progress bleed-off: partial exposure resets after idle gap + var solar2 = Ex.new().setup("solar2", "unpowered", solar.transitions) + solar2.expose("light", 0.3) + solar2._process(1.0) + solar2.expose("light", 0.3) + check(solar2.state == "unpowered", "partial progress bleeds off after gap") + + # fuse-style permanence + var fuse = Ex.new().setup("fuse", "intact", [ + {"from": "intact", "to": "blown", "element": "electricity", + "duration": 0.1, "permanent": true}, + ]) + fuse.expose("electricity", 0.2) + check(fuse.state == "blown" and fuse.locked, "permanent transition locks") + fuse.force_state("intact") + check(fuse.state == "blown", "locked state refuses force_state") + + # bucket-style: fill, then leak empty + var bucket = Ex.new().setup("bucket", "empty", [ + {"from": "empty", "to": "full", "element": "water", + "duration": 3.0, "decay_to": "empty", "decay_s": 18.0}, + ]) + for i in 40: + bucket.expose("water", 0.1) + check(bucket.state == "full", "bucket fills under stream") + bucket._process(19.0) + check(bucket.state == "empty", "bucket leaks back to empty") + + # force_state clears decay + var b2 = Ex.new().setup("b2", "empty", bucket.transitions) + for i in 40: + b2.expose("water", 0.1) + b2.force_state("empty") + check(b2.decay_remaining() < 0, "force_state clears decay") + b2._process(0.1) + check(b2.state == "empty", "poured bucket stays empty") + + if fails == 0: + print("SELFCHECK PASS (11 checks)") + else: + printerr("SELFCHECK: %d FAILURES" % fails) + quit(1 if fails > 0 else 0) diff --git a/test/selfcheck.gd.uid b/test/selfcheck.gd.uid new file mode 100644 index 0000000..3ef52ca --- /dev/null +++ b/test/selfcheck.gd.uid @@ -0,0 +1 @@ +uid://j8j1hd6b0dgw