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:
parent
3283c6ec84
commit
d414ccd6e4
20
README.md
20
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
|
||||
```
|
||||
|
||||
13
project.godot
Normal file
13
project.godot
Normal 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
6
scenes/main.tscn
Normal 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
162
src/droid.gd
Normal 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
1
src/droid.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://wcp3odib25ya
|
||||
51
src/element_field.gd
Normal file
51
src/element_field.gd
Normal 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
1
src/element_field.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dtgwhdgx8s7mo
|
||||
94
src/exposable.gd
Normal file
94
src/exposable.gd
Normal 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
1
src/exposable.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dvutyjdnumoi1
|
||||
205
src/interactables.gd
Normal file
205
src/interactables.gd
Normal 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
1
src/interactables.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://blfbrrvhetj53
|
||||
452
src/main.gd
Normal file
452
src/main.gd
Normal 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
1
src/main.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://be7105kwteioj
|
||||
127
test/integration.gd
Normal file
127
test/integration.gd
Normal 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
80
test/selfcheck.gd
Normal 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
1
test/selfcheck.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://j8j1hd6b0dgw
|
||||
Loading…
Reference in New Issue
Block a user