W3: the Cove (bay, pond, pier for sea fishing), mackerel and cod (cod only in deep water), non-start worldlets offered for testing when run from source

This commit is contained in:
Paul Bailey
2026-11-06 17:08:42 +00:00
parent 9b88a10609
commit 793b214786
21 changed files with 527 additions and 14 deletions

View File

@@ -1,5 +1,5 @@
{ {
"_comment": "Fishing by water type. world.water_at() says which type a spot is; a type with no entry has no fish. Weights are relative. 'dawn_dusk' replaces the weight at dawn (05:00-08:30) and dusk (17:00-20:30). Bite waits are seconds, scaled by the modifiers.", "_comment": "Fishing by water type. world.water_at() says which type a spot is; a type with no entry has no fish. Weights are relative. 'dawn_dusk' replaces the weight at dawn (05:00-08:30) and dusk (17:00-20:30). Bite waits are seconds, scaled by the modifiers. A fish with its own 'min_depth' only bites in water at least that deep (cod: cast well out from the pier).",
"modifiers": { "modifiers": {
"dawn_dusk_wait": 0.6, "dawn_dusk_wait": 0.6,
"rain_wait": 0.8, "rain_wait": 0.8,
@@ -24,6 +24,26 @@
"dawn_dusk": 40 "dawn_dusk": 40
} }
] ]
},
"sea": {
"min_depth": 1.0,
"bite_wait": [
6.0,
16.0
],
"fish": [
{
"id": "mackerel",
"weight": 75,
"dawn_dusk": 65
},
{
"id": "cod",
"weight": 25,
"dawn_dusk": 35,
"min_depth": 2.2
}
]
} }
} }
} }

View File

@@ -543,6 +543,72 @@
}, },
"keeps": 4 "keeps": 4
}, },
{
"id": "mackerel",
"name": "Mackerel",
"category": "food",
"stack": 20,
"description": "A quick, striped sea fish, caught off the pier. Grill it at a campfire. (Fish)",
"mesh": "mackerel",
"food": {
"hunger": 5,
"health": -2,
"diet": {
"fish": 10
},
"risk": 0.2
},
"keeps": 2
},
{
"id": "cod",
"name": "Cod",
"category": "food",
"stack": 20,
"description": "A big sea fish from deeper water: cast well out. (Fish)",
"mesh": "cod",
"food": {
"hunger": 8,
"health": -2,
"diet": {
"fish": 16
},
"risk": 0.2
},
"keeps": 2
},
{
"id": "grilled_mackerel",
"name": "Grilled Mackerel",
"category": "food",
"stack": 20,
"description": "Oily and good. (Fish)",
"mesh": "grilled_mackerel",
"food": {
"hunger": 22,
"health": 8,
"diet": {
"fish": 36
}
},
"keeps": 4
},
{
"id": "grilled_cod",
"name": "Grilled Cod",
"category": "food",
"stack": 20,
"description": "Thick white flakes: a proper meal. (Fish)",
"mesh": "grilled_cod",
"food": {
"hunger": 32,
"health": 12,
"diet": {
"fish": 52
}
},
"keeps": 4
},
{ {
"id": "iron_nugget", "id": "iron_nugget",
"name": "Iron Nugget", "name": "Iron Nugget",

View File

@@ -249,6 +249,26 @@
"trout": 1 "trout": 1
} }
}, },
{
"id": "grilled_mackerel",
"output": "grilled_mackerel",
"count": 1,
"station": "campfire",
"category": "cooking",
"inputs": {
"mackerel": 1
}
},
{
"id": "grilled_cod",
"output": "grilled_cod",
"count": 1,
"station": "campfire",
"category": "cooking",
"inputs": {
"cod": 1
}
},
{ {
"id": "charcoal_kiln", "id": "charcoal_kiln",
"output": "charcoal_kiln", "output": "charcoal_kiln",
@@ -578,6 +598,28 @@
"salt": 1 "salt": 1
} }
}, },
{
"id": "salted_mackerel",
"output": "salted_fish",
"count": 2,
"station": "",
"category": "cooking",
"inputs": {
"mackerel": 2,
"salt": 1
}
},
{
"id": "salted_cod",
"output": "salted_fish",
"count": 2,
"station": "",
"category": "cooking",
"inputs": {
"cod": 1,
"salt": 1
}
},
{ {
"id": "antler_pickaxe", "id": "antler_pickaxe",
"output": "antler_pickaxe", "output": "antler_pickaxe",

22
data/worldlets/cove.json Normal file
View File

@@ -0,0 +1,22 @@
{
"_doc": "The Cove (docs/WORLDLETS_DESIGN.md §5, W3): the second worldlet, reached from the Valley down the cliff tunnel (J1). Low hills run down to a wide sandy bay with a pier (scripts/world/pier.gd) for sea fishing; a small pond inland; mountains behind. Not a start: you get here on the journey.",
"id": "cove",
"name": "Cove",
"description": "Low hills running down to a sheltered sandy bay, with a pier out over the water for sea fishing.",
"start": false,
"size": 320,
"resolution": 256,
"terrain": [
{"type": "hills", "height_scale": 5.5, "detail": 1.2},
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 52.0, "rim_warp": 14.0, "wall_frac": 0.45},
{"type": "beach", "angle_deg": 90.0, "half_width_deg": 55.0, "fade_deg": 22.0, "shore_offset": -16.0, "slope": 0.08, "current_out": 22.0, "wall_out": 36.0},
{"type": "lake", "center": [-30.0, -40.0], "radius": 11.0, "water_level": -2.6},
{"type": "ocean_edge"}
],
"climate": {"snowline": 42.0},
"places": [{"type": "pier", "at": "beach"}],
"next": [
{"to": "valley", "by": "tunnel", "where": "sea_cave"},
{"to": "highlands", "by": "sail", "where": "pier"}
]
}

View File

@@ -1,9 +1,33 @@
# Worldlets design # Worldlets design
Status: **W0, W1 and W2 built**; the rest is planned. The journey between Status: **W0-W3 built** (the Cove, without its stream yet); the rest is
planned. The journey between
worldlets is §10, and the two ways to play (the journey, or one land) are in worldlets is §10, and the two ways to play (the journey, or one land) are in
its decisions. its decisions.
*As built in W3:*
- `data/worldlets/cove.json`: low hills, mountains behind, a wide sandy
bay (a `beach` block) and a small pond. Not a start; its `next` is back
to the Valley (tunnel, J1) and on to the Highlands (sail).
- The pier (`scripts/world/pier.gd`), a world "place" (`"places":
[{"type": "pier"}]`): planks on posts from where the sand is at deck
height to 24 m past the waterline. Worked out from the terrain, so it's
the same for everyone and never saved or dismantled; scenery keeps clear
of it. ~1.9 m of sea at its end, ~2.7 m a cast beyond.
- 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.
- 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
`test_boundary`.
- Still to do: the stream (it needs water that runs downhill, which the
one flat water surface can't do: its own ribbon of water, planned with
the Cove's sea cave when the tunnel arrives).
*As built in W2:* *As built in W2:*
- `lake` blocks can repeat: every lake and pond is its own body - `lake` blocks can repeat: every lake and pond is its own body
(`terrain.lakes`, `world.water_bodies()`), all at one surface (`terrain.lakes`, `world.water_bodies()`), all at one surface

View File

@@ -23,6 +23,8 @@ const RECIPES := {
{"inputs": {"raw_meat": 2, "wood": 1}, "output": "smoked_meat", "count": 2, "seconds": 60.0}, {"inputs": {"raw_meat": 2, "wood": 1}, "output": "smoked_meat", "count": 2, "seconds": 60.0},
{"inputs": {"perch": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0}, {"inputs": {"perch": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
{"inputs": {"trout": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0}, {"inputs": {"trout": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
{"inputs": {"mackerel": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
{"inputs": {"cod": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
], ],
} }
## What the prompt says it makes, and what it needs when idle. ## What the prompt says it makes, and what it needs when idle.

View File

@@ -92,7 +92,8 @@ func define_water(water: String, d: Dictionary) -> void:
if not items.has(f.get("id", "")): if not items.has(f.get("id", "")):
push_error("ItemDB: fish '%s' in water '%s' is not an item" % [f.get("id", "?"), water]) push_error("ItemDB: fish '%s' in water '%s' is not an item" % [f.get("id", "?"), water])
continue continue
fish.append({"id": f.id, "weight": float(f.get("weight", 1)), "dawn_dusk": float(f.get("dawn_dusk", f.get("weight", 1)))}) fish.append({"id": f.id, "weight": float(f.get("weight", 1)), "dawn_dusk": float(f.get("dawn_dusk", f.get("weight", 1))),
"min_depth": float(f.get("min_depth", 0.0))}) # (a fish of deeper water: cod)
var w: Array = d.get("bite_wait", [5.0, 14.0]) var w: Array = d.get("bite_wait", [5.0, 14.0])
fishing.waters[water] = {"min_depth": float(d.get("min_depth", 0.5)), "bite_wait": [float(w[0]), float(w[1])], "fish": fish} fishing.waters[water] = {"min_depth": float(d.get("min_depth", 0.5)), "bite_wait": [float(w[0]), float(w[1])], "fish": fish}

View File

@@ -15,6 +15,7 @@ const BITE_WINDOW := 1.5
const LINE_LENGTH := 26.0 const LINE_LENGTH := 26.0
var water_type := "" ## "lake", "sea"... (world.water_at) var water_type := "" ## "lake", "sea"... (world.water_at)
var water_depth := 0.0 ## how deep it is where the float came down
var table: Dictionary = {} ## that water's fishing table (ItemDB.water_fishing) var table: Dictionary = {} ## that water's fishing table (ItemDB.water_fishing)
var world: Node3D var world: Node3D
@@ -91,8 +92,9 @@ func _next_wait() -> float:
return rng.randf_range(w[0], w[1]) * k return rng.randf_range(w[0], w[1]) * k
## A weighted pick from this water's fish (dawn/dusk weights when they apply). ## A weighted pick from this water's fish (dawn/dusk weights when they apply).
## (A fish with its own "min_depth" only bites in water that deep: cod.)
func _pick_fish() -> String: func _pick_fish() -> String:
var fish: Array = table.get("fish", []) var fish: Array = table.get("fish", []).filter(func(f): return water_depth >= float(f.get("min_depth", 0.0)))
var key := "dawn_dusk" if _dawn_dusk() else "weight" var key := "dawn_dusk" if _dawn_dusk() else "weight"
var total := 0.0 var total := 0.0
for f in fish: for f in fish:
@@ -120,6 +122,7 @@ func step(dt: float) -> void:
if not water.is_empty() and p.y <= float(water.surface): if not water.is_empty() and p.y <= float(water.surface):
p.y = water.surface p.y = water.surface
water_type = water.type water_type = water.type
water_depth = float(water.depth)
table = ItemDB.water_fishing(water_type) table = ItemDB.water_fishing(water_type)
if table.is_empty() or table.fish.is_empty(): if table.is_empty() or table.fish.is_empty():
state = "landed" state = "landed"

View File

@@ -75,6 +75,10 @@ static func build(key: String) -> ArrayMesh:
"trout": m = _fish(TROUT, 0.24, false) "trout": m = _fish(TROUT, 0.24, false)
"grilled_perch": m = _fish(PERCH, 0.16, true) "grilled_perch": m = _fish(PERCH, 0.16, true)
"grilled_trout": m = _fish(TROUT, 0.24, true) "grilled_trout": m = _fish(TROUT, 0.24, true)
"mackerel": m = _fish(MACKEREL, 0.2, false)
"cod": m = _fish(COD, 0.3, false)
"grilled_mackerel": m = _fish(MACKEREL, 0.2, true)
"grilled_cod": m = _fish(COD, 0.3, true)
"fiber_plant": m = _fiber_plant() "fiber_plant": m = _fiber_plant()
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6) "iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
"iron_ore": m = _ore() "iron_ore": m = _ore()
@@ -694,6 +698,8 @@ static func _cloak(col: Color = HIDE) -> ArrayMesh:
const PERCH := Color(0.45, 0.52, 0.30) const PERCH := Color(0.45, 0.52, 0.30)
const TROUT := Color(0.50, 0.55, 0.58) const TROUT := Color(0.50, 0.55, 0.58)
const MACKEREL := Color(0.22, 0.42, 0.48)
const COD := Color(0.56, 0.52, 0.40)
const GRILLED := Color(0.55, 0.36, 0.18) const GRILLED := Color(0.55, 0.36, 0.18)
const FLOAT_RED := Color(0.85, 0.15, 0.12) const FLOAT_RED := Color(0.85, 0.15, 0.12)

View File

@@ -698,7 +698,7 @@ var _host_worldlet: OptionButton
## description under it. (Only the Valley today: the others are reached on ## description under it. (Only the Valley today: the others are reached on
## the journey, so the list is short and the picker sits still.) ## the journey, so the list is short and the picker sits still.)
func _worldlet_picker(v: VBoxContainer) -> OptionButton: func _worldlet_picker(v: VBoxContainer) -> OptionButton:
var starts: Array = Terrain.starting_worldlets() var starts: Array = offered_worldlets(OS.is_debug_build())
v.add_child(UITheme.label("Where", 15, UITheme.TEXT_DIM)) v.add_child(UITheme.label("Where", 15, UITheme.TEXT_DIM))
var pick := OptionButton.new() var pick := OptionButton.new()
pick.custom_minimum_size = Vector2(360, 36) pick.custom_minimum_size = Vector2(360, 36)
@@ -716,6 +716,19 @@ func _worldlet_picker(v: VBoxContainer) -> OptionButton:
about.text = String(Terrain.load_worldlet(String(pick.get_item_metadata(i))).get("description", ""))) about.text = String(Terrain.load_worldlet(String(pick.get_item_metadata(i))).get("description", "")))
return pick 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:
var out: Array = Terrain.starting_worldlets()
if debug:
for w in Terrain.worldlets():
if not w.start:
var t: Dictionary = w.duplicate()
t.name = "%s (testing)" % w.name
out.append(t)
return out
## The Host page's "Where": a hosted island keeps its worldlet, like its ## The Host page's "Where": a hosted island keeps its worldlet, like its
## seed, so once it exists the picker shows that one and stays put. ## seed, so once it exists the picker shows that one and stays put.
func _show_host_worldlet(existing: bool, meta_path: String) -> void: func _show_host_worldlet(existing: bool, meta_path: String) -> void:

114
scripts/world/pier.gd Normal file
View File

@@ -0,0 +1,114 @@
extends Node3D
## The Cove's pier (W3, docs/WORLDLETS_DESIGN.md §5): a wooden walkway from
## the sand straight out over the bay, so a cast from its end reaches deep
## sea water (mackerel, and cod further out). Part of the world, like the
## barrow: worked out from the terrain alone, so every player and the server
## get the same one, and it's never saved, built or dismantled.
##
## It starts where the beach has risen to deck height (so you walk straight
## on) and runs `LENGTH_OUT` metres past the waterline.
const WIDTH := 2.4
const DECK_ABOVE := 1.0 ## deck height over the water
const LENGTH_OUT := 24.0 ## how far past the waterline it reaches
const POST_EVERY := 3.0
const WOOD := Color(0.42, 0.30, 0.19)
const POST := Color(0.30, 0.22, 0.15)
var start := Vector3.ZERO ## the landward end, on the sand
var end := Vector3.ZERO ## the seaward end (fish from here)
var dir := Vector3.ZERO ## out to sea along the deck
## Lays the pier off the terrain's beach. Returns false (and adds nothing)
## when the worldlet has no beach.
func place(terrain: Node3D) -> bool:
if not terrain._has_beach or not terrain.worldlet.get("places", []).any(func(p): return p is Dictionary and p.get("type", "") == "pier"):
return false
var deck_y: float = terrain.water_level + DECK_ABOVE
var out2: Vector2 = terrain.shore_point(LENGTH_OUT)
var root2: Vector2 = terrain.shore_point(-2.0)
var d2 := (out2 - root2).normalized()
# Walk inland from the waterline to where the sand is at deck height.
var s2 := root2
for _i in range(40):
if terrain.sample_height(s2.x, s2.y) >= deck_y - 0.15:
break
s2 -= d2 * 0.5
start = Vector3(s2.x, deck_y, s2.y)
end = Vector3(out2.x, deck_y, out2.y)
dir = (end - start).normalized()
_build(terrain)
return true
## Whether (x, z) is under or right beside the pier (scenery keeps clear).
func covers(x: float, z: float, margin: float = 1.0) -> bool:
var a := Vector2(start.x, start.z)
var b := Vector2(end.x, end.z)
var p := Vector2(x, z)
var t := clampf((p - a).dot(b - a) / maxf((b - a).length_squared(), 0.001), 0.0, 1.0)
return p.distance_to(a.lerp(b, t)) < WIDTH * 0.5 + margin
func _build(terrain: Node3D) -> void:
var length := start.distance_to(end)
var yaw := atan2(dir.x, dir.z)
var basis := Basis(Vector3.UP, yaw)
var mid := (start + end) * 0.5
# The deck: planks across it, with a little gap and wobble between them.
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var n := int(length / 0.32)
var rng := RandomNumberGenerator.new()
rng.seed = 7171
for i in range(n):
var z := -length * 0.5 + (i + 0.5) * length / n
var h := 0.08 + rng.randf_range(-0.01, 0.01)
_box(st, Vector3(rng.randf_range(-0.04, 0.04), -h * 0.5, z), Vector3(WIDTH, h, length / n - 0.03),
WOOD.darkened(rng.randf_range(0.0, 0.18)))
# Posts down to the bed, in pairs, and a rail beam along each side.
var k := 0.0
while k <= length + 0.01:
var along := -length * 0.5 + k
for side in [-1.0, 1.0]:
var local := Vector3(side * (WIDTH * 0.5 - 0.1), 0.0, along)
var world_p: Vector3 = mid + basis * local
var bed: float = terrain.sample_height(world_p.x, world_p.z)
var tall := start.y - bed + 0.3
if tall > 0.2:
_box(st, local + Vector3(0, -tall * 0.5 - 0.05, 0), Vector3(0.22, tall, 0.22), POST)
k += POST_EVERY
for side in [-1.0, 1.0]:
_box(st, Vector3(side * (WIDTH * 0.5 - 0.06), -0.16, 0.0), Vector3(0.12, 0.14, length), POST)
st.generate_normals()
var mi := MeshInstance3D.new()
mi.name = "Deck"
mi.mesh = st.commit()
var mat := StandardMaterial3D.new()
mat.vertex_color_use_as_albedo = true
mat.roughness = 0.9
mi.material_override = mat
add_child(mi)
# Walkable deck (on the ground layer, like the terrain).
var body := StaticBody3D.new()
body.name = "DeckBody"
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(WIDTH, 0.12, length)
cs.shape = box
cs.position = Vector3(0, -0.06, 0)
body.add_child(cs)
add_child(body)
transform = Transform3D(basis, mid)
add_to_group("pier")
static func _box(st: SurfaceTool, c: Vector3, s: Vector3, col: Color) -> void:
var h := s * 0.5
var v := [
c + Vector3(-h.x, -h.y, -h.z), c + Vector3(h.x, -h.y, -h.z), c + Vector3(h.x, h.y, -h.z), c + Vector3(-h.x, h.y, -h.z),
c + Vector3(-h.x, -h.y, h.z), c + Vector3(h.x, -h.y, h.z), c + Vector3(h.x, h.y, h.z), c + Vector3(-h.x, h.y, h.z),
]
var faces := [[0, 3, 2, 1], [4, 5, 6, 7], [0, 4, 7, 3], [1, 2, 6, 5], [3, 7, 6, 2], [0, 1, 5, 4]]
st.set_color(col)
for f in faces:
# (listed anticlockwise from outside; Godot's front faces are clockwise)
st.add_vertex(v[f[0]]); st.add_vertex(v[f[2]]); st.add_vertex(v[f[1]])
st.add_vertex(v[f[0]]); st.add_vertex(v[f[3]]); st.add_vertex(v[f[2]])

View File

@@ -0,0 +1 @@
uid://ps0h2qmbn5ms

View File

@@ -31,6 +31,7 @@ const FLOWER_COLORS := [
@export var grass_tiles_per_update: int = 6 @export var grass_tiles_per_update: int = 6
var terrain: Node3D var terrain: Node3D
var pier: Node3D ## the Cove's pier, kept clear of scenery (set by the world)
var _player: Node3D var _player: Node3D
var _spawn := Vector3.ZERO var _spawn := Vector3.ZERO
var _forest_noise := FastNoiseLite.new() var _forest_noise := FastNoiseLite.new()
@@ -417,7 +418,10 @@ func _scatter_trees() -> void:
## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so ## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so
## the view out to sea is clear and you can walk right up to the edge. ## the view out to sea is clear and you can walk right up to the edge.
## (Also kept clear: the Cove's pier and the sand by its foot.)
func _on_clifftop(x: float, z: float) -> bool: func _on_clifftop(x: float, z: float) -> bool:
if pier != null and pier.covers(x, z, 2.0):
return true
return terrain.sea_weight(x, z) > 0.5 and not terrain.inside_boundary(x, z, 10.0) \ 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) and terrain.inside_boundary(x, z, -8.0)
@@ -486,7 +490,7 @@ func _scatter_rocks() -> void:
# Sea stacks: tall pillars standing off the cliff (no collision, unreachable). # Sea stacks: tall pillars standing off the cliff (no collision, unreachable).
var placed := 0 var placed := 0
var tries := 0 var tries := 0
while placed < 9 and tries < 200: while placed < 9 and tries < 200 and terrain._has_sea: # (only off a sea cliff)
tries += 1 tries += 1
var ang: float = terrain.sea_angle_deg + rng.randf_range(-1.0, 1.0) * (terrain.sea_half_width_deg + 8.0) var ang: float = terrain.sea_angle_deg + rng.randf_range(-1.0, 1.0) * (terrain.sea_half_width_deg + 8.0)
var p: Vector2 = terrain.coast_point(ang, rng.randf_range(10.0, 48.0)) var p: Vector2 = terrain.coast_point(ang, rng.randf_range(10.0, 48.0))
@@ -672,7 +676,7 @@ func _scatter_pickups() -> void:
continue continue
var h: float = terrain.sample_height(x, z) var h: float = terrain.sample_height(x, z)
var n: Vector3 = terrain.sample_normal(x, z) var n: Vector3 = terrain.sample_normal(x, z)
if h < terrain.water_level + 0.3 or n.y < 0.8: if h < terrain.water_level + 0.3 or n.y < 0.8 or (pier != null and pier.covers(x, z, 1.0)):
continue continue
var chance := 1.0 var chance := 1.0
match id: match id:

View File

@@ -53,6 +53,7 @@ var _spawn := Vector3.ZERO
var _spawn_yaw := 0.0 var _spawn_yaw := 0.0
var _pieces: Node3D var _pieces: Node3D
var hud: CanvasLayer var hud: CanvasLayer
var pier: Node3D ## the Cove's pier (scripts/world/pier.gd), or null
var _session_time := 0.0 ## seconds played since the last save var _session_time := 0.0 ## seconds played since the last save
var _autosave_left := AUTOSAVE_SECONDS var _autosave_left := AUTOSAVE_SECONDS
var _menu_cam: Camera3D var _menu_cam: Camera3D
@@ -352,6 +353,15 @@ func _on_terrain_ready() -> void:
var to_lake := lake - Vector2(_spawn.x, _spawn.z) var to_lake := lake - Vector2(_spawn.x, _spawn.z)
_spawn_yaw = atan2(-to_lake.x, -to_lake.y) _spawn_yaw = atan2(-to_lake.x, -to_lake.y)
player.water_level = terrain.water_level 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()
props.place_on(terrain, player if not menu_mode else _menu_cam, _spawn) 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). # The wolf den: part of the scenery, so it's there from day 1 (empty).
den = preload("res://scripts/creatures/den.gd").new() den = preload("res://scripts/creatures/den.gd").new()

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..." echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1 godot --headless --path . --import >/dev/null 2>&1
fi 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_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_terrain_edges test_boundary; do
extra="" extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever, # A suite that waits on a world that failed to load would hang forever,

View File

@@ -30,6 +30,9 @@ func _run() -> void:
print("a beach (testbay)") print("a beach (testbay)")
S.current_id = S.create_world("Bay", 1337, {"worldlet": "testbay"}) S.current_id = S.create_world("Bay", 1337, {"worldlet": "testbay"})
await _escape_all(BAY_DIRECTIONS, 90.0) await _escape_all(BAY_DIRECTIONS, 90.0)
print("the Cove (all round)")
S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"})
await _escape_all(DIRECTIONS, -1.0)
for w in S.list_worlds(): for w in S.list_worlds():
S.delete_world(w.id) S.delete_world(w.id)
DirAccess.remove_absolute(S.root_dir) DirAccess.remove_absolute(S.root_dir)

175
tests/test_cove.gd Normal file
View File

@@ -0,0 +1,175 @@
extends SceneTree
## W3 (docs/WORLDLETS_DESIGN.md §5): the Cove. A world made there has its
## bay, a pond and a pier; you walk from the sand onto the pier, cast off its
## end into the sea and catch a mackerel; cod only bite in deeper water; the
## sea fish cook like the lake's. The Cove isn't a start (you reach it on
## the journey), and its ways on are back to the Valley and on by sail.
## Run: godot --headless --path . --script res://tests/test_cove.gd
var _fails := 0
var _finished := false
var A: Node
var S: Node
var DB: Node
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")
S.root_dir = "user://test_cove_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 _body() -> void:
var T: Script = load("res://scripts/world/terrain.gd")
print("the Cove")
var def: Dictionary = T.load_worldlet("cove")
check(def.get("name", "") == "Cove" and not bool(def.get("start", true)), "a worldlet, not a start (reached on the journey)")
S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"})
var world: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(8):
await physics_frame
var t: Node3D = world.terrain
var player: CharacterBody3D = world.player
world.day_night.frozen = true
world.weather.force("clear")
world.creatures.enabled = false
world.day_night.time = 0.5
world.day_night.apply(true)
player.survival.set_process(false)
check(world.worldlet_id() == "cove", "a world made in the Cove is the Cove")
var bodies: Array = world.water_bodies().map(func(b): return b.type)
check(bodies.has("sea") and bodies.has("lake"), "with the sea and a pond (%s)" % [bodies])
check(world.crossings().any(func(c): return c.to == "valley") and world.crossings().any(func(c): return c.by == "sail"),
"its ways on: back to the Valley, and on by sail")
check(world._spawn.y > t.water_level + 1.0, "you start on dry land")
check(world.den != null and world.barrow != null and world.stones != null, "the den, the barrow and the stones found places")
check(t.get_node_or_null("Water") == null, "one water surface (the bay is the sea)")
print("the pier")
var pier: Node3D = world.pier
check(pier != null and pier.is_in_group("pier"), "a pier")
if pier == null:
return
var len: float = pier.start.distance_to(pier.end)
check(len > 20.0, "running out over the bay (%.0f m)" % len)
var at_end: Dictionary = world.water_at(pier.end.x, pier.end.z)
check(at_end.get("type", "") == "sea" and float(at_end.depth) > 1.0, "into the sea (%.1f m deep at the end)" % float(at_end.get("depth", 0.0)))
check(absf(t.sample_height(pier.start.x, pier.start.z) - pier.start.y) < 0.3, "starting at the sand, so you walk straight on")
# Walk on from the sand: the deck holds you up above the water.
var on: Vector3 = pier.start - pier.dir * 1.5
on.y = t.sample_height(on.x, on.z) + 0.2
player.global_position = on
player.velocity = Vector3.ZERO
player.rotation = Vector3(0.0, atan2(-pier.dir.x, -pier.dir.z), 0.0)
Input.action_press("move_forward")
for _i in range(int(4.0 * Engine.physics_ticks_per_second)):
await physics_frame
Input.action_release("move_forward")
var p: Vector3 = player.global_position
var along: float = (p - pier.start).dot(pier.dir)
check(along > 10.0 and p.y > t.water_level + 0.5, "walk on from the beach and along it, dry (%.0f m out, %.1f m above the water)" % [along, p.y - t.water_level])
check(not world._in_current, "(no current up on the deck)")
print("sea fishing")
player.set_physics_process(false)
player.global_position = pier.end - pier.dir * 0.8
player.inventory.add("fishing_rod", 1)
_hold(player, "fishing_rod")
var out: Vector3 = pier.end + pier.dir * 8.0
out.y = t.water_level
var got := {}
var bobs := 0
for _try in range(25):
player.survival.stamina = 100.0
if not _cast(world, player, out):
break
var b := _bob_until(world, "floating", 5.0)
if b == null or b.state != "floating":
break
bobs += 1
if bobs == 1: check(b.water_type == "sea", "the float lands on the sea (%.1f m deep)" % b.water_depth)
b = _bob_until(world, "biting", 60.0)
if b == null or b.state != "biting":
break
var fish: String = b.fish
var before: int = player.inventory.count(fish)
if A.reel(player, world) and player.inventory.count(fish) == before + 1:
got[fish] = int(got.get(fish, 0)) + 1
if got.has("mackerel") and _try >= 6:
break
check(got.has("mackerel"), "you catch a mackerel off the pier (%s)" % [got])
check(got.keys().all(func(k): return k in ["mackerel", "cod"]), "(sea fish only: no perch in the sea)")
print("cod like it deep")
var bob: Node3D = load("res://scripts/fishing/bobber.gd").new()
bob.table = DB.water_fishing("sea")
bob.world = world
bob.rng.seed = 5
var shallow := {}
var deep := {}
for i in range(200):
bob.water_depth = 1.5
shallow[bob._pick_fish()] = true
bob.water_depth = 3.0
deep[bob._pick_fish()] = true
check(not shallow.has("cod") and shallow.has("mackerel"), "in shallow sea only mackerel bite (%s)" % [shallow.keys()])
check(deep.has("cod") and deep.has("mackerel"), "cast out deep and cod bite too")
bob.free()
print("cooking them")
for fish in ["mackerel", "cod"]:
var r: Dictionary = DB.recipes.get("grilled_" + fish, {})
check(r.get("station", "") == "campfire" and r.get("inputs", {}).has(fish) and not DB.get_item("grilled_" + fish).is_empty(),
"grilled %s, at a campfire" % fish)
check(DB.recipes.get("salted_" + fish, {}).get("output", "") == "salted_fish", "and %s salts into salted fish" % fish)
check(not DB.get_item("mackerel").get("food", {}).is_empty() and DB.get_item("cod").get("food", {}).get("diet", {}).has("fish"),
"the raw fish count as fish in your diet")
world.queue_free()
await process_frame
_finished = true
func _cast(world: Node3D, player: Node3D, toward: Vector3) -> bool:
var origin: Vector3 = player.global_position + Vector3(0, 1.6, 0)
return A.cast_line(player, origin, toward - origin, world)
## Steps the float until it reaches `want` (or `secs` pass). Returns it.
func _bob_until(world: Node3D, want: String, secs: float) -> Node3D:
var tt := 0.0
while tt < secs:
var b: Node3D = world.bobber
if b == null or not is_instance_valid(b):
return null
if b.state == want:
return b
b.set_physics_process(false)
b.step(0.05)
tt += 0.05
return world.bobber
func _hold(player: Node, id: String) -> void:
var inv: Inventory = 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)

1
tests/test_cove.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://cso32qbo6jvw3

View File

@@ -118,7 +118,8 @@ func _body(world: Node3D) -> void:
for f in lt.get("fish", []): for f in lt.get("fish", []):
ids.append(f.id) ids.append(f.id)
check(ids == ["perch", "trout"] and lt.min_depth == 0.5, "the lake's fish come from data/fish.json (%s)" % [ids]) check(ids == ["perch", "trout"] and lt.min_depth == 0.5, "the lake's fish come from data/fish.json (%s)" % [ids])
check(DB.water_fishing("sea").is_empty(), "this island has no sea fish defined (the cliffs are in the way)") var st: Dictionary = DB.water_fishing("sea")
check(st.get("fish", []).map(func(f): return f.id) == ["mackerel", "cod"], "the sea's fish: mackerel and cod (caught in the Cove; tests/test_cove.gd)")
print("casting") print("casting")
sv.stamina = 100.0 sv.stamina = 100.0

View File

@@ -21,7 +21,7 @@ func _initialize() -> void:
## Each worldlet's edges (W2): the Valley, and a test worldlet with two ## Each worldlet's edges (W2): the Valley, and a test worldlet with two
## ponds and a beach (tests/worldlets/testbay.json). ## ponds and a beach (tests/worldlets/testbay.json).
const WORLDLETS := ["valley", "testbay"] const WORLDLETS := ["valley", "cove", "testbay"]
func _all() -> void: func _all() -> void:
var failed := 0 var failed := 0

View File

@@ -40,7 +40,7 @@ func _body(S: Node, Net: Node) -> void:
var TS: Script = load("res://scripts/ui/title_screen.gd") var TS: Script = load("res://scripts/ui/title_screen.gd")
print("the worldlets to start in") print("the worldlets to start in")
var starts: Array = T.starting_worldlets() var starts: Array = T.starting_worldlets()
check(starts.size() == 1 and String(starts[0].id) == "valley", "only the Valley (%s)" % [starts.map(func(w): return w.id)]) check(starts.size() == 1 and String(starts[0].id) == "valley", "only the Valley (%s; the Cove is reached on the journey)" % [starts.map(func(w): return w.id)])
check(T.worldlets().all(func(w): return w.has("start")), "every worldlet says whether it's a start") check(T.worldlets().all(func(w): return w.has("start")), "every worldlet says whether it's a start")
check(T.worldlet_name("valley") == "Valley", "the Valley's name, for the menus") check(T.worldlet_name("valley") == "Valley", "the Valley's name, for the menus")
check(T.worldlet_name("nowhere") == "nowhere", "(an unknown one: its id)") check(T.worldlet_name("nowhere") == "nowhere", "(an unknown one: its id)")
@@ -51,9 +51,14 @@ func _body(S: Node, Net: Node) -> void:
root.add_child(title) root.add_child(title)
for _i in range(3): for _i in range(3):
await process_frame 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")
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)])
var pick: OptionButton = title._worldlet_pick var pick: OptionButton = title._worldlet_pick
check(pick != null and pick.item_count == 1 and pick.get_item_text(0) == "Valley", "a \"Where\" picker with the Valley in it") check(pick != null and pick.item_count == dev.size() and pick.get_item_text(0) == "Valley", "a \"Where\" picker, on the Valley first")
check(pick.disabled, "which sits still with only one choice") check(not pick.disabled, "(with more than one to choose from)")
check(TS.picked_worldlet(pick) == "valley", "and gives the Valley") check(TS.picked_worldlet(pick) == "valley", "and gives the Valley")
var id: String = S.create_world("Pick test", 4242, {"worldlet": TS.picked_worldlet(pick)}) 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 {} var meta: Dictionary = S.list_worlds().filter(func(w): return w.id == id)[0] if not id.is_empty() else {}
@@ -69,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") check(hp != null and hp.get_item_text(hp.selected) == "Valley", "a \"Where\" picker too, on the Valley")
title._show("host") title._show("host")
await process_frame await process_frame
check(hp.disabled and TS.picked_worldlet(hp) == "valley", "a new island: the Valley (the only start)") check(not hp.disabled and TS.picked_worldlet(hp) == "valley", "a new island: the Valley to start with, free to change")
# Once the island exists it keeps its worldlet: the picker shows that one. # Once the island exists it keeps its worldlet: the picker shows that one.
DirAccess.make_dir_recursive_absolute(DIR + "/server/worlds/server-world") DirAccess.make_dir_recursive_absolute(DIR + "/server/worlds/server-world")
var f := FileAccess.open(DIR + "/server/worlds/server-world/meta.json", FileAccess.WRITE) var f := FileAccess.open(DIR + "/server/worlds/server-world/meta.json", FileAccess.WRITE)