J1: the cliff tunnel (seam, six propped stretches, dark, lights), travel Valley <-> Cove through the sea cave, a save file per worldlet; New World has no Where (--dev-worldlets to try others)
This commit is contained in:
@@ -1,7 +1,34 @@
|
||||
# Worldlets design
|
||||
|
||||
Status: **W0-W3 built** (the Cove, without its stream yet); the rest is
|
||||
planned. The journey between
|
||||
Status: **W0-W3 and J1 built** (the Cove without its stream yet; the
|
||||
tunnel single-player only); the rest is planned.
|
||||
|
||||
*As built in J1:*
|
||||
- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip,
|
||||
7 m in from the edge, its angle from the seed) and the tunnel space, out
|
||||
of sight 260 m under the island: a CSG rock block with the landings and
|
||||
six sloping stretches cut out (6 m on, 4 m down each, switching back on
|
||||
two lanes: 24 m down), then a sea cave. Faces (`tunnel_face.gd`) answer
|
||||
like boulders, so the pick works on them: tier 3 only ("Too hard: you
|
||||
need a better pick"), 10 blows for the seam and 15 per stretch. Each
|
||||
stretch must be propped (2 wood, [E] at its mouth) before the next face
|
||||
takes a blow ("It's loose..."): 10 wood all the way down.
|
||||
- Underground: no sun, moon, sky reflections or sky-tinted fog
|
||||
(`day_night.underground`), no weather, no lake water, sheltered. Candles
|
||||
and torch stands place anywhere on its floor and save as ordinary pieces.
|
||||
- Walking out of the sea cave's mouth travels (`world.travel()`): the
|
||||
worldlet you leave is saved, your character and the clock go with you,
|
||||
and the other loads, arriving at its sea cave (`sea_cave.gd`, on the
|
||||
Cove's beach where the headland starts). [E] at the cave goes back up the
|
||||
same tunnel to its bottom, once it's dug through.
|
||||
- Saves: `world.json` is the home worldlet's state, as before;
|
||||
others are `worldlets/<id>.json`; meta.json's `worldlet` is where you
|
||||
are, `home` where you began, `arrive` where the next load puts you.
|
||||
- `tests/test_tunnel.gd`: dig through, walk every stretch down and back
|
||||
up, light it, reload, out to the Cove, back up, out at the top, and the
|
||||
chest at home is still there.
|
||||
- Not yet: Old Tam's tip about the seam, the Guide page, and travel on a
|
||||
server (J3: the tunnel isn't built online). The journey between
|
||||
worldlets is §10, and the two ways to play (the journey, or one land) are in
|
||||
its decisions.
|
||||
|
||||
@@ -17,9 +44,10 @@ its decisions.
|
||||
- Sea fish in `data/fish.json`: mackerel (common) and cod, which only bites
|
||||
in water 2.2 m or deeper (a per-fish `min_depth`). Grilled at a campfire,
|
||||
salted, or smoked, like the lake's.
|
||||
- Running from the source, the New World and Host pages also offer
|
||||
worldlets that aren't starts, marked "(testing)", so the Cove can be
|
||||
played before the tunnel exists. Release builds offer only the Valley.
|
||||
- New World has no "Where": the journey always begins in the Valley (the
|
||||
Host page keeps it, for One land mode). Started with
|
||||
`godot --path . -- --dev-worldlets`, New World and Host offer every
|
||||
worldlet, the others marked "(testing)", to try one directly.
|
||||
- No sea stacks without a sea cliff.
|
||||
- Tests: `test_cove` (walk onto the pier, catch a mackerel off its end, cod
|
||||
only in deep water), and the Cove in `test_terrain_edges` and
|
||||
@@ -302,7 +330,7 @@ tunnel goes **down**, inside the cliff, from the clifftop to the shore.
|
||||
- **Props:** each new stretch needs a **timber prop** (2 wood) set at its
|
||||
mouth before you can dig the next. Leave one out and the face won't take
|
||||
another blow ("It's loose: prop it first"); nothing caves in on you, so
|
||||
it's a cost, not a trap. That's about 12 wood all the way down.
|
||||
it's a cost, not a trap. That's 10 wood all the way down.
|
||||
- **Light:** the tunnel has no daylight at all. Every stretch is pitch dark
|
||||
until you bring light: a torch in your hand works, but your hands are full
|
||||
with the pick, so the way to do it is to set **candles and torch stands**
|
||||
|
||||
@@ -257,6 +257,14 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
|
||||
return false
|
||||
var held: Dictionary = ItemDB.get_item(player.held_item_id())
|
||||
var tool: Dictionary = held.get("tool", {})
|
||||
if node.has_method("refusal"): # (the cliff tunnel's faces: their own rules first)
|
||||
var why: String = node.refusal()
|
||||
if not why.is_empty():
|
||||
notify.emit(why)
|
||||
return false
|
||||
if tool.get("kind", "") == node.tool_kind and int(tool.get("tier", 0)) < node.min_tier:
|
||||
notify.emit("Too hard: you need a better pick")
|
||||
return false
|
||||
if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier:
|
||||
notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
|
||||
return false
|
||||
|
||||
@@ -60,6 +60,7 @@ func create_world(world_name: String, seed: int, options: Dictionary = {}) -> St
|
||||
}
|
||||
for k in options:
|
||||
meta[k] = options[k]
|
||||
meta["home"] = String(meta.worldlet)
|
||||
_write_json(_dir(id).path_join("meta.json"), meta)
|
||||
return id
|
||||
|
||||
@@ -84,6 +85,8 @@ func delete_world(id: String) -> void:
|
||||
var d := DirAccess.open(dir)
|
||||
if d == null:
|
||||
return
|
||||
for sub in d.get_directories(): # (worldlets/, and a server's backups/)
|
||||
_remove_tree(dir.path_join(sub))
|
||||
for f in d.get_files():
|
||||
d.remove(f)
|
||||
DirAccess.remove_absolute(dir)
|
||||
@@ -93,16 +96,64 @@ func delete_world(id: String) -> void:
|
||||
func read_meta(id: String) -> Dictionary:
|
||||
return _read_json(_dir(id).path_join("meta.json"))
|
||||
|
||||
func load_state(id: String) -> Dictionary:
|
||||
var s := _read_json(_dir(id).path_join("world.json"))
|
||||
if s.is_empty() and FileAccess.file_exists(_dir(id).path_join("world.json.bak")):
|
||||
push_warning("Saves: world.json unreadable, using backup")
|
||||
s = _read_json(_dir(id).path_join("world.json.bak"))
|
||||
# --- worldlets in one world (J1, docs/WORLDLETS_DESIGN.md §10) --------------------
|
||||
# A world can span several worldlets (the journey). Each keeps its own state
|
||||
# file: the one it began in (its "home") is world.json, as before, so older
|
||||
# saves are unchanged; the others are worldlets/<id>.json. meta.json's
|
||||
# "worldlet" is where you are now, so that's the one loaded.
|
||||
|
||||
## The worldlet world `id` is being played in now.
|
||||
func worldlet_of(id: String) -> String:
|
||||
return String(read_meta(id).get("worldlet", "valley"))
|
||||
|
||||
## The worldlet world `id` began in (its world.json).
|
||||
func home_of(id: String) -> String:
|
||||
var m := read_meta(id)
|
||||
return String(m.get("home", m.get("worldlet", "valley")))
|
||||
|
||||
## The state file for worldlet `w` of world `id`.
|
||||
func state_path(id: String, w: String) -> String:
|
||||
if w == home_of(id):
|
||||
return _dir(id).path_join("world.json")
|
||||
return _dir(id).path_join("worldlets/%s.json" % w)
|
||||
|
||||
## Whether world `id` has been to worldlet `w` (has a state for it).
|
||||
func has_state(id: String, w: String) -> bool:
|
||||
return FileAccess.file_exists(state_path(id, w))
|
||||
|
||||
## Moves world `id` to worldlet `to` (the next load is there), arriving at
|
||||
## `arrive` (a place the arriving world puts you: "sea_cave", "tunnel_bottom").
|
||||
func set_worldlet(id: String, to: String, arrive: String) -> void:
|
||||
var m := read_meta(id)
|
||||
if not m.has("home"):
|
||||
m["home"] = String(m.get("worldlet", "valley")) # (pin it before "worldlet" moves)
|
||||
m["worldlet"] = to
|
||||
m["arrive"] = arrive
|
||||
_write_json(_dir(id).path_join("meta.json"), m)
|
||||
|
||||
## Where the next load should put you, once (then forgotten).
|
||||
func take_arrival(id: String) -> String:
|
||||
var m := read_meta(id)
|
||||
var a := String(m.get("arrive", ""))
|
||||
if not a.is_empty():
|
||||
m.erase("arrive")
|
||||
_write_json(_dir(id).path_join("meta.json"), m)
|
||||
return a
|
||||
|
||||
## The state of the worldlet being played (or of worldlet `w`).
|
||||
func load_state(id: String, w: String = "") -> Dictionary:
|
||||
var path := state_path(id, worldlet_of(id) if w.is_empty() else w)
|
||||
var s := _read_json(path)
|
||||
if s.is_empty() and FileAccess.file_exists(path + ".bak"):
|
||||
push_warning("Saves: %s unreadable, using backup" % path.get_file())
|
||||
s = _read_json(path + ".bak")
|
||||
return s
|
||||
|
||||
## Writes the state for `id` and updates its meta. Returns true on success.
|
||||
func save_state(id: String, state: Dictionary, session_seconds: float) -> bool:
|
||||
var path := _dir(id).path_join("world.json")
|
||||
## Writes the state for `id` (the worldlet being played, or `w`) and
|
||||
## updates its meta. Returns true on success.
|
||||
func save_state(id: String, state: Dictionary, session_seconds: float, w: String = "") -> bool:
|
||||
var path := state_path(id, worldlet_of(id) if w.is_empty() else w)
|
||||
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||
if FileAccess.file_exists(path):
|
||||
DirAccess.copy_absolute(path, path + ".bak")
|
||||
state["version"] = VERSION
|
||||
@@ -129,3 +180,10 @@ func _read_json(path: String) -> Dictionary:
|
||||
return {}
|
||||
var data = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||
return data if typeof(data) == TYPE_DICTIONARY else {}
|
||||
|
||||
static func _remove_tree(d: String) -> void:
|
||||
for sub in DirAccess.get_directories_at(d):
|
||||
_remove_tree(d.path_join(sub))
|
||||
for f in DirAccess.get_files_at(d):
|
||||
DirAccess.remove_absolute(d.path_join(f))
|
||||
DirAccess.remove_absolute(d)
|
||||
|
||||
@@ -103,8 +103,9 @@ func step(dt: float) -> void:
|
||||
_shelter_left -= dt
|
||||
if _shelter_left <= 0.0:
|
||||
_shelter_left = 0.25
|
||||
roofed = world.build.roofed_at(p)
|
||||
sheltered = roofed and world.build.enclosure_at(p) >= 0.74
|
||||
var cave: bool = world.has_method("underground_at") and world.underground_at(p) # (the tunnel: rock all round)
|
||||
roofed = cave or world.build.roofed_at(p)
|
||||
sheltered = roofed and (cave or world.build.enclosure_at(p) >= 0.74)
|
||||
stone_room = sheltered and world.build.stone_walls_at(p) >= 0.5
|
||||
cozy = sheltered and _rug_near(p)
|
||||
# Rain soaks you unless there's a roof over your head.
|
||||
|
||||
@@ -594,6 +594,9 @@ func _new_page() -> Control:
|
||||
_rot_pick.add_item(t[1])
|
||||
_rot_pick.custom_minimum_size = Vector2(360, 36)
|
||||
v.add_child(_rot_pick)
|
||||
# The journey always begins in the Valley, so a new world has no "Where"
|
||||
# (only with --dev-worldlets, to try the others).
|
||||
if dev_worldlets():
|
||||
_worldlet_pick = _worldlet_picker(v)
|
||||
var h := HBoxContainer.new()
|
||||
h.add_theme_constant_override("separation", 10)
|
||||
@@ -698,7 +701,7 @@ var _host_worldlet: OptionButton
|
||||
## description under it. (Only the Valley today: the others are reached on
|
||||
## the journey, so the list is short and the picker sits still.)
|
||||
func _worldlet_picker(v: VBoxContainer) -> OptionButton:
|
||||
var starts: Array = offered_worldlets(OS.is_debug_build())
|
||||
var starts: Array = offered_worldlets(dev_worldlets())
|
||||
v.add_child(UITheme.label("Where", 15, UITheme.TEXT_DIM))
|
||||
var pick := OptionButton.new()
|
||||
pick.custom_minimum_size = Vector2(360, 36)
|
||||
@@ -716,12 +719,16 @@ func _worldlet_picker(v: VBoxContainer) -> OptionButton:
|
||||
about.text = String(Terrain.load_worldlet(String(pick.get_item_metadata(i))).get("description", "")))
|
||||
return pick
|
||||
|
||||
## The worldlets offered for a new world: the starts, and (running from the
|
||||
## source, `debug`) the rest too, marked for testing, so a worldlet can be
|
||||
## tried before the journey reaches it. A release build offers only the starts.
|
||||
static func offered_worldlets(debug: bool) -> Array:
|
||||
## Started with `-- --dev-worldlets`: every worldlet can be picked, for
|
||||
## trying one before the journey reaches it.
|
||||
static func dev_worldlets() -> bool:
|
||||
return "--dev-worldlets" in OS.get_cmdline_user_args()
|
||||
|
||||
## The worldlets offered for a new world or island: the starts, and (`dev`)
|
||||
## the rest too, marked for testing.
|
||||
static func offered_worldlets(dev: bool) -> Array:
|
||||
var out: Array = Terrain.starting_worldlets()
|
||||
if debug:
|
||||
if dev:
|
||||
for w in Terrain.worldlets():
|
||||
if not w.start:
|
||||
var t: Dictionary = w.duplicate()
|
||||
|
||||
@@ -15,6 +15,8 @@ const DAY_BIAS := 0.2
|
||||
var day := 1
|
||||
var time := 8.0 / 24.0
|
||||
var frozen := false ## title-screen backdrop: hold a fixed time
|
||||
var underground := 0.0 ## 0..1: how far into the tunnel you are (no daylight; world.gd sets it)
|
||||
const AERIAL := 0.55 ## the fog's sky tint with distance (scenes/default_env.tres)
|
||||
|
||||
var sun: DirectionalLight3D
|
||||
var moon: DirectionalLight3D
|
||||
@@ -123,6 +125,26 @@ func apply(force: bool) -> void:
|
||||
env.ambient_light_energy = lerpf(0.3, 1.0, daylight) * lerpf(1.0, 0.85, overcast) + w_flash * 3.0
|
||||
env.fog_light_energy = lerpf(0.25, 1.0, daylight) * lerpf(1.0, 0.7, overcast) + w_flash * 2.0
|
||||
env.volumetric_fog_density = lerpf(0.0025, 0.0045, daylight) * lerpf(1.0, 2.0, overcast)
|
||||
if underground > 0.0:
|
||||
# In the tunnel (J1): no sun, moon or sky, only the lights you bring.
|
||||
var u := underground
|
||||
sun.light_energy *= 1.0 - u
|
||||
sun.visible = sun.light_energy > 0.01
|
||||
sun.shadow_enabled = sun.visible
|
||||
moon.light_energy *= 1.0 - u
|
||||
moon.visible = moon.light_energy > 0.01
|
||||
env.ambient_light_energy = lerpf(env.ambient_light_energy, 0.015, u)
|
||||
env.ambient_light_sky_contribution = 1.0 - u
|
||||
env.fog_light_energy = lerpf(env.fog_light_energy, 0.0, u)
|
||||
env.volumetric_fog_density *= 1.0 - u
|
||||
# (Sky reflections and the fog's sky-coloured distance would light the
|
||||
# rock blue-grey all the way down: off.)
|
||||
env.fog_aerial_perspective = lerpf(AERIAL, 0.0, u)
|
||||
env.reflected_light_source = Environment.REFLECTION_SOURCE_DISABLED if u > 0.5 else Environment.REFLECTION_SOURCE_SKY
|
||||
else:
|
||||
env.ambient_light_sky_contribution = 1.0
|
||||
env.fog_aerial_perspective = AERIAL
|
||||
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
||||
env.fog_density = BASE_FOG * w_fog
|
||||
|
||||
_sky_timer -= get_process_delta_time()
|
||||
|
||||
@@ -32,6 +32,7 @@ const FLOWER_COLORS := [
|
||||
|
||||
var terrain: Node3D
|
||||
var pier: Node3D ## the Cove's pier, kept clear of scenery (set by the world)
|
||||
var keep_clear: Array = [] ## [[Vector3, radius]]: the tunnel's seam, the sea cave
|
||||
var _player: Node3D
|
||||
var _spawn := Vector3.ZERO
|
||||
var _forest_noise := FastNoiseLite.new()
|
||||
@@ -422,6 +423,9 @@ func _scatter_trees() -> void:
|
||||
func _on_clifftop(x: float, z: float) -> bool:
|
||||
if pier != null and pier.covers(x, z, 2.0):
|
||||
return true
|
||||
for c in keep_clear:
|
||||
if Vector2(x - c[0].x, z - c[0].z).length() < float(c[1]):
|
||||
return true
|
||||
return terrain.sea_weight(x, z) > 0.5 and not terrain.inside_boundary(x, z, 10.0) \
|
||||
and terrain.inside_boundary(x, z, -8.0)
|
||||
|
||||
|
||||
120
scripts/world/sea_cave.gd
Normal file
120
scripts/world/sea_cave.gd
Normal file
@@ -0,0 +1,120 @@
|
||||
extends Node3D
|
||||
## The Cove's sea cave (J1): the bottom of the Valley's cliff tunnel, seen
|
||||
## from the shore. A dark opening in the rocks at the end of the beach,
|
||||
## where the headland meets the sand. [E] goes in and up the tunnel, back to
|
||||
## the Valley, once that tunnel has been dug through from the top. (Until
|
||||
## then the cave runs back into solid rock.)
|
||||
##
|
||||
## Placed from the terrain alone, like the pier, so it's the same for
|
||||
## everyone; nothing about it is saved here (the tunnel is the Valley's).
|
||||
|
||||
var world: Node3D
|
||||
var to := "valley"
|
||||
var front := Vector3.ZERO ## where you stand in front of it (arriving from the tunnel)
|
||||
|
||||
## Places the cave if this worldlet has a way back by tunnel from a sea cave.
|
||||
func place(w: Node3D) -> bool:
|
||||
world = w
|
||||
var t: Node3D = w.terrain
|
||||
var way: Dictionary = {}
|
||||
for c in w.crossings():
|
||||
if c is Dictionary and c.get("by", "") == "tunnel" and c.get("where", "") == "sea_cave":
|
||||
way = c
|
||||
if way.is_empty() or not t._has_beach:
|
||||
return false
|
||||
to = String(way.to)
|
||||
# At the end of the beach, where the headland starts: the first dry,
|
||||
# gentle spot coming in from the waterline.
|
||||
var a := deg_to_rad(t.beach_angle_deg + t.beach_half_width_deg + t.beach_fade_deg * 0.35)
|
||||
var dir := Vector2(cos(a), sin(a))
|
||||
var at := Vector3.INF
|
||||
for i in range(60):
|
||||
var r: float = t._shore_d() - 2.0 - i * 0.5 - t._rim_offset(dir.x, dir.y)
|
||||
var p := dir * r
|
||||
var h: float = t.sample_height(p.x, p.y)
|
||||
if h > t.water_level + 0.5 and t.sample_normal(p.x, p.y).y > 0.86 and t.inside_boundary(p.x, p.y, 3.0):
|
||||
at = Vector3(p.x, h, p.y)
|
||||
break
|
||||
if at == Vector3.INF:
|
||||
return false
|
||||
# Facing back along the beach (towards its middle), so you see it from the sand.
|
||||
var mid: Vector2 = t.shore_point(-6.0)
|
||||
var face := Vector3(mid.x - at.x, 0.0, mid.y - at.z).normalized()
|
||||
global_transform = Transform3D(Basis.looking_at(face, Vector3.UP), at)
|
||||
front = at + face * 2.4
|
||||
front.y = t.sample_height(front.x, front.z)
|
||||
_build(w)
|
||||
add_to_group("sea_cave")
|
||||
return true
|
||||
|
||||
## Whether the Valley's tunnel has been dug right down to here.
|
||||
func open() -> bool:
|
||||
if not Saves.has_world():
|
||||
return false
|
||||
var s: Dictionary = Saves.load_state(Saves.current_id, to)
|
||||
return int(s.get("tunnel", {}).get("dug", 0)) >= preload("res://scripts/world/tunnel.gd").STRETCHES
|
||||
|
||||
func interact_prompt(_player: Node) -> String:
|
||||
if open():
|
||||
return "[E] Go into the sea cave and up the tunnel to the %s" % preload("res://scripts/world/terrain.gd").worldlet_name(to)
|
||||
return "A sea cave. It runs back into solid rock: the way up hasn't been dug"
|
||||
|
||||
func interact(_player: Node) -> void:
|
||||
if open():
|
||||
world.travel(to, "tunnel_bottom")
|
||||
else:
|
||||
Actions.notify.emit(interact_prompt(null))
|
||||
|
||||
func _build(w: Node3D) -> void:
|
||||
var rock := StandardMaterial3D.new()
|
||||
rock.albedo_color = Color(0.42, 0.40, 0.38)
|
||||
rock.roughness = 1.0
|
||||
if w.terrain.noise_tex != null:
|
||||
rock.albedo_texture = w.terrain.noise_tex
|
||||
rock.uv1_triplanar = true
|
||||
rock.uv1_scale = Vector3(0.18, 0.18, 0.18)
|
||||
var body := StaticBody3D.new()
|
||||
add_child(body)
|
||||
# (local -Z faces the beach; the rocks are behind, +Z)
|
||||
for part in [[Vector3(-2.0, 1.6, 1.0), Vector3(1.8, 3.6, 3.0)], [Vector3(2.0, 1.4, 1.0), Vector3(1.8, 3.2, 3.0)],
|
||||
[Vector3(0, 3.5, 1.0), Vector3(5.8, 1.4, 3.2)], [Vector3(0, 1.4, 2.4), Vector3(2.4, 3.0, 0.6)],
|
||||
[Vector3(-3.4, 1.0, 2.0), Vector3(2.0, 2.4, 3.0)], [Vector3(3.2, 0.8, 2.2), Vector3(1.8, 2.0, 2.6)]]:
|
||||
var mi := MeshInstance3D.new()
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = part[1]
|
||||
mi.mesh = bm
|
||||
mi.material_override = rock
|
||||
mi.position = part[0]
|
||||
mi.rotation.y = part[0].x * 0.06
|
||||
add_child(mi)
|
||||
var cs := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = part[1]
|
||||
cs.shape = box
|
||||
cs.position = part[0]
|
||||
cs.rotation.y = part[0].x * 0.06
|
||||
body.add_child(cs)
|
||||
var dark := MeshInstance3D.new()
|
||||
var q := QuadMesh.new()
|
||||
q.size = Vector2(2.2, 2.8)
|
||||
dark.mesh = q
|
||||
var m := StandardMaterial3D.new()
|
||||
m.albedo_color = Color(0.02, 0.02, 0.025)
|
||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
dark.material_override = m
|
||||
dark.position = Vector3(0, 1.4, 2.08)
|
||||
dark.rotation.y = PI
|
||||
add_child(dark)
|
||||
# What you press E on: the opening (interactables layer: you walk into it).
|
||||
var spot := StaticBody3D.new()
|
||||
spot.collision_layer = 2
|
||||
spot.collision_mask = 0
|
||||
var cs2 := CollisionShape3D.new()
|
||||
var b2 := BoxShape3D.new()
|
||||
b2.size = Vector3(2.2, 2.8, 1.6)
|
||||
cs2.shape = b2
|
||||
spot.add_child(cs2)
|
||||
spot.position = Vector3(0, 1.4, 1.2)
|
||||
spot.set_script(preload("res://scripts/world/sea_cave_spot.gd"))
|
||||
spot.cave = self
|
||||
add_child(spot)
|
||||
1
scripts/world/sea_cave.gd.uid
Normal file
1
scripts/world/sea_cave.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dne5yll2h1st2
|
||||
10
scripts/world/sea_cave_spot.gd
Normal file
10
scripts/world/sea_cave_spot.gd
Normal file
@@ -0,0 +1,10 @@
|
||||
extends StaticBody3D
|
||||
## The sea cave's opening (sea_cave.gd): what you look at and press E on.
|
||||
|
||||
var cave: Node3D
|
||||
|
||||
func interact_prompt(player: Node) -> String:
|
||||
return cave.interact_prompt(player)
|
||||
|
||||
func interact(player: Node) -> void:
|
||||
cave.interact(player)
|
||||
1
scripts/world/sea_cave_spot.gd.uid
Normal file
1
scripts/world/sea_cave_spot.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cvc1bri24m1h4
|
||||
451
scripts/world/tunnel.gd
Normal file
451
scripts/world/tunnel.gd
Normal file
@@ -0,0 +1,451 @@
|
||||
extends Node3D
|
||||
## The cliff tunnel (J1, docs/WORLDLETS_DESIGN.md §10): the Valley's way down
|
||||
## to the Cove, dug by hand.
|
||||
##
|
||||
## - **The seam:** a cracked outcrop on the clifftop strip near the edge.
|
||||
## Only the antler pickaxe (tier 3) bites into it; once it gives, it's a
|
||||
## dark opening: [E] goes down into the tunnel.
|
||||
## - **The tunnel:** its own small space, out of sight beneath the island
|
||||
## (the heightmap can't have holes), so going in and out is a step through
|
||||
## a doorway. Six sloping stretches, each one turning back under the last
|
||||
## like a stair, about 24 m down in all. The end of the dug part is a rock
|
||||
## face; each stretch dug must be propped (2 wood, [E] at its mouth) before
|
||||
## the next face takes a blow. Nothing caves in: it's a cost, not a trap.
|
||||
## - **Dark:** no daylight reaches it (day_night.underground). Bring light:
|
||||
## candles and torch stands can be set anywhere on its floor, and stay.
|
||||
## - **The bottom:** the last stretch breaks into a sea cave; walking out of
|
||||
## its mouth is the crossing to the Cove (world.travel()).
|
||||
##
|
||||
## How far it's dug, the face's damage and the props are saved with the
|
||||
## Valley (to_data()); the lights are ordinary placed pieces. Single-player
|
||||
## for now: on a server it isn't built (J3 brings travel online).
|
||||
|
||||
const TunnelFace = preload("res://scripts/world/tunnel_face.gd")
|
||||
|
||||
const ORIGIN := Vector3(0.0, -260.0, 0.0) ## where the tunnel space sits (well under the island)
|
||||
const STRETCHES := 6
|
||||
const RUN := 6.0 ## each stretch: this far along...
|
||||
const DROP := 4.0 ## ...and this far down
|
||||
const W := 2.6 ## passage width
|
||||
const H := 3.0 ## passage height
|
||||
const LAND := 3.4 ## landing length, where it turns back
|
||||
const LAND_EXT := 2.0 ## how far a landing's headroom reaches into its stretches
|
||||
const GAP := 1.4 ## rock between the two lanes
|
||||
const CAVE_LEN := 7.0 ## the sea cave passage at the bottom
|
||||
const PROP_WOOD := 2
|
||||
const FACE_HP := 45.0 ## 15 blows of the antler pickaxe
|
||||
const SEAM_HP := 30.0
|
||||
|
||||
var world: Node3D
|
||||
var terrain: Node3D
|
||||
var to := "cove" ## the worldlet at the bottom
|
||||
|
||||
# Saved state.
|
||||
var opened := false ## the seam has given way
|
||||
var dug := 0 ## stretches dug (0..STRETCHES)
|
||||
var face_hp := FACE_HP ## damage to the face being dug (the seam's, before it opens)
|
||||
var props: Array = [] ## props[k] for stretch k (1-based; [0] unused)
|
||||
|
||||
# Where things are (world coordinates).
|
||||
var seam_at := Vector3.ZERO ## the seam, on the ground
|
||||
var seam_out := Vector3.ZERO ## where you stand outside it (inland)
|
||||
var top := Vector3.ZERO ## inside, at the top
|
||||
var bottom := Vector3.ZERO ## inside, at the bottom by the cave
|
||||
var cave_mouth := Vector3.ZERO ## the far end of the sea cave
|
||||
var aabb := AABB()
|
||||
|
||||
var _rock_mat: StandardMaterial3D
|
||||
var _face_mat: StandardMaterial3D
|
||||
var _wood_mat: StandardMaterial3D
|
||||
var _rock: CSGCombiner3D
|
||||
var _cuts: Node3D
|
||||
var _face: StaticBody3D
|
||||
var _seam: Node3D
|
||||
var _spots: Node3D
|
||||
|
||||
## Builds the tunnel for `w` if its worldlet has a way on by tunnel from
|
||||
## its sea cliff. Returns false (and builds nothing) otherwise.
|
||||
func setup(w: Node3D) -> bool:
|
||||
world = w
|
||||
terrain = w.terrain
|
||||
var way: Dictionary = {}
|
||||
for c in w.crossings():
|
||||
if c is Dictionary and c.get("by", "") == "tunnel" and c.get("where", "") == "sea_cliff":
|
||||
way = c
|
||||
if way.is_empty() or not terrain._has_sea:
|
||||
return false
|
||||
to = String(way.to)
|
||||
props.resize(STRETCHES + 1)
|
||||
props.fill(false)
|
||||
_make_materials()
|
||||
_place_seam()
|
||||
_layout()
|
||||
_rebuild()
|
||||
add_to_group("tunnel")
|
||||
return true
|
||||
|
||||
# --- where it is ----------------------------------------------------------------
|
||||
|
||||
func _lane_z(k: int) -> float:
|
||||
return 0.0 if k % 2 == 1 else W + GAP
|
||||
|
||||
func _landing_x(k: int) -> float:
|
||||
return (k % 2) * (LAND + RUN)
|
||||
|
||||
func _floor_y(k: int) -> float:
|
||||
return -k * DROP
|
||||
|
||||
## Inside the tunnel's space (with a margin), in world coordinates.
|
||||
func contains(p: Vector3) -> bool:
|
||||
return aabb.grow(1.0).has_point(p)
|
||||
|
||||
func _layout() -> void:
|
||||
var x0 := -LAND * 0.5 - 3.0 - CAVE_LEN
|
||||
var x1 := LAND * 1.5 + RUN + 3.0
|
||||
var y0 := _floor_y(STRETCHES) - 3.0
|
||||
var y1 := H + 3.0
|
||||
var z0 := -W * 0.5 - 3.0
|
||||
var z1 := W * 1.5 + GAP + 3.0
|
||||
aabb = AABB(ORIGIN + Vector3(x0, y0, z0), Vector3(x1 - x0, y1 - y0, z1 - z0))
|
||||
top = ORIGIN + Vector3(_landing_x(0) - LAND * 0.25, 0.0, _lane_z(1))
|
||||
var zb := _lane_z(STRETCHES)
|
||||
bottom = ORIGIN + Vector3(_landing_x(STRETCHES), _floor_y(STRETCHES), zb)
|
||||
cave_mouth = bottom + Vector3(-LAND * 0.5 - CAVE_LEN + 0.6, 0.0, 0.0)
|
||||
|
||||
## The seam: on the clifftop strip, a few metres in from the edge, at an
|
||||
## angle the seed picks.
|
||||
func _place_seam() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = terrain.noise_seed + 404
|
||||
for _try in range(40):
|
||||
var a: float = terrain.sea_angle_deg + rng.randf_range(-18.0, 18.0)
|
||||
var p: Vector2 = terrain.coast_point(a, -7.0)
|
||||
if not terrain.inside_boundary(p.x, p.y, 2.0):
|
||||
continue
|
||||
var n: Vector3 = terrain.sample_normal(p.x, p.y)
|
||||
if n.y < 0.9:
|
||||
continue
|
||||
seam_at = Vector3(p.x, terrain.sample_height(p.x, p.y), p.y)
|
||||
break
|
||||
var inland := -Vector3(seam_at.x, 0.0, seam_at.z).normalized()
|
||||
seam_out = seam_at + inland * 2.2
|
||||
seam_out.y = terrain.sample_height(seam_out.x, seam_out.z)
|
||||
|
||||
# --- digging ---------------------------------------------------------------------
|
||||
|
||||
## Why the face at `stage` won't take a blow ("" = it will).
|
||||
func dig_refusal(stage: int) -> String:
|
||||
if stage >= 2:
|
||||
for k in range(1, stage):
|
||||
if not props[k]:
|
||||
return "It's loose: prop the last stretch first ([E] at its mouth, %d wood)" % PROP_WOOD
|
||||
return ""
|
||||
|
||||
func face_hit(f: Node) -> void:
|
||||
face_hp = f.hp
|
||||
|
||||
func face_broken(f: Node) -> void:
|
||||
face_hp = FACE_HP
|
||||
if f.stage == 0:
|
||||
opened = true
|
||||
Actions.notify.emit("The seam gives way: a dark passage leads down into the cliff")
|
||||
else:
|
||||
dug = f.stage
|
||||
if dug >= STRETCHES:
|
||||
Actions.notify.emit("You break through into a sea cave. You can hear the waves")
|
||||
else:
|
||||
Actions.notify.emit("The rock gives. Prop this stretch before you dig on")
|
||||
_rebuild.call_deferred()
|
||||
|
||||
## [E] at the mouth of stretch `k`: sets a timber prop there.
|
||||
func set_prop(player: Node, k: int) -> bool:
|
||||
if k < 1 or k > dug or props[k]:
|
||||
return false
|
||||
if player.inventory.count("wood") < PROP_WOOD:
|
||||
Actions.notify.emit("A timber prop needs %d wood" % PROP_WOOD)
|
||||
return false
|
||||
player.inventory.remove("wood", PROP_WOOD)
|
||||
props[k] = true
|
||||
Actions.notify.emit("Propped. The next face is safe to dig")
|
||||
_rebuild.call_deferred()
|
||||
return true
|
||||
|
||||
# --- going in and out ----------------------------------------------------------
|
||||
|
||||
func go_in(player: Node3D) -> void:
|
||||
_move(player, top + Vector3(0, 0.1, 0), Vector3(1, 0, 0))
|
||||
|
||||
func go_out(player: Node3D) -> void:
|
||||
_move(player, seam_out + Vector3(0, 0.2, 0), -Vector3(seam_at.x, 0.0, seam_at.z).normalized())
|
||||
|
||||
func _move(player: Node3D, at: Vector3, facing: Vector3) -> void:
|
||||
player.global_position = at
|
||||
player.velocity = Vector3.ZERO
|
||||
player.rotation = Vector3(0.0, atan2(-facing.x, -facing.z), 0.0)
|
||||
|
||||
# --- saving ----------------------------------------------------------------------
|
||||
|
||||
func to_data() -> Dictionary:
|
||||
var p: Array = []
|
||||
for k in range(1, STRETCHES + 1):
|
||||
if props[k]:
|
||||
p.append(k)
|
||||
return {"opened": opened, "dug": dug, "face_hp": face_hp, "props": p}
|
||||
|
||||
func load_data(d: Dictionary) -> void:
|
||||
opened = bool(d.get("opened", false))
|
||||
dug = clampi(int(d.get("dug", 0)), 0, STRETCHES)
|
||||
face_hp = float(d.get("face_hp", FACE_HP))
|
||||
props.fill(false)
|
||||
for k in d.get("props", []):
|
||||
if int(k) >= 1 and int(k) <= STRETCHES:
|
||||
props[int(k)] = true
|
||||
_rebuild()
|
||||
|
||||
# --- building it ---------------------------------------------------------------
|
||||
|
||||
func _make_materials() -> void:
|
||||
_rock_mat = StandardMaterial3D.new()
|
||||
_rock_mat.albedo_color = Color(0.40, 0.38, 0.36)
|
||||
_rock_mat.roughness = 1.0
|
||||
if terrain.noise_tex != null:
|
||||
_rock_mat.albedo_texture = terrain.noise_tex
|
||||
_rock_mat.uv1_triplanar = true
|
||||
_rock_mat.uv1_scale = Vector3(0.18, 0.18, 0.18)
|
||||
_face_mat = _rock_mat.duplicate()
|
||||
_face_mat.albedo_color = Color(0.30, 0.27, 0.25)
|
||||
_wood_mat = StandardMaterial3D.new()
|
||||
_wood_mat.albedo_color = Color(0.42, 0.29, 0.17)
|
||||
_wood_mat.roughness = 0.9
|
||||
|
||||
func _rebuild() -> void:
|
||||
for n in [_rock, _seam, _spots, _face]:
|
||||
if n != null and is_instance_valid(n):
|
||||
n.queue_free()
|
||||
_face = null
|
||||
_build_rock()
|
||||
_build_seam()
|
||||
_spots = Node3D.new()
|
||||
_spots.name = "Spots"
|
||||
_spots.top_level = true
|
||||
add_child(_spots)
|
||||
# The face being dug: at the mouth of the next stretch (or the seam).
|
||||
if opened and dug < STRETCHES:
|
||||
var k := dug + 1
|
||||
var mouth := _mouth(k)
|
||||
_face = _make_face(k, mouth + Vector3(0, H * 0.5, 0), Vector3(0.5, H, W + 0.2))
|
||||
# Props set (posts and a beam), and spots to set the missing ones.
|
||||
for k in range(1, dug + 1):
|
||||
var mouth := _mouth(k)
|
||||
if props[k]:
|
||||
_add_prop_mesh(mouth)
|
||||
else:
|
||||
_add_spot(mouth + Vector3(0, H * 0.5, 0), Vector3(0.6, H, W), "prop", k)
|
||||
_add_spot(ORIGIN + Vector3(_landing_x(0) - LAND * 0.5 + 0.35, H * 0.5, _lane_z(1)), Vector3(0.4, H, W), "out", 0)
|
||||
if dug >= STRETCHES:
|
||||
_add_mouth()
|
||||
|
||||
## The start of stretch k, on the floor of landing k-1 (world coordinates).
|
||||
func _mouth(k: int) -> Vector3:
|
||||
var s := signf(_landing_x(k) - _landing_x(k - 1))
|
||||
return ORIGIN + Vector3(_landing_x(k - 1) + s * LAND * 0.5, _floor_y(k - 1), _lane_z(k))
|
||||
|
||||
func _build_rock() -> void:
|
||||
_rock = CSGCombiner3D.new()
|
||||
_rock.name = "Rock"
|
||||
_rock.use_collision = true
|
||||
_rock.collision_layer = 1
|
||||
_rock.top_level = true
|
||||
add_child(_rock)
|
||||
_rock.global_position = ORIGIN
|
||||
var base := CSGBox3D.new()
|
||||
base.size = aabb.size
|
||||
base.position = aabb.get_center() - ORIGIN
|
||||
base.material = _rock_mat
|
||||
_rock.add_child(base)
|
||||
if not opened:
|
||||
return
|
||||
# Landings: the top one on lane A only; the rest span both lanes.
|
||||
for k in range(0, dug + 1):
|
||||
var x := _landing_x(k)
|
||||
var y := _floor_y(k)
|
||||
var zlo := -W * 0.5 if k < STRETCHES else _lane_z(k) - W * 0.5
|
||||
var zhi := W * 0.5 if k == 0 else W * 1.5 + GAP
|
||||
if k == STRETCHES:
|
||||
zhi = _lane_z(k) + W * 0.5
|
||||
_cut(Vector3(x, y + H * 0.5, (zlo + zhi) * 0.5), Vector3(LAND, H, zhi - zlo), 0.0)
|
||||
# Headroom LAND_EXT on into the stretch going down from here (in its
|
||||
# lane only): a sloping cut's end is square to the slope, so on its
|
||||
# own it would leave a lip of rock across the top of it at head height.
|
||||
if k < dug:
|
||||
var side := signf(_landing_x(k + 1) - x)
|
||||
_cut(Vector3(x + side * (LAND + LAND_EXT) * 0.5, y + H * 0.5, _lane_z(k + 1)), Vector3(LAND_EXT, H, W), 0.0)
|
||||
# The stretches dug: sloping boxes from one landing down to the next.
|
||||
for k in range(1, dug + 1):
|
||||
var a := _mouth(k) - ORIGIN
|
||||
var s := signf(_landing_x(k) - _landing_x(k - 1))
|
||||
var b := Vector3(_landing_x(k) - s * LAND * 0.5, _floor_y(k), _lane_z(k))
|
||||
var d := b - a
|
||||
var length := Vector2(d.x, d.y).length()
|
||||
var ang := atan2(d.y, d.x)
|
||||
var up := Vector3(-sin(ang), cos(ang), 0.0)
|
||||
if up.y < 0.0:
|
||||
up = -up
|
||||
_cut((a + b) * 0.5 + up * H * 0.5, Vector3(length + 0.3, H, W), ang)
|
||||
# The sea cave, off the bottom landing.
|
||||
if dug >= STRETCHES:
|
||||
var c := bottom - ORIGIN + Vector3(-LAND * 0.5 - CAVE_LEN * 0.5, 0.0, 0.0)
|
||||
_cut(c + Vector3(0, H * 0.6, 0), Vector3(CAVE_LEN + 0.2, H * 1.2, W + 0.8), 0.0)
|
||||
|
||||
func _cut(center: Vector3, size: Vector3, z_rot: float) -> void:
|
||||
var b := CSGBox3D.new()
|
||||
b.operation = CSGShape3D.OPERATION_SUBTRACTION
|
||||
b.size = size
|
||||
b.position = center
|
||||
b.rotation = Vector3(0.0, 0.0, z_rot)
|
||||
b.material = _rock_mat
|
||||
_rock.add_child(b)
|
||||
|
||||
func _make_face(stage: int, center: Vector3, size: Vector3) -> StaticBody3D:
|
||||
var f: StaticBody3D = TunnelFace.new()
|
||||
f.name = "Face%d" % stage
|
||||
f.tunnel = self
|
||||
f.stage = stage
|
||||
f.uid = "tunnel_face_%d" % stage
|
||||
f.max_hp = SEAM_HP if stage == 0 else FACE_HP
|
||||
f.hp = clampf(face_hp, 0.5, f.max_hp)
|
||||
f.top_level = true
|
||||
f.collision_layer = 1
|
||||
f.add_to_group("harvestable")
|
||||
var cs := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = size
|
||||
cs.shape = box
|
||||
f.add_child(cs)
|
||||
var mi := MeshInstance3D.new()
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = size
|
||||
mi.mesh = bm
|
||||
mi.material_override = _face_mat
|
||||
f.add_child(mi)
|
||||
add_child(f)
|
||||
f.global_position = center
|
||||
return f
|
||||
|
||||
## The seam on the clifftop: an outcrop with a cracked face, then (dug) a
|
||||
## dark doorway into the cliff.
|
||||
func _build_seam() -> void:
|
||||
_seam = Node3D.new()
|
||||
_seam.name = "Seam"
|
||||
_seam.top_level = true
|
||||
add_child(_seam)
|
||||
var inland := -Vector3(seam_at.x, 0.0, seam_at.z).normalized()
|
||||
_seam.global_transform = Transform3D(Basis.looking_at(inland, Vector3.UP), seam_at)
|
||||
# (local -Z faces inland, towards the valley; the outcrop is behind, +Z)
|
||||
var body := StaticBody3D.new()
|
||||
_seam.add_child(body)
|
||||
for part in [[Vector3(-1.5, 1.2, 0.4), Vector3(1.0, 2.6, 2.0)], [Vector3(1.5, 1.1, 0.4), Vector3(1.0, 2.4, 2.0)],
|
||||
[Vector3(0, 2.6, 0.4), Vector3(4.0, 0.9, 2.2)], [Vector3(0, 1.1, 1.1), Vector3(2.2, 2.4, 0.6)]]:
|
||||
var mi := MeshInstance3D.new()
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = part[1]
|
||||
mi.mesh = bm
|
||||
mi.material_override = _rock_mat
|
||||
mi.position = part[0]
|
||||
_seam.add_child(mi)
|
||||
var cs := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = part[1]
|
||||
cs.shape = box
|
||||
cs.position = part[0]
|
||||
body.add_child(cs)
|
||||
if not opened:
|
||||
_face = _make_face(0, _seam.global_transform * Vector3(0, 1.1, -0.35), Vector3(2.0, 2.2, 0.5))
|
||||
_face.global_basis = _seam.global_basis
|
||||
else:
|
||||
var dark := MeshInstance3D.new()
|
||||
var q := QuadMesh.new()
|
||||
q.size = Vector2(2.0, 2.1)
|
||||
dark.mesh = q
|
||||
var m := StandardMaterial3D.new()
|
||||
m.albedo_color = Color(0.02, 0.02, 0.02)
|
||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
dark.material_override = m
|
||||
dark.position = Vector3(0, 1.05, 0.78)
|
||||
dark.rotation.y = PI # (a quad faces +Z: turn it to face inland)
|
||||
_seam.add_child(dark)
|
||||
var spot := _spot_body(Vector3(2.0, 2.1, 0.4), "in", 0)
|
||||
_seam.add_child(spot)
|
||||
spot.position = Vector3(0, 1.05, -0.2)
|
||||
|
||||
## A box you can look at and press E on (layer 2: you walk through it).
|
||||
func _spot_body(size: Vector3, what: String, k: int) -> StaticBody3D:
|
||||
var s := StaticBody3D.new()
|
||||
s.set_script(preload("res://scripts/world/tunnel_spot.gd"))
|
||||
s.tunnel = self
|
||||
s.what = what
|
||||
s.k = k
|
||||
s.collision_layer = 2
|
||||
s.collision_mask = 0
|
||||
var cs := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = size
|
||||
cs.shape = box
|
||||
s.add_child(cs)
|
||||
return s
|
||||
|
||||
func _add_spot(center: Vector3, size: Vector3, what: String, k: int) -> void:
|
||||
var s := _spot_body(size, what, k)
|
||||
_spots.add_child(s)
|
||||
s.global_position = center
|
||||
|
||||
## Two posts and a beam across the mouth of a stretch.
|
||||
func _add_prop_mesh(mouth: Vector3) -> void:
|
||||
for part in [[Vector3(0, H * 0.5 - 0.1, -W * 0.5 + 0.18), Vector3(0.22, H - 0.2, 0.22)],
|
||||
[Vector3(0, H * 0.5 - 0.1, W * 0.5 - 0.18), Vector3(0.22, H - 0.2, 0.22)],
|
||||
[Vector3(0, H - 0.25, 0), Vector3(0.26, 0.24, W)]]:
|
||||
var mi := MeshInstance3D.new()
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = part[1]
|
||||
mi.mesh = bm
|
||||
mi.material_override = _wood_mat
|
||||
_spots.add_child(mi)
|
||||
mi.global_position = mouth + part[0]
|
||||
|
||||
## The sea cave's mouth: grey daylight and the sound of the sea; walk into
|
||||
## it to go out onto the Cove's shore.
|
||||
func _add_mouth() -> void:
|
||||
var glow := MeshInstance3D.new()
|
||||
var q := QuadMesh.new()
|
||||
q.size = Vector2(W + 0.8, H * 1.2)
|
||||
glow.mesh = q
|
||||
var m := StandardMaterial3D.new()
|
||||
m.albedo_color = Color(0.75, 0.82, 0.88)
|
||||
m.emission_enabled = true
|
||||
m.emission = Color(0.6, 0.7, 0.78)
|
||||
m.emission_energy_multiplier = 1.6
|
||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
glow.material_override = m
|
||||
_spots.add_child(glow)
|
||||
glow.global_position = cave_mouth + Vector3(-0.55, H * 0.6, 0.0)
|
||||
glow.rotation.y = PI * 0.5
|
||||
var light := OmniLight3D.new()
|
||||
light.light_color = Color(0.7, 0.8, 0.9)
|
||||
light.light_energy = 1.2
|
||||
light.omni_range = 6.0
|
||||
_spots.add_child(light)
|
||||
light.global_position = cave_mouth + Vector3(0.8, H * 0.6, 0.0)
|
||||
var area := Area3D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 1 | 2 | 4 | 8 | 16 | 32
|
||||
var cs := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = Vector3(1.0, H, W + 0.6)
|
||||
cs.shape = box
|
||||
area.add_child(cs)
|
||||
_spots.add_child(area)
|
||||
area.global_position = cave_mouth + Vector3(0, H * 0.5, 0)
|
||||
area.body_entered.connect(func(b):
|
||||
if b == world.player:
|
||||
world.travel(to, "sea_cave"))
|
||||
_add_spot(cave_mouth + Vector3(1.2, H * 0.5, 0), Vector3(0.4, H, W), "cave", 0)
|
||||
1
scripts/world/tunnel.gd.uid
Normal file
1
scripts/world/tunnel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dxkd1tmf7rs5b
|
||||
44
scripts/world/tunnel_face.gd
Normal file
44
scripts/world/tunnel_face.gd
Normal file
@@ -0,0 +1,44 @@
|
||||
extends StaticBody3D
|
||||
## A rock face to dig through (J1): the seam on the clifftop, or the end of
|
||||
## the tunnel dug so far. It answers the same questions a boulder does
|
||||
## (Actions.harvest_hit), so a swing of the pick works on it, but the tunnel
|
||||
## (tunnel.gd) owns the rules: which pick, whether it's propped, and what
|
||||
## happens when it gives.
|
||||
|
||||
var tunnel: Node3D
|
||||
var stage := 0 ## 0 = the seam; k = the face at the start of stretch k
|
||||
var uid := ""
|
||||
var tool_kind := "pickaxe"
|
||||
var min_tier := 3 ## the antler pickaxe
|
||||
var max_hp := 45.0
|
||||
var hp := 45.0
|
||||
var depleted := false
|
||||
var kind := "rock"
|
||||
var chip_color := Color(0.42, 0.40, 0.38)
|
||||
|
||||
func display_name() -> String:
|
||||
return "Cracked seam" if stage == 0 else "Rock face"
|
||||
|
||||
## Why the next blow won't land ("" = it will), before the tool check.
|
||||
func refusal() -> String:
|
||||
return tunnel.dig_refusal(stage)
|
||||
|
||||
func interact_prompt(_player: Node) -> String:
|
||||
var why := refusal()
|
||||
var left := ceili(hp / 3.0)
|
||||
if not why.is_empty():
|
||||
return "%s: %s" % [display_name(), why]
|
||||
return "%s: [LMB] dig with the antler pickaxe (about %d blows left)" % [display_name(), left]
|
||||
|
||||
func interact(player: Node) -> void:
|
||||
Actions.tell(player, interact_prompt(player))
|
||||
|
||||
## One blow (from Actions.harvest_hit). Returns what it knocks loose.
|
||||
func hit(damage: float, _at: Vector3, _from: Vector3) -> Dictionary:
|
||||
hp = maxf(hp - damage, 0.0)
|
||||
tunnel.face_hit(self)
|
||||
if hp <= 0.0 and not depleted:
|
||||
depleted = true
|
||||
tunnel.face_broken(self)
|
||||
return {"stone": 3}
|
||||
return {"stone": 1} if randf() < 0.4 else {}
|
||||
1
scripts/world/tunnel_face.gd.uid
Normal file
1
scripts/world/tunnel_face.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ctu24jeo68pkx
|
||||
35
scripts/world/tunnel_spot.gd
Normal file
35
scripts/world/tunnel_spot.gd
Normal file
@@ -0,0 +1,35 @@
|
||||
extends StaticBody3D
|
||||
## Something to press E on in or by the cliff tunnel (tunnel.gd): the
|
||||
## seam's doorway ("in"), the way out at the top ("out"), the mouth of a
|
||||
## stretch that needs propping ("prop", stretch k), and the sea cave's
|
||||
## mouth ("cave"). On the interactables layer, so you walk through it.
|
||||
|
||||
var tunnel: Node3D
|
||||
var what := ""
|
||||
var k := 0
|
||||
|
||||
func interact_prompt(player: Node) -> String:
|
||||
match what:
|
||||
"in":
|
||||
return "[E] Go down into the tunnel"
|
||||
"out":
|
||||
return "[E] Climb out onto the clifftop"
|
||||
"prop":
|
||||
var have: int = player.inventory.count("wood")
|
||||
return "Unpropped: [E] Set a timber prop (%d wood, you have %d)" % [tunnel.PROP_WOOD, have]
|
||||
"cave":
|
||||
return "The sea cave: walk on out to the %s" % Terrain.worldlet_name(tunnel.to)
|
||||
return ""
|
||||
|
||||
func interact(player: Node) -> void:
|
||||
match what:
|
||||
"in":
|
||||
tunnel.go_in(player)
|
||||
"out":
|
||||
tunnel.go_out(player)
|
||||
"prop":
|
||||
tunnel.set_prop(player, k)
|
||||
"cave":
|
||||
tunnel.world.travel(tunnel.to, "sea_cave")
|
||||
|
||||
const Terrain = preload("res://scripts/world/terrain.gd")
|
||||
1
scripts/world/tunnel_spot.gd.uid
Normal file
1
scripts/world/tunnel_spot.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://wpqf817odym0
|
||||
@@ -54,6 +54,9 @@ 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
|
||||
@@ -362,6 +365,25 @@ func _on_terrain_ready() -> void:
|
||||
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()
|
||||
@@ -424,6 +446,7 @@ func _on_terrain_ready() -> void:
|
||||
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)
|
||||
@@ -652,6 +675,7 @@ func collect_save() -> Dictionary:
|
||||
"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:
|
||||
@@ -797,6 +821,8 @@ func _apply_save(s: Dictionary, with_player: bool) -> void:
|
||||
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:
|
||||
@@ -822,14 +848,82 @@ func _respawn() -> void:
|
||||
player.get_node("Camera3D").rotation.x = 0.0
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_current(delta)
|
||||
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.
|
||||
@@ -858,9 +952,10 @@ func placement_valid(def: Dictionary, xf: Transform3D) -> bool:
|
||||
if BuildPiece.is_build(def):
|
||||
return build.check(def, xf).is_empty()
|
||||
var p := xf.origin
|
||||
if not terrain.inside_boundary(p.x, p.z, 1.5):
|
||||
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 p.y < terrain.water_level + 0.15:
|
||||
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
|
||||
|
||||
@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
|
||||
echo "Importing the project first (fresh copy)..."
|
||||
godot --headless --path . --import >/dev/null 2>&1
|
||||
fi
|
||||
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_terrain_edges test_boundary; do
|
||||
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_tunnel test_terrain_edges test_boundary; do
|
||||
extra=""
|
||||
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
||||
# A suite that waits on a world that failed to load would hang forever,
|
||||
|
||||
259
tests/test_tunnel.gd
Normal file
259
tests/test_tunnel.gd
Normal file
@@ -0,0 +1,259 @@
|
||||
extends SceneTree
|
||||
## J1 (docs/WORLDLETS_DESIGN.md §10): the cliff tunnel, in single-player. Dig
|
||||
## the seam on the Valley clifftop with the antler pickaxe (a lesser pick
|
||||
## won't do), go down, dig six stretches (each propped before the next),
|
||||
## light it, walk down it, out through the sea cave to the Cove, and back up
|
||||
## the same tunnel to your chest at home. Each worldlet keeps its own save.
|
||||
## Run: godot --headless --path . --script res://tests/test_tunnel.gd
|
||||
|
||||
var _fails := 0
|
||||
var _finished := false
|
||||
var A: Node
|
||||
var S: Node
|
||||
var DB: Node
|
||||
var _toasts: Array[String] = []
|
||||
var _went := ""
|
||||
|
||||
func check(cond: bool, what: String) -> void:
|
||||
print((" ok " if cond else " FAIL ") + what)
|
||||
if not cond:
|
||||
_fails += 1
|
||||
|
||||
func _initialize() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
func _run() -> void:
|
||||
A = root.get_node("Actions")
|
||||
S = root.get_node("Saves")
|
||||
DB = root.get_node("ItemDB")
|
||||
A.notify.connect(func(t): _toasts.append(t))
|
||||
S.root_dir = "user://test_tunnel_worlds"
|
||||
for w in S.list_worlds():
|
||||
S.delete_world(w.id)
|
||||
await _body()
|
||||
check(_finished, "test ran to the end (no script errors)")
|
||||
for w in S.list_worlds():
|
||||
S.delete_world(w.id)
|
||||
DirAccess.remove_absolute(S.root_dir)
|
||||
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||
quit(1 if _fails > 0 else 0)
|
||||
|
||||
func _load_world() -> Node3D:
|
||||
var w: Node3D = load("res://scenes/world.tscn").instantiate()
|
||||
root.add_child(w)
|
||||
for _i in range(8):
|
||||
await physics_frame
|
||||
w.creatures.enabled = false
|
||||
w.weather.force("clear")
|
||||
w.player.survival.set_process(false)
|
||||
w.travelled.connect(func(to): _went = to)
|
||||
return w
|
||||
|
||||
func _drop_world(w: Node3D) -> void:
|
||||
w.queue_free()
|
||||
for _i in range(3):
|
||||
await process_frame
|
||||
|
||||
## Swings at `face` until it gives (or `limit` blows). Returns the blows.
|
||||
func _dig(w: Node3D, face: Node3D, limit: int = 40) -> int:
|
||||
var n := 0
|
||||
while is_instance_valid(face) and not face.depleted and n < limit:
|
||||
w.player.survival.stamina = 100.0
|
||||
w.player.global_position = face.global_position - _toward(w, face) * 1.6 - Vector3(0, 1.0, 0)
|
||||
if not A.harvest_hit(w.player, face, face.global_position):
|
||||
break
|
||||
n += 1
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
return n
|
||||
|
||||
func _toward(w: Node3D, face: Node3D) -> Vector3:
|
||||
var d: Vector3 = face.global_position - w.player.global_position
|
||||
d.y = 0.0
|
||||
return d.normalized() if d.length() > 0.01 else Vector3.FORWARD
|
||||
|
||||
func _hold(player: Node, id: String) -> void:
|
||||
var inv = player.inventory
|
||||
for i in range(inv.size()):
|
||||
var s = inv.get_slot(i)
|
||||
if s != null and s.id == id:
|
||||
var tmp = inv.get_slot(0)
|
||||
inv.set_slot(0, s)
|
||||
inv.set_slot(i, tmp)
|
||||
break
|
||||
player.select(0)
|
||||
|
||||
func _body() -> void:
|
||||
S.current_id = S.create_world("Journey", 1337)
|
||||
var w: Node3D = await _load_world()
|
||||
var t: Node3D = w.tunnel
|
||||
var player: CharacterBody3D = w.player
|
||||
print("the seam")
|
||||
check(t != null, "the Valley has a cliff tunnel to dig (to the %s)" % (t.to if t else "-"))
|
||||
if t == null:
|
||||
return
|
||||
var ter: Node3D = w.terrain
|
||||
check(ter.inside_boundary(t.seam_at.x, t.seam_at.z, 1.0) and not ter.inside_boundary(t.seam_at.x, t.seam_at.z, 14.0)
|
||||
and ter.sea_weight(t.seam_at.x, t.seam_at.z) > 0.5, "a seam on the clifftop, near the edge")
|
||||
var seam: Node3D = t._face
|
||||
check(seam != null and seam.stage == 0, "a cracked face to dig")
|
||||
# Your hut: a chest by the start, to come back to.
|
||||
var chest: Node3D = w.spawn_piece(DB.get_item("chest"), Transform3D(Basis(), w._spawn + Vector3(3, 0, 0)))
|
||||
var chest_at: Vector3 = chest.global_position
|
||||
player.set_physics_process(false)
|
||||
player.inventory.add("stone_pickaxe", 1)
|
||||
_hold(player, "stone_pickaxe")
|
||||
_toasts.clear()
|
||||
await _dig(w, seam, 3)
|
||||
check(not t.opened and _toasts.has("Too hard: you need a better pick"), "a stone pick won't touch it (%s)" % [_toasts])
|
||||
player.inventory.add("antler_pickaxe", 1)
|
||||
_hold(player, "antler_pickaxe")
|
||||
var blows := await _dig(w, seam)
|
||||
check(t.opened, "the antler pickaxe opens it (%d blows)" % blows)
|
||||
check(player.inventory.count("stone") > 0, "(and knocks loose some stone)")
|
||||
|
||||
print("down into the dark")
|
||||
t.go_in(player)
|
||||
for _i in range(40):
|
||||
await physics_frame
|
||||
check(w.underground_at(player.global_position), "inside the tunnel")
|
||||
check(w.day_night.underground > 0.95 and w.day_night.env.ambient_light_energy < 0.05 and not w.day_night.sun.visible,
|
||||
"pitch dark: no sun, no sky (ambient %.3f)" % w.day_night.env.ambient_light_energy)
|
||||
player.survival.step(0.3)
|
||||
check(player.survival.roofed and player.survival.sheltered, "and out of the weather")
|
||||
check(player.water_level == -INF, "(and no lake water down here, though it's far below the lake)")
|
||||
|
||||
print("digging down")
|
||||
var face1: Node3D = t._face
|
||||
check(face1 != null and face1.stage == 1, "the first face, at the top")
|
||||
blows = await _dig(w, face1)
|
||||
check(t.dug == 1, "dug the first stretch (%d blows)" % blows)
|
||||
var face2: Node3D = t._face
|
||||
_toasts.clear()
|
||||
await _dig(w, face2, 2)
|
||||
check(t.dug == 1 and _toasts.any(func(s): return s.begins_with("It's loose")), "the next won't take a blow until it's propped")
|
||||
player.inventory.remove("wood", player.inventory.count("wood"))
|
||||
check(not t.set_prop(player, 1), "a prop needs wood")
|
||||
player.inventory.add("wood", 20)
|
||||
var wood0: int = player.inventory.count("wood")
|
||||
check(t.set_prop(player, 1) and t.props[1], "set a timber prop (2 wood)")
|
||||
await process_frame
|
||||
for k in range(2, t.STRETCHES + 1):
|
||||
await _dig(w, t._face)
|
||||
if k < t.STRETCHES:
|
||||
t.set_prop(player, k)
|
||||
await process_frame
|
||||
check(t.dug == t.STRETCHES, "dug all %d stretches down" % t.STRETCHES)
|
||||
check(wood0 - player.inventory.count("wood") == 2 * (t.STRETCHES - 1), "propping it all took %d wood" % (wood0 - player.inventory.count("wood")))
|
||||
check(t.bottom.y < t.top.y - 20.0, "%.0f m down" % (t.top.y - t.bottom.y))
|
||||
check(_toasts.any(func(s): return s.begins_with("You break through into a sea cave")), "and broke through into a sea cave")
|
||||
for _i in range(4):
|
||||
await physics_frame
|
||||
|
||||
print("walking it")
|
||||
player.set_physics_process(true)
|
||||
var stuck := ""
|
||||
for k in range(1, t.STRETCHES + 1):
|
||||
var m: Vector3 = t._mouth(k) + Vector3(0, 0.2, 0)
|
||||
var dir := signf(t._landing_x(k) - t._landing_x(k - 1))
|
||||
player.global_position = m - Vector3(dir * 0.6, 0, 0)
|
||||
player.velocity = Vector3.ZERO
|
||||
player.rotation = Vector3(0.0, atan2(-dir, 0.0), 0.0)
|
||||
Input.action_press("move_forward")
|
||||
for _i in range(int(2.6 * Engine.physics_ticks_per_second)):
|
||||
await physics_frame
|
||||
Input.action_release("move_forward")
|
||||
var p: Vector3 = player.global_position
|
||||
var want_y: float = t.ORIGIN.y + t._floor_y(k)
|
||||
if absf(p.y - want_y) > 0.6 or not w.underground_at(p):
|
||||
stuck += " stretch %d ended at (%.1f, %.1f, %.1f), floor %.1f;" % [k, p.x, p.y, p.z, want_y]
|
||||
check(stuck.is_empty(), "walk down every stretch to the landing below%s" % stuck)
|
||||
stuck = ""
|
||||
for k in range(t.STRETCHES, 0, -1):
|
||||
var dir := signf(t._landing_x(k - 1) - t._landing_x(k)) # (back up: towards the landing above)
|
||||
var foot: Vector3 = t.ORIGIN + Vector3(t._landing_x(k) + dir * (t.LAND * 0.5 - 0.6), t._floor_y(k) + 0.2, t._lane_z(k))
|
||||
player.global_position = foot
|
||||
player.velocity = Vector3.ZERO
|
||||
player.rotation = Vector3(0.0, atan2(-dir, 0.0), 0.0)
|
||||
Input.action_press("move_forward")
|
||||
for _i in range(int(3.4 * Engine.physics_ticks_per_second)):
|
||||
await physics_frame
|
||||
Input.action_release("move_forward")
|
||||
var p: Vector3 = player.global_position
|
||||
var want_y: float = t.ORIGIN.y + t._floor_y(k - 1)
|
||||
if absf(p.y - want_y) > 0.6:
|
||||
stuck += " stretch %d ended at (%.1f, %.1f, %.1f), floor %.1f;" % [k, p.x, p.y, p.z, want_y]
|
||||
check(stuck.is_empty(), "and back up every stretch to the landing above%s" % stuck)
|
||||
|
||||
print("light")
|
||||
var floor_at: Vector3 = t._mouth(3) + Vector3(0.0, 0.0, 0.6)
|
||||
floor_at.x += 0.8 * signf(t._landing_x(2) - t._landing_x(3))
|
||||
var xf := Transform3D(Basis(), floor_at)
|
||||
check(w.placement_valid(DB.get_item("torch_stand"), xf), "a torch stand can stand on the tunnel floor")
|
||||
player.set_physics_process(false)
|
||||
player.global_position = floor_at + Vector3(1.0, 0, 0)
|
||||
player.inventory.add("torch_stand", 1)
|
||||
var slot := -1
|
||||
for i in range(player.inventory.size()):
|
||||
var s = player.inventory.get_slot(i)
|
||||
if s != null and s.id == "torch_stand":
|
||||
slot = i
|
||||
check(A.place(player, slot, xf, w), "set one down")
|
||||
var lights: int = w.all_pieces().filter(func(p): return p.item_id == "torch_stand").size()
|
||||
check(lights == 1, "(it's a piece like any other, so it stays: %d)" % lights)
|
||||
|
||||
print("saved with the Valley")
|
||||
w.save_now()
|
||||
await _drop_world(w)
|
||||
w = await _load_world()
|
||||
t = w.tunnel
|
||||
check(t.opened and t.dug == t.STRETCHES and t.props.count(true) == t.STRETCHES - 1, "the tunnel, dug and propped, after a reload")
|
||||
check(w.all_pieces().any(func(p): return p.item_id == "torch_stand" and w.underground_at(p.global_position)), "the torch stand still down there")
|
||||
player = w.player
|
||||
|
||||
print("out to the Cove")
|
||||
var stone0: int = player.inventory.count("stone")
|
||||
var day0: int = w.day_night.day
|
||||
player.set_physics_process(true)
|
||||
player.global_position = t.cave_mouth + Vector3(4.0, 0.2, 0.0)
|
||||
player.rotation = Vector3(0.0, atan2(1.0, 0.0), 0.0) # (facing -X, out of the cave)
|
||||
_went = ""
|
||||
Input.action_press("move_forward")
|
||||
for _i in range(int(3.0 * Engine.physics_ticks_per_second)):
|
||||
await physics_frame
|
||||
if not _went.is_empty():
|
||||
break
|
||||
Input.action_release("move_forward")
|
||||
check(_went == "cove", "walking out of the sea cave's mouth goes on to the Cove")
|
||||
check(S.worldlet_of(S.current_id) == "cove" and S.has_state(S.current_id, "cove"), "(the save is in the Cove now, with a file of its own)")
|
||||
check(S.has_state(S.current_id, "valley"), "(and the Valley's is kept)")
|
||||
await _drop_world(w)
|
||||
w = await _load_world()
|
||||
player = w.player
|
||||
check(w.worldlet_id() == "cove", "the Cove")
|
||||
check(w.sea_cave != null and player.global_position.distance_to(w.sea_cave.front) < 1.5, "arriving at the sea cave on its shore")
|
||||
check(player.inventory.count("stone") == stone0 and player.inventory.count("antler_pickaxe") == 1, "with your bag as it was")
|
||||
check(w.day_night.day == day0, "on the same day")
|
||||
check(w.tunnel == null, "(the Cove has no tunnel of its own: it's the Valley's)")
|
||||
|
||||
print("back up home")
|
||||
check(w.sea_cave.open(), "the sea cave leads back up (the tunnel's dug)")
|
||||
_went = ""
|
||||
w.sea_cave.interact(player)
|
||||
check(_went == "valley", "into the cave: back to the Valley")
|
||||
await _drop_world(w)
|
||||
w = await _load_world()
|
||||
t = w.tunnel
|
||||
player = w.player
|
||||
check(w.worldlet_id() == "valley" and w.underground_at(player.global_position) and player.global_position.distance_to(t.bottom) < 2.0,
|
||||
"arriving at the bottom of the tunnel")
|
||||
t.go_out(player)
|
||||
await physics_frame
|
||||
check(not w.underground_at(player.global_position) and player.global_position.distance_to(t.seam_out) < 1.0, "climb out at the top, on the clifftop")
|
||||
check(w.all_pieces().any(func(p): return p.item_id == "chest" and p.global_position.distance_to(chest_at) < 0.1), "and your chest is waiting at home")
|
||||
await _drop_world(w)
|
||||
|
||||
print("a world that never left home")
|
||||
var id2: String = S.create_world("Homebody", 42)
|
||||
check(S.state_path(id2, "valley").ends_with("/world.json"), "keeps its world.json (older saves just work)")
|
||||
_finished = true
|
||||
1
tests/test_tunnel.gd.uid
Normal file
1
tests/test_tunnel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dgyh7ihkmdkxl
|
||||
@@ -52,14 +52,14 @@ func _body(S: Node, Net: Node) -> void:
|
||||
for _i in range(3):
|
||||
await process_frame
|
||||
var release: Array = TS.offered_worldlets(false)
|
||||
check(release.size() == 1 and String(release[0].id) == "valley", "a release build offers only the Valley")
|
||||
check(release.size() == 1 and String(release[0].id) == "valley", "normally only the Valley is offered")
|
||||
var dev: Array = TS.offered_worldlets(true)
|
||||
check(String(dev[0].id) == "valley" and dev.any(func(w): return w.id == "cove" and String(w.name) == "Cove (testing)"),
|
||||
"running from the source, the Cove too, marked for testing (%s)" % [dev.map(func(w): return w.name)])
|
||||
"with --dev-worldlets, the Cove too, marked for testing (%s)" % [dev.map(func(w): return w.name)])
|
||||
check(not TS.dev_worldlets(), "(this test runs without --dev-worldlets)")
|
||||
var pick: OptionButton = title._worldlet_pick
|
||||
check(pick != null and pick.item_count == dev.size() and pick.get_item_text(0) == "Valley", "a \"Where\" picker, on the Valley first")
|
||||
check(not pick.disabled, "(with more than one to choose from)")
|
||||
check(TS.picked_worldlet(pick) == "valley", "and gives the Valley")
|
||||
check(pick == null, "New World has no \"Where\": the journey always begins in the Valley")
|
||||
check(TS.picked_worldlet(pick) == "valley", "so a new world is the Valley")
|
||||
var id: String = S.create_world("Pick test", 4242, {"worldlet": TS.picked_worldlet(pick)})
|
||||
var meta: Dictionary = S.list_worlds().filter(func(w): return w.id == id)[0] if not id.is_empty() else {}
|
||||
check(String(meta.get("worldlet", "")) == "valley", "a world made from it remembers the Valley (%s)" % meta.get("worldlet", "-"))
|
||||
@@ -74,7 +74,7 @@ func _body(S: Node, Net: Node) -> void:
|
||||
check(hp != null and hp.get_item_text(hp.selected) == "Valley", "a \"Where\" picker too, on the Valley")
|
||||
title._show("host")
|
||||
await process_frame
|
||||
check(not hp.disabled and TS.picked_worldlet(hp) == "valley", "a new island: the Valley to start with, free to change")
|
||||
check(hp.disabled and TS.picked_worldlet(hp) == "valley", "a new island: the Valley (the only start; One land mode will widen it)")
|
||||
# Once the island exists it keeps its worldlet: the picker shows that one.
|
||||
DirAccess.make_dir_recursive_absolute(DIR + "/server/worlds/server-world")
|
||||
var f := FileAccess.open(DIR + "/server/worlds/server-world/meta.json", FileAccess.WRITE)
|
||||
|
||||
Reference in New Issue
Block a user