Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b611a6d34 | ||
|
|
27040d6805 | ||
|
|
2004fe193c | ||
|
|
9709d2c424 | ||
|
|
5bcfa23710 | ||
|
|
74bd8f5898 | ||
|
|
419c71e638 | ||
|
|
ec8caa9ef4 | ||
|
|
1bf33935e0 | ||
|
|
62b3d6c47f | ||
|
|
f9b138b304 | ||
|
|
86ebe07899 | ||
|
|
85bbed249b | ||
|
|
6544862885 | ||
|
|
109fff22d0 | ||
|
|
b938e045cb | ||
|
|
ad47cd53da | ||
|
|
0209c6149a | ||
|
|
b86fb879d2 | ||
|
|
2b7e52cd82 | ||
|
|
980ee53579 | ||
|
|
793b214786 | ||
|
|
9b88a10609 | ||
|
|
68e6d814d9 | ||
|
|
db4a659236 | ||
|
|
f4df2bc99d | ||
|
|
5584f74a8b | ||
|
|
77517be778 | ||
|
|
40f5e25c41 | ||
|
|
eaeb841c1d | ||
|
|
5f40fa3f7b | ||
|
|
4b2e687188 | ||
|
|
5778d61763 | ||
|
|
787d8d1d65 | ||
|
|
0550a4daa0 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -16,7 +16,31 @@ 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.6.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.
|
||||||
|
- 0.6.0, the Cove's own (`docs/WORLDLETS_DESIGN.md` §5): the wreck, a
|
||||||
|
treasure map and a dig for each player, the drowned crew, crabs and gulls,
|
||||||
|
and a rowing boat to build and moor at the pier (sailing comes with the
|
||||||
|
Highlands). Report a bug in the pause menu; `docs/PLAYTEST.md` for testers.
|
||||||
|
Next: after the playtest, 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).
|
||||||
|
|||||||
@@ -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.
BIN
assets/audio/ambience/owl_0.ogg
Normal file
BIN
assets/audio/ambience/owl_0.ogg
Normal file
Binary file not shown.
BIN
assets/audio/ambience/owl_1.ogg
Normal file
BIN
assets/audio/ambience/owl_1.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/creatures/crab_click_0.ogg
Normal file
BIN
assets/audio/sfx/creatures/crab_click_0.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/creatures/crab_click_1.ogg
Normal file
BIN
assets/audio/sfx/creatures/crab_click_1.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/creatures/gull_cry_0.ogg
Normal file
BIN
assets/audio/sfx/creatures/gull_cry_0.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/creatures/gull_cry_1.ogg
Normal file
BIN
assets/audio/sfx/creatures/gull_cry_1.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_cold.ogg
Normal file
BIN
assets/audio/voice/hint_cold.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_cove.ogg
Normal file
BIN
assets/audio/voice/hint_cove.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_crab.ogg
Normal file
BIN
assets/audio/voice/hint_crab.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_died.ogg
Normal file
BIN
assets/audio/voice/hint_died.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_diet.ogg
Normal file
BIN
assets/audio/voice/hint_diet.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_freezing.ogg
Normal file
BIN
assets/audio/voice/hint_freezing.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_hard_winter.ogg
Normal file
BIN
assets/audio/voice/hint_hard_winter.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_howl.ogg
Normal file
BIN
assets/audio/voice/hint_howl.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_hungry.ogg
Normal file
BIN
assets/audio/voice/hint_hungry.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_night.ogg
Normal file
BIN
assets/audio/voice/hint_night.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_rotten.ogg
Normal file
BIN
assets/audio/voice/hint_rotten.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_seam.ogg
Normal file
BIN
assets/audio/voice/hint_seam.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_sheltered.ogg
Normal file
BIN
assets/audio/voice/hint_sheltered.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_sick.ogg
Normal file
BIN
assets/audio/voice/hint_sick.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_snow.ogg
Normal file
BIN
assets/audio/voice/hint_snow.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_stamina.ogg
Normal file
BIN
assets/audio/voice/hint_stamina.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_storm.ogg
Normal file
BIN
assets/audio/voice/hint_storm.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_wet.ogg
Normal file
BIN
assets/audio/voice/hint_wet.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_wolf_moon.ogg
Normal file
BIN
assets/audio/voice/hint_wolf_moon.ogg
Normal file
Binary file not shown.
44
assets/audio/voice/lines.json
Normal file
44
assets/audio/voice/lines.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"hint_cold": "91c5088e453fb0e6",
|
||||||
|
"hint_cove": "f7e5c0283b31e1cc",
|
||||||
|
"hint_crab": "283542496ecd180c",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
BIN
assets/audio/voice/tutorial_animals.ogg
Normal file
BIN
assets/audio/voice/tutorial_animals.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_axe.ogg
Normal file
BIN
assets/audio/voice/tutorial_axe.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_bed.ogg
Normal file
BIN
assets/audio/voice/tutorial_bed.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_berries.ogg
Normal file
BIN
assets/audio/voice/tutorial_berries.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_campfire.ogg
Normal file
BIN
assets/audio/voice/tutorial_campfire.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_cook.ogg
Normal file
BIN
assets/audio/voice/tutorial_cook.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_diet.ogg
Normal file
BIN
assets/audio/voice/tutorial_diet.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_finish.ogg
Normal file
BIN
assets/audio/voice/tutorial_finish.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_fish.ogg
Normal file
BIN
assets/audio/voice/tutorial_fish.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_flax.ogg
Normal file
BIN
assets/audio/voice/tutorial_flax.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_hut.ogg
Normal file
BIN
assets/audio/voice/tutorial_hut.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_look.ogg
Normal file
BIN
assets/audio/voice/tutorial_look.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_mushroom.ogg
Normal file
BIN
assets/audio/voice/tutorial_mushroom.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_pickaxe.ogg
Normal file
BIN
assets/audio/voice/tutorial_pickaxe.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_rabbit.ogg
Normal file
BIN
assets/audio/voice/tutorial_rabbit.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_rod.ogg
Normal file
BIN
assets/audio/voice/tutorial_rod.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_sleep.ogg
Normal file
BIN
assets/audio/voice/tutorial_sleep.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_sticks.ogg
Normal file
BIN
assets/audio/voice/tutorial_sticks.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_torch.ogg
Normal file
BIN
assets/audio/voice/tutorial_torch.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_tree.ogg
Normal file
BIN
assets/audio/voice/tutorial_tree.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_weapon.ogg
Normal file
BIN
assets/audio/voice/tutorial_weapon.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_wolf.ogg
Normal file
BIN
assets/audio/voice/tutorial_wolf.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/tutorial_workbench.ogg
Normal file
BIN
assets/audio/voice/tutorial_workbench.ogg
Normal file
Binary file not shown.
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 @@
|
|||||||
{"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. Mind the gulls: they'll have any food you leave on the sand, and fish off a smoking rack. And at dusk the crabs come up the beach: give them room, or they pinch."},
|
||||||
|
{"id": "crab", "when": "has:crab_meat", "hold": 2, "title": "Crab",
|
||||||
|
"text": "Two lots of crab meat cook into a good crab supper at a campfire. Raw, it's as risky as raw fish."}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
372
data/items.json
372
data/items.json
@@ -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,105 @@
|
|||||||
},
|
},
|
||||||
"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": "crab_meat",
|
||||||
|
"name": "Crab Meat",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Sweet white meat from a crab off the Cove's beach. Cook it at a campfire. (Fish)",
|
||||||
|
"mesh": "crab_meat",
|
||||||
|
"food": {
|
||||||
|
"hunger": 4,
|
||||||
|
"health": -2,
|
||||||
|
"diet": {
|
||||||
|
"fish": 8
|
||||||
|
},
|
||||||
|
"risk": 0.2
|
||||||
|
},
|
||||||
|
"keeps": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cooked_crab",
|
||||||
|
"name": "Cooked Crab",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Boiled pink in its shell. Worth the pinches. (Fish)",
|
||||||
|
"mesh": "cooked_crab",
|
||||||
|
"food": {
|
||||||
|
"hunger": 20,
|
||||||
|
"health": 10,
|
||||||
|
"diet": {
|
||||||
|
"fish": 32
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"keeps": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 +663,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 +1027,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 +1035,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 +1055,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 +1063,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 +1095,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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1007,6 +1106,119 @@
|
|||||||
"description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.",
|
"description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.",
|
||||||
"mesh": "salt"
|
"mesh": "salt"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "driftwood",
|
||||||
|
"name": "Driftwood",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Sea-bleached wood washed up on the Cove's beach. It burns well, and it's light and tough: what a rowing boat is built from.",
|
||||||
|
"mesh": "driftwood",
|
||||||
|
"fuel": 48.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seagrass",
|
||||||
|
"name": "Seagrass",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "Long, tough ribbons of seagrass washed up in the Cove's shallows. Twisted together it makes a rope that won't rot in salt water.",
|
||||||
|
"mesh": "seagrass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rope",
|
||||||
|
"name": "Seagrass Rope",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Twisted from seagrass: for rigging and lashing a boat.",
|
||||||
|
"mesh": "rope"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pine_resin",
|
||||||
|
"name": "Pine Resin",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "Sticky amber resin from a felled pine. Boiled with salt it makes pitch.",
|
||||||
|
"mesh": "pine_resin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pitch",
|
||||||
|
"name": "Pitch",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Black, tarry pitch from pine resin and salt, boiled over a fire. It keeps the sea out of a boat's seams.",
|
||||||
|
"mesh": "pitch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rowing_boat",
|
||||||
|
"name": "Rowing Boat",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A small driftwood boat, seams pitched tight. Carry it to the mooring post at the end of the Cove's pier to tie it up. Where it'll take you, a ship's compass will tell.",
|
||||||
|
"mesh": "rowing_boat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "treasure_map",
|
||||||
|
"name": "Treasure Map",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A torn chart from a wrecked ship's sea chest, with an X inked in the hills above the Cove. Right-click to read it: the X goes on your map (N).",
|
||||||
|
"mesh": "treasure_map",
|
||||||
|
"read": {
|
||||||
|
"marks": "treasure"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ship_compass",
|
||||||
|
"name": "Ship's Compass",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A brass sea compass from the pirate chest, its needle still true. No boat leaves for the Highlands without one.",
|
||||||
|
"mesh": "ship_compass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spyglass",
|
||||||
|
"name": "Spyglass",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A captain's brass telescope. Hold right-click to look through it.",
|
||||||
|
"mesh": "spyglass",
|
||||||
|
"zoom": 4.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pirate_gold",
|
||||||
|
"name": "Pirate Gold",
|
||||||
|
"category": "placeable",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Old gold coins from the pirate chest. Nothing on the island takes coin (yet), but a heap of it looks fine in a hut.",
|
||||||
|
"mesh": "pirate_gold",
|
||||||
|
"place": {
|
||||||
|
"piece": "trophy",
|
||||||
|
"footprint": [
|
||||||
|
0.6,
|
||||||
|
0.6
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cutlass",
|
||||||
|
"name": "Cutlass",
|
||||||
|
"category": "weapon",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A pirate's curved blade: quick, and it hits harder than the iron spear.",
|
||||||
|
"mesh": "cutlass",
|
||||||
|
"weapon": {
|
||||||
|
"damage": 30,
|
||||||
|
"reach": 2.3,
|
||||||
|
"stamina": 8,
|
||||||
|
"cooldown": 0.45
|
||||||
|
},
|
||||||
|
"durability": 220,
|
||||||
|
"repair": {
|
||||||
|
"inputs": {
|
||||||
|
"iron_bar": 1
|
||||||
|
},
|
||||||
|
"station": "forge"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "smoking_rack",
|
"id": "smoking_rack",
|
||||||
"name": "Smoking Rack",
|
"name": "Smoking Rack",
|
||||||
@@ -1129,6 +1341,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,36 @@
|
|||||||
"trout": 1
|
"trout": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "grilled_mackerel",
|
||||||
|
"output": "grilled_mackerel",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"mackerel": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cooked_crab",
|
||||||
|
"output": "cooked_crab",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"crab_meat": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 +608,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 +641,132 @@
|
|||||||
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rope",
|
||||||
|
"output": "rope",
|
||||||
|
"count": 1,
|
||||||
|
"station": "",
|
||||||
|
"category": "tools",
|
||||||
|
"inputs": {
|
||||||
|
"seagrass": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pitch",
|
||||||
|
"output": "pitch",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"category": "building",
|
||||||
|
"inputs": {
|
||||||
|
"pine_resin": 2,
|
||||||
|
"salt": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rowing_boat",
|
||||||
|
"output": "rowing_boat",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"category": "building",
|
||||||
|
"inputs": {
|
||||||
|
"driftwood": 12,
|
||||||
|
"rope": 4,
|
||||||
|
"pitch": 3,
|
||||||
|
"nails": 6
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
24
data/worldlets/cove.json
Normal file
24
data/worldlets/cove.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"_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": "den"}, {"type": "pier", "at": "beach"}, {"type": "wreck"}],
|
||||||
|
"wildlife": ["crabs", "gulls"],
|
||||||
|
"next": [
|
||||||
|
{"to": "valley", "by": "tunnel", "where": "sea_cave"},
|
||||||
|
{"to": "highlands", "by": "sail", "where": "pier"}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
data/worldlets/valley.json
Normal file
19
data/worldlets/valley.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"_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},
|
||||||
|
"places": [{"type": "den"}, {"type": "barrow"}, {"type": "stones"}, {"type": "bear"}],
|
||||||
|
"next": [{"to": "cove", "by": "tunnel", "where": "sea_cliff"}]
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
|
|||||||
80
docs/PLAYTEST.md
Normal file
80
docs/PLAYTEST.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Emberwild 0.6.0 playtest
|
||||||
|
|
||||||
|
Thanks for playing. This build is the **Valley** and the **Cove**, and
|
||||||
|
we'd like to know what breaks, what's confusing, and what's no fun. The
|
||||||
|
game's player guide is on the download page: https://emberwild-95k.pages.dev
|
||||||
|
|
||||||
|
## Reporting a bug
|
||||||
|
|
||||||
|
Press **Esc** in the game, then **Report a bug**:
|
||||||
|
|
||||||
|
- **Open the bug report page** opens a new issue on the Emberwild Gitea page
|
||||||
|
in your browser, already filled in with your version, computer, mode,
|
||||||
|
land, the in-game time and where you are. Say what happened, what you
|
||||||
|
expected, and how to make it happen again, then press **Create Issue**.
|
||||||
|
Posting needs a (free) account on the Gitea server, and you need to be
|
||||||
|
signed in. Nothing is sent from the game itself.
|
||||||
|
- **Copy the details** puts the same details on your clipboard, if you'd
|
||||||
|
rather send them by message.
|
||||||
|
- **Show the log file** opens the folder with `godot.log`. For a crash or an
|
||||||
|
error message, attach it to the issue (drag it into the comment box).
|
||||||
|
`godot.log` is the latest run; the last few runs before it are kept
|
||||||
|
beside it, named with the date and time.
|
||||||
|
|
||||||
|
Where the log is:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Mac | `~/Library/Application Support/Godot/app_userdata/Emberwild/logs/godot.log` |
|
||||||
|
| Windows | `%APPDATA%\Godot\app_userdata\Emberwild\logs\godot.log` |
|
||||||
|
| Linux | `~/.local/share/godot/app_userdata/Emberwild/logs/godot.log` |
|
||||||
|
|
||||||
|
A screenshot helps for anything that looks wrong (Cmd+Shift+4 on a Mac,
|
||||||
|
Win+Shift+S on Windows).
|
||||||
|
|
||||||
|
One problem per issue, please; a second issue is better than a long one.
|
||||||
|
|
||||||
|
## What to try
|
||||||
|
|
||||||
|
Play however you like; these are the parts that are new or that we'd most
|
||||||
|
like tried.
|
||||||
|
|
||||||
|
**The Valley**
|
||||||
|
- The tutorial with Old Tam (New World, Tutorial), or start a normal world
|
||||||
|
and listen for his tips.
|
||||||
|
- Make a camp, survive a few nights, a winter, a wolf moon.
|
||||||
|
- Iron, stone building, the barrow, the standing stones and the Old Stag.
|
||||||
|
- Out on the clifftop, the cracked seam: dig the cliff tunnel down to the
|
||||||
|
Cove (an antler pickaxe, wood to prop it, lights).
|
||||||
|
|
||||||
|
**The Cove (new in 0.6.0)**
|
||||||
|
- The wreck at the far end of the beach: the captain's sea chest has a
|
||||||
|
treasure map for each player. Right-click the map, find your X (N for the
|
||||||
|
map), and dig it up with a pickaxe. Don't dig at night, or do.
|
||||||
|
- The pier: mackerel close in, cod from the far end.
|
||||||
|
- Gulls, after food on the sand and fish on a smoking rack.
|
||||||
|
- Crabs on the beach from dusk till dawn.
|
||||||
|
- Build a rowing boat (seagrass rope, pitch from pine resin and salt,
|
||||||
|
driftwood and nails, at a workbench) and tie it up at the end of the pier.
|
||||||
|
|
||||||
|
**Online**
|
||||||
|
- Host Game from the title screen, and have a friend join. Over the
|
||||||
|
internet, forward UDP ports **24242** and **24243** (the Cove has its own
|
||||||
|
server on the next port).
|
||||||
|
- Go between the lands together and apart, chat, sleep (everyone in every
|
||||||
|
land has to be in bed), and leave and come back.
|
||||||
|
|
||||||
|
## Not in this build (these aren't bugs)
|
||||||
|
|
||||||
|
- **Setting sail.** The boat ties up but can't leave yet: the Highlands are
|
||||||
|
the next part of the journey.
|
||||||
|
- **Pirate gold** has no use yet beyond being a trophy: there's no trader.
|
||||||
|
- **The ship's compass** does nothing yet; it's what the boat will need.
|
||||||
|
|
||||||
|
## Things we'd especially like to hear
|
||||||
|
|
||||||
|
- Anywhere you got stuck, or didn't know what to do next.
|
||||||
|
- Anything that felt too hard, too easy, or too slow (night, hunger, cold,
|
||||||
|
the drowned crew, the crabs).
|
||||||
|
- Frame-rate drops, and on what computer.
|
||||||
|
- Anything online that went out of step between players.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -85,8 +85,27 @@ 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. *(0.6.0, a playtest):* the Cove's own: the wreck,
|
||||||
|
a treasure dig for each player and its drowned crew, crabs, gulls, and a
|
||||||
|
rowing boat moored at the pier; Report a bug in the pause menu. 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -122,6 +283,68 @@ The steps:
|
|||||||
- Later: tides (sea level shifting slowly), a raft or rowing boat, and
|
- Later: tides (sea level shifting slowly), a raft or rowing boat, and
|
||||||
driftwood on the beach.
|
driftwood on the beach.
|
||||||
|
|
||||||
|
### The Cove's own dangers and the pirate treasure (Paul, Nov 2026, for 0.6.0)
|
||||||
|
Paused at the Cove for a playtest before the Highlands. The Cove gets its
|
||||||
|
own set pieces instead of copies of the Valley's:
|
||||||
|
- **Set pieces per land**, listed in each worldlet's data (`"places"`). The
|
||||||
|
Valley keeps the wolf den, the barrow, the standing stones (the Old Stag)
|
||||||
|
and the bear; the Cove keeps the wolves only, and gets the wreck and the
|
||||||
|
treasure.
|
||||||
|
- **The wreck:** a beached ship on the rocks at the far end of the bay, its
|
||||||
|
timbers along the sand (driftwood). Its captain's sea chest: some nails,
|
||||||
|
rope, coins and a **torn treasure map**.
|
||||||
|
- **The map:** reading it puts an X on your N map: a mound of loose earth
|
||||||
|
by a marker stone in the Cove's hills (your own spot: see below).
|
||||||
|
- **The dig:** a pickaxe, many blows, turns up the **pirate chest**: a
|
||||||
|
**ship's compass** (what the boat will need to sail to the Highlands: the
|
||||||
|
next crossing's gate, J2), a **spyglass** (zooms your view while held),
|
||||||
|
**pirate gold**, and a **cutlass** (quick, harder-hitting than the iron
|
||||||
|
spear).
|
||||||
|
- **One treasure each, each in its own place** (online too): the wreck's
|
||||||
|
chest gives each player their own map, once, and each map's X is
|
||||||
|
somewhere different: picked from the world's seed and the player's id
|
||||||
|
(so it's fixed for them), among dry, gentle spots in the Cove's hills
|
||||||
|
away from the other places. You only see your own mound; the server knows
|
||||||
|
every player's spot and what each has found, and checks the right one
|
||||||
|
when you dig.
|
||||||
|
- **Gold** is a **trophy you can place** in your hut (a heap of coins) for
|
||||||
|
now; what else it's for waits until there's a trader.
|
||||||
|
- **The drowned crew:** the treasure's guards (the Cove's barrow guards).
|
||||||
|
Two or three rise from the shallows round the dig at night, or as the
|
||||||
|
chest comes up, for whoever's digging. Slow, hit hard, unafraid of fire,
|
||||||
|
they drag at you in the water.
|
||||||
|
- **Crabs** on the beach at dusk (pinch if trodden on; food), and **gulls**,
|
||||||
|
thieves like the crows (fish off drying racks, things dropped on sand).
|
||||||
|
- **The boat** (J2's first part): driftwood, seagrass rope and pitch (pine
|
||||||
|
resin and salt), at a workbench, placed at the pier. One each, moored,
|
||||||
|
yours and your friends'. Setting sail says the Highlands are uncharted
|
||||||
|
for now.
|
||||||
|
- **Report a bug** in the pause menu, and a short playtest guide.
|
||||||
|
- Order: set pieces per land, the wreck and map, the dig and the drowned
|
||||||
|
crew, crabs and gulls, the boat, report a bug; then 0.6.0 (protocol 18).
|
||||||
|
|
||||||
|
**Built (Nov 2026), and where it differs from the plan above:**
|
||||||
|
- Places: `"places"` in `data/worldlets/*.json` (`world.has_place()`); the
|
||||||
|
Cove's extra wildlife in `"wildlife"` (`world.has_wildlife()`).
|
||||||
|
- The wreck (`scripts/world/wreck.gd`): the sea chest gives a map, 4 nails
|
||||||
|
and 2 salt (no rope or coins: gold comes from the dig). Each X is one of
|
||||||
|
12 hiding spots picked from the seed (`world.treasure_spots()`), chosen
|
||||||
|
per player by their id.
|
||||||
|
- The dig (`treasure.gd`, `treasure_mound.gd`, `crew_site.gd`): 18 HP, any
|
||||||
|
pickaxe; three drowned rise round the mound itself (not from the
|
||||||
|
shallows), at the first blow by night or as the chest comes up by day.
|
||||||
|
- Crabs (`crab.gd`, `shore.gd`): up to 5 per player on the sand from 17:30
|
||||||
|
to 06:30, pinch for 5 within about 2 m, let you go past 6 m; crab meat.
|
||||||
|
Gulls (`gull.gd`, `thieves.gd`): food on the sand, and fish on a smoking
|
||||||
|
rack; they take one and fly off (only food, not tools left on the sand).
|
||||||
|
- The boat (`moorings.gd`, `boat.gd`, `mooring_post.gd`): a rowing boat
|
||||||
|
item (12 driftwood, 4 rope, 3 pitch, 6 nails), tied up at the post at the
|
||||||
|
end of the pier rather than placed freely: 6 berths, one boat each, saved
|
||||||
|
with the Cove. Pine resin drops from felled pines in every land.
|
||||||
|
- Report a bug (`scripts/core/bug_report.gd`): opens the repo's new-issue
|
||||||
|
page filled in (the tester posts it, signed in); no token in the game.
|
||||||
|
Testers' notes: `docs/PLAYTEST.md`.
|
||||||
|
|
||||||
## 6. Done already (groundwork)
|
## 6. Done already (groundwork)
|
||||||
- `world.water_at(x, z)` returns `{type, surface, depth}` for the lake and
|
- `world.water_at(x, z)` returns `{type, surface, depth}` for the lake and
|
||||||
the sea, and fishing uses it instead of `terrain.water_level`.
|
the sea, and fishing uses it instead of `terrain.water_level`.
|
||||||
@@ -164,3 +387,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?
|
||||||
|
|||||||
@@ -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.6.0"
|
||||||
application/version="0.2.0"
|
application/version="0.6.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.6.0"
|
||||||
application/product_version="0.2.0"
|
application/product_version="0.6.0"
|
||||||
application/company_name=""
|
application/company_name=""
|
||||||
application/product_name="Emberwild"
|
application/product_name="Emberwild"
|
||||||
application/file_description="Emberwild"
|
application/file_description="Emberwild"
|
||||||
|
|||||||
@@ -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.6.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]
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
1
scripts/building/bed.gd.uid
Normal file
1
scripts/building/bed.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://nx08dmty60ny
|
||||||
1
scripts/building/build_piece.gd.uid
Normal file
1
scripts/building/build_piece.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dsbvhwas6wsxf
|
||||||
1
scripts/building/build_system.gd.uid
Normal file
1
scripts/building/build_system.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://gj02g6p3xfip
|
||||||
1
scripts/building/door.gd.uid
Normal file
1
scripts/building/door.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dhsyx4mrm28oe
|
||||||
1
scripts/building/forge.gd.uid
Normal file
1
scripts/building/forge.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cl5ip4achfdwo
|
||||||
1
scripts/building/hearth.gd.uid
Normal file
1
scripts/building/hearth.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://mjs6qb5p3q1a
|
||||||
29
scripts/building/jar.gd
Normal file
29
scripts/building/jar.gd
Normal 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)
|
||||||
1
scripts/building/jar.gd.uid
Normal file
1
scripts/building/jar.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ciqsy8msggcke
|
||||||
64
scripts/building/lamp.gd
Normal file
64
scripts/building/lamp.gd
Normal 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)
|
||||||
1
scripts/building/lamp.gd.uid
Normal file
1
scripts/building/lamp.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cwvlv0fbxhlgj
|
||||||
@@ -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.
|
||||||
|
|||||||
1
scripts/building/processor.gd.uid
Normal file
1
scripts/building/processor.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://jvcfwahbmdsm
|
||||||
1
scripts/building/rug.gd.uid
Normal file
1
scripts/building/rug.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d21if6sepl888
|
||||||
1
scripts/building/stonecutter.gd.uid
Normal file
1
scripts/building/stonecutter.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dk1ge2xxgiev1
|
||||||
@@ -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
1
scripts/core/boot.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://frm15unnr4w5
|
||||||
88
scripts/core/bug_report.gd
Normal file
88
scripts/core/bug_report.gd
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Report a bug (0.6.0, the playtest; pause menu): opens a new issue on the
|
||||||
|
## Emberwild repo in your browser, already filled in with a title to edit,
|
||||||
|
## a few questions to answer, and the details we'd ask for anyway (version,
|
||||||
|
## computer, single-player or online, which land, the day and time, where you
|
||||||
|
## are). You read it, add what happened, and press Create Issue: nothing is
|
||||||
|
## sent from the game itself. Posting needs an account on the Gitea server.
|
||||||
|
##
|
||||||
|
## The game's log (Godot writes it to user://logs/godot.log) helps with a
|
||||||
|
## crash or an error: the pause menu shows where it is and opens its folder,
|
||||||
|
## to attach the file to the issue.
|
||||||
|
|
||||||
|
const REPO := "https://git.psbailey.uk/Admin/Emberwild"
|
||||||
|
|
||||||
|
## An autoload (Net, Updates), looked up at run time: this script is also
|
||||||
|
## loaded by tests that start before the autoloads are compiled in.
|
||||||
|
static func _al(n: String) -> Node:
|
||||||
|
return (Engine.get_main_loop() as SceneTree).root.get_node_or_null(n)
|
||||||
|
|
||||||
|
## Where this game's log is, on disk.
|
||||||
|
static func log_path() -> String:
|
||||||
|
return ProjectSettings.globalize_path("user://logs/godot.log")
|
||||||
|
|
||||||
|
## The same, with your home folder as "~" (so your user name isn't in an
|
||||||
|
## issue anyone can read).
|
||||||
|
static func log_path_short() -> String:
|
||||||
|
var home := OS.get_environment("HOME") if OS.get_name() != "Windows" else OS.get_environment("USERPROFILE")
|
||||||
|
var p := log_path()
|
||||||
|
if not home.is_empty() and p.begins_with(home):
|
||||||
|
p = "~" + p.substr(home.length())
|
||||||
|
return p
|
||||||
|
|
||||||
|
static func log_dir() -> String:
|
||||||
|
return ProjectSettings.globalize_path("user://logs")
|
||||||
|
|
||||||
|
## How you're playing: "single-player", or online with the server's rules.
|
||||||
|
static func mode(world: Node) -> String:
|
||||||
|
if world == null or not world.has_method("is_online") or not world.is_online():
|
||||||
|
return "single-player"
|
||||||
|
var net := _al("Net")
|
||||||
|
var rules := "PvP" if bool(net.server_info.get("pvp", false)) else "co-op"
|
||||||
|
if bool(net.server_info.get("raiding", false)):
|
||||||
|
rules += ", raiding"
|
||||||
|
return "online (%s%s)" % [rules, ", hosting" if net.hosting else ""]
|
||||||
|
|
||||||
|
## The details, one per line (what's in the issue, and what "Copy" copies).
|
||||||
|
static func details(world: Node) -> Dictionary:
|
||||||
|
var d := {
|
||||||
|
"Version": "Emberwild %s (protocol %d)" % [String(ProjectSettings.get_setting("application/config/version", "?")), int(_al("Net").PROTOCOL)],
|
||||||
|
"Computer": "%s %s" % [OS.get_name(), OS.get_version()],
|
||||||
|
"Graphics": "%s (%s)" % [RenderingServer.get_video_adapter_name(), RenderingServer.get_current_rendering_method()],
|
||||||
|
"Mode": mode(world),
|
||||||
|
}
|
||||||
|
if world != null and is_instance_valid(world) and world.has_method("worldlet_id"):
|
||||||
|
d["Land"] = preload("res://scripts/world/terrain.gd").worldlet_name(String(world.worldlet_id()))
|
||||||
|
if world.get("day_night") != null:
|
||||||
|
var h: float = world.day_night.hours()
|
||||||
|
d["Time"] = "day %d, %02d:%02d" % [int(world.day_night.day), int(h), int(fmod(h, 1.0) * 60.0)]
|
||||||
|
if world.get("player") != null and is_instance_valid(world.player):
|
||||||
|
var p: Vector3 = world.player.global_position
|
||||||
|
d["Where"] = "%.0f, %.0f, %.0f" % [p.x, p.y, p.z]
|
||||||
|
if world.get("terrain") != null:
|
||||||
|
d["Seed"] = str(world.terrain.noise_seed)
|
||||||
|
return d
|
||||||
|
|
||||||
|
static func details_text(world: Node) -> String:
|
||||||
|
var d := details(world)
|
||||||
|
var lines: Array[String] = []
|
||||||
|
for k in d:
|
||||||
|
lines.append("- %s: %s" % [k, d[k]])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
static func body(world: Node) -> String:
|
||||||
|
return "\n".join([
|
||||||
|
"**What happened?**", "", "",
|
||||||
|
"**What did you expect to happen?**", "", "",
|
||||||
|
"**How can we make it happen again?** (what you did just before)", "1. ", "",
|
||||||
|
"**Screenshot or log** (optional: drag a screenshot here, or the log file from %s)" % log_path_short(), "",
|
||||||
|
"---", details_text(world)])
|
||||||
|
|
||||||
|
## The new-issue page, filled in.
|
||||||
|
static func issue_url(world: Node, title: String = "") -> String:
|
||||||
|
var t := title if not title.is_empty() else "Bug: "
|
||||||
|
return "%s/issues/new?title=%s&body=%s" % [REPO, t.uri_encode(), body(world).uri_encode()]
|
||||||
|
|
||||||
|
## Opens it in the browser. Returns false when the browser couldn't be opened.
|
||||||
|
static func open(world: Node) -> bool:
|
||||||
|
return OS.shell_open(issue_url(world)) == OK
|
||||||
1
scripts/core/bug_report.gd.uid
Normal file
1
scripts/core/bug_report.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://tnlssmningwq
|
||||||
@@ -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():
|
||||||
|
|||||||
1
scripts/core/guide.gd.uid
Normal file
1
scripts/core/guide.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://nva2x7o0ggw1
|
||||||
@@ -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
|
||||||
|
|||||||
1
scripts/core/hints.gd.uid
Normal file
1
scripts/core/hints.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cd4pyfn6igpx0
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1
scripts/core/music.gd.uid
Normal file
1
scripts/core/music.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://jojnurcejyff
|
||||||
66
scripts/core/narrator.gd
Normal file
66
scripts/core/narrator.gd
Normal 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
|
||||||
1
scripts/core/narrator.gd.uid
Normal file
1
scripts/core/narrator.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ctvohlyg6yr5m
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -87,9 +87,11 @@ const SOUNDS := {
|
|||||||
"guard_rattle": {"set": [C + "guard_rattle_%d.ogg", 2], "db": -2.0, "pitch": [0.85, 1.1], "reach": 25.0},
|
"guard_rattle": {"set": [C + "guard_rattle_%d.ogg", 2], "db": -2.0, "pitch": [0.85, 1.1], "reach": 25.0},
|
||||||
"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},
|
||||||
|
"gull_cry": {"set": [C + "gull_cry_%d.ogg", 2], "db": -5.0, "pitch": [0.9, 1.12], "reach": 50.0},
|
||||||
|
"crab_click": {"set": [C + "crab_click_%d.ogg", 2], "db": -6.0, "pitch": [0.85, 1.15], "reach": 14.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
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user