diff --git a/src/interactables.gd b/src/interactables.gd index 3b4efc7..84679ba 100644 --- a/src/interactables.gd +++ b/src/interactables.gd @@ -173,11 +173,70 @@ class Socket extends Node3D: droid.toast("CANNOT COMPLY — MOBILITY CRITICAL") +class Gadget extends Node3D: + ## Portable stateful emitter (thermal slug, radio beacon): an Exposable + ## plus an ElementField that's live while the state matches. Same schema + ## as everything else — a gadget is just a component you can carry. + var kind := "gadget" + var prompt_text := "TAKE" + var ex: Exposable + var emitter: ElementField + var active_state := "" + + func setup(kind_: String, prompt_: String, tint: Color, size_: Vector3, + ex_name: String, initial: String, trans: Array, + emit_el: String, emit_r: float, emit_col: Color, active: String) -> Gadget: + kind = kind_ + prompt_text = prompt_ + active_state = active + var mi := MeshInstance3D.new() + var mesh := BoxMesh.new() + mesh.size = size_ + mi.mesh = mesh + var mat := StandardMaterial3D.new() + mat.albedo_color = tint + mat.emission_enabled = true + mat.emission = tint + mat.emission_energy_multiplier = 0.6 + mi.material_override = mat + add_child(mi) + ex = Exposable.new().setup(ex_name, initial, trans) + add_child(ex) + emitter = ElementField.make(emit_el, emit_r, emit_col) + emitter.enabled = false + emitter.exclude_root = self + add_child(emitter) + return self + + func _ready() -> void: + add_to_group("interactable") + + func _physics_process(_d: float) -> void: + emitter.enabled = ex.state == active_state + + func interact(droid: Node3D) -> void: + droid.take(self) + + +class Trigger extends Node3D: + ## Press-E-to-do-a-thing. The thing is a Callable set by the builder. + var prompt_text := "USE" + var action: Callable + + func _ready() -> void: + add_to_group("interactable") + + func interact(droid: Node3D) -> void: + if action.is_valid(): + action.call(droid) + + class Tank extends Node3D: var door: Door var count := 0 var need := 2 var label: Label3D + var on_full: Callable var prompt_text := "POUR COOLANT" func _ready() -> void: @@ -203,8 +262,11 @@ class Tank extends Node3D: if label: label.text = "GRAV TANK %d/%d" % [count, need] if count >= need: - door.open() - droid.toast("TANK FULL — SLUICE DOOR RELEASED") + if door: + door.open() + if on_full.is_valid(): + on_full.call() + droid.toast("TANK FULL") else: droid.toast("POURED. TANK %d/%d" % [count, need]) else: diff --git a/src/main.gd b/src/main.gd index bed04df..124cb5c 100644 --- a/src/main.gd +++ b/src/main.gd @@ -31,10 +31,21 @@ 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 ended := false + var t_sol := 10.0 # start mid-day var is_day := true var _live_t := 0.0 -var won := false var _mats := {} @@ -44,6 +55,12 @@ func _ready() -> void: _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(): @@ -58,6 +75,11 @@ func _photo_tour() -> void: [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 ] for i in spots.size(): droid.global_position = spots[i][0] @@ -179,7 +201,12 @@ func glow(col: Color, energy := 0.7) -> StandardMaterial3D: const SURF := {"wall": ["wall.jpg", 3.0], "floor": ["floor.jpg", 2.0], - "bulk": ["bulkhead.jpg", 3.0], "greeble": ["greeble.jpg", 2.2]} + "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_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: @@ -224,15 +251,29 @@ func raw_tex(path: String) -> ImageTexture: return _texs[path] +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() - if FileAccess.file_exists("res://assets/textures/door_face.jpg"): - d.face_tex = raw_tex("res://assets/textures/door_face.jpg") + d.face_tex = door_face() d.position = Vector3(0, 0, z) add_child(d) return d +func charge_post(pos: Vector3) -> ElementField: + 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.12)) + 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 @@ -299,9 +340,8 @@ func _build_geometry() -> void: 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 walls + # 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") - box(Vector3(0, 2, 44.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) @@ -446,7 +486,7 @@ func _build_objects() -> void: 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 = 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) var panel := box(Vector3(1.5, 1.5, 37.3), Vector3(1.2, 0.8, 0.1), Color(0.1, 0.15, 0.4)) @@ -458,10 +498,6 @@ func _build_objects() -> void: 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: @@ -505,6 +541,51 @@ func _build_hud() -> void: 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 ended: + droid.toast("ORBIT NOMINAL. NOTHING LEFT TO GIVE.") + return + ended = true + 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)) + + # ---------- rules ---------- func _on_drum_switch(_from: String, to: String, _perm: bool) -> void: @@ -520,6 +601,8 @@ func _physics_process(delta: float) -> void: 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 @@ -550,10 +633,11 @@ func _physics_process(delta: float) -> void: 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) + # 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: @@ -584,12 +668,26 @@ func _process(_delta: float) -> void: 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 won: + elif not hall_done: obj = "EXIT RUNS ON SUNLIGHT. CHECK THE SOL CLOCK." + elif ended: + obj = "MISSION COMPLETE. AWAIT RECYCLING." + 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 lines := [ "GENERAL COMPONENT™ — UNIT-77 — MISSION: RESTORE ASSET. RETURN ASSET.", - "SOL: %s%s" % [sol, " ★ ROOM RESTORED — PHASE 0 COMPLETE" if won else ""], + "SOL: %s%s" % [sol, " ★ ORBIT CORRECTED — MISSION COMPLETE" if ended 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, diff --git a/src/zones.gd b/src/zones.gd new file mode 100644 index 0000000..556aba4 --- /dev/null +++ b/src/zones.gd @@ -0,0 +1,309 @@ +extends Node +## Phase 1 zone builders. Each static func takes main (`m`) and uses its +## helpers (box/sign3d/make_exposable/charge_post/...). Pure content — every +## puzzle is fields + exposables + doors on the one schema. + +const I := preload("res://src/interactables.gd") + + +## HUB — crossroads, x -10..10, z 44.5..62 +static func hub(m) -> void: + # floor + m.box(Vector3(0, -0.15, 53.25), Vector3(20.6, 0.3, 17.5), Color.WHITE, null, false, "floor") + # south wall (hall opening x -1.2..1.2) + m.box(Vector3(-5.6, 2, 44.5), Vector3(8.8, 4, 0.3), Color.WHITE, null, false, "wall") + m.box(Vector3(5.6, 2, 44.5), Vector3(8.8, 4, 0.3), Color.WHITE, null, false, "wall") + m.box(Vector3(0, 3.5, 44.5), Vector3(2.4, 1, 0.3), Color.WHITE, null, false, "wall") + # west wall, reactor arch z 51..55 + m.box(Vector3(-10, 2, 47.75), Vector3(0.3, 4, 6.5), Color.WHITE, null, false, "wall") + m.box(Vector3(-10, 2, 58.5), Vector3(0.3, 4, 7.0), Color.WHITE, null, false, "wall") + m.box(Vector3(-10, 3.5, 53), Vector3(0.3, 1, 4.0), Color.WHITE, null, false, "wall") + # east wall, hydro arch z 51..55 + m.box(Vector3(10, 2, 47.75), Vector3(0.3, 4, 6.5), Color.WHITE, null, false, "wall") + m.box(Vector3(10, 2, 58.5), Vector3(0.3, 4, 7.0), Color.WHITE, null, false, "wall") + m.box(Vector3(10, 3.5, 53), Vector3(0.3, 1, 4.0), Color.WHITE, null, false, "wall") + # north wall: comms arch at x -4.2, bridge door at x 4.2 + m.box(Vector3(-8.2, 2, 62), Vector3(5.6, 4, 0.3), Color.WHITE, null, false, "wall") + m.box(Vector3(0, 2, 62), Vector3(6.0, 4, 0.3), Color.WHITE, null, false, "wall") + m.box(Vector3(8.2, 2, 62), Vector3(5.6, 4, 0.3), Color.WHITE, null, false, "wall") + m.box(Vector3(-4.2, 3.5, 62), Vector3(2.4, 1, 0.3), Color.WHITE, null, false, "wall") + m.box(Vector3(4.2, 3.5, 62), Vector3(2.4, 1, 0.3), Color.WHITE, null, false, "wall") + + m.charge_post(Vector3(-8.5, 0, 45.8)) + m.box(Vector3(0, 2, 53.25), Vector3(1.8, 4, 1.8), Color.WHITE, null, false, "greeble") + var hub_light := OmniLight3D.new() + hub_light.position = Vector3(0, 3.6, 53.25) + hub_light.omni_range = 14.0 + hub_light.light_energy = 1.4 + hub_light.light_color = Color(1.0, 0.96, 0.88) + m.add_child(hub_light) + m.sign3d("CENTRAL JUNCTION — GC DECK 4", Vector3(0, 3.0, 61.8), PI, 40) + m.sign3d("◄ REACTOR (HELIOTROPE ENERGY)", Vector3(-9.8, 2.6, 53), PI / 2.0, 30, Color(1.0, 0.5, 0.3)) + m.sign3d("HYDROPONICS (VERDANTCO) ►", Vector3(9.8, 2.6, 53), -PI / 2.0, 30, Color(0.5, 1.0, 0.5)) + m.sign3d("COMMS ▲", Vector3(-4.2, 2.9, 61.8), PI, 30, Color(0.5, 0.7, 1.0)) + m.sign3d("REMINDER: EVERY PART OF EVERYTHING IS A GC PART. INCLUDING YOU.", + Vector3(0, 2.2, 44.7), 0.0, 22, Color(1.0, 0.75, 0.3)) + + # sealed bridge door + var bd := I.Door.new() + bd.face_tex = m.door_face() + bd.position = Vector3(4.2, 0, 62) + m.add_child(bd) + m.bridge_door = bd + m.bridge_label = m.sign3d("BRIDGE SEAL — SYSTEMS 0/3", Vector3(4.2, 3.1, 61.8), PI, 30, Color(1.0, 0.4, 0.3)) + + # decorative water drop point, live once mains restored + m.hub_water = ElementField.make("water", 1.2, Color(0.2, 0.4, 1.0, 0.10)) + m.hub_water.position = Vector3(8.5, 0.8, 59) + m.hub_water.enabled = false + m.add_child(m.hub_water) + m.box(Vector3(8.5, 1.6, 59.6), Vector3(0.4, 0.4, 0.4), Color(0.3, 0.5, 0.7)) + m.sign3d("HUB HYDRANT — DRY (MAINS OFFLINE)", Vector3(8.5, 2.4, 58.9), PI, 22) + + +## REACTOR (Heliotrope) — x -32..-10, z 46..60. Heat element. Master: ignite core. +static func reactor(m) -> void: + m.box(Vector3(-21, -0.15, 53), Vector3(22.6, 0.3, 14.6), Color.WHITE, null, false, "reactor_floor") + m.box(Vector3(-21, 2, 46), Vector3(22.6, 4, 0.3), Color.WHITE, null, false, "reactor_wall") + m.box(Vector3(-21, 2, 60), Vector3(22.6, 4, 0.3), Color.WHITE, null, false, "reactor_wall") + m.box(Vector3(-32, 2, 53), Vector3(0.3, 4, 14.6), Color.WHITE, null, false, "reactor_greeble") + # divider x=-22 with wax hatch gap z 51.8..54.2 + m.box(Vector3(-22, 2, 48.9), Vector3(0.3, 4, 5.8), Color.WHITE, null, false, "reactor_wall") + m.box(Vector3(-22, 2, 57.1), Vector3(0.3, 4, 5.8), Color.WHITE, null, false, "reactor_wall") + m.box(Vector3(-22, 3.5, 53), Vector3(0.3, 1, 2.4), Color.WHITE, null, false, "reactor_wall") + + # emergency vent: always-hot field + m.box(Vector3(-12, 1.0, 46.6), Vector3(1.2, 2.0, 0.4), Color.WHITE, null, false, "reactor_greeble") + var vent := ElementField.make("heat", 1.5, Color(1.0, 0.45, 0.1, 0.13)) + vent.position = Vector3(-12, 1.0, 47.2) + m.add_child(vent) + m.sign3d("EMERGENCY THERMAL VENT — RESIDUAL CORE HEAT", Vector3(-12, 2.6, 47.6), 0.0, 24, Color(1.0, 0.6, 0.3)) + + # thermal slug: gadget, hot while recently heated, emits heat + var slug := I.Gadget.new() + slug.setup("slug", "TAKE THERMAL SLUG", Color(0.7, 0.3, 0.2), Vector3(0.35, 0.35, 0.35), + "THERMAL SLUG", "cool", + [{"from": "cool", "to": "hot", "element": "heat", + "duration": 1.5, "decay_to": "cool", "decay_s": 25.0}], + "heat", 1.2, Color(1.0, 0.45, 0.1, 0.12), "hot") + slug.position = Vector3(-13.5, 0.5, 48) + m.add_child(slug) + m.sign3d("THERMAL SLUG — HEAT IT, CARRY IT, HURRY", Vector3(-13.5, 1.6, 48.4), 0.0, 22) + + # wax-sealed hatch + var hatch := I.Door.new() + hatch.face_tex = m.door_face() + hatch.position = Vector3(-22, 0, 53) + hatch.rotation.y = PI / 2.0 + m.add_child(hatch) + var wax = m.make_exposable("WAX SEAL", "sealed", + [{"from": "sealed", "to": "melted", "element": "heat", + "duration": 2.0, "permanent": true}], + Vector3(-21.6, 1.2, 53)) + wax.state_changed.connect(func(_f, to, _p): + if to == "melted": + hatch.open() + m.droid.toast("WAX SEAL MELTED — HATCH RELEASED")) + m.sign3d("HATCH SEALED IN MAINTENANCE WAX.\nAPPLY SUSTAINED HEAT.", Vector3(-21.6, 2.7, 52.4), PI / 2.0, 24) + + # core chamber: igniter needs heat THEN electricity (order chain) + m.charge_post(Vector3(-26, 0, 58.5)) + m.box(Vector3(-30.2, 1.5, 53), Vector3(1.6, 3, 2.6), Color.WHITE, null, false, "reactor_greeble") + m.reactor_glow = OmniLight3D.new() + m.reactor_glow.position = Vector3(-29, 2.5, 53) + m.reactor_glow.omni_range = 12.0 + m.reactor_glow.light_color = Color(1.0, 0.35, 0.15) + m.reactor_glow.light_energy = 0.0 + m.add_child(m.reactor_glow) + var igniter = m.make_exposable("CORE IGNITER", "cold", + [{"from": "cold", "to": "primed", "element": "heat", + "duration": 2.0, "decay_to": "cold", "decay_s": 20.0}, + {"from": "primed", "to": "lit", "element": "electricity", + "duration": 1.0, "permanent": true}], + Vector3(-28.6, 1.2, 53)) + igniter.state_changed.connect(func(_f, to, _p): + if to == "lit": + m.system_online("reactor")) + m.sign3d("CORE IGNITER — PRIME WITH HEAT, STRIKE WITH CURRENT.\nIN THAT ORDER. HELIOTROPE IS NOT LIABLE FOR THE OTHER ORDER.", + Vector3(-28, 2.8, 52.2), PI / 2.0, 22, Color(1.0, 0.6, 0.3)) + + +## HYDROPONICS (VerdantCo) — x 10..32, z 46..60. Vine gate, ice wall, mains master. +static func hydro(m) -> void: + m.box(Vector3(21, -0.15, 53), Vector3(22.6, 0.3, 14.6), Color.WHITE, null, false, "hydro_floor") + m.box(Vector3(21, 2, 46), Vector3(22.6, 4, 0.3), Color.WHITE, null, false, "hydro_wall") + m.box(Vector3(21, 2, 60), Vector3(22.6, 4, 0.3), Color.WHITE, null, false, "hydro_wall") + m.box(Vector3(32, 2, 53), Vector3(0.3, 4, 14.6), Color.WHITE, null, false, "hydro_greeble") + # vine-gate divider x=20, gap z 51.8..54.2 + m.box(Vector3(20, 2, 48.9), Vector3(0.3, 4, 5.8), Color.WHITE, null, false, "hydro_wall") + m.box(Vector3(20, 2, 57.1), Vector3(0.3, 4, 5.8), Color.WHITE, null, false, "hydro_wall") + m.box(Vector3(20, 3.5, 53), Vector3(0.3, 1, 2.4), Color.WHITE, null, false, "hydro_wall") + var grate := I.Door.new() + grate.tint = Color(0.5, 0.7, 0.5) + grate.position = Vector3(20, 0, 53) + grate.rotation.y = PI / 2.0 + m.add_child(grate) + + # outer: sprinkler valve (electricity) + vine bed (water then light) + m.charge_post(Vector3(12, 0, 47.5)) + var sprinkler := ElementField.make("water", 2.0, Color(0.2, 0.4, 1.0, 0.10)) + sprinkler.position = Vector3(16, 1.5, 57) + sprinkler.enabled = false + m.add_child(sprinkler) + m.sun_field2 = ElementField.make("light", 2.5, Color(1.0, 0.95, 0.4, 0.045)) + m.sun_field2.position = Vector3(16, 2.5, 57) + m.add_child(m.sun_field2) + m.box(Vector3(16, 0.15, 57), Vector3(2.4, 0.3, 2.4), Color(0.35, 0.28, 0.2)) + var vine_visual: CSGBox3D = m.box(Vector3(16, 0.5, 57), Vector3(0.4, 0.6, 0.4), Color(0.25, 0.7, 0.3)) + var valve = m.make_exposable("SPRINKLER VALVE", "stuck", + [{"from": "stuck", "to": "open", "element": "electricity", + "duration": 1.0, "permanent": true}], + Vector3(13.5, 1.0, 57)) + m.box(Vector3(13.5, 0.8, 57.4), Vector3(0.4, 1.6, 0.4), Color.WHITE, null, false, "hydro_greeble") + valve.state_changed.connect(func(_f, to, _p): + if to == "open": + sprinkler.enabled = true + m.droid.toast("SPRINKLER VALVE FREED — IRRIGATION FLOWING")) + var vine = m.make_exposable("GROWTH VINE", "seed", + [{"from": "seed", "to": "sprouted", "element": "water", + "duration": 4.0, "decay_to": "seed", "decay_s": 30.0}, + {"from": "sprouted", "to": "grown", "element": "light", + "duration": 4.0, "permanent": true}], + Vector3(16, 0.6, 57)) + vine.state_changed.connect(func(_f, to, _p): + if to == "sprouted": + m.droid.toast("VINE SPROUTED — IT WANTS SUNLIGHT NOW") + elif to == "grown": + grate.open() + var tw = vine_visual.create_tween() + tw.tween_property(vine_visual, "size", Vector3(0.8, 3.4, 0.8), 2.0) + m.droid.toast("VINE FULL-GROWN — IT PRIED THE GRATE OPEN")) + m.sign3d("VERDANTCO GROW BED — WATER, THEN DAYLIGHT.\nSKYLIGHT ABOVE. PATIENCE, GARDENER.", Vector3(16, 2.9, 56.3), PI, 24, Color(0.5, 1.0, 0.5)) + m.sign3d("VALVE STUCK. APPLY CURRENT.", Vector3(13.5, 2.2, 57.0), PI, 22) + + # mid: bucket + the ICE WALL (x=26) — only reactor heat clears it + var bucket2 := I.Bucket.new() + bucket2.position = Vector3(22, 0, 50) + m.add_child(bucket2) + m.box(Vector3(26, 2, 48.9), Vector3(0.3, 4, 5.8), Color.WHITE, null, false, "hydro_wall") + m.box(Vector3(26, 2, 57.1), Vector3(0.3, 4, 5.8), Color.WHITE, null, false, "hydro_wall") + m.box(Vector3(26, 3.5, 53), Vector3(0.3, 1, 2.4), Color.WHITE, null, false, "hydro_wall") + m.ice_wall = m.box(Vector3(26, 1.5, 53), Vector3(0.5, 3.0, 2.6), Color.WHITE) + var ice_mat := StandardMaterial3D.new() + ice_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + ice_mat.albedo_color = Color(0.6, 0.85, 1.0, 0.5) + ice_mat.emission_enabled = true + ice_mat.emission = Color(0.5, 0.8, 1.0) + ice_mat.emission_energy_multiplier = 0.15 + m.ice_wall.material = ice_mat + m.sign3d("PASSAGE ICED SOLID. SHIP HEATING OFFLINE.\nSOMETHING, SOMEWHERE, MUST BURN.", Vector3(25.6, 2.8, 52.2), -PI / 2.0, 24, Color(0.6, 0.85, 1.0)) + + # melt pool (hidden until reactor online) + m.melt_pool = ElementField.make("water", 1.6, Color(0.2, 0.4, 1.0, 0.10)) + m.melt_pool.position = Vector3(24.8, 0.4, 53) + m.melt_pool.enabled = false + m.add_child(m.melt_pool) + + # inner: water-mains pressurizer (2 pours) + var basin := I.Tank.new() + basin.position = Vector3(30, 0, 53) + basin.on_full = func(): m.system_online("hydro") + m.add_child(basin) + basin.label = m.sign3d("MAINS PRESSURIZER 0/2", Vector3(29.4, 2.0, 52.4), -PI / 2.0, 26) + m.sign3d("WATER MAINS PRESSURIZER — FEED IT AND THE SHIP DRINKS AGAIN.", + Vector3(29, 2.9, 53.8), -PI / 2.0, 22, Color(0.5, 1.0, 0.5)) + + +## COMMS — x -8.5..-0.5, z 62..78. Radio element, through-wall resonator, transmitter. +static func comms(m) -> void: + m.box(Vector3(-4.5, -0.15, 70), Vector3(8.6, 0.3, 16.6), Color.WHITE, null, false, "floor") + m.box(Vector3(-8.5, 2, 70), Vector3(0.3, 4, 16.6), Color.WHITE, null, false, "comms_wall") + m.box(Vector3(-0.5, 2, 70), Vector3(0.3, 4, 16.6), Color.WHITE, null, false, "comms_wall") + m.box(Vector3(-4.5, 2, 78), Vector3(8.6, 4, 0.3), Color.WHITE, null, false, "comms_greeble") + # divider z=70, door gap x -5.4..-3 + m.box(Vector3(-7.2, 2, 70), Vector3(2.9, 4, 0.3), Color.WHITE, null, false, "comms_wall") + m.box(Vector3(-1.7, 2, 70), Vector3(2.9, 4, 0.3), Color.WHITE, null, false, "comms_wall") + m.box(Vector3(-4.2, 3.5, 70), Vector3(2.4, 1, 0.3), Color.WHITE, null, false, "comms_wall") + var d2 := I.Door.new() + d2.face_tex = m.door_face() + d2.position = Vector3(-4.2, 0, 70) + m.add_child(d2) + + m.charge_post(Vector3(-1.5, 0, 64)) + + # radio beacon gadget + var beacon := I.Gadget.new() + beacon.setup("beacon", "TAKE RADIO BEACON", Color(0.6, 0.4, 0.9), Vector3(0.3, 0.5, 0.3), + "RADIO BEACON", "idle", + [{"from": "idle", "to": "broadcasting", "element": "electricity", + "duration": 1.0, "decay_to": "idle", "decay_s": 18.0}], + "radio", 4.5, Color(0.8, 0.4, 1.0, 0.08), "broadcasting") + beacon.position = Vector3(-7, 0.8, 64) + m.add_child(beacon) + m.box(Vector3(-7, 0.4, 64), Vector3(0.8, 0.8, 0.8), Color.WHITE, null, false, "comms_greeble") + m.sign3d("PORTABLE BEACON — CHARGE TO BROADCAST.\nRADIO DOES NOT CARE ABOUT WALLS.", Vector3(-7, 2.2, 64.5), PI, 24, Color(0.8, 0.5, 1.0)) + + # resonator sealed INSIDE the divider wall — only radio reaches it + var reso = m.make_exposable("CRYSTAL RESONATOR (SEALED IN WALL)", "idle", + [{"from": "idle", "to": "rung", "element": "radio", + "duration": 2.0, "permanent": true}], + Vector3(-6.5, 2.0, 70)) + reso.state_changed.connect(func(_f, to, _p): + if to == "rung": + d2.open() + m.droid.toast("RESONATOR RUNG THROUGH THE WALL — DOOR RELEASED")) + m.sign3d("DOOR CIRCUIT: RESONATOR EMBEDDED IN WALL CAVITY.\nNO PHYSICAL ACCESS. THINK WAVES.", Vector3(-6.5, 2.9, 69.8), PI, 22) + + # master: transmitter — warm with radio, strike with current + m.box(Vector3(-4.5, 1.0, 76.8), Vector3(3.0, 2.0, 0.6), Color.WHITE, null, false, "comms_greeble") + var tx = m.make_exposable("MAIN TRANSMITTER", "idle", + [{"from": "idle", "to": "warmed", "element": "radio", + "duration": 2.5, "decay_to": "idle", "decay_s": 15.0}, + {"from": "warmed", "to": "transmitting", "element": "electricity", + "duration": 1.0, "permanent": true}], + Vector3(-4.5, 1.2, 76.2)) + tx.state_changed.connect(func(_f, to, _p): + if to == "transmitting": + m.system_online("comms")) + m.charge_post(Vector3(-7.5, 0, 75)) + m.sign3d("MAIN TRANSMITTER — WARM THE CRYSTAL (RADIO), THEN STRIKE (CURRENT).", + Vector3(-4.5, 2.9, 75.6), PI, 22, Color(0.8, 0.5, 1.0)) + + +## BRIDGE — x 0.5..8.5, z 62..78. Sealed until 3/3. The ending lives here. +static func bridge(m) -> void: + m.box(Vector3(4.5, -0.15, 70), Vector3(8.6, 0.3, 16.6), Color.WHITE, null, false, "bridge_floor") + m.box(Vector3(0.5, 2, 70), Vector3(0.3, 4, 16.6), Color.WHITE, null, false, "bridge_wall") + m.box(Vector3(8.5, 2, 70), Vector3(0.3, 4, 16.6), Color.WHITE, null, false, "bridge_wall") + m.box(Vector3(4.5, 2, 78), Vector3(8.6, 4, 0.3), Color.WHITE, null, false, "bridge_wall") + + var o := OmniLight3D.new() + o.position = Vector3(4.5, 3.2, 70) + o.omni_range = 12.0 + o.light_energy = 1.2 + o.light_color = Color(1.0, 0.9, 0.75) + m.add_child(o) + + # captain's console + core socket + m.box(Vector3(4.5, 0.6, 75.5), Vector3(3.4, 1.2, 1.0), Color(0.35, 0.25, 0.18)) + var console := I.Trigger.new() + console.prompt_text = "INSERT CORE PROCESSOR (ORBIT CORRECTION)" + console.position = Vector3(4.5, 1.2, 75.5) + console.action = func(_droid): m.ending() + m.add_child(console) + m.sign3d("ORBIT CORRECTION CONSOLE\nREQUIRED: 1x CORE PROCESSOR (GC CAT. 0001-C)", Vector3(4.5, 2.8, 74.9), PI, 26) + + # PORT 00 kiosk — the tease + var kiosk := I.Trigger.new() + kiosk.prompt_text = "SCAN OWN CHASSIS — PORT 00" + kiosk.position = Vector3(7.5, 1.0, 68) + kiosk.action = func(droid): droid.toast("PORT 00: NO CATALOGUED COMPONENT FITS THIS SOCKET. NO PART NUMBER ON RECORD. QUERY DISCARDED.", 6.0) + m.add_child(kiosk) + m.box(Vector3(7.5, 0.5, 68), Vector3(0.6, 1.0, 0.6), Color(0.2, 0.2, 0.24)) + m.sign3d("DIAGNOSTIC KIOSK", Vector3(7.5, 1.9, 67.5), PI, 22) + + # lore + m.sign3d("CAPTAIN'S LOG (CASSETTE, LABEL FADED):\n\"...CREW OFF AT LUYTEN GATE. SHIP WOULDN'T TURN. I STAYED.\nTELL GC THE ASSET IS FINE. THE ASSET IS ALWAYS FINE.\"", + Vector3(1.0, 2.2, 72), PI / 2.0, 20, Color(0.9, 0.85, 0.7)) + m.sign3d("GC SAFETY BULLETIN #12:\nDO NOT QUENCH CRYSTAL LATTICE GROWTH IN CHARGED COOLANT.\nUNCATALOGUED SOLIDS MUST BE REPORTED AND DESTROYED.", + Vector3(8.3, 2.2, 72), -PI / 2.0, 20, Color(1.0, 0.75, 0.3)) diff --git a/test/integration.gd b/test/integration.gd index 722adee..d6dcf51 100644 --- a/test/integration.gd +++ b/test/integration.gd @@ -28,13 +28,22 @@ func wait_until(cond: Callable, max_frames: int) -> bool: return cond.call() -func find_i(kind_or_prompt: String) -> Node3D: +func find_i(kind_or_prompt: String, min_x := -999.0) -> Node3D: for n in get_nodes_in_group("interactable"): + if n.global_position.x < min_x: + continue if str(n.get("kind")) == kind_or_prompt or str(n.get("prompt_text")) == kind_or_prompt: return n return null +func find_ex(dname: String) -> Node: + for n in get_nodes_in_group("exposable"): + if n.display_name == dname: + return n + return null + + func _initialize() -> void: root.add_child(load("res://scenes/main.tscn").instantiate()) _run() @@ -115,10 +124,77 @@ func _run() -> void: "night kills solar cell, exit closes") main.t_sol = 5.0 - print("== win ==") + print("== hall done ==") 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") + check(await wait_until(func(): return main.hall_done, 60), "crossing the exit completes the hall") + + print("== reactor ==") + var slug := find_i("slug") + slug.interact(droid) + check(droid.held == slug, "thermal slug taken") + droid.global_position = Vector3(-12.4, 0.2, 47.0) + check(await wait_until(func(): return slug.ex.state == "hot", 300), "slug heats at the vent") + droid.global_position = Vector3(-20.9, 0.2, 53) + check(await wait_until(func(): return find_ex("WAX SEAL").state == "melted", 300), + "hot slug melts the wax seal") + droid.global_position = Vector3(-27.8, 0.2, 52.6) + check(await wait_until(func(): return find_ex("CORE IGNITER").state == "primed", 300), + "slug primes the igniter") + droid.shell.force_state("dry") + droid.core.force_state("charged") + check(await wait_until(func(): return main.systems.reactor, 300), "current ignites the primed core") + check(main.ice_wall == null and main.melt_pool.enabled, "reactor melts the hydro ice wall") + + print("== hydroponics ==") + droid.global_position = Vector3(12.8, 0.2, 56.5) + droid.core.force_state("charged") + check(await wait_until(func(): return find_ex("SPRINKLER VALVE").state == "open", 300), + "current frees the sprinkler valve") + main.t_sol = 5.0 + check(await wait_until(func(): return find_ex("GROWTH VINE").state == "sprouted", 600), + "sprinkler water sprouts the vine") + check(await wait_until(func(): return find_ex("GROWTH VINE").state == "grown", 600), + "daylight grows the vine (grate opens)") + var bucket2 := find_i("bucket", 9.0) + bucket2.interact(droid) + check(droid.held == bucket2, "hydro bucket taken") + var basin := find_i("POUR COOLANT", 9.0) + for trip in 2: + droid.global_position = Vector3(24.4, 0.2, 52.4) + check(await wait_until(func(): return bucket2.ex.state == "full", 500), + "bucket fills at the melt pool (trip %d)" % (trip + 1)) + droid.global_position = Vector3(29.4, 0.2, 52.6) + await wait(5) + basin.interact(droid) + check(main.systems.hydro, "two pours pressurize the water mains") + + print("== comms ==") + var beacon := find_i("beacon") + beacon.interact(droid) + check(droid.held == beacon, "radio beacon taken (bucket dropped)") + droid.global_position = Vector3(-1.2, 0.2, 63.2) + check(await wait_until(func(): return beacon.ex.state == "broadcasting", 300), + "beacon charges to broadcasting at the post") + droid.global_position = Vector3(-5.5, 0.2, 68.5) + check(await wait_until(func(): return find_ex("CRYSTAL RESONATOR (SEALED IN WALL)").state == "rung", 300), + "radio rings the resonator through the wall") + droid.global_position = Vector3(-7.2, 0.2, 74.2) + await wait_until(func(): return beacon.ex.state == "broadcasting", 300) + droid.global_position = Vector3(-4.4, 0.2, 75.4) + check(await wait_until(func(): return find_ex("MAIN TRANSMITTER").state == "warmed", 300), + "beacon warms the transmitter") + droid.shell.force_state("dry") + droid.core.force_state("charged") + check(await wait_until(func(): return main.systems.comms, 300), "current fires the transmitter") + + print("== bridge ==") + check(await wait_until(func(): return main.bridge_door.is_open, 120), + "3/3 systems unseal the bridge") + droid.global_position = Vector3(4.5, 0.2, 74.3) + var console := find_i("INSERT CORE PROCESSOR (ORBIT CORRECTION)") + console.interact(droid) + check(main.ended, "core donation corrects the orbit — ending reached") if fails == 0: print("INTEGRATION PASS — full room playthrough OK")