extends Node3D ## World controller. ## ## Owns spawn ordering so the player never falls through ungenerated ground: ## waits for the terrain, places the scenery, then drops the player onto a dry, ## flat spot facing the lake. Also aims the sun, and respawns the player if they ## somehow leave the map or fall below it (a safety net behind the mountain rim ## and invisible wall in terrain.gd). ## ## Dev hook: `godot --path . -- --shot=/abs/prefix` renders a few fixed views to ## _.png and quits. Handy for checking visual changes. @onready var terrain: Node3D = $Terrain @onready var props: Node3D = $Props @onready var player: CharacterBody3D = $Player @onready var sun: DirectionalLight3D = $Sun const PIECES := { "campfire": preload("res://scripts/building/campfire.gd"), "workbench": preload("res://scripts/building/workbench.gd"), "chest": preload("res://scripts/building/chest.gd"), "grave": preload("res://scripts/building/grave.gd"), } const AUTOSAVE_SECONDS := 120.0 ## Set by the title screen before adding the world: no player, no HUD, no ## saving, just a slow camera flight over the island as a menu backdrop. var menu_mode := false var _spawn := Vector3.ZERO var _spawn_yaw := 0.0 var _pieces: Node3D var hud: CanvasLayer var _session_time := 0.0 ## seconds played since the last save var _autosave_left := AUTOSAVE_SECONDS var _menu_cam: Camera3D var _menu_t := 0.0 var day_night: Node ## day_night.gd: time of day, sun, sky func _ready() -> void: _pieces = Node3D.new() _pieces.name = "Pieces" add_child(_pieces) day_night = preload("res://scripts/world/day_night.gd").new() day_night.name = "DayNight" add_child(day_night) day_night.setup(sun, $WorldEnvironment.environment) if menu_mode: day_night.time = 9.5 / 24.0 day_night.frozen = true day_night.apply(true) player.process_mode = Node.PROCESS_MODE_DISABLED player.get_node("Camera3D").current = false _menu_cam = Camera3D.new() add_child(_menu_cam) _menu_cam.current = true $HUD.visible = false elif DisplayServer.get_name() != "headless": hud = preload("res://scripts/ui/hud.gd").new() add_child(hud) hud.bind(player) if not menu_mode: player.survival.died.connect(_on_player_died) if Saves.has_world() and not menu_mode: # Save on window close instead of quitting straight away. get_tree().auto_accept_quit = false player.set_physics_process(false) set_physics_process(false) if terrain.built: _on_terrain_ready() else: terrain.ready_built.connect(_on_terrain_ready) # --- survival environment --------------------------------------------------- ## 0..100 air warmth at `pos` before fires: time of day, minus altitude. func air_warmth(pos: Vector3) -> float: var w: float = day_night.air_warmth() w -= maxf(pos.y - 12.0, 0.0) * 0.9 return w ## 0..1 heat from fires and a held torch. func heat_at(pos: Vector3) -> float: var h := 0.0 for n in get_tree().get_nodes_in_group("heat"): h = maxf(h, n.heat_at(pos)) if player.hands != null: h = maxf(h, player.hands.held_heat()) return h ## Death: everything you carried goes into a grave where you fell, and you ## wake up at the island start. The world carries on. func _on_player_died() -> void: var pos := player.global_position pos.y = terrain.sample_height(pos.x, pos.z) if pos.y < terrain.water_level: # Drowned or died in the lake: put the grave on the nearest dry shore. pos = terrain.find_spawn(Vector2(pos.x, pos.z)) var has_items := false for st in player.inventory.slots: if st != null: has_items = true if has_items: var grave := spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, player.rotation.y), pos)) grave.inventory.load_data(player.inventory.to_data()) player.inventory.load_data(Inventory.new(player.inventory.size()).to_data()) if hud != null: hud.close_screens() _respawn() player.survival.reset_after_death() Actions.notify.emit("You died. Your belongings are in your grave." if has_items else "You died.") func _on_terrain_ready() -> void: var t0 := Time.get_ticks_msec() _spawn = terrain.find_spawn(Vector2.ZERO) var lake: Vector2 = terrain.lake_center var to_lake := lake - Vector2(_spawn.x, _spawn.z) _spawn_yaw = atan2(-to_lake.x, -to_lake.y) player.water_level = terrain.water_level props.place_on(terrain, player if not menu_mode else _menu_cam, _spawn) print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0)) if menu_mode: return _respawn() if Saves.has_world(): var state: Dictionary = Saves.load_state(Saves.current_id) if not state.is_empty(): apply_save(state) print("[world] loaded '%s'" % Saves.current_id) player.set_physics_process(true) set_physics_process(true) _maybe_capture() func _process(delta: float) -> void: if menu_mode: # Slow orbit over the valley, looking toward the lake. _menu_t += delta * 0.025 var r := 70.0 var p := Vector3(cos(_menu_t) * r, 0.0, sin(_menu_t) * r) p.y = maxf(terrain.sample_height(p.x, p.z), terrain.water_level) + 24.0 _menu_cam.global_position = p var lake: Vector2 = terrain.lake_center _menu_cam.look_at(Vector3(lake.x * 0.5, 2.0, lake.y * 0.5), Vector3.UP) return if not Saves.has_world() or get_tree().paused: return _session_time += delta _autosave_left -= delta if _autosave_left <= 0.0: save_now() # --- saving ----------------------------------------------------------------- ## Writes the current game to the active world slot. Returns false if this ## session isn't tied to a slot (e.g. running world.tscn directly). func save_now() -> bool: _autosave_left = AUTOSAVE_SECONDS if not Saves.has_world(): return false var ok: bool = Saves.save_state(Saves.current_id, collect_save(), _session_time) if ok: _session_time = 0.0 Actions.notify.emit("Game saved" if ok else "Save FAILED") return ok func _notification(what: int) -> void: if what == NOTIFICATION_WM_CLOSE_REQUEST and Saves.has_world() and not menu_mode: save_now() get_tree().quit() static func _xf_to_array(xf: Transform3D) -> Array: var b := xf.basis return [b.x.x, b.x.y, b.x.z, b.y.x, b.y.y, b.y.z, b.z.x, b.z.y, b.z.z, xf.origin.x, xf.origin.y, xf.origin.z] static func _array_to_xf(a: Array) -> Transform3D: return Transform3D(Vector3(a[0], a[1], a[2]), Vector3(a[3], a[4], a[5]), Vector3(a[6], a[7], a[8]), Vector3(a[9], a[10], a[11])) ## Everything the player changed. The rest regenerates from the seed. func collect_save() -> Dictionary: var cam: Camera3D = player.get_node("Camera3D") var harvested := {} for uid in props.resource_nodes: var n = props.resource_nodes[uid] if not is_instance_valid(n) or n.depleted: harvested[uid] = 0.0 elif n.hp < n.max_hp: harvested[uid] = n.hp var regrow := {} for uid in props.forage: var f = props.forage[uid] if is_instance_valid(f) and f.regrow_left > 0.0: regrow[uid] = f.regrow_left var taken: Array = [] for uid in props.pickups: var pk = props.pickups[uid] if not is_instance_valid(pk) or pk.taken: taken.append(uid) var pieces: Array = [] for piece in _pieces.get_children(): if piece.is_queued_for_deletion(): continue var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)} if piece.get("inventory") != null: entry["inventory"] = piece.inventory.to_data() if piece.has_method("save_extra"): entry["extra"] = piece.save_extra() pieces.append(entry) return { "player": { "pos": [player.global_position.x, player.global_position.y, player.global_position.z], "yaw": player.rotation.y, "pitch": cam.rotation.x, "inventory": player.inventory.to_data(), "selected": player.selected, "stats": player.survival.to_data(), }, "time": {"day": day_night.day, "time": day_night.time}, "forage_regrow": regrow, "harvested": harvested, "pickups_taken": taken, "pieces": pieces, } func apply_save(s: Dictionary) -> void: var p: Dictionary = s.get("player", {}) if p.has("pos"): player.global_position = Vector3(p.pos[0], p.pos[1] + 0.05, p.pos[2]) player.rotation = Vector3(0.0, float(p.get("yaw", 0.0)), 0.0) player.get_node("Camera3D").rotation.x = float(p.get("pitch", 0.0)) if p.has("inventory"): player.inventory.load_data(p.inventory) player.select(int(p.get("selected", 0))) if p.has("stats"): player.survival.load_data(p.stats) var t: Dictionary = s.get("time", {}) if not t.is_empty(): day_night.day = int(t.get("day", 1)) day_night.time = float(t.get("time", day_night.time)) day_night.apply(true) var regrow: Dictionary = s.get("forage_regrow", {}) for uid in regrow: if props.forage.has(uid): props.forage[uid].restore(float(regrow[uid])) var harvested: Dictionary = s.get("harvested", {}) for uid in harvested: if props.resource_nodes.has(uid): props.resource_nodes[uid].restore(float(harvested[uid])) for uid in s.get("pickups_taken", []): if props.pickups.has(uid) and is_instance_valid(props.pickups[uid]): props.pickups[uid].collected() for e in s.get("pieces", []): var def: Dictionary = ItemDB.get_item(e.get("item", "")) if def.is_empty() or not def.has("place"): continue var piece := spawn_piece(def, _array_to_xf(e.xf)) if e.has("inventory") and piece.get("inventory") != null: piece.inventory.load_data(e.inventory) if e.has("extra") and piece.has_method("load_extra"): piece.load_extra(e.extra) func _respawn() -> void: player.global_position = _spawn + Vector3(0.0, 1.5, 0.0) player.velocity = Vector3.ZERO player.rotation = Vector3(0.0, _spawn_yaw, 0.0) player.get_node("Camera3D").rotation.x = 0.0 func _physics_process(_delta: float) -> void: var p := player.global_position var half: float = terrain.size * 0.5 if p.y < terrain.min_height - 30.0 or absf(p.x) > half or absf(p.z) > half \ or not terrain.inside_boundary(p.x, p.z, -4.0): push_warning("[world] player left the map at %s; respawning" % p) _respawn() # --- building --------------------------------------------------------------- ## Whether a piece can go at `xf`: inside the valley, on dry fairly flat ## ground, and not overlapping trees, rocks or other pieces. func placement_valid(def: Dictionary, xf: Transform3D) -> bool: var p := xf.origin if not terrain.inside_boundary(p.x, p.z, 1.5): return false if p.y < terrain.water_level + 0.15: return false var fp: Array = def.place.get("footprint", [1.0, 1.0]) var hx := float(fp[0]) * 0.5 var hz := float(fp[1]) * 0.5 # Ground under the corners must be within reach of the base. for c in [Vector3(-hx, 0, -hz), Vector3(hx, 0, -hz), Vector3(-hx, 0, hz), Vector3(hx, 0, hz)]: var w: Vector3 = xf * c if absf(terrain.sample_height(w.x, w.z) - p.y) > 0.45: return false # Overlap test against everything solid except the ground itself. var box := BoxShape3D.new() box.size = Vector3(hx * 2.0 - 0.1, 1.0, hz * 2.0 - 0.1) var q := PhysicsShapeQueryParameters3D.new() q.shape = box q.transform = Transform3D(xf.basis, p + Vector3(0, 0.75, 0)) q.collision_mask = 1 q.exclude = [terrain.get_node("TerrainBody").get_rid(), player.get_rid()] return get_world_3d().direct_space_state.intersect_shape(q, 1).is_empty() func spawn_piece(def: Dictionary, xf: Transform3D) -> Node3D: var script: Script = PIECES.get(def.place.piece, preload("res://scripts/building/piece.gd")) var piece: StaticBody3D = script.new() piece.setup(def) _pieces.add_child(piece) piece.global_transform = xf return piece # --- dev screenshot hook ---------------------------------------------------- func _maybe_capture() -> void: var prefix := "" for a in OS.get_cmdline_user_args(): if a.begins_with("--shot="): prefix = a.trim_prefix("--shot=") if prefix.is_empty(): return var lake: Vector2 = terrain.lake_center # [name, eye position (y = height above ground), look-at point] ; null = spawn view var views := [ ["spawn", null, null], ["aerial", Vector3(0, 110, 150), Vector3(0, 0, -20)], ["lake", Vector3(lake.x - 30.0, 3.0, lake.y + 22.0), Vector3(lake.x, -2.0, lake.y)], ["forest", Vector3(-40, 1.7, 30), Vector3(-70, 4.0, 10)], ["rim", Vector3(0, 2.0, 70), Vector3(0, 25.0, 125)], ] # Sea-cliff views: from the cliff top out to sea, and from the water back at the cliff. var sa: float = terrain.sea_angle_deg var top: Vector2 = terrain.coast_point(sa + 12.0, -6.0) var out: Vector2 = terrain.coast_point(sa + 12.0, 200.0) views.append(["seacliff_top", Vector3(top.x, 1.7, top.y), Vector3(out.x, terrain.sea_level - 6.0, out.y)]) var boat: Vector2 = terrain.coast_point(sa - 6.0, 38.0) var face: Vector2 = terrain.coast_point(sa + 4.0, 0.0) var boat_eye := Vector3(boat.x, terrain.sea_level + 3.0 - terrain.sample_height(boat.x, boat.y), boat.y) views.append(["seacliff_from_sea", boat_eye, Vector3(face.x, terrain.sea_level + 8.0, face.y)]) # Along the cliff edge into the western headland (where the old pit was). var walk: Vector2 = terrain.coast_point(sa - 20.0, -8.0) var head: Vector2 = terrain.coast_point(sa - 52.0, 0.0) views.append(["headland", Vector3(walk.x, 1.7, walk.y), Vector3(head.x, 2.0, head.y)]) await get_tree().create_timer(4.0).timeout set_physics_process(false) # views may sit outside the walkable area var cam: Camera3D = player.get_node("Camera3D") for v in views: if v[1] != null: player.set_physics_process(false) var eye: Vector3 = v[1] eye.y += terrain.sample_height(eye.x, eye.z) var dir: Vector3 = (v[2] as Vector3) - eye player.global_position = eye - Vector3(0.0, cam.position.y, 0.0) player.rotation = Vector3(0.0, atan2(-dir.x, -dir.z), 0.0) cam.rotation.x = atan2(dir.y, Vector2(dir.x, dir.z).length()) props._update_grass(100000) await get_tree().create_timer(1.0).timeout await RenderingServer.frame_post_draw var path := "%s_%s.png" % [prefix, v[0]] get_viewport().get_texture().get_image().save_png(path) print("[world] saved ", path) await _capture_survival(prefix, cam) get_tree().quit() ## Survival views: a small camp in front of the spawn (axe in hand), then the ## inventory screen with a chest open. func _capture_survival(prefix: String, cam: Camera3D) -> void: _respawn() var inv: Inventory = player.inventory for pair in [["stone_axe", 1], ["stone_pickaxe", 1], ["campfire", 1], ["club", 1], ["wood", 23], ["stick", 12], ["stone", 17], ["flint", 5]]: inv.add(pair[0], pair[1]) var fwd := -player.global_basis.z var right := player.global_basis.x var chest: Node = null for k in [["campfire", 4.5, 0.0], ["workbench", 7.0, -2.8], ["chest", 6.5, 2.4]]: var p: Vector3 = player.global_position + fwd * float(k[1]) + right * float(k[2]) p.y = terrain.sample_height(p.x, p.z) var piece := spawn_piece(ItemDB.get_item(k[0]), Transform3D(Basis(Vector3.UP, player.rotation.y), p)) if k[0] == "chest": chest = piece player.select(0) cam.rotation.x = -0.22 await get_tree().create_timer(2.5).timeout await RenderingServer.frame_post_draw get_viewport().get_texture().get_image().save_png("%s_camp.png" % prefix) print("[world] saved %s_camp.png" % prefix) # Same camp at dusk and at night, torch in hand. inv.set_slot(1, {"id": "torch", "count": 1}) player.select(1) for shot in [["dusk", 18.3], ["night", 22.5]]: day_night.time = float(shot[1]) / 24.0 day_night.apply(true) await get_tree().create_timer(1.5).timeout await RenderingServer.frame_post_draw get_viewport().get_texture().get_image().save_png("%s_camp_%s.png" % [prefix, shot[0]]) print("[world] saved %s_camp_%s.png" % [prefix, shot[0]]) day_night.time = 9.0 / 24.0 day_night.apply(true) chest.inventory.add("wood", 40) chest.inventory.add("stone", 12) player.global_position = chest.global_position + fwd * -2.0 hud._screen.open(chest) await get_tree().create_timer(0.6).timeout await RenderingServer.frame_post_draw get_viewport().get_texture().get_image().save_png("%s_inventory.png" % prefix) print("[world] saved %s_inventory.png" % prefix) hud._screen.close() hud._menu.open() await get_tree().create_timer(0.4).timeout await RenderingServer.frame_post_draw get_viewport().get_texture().get_image().save_png("%s_menu.png" % prefix) print("[world] saved %s_menu.png" % prefix) hud._menu.close()