1640 lines
65 KiB
GDScript
1640 lines
65 KiB
GDScript
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
|
|
## <prefix>_<view>.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"),
|
|
"build": preload("res://scripts/building/build_piece.gd"),
|
|
"door": preload("res://scripts/building/door.gd"),
|
|
"bed": preload("res://scripts/building/bed.gd"),
|
|
"kiln": preload("res://scripts/building/processor.gd"),
|
|
"smelter": preload("res://scripts/building/processor.gd"),
|
|
"smoker": preload("res://scripts/building/processor.gd"),
|
|
"forge": preload("res://scripts/building/forge.gd"),
|
|
"hearth": preload("res://scripts/building/hearth.gd"),
|
|
"rug": preload("res://scripts/building/rug.gd"),
|
|
"jar": preload("res://scripts/building/jar.gd"),
|
|
"lamp": preload("res://scripts/building/lamp.gd"),
|
|
"stonecutter": preload("res://scripts/building/stonecutter.gd"),
|
|
}
|
|
const BuildPiece = preload("res://scripts/building/build_piece.gd")
|
|
const UITheme = preload("res://scripts/ui/theme.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
|
|
## Set by WorldNet on a dedicated server: the shared island with nobody of its
|
|
## own in it (no player, HUD, animals or effects). It saves to the server's
|
|
## world slot. See world_net.gd.
|
|
var server_mode := false
|
|
var day_length := 1200.0 ## seconds per day (a server sets its own)
|
|
var autosave_seconds := AUTOSAVE_SECONDS
|
|
|
|
var _spawn := Vector3.ZERO
|
|
var _spawn_yaw := 0.0
|
|
var _pieces: Node3D
|
|
var hud: CanvasLayer
|
|
var pier: Node3D ## the Cove's pier (scripts/world/pier.gd), or null
|
|
var tunnel: Node3D ## the cliff tunnel (scripts/world/tunnel.gd, J1), or null
|
|
var sea_cave: Node3D ## the Cove's end of it (scripts/world/sea_cave.gd), or null
|
|
var traveling := false ## on the way to another worldlet (the scene is about to change)
|
|
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
|
|
var weather: Node ## weather.gd: the weather model
|
|
var weather_fx: Node3D ## weather_fx.gd: rain/snow particles, sound
|
|
var build: Node ## build_system.gd: snapping, support, shelter
|
|
var creatures: Node3D ## spawner.gd: wildlife around the player
|
|
var _dropped: Node3D ## arrows, spears and other dropped items
|
|
var den: Node3D ## den.gd: the wolves' cave at the foot of the rim
|
|
var bobber: Node3D ## bobber.gd: the cast fishing float, if any
|
|
var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site)
|
|
var bears: Node3D ## bear_range.gd: the island's bear (null online as a client)
|
|
var thieves: Node3D ## thieves.gd: crows and foxes after food left out
|
|
var hazards: Node3D ## hazards.gd: lightning on trees, hard-winter ice (P4)
|
|
var stones: Node3D ## standing_stones.gd: the altar and the Old Stag (P5)
|
|
var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run)
|
|
var _saved_bear: Dictionary = {}
|
|
var _saved_tutorial: Dictionary = {} ## the tutorial's progress (Guide), from the save ## its saved state, kept until bears exists (and re-saved if it never does)
|
|
var net_sync: Node ## net_sync.gd when playing on a server, else null
|
|
var has_bed_spawn := false
|
|
var bed_spawn := Vector3.ZERO ## position of the bed you respawn at
|
|
|
|
## Playing on a server (multiplayer) rather than a local world.
|
|
func is_online() -> bool:
|
|
return not menu_mode and Net.state == "connected"
|
|
var _loading := false ## applying a save: defer support recompute
|
|
var _applying := false ## applying a save or snapshot: not news for the server's players
|
|
|
|
## Server only: changes to the island, wherever they came from (a player's
|
|
## request, a collapse, a death), for world_net.gd to send to everyone.
|
|
signal piece_added(piece: Node3D)
|
|
signal pieces_removed(gone: Array, fell: Array)
|
|
signal dropped_added(item: Node3D)
|
|
signal saved
|
|
|
|
func _ready() -> void:
|
|
_autosave_left = autosave_seconds
|
|
_pieces = Node3D.new()
|
|
_pieces.name = "Pieces"
|
|
add_child(_pieces)
|
|
day_night = preload("res://scripts/world/day_night.gd").new()
|
|
day_night.name = "DayNight"
|
|
if is_online() and Net.server_info.has("day_length_minutes"):
|
|
day_length = float(Net.server_info.day_length_minutes) * 60.0 # same days, same weather
|
|
day_night.day_length = day_length
|
|
add_child(day_night)
|
|
day_night.setup(sun, $WorldEnvironment.environment)
|
|
weather = preload("res://scripts/world/weather.gd").new()
|
|
weather.name = "Weather"
|
|
add_child(weather)
|
|
weather.snowline = terrain.snow_height - 8.0
|
|
weather.frozen = menu_mode
|
|
weather.setup(terrain.noise_seed, day_night)
|
|
day_night.weather = weather
|
|
build = preload("res://scripts/building/build_system.gd").new()
|
|
build.name = "Build"
|
|
add_child(build)
|
|
build.setup(self)
|
|
_dropped = Node3D.new()
|
|
_dropped.name = "Dropped"
|
|
add_child(_dropped)
|
|
creatures = preload("res://scripts/creatures/spawner.gd").new()
|
|
creatures.name = "Creatures"
|
|
creatures.enabled = false # until the scenery is placed
|
|
add_child(creatures)
|
|
if server_mode:
|
|
# Nobody of our own: the player node stays, parked out of the way and
|
|
# switched off, because a few shared functions expect one.
|
|
player.process_mode = Node.PROCESS_MODE_DISABLED
|
|
player.collision_layer = 0
|
|
player.position = Vector3(0, -500, 0)
|
|
player.get_node("Camera3D").current = false
|
|
$HUD.visible = false
|
|
elif not menu_mode and DisplayServer.get_name() != "headless":
|
|
weather_fx = preload("res://scripts/world/weather_fx.gd").new()
|
|
weather_fx.name = "WeatherFX"
|
|
add_child(weather_fx)
|
|
weather_fx.setup(weather, player)
|
|
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" and not server_mode:
|
|
hud = preload("res://scripts/ui/hud.gd").new()
|
|
add_child(hud)
|
|
hud.bind(player)
|
|
if not menu_mode and not server_mode:
|
|
player.survival.died.connect(_on_player_died)
|
|
player.survival.hurt.connect(func(_amount, _from): Sfx.me("hurt"))
|
|
if not server_mode:
|
|
Music.start(self) # (the title screen's backdrop world too: calm music)
|
|
if Saves.has_world() and not menu_mode and not server_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,
|
|
## plus the weather. A proper shelter keeps out most of the weather and adds a
|
|
## little warmth of its own.
|
|
func air_warmth(pos: Vector3, sheltered: bool = false) -> float:
|
|
var w: float = day_night.air_warmth()
|
|
w -= maxf(pos.y - 12.0, 0.0) * 0.9
|
|
var ww: float = weather.warmth_at(pos)
|
|
if sheltered:
|
|
w += ww * 0.25 + 8.0
|
|
else:
|
|
w += ww - weather.blizzard_chill(false) # (P4: the wind cuts through you)
|
|
return w
|
|
|
|
## 0..1 heat from fires and a held torch.
|
|
## `holder` is whose torch counts (default: the local player; on a server,
|
|
## each player's character).
|
|
func heat_at(pos: Vector3, holder: Node = null) -> float:
|
|
var h := 0.0
|
|
for n in get_tree().get_nodes_in_group("heat"):
|
|
h = maxf(h, n.heat_at(pos))
|
|
var who: Node = holder if holder != null else player
|
|
if who.get("hands") != null:
|
|
h = maxf(h, who.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:
|
|
Hints.event("died")
|
|
Sfx.me("death")
|
|
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))
|
|
# What you were wearing goes in the grave too.
|
|
if not player.worn.is_empty() and player.inventory.add(player.worn, 1) == 0:
|
|
player.worn = ""
|
|
player.worn_changed.emit()
|
|
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())
|
|
# The death screen: you stay down for a few seconds, then Respawn.
|
|
# (Headless, with no screen, you wake straight away.)
|
|
dead_waiting = true
|
|
player.velocity = Vector3.ZERO
|
|
player.set_physics_process(false)
|
|
if hud != null:
|
|
hud.close_screens()
|
|
hud.show_death(player.survival.cause, _death_note(has_items, spawn_bed() != null), RESPAWN_DELAY, respawn_after_death)
|
|
else:
|
|
respawn_after_death()
|
|
|
|
const RESPAWN_DELAY := 3.0 ## seconds on the death screen before you can respawn
|
|
var dead_waiting := false ## dead, on the death screen
|
|
|
|
static func _death_note(has_items: bool, to_bed: bool, penalty: String = "grave") -> String:
|
|
var things := "You had nothing on you."
|
|
if penalty == "keep":
|
|
things = "You keep everything you were carrying."
|
|
elif has_items:
|
|
things = "Everything you carried is scattered where you fell: anyone can pick it up." if penalty == "drop_all" \
|
|
else "Your belongings are in a grave where you fell."
|
|
return "%s\nYou'll wake %s." % [things, "at your bed" if to_bed else "at the island start"]
|
|
|
|
## Respawn (the death screen's button): at your bed, or the island start.
|
|
func respawn_after_death() -> void:
|
|
if not dead_waiting:
|
|
return
|
|
dead_waiting = false
|
|
player.set_physics_process(true)
|
|
var bed := spawn_bed()
|
|
if bed != null:
|
|
_respawn_at_bed(bed)
|
|
else:
|
|
_respawn()
|
|
if has_bed_spawn:
|
|
has_bed_spawn = false
|
|
Actions.notify.emit("Your bed is gone, so you woke at the island start")
|
|
player.survival.reset_after_death()
|
|
|
|
## Online: the server says we died. Same screen; Respawn asks the server.
|
|
func died_online(text: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void:
|
|
Hints.event("died")
|
|
Sfx.me("death")
|
|
dead_waiting = true
|
|
player.velocity = Vector3.ZERO
|
|
player.set_physics_process(false)
|
|
if hud != null:
|
|
hud.close_screens()
|
|
hud.show_death(text, _death_note(has_items, to_bed, penalty), RESPAWN_DELAY, func(): PlayerNet.ask("respawn"))
|
|
else:
|
|
get_tree().create_timer(RESPAWN_DELAY + 0.1).timeout.connect(func(): PlayerNet.ask("respawn"))
|
|
|
|
# --- beds and sleeping ------------------------------------------------------
|
|
|
|
## The bed you respawn at, if it still exists.
|
|
func spawn_bed() -> Node3D:
|
|
if not has_bed_spawn:
|
|
return null
|
|
for p in _pieces.get_children():
|
|
if p.is_in_group("bed") and not p.is_queued_for_deletion() and p.global_position.distance_to(bed_spawn) < 0.3:
|
|
return p
|
|
return null
|
|
|
|
func set_bed_spawn(bed: Node3D) -> void:
|
|
has_bed_spawn = true
|
|
bed_spawn = bed.global_position
|
|
|
|
func _respawn_at_bed(bed: Node3D) -> void:
|
|
player.global_position = bed.global_position + Vector3(0, 0.9, 0)
|
|
player.velocity = Vector3.ZERO
|
|
player.rotation = Vector3(0.0, bed.global_basis.get_euler().y + PI, 0.0)
|
|
player.get_node("Camera3D").rotation.x = 0.0
|
|
|
|
## Online: the server says we died (or were moved) and puts us here.
|
|
func respawn_from_server(at: Vector3, yaw: float) -> void:
|
|
dead_waiting = false
|
|
player.set_physics_process(true)
|
|
if hud != null:
|
|
hud.close_screens()
|
|
hud.hide_death()
|
|
player.global_position = at
|
|
player.velocity = Vector3.ZERO
|
|
player.rotation = Vector3(0.0, yaw, 0.0)
|
|
player.get_node("Camera3D").rotation.x = 0.0
|
|
|
|
## Why you can't sleep in `bed` right now ("" = you can).
|
|
func sleep_block_reason(bed: Node3D) -> String:
|
|
var h: float = day_night.hours()
|
|
if h > 5.5 and h < 19.5:
|
|
return "You can only sleep at night (after 19:30)"
|
|
if bed.has_method("open_air") and bed.open_air():
|
|
# A bedroll: anywhere, as long as it isn't raining or snowing on you.
|
|
if not build.sheltered_at(bed.global_position) and weather.precip > 0.3:
|
|
return "Not out here in this weather: find shelter, or wait for it to clear"
|
|
elif not build.sheltered_at(bed.global_position):
|
|
return "Your bed needs a roof and walls around it"
|
|
if wolves != null:
|
|
for w in wolves.alive():
|
|
if w.state in ["stalk", "lunge", "defend", "retreat"] and w.global_position.distance_to(bed.global_position) < 40.0:
|
|
return "You can't sleep with wolves prowling outside"
|
|
return ""
|
|
|
|
## Sleeps until 06:00: the clock, fires, regrowth and weather all move on.
|
|
## On a server you get into bed, and the night passes once enough players
|
|
## are in bed (the server's sleep rule, player_net.gd).
|
|
func sleep(p: Node3D) -> void:
|
|
if server_mode:
|
|
PlayerNet.go_to_bed(p)
|
|
return
|
|
var secs: float = fposmod(6.0 - day_night.hours(), 24.0) / 24.0 * day_night.day_length
|
|
if hud != null:
|
|
hud.fade(func(): _finish_sleep(p, secs))
|
|
else:
|
|
_finish_sleep(p, secs)
|
|
|
|
func _finish_sleep(p: Node3D, secs: float) -> void:
|
|
skip_time(secs)
|
|
p.survival.after_sleep(secs)
|
|
save_now()
|
|
Actions.notify.emit("You slept until morning")
|
|
|
|
## Fast-forwards the world (not the player) by `secs`.
|
|
func skip_time(secs: float) -> void:
|
|
day_night.advance(secs)
|
|
day_night.apply(true)
|
|
weather.snap()
|
|
props.skip_time(secs)
|
|
for m in get_tree().get_nodes_in_group("processor"):
|
|
m.step(secs) # (kilns and smelters work through the night)
|
|
for f in get_tree().get_nodes_in_group("heat"):
|
|
if f.has_method("burn"):
|
|
f.burn(secs)
|
|
spoil_all() # (food goes off overnight too)
|
|
|
|
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
|
|
# The pier (W3): off a beach, laid before the scenery so it keeps clear.
|
|
var pr := preload("res://scripts/world/pier.gd").new()
|
|
pr.name = "Pier"
|
|
add_child(pr)
|
|
if pr.place(terrain):
|
|
pier = pr
|
|
props.pier = pr
|
|
else:
|
|
pr.free()
|
|
# The journey's ways on (J1): the Valley's cliff tunnel and the Cove's sea
|
|
# cave. Single-player for now (J3 brings travel online).
|
|
if not server_mode and not menu_mode and not is_online():
|
|
var tn := preload("res://scripts/world/tunnel.gd").new()
|
|
tn.name = "Tunnel"
|
|
add_child(tn)
|
|
if tn.setup(self):
|
|
tunnel = tn
|
|
props.keep_clear.append([tn.seam_at, 4.0])
|
|
else:
|
|
tn.free()
|
|
var sc := preload("res://scripts/world/sea_cave.gd").new()
|
|
sc.name = "SeaCave"
|
|
add_child(sc)
|
|
if sc.place(self):
|
|
sea_cave = sc
|
|
props.keep_clear.append([sc.global_position, 6.0])
|
|
else:
|
|
sc.free()
|
|
props.place_on(terrain, player if not menu_mode else _menu_cam, _spawn)
|
|
# The wolf den: part of the scenery, so it's there from day 1 (empty).
|
|
den = preload("res://scripts/creatures/den.gd").new()
|
|
den.name = "WolfDen"
|
|
add_child(den)
|
|
if den.place(self, props._rock_mat):
|
|
props.static_cuts.append([den.global_transform, 2.2, 3.4])
|
|
_update_grass_cuts()
|
|
# The barrow: scenery too (P3), out on open ground. Its guards only run
|
|
# where creatures do (_setup_bear).
|
|
barrow = preload("res://scripts/world/barrow.gd").new()
|
|
barrow.name = "Barrow"
|
|
add_child(barrow)
|
|
if barrow.place(self):
|
|
_update_grass_cuts()
|
|
# The standing stones (P5): where the Old Stag is called.
|
|
stones = preload("res://scripts/world/standing_stones.gd").new()
|
|
stones.name = "StandingStones"
|
|
add_child(stones)
|
|
if stones.place(self):
|
|
_update_grass_cuts()
|
|
if DisplayServer.get_name() != "headless":
|
|
var fish := preload("res://scripts/fishing/lake_fish.gd").new()
|
|
fish.name = "LakeFish"
|
|
add_child(fish)
|
|
fish.setup(terrain)
|
|
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
|
|
if menu_mode:
|
|
return
|
|
# Weather hazards (P4): lightning on trees, the hard winter's ice. Decided
|
|
# here unless we're a player's game online (then the server tells us).
|
|
hazards = preload("res://scripts/world/hazards.gd").new()
|
|
hazards.name = "Hazards"
|
|
add_child(hazards)
|
|
hazards.setup(self, not is_online())
|
|
stones.deciding = not is_online()
|
|
if server_mode:
|
|
if Saves.has_world():
|
|
var saved: Dictionary = Saves.load_state(Saves.current_id)
|
|
if not saved.is_empty():
|
|
apply_save(saved)
|
|
# The server runs the wildlife and the wolves for everyone (M2.5):
|
|
# animals live around the players, and players see them via
|
|
# creature_net.gd.
|
|
set_physics_process(false) # (no player of our own to keep on the map)
|
|
creatures.setup(self)
|
|
creatures.enabled = not "--no-creatures" in OS.get_cmdline_user_args()
|
|
if den.found:
|
|
wolves = preload("res://scripts/creatures/wolf_pack.gd").new()
|
|
wolves.name = "Wolves"
|
|
add_child(wolves)
|
|
wolves.setup(self, den)
|
|
_setup_bear()
|
|
apply_rules()
|
|
return
|
|
_apply_world_rot()
|
|
_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)
|
|
_arrive(Saves.take_arrival(Saves.current_id))
|
|
player.set_physics_process(true)
|
|
set_physics_process(true)
|
|
creatures.setup(self)
|
|
# Old Tam's tips (T4): your own world or a server's, not the tutorial's.
|
|
if not server_mode and not menu_mode and not bool(Saves.read_meta(Saves.current_id).get("tutorial", false)) \
|
|
and not "--tutorial" in OS.get_cmdline_user_args():
|
|
Hints.start(self)
|
|
if is_online():
|
|
_go_online()
|
|
_maybe_capture()
|
|
return
|
|
if not "--no-creatures" in OS.get_cmdline_user_args():
|
|
creatures.populate()
|
|
creatures.enabled = true
|
|
if den.found:
|
|
wolves = preload("res://scripts/creatures/wolf_pack.gd").new()
|
|
wolves.name = "Wolves"
|
|
add_child(wolves)
|
|
wolves.setup(self, den)
|
|
# The tutorial (docs/TUTORIAL_DESIGN.md): a world made for it (meta.json
|
|
# "tutorial"), or any world with `-- --tutorial`. Its island is calm: no
|
|
# wolf pack (just Old Tam's demo wolf), no bear, nothing stealing your food.
|
|
var tutorial := bool(Saves.read_meta(Saves.current_id).get("tutorial", false)) or "--tutorial" in OS.get_cmdline_user_args()
|
|
if tutorial and wolves != null:
|
|
wolves.enabled = false
|
|
if not "--no-creatures" in OS.get_cmdline_user_args() and not tutorial:
|
|
_setup_bear()
|
|
if tutorial:
|
|
Guide.start(self, _saved_tutorial)
|
|
_maybe_capture()
|
|
|
|
## The island's bear and its patch (bear_range.gd), and the crows and foxes
|
|
## after food left out (thieves.gd). The server runs them for everyone;
|
|
## players' games see them through creature_net.gd.
|
|
func _setup_bear() -> void:
|
|
thieves = preload("res://scripts/creatures/thieves.gd").new()
|
|
thieves.name = "Thieves"
|
|
add_child(thieves)
|
|
thieves.setup(self)
|
|
thieves.enabled = not "--no-creatures" in OS.get_cmdline_user_args()
|
|
if barrow != null and barrow.found and thieves.enabled:
|
|
barrow.start()
|
|
bears = preload("res://scripts/creatures/bear_range.gd").new()
|
|
bears.name = "Bear"
|
|
add_child(bears)
|
|
bears.setup(self)
|
|
bears.load_data(_saved_bear)
|
|
bears.enabled = not "--no-creatures" in OS.get_cmdline_user_args()
|
|
if not bears.enabled and bears.bear != null:
|
|
bears.bear.queue_free() # (a server started without wildlife)
|
|
|
|
## A server's rules for the island (at start-up, and after an admin's /set).
|
|
func apply_rules() -> void:
|
|
var R = preload("res://scripts/net/rules.gd")
|
|
if wolves != null:
|
|
wolves.den_day = R.grace_days()
|
|
wolves.hunt_day = R.grace_days() + 1
|
|
wolves.pack_bonus = int(R.difficulty("pack"))
|
|
for p in PlayerNet.proxies.values():
|
|
p.survival.drain_mult = R.difficulty("drain")
|
|
Inventory.rot_speed = ROT_SPEEDS.get(R.rot(), 1.0)
|
|
|
|
# --- food spoilage (P4, docs/PROGRESSION_DESIGN.md §5) ----------------------------
|
|
|
|
const ROT_SPEEDS := {"normal": 1.0, "slow": 0.5, "off": 0.0}
|
|
const SPOIL_EVERY := 2.0
|
|
var _spoil_left := 0.0
|
|
|
|
## Keeps Inventory's clock (food's "rot" stamps) on world time. Players'
|
|
## games online run it too (for showing freshness; the server does the rotting).
|
|
func _tick_clock() -> void:
|
|
Inventory.day_seconds = float(day_night.day_length)
|
|
Inventory.clock = weather.world_seconds()
|
|
|
|
## The single-player world's rot setting, from its meta.json (a server's
|
|
## comes from its rules: apply_rules()).
|
|
func _apply_world_rot() -> void:
|
|
var r := String(Saves.read_meta(Saves.current_id).get("rot", "normal")) if Saves.has_world() else "normal"
|
|
Inventory.rot_speed = ROT_SPEEDS.get(r, 1.0)
|
|
|
|
## Food rots: in everyone's bags, in chests, graves and racks, and lying on
|
|
## the ground (where rotten food just goes). Where the game decides (single
|
|
## player, a server); players' games online are sent the result.
|
|
func spoil_all() -> void:
|
|
if net_sync != null or menu_mode:
|
|
return
|
|
_tick_clock()
|
|
for p in players():
|
|
if p.get("inventory") != null:
|
|
p.inventory.spoil()
|
|
for piece in all_pieces():
|
|
if piece.get("inventory") != null:
|
|
piece.inventory.spoil()
|
|
if Inventory.rot_speed <= 0.0:
|
|
return
|
|
for d in all_dropped():
|
|
if Inventory.keeps_days(d.item_id) <= 0.0:
|
|
continue
|
|
if not d.extra.has("rot"):
|
|
d.extra.rot = Inventory.fresh_rot(d.item_id)
|
|
elif Inventory.clock >= float(d.extra.rot):
|
|
take_dropped(d)
|
|
|
|
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
|
|
_tick_clock()
|
|
_spoil_left -= delta
|
|
if _spoil_left <= 0.0:
|
|
_spoil_left = SPOIL_EVERY
|
|
spoil_all()
|
|
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
|
|
if dead_waiting and not server_mode:
|
|
respawn_after_death() # never save someone lying dead
|
|
if hud != null:
|
|
hud.hide_death()
|
|
var ok: bool = Saves.save_state(Saves.current_id, collect_save(), _session_time)
|
|
if ok:
|
|
_session_time = 0.0
|
|
if server_mode:
|
|
if not ok:
|
|
printerr("[server] saving the world FAILED")
|
|
saved.emit() # (the players' characters save with it)
|
|
else:
|
|
Actions.notify.emit("Game saved" if ok else "Save FAILED")
|
|
return ok
|
|
|
|
## A server's world saves as it closes, however the server stops (Ctrl+C
|
|
## included), unless it has just saved.
|
|
func _exit_tree() -> void:
|
|
if server_mode and Saves.has_world() and _session_time > 0.0:
|
|
save_now()
|
|
if Guide.world == self:
|
|
Guide.stop()
|
|
if Hints.world == self:
|
|
Hints.stop()
|
|
Music.stop(self)
|
|
|
|
func _notification(what: int) -> void:
|
|
if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:
|
|
return
|
|
if net_sync != null:
|
|
Net.left.disconnect(_on_net_left)
|
|
Net.leave()
|
|
get_tree().quit()
|
|
elif Saves.has_world():
|
|
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 dropped: Array = []
|
|
for d in all_dropped():
|
|
dropped.append(dropped_entry(d))
|
|
var pieces: Array = []
|
|
for piece in all_pieces():
|
|
pieces.append(piece_entry(piece))
|
|
if server_mode:
|
|
return _shared_state(harvested, regrow, taken, dropped, pieces)
|
|
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(),
|
|
"worn": player.worn,
|
|
},
|
|
"dropped": dropped,
|
|
"time": {"day": day_night.day, "time": day_night.time},
|
|
"tutorial": Guide.save_data() if Guide.world == self else _saved_tutorial,
|
|
"spawn": {"bed": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]},
|
|
"forage_regrow": regrow,
|
|
"regrow": props.regrow.duplicate(),
|
|
"harvested": harvested,
|
|
"pickups_taken": taken,
|
|
"pieces": pieces,
|
|
"bear": bears.save_data() if bears != null else _saved_bear,
|
|
"barrow": barrow.save_data() if barrow != null else {},
|
|
"stones": stones.save_data() if stones != null else {},
|
|
"tunnel": tunnel.to_data() if tunnel != null else {},
|
|
}
|
|
|
|
func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, dropped: Array, pieces: Array) -> Dictionary:
|
|
return {
|
|
"dropped": dropped,
|
|
"time": {"day": day_night.day, "time": day_night.time},
|
|
"forage_regrow": regrow,
|
|
"regrow": props.regrow.duplicate(),
|
|
"harvested": harvested,
|
|
"pickups_taken": taken,
|
|
"pieces": pieces,
|
|
"bear": bears.save_data() if bears != null else _saved_bear,
|
|
"barrow": barrow.save_data() if barrow != null else {},
|
|
"stones": stones.save_data() if stones != null else {},
|
|
}
|
|
|
|
## The live pieces (placed and not being removed).
|
|
func all_pieces() -> Array:
|
|
return _pieces.get_children().filter(func(p): return not p.is_queued_for_deletion())
|
|
|
|
func all_dropped() -> Array:
|
|
return _dropped.get_children().filter(func(d): return not d.is_queued_for_deletion() and not d.taken)
|
|
|
|
## One piece as saved (and as sent to players): its item, where it is, what's
|
|
## in it, its state (fuel, door open), and its net id if it has one.
|
|
func piece_entry(piece: Node3D) -> Dictionary:
|
|
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()
|
|
if piece.has_meta("nid"):
|
|
entry["nid"] = int(piece.get_meta("nid"))
|
|
if piece.has_meta("owner"):
|
|
entry["owner"] = String(piece.get_meta("owner"))
|
|
entry["owner_name"] = String(piece.get_meta("owner_name", ""))
|
|
return entry
|
|
|
|
func dropped_entry(d: Node3D) -> Dictionary:
|
|
var e := {"item": d.item_id, "count": d.amount, "xf": _xf_to_array(d.global_transform)}
|
|
if not d.extra.is_empty():
|
|
e["extra"] = d.extra
|
|
if d.has_meta("nid"):
|
|
e["nid"] = int(d.get_meta("nid"))
|
|
return e
|
|
|
|
## The shared state a server sends a player who joins (everything but the
|
|
## server's own bookkeeping).
|
|
func collect_shared() -> Dictionary:
|
|
return collect_save()
|
|
|
|
## A joining player's copy of the server's island.
|
|
func apply_shared(s: Dictionary) -> void:
|
|
apply_save(s, false)
|
|
props.regrow.clear() # the server says when things grow back
|
|
|
|
func add_piece_entry(e: Dictionary) -> Node3D:
|
|
var def: Dictionary = ItemDB.get_item(e.get("item", ""))
|
|
if def.is_empty() or not def.has("place"):
|
|
return null
|
|
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)
|
|
if e.has("nid"):
|
|
piece.set_meta("nid", int(e.nid))
|
|
if e.has("owner"):
|
|
piece.set_meta("owner", String(e.owner))
|
|
piece.set_meta("owner_name", String(e.get("owner_name", "")))
|
|
return piece
|
|
|
|
func add_dropped_entry(e: Dictionary) -> Node3D:
|
|
if ItemDB.get_item(e.get("item", "")).is_empty():
|
|
return null
|
|
var ex: Dictionary = {}
|
|
if e.get("extra") is Dictionary:
|
|
ex = Inventory.extra_of(e.extra) # (a worn tool's "dur", food's "rot")
|
|
var d := _make_dropped(e.item, int(e.get("count", 1)), _array_to_xf(e.xf), ex)
|
|
if e.has("nid"):
|
|
d.set_meta("nid", int(e.nid))
|
|
return d
|
|
|
|
## Pieces the server took out (dismantled, collapsed, a grave emptied). No
|
|
## refunds here: the server sends those to whoever did it.
|
|
func remove_pieces_net(gone: Array, collapsed_nids: Array) -> void:
|
|
var any_build := false
|
|
for p in gone:
|
|
if p.is_in_group("bed") and spawn_bed() == p:
|
|
has_bed_spawn = false
|
|
if p.is_in_group("build"):
|
|
any_build = true
|
|
if int(p.get_meta("nid", 0)) in collapsed_nids:
|
|
_collapse_fx(p)
|
|
_pieces.remove_child(p)
|
|
p.queue_free()
|
|
if any_build:
|
|
build.recompute()
|
|
_settle_loose_pieces()
|
|
_update_grass_cuts()
|
|
|
|
func apply_save(s: Dictionary, with_player: bool = true) -> void:
|
|
_applying = true
|
|
_apply_save(s, with_player)
|
|
_applying = false
|
|
|
|
func _apply_save(s: Dictionary, with_player: bool) -> void:
|
|
var p: Dictionary = s.get("player", {}) if with_player else {}
|
|
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 with_player:
|
|
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)
|
|
player.worn = String(p.get("worn", ""))
|
|
player.worn_changed.emit()
|
|
for d in s.get("dropped", []):
|
|
add_dropped_entry(d)
|
|
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()
|
|
# Regrowth timers (older saves have none: everything gone starts a fresh timer).
|
|
props.restore_regrow(s.get("regrow", {}))
|
|
_saved_tutorial = s.get("tutorial", {}) if with_player else {}
|
|
if barrow != null:
|
|
barrow.load_data(s.get("barrow", {}))
|
|
if stones != null:
|
|
stones.load_data(s.get("stones", {}))
|
|
if tunnel != null:
|
|
tunnel.load_data(s.get("tunnel", {}))
|
|
# The bear (bear_range.gd): applied when it's set up, after loading.
|
|
_saved_bear = s.get("bear", {})
|
|
if bears != null:
|
|
bears.load_data(_saved_bear)
|
|
var sp: Dictionary = s.get("spawn", {})
|
|
has_bed_spawn = bool(sp.get("bed", false))
|
|
if sp.has("pos"):
|
|
bed_spawn = Vector3(sp.pos[0], sp.pos[1], sp.pos[2])
|
|
weather.snap()
|
|
_loading = true
|
|
for e in s.get("pieces", []):
|
|
add_piece_entry(e)
|
|
_loading = false
|
|
build.recompute() # same terrain, same pieces: nothing collapses on load
|
|
_update_grass_cuts()
|
|
|
|
func _respawn() -> void:
|
|
if net_sync != null:
|
|
net_sync.mark_teleport() # tell the server this jump back to the spawn is legitimate
|
|
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 under := underground_at(p)
|
|
day_night.underground = move_toward(day_night.underground, 1.0 if under else 0.0, delta * 3.0)
|
|
if weather_fx != null:
|
|
weather_fx.visible = not under
|
|
# (No water down there: the tunnel is far below the lake's surface.)
|
|
player.water_level = -INF if under else terrain.water_level
|
|
if under:
|
|
return # (the tunnel is its own space, out of sight under the island)
|
|
_current(delta)
|
|
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()
|
|
|
|
## In the cliff tunnel (J1): no daylight, no weather, sheltered.
|
|
func underground_at(p: Vector3) -> bool:
|
|
return tunnel != null and tunnel.contains(p)
|
|
|
|
# --- the journey (J1, docs/WORLDLETS_DESIGN.md §10) ------------------------------
|
|
|
|
## Goes on to worldlet `to`: saves this one, takes your character (bag,
|
|
## stats, what you wear) and the clock along, and loads the other, arriving
|
|
## at `arrive` ("sea_cave", "tunnel_bottom"). Each worldlet keeps its own
|
|
## state file (Saves.state_path), so what you built here waits for you.
|
|
func travel(to: String, arrive: String) -> void:
|
|
if traveling or not Saves.has_world() or is_online() or server_mode:
|
|
return
|
|
traveling = true
|
|
var id := Saves.current_id
|
|
var here: Dictionary = collect_save()
|
|
Saves.save_state(id, here, _session_time)
|
|
_session_time = 0.0
|
|
var there: Dictionary = Saves.load_state(id, to)
|
|
if there.is_empty():
|
|
there = {"time": here.time} # (a new place: the same day and hour)
|
|
there["player"] = here.player
|
|
Saves.save_state(id, there, 0.0, to)
|
|
Saves.set_worldlet(id, to, arrive)
|
|
print("[world] travelling to %s (%s)" % [to, arrive])
|
|
if hud != null:
|
|
hud.toast("On to the %s..." % Terrain.worldlet_name(to))
|
|
travelled.emit(to)
|
|
if get_tree().current_scene == self: # (in a test the world isn't the scene: the test loads the next one)
|
|
get_tree().change_scene_to_file.call_deferred(scene_file_path if not scene_file_path.is_empty() else "res://scenes/world.tscn")
|
|
|
|
signal travelled(to: String) ## saved and on the way to another worldlet
|
|
|
|
## Just arrived from another worldlet: put you where you came in.
|
|
func _arrive(at: String) -> void:
|
|
if at.is_empty():
|
|
return
|
|
var pos := Vector3.INF
|
|
var facing := Vector3.FORWARD
|
|
match at:
|
|
"sea_cave":
|
|
if sea_cave != null:
|
|
pos = sea_cave.front
|
|
facing = (sea_cave.front - sea_cave.global_position).normalized()
|
|
"tunnel_bottom":
|
|
if tunnel != null and tunnel.dug >= tunnel.STRETCHES:
|
|
pos = tunnel.bottom + Vector3(0.8, 0.0, 0.0)
|
|
facing = Vector3(1, 0, 0)
|
|
if pos == Vector3.INF:
|
|
push_warning("[world] nowhere to arrive at '%s': the start instead" % at)
|
|
_respawn()
|
|
return
|
|
player.global_position = pos + Vector3(0, 0.2, 0)
|
|
player.velocity = Vector3.ZERO
|
|
player.rotation = Vector3(0.0, atan2(-facing.x, -facing.z), 0.0)
|
|
if hud != null:
|
|
hud.toast("The %s" % Terrain.worldlet_name(worldlet_id()))
|
|
|
|
const Terrain = preload("res://scripts/world/terrain.gd")
|
|
|
|
## Off a beach (W2), a current carries anyone swimming too far out back
|
|
## towards the shore: the sea's version of the invisible wall. It's faster
|
|
## than you can swim, so you can't push through it, and it says so once.
|
|
const CURRENT_SPEED := 4.5
|
|
var _in_current := false
|
|
|
|
func _current(delta: float) -> void:
|
|
var p := player.global_position
|
|
var c: float = terrain.current_at(p.x, p.z) if p.y < terrain.sea_level else 0.0
|
|
if c <= 0.0:
|
|
_in_current = false
|
|
return
|
|
var back := -Vector3(p.x, 0.0, p.z).normalized()
|
|
player.global_position += back * CURRENT_SPEED * c * delta
|
|
if not _in_current and c > 0.2:
|
|
_in_current = true
|
|
if hud != null:
|
|
hud.toast("A strong current pulls you back towards the shore")
|
|
|
|
# --- building ---------------------------------------------------------------
|
|
|
|
## Whether a piece can go at `xf`: inside the valley, on dry fairly flat
|
|
## ground (or a floor), and not overlapping trees, rocks or other pieces.
|
|
## Building pieces have their own rules (build_system.gd).
|
|
func placement_valid(def: Dictionary, xf: Transform3D) -> bool:
|
|
if BuildPiece.is_build(def):
|
|
return build.check(def, xf).is_empty()
|
|
var p := xf.origin
|
|
var under := underground_at(p) # (candles and torch stands down the tunnel: its floor is the ground)
|
|
if not under and not terrain.inside_boundary(p.x, p.z, 1.5):
|
|
return false
|
|
if not under and 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
|
|
# Something solid (ground or a floor) under each corner, close to the base.
|
|
var space := get_world_3d().direct_space_state
|
|
for c in [Vector3(-hx, 0, -hz), Vector3(hx, 0, -hz), Vector3(-hx, 0, hz), Vector3(hx, 0, hz)]:
|
|
var w: Vector3 = xf * c
|
|
var rq := PhysicsRayQueryParameters3D.create(w + Vector3(0, 0.45, 0), w - Vector3(0, 0.45, 0), 1, [player.get_rid()])
|
|
if space.intersect_ray(rq).is_empty():
|
|
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()
|
|
|
|
## `extra` is the piece's own state (a grave's owner, a door left open...).
|
|
func spawn_piece(def: Dictionary, xf: Transform3D, extra: Dictionary = {}) -> 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
|
|
if not extra.is_empty() and piece.has_method("load_extra"):
|
|
piece.load_extra(extra)
|
|
if piece.is_in_group("build") and not _loading:
|
|
build.recompute()
|
|
if piece.kind == "floor":
|
|
_update_grass_cuts()
|
|
# Sticks, stones and plants it's built over are gone (cleared as if picked,
|
|
# so a server tells everyone and the save keeps it; regrowth won't put them
|
|
# back under a building). Also on load, for huts built before this.
|
|
if piece.is_in_group("build") and not WorldNet.is_client():
|
|
props.clear_under.call_deferred(piece)
|
|
if server_mode and not _applying:
|
|
piece_added.emit(piece)
|
|
return piece
|
|
|
|
## Grass doesn't grow up through floors.
|
|
func _update_grass_cuts() -> void:
|
|
var xfs: Array = []
|
|
for p in _pieces.get_children():
|
|
if p.is_in_group("build") and p.kind == "floor" and not p.is_queued_for_deletion():
|
|
xfs.append(p.global_transform)
|
|
props.set_grass_cuts(xfs, 1.02)
|
|
|
|
## What water is at (x, z): {"type": "lake"|"sea", "surface": y, "depth": m},
|
|
## or {} on dry land. The one place that knows this island's water bodies, so
|
|
## fishing (and later swimming, boats) work on any world that answers it.
|
|
func water_at(x: float, z: float) -> Dictionary:
|
|
var ground: float = terrain.sample_height(x, z)
|
|
var beach: bool = terrain.beach_weight(x, z) > 0.5
|
|
if terrain.inside_boundary(x, z, -2.0) and ground < terrain.water_level:
|
|
if beach and not _in_lake(x, z):
|
|
return {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground}
|
|
if hazards != null and hazards.iced(x, z):
|
|
return {} # (frozen over in a hard winter: cast further out)
|
|
return {"type": "lake", "surface": terrain.water_level, "depth": terrain.water_level - ground}
|
|
if (beach or terrain.sea_weight(x, z) > 0.5) and ground < terrain.sea_level:
|
|
return {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground}
|
|
return {}
|
|
|
|
# --- the worldlet (docs/WORLDLETS_DESIGN.md: the WorldInfo API) --------------------
|
|
# What a system outside the terrain should ask about this place, rather than
|
|
# reading the terrain's numbers: which worldlet it is, its water, its climate,
|
|
# and its ways out. (W0 adds these; systems move over to them as each worldlet
|
|
# needs them to be different.)
|
|
|
|
## This worldlet's id ("valley") and its whole definition.
|
|
func worldlet_id() -> String:
|
|
return terrain.worldlet_id
|
|
|
|
func worldlet() -> Dictionary:
|
|
return terrain.worldlet
|
|
|
|
## Its bodies of water: [{"type", "surface", "center" (x, z; the sea's is a
|
|
## point on its coast), "radius" (a lake's; the sea's is 0)}].
|
|
func water_bodies() -> Array:
|
|
var out: Array = []
|
|
for b in terrain._blocks:
|
|
match String(b.type):
|
|
"lake":
|
|
out.append({"type": "lake", "surface": terrain.water_level,
|
|
"center": b._c, "radius": b._r})
|
|
"sea_cliff":
|
|
out.append({"type": "sea", "surface": terrain.sea_level,
|
|
"center": terrain.coast_point(terrain.sea_angle_deg, 20.0), "radius": 0.0})
|
|
"beach":
|
|
out.append({"type": "sea", "surface": terrain.sea_level,
|
|
"center": terrain.shore_point(12.0), "radius": 0.0})
|
|
return out
|
|
|
|
## Whether (x, z) is in one of the lakes or ponds (not the sea off a beach).
|
|
func _in_lake(x: float, z: float) -> bool:
|
|
for l in terrain.lakes:
|
|
if Vector2(x - l.center.x, z - l.center.y).length() < float(l.radius) * 1.3:
|
|
return true
|
|
return false
|
|
|
|
## Its climate: {"snowline": m, ...} (more as worldlets differ).
|
|
func climate() -> Dictionary:
|
|
var c: Dictionary = terrain.worldlet.get("climate", {}).duplicate()
|
|
c["snowline"] = terrain.snow_height
|
|
return c
|
|
|
|
## Its ways on to other worldlets (the journey): [{"to", "by", "where"}].
|
|
## Planned in the definition; travel comes with J1.
|
|
func crossings() -> Array:
|
|
return terrain.worldlet.get("next", [])
|
|
|
|
# --- multiplayer -----------------------------------------------------------------
|
|
|
|
## Joined a server: start where it says, follow its clock, show the others.
|
|
## Animals stay off until the server runs them (M2.5): otherwise everyone
|
|
## would see different wildlife.
|
|
func _go_online() -> void:
|
|
_spawn = Net.world_info.get("island_spawn", _spawn)
|
|
var start: Vector3 = Net.world_info.get("spawn", _spawn)
|
|
player.global_position = start + Vector3(0.0, 0.05, 0.0)
|
|
if Net.world_info.has("yaw"):
|
|
player.rotation.y = float(Net.world_info.yaw) # facing the way you were when you left
|
|
net_sync = preload("res://scripts/net/net_sync.gd").new()
|
|
net_sync.name = "NetSync"
|
|
add_child(net_sync)
|
|
net_sync.setup(self)
|
|
Net.left.connect(_on_net_left)
|
|
get_tree().auto_accept_quit = false # leave the server cleanly on window close
|
|
# The island's changes come from the server from now on.
|
|
props.regrow_enabled = false
|
|
WorldNet.join_world(self)
|
|
PlayerNet.join_world(self) # and our inventory and stats
|
|
CreatureNet.join_world(self) # the server's animals, wolves, arrows and floats
|
|
if hud != null:
|
|
var bar := UITheme.label("Online: %s · T to chat (/help)" % Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM)
|
|
bar.position = Vector2(16, 40)
|
|
$HUD.add_child(bar)
|
|
Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server"))
|
|
if Net.hosting:
|
|
var at := Net.my_addresses()
|
|
Actions.notify.emit("You're hosting. Friends join at %s" % (", ".join(PackedStringArray(at)) if not at.is_empty() else "this computer's address"))
|
|
|
|
func _on_net_left(_reason: String) -> void:
|
|
WorldNet.leave_world()
|
|
PlayerNet.leave_world()
|
|
CreatureNet.leave_world()
|
|
if is_inside_tree():
|
|
get_tree().paused = false
|
|
get_tree().auto_accept_quit = true
|
|
get_tree().change_scene_to_file.call_deferred("res://scenes/title.tscn")
|
|
|
|
## Leaves `count` of an item lying in the world at `xf` (an arrow stuck in a
|
|
## tree, a thrown spear, meat that didn't fit). Saved with the world.
|
|
## Online only the server drops things (our game never does: returns null).
|
|
## `extra` is what's special about it (a worn tool's "dur"), kept when it's
|
|
## picked up again.
|
|
func drop_item(id: String, count: int, xf: Transform3D, extra: Dictionary = {}) -> Node3D:
|
|
if net_sync != null:
|
|
push_warning("[world] drop_item(%s) on a client: only the server drops items" % id)
|
|
return null
|
|
return _make_dropped(id, count, xf, extra)
|
|
|
|
## Something other than a player took a dropped item (the bear ate it, a fox
|
|
## ran off with it). On a server, everyone's game is told it's gone.
|
|
func take_dropped(pk: Node3D) -> void:
|
|
if server_mode and pk.has_meta("nid"):
|
|
WorldNet.taken("#%d" % int(pk.get_meta("nid")))
|
|
pk.collected()
|
|
|
|
func _make_dropped(id: String, count: int, xf: Transform3D, extra: Dictionary = {}) -> Node3D:
|
|
var pk: Area3D = preload("res://scripts/world/pickup.gd").new()
|
|
pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3)
|
|
pk.amount = count
|
|
pk.dropped = true
|
|
pk.extra = extra.duplicate()
|
|
_dropped.add_child(pk)
|
|
pk.global_transform = xf
|
|
if server_mode and not _applying:
|
|
dropped_added.emit(pk)
|
|
return pk
|
|
|
|
## Casts a fishing float (Actions validated it). One at a time.
|
|
## One float per player (on a server, everyone can fish at once). `bobber`
|
|
## is the local player's.
|
|
func start_fishing(p: Node3D, origin: Vector3, vel: Vector3) -> Node3D:
|
|
end_fishing(p)
|
|
var b: Node3D = preload("res://scripts/fishing/bobber.gd").new()
|
|
b.setup(self, p, origin, vel, randi())
|
|
add_child(b)
|
|
bobbers[p] = b
|
|
if p == player:
|
|
bobber = b
|
|
return b
|
|
|
|
func end_fishing(p: Node3D = null) -> void:
|
|
if p == null:
|
|
p = player
|
|
if is_online() and p == player and bobber != null:
|
|
PlayerNet.ask("reel") # (switched away from the rod: the server reels in)
|
|
var b: Node3D = bobbers.get(p)
|
|
bobbers.erase(p)
|
|
if b != null and is_instance_valid(b):
|
|
b.queue_free()
|
|
if p == player:
|
|
if bobber != null and is_instance_valid(bobber) and bobber != b:
|
|
bobber.queue_free() # (online: the server's float, shown here)
|
|
bobber = null
|
|
|
|
func bobber_of(p: Node3D) -> Node3D:
|
|
var b: Node3D = bobbers.get(p)
|
|
return b if b != null and is_instance_valid(b) else null
|
|
|
|
var bobbers: Dictionary = {} ## player -> their float
|
|
|
|
# --- who's here ------------------------------------------------------------
|
|
|
|
## A server's difficulty for one thing (rules.gd); 1 in single-player.
|
|
func difficulty_mult(k: String) -> float:
|
|
return preload("res://scripts/net/rules.gd").difficulty(k) if server_mode else 1.0
|
|
|
|
## Everyone animals react to: the local player in single-player; on a
|
|
## server, every player's character that's alive (player_net.gd).
|
|
func players() -> Array:
|
|
if server_mode:
|
|
return PlayerNet.proxies.values().filter(func(p): return is_instance_valid(p) and not p.survival.dead)
|
|
return [player]
|
|
|
|
## The nearest of them to `pos`, or null if nobody's around (a server with
|
|
## everyone dead or gone).
|
|
func nearest_player(pos: Vector3) -> Node3D:
|
|
if not server_mode:
|
|
return player
|
|
var best: Node3D = null
|
|
for p in players():
|
|
if best == null or p.global_position.distance_to(pos) < best.global_position.distance_to(pos):
|
|
best = p
|
|
return best
|
|
|
|
## Fires an arrow or thrown spear (Actions validated it).
|
|
func launch(id: String, origin: Vector3, vel: Vector3, damage: float, shooter: Node3D) -> Node3D:
|
|
var pr: Node3D = preload("res://scripts/creatures/projectile.gd").new()
|
|
pr.setup(id, self, origin, vel, damage, shooter)
|
|
add_child(pr)
|
|
if server_mode:
|
|
launched.emit(id, origin, vel, shooter) # everyone sees it fly (creature_net.gd)
|
|
return pr
|
|
|
|
signal launched(id: String, origin: Vector3, vel: Vector3, shooter: Node3D)
|
|
|
|
## Takes a piece out of the world (dismantled). Anything that loses its
|
|
## support collapses, and its item goes back to `refund_to` if there's room.
|
|
## Returns how many pieces collapsed.
|
|
func remove_piece(piece: Node3D, refund_to: Node3D = null) -> int:
|
|
if piece.is_in_group("bed") and spawn_bed() == piece:
|
|
has_bed_spawn = false
|
|
Actions.notify.emit("Spawn point cleared")
|
|
var was_build := piece.is_in_group("build")
|
|
_pieces.remove_child(piece)
|
|
piece.queue_free()
|
|
if not was_build:
|
|
if server_mode:
|
|
pieces_removed.emit([piece], [])
|
|
return 0
|
|
var weak: Array = build.recompute()
|
|
if server_mode:
|
|
pieces_removed.emit([piece] + weak, weak)
|
|
for w in weak:
|
|
if refund_to != null:
|
|
Actions._give(refund_to, w.item_id, 1)
|
|
_collapse_fx(w)
|
|
_pieces.remove_child(w)
|
|
w.queue_free()
|
|
if not weak.is_empty():
|
|
build.recompute()
|
|
Actions.notify.emit("%d piece%s collapsed" % [weak.size(), "" if weak.size() == 1 else "s"])
|
|
_settle_loose_pieces()
|
|
_update_grass_cuts()
|
|
return weak.size()
|
|
|
|
## A collapsing piece: a copy of its mesh tumbles down and sinks away.
|
|
func _collapse_fx(piece: Node3D) -> void:
|
|
if DisplayServer.get_name() == "headless":
|
|
return
|
|
var mi := MeshInstance3D.new()
|
|
mi.mesh = piece.get_node("Mesh").mesh
|
|
mi.material_override = ItemDB.item_material()
|
|
add_child(mi)
|
|
mi.global_transform = piece.global_transform
|
|
var tw := mi.create_tween().set_parallel(true)
|
|
tw.tween_property(mi, "global_position:y", mi.global_position.y - 4.0, 1.2).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
|
|
tw.tween_property(mi, "rotation:x", mi.rotation.x + randf_range(-0.8, 0.8), 1.2)
|
|
tw.chain().tween_callback(mi.queue_free)
|
|
|
|
## Chests, fires and beds left hanging after a collapse drop to whatever is
|
|
## below them.
|
|
func _settle_loose_pieces() -> void:
|
|
var space := get_world_3d().direct_space_state
|
|
for p in _pieces.get_children():
|
|
if p.is_in_group("build") or p.is_queued_for_deletion():
|
|
continue
|
|
var at: Vector3 = p.global_position
|
|
var rq := PhysicsRayQueryParameters3D.create(at + Vector3(0, 0.3, 0), at - Vector3(0, 0.5, 0), 1, [p.get_rid()])
|
|
if not space.intersect_ray(rq).is_empty():
|
|
continue
|
|
rq = PhysicsRayQueryParameters3D.create(at, at - Vector3(0, 80, 0), 1, [p.get_rid(), player.get_rid()])
|
|
var hit := space.intersect_ray(rq)
|
|
p.global_position.y = hit.position.y if not hit.is_empty() else terrain.sample_height(at.x, at.z)
|
|
|
|
# --- 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()
|
|
await _capture_weather(prefix, cam)
|
|
await _capture_creatures(prefix, cam)
|
|
await _capture_wolves_fishing(prefix, cam)
|
|
await _capture_players(prefix, cam)
|
|
|
|
## Two sample players (as other people would see them) at the camp.
|
|
func _capture_players(prefix: String, cam: Camera3D) -> void:
|
|
weather.force("clear")
|
|
weather.snow_cover = 0.0
|
|
weather.ground_wet = 0.0
|
|
day_night.time = 10.0 / 24.0
|
|
day_night.apply(true)
|
|
_respawn()
|
|
player.set_physics_process(false)
|
|
var fwd := -player.global_basis.z
|
|
var right := player.global_basis.x
|
|
var root := Node3D.new()
|
|
add_child(root)
|
|
var AvatarScript = preload("res://scripts/net/avatar.gd")
|
|
var made: Array = []
|
|
for d in [["Freya", {"tunic": 2, "hair": 2, "beard": false}, 3.2, -0.9, "stone_axe", 1],
|
|
["Old Bjorn", {"tunic": 1, "hair": 4, "beard": true}, 4.4, 1.3, "", 0]]:
|
|
var av: Node3D = AvatarScript.new()
|
|
av.setup(made.size() + 2, {"name": d[0], "appearance": d[1]})
|
|
root.add_child(av)
|
|
var p: Vector3 = player.global_position + fwd * float(d[2]) + right * float(d[3])
|
|
p.y = terrain.sample_height(p.x, p.z)
|
|
var face: float = player.rotation.y + PI + float(d[3]) * 0.3
|
|
av.push(0.0, p, face, 0.0, d[5], d[4])
|
|
av.push(1.0, p + fwd * 0.01 * d[5], face, 0.0, d[5], d[4])
|
|
made.append(av)
|
|
player.select(0)
|
|
cam.rotation.x = -0.12
|
|
for i in range(30):
|
|
for av in made:
|
|
av.update_at(0.5 + i * 0.016, 0.016)
|
|
await get_tree().process_frame
|
|
await RenderingServer.frame_post_draw
|
|
get_viewport().get_texture().get_image().save_png("%s_players.png" % prefix)
|
|
print("[world] saved %s_players.png" % prefix)
|
|
day_night.time = 19.2 / 24.0
|
|
day_night.apply(true)
|
|
await get_tree().create_timer(0.5).timeout
|
|
await RenderingServer.frame_post_draw
|
|
get_viewport().get_texture().get_image().save_png("%s_players_dusk.png" % prefix)
|
|
print("[world] saved %s_players_dusk.png" % prefix)
|
|
root.queue_free()
|
|
|
|
## The den by day with the pack resting, wolves stalking at night, and
|
|
## fishing from the lake shore.
|
|
func _capture_wolves_fishing(prefix: String, cam: Camera3D) -> void:
|
|
if wolves == null:
|
|
return
|
|
for c in creatures.get_children():
|
|
c.queue_free()
|
|
wolves.enabled = false
|
|
day_night.day = 5
|
|
day_night.time = 10.0 / 24.0
|
|
day_night.apply(true)
|
|
wolves.step(0.1)
|
|
var out: Vector3 = (den.mouth_pos() - den.position).normalized()
|
|
var eye: Vector3 = den.position + Basis(Vector3.UP, 0.35) * out * 11.0
|
|
eye.y = terrain.sample_height(eye.x, eye.z) + 1.7
|
|
player.select(0)
|
|
_aim_camera(cam, eye, den.position + Vector3(0, 1.0, 0))
|
|
for w in wolves.alive():
|
|
w.set_physics_process(false)
|
|
for _i in range(30):
|
|
w._animate(0.1)
|
|
await get_tree().create_timer(1.5).timeout
|
|
await RenderingServer.frame_post_draw
|
|
get_viewport().get_texture().get_image().save_png("%s_den.png" % prefix)
|
|
print("[world] saved %s_den.png" % prefix)
|
|
|
|
# Night: the pack circling you in the open.
|
|
var site: Vector3 = build.debug_find_site(_spawn + Vector3(-10, 0, 10), 6.0)
|
|
site.y = terrain.sample_height(site.x, site.z)
|
|
day_night.time = 22.5 / 24.0
|
|
day_night.apply(true)
|
|
var live: Array = wolves.alive()
|
|
for i in range(live.size()):
|
|
var a := -0.5 + i * 0.7
|
|
var p: Vector3 = site + Vector3(sin(a), 0, -cos(a)) * (7.0 + i * 1.5)
|
|
live[i].global_position = Vector3(p.x, terrain.sample_height(p.x, p.z), p.z)
|
|
live[i].state = "stalk"
|
|
var to: Vector3 = site - p
|
|
live[i].yaw = atan2(-to.x, -to.z)
|
|
live[i].rotation.y = live[i].yaw
|
|
live[i]._visual.position.y = 0.0
|
|
for l in live[i]._legs:
|
|
l.rotation.x = 0.0
|
|
player.inventory.set_slot(1, {"id": "torch", "count": 1})
|
|
player.select(1)
|
|
var e2: Vector3 = site + Vector3(0, 1.7, 0)
|
|
_aim_camera(cam, e2, site + Vector3(0, 0.6, -8.0))
|
|
await get_tree().create_timer(1.5).timeout
|
|
await RenderingServer.frame_post_draw
|
|
get_viewport().get_texture().get_image().save_png("%s_wolves_night.png" % prefix)
|
|
print("[world] saved %s_wolves_night.png" % prefix)
|
|
for w in live:
|
|
w.global_position = den.slot_pos(w.slot)
|
|
|
|
# Fishing at the lake in the evening light, a fish on the line.
|
|
day_night.time = 18.2 / 24.0
|
|
day_night.apply(true)
|
|
var lake: Vector2 = terrain.lake_center
|
|
var x: float = lake.x
|
|
while terrain.sample_height(x, lake.y) < terrain.water_level + 0.4 and x < lake.x + 60.0:
|
|
x += 0.5
|
|
var shore := Vector3(x + 1.0, 0, lake.y)
|
|
shore.y = terrain.sample_height(shore.x, shore.z)
|
|
player.inventory.set_slot(2, {"id": "fishing_rod", "count": 1})
|
|
player.select(2)
|
|
var fe: Vector3 = shore + Vector3(0, 1.7, 0)
|
|
# Midday, looking at the nearest fish, one mid-jump.
|
|
var lf: Node = get_node_or_null("LakeFish")
|
|
if lf != null and not lf._fish.is_empty():
|
|
day_night.time = 12.5 / 24.0
|
|
day_night.apply(true)
|
|
var t_now := Time.get_ticks_msec() / 1000.0
|
|
var best := 0
|
|
for i in range(lf._fish.size()):
|
|
if lf._swim_pos(i, t_now).distance_to(fe) < lf._swim_pos(best, t_now).distance_to(fe):
|
|
best = i
|
|
_aim_camera(cam, fe, lf._swim_pos(best, t_now))
|
|
lf._jump_left = 0.4
|
|
await get_tree().create_timer(0.75).timeout
|
|
await RenderingServer.frame_post_draw
|
|
get_viewport().get_texture().get_image().save_png("%s_lake_fish.png" % prefix)
|
|
print("[world] saved %s_lake_fish.png (fish %.0f m away)" % [prefix, lf._swim_pos(best, t_now).distance_to(fe)])
|
|
day_night.time = 18.2 / 24.0
|
|
day_night.apply(true)
|
|
_aim_camera(cam, fe, Vector3(lake.x + 6.0, terrain.water_level - 1.5, lake.y + 2.0))
|
|
await get_tree().create_timer(0.3).timeout
|
|
start_fishing(player, player.hands.rod_tip(), Vector3.ZERO)
|
|
bobber.set_physics_process(false)
|
|
bobber.global_position = Vector3(lake.x + 9.0, terrain.water_level, lake.y + 1.5)
|
|
bobber.state = "biting"
|
|
await get_tree().create_timer(1.5).timeout
|
|
await RenderingServer.frame_post_draw
|
|
get_viewport().get_texture().get_image().save_png("%s_fishing.png" % prefix)
|
|
print("[world] saved %s_fishing.png" % prefix)
|
|
end_fishing()
|
|
|
|
## Wildlife views: a rabbit, a deer and a boar in a meadow, the bow drawn on
|
|
## a deer, and a carcass with an arrow in it.
|
|
func _capture_creatures(prefix: String, cam: Camera3D) -> void:
|
|
weather.force("clear")
|
|
weather.snow_cover = 0.0
|
|
weather.ground_wet = 0.0
|
|
day_night.time = 9.5 / 24.0
|
|
day_night.apply(true)
|
|
creatures.enabled = false
|
|
for c in creatures.get_children():
|
|
c.queue_free()
|
|
_respawn()
|
|
player.survival.wet = 0.0
|
|
# Open meadow toward the lake, looking the same way as the spawn view.
|
|
var site: Vector3 = build.debug_find_site(player.global_position - player.global_basis.z * 16.0 + player.global_basis.x * 10.0, 7.0)
|
|
site.y = terrain.sample_height(site.x, site.z)
|
|
var hb := Basis(Vector3.UP, player.rotation.y)
|
|
var line: Array = []
|
|
for k in [["rabbit", Vector3(-1.6, 0, -4.0), 0.9], ["deer", Vector3(1.4, 0, -8.0), -1.2], ["boar", Vector3(-3.8, 0, -7.5), 2.1]]:
|
|
var c: Node3D = creatures.spawn(k[0], site + hb * (k[1] as Vector3))
|
|
c.set_physics_process(false)
|
|
c.yaw = player.rotation.y + float(k[2])
|
|
c.rotation.y = c.yaw
|
|
line.append(c)
|
|
var eye: Vector3 = site + hb * Vector3(0, 0, 1.0)
|
|
eye.y = terrain.sample_height(eye.x, eye.z) + 1.7
|
|
player.set_physics_process(false)
|
|
player.inventory.set_slot(2, {"id": "spear", "count": 1})
|
|
player.select(2)
|
|
_aim_camera(cam, eye, site + hb * Vector3(-0.8, 0.4, -6.0))
|
|
await get_tree().create_timer(1.5).timeout
|
|
await RenderingServer.frame_post_draw
|
|
get_viewport().get_texture().get_image().save_png("%s_animals.png" % prefix)
|
|
print("[world] saved %s_animals.png" % prefix)
|
|
# Bow drawn on the deer.
|
|
player.inventory.set_slot(3, {"id": "bow", "count": 1})
|
|
player.inventory.set_slot(4, {"id": "arrow", "count": 12})
|
|
player.select(3)
|
|
var deer: Node3D = line[1]
|
|
_aim_camera(cam, eye, deer.global_position + Vector3(0, 1.1, 0))
|
|
player.hands._drawing = true
|
|
player.hands._draw_t = 0.8
|
|
await get_tree().create_timer(0.8).timeout
|
|
await RenderingServer.frame_post_draw
|
|
get_viewport().get_texture().get_image().save_png("%s_bow.png" % prefix)
|
|
print("[world] saved %s_bow.png" % prefix)
|
|
player.hands._drawing = false
|
|
# A felled deer.
|
|
deer.take_hit(100.0, eye, "arrow")
|
|
player.select(0)
|
|
var back := eye - deer.global_position
|
|
back.y = 0.0
|
|
var near: Vector3 = deer.global_position + back.normalized() * 2.8
|
|
near.y = terrain.sample_height(near.x, near.z) + 1.7
|
|
_aim_camera(cam, near, deer.global_position + Vector3(0, 0.3, 0))
|
|
await get_tree().create_timer(1.0).timeout
|
|
await RenderingServer.frame_post_draw
|
|
get_viewport().get_texture().get_image().save_png("%s_carcass.png" % prefix)
|
|
print("[world] saved %s_carcass.png" % prefix)
|
|
|
|
## Weather views: a snapped-together hut in clear weather, rain, a storm and
|
|
## snow, and the view out of its open door in the rain.
|
|
func _capture_weather(prefix: String, cam: Camera3D) -> void:
|
|
_respawn()
|
|
player.set_physics_process(false)
|
|
var yaw: float = player.rotation.y
|
|
var hb := Basis(Vector3.UP, yaw)
|
|
var site: Vector3 = build.debug_find_site(player.global_position - player.global_basis.z * 11.0, 3.0)
|
|
var hut: Dictionary = await build.debug_build_hut(site, yaw)
|
|
print("[world] demo hut: %d pieces, failed %s" % [hut.placed, hut.failed])
|
|
var top: float = hut.pieces[0].global_position.y
|
|
spawn_piece(ItemDB.get_item("bed"), Transform3D(hb, site + hb * Vector3(1.0, top - site.y, -0.6)))
|
|
var fire_at: Vector3 = site + hb * Vector3(-0.6, 0, 4.5)
|
|
fire_at.y = terrain.sample_height(fire_at.x, fire_at.z)
|
|
var fire: Node = spawn_piece(ItemDB.get_item("campfire"), Transform3D(hb, fire_at))
|
|
fire.fuel = fire.MAX_FUEL
|
|
var door: Node = null
|
|
for p in hut.pieces:
|
|
if p.item_id == "wood_door":
|
|
door = p
|
|
player.select(4) # wood: nothing lit in hand
|
|
var outside_eye: Vector3 = site + hb * Vector3(4.2, 0, 8.5)
|
|
outside_eye.y = terrain.sample_height(outside_eye.x, outside_eye.z) + 1.7
|
|
var look: Vector3 = site + Vector3(0, 1.4, 0)
|
|
day_night.time = 10.5 / 24.0
|
|
for shot in [["hut_clear", "clear", 10.5], ["hut_rain", "rain", 10.5], ["hut_storm", "storm", 16.0], ["hut_snow", "snow", 11.0]]:
|
|
weather.force(shot[1])
|
|
if shot[1] == "snow":
|
|
weather.snow_cover = 1.0
|
|
weather.ground_wet = 0.0
|
|
day_night.time = float(shot[2]) / 24.0
|
|
day_night.apply(true)
|
|
_aim_camera(cam, outside_eye, look)
|
|
await get_tree().create_timer(2.0).timeout
|
|
if shot[1] == "storm":
|
|
weather.flash = 0.8
|
|
day_night.apply(true)
|
|
await RenderingServer.frame_post_draw
|
|
get_viewport().get_texture().get_image().save_png("%s_%s.png" % [prefix, shot[0]])
|
|
print("[world] saved %s_%s.png" % [prefix, shot[0]])
|
|
# From inside, out through the open door, in the rain.
|
|
weather.force("rain")
|
|
weather.snow_cover = 0.0
|
|
day_night.time = 17.5 / 24.0
|
|
day_night.apply(true)
|
|
door.set_open(true, false)
|
|
var in_eye: Vector3 = site + hb * Vector3(0.6, top - site.y + 1.6, -1.2)
|
|
_aim_camera(cam, in_eye, site + hb * Vector3(1.2, top - site.y + 1.2, 6.0))
|
|
await get_tree().create_timer(2.0).timeout
|
|
await RenderingServer.frame_post_draw
|
|
get_viewport().get_texture().get_image().save_png("%s_hut_inside.png" % prefix)
|
|
print("[world] saved %s_hut_inside.png" % prefix)
|
|
weather.force("")
|
|
|
|
func _aim_camera(cam: Camera3D, eye: Vector3, at: Vector3) -> void:
|
|
var dir := at - 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)
|