14 Commits

Author SHA1 Message Date
Paul Bailey
109fff22d0 0.4.1: the Cove's stream (trout), the seam and sea cave in real rock, one clock for every land (going home no longer rewinds the day) 2026-11-06 22:02:16 +00:00
Paul Bailey
b938e045cb One clock for every land: going back to a land catches it up on the time away (like a night's sleep) instead of rewinding to when you left; J3 decided: a server per land with the Valley's as hub, sleep needs everyone in every land 2026-11-06 21:57:37 +00:00
Paul Bailey
ad47cd53da The Cove's stream: a stream block whose water only runs downhill (spring to the bay), its own water ribbon, trout, on the map; scenery and landmarks keep clear 2026-11-06 21:35:04 +00:00
Paul Bailey
0209c6149a The seam and the sea cave built from real boulders (rock_pile.gd: the scenery's rocks with matching colliders), the seam a cleft between two slabs with pick marks and loose stones 2026-11-06 21:26:15 +00:00
Paul Bailey
b86fb879d2 Site: trailer encoded smaller (CRF 23, 3.5 Mbit/s cap) to fit Cloudflare Pages' 25 MiB file limit; make_site.sh refuses an oversized file 2026-11-06 21:06:08 +00:00
Paul Bailey
2b7e52cd82 0.4.0: map (N) that fills in as you explore and a compass (closes #1); Old Tam's seam and Cove tips (voiced); the journey and sea fish in the guide; trailer with the tunnel and the Cove; protocol 16 2026-11-06 18:36:20 +00:00
Paul Bailey
980ee53579 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) 2026-11-06 17:51:58 +00:00
Paul Bailey
793b214786 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 2026-11-06 17:08:42 +00:00
Paul Bailey
9b88a10609 W2: several lakes per worldlet, a beach block shelving into the sea with a current that turns swimmers back; testbay test worldlet through the water, edge and boundary suites; the two ways to play (journey, one land) recorded 2026-11-06 16:48:20 +00:00
Paul Bailey
68e6d814d9 W1: choose a worldlet (New World and Host Game pages, kept once an island exists), worldlet shown in the Load list; Godot .uid files 2026-11-06 16:10:32 +00:00
Paul Bailey
db4a659236 W0: terrain from worldlet building blocks (Valley in data/worldlets, identical heights), WorldInfo API, worldlet in saves and servers; the cliff tunnel plan 2026-11-06 15:51:43 +00:00
Paul Bailey
f4df2bc99d Journey: one boat each, no passengers; tunnels, ropes and burrows shared 2026-11-06 14:30:51 +00:00
Paul Bailey
5584f74a8b Journey: multiplayer spread out (decided) 2026-11-06 14:17:56 +00:00
Paul Bailey
77517be778 Journey decisions: extendable ending, four crossings, biome hazards; server cost measured 2026-11-06 14:16:07 +00:00
166 changed files with 3760 additions and 130 deletions

View File

@@ -16,7 +16,7 @@ addon SDK with guard rails.
## Status ## Status
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design. See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
**Emberwild 0.3.0** is released (download page and player guide: **Emberwild 0.4.1** is released (download page and player guide:
https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far: https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing. - M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin). - M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
@@ -26,8 +26,14 @@ https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
- T1, T2, T4 (`docs/TUTORIAL_DESIGN.md`): the tutorial with Old Tam (spoken), - T1, T2, T4 (`docs/TUTORIAL_DESIGN.md`): the tutorial with Old Tam (spoken),
and his tips in normal worlds. and his tips in normal worlds.
- M4 so far: packaged builds, the update check, music, ambience, sound effects - M4 so far: packaged builds, the update check, music, ambience, sound effects
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages. and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages,
Next: the worldlets and the journey between them (`docs/WORLDLETS_DESIGN.md`). the map (N) and compass.
- W0-W3, J1 (`docs/WORLDLETS_DESIGN.md`): worldlets as data, the Cove (beach,
pier, sea fishing), and the cliff tunnel between them, with a save file per
worldlet (single-player).
Next: J3, the journey on a server ("spread out"), then the Highlands.
Running from the source, `godot --path . -- --dev-worldlets` lets New World
start in any worldlet, to try one directly.
## Requirements ## Requirements
- **Godot 4.3+** (standard build; no C# needed — this project uses GDScript). - **Godot 4.3+** (standard build; no C# needed — this project uses GDScript).

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,6 @@
{ {
"hint_cold": "91c5088e453fb0e6", "hint_cold": "91c5088e453fb0e6",
"hint_cove": "126f0b5e977fbef7",
"hint_died": "e865f8a63622b3c3", "hint_died": "e865f8a63622b3c3",
"hint_diet": "3178b319c44e9fa0", "hint_diet": "3178b319c44e9fa0",
"hint_freezing": "48b1e505119a0fc9", "hint_freezing": "48b1e505119a0fc9",
@@ -8,6 +9,7 @@
"hint_hungry": "ad76f22cb8e034a0", "hint_hungry": "ad76f22cb8e034a0",
"hint_night": "2a07d9c0bfae4bf9", "hint_night": "2a07d9c0bfae4bf9",
"hint_rotten": "98b69de03dbee089", "hint_rotten": "98b69de03dbee089",
"hint_seam": "b353f46816d329b7",
"hint_sheltered": "ba6d6ff8bc4a1c80", "hint_sheltered": "ba6d6ff8bc4a1c80",
"hint_sick": "ec07963c46638ffc", "hint_sick": "ec07963c46638ffc",
"hint_snow": "8f0ac7d093f73840", "hint_snow": "8f0ac7d093f73840",

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,40 @@
"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
}
]
},
"stream": {
"min_depth": 0.3,
"bite_wait": [
8.0,
18.0
],
"fish": [
{
"id": "trout",
"weight": 100,
"dawn_dusk": 100
}
]
} }
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"guide": "Old Tam", "guide": "Old Tam",
"_doc": "T4 (docs/TUTORIAL_DESIGN.md §5): one-off tips in normal worlds, each shown once per player (user://hints.json), in list order when several are due. 'when' is kind:value, with | for alternatives: status (a survival status, by prefix), weather (a state), has (an item in your bag), event (howl, died), or a plain check (night, stamina_low, wolf_moon, hard_winter). 'hold' is how long it must stay true first (seconds, default 1).", "_doc": "T4 (docs/TUTORIAL_DESIGN.md §5): one-off tips in normal worlds, each shown once per player (user://hints.json), in list order when several are due. 'when' is kind:value, with | for alternatives: status (a survival status, by prefix), weather (a state), has (an item in your bag), event (howl, died), worldlet (where you are on the journey), or a plain check (night, stamina_low, wolf_moon, hard_winter, seam: holding the antler pickaxe with the cliff tunnel's seam still to dig). 'hold' is how long it must stay true first (seconds, default 1).",
"hints": [ "hints": [
{"id": "died", "when": "event:died", "title": "Back on your feet", {"id": "died", "when": "event:died", "title": "Back on your feet",
"text": "Dying's not the end out here. Whatever you carried is waiting where you fell, so go back for it before anything else. Sleep in a bed once and that's where you'll wake."}, "text": "Dying's not the end out here. Whatever you carried is waiting where you fell, so go back for it before anything else. Sleep in a bed once and that's where you'll wake."},
@@ -33,6 +33,10 @@
{"id": "stamina", "when": "stamina_low", "title": "Out of breath", {"id": "stamina", "when": "stamina_low", "title": "Out of breath",
"text": "You're spent. Stop swinging and running a moment and your stamina comes back. Rested by a fire, it comes back faster."}, "text": "You're spent. Stop swinging and running a moment and your stamina comes back. Rested by a fire, it comes back faster."},
{"id": "sheltered", "when": "status:Sheltered", "hold": 3, "title": "A proper shelter", {"id": "sheltered", "when": "status:Sheltered", "hold": 3, "title": "A proper shelter",
"text": "Roof and walls: the weather can't touch you here. Sit by a fire in here a little while and you'll be Rested."} "text": "Roof and walls: the weather can't touch you here. Sit by a fire in here a little while and you'll be Rested."},
{"id": "seam", "when": "seam", "hold": 3, "title": "The cracked seam",
"text": "That antler pick bites rock iron only scratches. Out on the clifftop, near the edge, there's a seam of cracked rock, and a way down to the sea behind it if you dig. Take wood to prop it as you go, and lights: it's black as pitch in there."},
{"id": "cove", "when": "worldlet:cove", "hold": 3, "title": "The Cove",
"text": "You made it down. Sand, salt and sea fish. Fish off the end of the pier: mackerel bite close in, cod further out where it's deep. The sea cave takes you back up the tunnel, and home's waiting at the top."}
] ]
} }

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",

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

@@ -0,0 +1,23 @@
{
"_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; a stream from a spring under the mountains down to the bay (a 'stream' block: its points, spring first, in world metres); 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": "stream", "points": [[46.0, -80.0], [34.0, -48.0], [40.0, -14.0], [30.0, 22.0], [36.0, 52.0], [33.0, 78.0], [33.0, 104.0]], "width": 2.4, "depth": 0.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

@@ -0,0 +1,18 @@
{
"_doc": "A worldlet (docs/WORLDLETS_DESIGN.md): its name and description (the New World page), whether a new world can start here (\"start\"; the others are reached on the journey), its size, and its terrain as building blocks applied in order (scripts/world/terrain.gd: hills, rim, sea_cliff, lake, ocean_edge). The numbers here are the Valley exactly as it was hand-coded: tests/test_worldlets.gd checks it still makes the same height grid.",
"id": "valley",
"name": "Valley",
"description": "A sheltered valley ringed by mountains, with a lake in the middle and sea cliffs on one side.",
"start": true,
"size": 320,
"resolution": 256,
"terrain": [
{"type": "hills", "height_scale": 9.0, "detail": 1.4},
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 58.0, "rim_warp": 16.0, "wall_frac": 0.45},
{"type": "sea_cliff", "angle_deg": -75.0, "half_width_deg": 38.0, "fade_deg": 22.0, "sea_level": -24.0, "cliff_top": 0.5, "cliff_offset": 4.0, "drop_width": 6.0},
{"type": "lake", "center": [14.0, -46.0], "radius": 24.0, "water_level": -2.6},
{"type": "ocean_edge"}
],
"climate": {"snowline": 40.0},
"next": [{"to": "cove", "by": "tunnel", "where": "sea_cliff"}]
}

View File

@@ -87,7 +87,11 @@ phases M2.0-M2.7).
- PvP / raiding rules, building damage - PvP / raiding rules, building damage
- Better UI, settings, controls. *Done so far:* volume sliders, tips and - Better UI, settings, controls. *Done so far:* volume sliders, tips and
update settings in the pause menu; `Q` drops items; `K`/`M` put any bag row update settings in the pause menu; `Q` drops items; `K`/`M` put any bag row
on the 1-8 keys (0.3.0). on the 1-8 keys (0.3.0); the map on `N`, filling in as you explore, and a
compass along the top (0.4.0, Gitea issue #1).
- More places: the worldlets and the journey (`docs/WORLDLETS_DESIGN.md`).
*Done (0.4.0):* W0-W3 and J1, the Cove reached down the cliff tunnel
(single-player). Next J3 (multiplayer, "spread out"), then J2/J4.
- Audio, music, weather. *Done (0.2.0-0.2.1):* mood music (calm, night, - Audio, music, weather. *Done (0.2.0-0.2.1):* mood music (calm, night,
storm, danger, boss) that crossfades with what's happening, ambience (CC0 storm, danger, boss) that crossfades with what's happening, ambience (CC0
birdsong, crickets, owls, fire), sound effects for every action (Kenney birdsong, crickets, owls, fire), sound effects for every action (Kenney

View File

@@ -1,7 +1,103 @@
# Worldlets design # Worldlets design
Status: **proposal**. Nothing here is built yet, apart from the first piece Status: **W0-W3 and J1 built** (the Cove without its stream yet; the
of groundwork (`world.water_at()` and `data/fish.json`, see §6). 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.
*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.
- 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
`test_boundary`.
- The stream (after 0.4.0): a `stream` block, a line of points from a
spring under the mountains to the bay. Its water surface is the running
minimum of the ground along it (so it only ever runs downhill, down to
sea level), the bed is cut 0.6 m under that and the banks rise a little
above it; its water is its own ribbon mesh (`terrain._stream_mesh()`),
murkier than the lake. `water_at()` answers "stream" (trout bite there,
`data/fish.json`), the map shows it, and scenery, the den, the barrow and
the stones keep clear of it (`terrain.near_stream()`).
*As built in W2:*
- `lake` blocks can repeat: every lake and pond is its own body
(`terrain.lakes`, `world.water_bodies()`), all at one surface
(`water_level`, set by the first). The first is still "the lake" to the
older code (ice, its fish, the tutorial).
- A `beach` block (angle, width, `shore_offset`, `slope`, `current_out`,
`wall_out`): the land eases to a waterline and shelves gently into the
sea, which is then the same surface as the ponds (one ocean sheet, no
inland disc). The invisible wall stands well out in the water, and before
it a current (`terrain.current_at()`, `world._current()`) carries
swimmers back faster than they can swim, with a toast.
- `water_at()` tells the sea off a beach from a pond.
- Test-only worldlets live in `tests/worldlets/` (never listed or offered).
`testbay.json` (two ponds and a beach) runs through
`test_water_bodies`, `test_terrain_edges` (8 seeds) and `test_boundary`
(swimming out across the beach).
*As built in W1:* a "Where" picker on the New World and Host Game pages
(worldlets with `"start": true`; only the Valley, so it's greyed out), the
worldlet in the Load list, and a hosted island keeps its worldlet like its
seed.
*As built in W0:*
- `data/worldlets/valley.json`: the Valley as data, its terrain as five
building blocks in order (`hills`, `rim`, `sea_cliff`, `lake`,
`ocean_edge`) with every number that used to be hard-coded.
- `terrain.gd` runs a worldlet's blocks (`use_worldlet()`); each block is
the old code's arithmetic, unchanged. `tests/test_worldlets.gd` checks
four seeds give exactly the same height grid as before (SHA-256 of the
grid, taken from the old code).
- The worldlet is chosen like the seed: a server's (it sends its real one),
else the save's `meta.json` "worldlet", else the Valley (worlds from
before W0). New worlds and servers record it.
- The WorldInfo API on the world: `worldlet_id()`, `water_bodies()`,
`climate()`, `crossings()` (the planned ways on), beside `water_at()`.
Systems still read the terrain's numbers directly; they move over to
these as each new worldlet needs them to differ (W1-W3).
## 1. What a worldlet is ## 1. What a worldlet is
A **worldlet** is a small, complete world type: a ~320 m playable area with A **worldlet** is a small, complete world type: a ~320 m playable area with
@@ -224,6 +320,41 @@ you've learned and gathered there:
- **Through:** the far end opens onto the next worldlet. Walking out loads - **Through:** the far end opens onto the next worldlet. Walking out loads
it (a short fade, "The Cove"), with your bag, cloak and stats as they are. it (a short fade, "The Cove"), with your bag, cloak and stats as they are.
### The first tunnel: down the sea cliff to the Cove
The Valley's sea side ends at a sheer cliff, its top about level with the
valley and the sea some 25 m below. The Cove is down there, so the first
tunnel goes **down**, inside the cliff, from the clifftop to the shore.
- **The seam:** a cracked outcrop on the clifftop strip near the edge (the
sea_cliff block marks where), with old pick marks and loose stones at its
foot. It can't be dug until you have the antler pickaxe (tier 3); a lesser
pick just chips it ("Too hard: you need a better pick").
- **Digging down:** the tunnel is about six stretches, each a few metres on
and a few down: a sloping passage that turns back on itself like a
stair, so the 25 m drop fits inside the cliff. A stretch takes many pick
blows (stamina, and wear on the pick) to open.
- **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 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**
as you go (tallow candles from bear fat, torch stands from wood). Placed
lights stay, so the way is lit for the trip back and for friends.
- **The bottom:** the last stretch breaks out into a sea cave, its mouth
opening on the Cove's shingle beach. Walking out of the mouth is the
crossing: a fade, then the Cove, arriving at the cave's Cove end. The way
back starts there and climbs the same tunnel.
- **What's saved:** the tunnel belongs to the Valley's save: how far it's
dug, the props, the lights. On a server it's shared: anyone can dig on
from where someone stopped, and the lights are everyone's.
- **How it's built:** the tunnel is its own small space (a "between" scene
next to the worldlet), made from a kit of rock pieces (straight, turn,
slope down, the cave mouth) laid out from the seed, with box-shaped
colliders. The heightmap can't have holes or overhangs, so the passage is
never cut into the terrain itself: you walk into the seam and you're in
the tunnel scene.
### Why go back ### Why go back
- **Each worldlet has things the others lack.** Clay and salt only in the - **Each worldlet has things the others lack.** Clay and salt only in the
Valley and Cove; little wood in the Highlands; no bears outside the Valley and Cove; little wood in the Highlands; no bears outside the
@@ -232,8 +363,10 @@ you've learned and gathered there:
meat, clay jars) becomes what you carry between them. meat, clay jars) becomes what you carry between them.
- **Your bases stay** where you built them: each worldlet keeps its own - **Your bases stay** where you built them: each worldlet keeps its own
save file, so your Valley hut, chests and jars are waiting. save file, so your Valley hut, chests and jars are waiting.
- **Time:** a worldlet you're not in stands still (as a server does with - **Time:** there's one clock for every land (see the decisions below). A
nobody on), so a base you left doesn't starve or get raided. worldlet you're not in doesn't run, but catches up on the time when you
come back, as after a night's sleep; nothing roams it meanwhile, so a base
you left isn't raided.
### Building it (phases) ### Building it (phases)
| Phase | Delivers | Done when | | Phase | Delivers | Done when |
@@ -253,7 +386,93 @@ A server today runs one island. Options:
The most freedom, and the most work and CPU. The most freedom, and the most work and CPU.
- **Party leader:** whoever goes through brings the others with a vote. - **Party leader:** whoever goes through brings the others with a vote.
### Questions for you ### Decisions (Paul, Nov 2026)
- **Extendable, not finished:** the Ember has a last boss, and after it you
keep playing. The chain is built so more worldlets (and crossings) can be
added after the Ember later: each worldlet names the next, nothing assumes
the Ember is the end.
- **The crossings:** tunnel, sail, climb, burrow, in that order.
- **Harder by biome, not just by numbers:** each worldlet has its own,
stronger animals and its own hazards. The Marsh, for one: mosquitoes whose
bites can make you sick, and bog, mist and the like. Others in the same
spirit per biome (to design with each worldlet).
- **Multiplayer: spread out.** Each player goes where they like; the server
runs every worldlet someone is in. Built in J3, after single-player travel
works.
- **A server per land (Paul, Nov 2026)**, rather than one process holding
several worldlets (which would mean making every network module, the
saves and the actions handle several worlds at once):
- the Valley's server is the **hub**: the address everyone joins. It
starts a server for another land (the next UDP port: 24243 the Cove,
and so on) when someone first goes there, and stops it a couple of
minutes after its last player leaves;
- each land server is today's server, with its own worldlet, save file,
weather and creatures; it keeps a private link to the hub on the host
machine;
- characters live in one shared folder. A crossing saves your character
first, marks you as on your way, and hands your game the land's port
and a one-time pass; your game reconnects there ("On to the Cove...").
Nothing can be lost or copied on the way: crash mid-way and you come
back where you were going. Joining the hub when your character's in
another land sends you straight on there;
- chat, the player list (with where each player is) and admin commands
go through the hub to every land;
- the tunnel belongs to the Valley's server; the Cove's asks the hub
whether it's dug through;
- from the internet, friends need those extra ports forwarded too (on a
home network, nothing to do). The Host page will list them;
- the Old Stag and other bosses scale with the players in their land.
- Phases: **J3a** the tunnel online, land servers on demand, the
crossing and the shared characters; **J3b** chat, the player list and
admin across lands; **J3c** the awkward cases (a land server crashing,
rejoining mid-crossing, the Host page's ports).
- **One clock for every land (Paul, Nov 2026).** The day and hour are the
same everywhere, so the lands never drift apart:
- sleeping until morning needs **everyone, in every land**, in bed (on a
server the hub counts them all, and every land skips to morning
together);
- a land nobody is in doesn't run, but when someone arrives it catches up
on the time since anyone was last there, as after a night's sleep:
kilns and smelters work, fires burn down, food goes off, trees and
berries grow back. (Built for single-player already: `world.catch_up()`.)
No creatures roam it meanwhile, so a base you left isn't raided.
- **Boats: one each, no passengers.** A boat belongs to whoever built it
(like a bed): only the owner, and their friends under the server's locks
rule, can sail it, and nobody else can dismantle it. It stays moored where
it was last left (the Cove end or the Highlands end), so nobody is
stranded by someone else taking the only boat. Lose or wreck it and you
build another, which is harder where wood is scarce (the Highlands), so
plan the trip back. Each player has to earn their own crossing by boat.
- **The other crossings are shared:** a dug tunnel, a fixed climbing rope
and a dug burrow are changes to the world, there for everyone once one
player has made them (like a door or a bridge).
- **Two ways to play (Paul, Nov 2026).**
- **The journey** (the standard game), single-player and multiplayer
alike: every new world starts in the Valley and the worldlets come in
their fixed order (Valley, Cove, Highlands, Marsh, Ember). Each
worldlet's shape is set by its definition; the seed only changes the
detail (hills, trees, where the den is), so the journey is the same
adventure for everyone.
- **One land** (a hosting choice): no journey, no crossings, the server
is locked to one worldlet, and the host picks which, from all of them.
Co-op or PvP as now (the rules preset). In code: a `mode` in
`server.json` ("journey" / "one_land"); in one-land mode the Host
page's "Where" lists every worldlet rather than only the starts, and
the crossings (`world.crossings()`) answer none, so a tunnel seam
can't be dug. Kept, like the seed and the worldlet, once the island
exists. Worth building once the Cove exists (until then there's only
the Valley to pick).
### Server cost, measured (0.3.1)
One worldlet, a dedicated server on the M5 Pro, never pausing: about
**12% of one CPU core and 270 MB of memory**, with nobody on. Players add
creatures, weather and pickups around them. So with *spread out*, the worst
case of 8 players in all 5 worldlets is about five of today's servers in
one process: roughly 1.4 GB and a little over half a core before players,
and worldlets with nobody in them aren't running and cost nothing (each starts its own server when someone goes there).
Comfortable on the home Mac; a small cloud server (2 GB) would be tight.
### (Answered) questions
1. **The ending:** a real ending at the Ember (light the beacon, a last 1. **The ending:** a real ending at the Ember (light the beacon, a last
boss, the credits) or open-ended (the journey is the goal, keep living)? boss, the credits) or open-ended (the journey is the goal, keep living)?
2. **The crossings:** do the four above fit (tunnel, sail, climb, burrow), 2. **The crossings:** do the four above fit (tunnel, sail, climb, burrow),

View File

@@ -6,7 +6,7 @@ runnable=true
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="data/*.json" include_filter="data/*.json, data/worldlets/*.json"
exclude_filter="tests/*, docs/*, tools/*, server/*" exclude_filter="tests/*, docs/*, tools/*, server/*"
export_path="build/macos/Emberwild.zip" export_path="build/macos/Emberwild.zip"
patches=PackedStringArray() patches=PackedStringArray()
@@ -29,8 +29,8 @@ application/icon_interpolation=4
application/bundle_identifier="com.pbailey.emberwild" application/bundle_identifier="com.pbailey.emberwild"
application/signature="" application/signature=""
application/app_category="Games" application/app_category="Games"
application/short_version="0.3.1" application/short_version="0.4.1"
application/version="0.3.1" application/version="0.4.1"
application/copyright="" application/copyright=""
display/high_res=true display/high_res=true
codesign/codesign=1 codesign/codesign=1
@@ -64,7 +64,7 @@ runnable=true
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="data/*.json" include_filter="data/*.json, data/worldlets/*.json"
exclude_filter="tests/*, docs/*, tools/*, server/*" exclude_filter="tests/*, docs/*, tools/*, server/*"
export_path="build/windows/Emberwild/Emberwild.exe" export_path="build/windows/Emberwild/Emberwild.exe"
patches=PackedStringArray() patches=PackedStringArray()
@@ -89,8 +89,8 @@ application/modify_resources=false
application/icon="" application/icon=""
application/console_wrapper_icon="" application/console_wrapper_icon=""
application/icon_interpolation=4 application/icon_interpolation=4
application/file_version="0.3.1" application/file_version="0.4.1"
application/product_version="0.3.1" application/product_version="0.4.1"
application/company_name="" application/company_name=""
application/product_name="Emberwild" application/product_name="Emberwild"
application/file_description="Emberwild" application/file_description="Emberwild"

View File

@@ -19,7 +19,7 @@ config/description="Low-poly survival game — Valheim look, Rust depth, self-ho
run/main_scene="res://scenes/title.tscn" run/main_scene="res://scenes/title.tscn"
config/features=PackedStringArray("4.7", "Forward Plus") config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg" config/icon="res://icon.svg"
config/version="0.3.1" config/version="0.4.1"
[autoload] [autoload]

View File

@@ -43,5 +43,5 @@ offset_left = 16.0
offset_top = 12.0 offset_top = 12.0
offset_right = 600.0 offset_right = 600.0
offset_bottom = 60.0 offset_bottom = 60.0
text = "E interact · LMB use/place · RMB eat · R rotate · X dismantle · Q drop · K/M bag row · Tab inventory & crafting · Esc menu & controls" text = "E interact · LMB use/place · RMB eat · R rotate · X dismantle · Q drop · K/M bag row · N map · Tab inventory & crafting · Esc menu & controls"
label_settings = SubResource("Label_1") label_settings = SubResource("Label_1")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -34,6 +34,7 @@ func _ready() -> void:
_bind("drop", [KEY_Q]) # (shift+Q drops just one) _bind("drop", [KEY_Q]) # (shift+Q drops just one)
_bind("row_up", [KEY_K]) # the 1-8 keys pick from the bag row above... _bind("row_up", [KEY_K]) # the 1-8 keys pick from the bag row above...
_bind("row_down", [KEY_M]) # ...or below _bind("row_down", [KEY_M]) # ...or below
_bind("map", [KEY_N]) # the map (Gitea issue #1)
for i in range(8): for i in range(8):
_bind("hotbar_%d" % (i + 1), [KEY_1 + i]) _bind("hotbar_%d" % (i + 1), [KEY_1 + i])
_bind_mouse("attack", MOUSE_BUTTON_LEFT) _bind_mouse("attack", MOUSE_BUTTON_LEFT)
@@ -257,6 +258,14 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
return false return false
var held: Dictionary = ItemDB.get_item(player.held_item_id()) var held: Dictionary = ItemDB.get_item(player.held_item_id())
var tool: Dictionary = held.get("tool", {}) 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: 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)) notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
return false return false

1
scripts/core/boot.gd.uid Normal file
View File

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

View File

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

View File

@@ -135,4 +135,9 @@ func _met(when: String) -> bool:
return not dead and world.day_night.is_night() and world.day_night.wolf_moon_now() return not dead and world.day_night.is_night() and world.day_night.wolf_moon_now()
"hard_winter": "hard_winter":
return not dead and world.weather != null and world.weather.in_hard_winter() return not dead and world.weather != null and world.weather.in_hard_winter()
"worldlet": # (where you are on the journey: "worldlet:cove")
return not dead and world.has_method("worldlet_id") and String(world.worldlet_id()) in vals
"seam": # (an antler pickaxe in your bag, and the cliff tunnel's seam still to dig)
return not dead and world.get("tunnel") != null and not world.tunnel.opened \
and player.inventory.count("antler_pickaxe") > 0
return false return false

View File

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

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

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

View File

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

View File

@@ -56,16 +56,17 @@ func create_world(world_name: String, seed: int, options: Dictionary = {}) -> St
var now := Time.get_unix_time_from_system() var now := Time.get_unix_time_from_system()
var meta := { var meta := {
"name": world_name, "seed": seed, "created": now, "last_played": now, "name": world_name, "seed": seed, "created": now, "last_played": now,
"play_time": 0.0, "version": VERSION, "play_time": 0.0, "version": VERSION, "worldlet": "valley",
} }
for k in options: for k in options:
meta[k] = options[k] meta[k] = options[k]
meta["home"] = String(meta.worldlet)
_write_json(_dir(id).path_join("meta.json"), meta) _write_json(_dir(id).path_join("meta.json"), meta)
return id return id
## The world slot called exactly `id` (a server's world), made with `seed` if ## The world slot called exactly `id` (a server's world), made with `seed` if
## it doesn't exist yet. An existing world keeps its own seed. ## it doesn't exist yet. An existing world keeps its own seed.
func open_or_create(id: String, seed: int) -> String: func open_or_create(id: String, seed: int, worldlet: String = "valley") -> String:
if not read_meta(id).is_empty(): if not read_meta(id).is_empty():
var had := int(read_meta(id).get("seed", seed)) var had := int(read_meta(id).get("seed", seed))
if had != seed: if had != seed:
@@ -75,7 +76,7 @@ func open_or_create(id: String, seed: int) -> String:
var now := Time.get_unix_time_from_system() var now := Time.get_unix_time_from_system()
_write_json(_dir(id).path_join("meta.json"), { _write_json(_dir(id).path_join("meta.json"), {
"name": id, "seed": seed, "created": now, "last_played": now, "name": id, "seed": seed, "created": now, "last_played": now,
"play_time": 0.0, "version": VERSION, "play_time": 0.0, "version": VERSION, "worldlet": worldlet,
}) })
return id return id
@@ -84,6 +85,8 @@ func delete_world(id: String) -> void:
var d := DirAccess.open(dir) var d := DirAccess.open(dir)
if d == null: if d == null:
return 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(): for f in d.get_files():
d.remove(f) d.remove(f)
DirAccess.remove_absolute(dir) DirAccess.remove_absolute(dir)
@@ -93,16 +96,64 @@ func delete_world(id: String) -> void:
func read_meta(id: String) -> Dictionary: func read_meta(id: String) -> Dictionary:
return _read_json(_dir(id).path_join("meta.json")) return _read_json(_dir(id).path_join("meta.json"))
func load_state(id: String) -> Dictionary: # --- worldlets in one world (J1, docs/WORLDLETS_DESIGN.md §10) --------------------
var s := _read_json(_dir(id).path_join("world.json")) # A world can span several worldlets (the journey). Each keeps its own state
if s.is_empty() and FileAccess.file_exists(_dir(id).path_join("world.json.bak")): # file: the one it began in (its "home") is world.json, as before, so older
push_warning("Saves: world.json unreadable, using backup") # saves are unchanged; the others are worldlets/<id>.json. meta.json's
s = _read_json(_dir(id).path_join("world.json.bak")) # "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 return s
## Writes the state for `id` and updates its meta. Returns true on success. ## Writes the state for `id` (the worldlet being played, or `w`) and
func save_state(id: String, state: Dictionary, session_seconds: float) -> bool: ## updates its meta. Returns true on success.
var path := _dir(id).path_join("world.json") 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): if FileAccess.file_exists(path):
DirAccess.copy_absolute(path, path + ".bak") DirAccess.copy_absolute(path, path + ".bak")
state["version"] = VERSION state["version"] = VERSION
@@ -129,3 +180,10 @@ func _read_json(path: String) -> Dictionary:
return {} return {}
var data = JSON.parse_string(FileAccess.get_file_as_string(path)) var data = JSON.parse_string(FileAccess.get_file_as_string(path))
return data if typeof(data) == TYPE_DICTIONARY else {} 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)

1
scripts/core/sfx.gd.uid Normal file
View File

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

View File

@@ -0,0 +1 @@
uid://3lflq11qfg00

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,7 +90,7 @@ func _check_site(world: Node3D, p: Vector2) -> Vector3:
var hs: Array[float] = [] var hs: Array[float] = []
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]: for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
hs.append(terrain.sample_height(p.x + d.x * 4.0, p.y + d.y * 4.0)) hs.append(terrain.sample_height(p.x + d.x * 4.0, p.y + d.y * 4.0))
if hs.max() - hs.min() > 2.2 or hs.min() < terrain.water_level + 1.0: if hs.max() - hs.min() > 2.2 or hs.min() < terrain.water_level + 1.0 or terrain.near_stream(p.x, p.y, 7.0):
return Vector3.INF return Vector3.INF
# Trees and boulders in the way are cleared (_clear_area); just avoid # Trees and boulders in the way are cleared (_clear_area); just avoid
# the thickest woods. Counted by position, because their colliders # the thickest woods. Counted by position, because their colliders

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
uid://024u453fdbml

View File

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

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

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

View File

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

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

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

View File

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

1
scripts/net/chat.gd.uid Normal file
View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
const Identity = preload("res://scripts/net/identity.gd") const Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.gd") const ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 15 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand) const PROTOCOL := 16 ## 16: worldlets (the Cove), sea fish (mackerel, cod); 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand)
const DEFAULT_PORT := 24242 const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0 const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
@@ -304,14 +304,14 @@ var _host_out := ""
var _host_port := DEFAULT_PORT var _host_port := DEFAULT_PORT
## Settings for a hosted game: name, password, port, a rules preset ## Settings for a hosted game: name, password, port, a rules preset
## (co-op / rivals / adversarial) and the seed for a new island. ## (co-op / rivals / adversarial) and the seed and worldlet for a new island.
const PRESETS := { const PRESETS := {
"co-op": {"pvp": "false", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"}, "co-op": {"pvp": "false", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"rivals": {"pvp": "true", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"}, "rivals": {"pvp": "true", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"adversarial": {"pvp": "true", "raiding": "true", "locks": "owner", "death_penalty": "drop_all"}, "adversarial": {"pvp": "true", "raiding": "true", "locks": "owner", "death_penalty": "drop_all"},
} }
func host(server_name: String, password: String, port: int, preset: String, seed: int) -> void: func host(server_name: String, password: String, port: int, preset: String, seed: int, worldlet: String = "valley") -> void:
if hosting or state != "offline": if hosting or state != "offline":
return return
if _host_pid != 0 and OS.is_process_running(_host_pid): if _host_pid != 0 and OS.is_process_running(_host_pid):
@@ -324,8 +324,9 @@ func host(server_name: String, password: String, port: int, preset: String, seed
c.data.password = password c.data.password = password
c.data.port = port c.data.port = port
var fresh := not FileAccess.file_exists(host_config.get_base_dir().path_join("worlds/server-world/meta.json")) var fresh := not FileAccess.file_exists(host_config.get_base_dir().path_join("worlds/server-world/meta.json"))
if fresh: if fresh: # (an island keeps its seed and worldlet once it's made)
c.data.seed = seed c.data.seed = seed
c.data.worldlet = worldlet
c.save() c.save()
_host_port = port _host_port = port
var args := PackedStringArray(["--headless"]) var args := PackedStringArray(["--headless"])
@@ -638,7 +639,7 @@ func _c2s_proof(sig: PackedByteArray) -> void:
print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "", print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
players.size(), int(config.v("max_players"))]) players.size(), int(config.v("max_players"))])
# (The world's own seed: an existing world keeps the seed it was made with.) # (The world's own seed: an existing world keeps the seed it was made with.)
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, "yaw": yaw, var world := {"seed": int(_terrain.noise_seed), "worldlet": String(_terrain.worldlet_id), "spawn": start, "yaw": yaw,
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time, "island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
"weather": _world.weather.override} "weather": _world.weather.override}
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world}) _s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})

1
scripts/net/net.gd.uid Normal file
View File

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

View File

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

View File

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

1
scripts/net/rules.gd.uid Normal file
View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ func open_server_world(config: RefCounted) -> Node3D:
var id := String(config.v("world")).validate_filename() var id := String(config.v("world")).validate_filename()
if id.is_empty(): if id.is_empty():
id = "server-world" id = "server-world"
Saves.current_id = Saves.open_or_create(id, int(config.v("seed"))) Saves.current_id = Saves.open_or_create(id, int(config.v("seed")), String(config.v("worldlet")))
backup("start-up") # (the state it's about to load, before anyone changes it) backup("start-up") # (the state it's about to load, before anyone changes it)
_backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0 _backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0
var w: Node3D = load("res://scenes/world.tscn").instantiate() var w: Node3D = load("res://scenes/world.tscn").instantiate()

View File

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

View File

@@ -103,8 +103,9 @@ func step(dt: float) -> void:
_shelter_left -= dt _shelter_left -= dt
if _shelter_left <= 0.0: if _shelter_left <= 0.0:
_shelter_left = 0.25 _shelter_left = 0.25
roofed = world.build.roofed_at(p) var cave: bool = world.has_method("underground_at") and world.underground_at(p) # (the tunnel: rock all round)
sheltered = roofed and world.build.enclosure_at(p) >= 0.74 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 stone_room = sheltered and world.build.stone_walls_at(p) >= 0.5
cozy = sheltered and _rug_near(p) cozy = sheltered and _rug_near(p)
# Rain soaks you unless there's a roof over your head. # Rain soaks you unless there's a roof over your head.

53
scripts/ui/compass.gd Normal file
View File

@@ -0,0 +1,53 @@
extends Control
## The compass (Gitea issue #1): a strip across the top of the screen that
## shows which way you're facing, N/E/S/W and the points between, with a tick
## at the centre. North is the map's top (-Z).
const UITheme = preload("res://scripts/ui/theme.gd")
const WIDTH := 420.0
const SPAN := 180.0 ## degrees shown across the strip
const POINTS := [[0, "N"], [45, "NE"], [90, "E"], [135, "SE"], [180, "S"], [225, "SW"], [270, "W"], [315, "NW"]]
var player: Node3D
func setup(p: Node3D) -> void:
player = p
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_preset(Control.PRESET_CENTER_TOP)
offset_left = -WIDTH * 0.5
offset_right = WIDTH * 0.5
offset_top = 44.0
offset_bottom = 74.0
## Which way `p` faces, in degrees clockwise from north (0 = N, 90 = E).
static func heading(p: Node3D) -> float:
var f: Vector3 = -p.global_basis.z
return fposmod(rad_to_deg(atan2(f.x, -f.z)), 360.0)
func _process(_d: float) -> void:
visible = player != null and is_instance_valid(player) and not bool(player.get("ui_open"))
queue_redraw()
func _draw() -> void:
if player == null or not is_instance_valid(player):
return
var h := heading(player)
var font := get_theme_default_font()
draw_rect(Rect2(Vector2.ZERO, size), Color(0, 0, 0, 0.35))
for deg in range(0, 360, 15):
var off := wrapf(deg - h, -180.0, 180.0)
if absf(off) > SPAN * 0.5:
continue
var x := WIDTH * 0.5 + off / SPAN * WIDTH
var big := deg % 45 == 0
draw_line(Vector2(x, 0), Vector2(x, 6 if not big else 9), Color(1, 1, 1, 0.5 if not big else 0.8), 1.0)
for pt in POINTS:
var off := wrapf(float(pt[0]) - h, -180.0, 180.0)
if absf(off) > SPAN * 0.5 - 6.0:
continue
var x := WIDTH * 0.5 + off / SPAN * WIDTH
var s := 17 if String(pt[1]).length() == 1 else 13
var col := Color(1.0, 0.55, 0.35) if pt[1] == "N" else Color(1, 1, 1, 0.9)
var w := font.get_string_size(pt[1], HORIZONTAL_ALIGNMENT_CENTER, -1, s).x
draw_string(font, Vector2(x - w * 0.5, 25), pt[1], HORIZONTAL_ALIGNMENT_LEFT, -1, s, col)
draw_line(Vector2(WIDTH * 0.5, 0), Vector2(WIDTH * 0.5, 30), Color(1.0, 0.85, 0.4, 0.9), 2.0)

View File

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

1
scripts/ui/guide.gd.uid Normal file
View File

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

View File

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

View File

@@ -21,6 +21,8 @@ var _diet_bars: Dictionary = {}
var _status: Label var _status: Label
var _clock: Label var _clock: Label
var _hurt: ColorRect var _hurt: ColorRect
var compass: Control
var map: Control
## A red flash at the screen edge when something hits you. ## A red flash at the screen edge when something hits you.
func _on_hurt(amount: float, _from: Vector3) -> void: func _on_hurt(amount: float, _from: Vector3) -> void:
@@ -196,6 +198,15 @@ func _build_stats(root: Control) -> void:
tips.name = "HintCard" tips.name = "HintCard"
root.add_child(tips) root.add_child(tips)
tips.setup() tips.setup()
# The compass along the top, and the map on N (Gitea issue #1).
compass = preload("res://scripts/ui/compass.gd").new()
compass.name = "Compass"
root.add_child(compass)
compass.setup(player)
map = preload("res://scripts/ui/map_screen.gd").new()
map.name = "Map"
root.add_child(map)
map.setup(player.get_parent(), player)
func _process(_d: float) -> void: func _process(_d: float) -> void:
_fade_chat() _fade_chat()
@@ -387,7 +398,9 @@ func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
if is_dead_screen(): if is_dead_screen():
return # (respawn first) return # (respawn first)
if _screen.is_open(): if map != null and map.is_open():
map.close()
elif _screen.is_open():
_screen.close() _screen.close()
elif _menu.is_open(): elif _menu.is_open():
_menu.back() _menu.back()
@@ -398,6 +411,13 @@ func _input(event: InputEvent) -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if _menu.is_open(): if _menu.is_open():
return return
if event.is_action_pressed("map") and not _screen.is_open() and not is_dead_screen() \
and (map.is_open() or not player.ui_open):
map.toggle()
get_viewport().set_input_as_handled()
return
if map.is_open():
return
if event.is_action_pressed("inventory") and not _screen.is_open(): if event.is_action_pressed("inventory") and not _screen.is_open():
_screen.open(null) _screen.open(null)
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()

View File

@@ -0,0 +1 @@
uid://87b73wsdjp7t

184
scripts/ui/map_screen.gd Normal file
View File

@@ -0,0 +1,184 @@
extends Control
## The map (Gitea issue #1), on N. The worldlet you're in, drawn from its
## terrain (water, sand, grass, rock, snow, shaded by slope), but only where
## you've been: the rest is blank until you explore it (world.explored).
## Marked where you've seen them: the den, the barrow, the standing stones,
## the tunnel's seam, the pier and sea cave, your beds and buildings, your
## camp (fires, chests, benches) and your grave. You're a dot; which way
## you're facing is on the compass (and under the map). North is up.
const UITheme = preload("res://scripts/ui/theme.gd")
const Compass = preload("res://scripts/ui/compass.gd")
const RES := 200 ## map image pixels per side
const SIDE := 500.0 ## map size on screen
const MARKS := {
"den": [Color(0.85, 0.30, 0.25), "Wolf den"],
"barrow": [Color(0.75, 0.70, 0.55), "Barrow"],
"stones": [Color(0.80, 0.80, 0.85), "Standing stones"],
"seam": [Color(0.95, 0.60, 0.25), "Cliff tunnel"],
"pier": [Color(0.70, 0.50, 0.30), "Pier"],
"cave": [Color(0.95, 0.60, 0.25), "Sea cave"],
"bed": [Color(0.40, 0.75, 1.00), "Bed"],
"build": [Color(0.62, 0.45, 0.28), "Your buildings"],
"camp": [Color(1.00, 0.80, 0.35), "Camp"],
"grave": [Color(0.85, 0.85, 0.85), "Your things"],
}
var world: Node3D
var player: Node3D
var _tex: TextureRect
var _marks: Control
var _title: Label
var _facing: Label
func setup(w: Node3D, p: Node3D) -> void:
world = w
player = p
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
visible = false
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.6)
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
centre.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(centre)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 6)
centre.add_child(box)
_title = UITheme.label("", 22)
_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_title)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 14)
box.add_child(row)
var frame := Control.new()
frame.custom_minimum_size = Vector2(SIDE, SIDE)
row.add_child(frame)
_tex = TextureRect.new()
_tex.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_tex.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_tex.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
frame.add_child(_tex)
_marks = Control.new()
_marks.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_marks.mouse_filter = Control.MOUSE_FILTER_IGNORE
_marks.draw.connect(_draw_marks)
frame.add_child(_marks)
var legend := VBoxContainer.new()
legend.add_theme_constant_override("separation", 4)
row.add_child(legend)
legend.add_child(UITheme.label("Key", 16, UITheme.TEXT_DIM))
var you := UITheme.label("● You", 14)
you.add_theme_color_override("font_color", Color(1, 1, 1))
legend.add_child(you)
for k in MARKS:
var l := UITheme.label("■ %s" % MARKS[k][1], 14)
l.add_theme_color_override("font_color", MARKS[k][0])
legend.add_child(l)
_facing = UITheme.label("", 15, UITheme.TEXT_DIM)
_facing.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_facing)
var help := UITheme.label("N or Esc to close · north is up · the map fills in as you explore", 13, UITheme.TEXT_DIM)
help.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(help)
func is_open() -> bool:
return visible
func open() -> void:
_tex.texture = ImageTexture.create_from_image(render(world))
_title.text = "The %s" % preload("res://scripts/world/terrain.gd").worldlet_name(world.worldlet_id())
visible = true
player.ui_open = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func close() -> void:
visible = false
player.ui_open = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func toggle() -> void:
if visible:
close()
else:
open()
func _process(_d: float) -> void:
if not visible:
return
var h: float = Compass.heading(player)
var names := ["north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west"]
_facing.text = "Facing %s" % names[int(round(h / 45.0)) % 8]
if world.underground_at(player.global_position):
_facing.text += " · in the tunnel, under the cliff"
_marks.queue_redraw()
## Where (x, z) in the world falls on the map, in its frame's pixels.
func _to_map(p: Vector3) -> Vector2:
var s: float = world.terrain.size
return Vector2((p.x + s * 0.5) / s, (p.z + s * 0.5) / s) * SIDE
func _draw_marks() -> void:
var font := get_theme_default_font()
for m in world.map_marks():
var at := _to_map(m.pos)
var col: Color = MARKS.get(m.kind, [Color.WHITE])[0]
var r := 3.0 if m.kind == "build" else 6.0
_marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), col)
_marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), Color(0, 0, 0, 0.7), false, 1.0)
if m.kind in ["den", "barrow", "stones", "seam", "pier", "cave"]:
_marks.draw_string(font, at + Vector2(9, 5), String(m.label), HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col)
# You: a dot (the way you're facing is on the compass).
var p := player.global_position
if world.underground_at(p) and world.tunnel != null:
p = world.tunnel.seam_at # (down the tunnel: shown at its top)
var me := _to_map(p)
_marks.draw_circle(me, 6.5, Color(0, 0, 0, 0.8))
_marks.draw_circle(me, 5.0, Color(1, 1, 1))
# North.
_marks.draw_string(font, Vector2(SIDE - 26, 24), "N", HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(1.0, 0.55, 0.35))
_marks.draw_line(Vector2(SIDE - 20, 30), Vector2(SIDE - 20, 48), Color(1.0, 0.55, 0.35), 2.0)
## The map image: the terrain, coloured and shaded, blank where unexplored.
static func render(w: Node3D) -> Image:
var t: Node3D = w.terrain
var img := Image.create(RES, RES, false, Image.FORMAT_RGB8)
var s: float = t.size
var step := s / RES
var blank := Color(0.16, 0.14, 0.12)
for iy in range(RES):
for ix in range(RES):
var x := -s * 0.5 + (ix + 0.5) * step
var z := -s * 0.5 + (iy + 0.5) * step
if not w.seen(x, z):
# (a faint paper grain, so blank reads as "not yet", not "nothing")
img.set_pixel(ix, iy, blank.lightened(0.04 * float((ix * 7 + iy * 13) % 3) / 2.0))
continue
img.set_pixel(ix, iy, ground_colour(t, x, z))
return img
static func ground_colour(t: Node3D, x: float, z: float) -> Color:
var h: float = t.sample_height(x, z)
if t.near_stream(x, z, 0.3):
return Color(0.32, 0.56, 0.66) # (the stream, a little wider than it is so it shows)
var surface: float = t.water_level if t.inside_boundary(x, z, -2.0) else t.sea_level
if h < surface:
var deep := clampf((surface - h) / 6.0, 0.0, 1.0)
return Color(0.30, 0.52, 0.62).lerp(Color(0.10, 0.22, 0.36), deep)
var n: Vector3 = t.sample_normal(x, z)
var c: Color
if h > t.snow_height:
c = Color(0.92, 0.93, 0.95)
elif n.y < 0.75:
c = Color(0.48, 0.46, 0.43) # rock
elif h < surface + 0.9:
c = Color(0.80, 0.74, 0.55) # shore
else:
c = Color(0.36, 0.52, 0.27).lerp(Color(0.46, 0.50, 0.32), clampf(h / 20.0, 0.0, 1.0))
# Shading from the north-west, so hills and cliffs stand out.
var light := clampf(0.75 + (-n.x - n.z) * 0.9, 0.45, 1.25)
return Color(c.r * light, c.g * light, c.b * light)

View File

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

View File

@@ -15,6 +15,7 @@ const CONTROLS := [
["E on a bed", "Set spawn point / sleep (night, under a roof)"], ["E on a bed", "Set spawn point / sleep (night, under a roof)"],
["1 - 8, wheel", "Hotbar (the bag row you're on)"], ["K / M", "Hotbar shows the bag row above / below"], ["1 - 8, wheel", "Hotbar (the bag row you're on)"], ["K / M", "Hotbar shows the bag row above / below"],
["Q (shift+Q)", "Drop what you're holding (just one)"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"], ["Q (shift+Q)", "Drop what you're holding (just one)"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"],
["N", "Map (fills in as you explore; the compass is along the top)"],
["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"], ["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"],
["Esc", "This menu (game autosaves every 2 min)"], ["Esc", "This menu (game autosaves every 2 min)"],
] ]

View File

@@ -370,6 +370,7 @@ func _host_page() -> Control:
_host_preset.add_item(n) _host_preset.add_item(n)
_host_preset.custom_minimum_size = Vector2(420, 36) _host_preset.custom_minimum_size = Vector2(420, 36)
v.add_child(_host_preset) v.add_child(_host_preset)
_host_worldlet = _worldlet_picker(v)
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10) row.add_theme_constant_override("separation", 10)
var c1 := VBoxContainer.new() var c1 := VBoxContainer.new()
@@ -424,6 +425,7 @@ func _fill_host() -> void:
_host_seed.editable = not existing _host_seed.editable = not existing
_host_seed_label.text = "Seed" if not existing else "Seed (kept)" _host_seed_label.text = "Seed" if not existing else "Seed (kept)"
_host_seed.text = "" if not existing else str(int(c.data.seed)) _host_seed.text = "" if not existing else str(int(c.data.seed))
_show_host_worldlet(existing, meta_path)
_host_msg.text = "Your hosted island carries on where you left it." if existing else "A new island will be made." _host_msg.text = "Your hosted island carries on where you left it." if existing else "A new island will be made."
_host_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM) _host_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
_host_btn.disabled = false _host_btn.disabled = false
@@ -439,7 +441,8 @@ func _start_host() -> void:
if name.is_empty(): if name.is_empty():
name = "%s's island" % Net.get_identity().name name = "%s's island" % Net.get_identity().name
_host_btn.disabled = true _host_btn.disabled = true
Net.host(name, _host_pw.text, int(port_t), PRESET_IDS[maxi(_host_preset.selected, 0)], seed) Net.host(name, _host_pw.text, int(port_t), PRESET_IDS[maxi(_host_preset.selected, 0)], seed,
picked_worldlet(_host_worldlet))
func _on_host_status(text: String, failed: bool) -> void: func _on_host_status(text: String, failed: bool) -> void:
if not _pages["host"].visible: if not _pages["host"].visible:
@@ -591,6 +594,10 @@ func _new_page() -> Control:
_rot_pick.add_item(t[1]) _rot_pick.add_item(t[1])
_rot_pick.custom_minimum_size = Vector2(360, 36) _rot_pick.custom_minimum_size = Vector2(360, 36)
v.add_child(_rot_pick) 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() var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10) h.add_theme_constant_override("separation", 10)
h.add_child(_button("Create & Play", _create, 170)) h.add_child(_button("Create & Play", _create, 170))
@@ -633,7 +640,8 @@ func _fill_list() -> void:
info.add_child(UITheme.label(w.name, 18)) info.add_child(UITheme.label(w.name, 18))
var played := int(w.get("play_time", 0.0)) var played := int(w.get("play_time", 0.0))
var when := Time.get_datetime_string_from_unix_time(int(w.get("last_played", 0)), true) var when := Time.get_datetime_string_from_unix_time(int(w.get("last_played", 0)), true)
info.add_child(UITheme.label("seed %d · played %dh %02dm · %s" % [int(w.seed), played / 3600, (played / 60) % 60, when], 13, UITheme.TEXT_DIM)) info.add_child(UITheme.label("%s · seed %d · played %dh %02dm · %s" % [Terrain.worldlet_name(String(w.get("worldlet", "valley"))),
int(w.seed), played / 3600, (played / 60) % 60, when], 13, UITheme.TEXT_DIM))
h.add_child(info) h.add_child(info)
h.add_child(_button("Play", _play.bind(w.id), 80)) h.add_child(_button("Play", _play.bind(w.id), 80))
var del := Button.new() var del := Button.new()
@@ -681,7 +689,85 @@ func _create() -> void:
var seed_text := _seed_edit.text.strip_edges() var seed_text := _seed_edit.text.strip_edges()
var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi()) var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi())
var rot: String = ROT_CHOICES[maxi(_rot_pick.selected, 0)][0] var rot: String = ROT_CHOICES[maxi(_rot_pick.selected, 0)][0]
_play(Saves.create_world(world_name, seed, {"rot": rot})) _play(Saves.create_world(world_name, seed, {"rot": rot, "worldlet": picked_worldlet(_worldlet_pick)}))
# --- worldlets (docs/WORLDLETS_DESIGN.md, W1) -----------------------------------------
const Terrain = preload("res://scripts/world/terrain.gd")
var _worldlet_pick: OptionButton
var _host_worldlet: OptionButton
## "Where": the worldlets a new world can start in, with the chosen one's
## 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(dev_worldlets())
v.add_child(UITheme.label("Where", 15, UITheme.TEXT_DIM))
var pick := OptionButton.new()
pick.custom_minimum_size = Vector2(360, 36)
pick.focus_mode = Control.FOCUS_NONE
for w in starts:
pick.add_item(String(w.name))
pick.set_item_metadata(pick.item_count - 1, String(w.id))
pick.disabled = starts.size() < 2
v.add_child(pick)
var about := UITheme.label(String(starts[0].description) if not starts.is_empty() else "", 13, UITheme.TEXT_DIM)
about.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
about.custom_minimum_size = Vector2(360, 0)
v.add_child(about)
pick.item_selected.connect(func(i: int):
about.text = String(Terrain.load_worldlet(String(pick.get_item_metadata(i))).get("description", "")))
return pick
## 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 dev:
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
## seed, so once it exists the picker shows that one and stays put.
func _show_host_worldlet(existing: bool, meta_path: String) -> void:
var id := "valley"
if existing:
var meta: Variant = JSON.parse_string(FileAccess.get_file_as_string(meta_path))
if meta is Dictionary:
id = String(meta.get("worldlet", "valley"))
var i := select_worldlet(_host_worldlet, id)
if i >= 0:
_host_worldlet.item_selected.emit(i)
_host_worldlet.disabled = existing or _host_worldlet.item_count < 2
## Selects worldlet `id` in `pick`, adding it if it isn't a starting one
## (an island made somewhere else). Returns its index, or -1.
static func select_worldlet(pick: OptionButton, id: String) -> int:
for i in pick.item_count:
if String(pick.get_item_metadata(i)) == id:
pick.select(i)
return i
if Terrain.load_worldlet(id).is_empty():
return -1
pick.add_item(Terrain.worldlet_name(id))
pick.set_item_metadata(pick.item_count - 1, id)
pick.select(pick.item_count - 1)
return pick.item_count - 1
## The id of the worldlet chosen in `pick` (the Valley if nothing is).
static func picked_worldlet(pick: OptionButton) -> String:
if pick == null or pick.item_count == 0 or pick.selected < 0:
return "valley"
return String(pick.get_item_metadata(pick.selected))
## The tutorial (docs/TUTORIAL_DESIGN.md): its own world on the original ## The tutorial (docs/TUTORIAL_DESIGN.md): its own world on the original
## island (seed 1337), made the first time and carried on after that. ## island (seed 1337), made the first time and carried on after that.

View File

@@ -102,7 +102,7 @@ func _search(max_spread: float, from_spawn: float, from_den: float) -> Vector3:
hs.append(terrain.sample_height(c.x + cos(b) * rad, c.y + sin(b) * rad)) hs.append(terrain.sample_height(c.x + cos(b) * rad, c.y + sin(b) * rad))
var h0: float = terrain.sample_height(c.x, c.y) var h0: float = terrain.sample_height(c.x, c.y)
hs.append(h0) hs.append(h0)
if hs.min() < terrain.water_level + 1.0: if hs.min() < terrain.water_level + 1.0 or terrain.near_stream(c.x, c.y, MOUND_R + 4.0):
continue continue
var spread: float = hs.max() - hs.min() var spread: float = hs.max() - hs.min()
if spread > max_spread: if spread > max_spread:

View File

@@ -0,0 +1 @@
uid://5vs0iggnxpuc

View File

@@ -15,6 +15,8 @@ const DAY_BIAS := 0.2
var day := 1 var day := 1
var time := 8.0 / 24.0 var time := 8.0 / 24.0
var frozen := false ## title-screen backdrop: hold a fixed time 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 sun: DirectionalLight3D
var moon: 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.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.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) 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 env.fog_density = BASE_FOG * w_fog
_sky_timer -= get_process_delta_time() _sky_timer -= get_process_delta_time()

View File

@@ -163,7 +163,7 @@ func _on_new_day(day: int) -> void:
if Weather.hard_winter(day + 1) and not Weather.hard_winter(day): if Weather.hard_winter(day + 1) and not Weather.hard_winter(day):
Actions.tell_all("The air has a bite to it. A hard winter is coming tomorrow: three bitter days. Stock up on food and firewood.") Actions.tell_all("The air has a bite to it. A hard winter is coming tomorrow: three bitter days. Stock up on food and firewood.")
elif Weather.hard_winter(day) and not Weather.hard_winter(day - 1): elif Weather.hard_winter(day) and not Weather.hard_winter(day - 1):
Actions.tell_all("A hard winter has set in. The lake's edge has frozen, and the animals are holed up.") Actions.tell_all("A hard winter has set in. The %s's edge has frozen, and the animals are holed up." % ("lake" if world.worldlet_id() == "valley" else "pond"))
elif Weather.hard_winter(day - 1) and not Weather.hard_winter(day): elif Weather.hard_winter(day - 1) and not Weather.hard_winter(day):
Actions.tell_all("The hard winter is over.") Actions.tell_all("The hard winter is over.")

View File

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

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,8 @@ 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 keep_clear: Array = [] ## [[Vector3, radius]]: the tunnel's seam, the sea cave
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 +419,15 @@ 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
if terrain.near_stream(x, z, 1.2):
return true # (the stream's bed and banks)
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) \ 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 +496,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))
@@ -607,7 +617,7 @@ func _place_logs() -> 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.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0: if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0 or terrain.near_stream(x, z, 1.5):
continue continue
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
var shape: Shape3D var shape: Shape3D
@@ -672,7 +682,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)) or terrain.near_stream(x, z, 0.3):
continue continue
var chance := 1.0 var chance := 1.0
match id: match id:
@@ -734,7 +744,7 @@ func _scatter_berry_bushes(root: Node3D) -> 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.6 or n.y < 0.85: if h < terrain.water_level + 0.6 or n.y < 0.85 or terrain.near_stream(x, z, 1.0):
continue continue
var f := _forest(x, z) var f := _forest(x, z)
var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5 var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5
@@ -816,7 +826,7 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
if not terrain.in_bounds(x, z): if not terrain.in_bounds(x, z):
continue continue
var h: float = terrain.sample_height(x, z) var h: float = terrain.sample_height(x, z)
if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0: if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0 or terrain.near_stream(x, z, -0.2):
continue continue
var n: Vector3 = terrain.sample_normal(x, z) var n: Vector3 = terrain.sample_normal(x, z)
if n.y < 0.72: if n.y < 0.72:

View File

@@ -0,0 +1,39 @@
extends RefCounted
## Boulders for hand-placed rockwork (the cliff tunnel's seam and its faces,
## the Cove's sea cave): the same low-poly rocks as the scenery and the wolf
## den (ProcMesh.rock, rock.gdshader: moss on top, snow up high), each with a
## convex collider that matches it.
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
## The scenery's rock material, for a world's terrain.
static func material(terrain: Node3D) -> ShaderMaterial:
var m := ShaderMaterial.new()
m.shader = preload("res://shaders/rock.gdshader")
m.set_shader_parameter("noise_tex", terrain.noise_tex)
m.set_shader_parameter("snow_height", terrain.snow_height)
return m
## One boulder under `parent` at local `at`, `radii` across (a rock mesh is
## about 1 m round before scaling), turned `turn` radians. Its collider goes
## on `body` (which must share `parent`'s transform), unless `body` is null.
static func add(parent: Node3D, body: CollisionObject3D, seed: int, at: Vector3, radii: Vector3, turn: float,
mat: Material, tall: bool = false) -> MeshInstance3D:
var mesh := ProcMesh.rock(seed, tall)
var b := Basis(Vector3.UP, turn).scaled(radii)
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.material_override = mat
mi.transform = Transform3D(b, at)
parent.add_child(mi)
if body != null:
var hull := mesh.create_convex_shape(true, true) as ConvexPolygonShape3D
var pts := PackedVector3Array()
for p: Vector3 in hull.points:
pts.append(b * p)
hull.points = pts
var cs := CollisionShape3D.new()
cs.shape = hull
cs.position = at
body.add_child(cs)
return mi

View File

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

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

@@ -0,0 +1,114 @@
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).
const RockPile = preload("res://scripts/world/rock_pile.gd")
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: ShaderMaterial = RockPile.material(w.terrain)
var body := StaticBody3D.new()
add_child(body)
# A heap of big boulders at the foot of the headland, with a dark mouth
# between them (local -Z faces the beach; the heap is behind, +Z), and
# seaweed-dark stones scattered on the sand in front.
var s: int = w.terrain.noise_seed + 5100
for part in [[Vector3(-2.4, 1.3, 1.0), Vector3(1.3, 2.1, 1.7), 0.5, true],
[Vector3(2.4, 1.15, 1.0), Vector3(1.25, 1.9, 1.6), 2.0, true],
[Vector3(0.0, 3.75, 1.2), Vector3(2.9, 1.0, 1.9), 0.3, false],
[Vector3(0.0, 1.8, 3.2), Vector3(2.4, 2.4, 1.4), 1.1, false],
[Vector3(-4.3, 0.8, 2.0), Vector3(1.8, 1.5, 1.8), 2.6, false],
[Vector3(4.1, 0.6, 2.2), Vector3(1.6, 1.3, 1.7), 0.7, false],
[Vector3(-2.0, 4.4, 2.8), Vector3(1.8, 1.2, 1.6), 1.7, false],
[Vector3(2.3, 4.1, 3.0), Vector3(1.6, 1.1, 1.5), 2.9, false]]:
RockPile.add(self, body, s, part[0], part[1], part[2], rock, part[3])
s += 1
for stone in [[Vector3(-1.4, 0.08, -1.6), 0.4], [Vector3(1.6, 0.06, -2.2), 0.3],
[Vector3(-3.2, 0.06, -1.0), 0.35], [Vector3(0.4, 0.05, -2.9), 0.22], [Vector3(3.4, 0.05, -1.2), 0.28]]:
RockPile.add(self, null, s, stone[0], Vector3.ONE * float(stone[1]), float(s), rock)
s += 1
var dark := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(2.0, 2.6, 0.1)
dark.mesh = bm
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.015, 0.014, 0.018)
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
dark.material_override = m
dark.position = Vector3(0, 1.3, 1.6)
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)

View File

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

View 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)

View File

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

View File

@@ -97,7 +97,7 @@ func _find_site() -> Vector3:
hs.append(terrain.sample_height(c.x + cos(b) * RING_R, c.y + sin(b) * RING_R)) hs.append(terrain.sample_height(c.x + cos(b) * RING_R, c.y + sin(b) * RING_R))
var h0: float = terrain.sample_height(c.x, c.y) var h0: float = terrain.sample_height(c.x, c.y)
hs.append(h0) hs.append(h0)
if hs.max() - hs.min() > float(pass_[0]) or hs.min() < terrain.water_level + 1.0: if hs.max() - hs.min() > float(pass_[0]) or hs.min() < terrain.water_level + 1.0 or terrain.near_stream(c.x, c.y, RING_R + 3.0):
continue continue
return Vector3(c.x, h0, c.y) return Vector3(c.x, h0, c.y)
return Vector3.INF return Vector3.INF

View File

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

View File

@@ -41,6 +41,15 @@ signal ready_built ## emitted once the mesh + collision exist
@export var cliff_offset: float = 4.0 ## cliff edge, in rim distance past play_radius @export var cliff_offset: float = 4.0 ## cliff edge, in rim distance past play_radius
@export var cliff_drop_width: float = 6.0 ## horizontal run of the drop (smaller = sheerer) @export var cliff_drop_width: float = 6.0 ## horizontal run of the drop (smaller = sheerer)
@export_group("Beach") # (W2: a worldlet's "beach" block; the Valley has none)
@export var beach_angle_deg: float = 90.0
@export var beach_half_width_deg: float = 40.0
@export var beach_fade_deg: float = 20.0
@export var shore_offset: float = -10.0 ## waterline, in rim distance past play_radius
@export var beach_slope: float = 0.08 ## metres deeper per metre out
@export var current_out: float = 20.0 ## the current starts this far past the waterline
@export var wall_out: float = 34.0 ## and the invisible wall stands here
@export_group("Water & snow") @export_group("Water & snow")
@export var water_level: float = -2.6 @export var water_level: float = -2.6
@export var lake_center: Vector2 = Vector2(14.0, -46.0) @export var lake_center: Vector2 = Vector2(14.0, -46.0)
@@ -75,8 +84,15 @@ func _ready() -> void:
var saves := get_node_or_null("/root/Saves") var saves := get_node_or_null("/root/Saves")
if net != null and net.state == "connected" and net.world_info.has("seed"): if net != null and net.state == "connected" and net.world_info.has("seed"):
noise_seed = int(net.world_info.seed) noise_seed = int(net.world_info.seed)
worldlet_id = String(net.world_info.get("worldlet", worldlet_id))
elif saves != null and saves.has_world(): elif saves != null and saves.has_world():
noise_seed = saves.world_seed() noise_seed = saves.world_seed()
# (worlds from before worldlets have none: they're the Valley)
worldlet_id = String(saves.read_meta(saves.current_id).get("worldlet", "valley"))
var why := use_worldlet(worldlet_id)
if not why.is_empty():
push_error("[terrain] %s: using the Valley" % why)
use_worldlet("valley")
_build() _build()
# --- height function -------------------------------------------------------- # --- height function --------------------------------------------------------
@@ -125,24 +141,205 @@ func _cliff_d() -> float:
return play_radius + cliff_offset return play_radius + cliff_offset
## 1 on the open-sea side of the rim, 0 where the mountains are, smooth between ## 1 on the open-sea side of the rim, 0 where the mountains are, smooth between
## (the headlands). Depends only on the angle from the centre. ## (the headlands). Depends only on the angle from the centre. (0 everywhere
## in a worldlet with no sea cliff.)
func _sea_w(x: float, z: float) -> float: func _sea_w(x: float, z: float) -> float:
if not _has_sea:
return 0.0
var diff := absf(wrapf(atan2(z, x) - deg_to_rad(sea_angle_deg), -PI, PI)) var diff := absf(wrapf(atan2(z, x) - deg_to_rad(sea_angle_deg), -PI, PI))
return 1.0 - smoothstep(deg_to_rad(sea_half_width_deg), deg_to_rad(sea_half_width_deg + sea_fade_deg), diff) return 1.0 - smoothstep(deg_to_rad(sea_half_width_deg), deg_to_rad(sea_half_width_deg + sea_fade_deg), diff)
## Boundary threshold in rim distance: partway up the mountains, or just ## The same for a beach (W2): 1 where the land runs down into the sea.
## inside the cliff edge on the sea side. func _beach_w(x: float, z: float) -> float:
if not _has_beach:
return 0.0
var diff := absf(wrapf(atan2(z, x) - deg_to_rad(beach_angle_deg), -PI, PI))
return 1.0 - smoothstep(deg_to_rad(beach_half_width_deg), deg_to_rad(beach_half_width_deg + beach_fade_deg), diff)
## The waterline of the beach, in rim distance.
func _shore_d() -> float:
return play_radius + shore_offset
## Boundary threshold in rim distance: partway up the mountains, just
## inside the cliff edge on the sea side, or well out in the water off a
## beach (where the current turns you back before you reach it).
func _wall_threshold(x: float, z: float) -> float: func _wall_threshold(x: float, z: float) -> float:
return lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z)) var t := lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z))
if _has_beach:
t = lerpf(t, _shore_d() + wall_out, _beach_w(x, z))
return t
func _height_fn(x: float, z: float) -> float: # --- worldlets (docs/WORLDLETS_DESIGN.md, W0) ---------------------------------------
# A worldlet's terrain is a list of building blocks applied in order
# (data/worldlets/<id>.json "terrain"). Each block takes the ground so far and
# returns it changed; "hills" starts it. A worldlet can only combine these.
const BLOCKS := ["hills", "rim", "sea_cliff", "beach", "lake", "stream", "ocean_edge"]
var worldlet_id := "valley"
var worldlet: Dictionary = {} ## the definition, as loaded
var _blocks: Array = [] ## its terrain blocks, in order
var lakes: Array = [] ## every "lake" block's {center: Vector2, radius} (W2: several)
var stream_path := PackedVector2Array() ## a "stream" block's points, spring first (W3: the Cove's)
var stream_width := 2.4
var stream_depth := 0.6
var _stream_profile := PackedFloat32Array() ## its water surface, every STREAM_STEP m along it
var _stream_len := 0.0 ## its length
var _stream_end := 0.0 ## where it meets the sea (its water ends there)
const STREAM_STEP := 1.0
var _has_sea := true ## a sea cliff (the Valley's)
var _has_beach := false ## a beach (W2)
var _valley := 0.0 ## the hills before anything else (the sea cliff eases from it)
var _detail_k := 1.4
## The definition of worldlet `id` (data/worldlets/<id>.json), or {} if none.
static func load_worldlet(id: String) -> Dictionary:
var p := "res://data/worldlets/%s.json" % id
if not FileAccess.file_exists(p):
p = "res://tests/worldlets/%s.json" % id # (test-only worldlets: never listed or offered)
if not FileAccess.file_exists(p):
return {}
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(p))
return d if d is Dictionary else {}
## Every worldlet there is: [{id, name, description}].
static func worldlets() -> Array:
var out: Array = []
for f in DirAccess.get_files_at("res://data/worldlets"):
if f.ends_with(".json"):
var d := load_worldlet(f.get_basename())
if not d.is_empty():
out.append({"id": String(d.id), "name": String(d.get("name", d.id)), "description": String(d.get("description", "")),
"start": bool(d.get("start", false))})
out.sort_custom(func(a, b): return a.id == "valley" or (b.id != "valley" and a.name < b.name))
return out
## The worldlets a new world can start in (the rest are reached on the journey).
static func starting_worldlets() -> Array:
return worldlets().filter(func(w): return w.start)
## A worldlet's display name ("Valley"), or its id if there's no such worldlet.
static func worldlet_name(id: String) -> String:
var d := load_worldlet(id)
return String(d.get("name", id)) if not d.is_empty() else id
## Takes on worldlet `id`: its size and every block's numbers. Returns ""
## on success, else what's wrong (an unknown worldlet or block keeps the old
## settings, so the game still has an island).
func use_worldlet(id: String) -> String:
var d := load_worldlet(id)
if d.is_empty():
return "no worldlet '%s'" % id
for b in d.get("terrain", []):
if not (b is Dictionary) or not String(b.get("type", "")) in BLOCKS:
return "worldlet '%s': unknown terrain block %s" % [id, str(b)]
worldlet_id = id
worldlet = d
size = int(d.get("size", size))
resolution = int(d.get("resolution", resolution))
_blocks = d.get("terrain", [])
lakes = []
stream_path = PackedVector2Array()
_stream_profile = PackedFloat32Array()
_has_sea = _has_block("sea_cliff")
_has_beach = _has_block("beach")
for b in _blocks:
match String(b.type):
"hills":
height_scale = float(b.get("height_scale", height_scale))
_detail_k = float(b.get("detail", _detail_k))
"rim":
play_radius = float(b.get("play_radius", play_radius))
rim_width = float(b.get("rim_width", rim_width))
mountain_height = float(b.get("mountain_height", mountain_height))
rim_warp = float(b.get("rim_warp", rim_warp))
wall_frac = float(b.get("wall_frac", wall_frac))
"sea_cliff":
sea_angle_deg = float(b.get("angle_deg", sea_angle_deg))
sea_half_width_deg = float(b.get("half_width_deg", sea_half_width_deg))
sea_fade_deg = float(b.get("fade_deg", sea_fade_deg))
sea_level = float(b.get("sea_level", sea_level))
cliff_top = float(b.get("cliff_top", cliff_top))
cliff_offset = float(b.get("cliff_offset", cliff_offset))
cliff_drop_width = float(b.get("drop_width", cliff_drop_width))
"stream":
stream_path = PackedVector2Array()
for q in b.get("points", []):
stream_path.append(Vector2(float(q[0]), float(q[1])))
stream_width = float(b.get("width", stream_width))
stream_depth = float(b.get("depth", stream_depth))
"beach":
beach_angle_deg = float(b.get("angle_deg", beach_angle_deg))
beach_half_width_deg = float(b.get("half_width_deg", beach_half_width_deg))
beach_fade_deg = float(b.get("fade_deg", beach_fade_deg))
shore_offset = float(b.get("shore_offset", shore_offset))
beach_slope = float(b.get("slope", beach_slope))
current_out = float(b.get("current_out", current_out))
wall_out = float(b.get("wall_out", wall_out))
"lake":
# Every lake and pond shares one surface (water_level): the
# first lake sets it, and is "the lake" to the older code
# (lake_center/lake_radius: ice, its fish, the tutorial).
var c: Array = b.get("center", [lake_center.x, lake_center.y])
var at := Vector2(float(c[0]), float(c[1]))
var r := float(b.get("radius", lake_radius))
b["_c"] = at
b["_r"] = r
if lakes.is_empty():
lake_center = at
lake_radius = r
water_level = float(b.get("water_level", water_level))
lakes.append({"center": at, "radius": r})
if _has_beach:
sea_level = water_level # (a beach runs into the sea: one surface)
snow_height = float(d.get("climate", {}).get("snowline", snow_height))
return ""
func _has_block(t: String) -> bool:
for b in _blocks:
if String(b.type) == t:
return true
return false
## The ground at (x, z). `stop_at_stream`: as it is before the stream cuts
## its bed (what the stream's water level is worked out from).
func _height_fn(x: float, z: float, stop_at_stream: bool = false) -> float:
_ensure_noise() _ensure_noise()
var valley := _noise.get_noise_2d(x, z) * height_scale if _blocks.is_empty():
valley += _detail.get_noise_2d(x, z) * 1.4 use_worldlet(worldlet_id)
var d := _rim_d(x, z) if not stop_at_stream and stream_path.size() >= 2 and _stream_profile.is_empty():
_make_stream_profile()
var land := 0.0
for b in _blocks:
match String(b.type):
"hills":
land = _blk_hills(x, z)
"rim":
land = _blk_rim(x, z, land)
"sea_cliff":
land = _blk_sea_cliff(x, z, land)
"beach":
land = _blk_beach(x, z, land)
"lake":
land = _blk_lake(x, z, land, b._c, b._r)
"stream":
if stop_at_stream:
return land
land = _blk_stream(x, z, land)
"ocean_edge":
land = _blk_ocean_edge(x, z, land)
return land
# Mountain rim. ## Rolling hills: the ground everything else is built on.
var land := valley func _blk_hills(x: float, z: float) -> float:
var valley := _noise.get_noise_2d(x, z) * height_scale
valley += _detail.get_noise_2d(x, z) * _detail_k
_valley = valley
return valley
## The mountain rim: rises from play_radius, stepped into ledges.
func _blk_rim(x: float, z: float, land: float) -> float:
var valley := _valley
var d := _rim_d(x, z)
var t := smoothstep(play_radius, play_radius + rim_width, d) var t := smoothstep(play_radius, play_radius + rim_width, d)
if t > 0.0: if t > 0.0:
var ridge := 1.0 - absf(_ridge.get_noise_2d(x, z)) var ridge := 1.0 - absf(_ridge.get_noise_2d(x, z))
@@ -153,7 +350,12 @@ func _height_fn(x: float, z: float) -> float:
var stepped := floorf(m / band) * band + smoothstep(0.35, 0.65, fposmod(m, band) / band) * band var stepped := floorf(m / band) * band + smoothstep(0.35, 0.65, fposmod(m, band) / band) * band
m = lerpf(m, stepped, 0.45 * t) m = lerpf(m, stepped, 0.45 * t)
land = valley * (1.0 - t * 0.6) + m land = valley * (1.0 - t * 0.6) + m
return land
## The sea side of the rim: the valley eases to a cliff top, then drops sheer.
func _blk_sea_cliff(x: float, z: float, land: float) -> float:
var valley := _valley
var d := _rim_d(x, z)
# Sea side: the valley eases to a cliff top, then drops sheer to the seabed. # Sea side: the valley eases to a cliff top, then drops sheer to the seabed.
# The edge sits 1 m past the invisible wall at every angle, including the # The edge sits 1 m past the invisible wall at every angle, including the
# headlands where the wall moves outward. (With the edge fixed at _cliff_d(), # headlands where the wall moves outward. (With the edge fixed at _cliff_d(),
@@ -167,14 +369,129 @@ func _height_fn(x: float, z: float) -> float:
var seabed := sea_level - 7.0 - minf(maxf(d - edge, 0.0), 60.0) * 0.08 + _detail.get_noise_2d(x, z) * 2.0 var seabed := sea_level - 7.0 - minf(maxf(d - edge, 0.0), 60.0) * 0.08 + _detail.get_noise_2d(x, z) * 2.0
coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d)) coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d))
land = lerpf(land, coast, sw) land = lerpf(land, coast, sw)
return land
# Lake basin: blend the ground down to a bed below the waterline. ## A lake basin: the ground blends down to a bed below the waterline.
var ld := Vector2(x - lake_center.x, z - lake_center.y).length() func _blk_lake(x: float, z: float, land: float, at: Vector2, r: float) -> float:
var ld := Vector2(x - at.x, z - at.y).length()
ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0 ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0
land = lerpf(land, water_level - 3.2, smoothstep(lake_radius, lake_radius * 0.35, ld)) return lerpf(land, water_level - 3.2, smoothstep(r, r * 0.35, ld))
# Ocean all round the chunk edge, so the world is an island and the edge of ## A stream (W3): runs along its points from a spring to the sea. Its water
# the mesh is always hidden under deep water. ## surface comes down step by step from the ground at the spring, never
## rising (the running minimum of the land along it), so it always runs
## downhill. The bed is cut stream_depth below the surface, the banks rise a
## little above it (so the water never hangs over lower ground) and blend
## into the land, and where the land is high it cuts a little valley.
func _make_stream_profile() -> void:
_stream_profile = PackedFloat32Array()
_stream_len = 0.0
for i in range(1, stream_path.size()):
_stream_len += stream_path[i - 1].distance_to(stream_path[i])
_stream_end = _stream_len
var surf := INF
var n := int(_stream_len / STREAM_STEP) + 1
for i in range(n + 1):
var p := _stream_point(i * STREAM_STEP)
var g := _height_fn(p.x, p.y, true)
surf = minf(surf - 0.01, g - 0.25) if surf < INF else g - 0.25
surf = maxf(surf, water_level)
_stream_profile.append(surf)
if g < water_level - 0.2 and _stream_end >= _stream_len:
_stream_end = i * STREAM_STEP # (into the sea: the stream's done)
## The point `along` metres down the stream.
func _stream_point(along: float) -> Vector2:
var left := along
for i in range(1, stream_path.size()):
var seg := stream_path[i - 1].distance_to(stream_path[i])
if left <= seg or i == stream_path.size() - 1:
return stream_path[i - 1].lerp(stream_path[i], clampf(left / maxf(seg, 0.001), 0.0, 1.0))
left -= seg
return stream_path[stream_path.size() - 1]
## How far (x, z) is from the stream's line, and how far along it that is.
func _stream_near(x: float, z: float) -> Vector2:
var p := Vector2(x, z)
var best := INF
var best_along := 0.0
var run := 0.0
for i in range(1, stream_path.size()):
var a := stream_path[i - 1]
var b := stream_path[i]
var ab := b - a
var t := clampf((p - a).dot(ab) / maxf(ab.length_squared(), 0.001), 0.0, 1.0)
var d := p.distance_to(a + ab * t)
if d < best:
best = d
best_along = run + t * ab.length()
run += ab.length()
return Vector2(best, best_along)
func _stream_surface_at(along: float) -> float:
var f := clampf(along / STREAM_STEP, 0.0, _stream_profile.size() - 1.001)
var i := int(f)
return lerpf(_stream_profile[i], _stream_profile[i + 1], f - i)
func _blk_stream(x: float, z: float, land: float) -> float:
if _stream_profile.size() < 2:
return land
var near := _stream_near(x, z)
var hw := stream_width * 0.5
var k := near.x / hw
if k > 6.0:
return land
var along := near.y
var surf := _stream_surface_at(along)
var target: float
if k < 1.0:
target = surf - stream_depth * (1.0 - k * k)
else:
target = surf + 0.12 + (k - 1.0) * 0.3
# Down to the bed, up to the banks; where the land's high, a gentle
# valley rather than a slot.
var w := smoothstep(6.0, 1.6, k) if land > target else smoothstep(2.6, 1.6, k)
w *= smoothstep(0.0, 5.0, along) # (the spring)
w *= 1.0 - smoothstep(_stream_end - 8.0, _stream_end, along) # (into the sea)
return lerpf(land, target, w)
## The stream's water surface at (x, z), or -INF where there's no stream.
func stream_surface(x: float, z: float) -> float:
if _stream_profile.size() < 2:
return -INF
var near := _stream_near(x, z)
if near.x > stream_width * 0.5 or near.y > _stream_end - 1.0 or near.y < 0.5:
return -INF
return _stream_surface_at(near.y)
## Whether (x, z) is in or by the stream (within `margin` m of its banks):
## scenery and landmarks keep clear.
func near_stream(x: float, z: float, margin: float = 1.0) -> bool:
if _stream_profile.size() < 2:
return false
var near := _stream_near(x, z)
return near.x < stream_width * 0.5 + margin and near.y < _stream_end
## A beach (W2): on its side of the rim the land eases down to a sandy
## waterline, then shelves gently into the sea (beach_slope deeper per
## metre), so you can wade, then swim, until the current turns you back.
func _blk_beach(x: float, z: float, land: float) -> float:
var bw := _beach_w(x, z)
if bw <= 0.0:
return land
var d := _rim_d(x, z)
var shore := _shore_d()
var profile: float
if d < shore:
profile = water_level + (shore - d) * 0.12 + _detail.get_noise_2d(x * 0.5, z * 0.5) * 0.3
else:
profile = maxf(water_level - (d - shore) * beach_slope, water_level - 14.0) + _detail.get_noise_2d(x, z) * 0.25
var coast := lerpf(_valley, profile, smoothstep(shore - 50.0, shore - 8.0, d))
return lerpf(land, coast, bw)
## Ocean all round the chunk edge, so the world is an island and the edge of
## the mesh is always hidden under deep water.
func _blk_ocean_edge(x: float, z: float, land: float) -> float:
var e := smoothstep(size * 0.5 - 28.0, size * 0.5 - 4.0, sqrt(x * x + z * z)) var e := smoothstep(size * 0.5 - 28.0, size * 0.5 - 4.0, sqrt(x * x + z * z))
return lerpf(land, sea_level - 10.0 + _detail.get_noise_2d(x, z) * 2.0, e) return lerpf(land, sea_level - 10.0 + _detail.get_noise_2d(x, z) * 2.0, e)
@@ -370,8 +687,14 @@ func _build_water() -> void:
arrays[Mesh.ARRAY_INDEX] = idx arrays[Mesh.ARRAY_INDEX] = idx
var disc := ArrayMesh.new() var disc := ArrayMesh.new()
disc.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) disc.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
if absf(sea_level - water_level) > 0.05: # (by a beach the sea is the same surface: no second sheet)
_add_water("Water", disc, water_level, {}) _add_water("Water", disc, water_level, {})
if _stream_profile.size() >= 2:
# (Murkier than the lake, so half a metre of water reads as water, not a path.)
_add_water("Stream", _stream_mesh(), 0.0, {"clarity": 0.45,
"shallow_col": Color(0.20, 0.42, 0.46), "deep_col": Color(0.05, 0.16, 0.22)})
var ocean := PlaneMesh.new() var ocean := PlaneMesh.new()
ocean.size = Vector2(8000.0, 8000.0) ocean.size = Vector2(8000.0, 8000.0)
_add_water("Ocean", ocean, sea_level, { _add_water("Ocean", ocean, sea_level, {
@@ -380,6 +703,40 @@ func _build_water() -> void:
"clarity": 6.0, "clarity": 6.0,
}) })
## The stream's water: a ribbon down its bed, just under the tops of the
## banks, each point at the surface there. It stops where it meets the sea
## (whose own water carries on from there).
func _stream_mesh() -> ArrayMesh:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var hw := stream_width * 0.5 * 1.05
var prev_l := Vector3.ZERO
var prev_r := Vector3.ZERO
var have := false
var along := 0.6
var stop := _stream_end - 1.0
while along < stop:
var p := _stream_point(along)
var q := _stream_point(minf(along + 0.5, _stream_len))
var dir := (q - p).normalized() if q.distance_to(p) > 0.001 else Vector2(0, 1)
var side := Vector2(-dir.y, dir.x) * hw
var y := _stream_surface_at(along) + 0.02
if y <= water_level + 0.06:
break # (level with the sea: its water takes over)
var l := Vector3(p.x + side.x, y, p.y + side.y)
var r := Vector3(p.x - side.x, y, p.y - side.y)
if have:
for v in [prev_l, l, prev_r, prev_r, l, r]:
st.set_normal(Vector3.UP)
st.set_tangent(Plane(1, 0, 0, 1))
st.set_uv(Vector2(v.x, v.z) / size + Vector2(0.5, 0.5))
st.add_vertex(v)
prev_l = l
prev_r = r
have = true
along += 0.75
return st.commit()
func _add_water(label: String, mesh: Mesh, y: float, params: Dictionary) -> void: func _add_water(label: String, mesh: Mesh, y: float, params: Dictionary) -> void:
var mat := ShaderMaterial.new() var mat := ShaderMaterial.new()
mat.shader = preload("res://shaders/water.gdshader") mat.shader = preload("res://shaders/water.gdshader")
@@ -411,6 +768,26 @@ func sea_weight(x: float, z: float) -> float:
_ensure_noise() _ensure_noise()
return _sea_w(x, z) return _sea_w(x, z)
## How strongly (x, z) lies on the beach side of the rim (0..1; W2).
func beach_weight(x: float, z: float) -> float:
_ensure_noise()
return _beach_w(x, z)
## Off a beach, the current that turns swimmers back before the wall: 0
## (none) to 1 (full strength, just inside the wall). Always 0 elsewhere.
func current_at(x: float, z: float) -> float:
_ensure_noise()
if _beach_w(x, z) < 0.5:
return 0.0
var from := _shore_d() + current_out
return smoothstep(from, maxf(_wall_threshold(x, z) - 3.0, from + 1.0), _rim_d(x, z))
## A point out in the bay off the beach, `past_shore` metres beyond the waterline.
func shore_point(past_shore: float) -> Vector2:
_ensure_noise()
var dir := Vector2(cos(deg_to_rad(beach_angle_deg)), sin(deg_to_rad(beach_angle_deg)))
return dir * (_shore_d() + past_shore - _rim_offset(dir.x, dir.y))
## Point on the coast along `angle_deg` (atan2(z, x) convention), at rim ## Point on the coast along `angle_deg` (atan2(z, x) convention), at rim
## distance `past_edge` beyond the cliff edge (negative = inland of it). ## distance `past_edge` beyond the cliff edge (negative = inland of it).
func coast_point(angle_deg: float, past_edge: float) -> Vector2: func coast_point(angle_deg: float, past_edge: float) -> Vector2:

Some files were not shown because too many files have changed in this diff Show More