27 Commits

Author SHA1 Message Date
Paul Bailey
1bf33935e0 Download page: forward UDP 24243 too for the Cove; test_net_lands can run against an exported build (EMBERWILD_SERVER_BIN) 2026-11-07 00:33:03 +00:00
Paul Bailey
62b3d6c47f 0.5.0: the journey on a server (a server per land; chat, the player list, admin and sleep across lands); protocol 17 2026-11-07 00:23:43 +00:00
Paul Bailey
f9b138b304 J3c: a land server stopping tells its players and is started afresh when they rejoin; the extra ports to forward on the Host page, the hosting message, the server log and HOSTING.md 2026-11-07 00:08:05 +00:00
Paul Bailey
86ebe07899 J3b: chat, the player list (with each player's land) and admin commands across lands; sleep needs everyone in every land, with a 'waiting for everyone else' message 2026-11-06 23:42:50 +00:00
Paul Bailey
85bbed249b J3a: the journey on a server. A server per land (Lands: the hub starts land servers on demand over a private link), the cliff tunnel online, crossings with one-time passes, shared characters that remember their land, one clock from the hub; test_net_lands 2026-11-06 23:14:11 +00:00
Paul Bailey
6544862885 Map: other players in your land shown on a co-op server, never with PvP on 2026-11-06 22:33:14 +00:00
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
Paul Bailey
40f5e25c41 Version 0.3.1 2026-11-06 14:05:24 +00:00
Paul Bailey
eaeb841c1d Lights to place indoors (tallow candle, torch stand); docs brought up to date; the journey plan; sticky teleports in net tests; protocol 15 2026-11-06 13:56:43 +00:00
Paul Bailey
5f40fa3f7b Version 0.3.0 2026-11-06 11:58:27 +00:00
Paul Bailey
4b2e687188 Uses for every material (clay jar, bedroll, pelt rug, fang necklace, bear cloak, pemmican, bait), Q to drop, K/M bag rows on 1-8, help pages; protocol 14 2026-11-06 11:49:42 +00:00
Paul Bailey
5778d61763 Version 0.2.1 2026-11-06 00:12:53 +00:00
Paul Bailey
787d8d1d65 Old Tam speaks (Kokoro); CC0 birdsong, crickets and owls; no plants through floors; menu and marker fixes 2026-11-06 00:03:59 +00:00
Paul Bailey
0550a4daa0 tools/publish_release.sh: create the Gitea release, upload and verify 2026-11-05 21:06:33 +00:00
252 changed files with 7679 additions and 301 deletions

3
.gitignore vendored
View File

@@ -21,3 +21,6 @@ Thumbs.db
# Cloudflare wrangler cache (tools/site deploys) # Cloudflare wrangler cache (tools/site deploys)
.wrangler/ .wrangler/
# Kokoro TTS environment (tools/voice/make_voice.sh sets it up)
tools/voice/.venv/

View File

@@ -16,7 +16,26 @@ 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.
Current milestone: **M0 — playable single-player world skeleton** (scaffolding on disk; needs Godot 4 installed to open & run). **Emberwild 0.5.0** is released (download page and player guide:
https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
- P1-P5 (`docs/PROGRESSION_DESIGN.md`): iron, stone building, the wolf moon and
alpha, the bear, crows and foxes, the barrow, hazards, the Old Stag. P6: a use
for every material, lights to place, dropping items, bag rows on the hotbar.
- T1, T2, T4 (`docs/TUTORIAL_DESIGN.md`): the tutorial with Old Tam (spoken),
and his tips in normal worlds.
- M4 so far: packaged builds, the update check, music, ambience, sound effects
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages,
the map (N) and compass.
- W0-W3, J1, J3 (`docs/WORLDLETS_DESIGN.md`): worldlets as data, the Cove
(beach, pier, sea fishing, a stream), and the cliff tunnel between them,
with a save file per worldlet; on a server, a server per land (the Valley's
starts the Cove's on the next UDP port), with chat, the player list,
admin commands and sleep across lands, and one clock everywhere.
Next: J2 (each tunnel's own gate and supplies), then the Highlands (by sail).
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).

View File

@@ -19,9 +19,36 @@ http://creativecommons.org/publicdomain/zero/1.0/". Thank you, Kenney.
Which file is which is listed in `KENNEY` in `tools/audio/make_audio.py`, Which file is which is listed in `KENNEY` in `tools/audio/make_audio.py`,
which also re-copies them (`--kenney <folder with the unzipped packs>`). which also re-copies them (`--kenney <folder with the unzipped packs>`).
## SamsterBirdies (CC0)
`ambience/birds.ogg` is from "Bird ambience. Coniferous forest dawn chorus."
by SamsterBirdies (https://freesound.org/people/SamsterBirdies/sounds/745273/,
License: Creative Commons 0): its first 90 s (kept in
`tools/audio/sources/birds_samsterbirdies_745273.ogg`), softened a little and
made into a seamless loop by `tools/audio/make_audio.py birds`. Thank you.
## OwlStorm (CC0)
`ambience/crickets.ogg` is "Night Crickets Ambience on Rural Property" by
OwlStorm (https://freesound.org/people/OwlStorm/sounds/320145/, License:
Creative Commons 0; the preview is in `tools/audio/sources/`), with the low
rumble taken out and made into a seamless loop by
`tools/audio/make_audio.py crickets`. Thank you.
## Breviceps (CC0)
`ambience/owl_0.ogg` and `owl_1.ogg` are the two hoots in "Owl Hoot" by
Breviceps (https://freesound.org/people/Breviceps/sounds/465697/, License:
Creative Commons 0; the preview is in `tools/audio/sources/`), cut at 0.12 s
and 2.65 s (leaving out the short sound between them), then levelled. Thank you.
## Old Tam's voice
`voice/` is spoken by **Kokoro** (Kokoro-82M by hexgrad, Apache-2.0,
https://huggingface.co/hexgrad/Kokoro-82M) with its British "George" voice,
made a little older (deeper, slower, warmer) by `tools/voice/make_voice.py`.
The recordings are generated from our own text; rebuild them with
`tools/voice/make_voice.sh` after changing any line.
## Emberwild's own (CC0) ## Emberwild's own (CC0)
`music/`, `ambience/` and `sfx/synth/` are synthesised from scratch by `music/`, `ambience/` and `sfx/synth/` are synthesised from scratch by
`tools/audio/make_audio.py` (no samples): the five music tracks (calm, `tools/audio/make_audio.py` (no samples): the five music tracks (calm,
night, storm, danger, boss), the birds, crickets and fire crackle, and the night, storm, danger, boss), the fire crackle, the creature voices, and the
swings, eating, splashes, the fishing reel and bite, the bow, the hurt thump swings, eating, splashes, the fishing reel and bite, the bow, the hurt thump
and death. Released under CC0 like the rest of this folder. and death. Released under CC0 like the rest of this folder.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,43 @@
{
"hint_cold": "91c5088e453fb0e6",
"hint_cove": "126f0b5e977fbef7",
"hint_died": "e865f8a63622b3c3",
"hint_diet": "3178b319c44e9fa0",
"hint_freezing": "48b1e505119a0fc9",
"hint_hard_winter": "5513aca1655bc278",
"hint_howl": "05792dda3f3f0b60",
"hint_hungry": "ad76f22cb8e034a0",
"hint_night": "2a07d9c0bfae4bf9",
"hint_rotten": "98b69de03dbee089",
"hint_seam": "b353f46816d329b7",
"hint_sheltered": "ba6d6ff8bc4a1c80",
"hint_sick": "ec07963c46638ffc",
"hint_snow": "8f0ac7d093f73840",
"hint_stamina": "82bf5b903db7a29f",
"hint_storm": "f06bb82d15d991c8",
"hint_wet": "6aef86b85d67d7ac",
"hint_wolf_moon": "723b3dbd5439980c",
"tutorial_animals": "e82eb7e35e866812",
"tutorial_axe": "4bb51d6ea8b42840",
"tutorial_bed": "34cf4c842db48c19",
"tutorial_berries": "a7cd3805f11c8898",
"tutorial_campfire": "0a26bd897d1d8a90",
"tutorial_cook": "7164a8ab27a7821d",
"tutorial_diet": "fd47408ab4f2b5de",
"tutorial_finish": "36c07fc5a64c9d65",
"tutorial_fish": "32f2f3228c036136",
"tutorial_flax": "a87ff75ed8864ca6",
"tutorial_hut": "a10bfa289939dfb2",
"tutorial_look": "27acdf33905cc1a3",
"tutorial_mushroom": "79edcfb1afe18c8c",
"tutorial_pickaxe": "177e0474e4d0fda6",
"tutorial_rabbit": "6d429ab2cbdc7b19",
"tutorial_rod": "d610281e722e8fd3",
"tutorial_sleep": "e202cbf133500c2a",
"tutorial_sticks": "e526e30ddf8756a7",
"tutorial_torch": "68808fca608352fc",
"tutorial_tree": "1dfb2ab0491d962f",
"tutorial_weapon": "a17d1fe6d5067b8d",
"tutorial_wolf": "feb6bdda64e28161",
"tutorial_workbench": "1b6108ab18f05de9"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@@ -347,7 +347,7 @@
"name": "Hide", "name": "Hide",
"category": "resource", "category": "resource",
"stack": 20, "stack": 20,
"description": "Raw hide from a deer or boar. Makes a warm cloak.", "description": "Rawhide from an animal. For cloaks and bedrolls.",
"mesh": "hide" "mesh": "hide"
}, },
{ {
@@ -355,7 +355,7 @@
"name": "Feather", "name": "Feather",
"category": "resource", "category": "resource",
"stack": 50, "stack": 50,
"description": "Found in meadows. Fletching for arrows.", "description": "Fletching for arrows, and stuffing for a bedroll.",
"mesh": "feather" "mesh": "feather"
}, },
{ {
@@ -443,7 +443,7 @@
"name": "Wolf Pelt", "name": "Wolf Pelt",
"category": "resource", "category": "resource",
"stack": 20, "stack": 20,
"description": "Thick grey fur. The warmest thing on the island.", "description": "A grey wolf pelt. For wolf and alpha cloaks, or a rug.",
"mesh": "wolf_pelt" "mesh": "wolf_pelt"
}, },
{ {
@@ -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",
@@ -564,7 +630,7 @@
"name": "Clay", "name": "Clay",
"category": "resource", "category": "resource",
"stack": 50, "stack": 50,
"description": "Grey clay from the lake shore. For kilns and smelters.", "description": "Grey clay from the lake shore. For kilns, smelters and, fired at a campfire, clay jars that keep food fresh.",
"mesh": "clay" "mesh": "clay"
}, },
{ {
@@ -928,7 +994,7 @@
"name": "Alpha Pelt", "name": "Alpha Pelt",
"category": "resource", "category": "resource",
"stack": 5, "stack": 5,
"description": "The alpha's black fur, thick as a blanket.", "description": "The alpha's dark pelt. For the alpha cloak or a fang necklace.",
"mesh": "alpha_pelt" "mesh": "alpha_pelt"
}, },
{ {
@@ -936,7 +1002,7 @@
"name": "Wolf Fang", "name": "Wolf Fang",
"category": "resource", "category": "resource",
"stack": 20, "stack": 20,
"description": "A long yellowed fang from the alpha. A trophy, and a fine clasp.", "description": "A long yellowed fang from the alpha. For the alpha cloak, and a fang necklace that blunts wolf bites.",
"mesh": "wolf_fang" "mesh": "wolf_fang"
}, },
{ {
@@ -956,7 +1022,7 @@
"name": "Bear Hide", "name": "Bear Hide",
"category": "resource", "category": "resource",
"stack": 5, "stack": 5,
"description": "A huge brown hide. Makes a fur rug.", "description": "A huge brown hide. For a fur rug or a bear cloak, the warmest there is.",
"mesh": "bear_hide" "mesh": "bear_hide"
}, },
{ {
@@ -964,7 +1030,7 @@
"name": "Bear Fat", "name": "Bear Fat",
"category": "resource", "category": "resource",
"stack": 20, "stack": 20,
"description": "Rendered fat. Burns three times as long as wood in a fire.", "description": "Thick white fat. Burns long on a fire, and makes pemmican and tallow candles.",
"mesh": "bear_fat", "mesh": "bear_fat",
"fuel": 180 "fuel": 180
}, },
@@ -996,7 +1062,7 @@
"name": "Rotten Food", "name": "Rotten Food",
"category": "resource", "category": "resource",
"stack": 50, "stack": 50,
"description": "It's gone off. Good for nothing: throw it out.", "description": "It's gone off. Q drops it. Carried while fishing it's bait: bites come sooner, and each bite takes one.",
"mesh": "rotten_food" "mesh": "rotten_food"
}, },
{ {
@@ -1129,6 +1195,148 @@
}, },
"station": "forge" "station": "forge"
} }
},
{
"id": "clay_jar",
"name": "Clay Jar",
"category": "placeable",
"stack": 5,
"description": "Fired clay with a lid. Holds 6 stacks, and food kept in it lasts three times as long.",
"mesh": "clay_jar",
"place": {
"piece": "jar",
"footprint": [
0.7,
0.7
]
}
},
{
"id": "bedroll",
"name": "Bedroll",
"category": "placeable",
"stack": 1,
"description": "Hide stuffed with feathers. Sets your spawn point like a bed, and you can sleep the night out under the open sky (but not in rain or snow).",
"mesh": "bedroll",
"place": {
"piece": "bed",
"footprint": [
0.9,
2.0
],
"open_air": true
}
},
{
"id": "pelt_rug",
"name": "Wolf-pelt Rug",
"category": "placeable",
"stack": 1,
"description": "Wolf pelts stitched into a rug. In a sheltered room: a little warmer, and you get Rested twice as fast.",
"mesh": "pelt_rug",
"place": {
"piece": "rug",
"footprint": [
1.4,
1.8
]
}
},
{
"id": "fang_necklace",
"name": "Fang Necklace",
"category": "tool",
"stack": 1,
"description": "The alpha's fangs on a strip of its hide. Carry it anywhere in your bag and wolves, the alpha too, bite 30% less hard.",
"mesh": "fang_necklace",
"charm": {
"against": "wolf",
"damage": 0.7
}
},
{
"id": "bear_cloak",
"name": "Bear Cloak",
"category": "tool",
"stack": 1,
"description": "Right-click to wear it (+40 warmth, rain barely touches you): the warmest cloak there is. Take it off from the inventory screen.",
"mesh": "bear_cloak",
"wear": {
"warmth": 40,
"wet_chill": 0.3
}
},
{
"id": "pemmican",
"name": "Pemmican",
"category": "food",
"stack": 20,
"description": "Cooked meat and berries pounded into bear fat. Filling, counts as meat and greens, and keeps 30 days.",
"mesh": "pemmican",
"keeps": 30,
"food": {
"hunger": 40,
"health": 10,
"diet": {
"meat": 28,
"plants": 28
}
}
},
{
"id": "tallow_candle",
"name": "Tallow Candle",
"category": "placeable",
"stack": 20,
"description": "Bear fat and a fibre wick. Set it on a floor or a step and it lights a small room, steadily, for good.",
"mesh": "tallow_candle",
"place": {
"piece": "lamp",
"footprint": [
0.2,
0.2
]
},
"lamp": {
"range": 7.0,
"energy": 1.2,
"height": 0.2,
"flicker": 0.08,
"flame": 0.022,
"color": [
1.0,
0.82,
0.6
]
}
},
{
"id": "torch_stand",
"name": "Torch Stand",
"category": "placeable",
"stack": 5,
"description": "A torch on a wooden stand. Lights a whole room or a doorway, and never goes out.",
"mesh": "torch_stand",
"place": {
"piece": "lamp",
"footprint": [
0.5,
0.5
]
},
"lamp": {
"range": 11.0,
"energy": 1.6,
"height": 1.45,
"flicker": 0.22,
"flame": 0.06,
"color": [
1.0,
0.74,
0.5
],
"shadows": true
}
} }
] ]
} }

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",
@@ -589,6 +631,98 @@
"iron_bar": 1, "iron_bar": 1,
"wood": 2 "wood": 2
} }
},
{
"id": "clay_jar",
"output": "clay_jar",
"count": 1,
"station": "campfire",
"category": "furniture",
"inputs": {
"clay": 8
}
},
{
"id": "bedroll",
"output": "bedroll",
"count": 1,
"station": "workbench",
"category": "furniture",
"inputs": {
"hide": 3,
"feather": 6,
"fiber": 4
}
},
{
"id": "pelt_rug",
"output": "pelt_rug",
"count": 1,
"station": "workbench",
"category": "furniture",
"inputs": {
"wolf_pelt": 3,
"fiber": 2
}
},
{
"id": "fang_necklace",
"output": "fang_necklace",
"count": 1,
"station": "workbench",
"category": "clothing",
"inputs": {
"wolf_fang": 2,
"alpha_pelt": 1,
"fiber": 2
}
},
{
"id": "bear_cloak",
"output": "bear_cloak",
"count": 1,
"station": "workbench",
"category": "clothing",
"inputs": {
"bear_hide": 2,
"hide": 2,
"fiber": 4
}
},
{
"id": "pemmican",
"output": "pemmican",
"count": 3,
"station": "campfire",
"category": "cooking",
"inputs": {
"cooked_meat": 2,
"berries": 3,
"bear_fat": 1
}
},
{
"id": "tallow_candle",
"output": "tallow_candle",
"count": 4,
"station": "",
"category": "furniture",
"inputs": {
"bear_fat": 1,
"fiber": 1
}
},
{
"id": "torch_stand",
"output": "torch_stand",
"count": 1,
"station": "",
"category": "furniture",
"inputs": {
"wood": 2,
"stick": 2,
"fiber": 2
}
} }
] ]
} }

View File

@@ -41,7 +41,7 @@
"title": "Gather sticks and stones", "title": "Gather sticks and stones",
"text": "Empty hands are no use here. Look at a stick or a stone on the ground and press E. Three of each, mind.", "text": "Empty hands are no use here. Look at a stick or a stone on the ground and press E. Three of each, mind.",
"marker": { "marker": {
"nearest": "pickup:stick" "nearest": "pickup:stick|stone"
}, },
"goal": { "goal": {
"all": [ "all": [

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

@@ -113,15 +113,22 @@ To check it works: `tailscale ping <server name>` from the friend's
computer. computer.
### Port forwarding ### Port forwarding
On your router, forward **UDP 24242** to the server computer's local On your router, forward **UDP 24242 and 24243** to the server computer's
address (give that computer a fixed local address first). Friends join local address (give that computer a fixed local address first). Friends
with your public IP address. It needs no extra software, but your home IP join with your public IP address. It needs no extra software, but your
and that port are public, so set a `password`. home IP and those ports are public, so set a `password`.
Why two: each land of the journey runs as its own server on the next port
up (24243 is the Cove's, when the server's on 24242). The server players
join (the Valley's) starts it when someone first goes there and stops it
when it's been empty a couple of minutes; everyone still joins on 24242.
Host Game and the server's log both say which ports to forward. With
Tailscale, or on the same Wi-Fi, there's nothing to do.
### A cloud server ### A cloud server
Any small Linux server works (it needs little: a couple of GB of memory). Any small Linux server works (it needs little: a couple of GB of memory).
Install Godot, clone the game, use the systemd file, and open UDP 24242 in Install Godot, clone the game, use the systemd file, and open UDP 24242
the provider's firewall. and 24243 in the provider's firewall.
## Troubleshooting ## Troubleshooting
- **"Different game version":** the server and the player are on different - **"Different game version":** the server and the player are on different
@@ -132,6 +139,11 @@ the provider's firewall.
- **"Port 24242 is already in use":** another server is running on this - **"Port 24242 is already in use":** another server is running on this
computer (a dedicated one, or a Host Game that didn't stop). Stop it, or computer (a dedicated one, or a Host Game that didn't stop). Stop it, or
host on another port. host on another port.
- **"Lost the connection to the Cove":** that land's server stopped (it
logs to `land-cove.log` beside `server.json`). Join again: the server
starts it afresh and sends you back there.
- **Stuck going on to the Cove from the internet:** UDP 24243 isn't
forwarded (see above).
- **The server says the game's scripts didn't load:** it's a fresh copy - **The server says the game's scripts didn't load:** it's a fresh copy
that hasn't been imported, or Godot is older than 4.7. `run_server.sh` that hasn't been imported, or Godot is older than 4.7. `run_server.sh`
handles both; see `docs/SETUP_ON_NEW_MACHINE.md`. handles both; see `docs/SETUP_ON_NEW_MACHINE.md`.

View File

@@ -1,6 +1,26 @@
# Progression and challenges design # Progression and challenges design
Status: **P1-P5 built** (iron, stone, adversaries, hazards, the Old Stag). Status: **P1-P6 built** (iron, stone, adversaries, hazards, the Old Stag,
and uses for every material).
*As built in P6 (0.3.0, every material earns its place):*
- Clay jar (8 clay, fired at a campfire): 6 slots, food inside ages at a
third of the rate (`Inventory.keep_mult`, which moves the "rot" stamps).
- Bedroll (3 hide, 6 feathers, 4 fibre): a bed (group "bed", like the bed
everywhere a spawn bed is looked for) that needs no shelter, only no rain
or snow on it.
- Wolf-pelt rug (3 wolf pelts): a rug, so Cozy like the fur rug.
- Fang necklace (2 fangs, 1 alpha pelt): a "charm" carried anywhere in the
bag; hits whose cause names a wolf do 70% (`Actions.charm_mult`).
- Bear cloak (2 bear hide, 2 hide): +40 warmth, the warmest.
- Pemmican (2 cooked meat, 3 berries, 1 bear fat at a campfire, makes 3):
meat and greens, keeps 30 days.
- Rotten food is fishing bait: carried, bite waits x0.6, a bite uses one.
- Lights to place (after 0.3.0): tallow candle (bear fat and fibre, makes 4,
7 m) and torch stand (11 m, shadows), always lit (`lamp.gd`).
- `Q` drops what you hold (shift: one), at your feet, online too; `K`/`M`
move the hotbar up and down the bag's five rows (any slot can be held).
- `tests/test_items_more.gd` checks every material has a use.
*As built in P1:* *As built in P1:*
- Iron nuggets (70, in the scree under the rim), clay (60, on the lake - Iron nuggets (70, in the scree under the rim), clay (60, on the lake

View File

@@ -85,8 +85,24 @@ phases M2.0-M2.7).
## M4 — Content & polish · ongoing ## M4 — Content & polish · ongoing
- More biomes, creatures, items, building pieces - More biomes, creatures, items, building pieces
- PvP / raiding rules, building damage - PvP / raiding rules, building damage
- Better UI, settings, controls - Better UI, settings, controls. *Done so far:* volume sliders, tips and
- Audio, music, weather update settings in the pause menu; `Q` drops items; `K`/`M` put any bag row
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). *(0.5.0):* J3, the journey on a server: a server per
land, players in different lands at once, chat, the player list, admin
and sleep across lands. Next J2, then J4 (the Highlands by sail).
- 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
birdsong, crickets, owls, fire), sound effects for every action (Kenney
CC0 plus synthesised), creature voices, Old Tam's spoken lines (Kokoro).
Sources and licences: `assets/audio/CREDITS.md`; tools in `tools/audio/`
and `tools/voice/`.
- Help: the player guide on the download site (`tools/site/`), written
sections plus item, recipe, creature, fish and server-setting tables made
from the game's own data each build. The trailer (`tools/trailer/`).
- Packaged builds for Win/Mac/Linux (+ Mac signing/notarisation). - Packaged builds for Win/Mac/Linux (+ Mac signing/notarisation).
*First cut done:* `tools/export.sh` makes a universal macOS .app (ad-hoc *First cut done:* `tools/export.sh` makes a universal macOS .app (ad-hoc
signed, not notarised) and a Windows x86_64 .exe + .pck (unsigned), zipped signed, not notarised) and a Windows x86_64 .exe + .pck (unsigned), zipped

View File

@@ -1,7 +1,168 @@
# Worldlets design # Worldlets design
Status: **proposal**. Nothing here is built yet, apart from the first piece Status: **W0-W3, J1 and J3a built**: the Cove (with its stream), the cliff
of groundwork (`world.water_at()` and `data/fish.json`, see §6). tunnel, and the journey on a server (a server per land; chat and the
player list across lands are J3b). The rest is planned.
*As built in J3a (a server per land):*
- `scripts/net/lands.gd` (autoload `Lands`): the server players join is the
hub. `Lands.ensure(land)` starts a land server (`--server --land cove
--port <hub+1..> --hub-port <hub>`, same config and world folder, output
to `land-<land>.log` beside server.json) and waits for it to say it's
listening. Hub and land servers talk over TCP on 127.0.0.1, port hub +
100, one JSON object per line: hello/ready, the player count, the clock
(the hub's, every 10 s: one clock everywhere), how far the tunnel's dug,
one-time passes, "stop", "host_left". A land server stops after 2 minutes
empty, when the hub stops, or if it loses the hub.
- A land server's saves: `Saves.land` makes it load and save its own
worldlet's file (`worldlets/cove.json`) in the hub's world folder.
Characters (`players/<id>.json`) are shared, and say which land they're
in (`land`) and, mid-crossing, where they'll come out (`arrive`).
- Crossing (`PlayerNet.cross`): the server saves the character as heading
there, makes sure the other server's up, and sends the game
`_s2c_travel {land, port, pass}`; the game drops the connection quietly
and joins that port with the pass (`Net.join(address, "", pass)`), checked
after the identity proof: a land server lets nobody in without one. The
world reloads on `Net.arrived`. Joining the hub when your character's in
another land sends you on there; a crossing that fails goes back to the
title. A hosted game: the host crossing doesn't stop the hub; the host
leaving the game from another land stops everything.
- The tunnel online: built on the server and in every game. The server
decides blows on its faces (`"hit"` finds them by uid), props
(`"tunnel_prop"`), going in and out (`"tunnel_go"`: a server teleport;
the move check allows the tunnel only from inside it) and the crossings
(`"cross"`), and tells everyone how it stands (`_s2c_tunnel`; it's in the
shared state a joining player gets, and saved with the Valley).
- `tests/test_net_lands.gd`: Alice digs and props online, lights it,
crosses to the Cove (bag and day kept) while Bob stays in the Valley,
comes back up, then leaves from the Cove and rejoining sends her there.
*As built in J3b (everyone, everywhere):*
- Chat and server messages go to every land (`Chat._all` relays through
the hub, `Lands.relay_line`).
- The hub keeps the **roster** (everyone on every land, with their land);
every land and every player's game gets it (`Net.roster`,
`Net.everyone()`): the pause menu's player list and `/players` show
where each player is.
- Commands typed in another land run **on the hub** (it alone keeps
server.json) and answer back there; after one that changes server.json
(`/set`, `/ban`, `/admin`, `/friend`...) every land reloads it.
`/kick` and `/ban` reach the land the player's in; `/weather`, `/save`
and `/backup` reach every land.
- **Sleep needs everyone in every land:** land servers report their beds,
the hub decides, and every land passes the night together. Getting into
bed while others are up: "You're in bed: waiting for everyone else to go
to bed (1 of 2 asleep)", and every land hears "Bob is in bed (1 of 2
asleep)" in chat.
- `tests/test_net_lands.gd` covers it all, Alice in the Cove and Bob in the
Valley.
*As built in J3c:*
- A land server that stops (crashed, or killed) tells its players "Lost the
connection to the Cove. Join the server again to carry on there."; the
hub notices it's gone (even mid-join) and joining again starts it afresh
and sends them back (their characters were saved by its autosave).
- The extra ports: `Lands.extra_ports()` / `ports_text()` ("UDP 24242 and
24243 (the Cove)"), on the Host page, in the "You're hosting" message, in
a hub's start-up log, and in docs/HOSTING.md.
- Tested in `test_net_lands.gd` (the Cove's server killed, Alice rejoins)
and `test_worldlet_pick.gd` (the Host page).
*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
@@ -164,3 +325,227 @@ its edge or animals that spawn in the sea.
wolves sooner, or keep the same grace period everywhere? wolves sooner, or keep the same grace period everywhere?
4. **Which second worldlet first:** Cove (sea fishing, the beach you 4. **Which second worldlet first:** Cove (sea fishing, the beach you
mentioned) is my suggestion. mentioned) is my suggestion.
## 10. The journey: tunnelling out (proposal, Nov 2026)
A goal for the game, from Paul: once you have a good enough pick, you tunnel
through the mountain rim into the next, slightly harder worldlet, and now and
then go back to earlier ones for what only they have. This is option B
(linked worldlets) from §2, reached by digging rather than a path.
### The shape of it
- **A chain of worldlets**, each a little harder: Valley (today's island) →
Cove (sea, salt, mackerel) → Highlands (cold, deep snow, bigger packs) →
Marsh (bog iron, sickness, mists) → the Ember, the volcanic heart the game
is named for: the end of the journey.
- **The goal**, something to tell players on day 1: Old Tam talks of the
land beyond the mountains, where the fire in the earth still burns. Reach
the Ember and light the old beacon there. (Whether there's an ending or
it's open-ended is a question below.)
- **Gates:** each tunnel needs the best tool from the worldlet you're in.
The antler pickaxe (from the Old Stag) opens the Valley's tunnel; the
Highlands need a pick made from something found in the Cove, and so on.
Each worldlet has its own boss or trial, like the Old Stag.
### A different crossing each time (Paul, Nov 2026)
Players have to work out how to get from each worldlet to the next, and
it's a different way every time: tunnelling, climbing, burrowing, sailing.
Each crossing is that worldlet's puzzle, and what it needs comes from what
you've learned and gathered there:
| From → to | Crossing | Needs (a first idea) |
|---|---|---|
| Valley → Cove | **Tunnel** through the rim at a seam | the antler pickaxe, timber props, light |
| Cove → Highlands | **Sail** out of the bay and round the headland | a boat (planks, nails, a hide sail, rope), calm weather, food that keeps |
| Highlands → Marsh | **Climb** a frozen cliff | climbing picks and rope, warmth to last the climb (bear cloak, pemmican) |
| Marsh → Ember | **Burrow** under the old lava crust | a digging tool from bog iron, and a way through the heat and gas |
- **The hint, not the answer:** each worldlet shows its way out without
explaining it: a seam in the rock with old pick marks, a wrecked boat on
the beach, rope frayed on a cliff ledge. Old Tam (and his tips) nudge,
and the guide can keep it vague.
- **Coming back** uses the same crossing in reverse (the tunnel stays dug,
the boat stays moored, the rope stays fixed), so supply runs get easier
once you've made the way.
- **New systems this needs, one per crossing:** digging into a rim seam
(tunnel and burrow share most of it), a boat you build and steer on the
water, and climbing a marked face. Each is a phase of its own (J1 tunnel;
later the sail, the climb, the burrow).
### Tunnelling (the first crossing)
- **Where:** each worldlet's rim has one or two **seams**: cracked, darker
rock faces with a few loose stones at the foot, findable by exploring
(and Old Tam's tips). The rim stays a wall everywhere else.
- **How:** hit a seam with a good enough pick, many times. Each stretch dug
needs **timber props** (wood) or it falls back in, and it's dark in there:
candles, torch stands or a lantern. The tunnel is a short, built-for-it
cave (a few bends, maybe a side chamber with ore or a hazard), so it
stays hand-made rather than a hole through the terrain mesh, which the
heightmap can't do.
- **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.
### 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
- **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 (so bear fat for candles and pemmican is a supply run); a fish or
herb only one place has. Food that keeps (pemmican, salted and smoked
meat, clay jars) becomes what you carry between them.
- **Your bases stay** where you built them: each worldlet keeps its own
save file, so your Valley hut, chests and jars are waiting.
- **Time:** there's one clock for every land (see the decisions below). A
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)
| Phase | Delivers | Done when |
|---|---|---|
| **W0-W2** | the worldlet groundwork above (terrain pipeline, worldlet in the save, several water bodies) | as §8 |
| **J1** | seams in the rim, digging with props and light, a tunnel scene, travel between two worldlets in single-player, per-worldlet save files | dig through from the Valley, walk to the Cove, walk back to your hut |
| **W3** | the Cove (§5) as the second worldlet | its suites pass, and its tunnel back works |
| **J2** | gates and supply needs: tool tiers per tunnel, resources only some worldlets have | a test bot can't open the second tunnel without the Cove's pick |
| **J3** | multiplayer travel (see below) | two players in different worldlets on one server, or the rule we choose |
| **J4** | Highlands, Marsh, the Ember and its ending | the journey can be finished |
### Multiplayer: the hard part
A server today runs one island. Options:
- **Everyone together:** travel needs every player at the tunnel (like the
sleep rule). Simplest; one worldlet runs at a time.
- **Spread out:** the server runs each worldlet that has someone in it.
The most freedom, and the most work and CPU.
- **Party leader:** whoever goes through brings the others with a vote.
### 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
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),
and in that order? Should a worldlet have more than one way out (a hard
one and a long one)? And for the tunnel: only at marked seams, or
anywhere in the rim (more freedom, harder to make good tunnels)?
3. **Multiplayer:** everyone together, spread out, or a party vote?
4. **How much harder** each worldlet: stronger animals and weather only, or
new rules too (thirst in the Ember, sickness in the Marsh)?
5. **Order:** Cove second (sea fishing, gentle) as planned, or straight
into the Highlands for a harder step?

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.2.0" application/short_version="0.5.0"
application/version="0.2.0" application/version="0.5.0"
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.2.0" application/file_version="0.5.0"
application/product_version="0.2.0" application/product_version="0.5.0"
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.2.0" config/version="0.5.0"
[autoload] [autoload]
@@ -31,12 +31,14 @@ Hints="*res://scripts/core/hints.gd"
Updates="*res://scripts/core/updates.gd" Updates="*res://scripts/core/updates.gd"
Saves="*res://scripts/core/saves.gd" Saves="*res://scripts/core/saves.gd"
Net="*res://scripts/net/net.gd" Net="*res://scripts/net/net.gd"
Lands="*res://scripts/net/lands.gd"
WorldNet="*res://scripts/net/world_net.gd" WorldNet="*res://scripts/net/world_net.gd"
PlayerNet="*res://scripts/net/player_net.gd" PlayerNet="*res://scripts/net/player_net.gd"
CreatureNet="*res://scripts/net/creature_net.gd" CreatureNet="*res://scripts/net/creature_net.gd"
Chat="*res://scripts/net/chat.gd" Chat="*res://scripts/net/chat.gd"
Sfx="*res://scripts/core/sfx.gd" Sfx="*res://scripts/core/sfx.gd"
Music="*res://scripts/core/music.gd" Music="*res://scripts/core/music.gd"
Narrator="*res://scripts/core/narrator.gd"
[display] [display]

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 · 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

@@ -2,8 +2,15 @@ extends "res://scripts/building/piece.gd"
## Bed: E sets your spawn point here. Once it's yours, E at night sleeps until ## Bed: E sets your spawn point here. Once it's yours, E at night sleeps until
## morning, if the bed is under a roof with walls around it. ## morning, if the bed is under a roof with walls around it.
func _build() -> void:
add_to_group("bed") # (a bed or a bedroll: either is a spawn point)
func display_name() -> String: func display_name() -> String:
return "Bed" return String(def.get("name", "Bed"))
## A bedroll: you can sleep in the open (world.sleep_block_reason).
func open_air() -> bool:
return bool(def.get("place", {}).get("open_air", false))
func is_spawn() -> bool: func is_spawn() -> bool:
var w := get_parent().get_parent() var w := get_parent().get_parent()
@@ -11,8 +18,8 @@ func is_spawn() -> bool:
func interact_prompt(_player: Node) -> String: func interact_prompt(_player: Node) -> String:
if not is_spawn(): if not is_spawn():
return "Bed [E] Set spawn point" return "%s [E] Set spawn point" % display_name()
return "Bed (your spawn point) [E] Sleep" return "%s (your spawn point) [E] Sleep" % display_name()
func interact(player: Node) -> void: func interact(player: Node) -> void:
Actions.use_bed(player, self) Actions.use_bed(player, self)

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

29
scripts/building/jar.gd Normal file
View File

@@ -0,0 +1,29 @@
extends "res://scripts/building/piece.gd"
## Clay jar: 6 slots of storage, opened with E like a chest, and food kept in
## it lasts three times as long (Inventory.keep_mult).
const SLOTS := 6
const KEEP := 3.0
var inventory := Inventory.new(SLOTS)
func _build() -> void:
inventory.keep_mult = KEEP
func display_name() -> String:
return "Clay Jar"
func dismantle_block_reason() -> String:
for s in inventory.slots:
if s != null:
return "Empty the jar first"
return ""
func interact_prompt(_player: Node) -> String:
var used := 0
for s in inventory.slots:
if s != null:
used += 1
return "Clay jar (%d/%d, food keeps 3x as long) [E] Open" % [used, SLOTS]
func interact(player: Node) -> void:
player.open_inventory(self)

View File

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

64
scripts/building/lamp.gd Normal file
View File

@@ -0,0 +1,64 @@
extends "res://scripts/building/piece.gd"
## A light to place indoors (or anywhere): a tallow candle or a torch stand.
## Always lit, no fuel. Its item's "lamp" says how: {"range", "energy",
## "height" (of the flame), "flicker" (0 = steady), "color"}.
## Flames are a small additive glow; the light itself is an OmniLight3D.
var _light: OmniLight3D
var _flame: MeshInstance3D
var _noise := FastNoiseLite.new()
var _t := 0.0
var _base := 1.0
var _flicker := 0.0
var _h := 0.5
func _build() -> void:
add_to_group("lamp")
var l: Dictionary = def.get("lamp", {})
_base = float(l.get("energy", 1.0))
_flicker = float(l.get("flicker", 0.2))
_h = float(l.get("height", 0.5))
_noise.seed = int(get_instance_id() % 1000)
_noise.frequency = 2.0
_t = randf() * 100.0
_light = OmniLight3D.new()
var c: Array = l.get("color", [1.0, 0.7, 0.4])
_light.light_color = Color(c[0], c[1], c[2])
_light.light_energy = _base
_light.omni_range = float(l.get("range", 6.0))
_light.omni_attenuation = 1.3
_light.shadow_enabled = bool(l.get("shadows", false))
_light.position = Vector3(0, _h + 0.05, 0)
add_child(_light)
# The flame: a small glowing teardrop at the wick.
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
m.albedo_color = Color(1.0, 0.72, 0.32, 0.85)
var s := SphereMesh.new()
var fr := float(l.get("flame", 0.03))
s.radius = fr
s.height = fr * 3.2
s.radial_segments = 8
s.rings = 4
s.material = m
_flame = MeshInstance3D.new()
_flame.mesh = s
_flame.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_flame.position = Vector3(0, _h, 0)
add_child(_flame)
func display_name() -> String:
return String(def.get("name", "Light"))
func interact_prompt(_player: Node) -> String:
return "%s (X to take it up)" % display_name()
func _process(delta: float) -> void:
if _flicker <= 0.0 or _light == null:
return
_t += delta
var f := _noise.get_noise_1d(_t * 9.0)
_light.light_energy = _base * (1.0 + f * _flicker)
_flame.scale = Vector3.ONE * (1.0 + f * _flicker * 0.8)

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

@@ -31,6 +31,10 @@ func _ready() -> void:
_bind("inventory", [KEY_TAB, KEY_I]) _bind("inventory", [KEY_TAB, KEY_I])
_bind("rotate_piece", [KEY_R]) _bind("rotate_piece", [KEY_R])
_bind("dismantle", [KEY_X]) _bind("dismantle", [KEY_X])
_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_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)
@@ -254,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
@@ -736,8 +748,53 @@ static func harvest_sound(tool_kind: String, felled: bool) -> String:
## A piece going down: wood, stone, or something soft. ## A piece going down: wood, stone, or something soft.
static func place_sound(id: String) -> String: static func place_sound(id: String) -> String:
if id.contains("stone") or id in ["campfire", "forge", "hearth", "stonecutter"]: if id.contains("stone") or id.contains("jar") or id in ["campfire", "forge", "hearth", "stonecutter"]:
return "place_stone" return "place_stone"
if id.contains("rug") or id.contains("bed") or id == "smoking_rack": if id.contains("rug") or id.contains("bed") or id == "smoking_rack":
return "place_soft" return "place_soft"
return "place_wood" return "place_wood"
# --- charms and bait ------------------------------------------------------------------
## A charm carried anywhere in the bag (the fang necklace: {"against": "wolf",
## "damage": 0.7}) takes the edge off hits from what it's against. `by` is the
## hit's "a wolf" / "a alpha wolf" / "a spirit wolf" (survival.damage).
static func charm_mult(inv: Inventory, by: String) -> float:
var m := 1.0
if by.is_empty():
return m
for s in inv.slots:
if s == null:
continue
var c: Dictionary = ItemDB.get_item(String(s.id)).get("charm", {})
if not c.is_empty() and by.to_lower().contains(String(c.get("against", "?"))):
m = minf(m, float(c.get("damage", 1.0)))
return m
const BAIT := "rotten_food"
const BAIT_WAIT := 0.6 ## carried while fishing: bites come this much sooner
## Drops the stack in `slot` (or `count` of it) at your feet, a little in
## front (Q drops what's in your hand). Online the server does it, for everyone.
func drop(player: Node3D, slot: int, count: int = 0) -> bool:
if _online():
return _ask("drop", [slot, count])
var s = player.inventory.get_slot(slot)
if s == null:
return false
var n: int = int(s.count) if count <= 0 else mini(count, int(s.count))
# (on a server the player is a proxy under PlayerNet, not a child of the world)
var world: Node = WorldNet.world if Net.is_server() else player.get_parent()
if world == null or not world.has_method("drop_item"):
return false
var fwd: Vector3 = -player.global_basis.z
fwd.y = 0.0
var at: Vector3 = player.global_position + fwd.normalized() * 0.9
at.y = world.terrain.sample_height(at.x, at.z) + 0.05 if world.get("terrain") != null else player.global_position.y
var extra := Inventory.extra_of(s)
if world.drop_item(String(s.id), n, Transform3D(Basis(), at), extra) == null:
return false
player.inventory.take_from(slot, n)
Sfx.at("pickup", at)
notify.emit("Dropped %d %s" % [n, ItemDB.item_name(String(s.id))])
return true

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

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

View File

@@ -121,6 +121,7 @@ func start(w: Node3D, from_saved: Dictionary = {}) -> void:
func stop() -> void: func stop() -> void:
_clear_outline() _clear_outline()
Narrator.stop()
active = false active = false
world = null world = null
@@ -139,9 +140,11 @@ func chapter_title(step: Dictionary) -> String:
func _enter() -> void: func _enter() -> void:
if index >= steps.size(): if index >= steps.size():
finished.emit() finished.emit()
Narrator.say("tutorial_finish", String(finish.get("text", "")))
return return
_setup(steps[index].get("setup", {})) _setup(steps[index].get("setup", {}))
step_started.emit(index, steps[index]) step_started.emit(index, steps[index])
Narrator.say("tutorial_" + String(steps[index].id), String(steps[index].text))
_marker_t = 0.0 _marker_t = 0.0
_check() # (done early? it ticks straight away) _check() # (done early? it ticks straight away)
@@ -346,6 +349,14 @@ func marker_position() -> Vector3:
## Where the current step's marker goes: the nearest thing it asks for, or ## Where the current step's marker goes: the nearest thing it asks for, or
## INF (no marker, or you've got what it was for). ## INF (no marker, or you've got what it was for).
## Whether the part of `goal` about `kind`:`id` (e.g. gained: stone) is done.
func _part_met(goal: Dictionary, kind: String, id: String) -> bool:
var parts: Array = goal.get("all", [goal])
for p in parts:
if p is Dictionary and String(p.get(kind, "")) == id:
return _met(p)
return false
func find_marker() -> Vector3: func find_marker() -> Vector3:
var s := current() var s := current()
if s.is_empty() or not s.has("marker") or world == null: if s.is_empty() or not s.has("marker") or world == null:
@@ -360,9 +371,14 @@ func find_marker() -> Vector3:
var best_d := INF var best_d := INF
var cands: Array = [] var cands: Array = []
if what.begins_with("pickup:"): if what.begins_with("pickup:"):
var id := what.substr(7) # "pickup:stick|stone": the nearest of whichever you still need (the
# step's "gained" goal for that item isn't met yet).
var ids: Array = []
for id in what.substr(7).split("|", false):
if not _part_met(s.get("goal", {}), "gained", id):
ids.append(id)
for pk in world.props.pickups.values(): for pk in world.props.pickups.values():
if is_instance_valid(pk) and not pk.taken and pk.item_id == id: if is_instance_valid(pk) and not pk.taken and pk.item_id in ids:
cands.append(pk) cands.append(pk)
elif what in ["tree", "rock"]: elif what in ["tree", "rock"]:
for n in world.props.resource_nodes.values(): for n in world.props.resource_nodes.values():

View File

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

View File

@@ -103,6 +103,7 @@ func _show(h: Dictionary) -> void:
save_prefs() save_prefs()
_cooldown = GAP _cooldown = GAP
shown.emit(h) shown.emit(h)
Narrator.say("hint_" + String(h.id), String(h.text))
func _met(when: String) -> bool: func _met(when: String) -> bool:
var player: Node = world.player var player: Node = world.player
@@ -134,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

@@ -65,6 +65,10 @@ static func extra_of(stack: Dictionary) -> Dictionary:
static var clock := 0.0 static var clock := 0.0
static var day_seconds := 1200.0 static var day_seconds := 1200.0
static var rot_speed := 1.0 static var rot_speed := 1.0
## How many times longer food keeps in here (a clay jar: 3), and the clock at
## the last spoil() (the time since is what's slowed).
var keep_mult := 1.0
var _last_clock := -1.0
const ROTTEN := "rotten_food" const ROTTEN := "rotten_food"
static func _db() -> Node: static func _db() -> Node:
@@ -110,6 +114,15 @@ func spoil() -> int:
return 0 return 0
var rotted := 0 var rotted := 0
var touched := false var touched := false
# A clay jar (keep_mult 3): food in it ages at a third of the rate, so its
# "rot" stamp moves back by two thirds of the time that's passed.
if keep_mult > 1.0:
if _last_clock >= 0.0 and clock > _last_clock:
var later := (clock - _last_clock) * (1.0 - 1.0 / keep_mult)
for s in slots:
if s != null and s.has("rot") and float(s.rot) > _last_clock:
s.rot = float(s.rot) + later
_last_clock = clock
for i in range(slots.size()): for i in range(slots.size()):
var s = slots[i] var s = slots[i]
if s == null or keeps_days(String(s.id)) <= 0.0: if s == null or keeps_days(String(s.id)) <= 0.0:

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

@@ -42,10 +42,16 @@ var _b: AudioStreamPlayer
var _cur: AudioStreamPlayer var _cur: AudioStreamPlayer
var _birds: AudioStreamPlayer var _birds: AudioStreamPlayer
var _crickets: AudioStreamPlayer var _crickets: AudioStreamPlayer
var _owl: AudioStreamPlayer3D
var _owl_left := 20.0
var _owl_calls: Array = [] ## a few different calls, so it doesn't repeat itself
var owls := 0 ## hoots so far (tests)
const OWL_EVERY := [35.0, 110.0]
var _tracks: Dictionary = {} var _tracks: Dictionary = {}
var _rng := RandomNumberGenerator.new() var _rng := RandomNumberGenerator.new()
var _quiet := false var _quiet := false
var _fading := 0 ## crossfades in progress var _fading := 0 ## crossfades in progress
var duck := false ## Old Tam is talking (narrator.gd): the music dips
func _ready() -> void: func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS # (the pause menu stops the game, not the music) process_mode = Node.PROCESS_MODE_ALWAYS # (the pause menu stops the game, not the music)
@@ -58,6 +64,15 @@ func _ready() -> void:
_cur = _a _cur = _a
_birds = _player("Ambience", _load(AMB + "birds.ogg", true)) _birds = _player("Ambience", _load(AMB + "birds.ogg", true))
_crickets = _player("Ambience", _load(AMB + "crickets.ogg", true)) _crickets = _player("Ambience", _load(AMB + "crickets.ogg", true))
_owl = AudioStreamPlayer3D.new()
_owl.bus = "Ambience"
for i in range(2):
var call: Variant = _load(AMB + "owl_%d.ogg" % i, false)
if call is AudioStream:
_owl_calls.append(call)
_owl.unit_size = 30.0 # (far off in the trees, but clearly heard)
_owl.max_distance = 120.0
add_child(_owl)
for p in [_a, _b]: for p in [_a, _b]:
p.finished.connect(_on_finished.bind(p)) p.finished.connect(_on_finished.bind(p))
@@ -204,7 +219,7 @@ func _indoors() -> bool:
return sv != null and bool(sv.get("sheltered")) return sv != null and bool(sv.get("sheltered"))
func _target_db() -> float: func _target_db() -> float:
return -6.0 if _indoors() else -2.0 return (-6.0 if _indoors() else -2.0) - (7.0 if duck else 0.0)
func _ambience(dt: float) -> void: func _ambience(dt: float) -> void:
if _quiet or _birds == null: if _quiet or _birds == null:
@@ -218,10 +233,36 @@ func _ambience(dt: float) -> void:
wet = 1.0 if s in ["rain", "storm", "blizzard"] else 0.0 wet = 1.0 if s in ["rain", "storm", "blizzard"] else 0.0
snow = s in ["snow", "blizzard"] snow = s in ["snow", "blizzard"]
var inside := 0.35 if _indoors() else 1.0 var inside := 0.35 if _indoors() else 1.0
if world == null or not is_instance_valid(world) or world.get("menu_mode"):
inside = 0.0 # (the title screen: music only, no birds or crickets)
var birds := clampf((day - 0.35) / 0.4, 0.0, 1.0) * (1.0 - wet) * inside var birds := clampf((day - 0.35) / 0.4, 0.0, 1.0) * (1.0 - wet) * inside
var crickets := clampf((0.45 - day) / 0.3, 0.0, 1.0) * (0.0 if snow else 1.0) * (1.0 - wet * 0.8) * inside var crickets := clampf((0.45 - day) / 0.3, 0.0, 1.0) * (0.0 if snow else 1.0) * (1.0 - wet * 0.8) * inside
_owl_step(dt, crickets > 0.5, inside)
var k := clampf(dt * 0.8, 0.0, 1.0) var k := clampf(dt * 0.8, 0.0, 1.0)
_birds.volume_db = lerpf(_birds.volume_db, linear_to_db(maxf(birds * 0.8, 0.0001)), k) _birds.volume_db = lerpf(_birds.volume_db, linear_to_db(maxf(birds * 0.28, 0.0001)), k) # (in the background: the recording is full-scale)
_crickets.volume_db = lerpf(_crickets.volume_db, linear_to_db(maxf(crickets * 0.7, 0.0001)), k) _crickets.volume_db = lerpf(_crickets.volume_db, linear_to_db(maxf(crickets * 0.4, 0.0001)), k) # (a recording, full-scale)
if _cur != null and _cur.playing and _fading == 0: if _cur != null and _cur.playing and _fading == 0:
_cur.volume_db = lerpf(_cur.volume_db, _target_db(), k) _cur.volume_db = lerpf(_cur.volume_db, _target_db(), k)
## Now and then at night (when the crickets are out: dark, dry, no snow), an
## owl hoots from somewhere 25-45 m off in a random direction. Only you hear
## your owl; online, everyone's night has its own.
func _owl_step(dt: float, night_out: bool, inside: float) -> void:
if _owl == null or _owl_calls.is_empty() or not night_out:
_owl_left = maxf(_owl_left, 8.0)
return
_owl_left -= dt
if _owl_left > 0.0 or _owl.playing:
return
_owl_left = _rng.randf_range(OWL_EVERY[0], OWL_EVERY[1])
var me: Node3D = world.get("player") if world != null else null
if me == null:
return
var a := _rng.randf() * TAU
var r := _rng.randf_range(25.0, 45.0)
_owl.global_position = me.global_position + Vector3(cos(a) * r, _rng.randf_range(6.0, 12.0), sin(a) * r)
_owl.stream = _owl_calls[_rng.randi() % _owl_calls.size()]
_owl.pitch_scale = _rng.randf_range(0.96, 1.04)
_owl.volume_db = -8.0 + linear_to_db(maxf(inside, 0.05))
_owl.play()
owls += 1

View File

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

66
scripts/core/narrator.gd Normal file
View File

@@ -0,0 +1,66 @@
extends Node
## Old Tam's voice: the recorded line for a tutorial step, his goodbye or one
## of his tips (assets/audio/voice, made by tools/voice/make_voice.sh). A line
## only plays if it was made from exactly the text on screen (lines.json), so
## after an edit it stays quiet rather than saying the old words. A new line
## cuts off the one before; the music dips while he talks. The Voice volume
## (pause menu) turns him down or off. It pauses with the game.
const DIR := "res://assets/audio/voice/"
var lines: Dictionary = {} ## line id -> the key of the text it was made from
var spoken: Array = [] ## ids said (tests; headless nothing is heard)
var speaking := ""
var _p: AudioStreamPlayer
var _quiet := false
func _ready() -> void:
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(DIR + "lines.json")) \
if FileAccess.file_exists(DIR + "lines.json") else null
if d is Dictionary:
lines = d
_quiet = DisplayServer.get_name() == "headless"
if _quiet:
return
_p = AudioStreamPlayer.new()
_p.bus = "Voice"
add_child(_p)
_p.finished.connect(_done)
## The same key make_voice.py writes: the first 16 hex digits of the text's SHA-1.
static func text_key(text: String) -> String:
return text.sha1_text().substr(0, 16)
## Whether there's a recording of `text` for `id`.
func has_line(id: String, text: String) -> bool:
return String(lines.get(id, "")) == text_key(text)
## Says line `id` if it's the recording of `text`. True if it's being said.
func say(id: String, text: String) -> bool:
if not has_line(id, text):
return false
spoken.append(id)
speaking = id
if _quiet:
return true
var st: Variant = load(DIR + id + ".ogg")
if not (st is AudioStream):
speaking = ""
return false
_p.stop()
_p.stream = st
_p.play()
Music.duck = true
return true
func stop() -> void:
if _p != null:
_p.stop()
_done()
func is_speaking() -> bool:
return _p != null and _p.playing
func _done() -> void:
speaking = ""
Music.duck = false

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,70 @@ 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.
## A land server (J3, `--land cove`) runs one worldlet of a server's world:
## this is that worldlet ("" = whatever the world's meta says).
var land := ""
## The worldlet world `id` is being played in now.
func worldlet_of(id: String) -> String:
if not land.is_empty():
return land
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 +186,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)

View File

@@ -88,8 +88,8 @@ const SOUNDS := {
"crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0}, "crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0},
"fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0}, "fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0},
} }
const BUSES := ["Music", "SFX", "Ambience"] const BUSES := ["Music", "SFX", "Ambience", "Voice"]
const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7} const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7, "Voice": 1.0}
const POOL_3D := 20 const POOL_3D := 20
const POOL_2D := 6 const POOL_2D := 6

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

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