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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-03 17:51:00 +10:00
parent 3283c6ec84
commit d414ccd6e4
16 changed files with 1215 additions and 1 deletions

View File

@ -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
```

13
project.godot Normal file
View File

@ -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

6
scenes/main.tscn Normal file
View File

@ -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")

162
src/droid.gd Normal file
View File

@ -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()

1
src/droid.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://wcp3odib25ya

51
src/element_field.gd Normal file
View File

@ -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)

1
src/element_field.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://dtgwhdgx8s7mo

94
src/exposable.gd Normal file
View File

@ -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)

1
src/exposable.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://dvutyjdnumoi1

205
src/interactables.gd Normal file
View File

@ -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")

1
src/interactables.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://blfbrrvhetj53

452
src/main.gd Normal file
View File

@ -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 ""

1
src/main.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://be7105kwteioj

127
test/integration.gd Normal file
View File

@ -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)

80
test/selfcheck.gd Normal file
View File

@ -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)

1
test/selfcheck.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://j8j1hd6b0dgw