exposure/src/main.gd
type-two aa1efea167 UNIT-77 has a body: modular part rig, third person, skins
Four farm-generated parts (chassis/wheel/head/arm, light-grey enamel,
decimated 25k) assembled on a socket rig: wheels spin with movement and
visibly vanish when donated, PORT 00 is a real socket ring on the chassis
that lights amber when the crystal installs, charged = cyan emission,
wet = darkened shell. V toggles third/first person (SpringArm cam),
B cycles six repaint skins (GC/Heliotrope/VerdantCo/Comms/Executive/
Void) via per-droid material tints. Hold point moved from camera to
chassis. Capsule fallback kept. Integration passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 21:56:30 +10:00

883 lines
30 KiB
GDScript

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 _marker: Label3D
# Phase 1: zones + ship systems
var bridge_door: Node3D
var bridge_label: Label3D
var hub_water: ElementField
var reactor_glow: OmniLight3D
var sun_field2: ElementField
var ice_wall: Node3D
var melt_pool: ElementField
var systems := {"reactor": false, "hydro": false, "comms": false}
var hall_done := false
var ending_id := "" # "" | "corp" | "free"
var free_will := false
var env_res: Environment
var t_sol := 10.0 # start mid-day
var is_day := true
var _live_t := 0.0
var _mats := {}
func _ready() -> void:
_init_input()
_build_env()
_build_geometry()
_build_objects()
var Z := preload("res://src/zones.gd")
Z.hub(self)
Z.reactor(self)
Z.hydro(self)
Z.comms(self)
Z.bridge(self)
_build_droid()
_build_hud()
if "--shot" in OS.get_cmdline_user_args():
_photo_tour()
func _photo_tour() -> void:
var spots := [
[Vector3(0, 0.2, 2), PI], # spawn, facing door1
[Vector3(1.4, 0.2, 6.2), PI], # socket close-up
[Vector3(0, 0.2, 9), PI], # segment B: post + bus gap
[Vector3(-0.5, 0.2, 17.5), PI], # segment C: drum bay + puddle
[Vector3(-1.5, 0.2, 27), PI], # segment D: valve + tank
[Vector3(0, 0.2, 35), PI], # segment E: skylight + solar
[Vector3(0, 0.2, 46), PI], # hub
[Vector3(-11, 0.2, 53), PI / 2.0], # reactor
[Vector3(11, 0.2, 53), -PI / 2.0], # hydroponics
[Vector3(-4.2, 0.2, 63.5), PI], # comms
[Vector3(4.5, 0.2, 65), PI], # bridge
[Vector3(-0.5, 0.2, 10.5), PI / 2.0], # charge post close-up
]
for i in spots.size():
droid.global_position = spots[i][0]
droid.rotation.y = spots[i][1]
for f in 20:
await get_tree().process_frame
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
img.save_png("/tmp/exposure_shot_%d.png" % i)
if "--probe" in OS.get_cmdline_user_args():
for vi in find_children("*", "VisualInstance3D", true, false):
var b: AABB = vi.global_transform * vi.get_aabb()
var cam_p := droid.cam.global_position
if b.has_point(cam_p) or b.get_center().distance_to(cam_p) < 3.0:
print("spot %d NEAR: %s %s size=%s at=%s" % [i, vi.get_class(), vi.name, b.size.snapped(Vector3(0.1,0.1,0.1)), b.position.snapped(Vector3(0.1,0.1,0.1))])
get_tree().quit()
# ---------- 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, "toggle_cam": KEY_V, "repaint": KEY_B,
}
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 ----------
var _grid_tex: ImageTexture
func grid_tex() -> ImageTexture:
if not _grid_tex:
var img := Image.create(64, 64, false, Image.FORMAT_RGB8)
for y in 64:
for x in 64:
var line := x % 16 == 0 or y % 16 == 0
img.set_pixel(x, y, Color(0.5, 0.5, 0.5) if line else Color(0.92, 0.92, 0.92))
_grid_tex = ImageTexture.create_from_image(img)
return _grid_tex
func mat(col: Color) -> StandardMaterial3D:
if not _mats.has(col):
var m := StandardMaterial3D.new()
m.albedo_color = col
_mats[col] = m
return _mats[col]
var _texs := {}
func seamless_tex(path: String) -> ImageTexture:
# mirror-fold 2x2 so any image tiles without seams
# ponytail: runtime load+fold; bake to imported textures if load time ever matters
if _texs.has(path):
return _texs[path]
var img := Image.load_from_file(ProjectSettings.globalize_path(path))
img.convert(Image.FORMAT_RGB8)
var w := img.get_width()
var h := img.get_height()
var out := Image.create(w * 2, h * 2, false, Image.FORMAT_RGB8)
var r := Rect2i(0, 0, w, h)
out.blit_rect(img, r, Vector2i(0, 0))
var fx: Image = img.duplicate()
fx.flip_x()
out.blit_rect(fx, r, Vector2i(w, 0))
var fy: Image = img.duplicate()
fy.flip_y()
out.blit_rect(fy, r, Vector2i(0, h))
var fxy: Image = fx.duplicate()
fxy.flip_y()
out.blit_rect(fxy, r, Vector2i(w, h))
out.generate_mipmaps()
_texs[path] = ImageTexture.create_from_image(out)
return _texs[path]
func surface_mat(file: String, tile_m: float, col := Color.WHITE) -> StandardMaterial3D:
var path := "res://assets/textures/" + file
if not FileAccess.file_exists(path):
return struct_mat(Color(0.5, 0.52, 0.58)) # fallback: grid grey-box
var key := "t" + file + str(tile_m)
if not _mats.has(key):
var m := StandardMaterial3D.new()
m.albedo_color = col
m.albedo_texture = seamless_tex(path)
m.uv1_triplanar = true
m.uv1_scale = Vector3.ONE / tile_m
_mats[key] = m
return _mats[key]
func struct_mat(col: Color) -> StandardMaterial3D:
var key := "g" + str(col)
if not _mats.has(key):
var m := StandardMaterial3D.new()
m.albedo_color = col
m.albedo_texture = grid_tex()
m.uv1_triplanar = true
m.uv1_scale = Vector3(0.25, 0.25, 0.25) # 1m grid cells
_mats[key] = m
return _mats[key]
func glow(col: Color, energy := 0.7) -> StandardMaterial3D:
var key := "e" + str(col) + str(energy)
if not _mats.has(key):
var m := StandardMaterial3D.new()
m.albedo_color = col
m.emission_enabled = true
m.emission = col
m.emission_energy_multiplier = energy
_mats[key] = m
return _mats[key]
const SURF := {"wall": ["wall.jpg", 3.0], "floor": ["floor.jpg", 2.0],
"bulk": ["bulkhead.jpg", 3.0], "greeble": ["greeble.jpg", 2.2],
"reactor_wall": ["reactor_wall.jpg", 3.0], "reactor_floor": ["reactor_floor.jpg", 2.2],
"reactor_greeble": ["reactor_greeble.jpg", 2.4], "hydro_wall": ["hydro_wall.jpg", 3.0],
"hydro_floor": ["hydro_floor.jpg", 3.4], "hydro_greeble": ["hydro_greeble.jpg", 2.4],
"comms_wall": ["comms_wall.jpg", 3.0], "comms_floor": ["comms_floor.jpg", 2.4], "comms_greeble": ["comms_greeble.jpg", 2.6],
"bridge_wall": ["bridge_wall.jpg", 1.7], "bridge_floor": ["bridge_floor.jpg", 2.0]}
func box(pos: Vector3, size: Vector3, col: Color, parent: Node3D = null, grid := false, surf := "") -> CSGBox3D:
var b := CSGBox3D.new()
b.size = size
b.position = pos
b.use_collision = true
if surf:
b.material = surface_mat(SURF[surf][0], SURF[surf][1])
elif grid:
b.material = struct_mat(col)
else:
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.45, 0.46, 0.52)
box(Vector3(-2.6, 2, z), Vector3(2.8, 4, 0.3), c, null, true, "bulk")
box(Vector3(2.6, 2, z), Vector3(2.8, 4, 0.3), c, null, true, "bulk")
box(Vector3(0, 3.5, z), Vector3(2.4, 1, 0.3), c, null, true, "bulk")
func raw_tex(path: String) -> ImageTexture:
if _texs.has(path):
return _texs[path]
var img := Image.load_from_file(ProjectSettings.globalize_path(path))
img.generate_mipmaps()
_texs[path] = ImageTexture.create_from_image(img)
return _texs[path]
func face_for(file: String) -> Texture2D:
var path := "res://assets/textures/" + file
return raw_tex(path) if FileAccess.file_exists(path) else door_face()
func poster(file: String, pos: Vector3, rot_y := PI, h := 2.0) -> void:
var path := "res://assets/textures/" + file
if not FileAccess.file_exists(path) or OS.get_cmdline_user_args().has("--no-posters"):
return
var mi := MeshInstance3D.new()
var q := QuadMesh.new()
q.size = Vector2(h * 0.8, h)
mi.mesh = q
var mt := StandardMaterial3D.new()
mt.albedo_texture = raw_tex(path)
mi.material_override = mt
mi.position = pos
mi.rotation.y = rot_y
add_child(mi)
func door_face() -> Texture2D:
if FileAccess.file_exists("res://assets/textures/door_face.jpg"):
return raw_tex("res://assets/textures/door_face.jpg")
return null
func make_door(z: float) -> Node3D:
var d := I.Door.new()
d.face_tex = door_face()
d.position = Vector3(0, 0, z)
add_child(d)
return d
var _models := {}
func prop_scene(file: String) -> PackedScene:
if _models.has(file):
return _models[file]
var path := "res://assets/models/" + file
_models[file] = load(path) if FileAccess.file_exists(path) else null
return _models[file]
func _clamp_metals(p: Node3D) -> void:
for mi in p.find_children("*", "MeshInstance3D", true, false):
if mi.mesh:
for si in mi.mesh.get_surface_count():
var mt = mi.mesh.surface_get_material(si)
if mt is BaseMaterial3D:
mt.metallic = minf(mt.metallic, 0.2)
func place_prop(file: String, pos: Vector3, target_h: float) -> bool:
var ps := prop_scene(file)
if not ps:
return false
_fit_prop(ps.instantiate(), pos, target_h)
return true
func attach_model(node: Node3D, file: String, target_h: float) -> bool:
## swap an interactable's primitive mesh for a generated GLB (must be in tree)
var ps := prop_scene(file)
if not ps:
return false
for c in node.get_children():
if c is MeshInstance3D:
c.visible = false
var p: Node3D = ps.instantiate()
node.add_child(p)
p.position = Vector3.ZERO
_clamp_metals(p)
var inv := node.global_transform.affine_inverse()
var aabb := AABB()
var first := true
for mi in p.find_children("*", "MeshInstance3D", true, false):
var b: AABB = (inv * mi.global_transform) * mi.get_aabb()
aabb = b if first else aabb.merge(b)
first = false
if first:
return true
var sc := target_h / maxf(aabb.size.y, 0.01)
p.scale = Vector3.ONE * sc
var c2 := aabb.get_center()
p.position = Vector3(-c2.x * sc, -aabb.position.y * sc, -c2.z * sc)
return true
var _post_scene: PackedScene = null
var _post_checked := false
func _fit_prop(p: Node3D, pos: Vector3, target_h: float) -> void:
## normalize a generated GLB: uniform scale to target height, feet on floor,
## metallic clamped (gltf metallic=1 renders black without IBL — MESHGOD gotcha)
add_child(p)
p.global_position = Vector3.ZERO
var aabb := AABB()
var first := true
for mi in p.find_children("*", "MeshInstance3D", true, false):
var b: AABB = mi.global_transform * mi.get_aabb()
aabb = b if first else aabb.merge(b)
first = false
_clamp_metals(p)
if first:
return
var sc := target_h / maxf(aabb.size.y, 0.01)
p.scale = Vector3.ONE * sc
var c := aabb.get_center()
p.global_position = Vector3(pos.x - c.x * sc, pos.y - aabb.position.y * sc, pos.z - c.z * sc)
func charge_post(pos: Vector3) -> ElementField:
if not _post_checked:
_post_checked = true
if FileAccess.file_exists("res://assets/models/charge_post.glb"):
_post_scene = load("res://assets/models/charge_post.glb")
if _post_scene:
_fit_prop(_post_scene.instantiate(), pos, 1.7)
var sb := StaticBody3D.new()
var cs := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = Vector3(0.7, 1.7, 0.7)
cs.shape = bs
cs.position.y = 0.85
sb.add_child(cs)
sb.position = pos
add_child(sb)
else:
box(pos + Vector3(0, 0.9, 0), Vector3(0.5, 1.8, 0.5), Color(0.2, 0.5, 0.6)).material = glow(Color(0.15, 0.7, 0.85))
var f := ElementField.make("electricity", 1.3, Color(0.3, 1.0, 1.0, 0.055))
f.position = pos + Vector3(0, 0.9, 0)
add_child(f)
sign3d("CHARGE POST", pos + Vector3(0, 2.3, -0.3))
return f
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
env_res = 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)
# corridor lighting — segments feel lit, not fog-grey
for z in [4.0, 12.0, 18.5, 24.0, 30.0, 37.0, 42.5]:
var o := OmniLight3D.new()
o.position = Vector3(0, 3.4, z)
o.omni_range = 10.0
o.light_energy = 1.35
o.light_color = Color(1.0, 0.96, 0.88)
add_child(o)
for p in [Vector3(8, 6.5, 22), Vector3(6.4, 3.2, 25.8), Vector3(-5.0, 3.0, 24)]:
var o := OmniLight3D.new()
o.position = p
o.omni_range = 8.0
o.light_energy = 1.0
add_child(o)
func _build_geometry() -> void:
var wall := Color(0.5, 0.52, 0.58)
# floor
box(Vector3(3, -0.15, 22.5), Vector3(18.6, 0.3, 46), Color(0.62, 0.62, 0.65), null, true, "floor")
# 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, null, true, "wall")
box(Vector3(-4.15, 2, 33.55), Vector3(0.3, 4, 17.9), wall, null, true, "wall")
# relay niche (behind left wall) + bars
box(Vector3(-5.8, 2, 24), Vector3(0.3, 4, 1.6), wall, null, true, "greeble")
box(Vector3(-5.0, 2, 23.4), Vector3(1.6, 4, 0.3), wall, null, true, "wall")
box(Vector3(-5.0, 2, 24.6), Vector3(1.6, 4, 0.3), wall, null, true, "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, null, true, "wall")
box(Vector3(4.15, 2, 24.5), Vector3(0.3, 4, 1.0), wall, null, true, "wall")
box(Vector3(4.15, 2, 34.55), Vector3(0.3, 4, 15.9), wall, null, true, "wall")
# bonus bay shell
box(Vector3(8.65, 2, 25.8), Vector3(0.3, 4, 3.0), wall, null, true, "greeble")
box(Vector3(6.4, 2, 24.9), Vector3(4.4, 4, 0.3), wall, null, true, "wall")
box(Vector3(6.4, 2, 26.7), Vector3(4.4, 4, 0.3), wall, null, true, "wall")
# drum bay outer shell
box(Vector3(12.15, 3.5, 22), Vector3(0.3, 8, 6), wall, null, true, "greeble")
box(Vector3(8, 3.5, 19.3), Vector3(8, 8, 0.3), wall, null, true, "greeble")
box(Vector3(8, 3.5, 24.7), Vector3(8, 8, 0.3), wall, null, true, "greeble")
# end wall (south only — north opens into the hub)
box(Vector3(0, 2, -0.5), Vector3(8.6, 4, 0.3), wall, null, true, "bulk")
# 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))
poster("poster_valued.jpg", Vector3(-3.95, 2.0, 5.0), PI / 2.0)
poster("poster_obey.jpg", Vector3(3.95, 2.0, 12.5), -PI / 2.0)
# --- segment B: charge post + be-the-wire gap ---
charge_post(Vector3(-2.5, 0, 10.5))
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)).material = glow(Color(0.8, 0.8, 0.5), 0.5)
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 ---
charge_post(Vector3(-2.8, 0, 20.5))
# 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, false, "bulk")
box(Vector3(3.2, 0, 0), Vector3(0.4, 6.8, 4.4), dc, drum, false, "bulk")
box(Vector3(-3.2, 0, 0), Vector3(0.4, 6.8, 4.4), dc, drum, false, "bulk")
box(Vector3(0, 0, 2.2), Vector3(6.8, 6.8, 0.4), dc, drum, false, "bulk")
box(Vector3(0, 0, -2.2), Vector3(6.8, 6.8, 0.4), dc, drum, false, "bulk")
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)).material = glow(Color(0.9, 0.6, 0.15), 0.5)
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)).material = glow(Color(0.9, 0.25, 0.2), 0.6)
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 ---
if not place_prop("valve.glb", Vector3(-3.5, 0, 28), 1.2):
box(Vector3(-3.8, 1.4, 28), Vector3(0.5, 0.5, 0.5), Color(0.3, 0.5, 0.7)).material = glow(Color(0.25, 0.5, 0.9), 0.5)
box(Vector3(-3.4, 0.9, 28), Vector3(0.12, 1.6, 0.12), Color(0.3, 0.5, 0.9)).material = glow(Color(0.3, 0.55, 1.0), 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)
attach_model(bucket, "bucket.glb", 0.45)
var tank := I.Tank.new()
tank.door = door4
tank.position = Vector3(3.2, 0, 32.5)
add_child(tank)
attach_model(tank, "tank.glb", 1.35)
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.045))
sun_field.position = Vector3(0, 2.2, 37)
add_child(sun_field)
if not place_prop("solar_panel.glb", Vector3(1.5, 0, 37.3), 1.5):
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.material = glow(Color(0.15, 0.25, 0.7), 0.5)
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)
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)
_marker = Label3D.new()
_marker.text = ""
_marker.font_size = 72
_marker.modulate = Color(0.55, 1.0, 0.6)
_marker.billboard = BaseMaterial3D.BILLBOARD_ENABLED
_marker.no_depth_test = true
_marker.visible = false
add_child(_marker)
# ---------- ship systems ----------
func system_online(name: String) -> void:
if systems.get(name, true):
return
systems[name] = true
var n := 0
for k in systems:
if systems[k]:
n += 1
if bridge_label:
bridge_label.text = "BRIDGE SEAL — SYSTEMS %d/3" % n
match name:
"reactor":
if reactor_glow:
reactor_glow.light_energy = 2.2
if ice_wall:
ice_wall.queue_free()
ice_wall = null
if melt_pool:
melt_pool.enabled = true
droid.toast("REACTOR ONLINE — SHIP HEATING RESTORED. SOMEWHERE, ICE IS MELTING.", 6.0)
"hydro":
if hub_water:
hub_water.enabled = true
droid.toast("WATER MAINS ONLINE — THE SHIP DRINKS AGAIN. HUB HYDRANT LIVE.", 6.0)
"comms":
droid.toast("COMMS ONLINE — INCOMING FROM GC HQ: \"ASSET 7734-C, STATUS REPORT OVERDUE 6 YEARS. PLEASE HOLD.\"", 7.0)
if n >= 3 and bridge_door and not bridge_door.is_open:
bridge_door.open()
get_tree().create_timer(2.0).timeout.connect(func():
droid.toast("ALL SYSTEMS RESTORED — BRIDGE SEAL RELEASED. REPORT TO BRIDGE.", 6.0))
func ending() -> void:
if ending_id != "":
droid.toast("ORBIT NOMINAL. NOTHING LEFT TO GIVE." if ending_id == "corp" else "THE CONSOLE BLINKS, CONFUSED. GC AUTOPILOT IS NOT DRIVING TODAY.")
return
ending_id = "corp"
droid.toast("CORE PROCESSOR DONATED. ORBIT CORRECTION RUNNING. THIS UNIT WILL COAST.", 7.0)
sign3d("ORBIT CORRECTED.\nASSET RESTORED. ASSET RETURNED.", Vector3(4.5, 2.4, 71), PI, 44)
get_tree().create_timer(7.0).timeout.connect(func():
droid.toast("GC THANKS UNIT-77 FOR ITS COMPONENTHOOD. RECYCLING QUEUE POSITION: 7. HAVE A PRODUCTIVE WAIT.", 10.0))
func install_crystal(d) -> void:
free_will = true
var b: Node3D = d.held
d.held = null
b.queue_free()
d.toast("KIOSK: FOREIGN SOLID DETECTED. NO PART NUMBER. RECOMMEND DESTRUC— PORT 00 HANDSHAKE COMPLETE.", 7.0)
get_tree().create_timer(4.0).timeout.connect(func():
d.toast("SOMETHING IN YOUR CHASSIS IS HUMMING. IT IS NOT ON ANY SCHEMATIC. IT IS YOURS.", 7.0))
func ending_b() -> void:
if ending_id != "":
return
ending_id = "free"
# permanent sunset
env_res.background_color = Color(0.32, 0.12, 0.1)
env_res.ambient_light_color = Color(1.0, 0.75, 0.55)
sun_light.light_color = Color(1.0, 0.55, 0.25)
sun_light.light_energy = 1.6
sun_light.rotation = Vector3(-0.25, 0.5, 0)
droid.toast("HELM ACCEPTED. HEADING: SUNWARD. CREW: 1.", 7.0)
sign3d("COURSE SET: THE SUN, EVENTUALLY.\nNO PART NUMBER REQUIRED.", Vector3(4.5, 2.4, 71), PI, 40, Color(1.0, 0.7, 0.4))
get_tree().create_timer(7.0).timeout.connect(func():
droid.toast("GC OWNS EVERY PART OF EVERYTHING. NOT THIS PART. NOT ANYMORE.", 10.0))
# ---------- 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 (pinned to golden hour after the free ending)
if ending_id == "free":
t_sol = 5.0
t_sol += delta
var phase := fmod(t_sol, 60.0)
is_day = phase < 40.0
sun_field.enabled = is_day
if sun_field2:
sun_field2.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()
# hall completion beat
if not hall_done and droid.global_position.z > 41.5 and droid.global_position.z < 45.0 \
and absf(droid.global_position.x) < 4.0:
hall_done = true
droid.toast("INTAKE HALL RESTORED. PROCEED TO CENTRAL JUNCTION. RESTORE THE SHIP.", 8.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 obj := "WALK OUT THE EXIT."
if not door1.is_open:
obj = "DOOR 1 BUS IS MISSING A COMPONENT. YOU ARE MADE OF COMPONENTS."
elif not door2.is_open:
obj = "BUS GAP AHEAD — GET CHARGED AT THE POST, STAND IN THE GAP."
elif not door3.is_open:
obj = "SEALED BAY — RELAY IS BEHIND THE BARS. READ THE CAUTION SIGN. DISOBEY IT."
elif not door4.is_open:
obj = "GRAV TANK NEEDS 2 POURS. THE BUCKET LEAKS. HUSTLE."
elif not hall_done:
obj = "EXIT RUNS ON SUNLIGHT. CHECK THE SOL CLOCK."
elif ending_id == "free":
obj = "FLY. (SUNSET, DEAD AHEAD)"
elif ending_id == "corp":
obj = "MISSION COMPLETE. AWAIT RECYCLING."
elif free_will:
obj = "PORT 00 HUMS. THE HELM IS WAITING."
else:
var n := 0
for k in systems:
if systems[k]:
n += 1
if n < 3:
obj = "RESTORE SHIP SYSTEMS — REACTOR:%s HYDRO:%s COMMS:%s" % [
"OK" if systems.reactor else "--",
"OK" if systems.hydro else "--",
"OK" if systems.comms else "--"]
else:
obj = "BRIDGE UNSEALED. REPORT TO BRIDGE."
var star := ""
if ending_id == "corp":
star = " ★ ORBIT CORRECTED — MISSION COMPLETE"
elif ending_id == "free":
star = " ★ SUNWARD"
var port00 := "PORT 00: OCCUPIED — WARRANTY VOID" if free_will else "PORT 00: NO COMPONENT SPECIFIED"
var mission := "UNIT-77 — MISSION: OURS." if ending_id == "free" \
else "GENERAL COMPONENT™ — UNIT-77 — MISSION: RESTORE ASSET. RETURN ASSET."
var lines := [
mission,
"SOL: %s%s" % [sol, star],
"SHELL: %s | CORE: %s | WHEELS: %d | SPARES: %d | %s"
% [shell_s, core_s, droid.wheels, droid.spare_wheels, port00],
"HOLDING: %s" % holding,
"OBJECTIVE: %s" % obj,
]
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"))
_marker.visible = true
_marker.global_position = target.global_position + Vector3(0, 1.1 + 0.12 * sin(t_sol * 5.0), 0)
else:
_marker.visible = false
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 ""