Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5e7c0e343 | ||
|
|
73599401bf | ||
|
|
a629ddba49 | ||
|
|
a3660f91cf | ||
|
|
4a691b72e1 | ||
|
|
8f3c58c810 | ||
|
|
07b8db1683 | ||
|
|
5672f0713d | ||
|
|
31f8e0d22c | ||
|
|
415e673b28 | ||
|
|
da29308522 | ||
|
|
79061d8572 | ||
|
|
1f67d58b92 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,5 +22,7 @@ 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)
|
# Old Tam's voice environments (tools/voice/make_voice.sh sets one up; .venv was Kokoro, before 0.6.2)
|
||||||
tools/voice/.venv/
|
tools/voice/.venv/
|
||||||
|
tools/voice/.venv-qwen/
|
||||||
|
tools/voice/__pycache__/
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ addon SDK with guard rails.
|
|||||||
## Status
|
## Status
|
||||||
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
|
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
|
||||||
|
|
||||||
**Cinderwild 0.6.1** is released (download page and player guide:
|
**Cinderwild 0.6.2** is released (download page and player guide:
|
||||||
https://cinderwild.psbailey.uk, builds on the Gitea releases). Up to 0.6.0 it
|
https://cinderwild.psbailey.uk, builds on the Gitea releases). Up to 0.6.0 it
|
||||||
was called Emberwild (a name already taken on Steam); before that, Valheimlike.
|
was called Emberwild (a name already taken on Steam); before that, Valheimlike.
|
||||||
Built so far:
|
Built so far:
|
||||||
|
|||||||
@@ -39,12 +39,30 @@ Breviceps (https://freesound.org/people/Breviceps/sounds/465697/, License:
|
|||||||
Creative Commons 0; the preview is in `tools/audio/sources/`), cut at 0.12 s
|
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.
|
and 2.65 s (leaving out the short sound between them), then levelled. Thank you.
|
||||||
|
|
||||||
|
## Thunder (CC0)
|
||||||
|
`sfx/thunder/` is cut from six Freesound recordings, all License: Creative
|
||||||
|
Commons 0 (the previews are in `tools/audio/sources/thunder_*`), each round
|
||||||
|
its loudest strike, faded out and levelled by
|
||||||
|
`tools/audio/make_audio.py thunder`. Close cracks: "Thunder 08" by Emulius
|
||||||
|
(https://freesound.org/people/Emulius/sounds/744723/), "thunder11" by
|
||||||
|
saha213131 (https://freesound.org/people/saha213131/sounds/696550/) and
|
||||||
|
"Thunder strike" by AyaDrevis
|
||||||
|
(https://freesound.org/people/AyaDrevis/sounds/652690/). Distant rolls:
|
||||||
|
"Short_Thunder_Mid" by SholeColtis
|
||||||
|
(https://freesound.org/people/SholeColtis/sounds/683421/), "thunder7" by
|
||||||
|
nednednerb (https://freesound.org/people/nednednerb/sounds/38250/) and
|
||||||
|
"Thunder Clap OWB KY 441x16.wav" by Dave Welsh
|
||||||
|
(https://freesound.org/people/Dave%20Welsh/sounds/194364/). Thank you.
|
||||||
|
|
||||||
## Old Tam's voice
|
## Old Tam's voice
|
||||||
`voice/` is spoken by **Kokoro** (Kokoro-82M by hexgrad, Apache-2.0,
|
`voice/` is spoken by **Qwen3-TTS** (Qwen3-TTS-12Hz-1.7B by Alibaba's Qwen
|
||||||
https://huggingface.co/hexgrad/Kokoro-82M) with its British "George" voice,
|
team, Apache-2.0, https://huggingface.co/Qwen; run through mlx-audio, MIT).
|
||||||
made a little older (deeper, slower, warmer) by `tools/voice/make_voice.py`.
|
His voice, a reedy old West Country farmer, was designed once from a written
|
||||||
The recordings are generated from our own text; rebuild them with
|
description by Qwen3-TTS VoiceDesign (`tools/voice/old_tam/`), and every line
|
||||||
`tools/voice/make_voice.sh` after changing any line.
|
is spoken by cloning that recording. Each line's takes are checked against
|
||||||
|
the script by Whisper (OpenAI, MIT; mlx-whisper). The recordings are made from
|
||||||
|
our own text; rebuild them with `tools/voice/make_voice.sh` after changing any
|
||||||
|
line. (Up to 0.6.1 he was Kokoro-82M by hexgrad, Apache-2.0.)
|
||||||
|
|
||||||
## Cinderwild's own (CC0)
|
## Cinderwild's own (CC0)
|
||||||
`music/`, `ambience/` and `sfx/synth/` are synthesised from scratch by
|
`music/`, `ambience/` and `sfx/synth/` are synthesised from scratch by
|
||||||
|
|||||||
BIN
assets/audio/sfx/thunder/thunder_far_0.ogg
Normal file
BIN
assets/audio/sfx/thunder/thunder_far_0.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/thunder/thunder_far_1.ogg
Normal file
BIN
assets/audio/sfx/thunder/thunder_far_1.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/thunder/thunder_far_2.ogg
Normal file
BIN
assets/audio/sfx/thunder/thunder_far_2.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/thunder/thunder_near_0.ogg
Normal file
BIN
assets/audio/sfx/thunder/thunder_near_0.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/thunder/thunder_near_1.ogg
Normal file
BIN
assets/audio/sfx/thunder/thunder_near_1.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/thunder/thunder_near_2.ogg
Normal file
BIN
assets/audio/sfx/thunder/thunder_near_2.ogg
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,44 +1,44 @@
|
|||||||
{
|
{
|
||||||
"hint_cold": "91c5088e453fb0e6",
|
"hint_cold": "ac4139441b7a347a",
|
||||||
"hint_cove": "f7e5c0283b31e1cc",
|
"hint_cove": "91eb0dcbc24afe5c",
|
||||||
"hint_crab": "283542496ecd180c",
|
"hint_crab": "3a88cd0772a7676d",
|
||||||
"hint_died": "e865f8a63622b3c3",
|
"hint_died": "7b60d478f6fdbcef",
|
||||||
"hint_diet": "3178b319c44e9fa0",
|
"hint_diet": "b5e9600c2da38bdf",
|
||||||
"hint_freezing": "48b1e505119a0fc9",
|
"hint_freezing": "0a63a53be413d808",
|
||||||
"hint_hard_winter": "5513aca1655bc278",
|
"hint_hard_winter": "03e1316a475c3c43",
|
||||||
"hint_howl": "05792dda3f3f0b60",
|
"hint_howl": "05792dda3f3f0b60",
|
||||||
"hint_hungry": "ad76f22cb8e034a0",
|
"hint_hungry": "c99c9fbc83506c43",
|
||||||
"hint_night": "2a07d9c0bfae4bf9",
|
"hint_night": "04fc83670a1fd9e9",
|
||||||
"hint_rotten": "98b69de03dbee089",
|
"hint_rotten": "ecb8aecde8c6c762",
|
||||||
"hint_seam": "b353f46816d329b7",
|
"hint_seam": "05531946c57cb97d",
|
||||||
"hint_sheltered": "ba6d6ff8bc4a1c80",
|
"hint_sheltered": "7d3ebd27c230bc79",
|
||||||
"hint_sick": "ec07963c46638ffc",
|
"hint_sick": "b039c2449304ac53",
|
||||||
"hint_snow": "8f0ac7d093f73840",
|
"hint_snow": "90a2e7f6735e4c83",
|
||||||
"hint_stamina": "82bf5b903db7a29f",
|
"hint_stamina": "e3e6cad6202abcaa",
|
||||||
"hint_storm": "f06bb82d15d991c8",
|
"hint_storm": "477b250b6a9c97cb",
|
||||||
"hint_wet": "6aef86b85d67d7ac",
|
"hint_wet": "ebf9b034487bf92e",
|
||||||
"hint_wolf_moon": "723b3dbd5439980c",
|
"hint_wolf_moon": "d66b43dc8a23b5ed",
|
||||||
"tutorial_animals": "e82eb7e35e866812",
|
"tutorial_animals": "20b2eabd8de50d58",
|
||||||
"tutorial_axe": "4bb51d6ea8b42840",
|
"tutorial_axe": "c9a632910c8eaf19",
|
||||||
"tutorial_bed": "34cf4c842db48c19",
|
"tutorial_bed": "46f0130e86504f5b",
|
||||||
"tutorial_berries": "a7cd3805f11c8898",
|
"tutorial_berries": "31d4fd1560fcf46f",
|
||||||
"tutorial_campfire": "0a26bd897d1d8a90",
|
"tutorial_campfire": "6a7790be69235e80",
|
||||||
"tutorial_cook": "7164a8ab27a7821d",
|
"tutorial_cook": "180b4f1413608105",
|
||||||
"tutorial_diet": "fd47408ab4f2b5de",
|
"tutorial_diet": "d44066db5adc0f78",
|
||||||
"tutorial_finish": "36c07fc5a64c9d65",
|
"tutorial_finish": "a9960edb587c3176",
|
||||||
"tutorial_fish": "32f2f3228c036136",
|
"tutorial_fish": "7945d260ceff912b",
|
||||||
"tutorial_flax": "a87ff75ed8864ca6",
|
"tutorial_flax": "7e4f439fc7079272",
|
||||||
"tutorial_hut": "a10bfa289939dfb2",
|
"tutorial_hut": "57c364fcde4bbff9",
|
||||||
"tutorial_look": "27acdf33905cc1a3",
|
"tutorial_look": "4998d8b95ff4fa8e",
|
||||||
"tutorial_mushroom": "79edcfb1afe18c8c",
|
"tutorial_mushroom": "ae582e11c9214891",
|
||||||
"tutorial_pickaxe": "177e0474e4d0fda6",
|
"tutorial_pickaxe": "0697d3610e8a70c5",
|
||||||
"tutorial_rabbit": "6d429ab2cbdc7b19",
|
"tutorial_rabbit": "62544c47f9813cc4",
|
||||||
"tutorial_rod": "d610281e722e8fd3",
|
"tutorial_rod": "0aeae228c3e1e2ed",
|
||||||
"tutorial_sleep": "e202cbf133500c2a",
|
"tutorial_sleep": "51fc176c04af2683",
|
||||||
"tutorial_sticks": "e526e30ddf8756a7",
|
"tutorial_sticks": "5a350695e62a28ef",
|
||||||
"tutorial_torch": "68808fca608352fc",
|
"tutorial_torch": "4bdb8837ac441e6e",
|
||||||
"tutorial_tree": "1dfb2ab0491d962f",
|
"tutorial_tree": "efcbe43e71059ba1",
|
||||||
"tutorial_weapon": "a17d1fe6d5067b8d",
|
"tutorial_weapon": "4d04fcd046eae1e2",
|
||||||
"tutorial_wolf": "feb6bdda64e28161",
|
"tutorial_wolf": "b0725af812cc8d90",
|
||||||
"tutorial_workbench": "1b6108ab18f05de9"
|
"tutorial_workbench": "dd05c80fb8b3c3b8"
|
||||||
}
|
}
|
||||||
45
assets/audio/voice/spoken.json
Normal file
45
assets/audio/voice/spoken.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"_voice": "c55ffaa0e19b744b",
|
||||||
|
"hint_cold": "ac4139441b7a347a",
|
||||||
|
"hint_cove": "91eb0dcbc24afe5c",
|
||||||
|
"hint_crab": "3a88cd0772a7676d",
|
||||||
|
"hint_died": "7b60d478f6fdbcef",
|
||||||
|
"hint_diet": "b5e9600c2da38bdf",
|
||||||
|
"hint_freezing": "0a63a53be413d808",
|
||||||
|
"hint_hard_winter": "03e1316a475c3c43",
|
||||||
|
"hint_howl": "05792dda3f3f0b60",
|
||||||
|
"hint_hungry": "c99c9fbc83506c43",
|
||||||
|
"hint_night": "04fc83670a1fd9e9",
|
||||||
|
"hint_rotten": "ecb8aecde8c6c762",
|
||||||
|
"hint_seam": "05531946c57cb97d",
|
||||||
|
"hint_sheltered": "7d3ebd27c230bc79",
|
||||||
|
"hint_sick": "b039c2449304ac53",
|
||||||
|
"hint_snow": "90a2e7f6735e4c83",
|
||||||
|
"hint_stamina": "e3e6cad6202abcaa",
|
||||||
|
"hint_storm": "477b250b6a9c97cb",
|
||||||
|
"hint_wet": "ebf9b034487bf92e",
|
||||||
|
"hint_wolf_moon": "d66b43dc8a23b5ed",
|
||||||
|
"tutorial_animals": "20b2eabd8de50d58",
|
||||||
|
"tutorial_axe": "c9a632910c8eaf19",
|
||||||
|
"tutorial_bed": "46f0130e86504f5b",
|
||||||
|
"tutorial_berries": "31d4fd1560fcf46f",
|
||||||
|
"tutorial_campfire": "6a7790be69235e80",
|
||||||
|
"tutorial_cook": "180b4f1413608105",
|
||||||
|
"tutorial_diet": "d44066db5adc0f78",
|
||||||
|
"tutorial_finish": "a9960edb587c3176",
|
||||||
|
"tutorial_fish": "7945d260ceff912b",
|
||||||
|
"tutorial_flax": "7e4f439fc7079272",
|
||||||
|
"tutorial_hut": "57c364fcde4bbff9",
|
||||||
|
"tutorial_look": "2345ea0b154d4014",
|
||||||
|
"tutorial_mushroom": "ae582e11c9214891",
|
||||||
|
"tutorial_pickaxe": "0697d3610e8a70c5",
|
||||||
|
"tutorial_rabbit": "62544c47f9813cc4",
|
||||||
|
"tutorial_rod": "0aeae228c3e1e2ed",
|
||||||
|
"tutorial_sleep": "51fc176c04af2683",
|
||||||
|
"tutorial_sticks": "5a350695e62a28ef",
|
||||||
|
"tutorial_torch": "4bdb8837ac441e6e",
|
||||||
|
"tutorial_tree": "efcbe43e71059ba1",
|
||||||
|
"tutorial_weapon": "4d04fcd046eae1e2",
|
||||||
|
"tutorial_wolf": "b0725af812cc8d90",
|
||||||
|
"tutorial_workbench": "dd05c80fb8b3c3b8"
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,42 +3,42 @@
|
|||||||
"_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).",
|
"_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, don't you fret. Whatever you were carrying is waiting where you fell, so go back for it first. Sleep in a bed once, and that's where you'll wake."},
|
||||||
{"id": "freezing", "when": "status:Freezing", "title": "Freezing",
|
{"id": "freezing", "when": "status:Freezing", "title": "Freezing",
|
||||||
"text": "You're freezing, and that eats at your health. Get to a fire or under a roof, now. A cloak helps too."},
|
"text": "You're freezing, and that'll eat away at your health! Get to a fire or under a roof, now. A cloak helps too."},
|
||||||
{"id": "cold", "when": "status:Cold", "hold": 3, "title": "Getting cold",
|
{"id": "cold", "when": "status:Cold", "hold": 3, "title": "Getting cold",
|
||||||
"text": "Feel that chill? A fire warms you, a roof and walls keep the wind off, and a hide cloak buys you time."},
|
"text": "Feel that chill? A fire warms you up, a roof and walls keep the wind off, and a hide cloak buys you a bit of time."},
|
||||||
{"id": "sick", "when": "status:Food poisoning", "title": "Bad food",
|
{"id": "sick", "when": "status:Food poisoning", "title": "Bad food",
|
||||||
"text": "Raw meat or something past its best: your belly's turned. It passes. Rest, don't eat for a bit, and cook your meat next time."},
|
"text": "Ooh, your belly's turned. Raw meat, or something past its best, I'd wager. It'll pass. Rest a while, don't eat for a bit, and cook your meat next time."},
|
||||||
{"id": "rotten", "when": "has:rotten_food", "title": "Food goes off",
|
{"id": "rotten", "when": "has:rotten_food", "title": "Food goes off",
|
||||||
"text": "Food doesn't keep forever. Eat the fresh stuff first, smoke meat and fish on a rack, or salt it. Rotten food's no good to anyone: throw it out."},
|
"text": "Food doesn't keep forever, you know. Eat the fresh stuff first, smoke your meat and fish on a rack, or salt it. Rotten food's no good to anyone, so throw it out."},
|
||||||
{"id": "wet", "when": "status:Wet", "hold": 2, "title": "Soaked",
|
{"id": "wet", "when": "status:Wet", "hold": 2, "title": "Soaked",
|
||||||
"text": "Wet clothes steal your warmth. Stand under a roof or by a fire and you'll dry off."},
|
"text": "You're soaked through! Wet clothes steal your warmth. Stand under a roof or by a fire and you'll dry off."},
|
||||||
{"id": "hungry", "when": "status:Hungry", "hold": 2, "title": "Hungry",
|
{"id": "hungry", "when": "status:Hungry", "hold": 2, "title": "Hungry",
|
||||||
"text": "Your belly's empty. Berries and mushrooms tide you over, but cooked meat and fish fill you up for longer."},
|
"text": "Your belly's empty. Berries and mushrooms will tide you over, but cooked meat and fish fill you up for longer."},
|
||||||
{"id": "diet", "when": "status:Poor diet|Malnourished", "hold": 2, "title": "Mix it up",
|
{"id": "diet", "when": "status:Poor diet|Malnourished", "hold": 2, "title": "Mix it up",
|
||||||
"text": "Eat the same thing every day and your body notices. Watch the three diet bars: meat, fish and greens. Keep them all topped up and you'll be stronger for it."},
|
"text": "Eat the same thing every day and your body notices, it does. Watch them three diet bars: meat, fish and greens. Keep them all topped up and you'll be stronger for it."},
|
||||||
{"id": "howl", "when": "event:howl", "title": "Wolves",
|
{"id": "howl", "when": "event:howl", "title": "Wolves",
|
||||||
"text": "Hear that? Wolves have moved into the hills. They'll come hunting soon after dark. Walls, a door and a fire are your friends."},
|
"text": "Hear that? Wolves have moved into the hills. They'll come hunting soon after dark. Walls, a door and a fire are your friends."},
|
||||||
{"id": "wolf_moon", "when": "wolf_moon", "title": "The red moon",
|
{"id": "wolf_moon", "when": "wolf_moon", "title": "The red moon",
|
||||||
"text": "A red moon. The pack's bolder tonight and the big one leads them. Stay behind a good door, or stand by a fire with a weapon ready."},
|
"text": "A red moon, look. The pack's bolder tonight, and the big one leads them. Stay behind a good door, or stand by a fire with a weapon ready."},
|
||||||
{"id": "night", "when": "night", "hold": 5, "title": "Nightfall",
|
{"id": "night", "when": "night", "hold": 5, "title": "Nightfall",
|
||||||
"text": "Dark and cold till morning. Keep near a fire or carry a torch. A bed under a roof lets you sleep through it."},
|
"text": "Dark and cold till morning, now. Keep near a fire or carry a torch. A bed under a roof lets you sleep right through it."},
|
||||||
{"id": "hard_winter", "when": "hard_winter", "hold": 3, "title": "Hard winter",
|
{"id": "hard_winter", "when": "hard_winter", "hold": 3, "title": "Hard winter",
|
||||||
"text": "A hard winter's come in: a few days of bitter cold, and blizzards that freeze you even by day. Stock wood and food, and stay close to home."},
|
"text": "A hard winter's come in. A few days of bitter cold, and blizzards that'll freeze you even by day. Stock up on wood and food, and stay close to home."},
|
||||||
{"id": "snow", "when": "weather:snow|blizzard", "hold": 3, "title": "Snow",
|
{"id": "snow", "when": "weather:snow|blizzard", "hold": 3, "title": "Snow",
|
||||||
"text": "Snow's falling. It's colder out in it, and heavy snow piles up on roofs. Keep your fire fed."},
|
"text": "Snow's falling. It's colder out in it, and heavy snow piles up on your roofs. Keep your fire fed."},
|
||||||
{"id": "storm", "when": "weather:storm", "hold": 3, "title": "Storm",
|
{"id": "storm", "when": "weather:storm", "hold": 3, "title": "Storm",
|
||||||
"text": "A storm. Lightning goes for the trees, so don't shelter under one. Your buildings are safe enough."},
|
"text": "A storm's coming in. Lightning goes for the trees, so don't you go sheltering under one. Your buildings are safe enough."},
|
||||||
{"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 all puffed out. 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, that's the way. The weather can't touch you in here. Sit by a fire inside a little while and you'll be Rested."},
|
||||||
{"id": "seam", "when": "seam", "hold": 3, "title": "The cracked seam",
|
{"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."},
|
"text": "That antler pick bites rock that 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 down there."},
|
||||||
{"id": "cove", "when": "worldlet:cove", "hold": 3, "title": "The Cove",
|
{"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."},
|
"text": "Well, you made it down! Sand, salt and sea fish. Fish off the end of the pier: mackerel bite close in, and cod further out where it's deep. The sea cave takes you back up the tunnel to home. Watch out for 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, so give them room, or they'll pinch."},
|
||||||
{"id": "crab", "when": "has:crab_meat", "hold": 2, "title": "Crab",
|
{"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."}
|
"text": "Two lots of crab meat cook up into a good crab supper at a campfire. Raw, it's as risky as raw fish."}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,13 +280,49 @@
|
|||||||
"name": "Wood Gable",
|
"name": "Wood Gable",
|
||||||
"category": "building",
|
"category": "building",
|
||||||
"stack": 20,
|
"stack": 20,
|
||||||
"description": "Triangle that closes the end of a roof. R to flip it.",
|
"description": "Triangle that closes the end of a roof. R to flip it. On a wider house, stand it on a half wall to close a taller roof's end.",
|
||||||
"mesh": "wood_gable",
|
"mesh": "wood_gable",
|
||||||
"place": {
|
"place": {
|
||||||
"piece": "build",
|
"piece": "build",
|
||||||
"kind": "gable"
|
"kind": "gable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_half_wall",
|
||||||
|
"name": "Wood Half Wall",
|
||||||
|
"category": "building",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "2 x 1 m. A low wall or railing, or on top of a wall, under a gable, to close the end of a wider house's taller roof.",
|
||||||
|
"mesh": "wood_half_wall",
|
||||||
|
"place": {
|
||||||
|
"piece": "build",
|
||||||
|
"kind": "half_wall"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_roof_ridge",
|
||||||
|
"name": "Wood Roof Ridge",
|
||||||
|
"category": "building",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "A 2 m ridge cap: on a 6 m-wide house, it bridges the gap between the two sides' roofs, at the top.",
|
||||||
|
"mesh": "wood_roof_ridge",
|
||||||
|
"place": {
|
||||||
|
"piece": "build",
|
||||||
|
"kind": "roof_ridge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_ridge_gable",
|
||||||
|
"name": "Wood Ridge Gable",
|
||||||
|
"category": "building",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "A small triangle that closes the end under a roof ridge. Stand it on a half wall.",
|
||||||
|
"mesh": "wood_ridge_gable",
|
||||||
|
"place": {
|
||||||
|
"piece": "build",
|
||||||
|
"kind": "ridge_gable"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "bed",
|
"id": "bed",
|
||||||
"name": "Bed",
|
"name": "Bed",
|
||||||
|
|||||||
@@ -138,6 +138,37 @@
|
|||||||
"wood": 1
|
"wood": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_half_wall",
|
||||||
|
"output": "wood_half_wall",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"category": "building",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_roof_ridge",
|
||||||
|
"output": "wood_roof_ridge",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"category": "building",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 2,
|
||||||
|
"fiber": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_ridge_gable",
|
||||||
|
"output": "wood_ridge_gable",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"category": "building",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "bed",
|
"id": "bed",
|
||||||
"output": "bed",
|
"output": "bed",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"id": "look",
|
"id": "look",
|
||||||
"chapter": "tools",
|
"chapter": "tools",
|
||||||
"title": "Look around",
|
"title": "Look around",
|
||||||
"text": "Awake at last! Washed up on my island, have you. Stretch your legs: WASD to walk, the mouse to look. Shift if you're in a hurry.",
|
"text": "Ha! Awake at last! Washed up on my island, eh? Well, stretch your legs. Use WASD to move, the mouse to look around, and Shift to run.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"walk": 10
|
"walk": 10
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"id": "sticks",
|
"id": "sticks",
|
||||||
"chapter": "tools",
|
"chapter": "tools",
|
||||||
"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": "Now then! Empty hands are no use to anyone out here. Look at a stick or a stone on the ground and press E to pick it up. Why don't you go ahead and grab three of each.",
|
||||||
"marker": {
|
"marker": {
|
||||||
"nearest": "pickup:stick|stone"
|
"nearest": "pickup:stick|stone"
|
||||||
},
|
},
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"id": "flax",
|
"id": "flax",
|
||||||
"chapter": "tools",
|
"chapter": "tools",
|
||||||
"title": "Pick wild flax",
|
"title": "Pick wild flax",
|
||||||
"text": "See the pale, tall plants? Wild flax. Pick some. The fibre makes torches, bows and a decent bed.",
|
"text": "See them tall, pale plants? That's wild flax. Pick a few of those. The fibre makes bows, a decent bed, and torches. You'll need a torch before long. It gets pretty dark here.",
|
||||||
"marker": {
|
"marker": {
|
||||||
"nearest": "pickup:fiber"
|
"nearest": "pickup:fiber"
|
||||||
},
|
},
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
"id": "axe",
|
"id": "axe",
|
||||||
"chapter": "tools",
|
"chapter": "tools",
|
||||||
"title": "Craft a stone axe",
|
"title": "Craft a stone axe",
|
||||||
"text": "Now an axe. Two sticks and two flint: flint lies about, more by the water. Tab opens your pack, and the recipe's in there.",
|
"text": "Right, now you'll be wanting an axe. That's made from two sticks and two flint. Flint can be found lying all over the place, slightly darker than a stone. You'll find more by the water. If you press Tab to open your pack, you'll find the recipe in there.",
|
||||||
"marker": {
|
"marker": {
|
||||||
"nearest": "pickup:flint",
|
"nearest": "pickup:flint",
|
||||||
"unless_have": {
|
"unless_have": {
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
"id": "tree",
|
"id": "tree",
|
||||||
"chapter": "tools",
|
"chapter": "tools",
|
||||||
"title": "Chop down a tree",
|
"title": "Chop down a tree",
|
||||||
"text": "Axe in hand (the number keys), hold the left button at a trunk and keep at it. A tree gives wood, and sticks when it falls.",
|
"text": "Got your axe? Good. Pick it with the number keys so it's in your hand, then click the left mouse button at a tree trunk a few times. Down it comes, and you'll get wood, and a few sticks besides.",
|
||||||
"marker": {
|
"marker": {
|
||||||
"nearest": "tree"
|
"nearest": "tree"
|
||||||
},
|
},
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
"id": "pickaxe",
|
"id": "pickaxe",
|
||||||
"chapter": "tools",
|
"chapter": "tools",
|
||||||
"title": "Craft a pickaxe and break a boulder",
|
"title": "Craft a pickaxe and break a boulder",
|
||||||
"text": "Rocks are next. A pickaxe takes two sticks, three stone and a flint. Then go and break a boulder for stone and flint of your own.",
|
"text": "Now for the rocks. Make yourself a pickaxe, from two sticks, three stone and a flint. Then go and give one of them big boulders a good few whacks, and you'll have stone and flint of your own.",
|
||||||
"marker": {
|
"marker": {
|
||||||
"nearest": "rock"
|
"nearest": "rock"
|
||||||
},
|
},
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
"id": "campfire",
|
"id": "campfire",
|
||||||
"chapter": "food",
|
"chapter": "food",
|
||||||
"title": "Build a campfire",
|
"title": "Build a campfire",
|
||||||
"text": "Five stone and four sticks make a campfire. Pick it in your pack, then place it with the left button (R turns it). Fire gives light, warmth and somewhere to cook. Feed it wood with E.",
|
"text": "Now, a fire. Five stone and four sticks make a campfire. Pick it in your pack, then click the left mouse button to put it down. R turns it round, if you're fussy. A fire gives you light, warmth, and somewhere to cook. Keep it fed with wood: look at it and press E.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"placed": "campfire"
|
"placed": "campfire"
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
"id": "berries",
|
"id": "berries",
|
||||||
"chapter": "food",
|
"chapter": "food",
|
||||||
"title": "Pick berries and eat some",
|
"title": "Pick berries and eat some",
|
||||||
"text": "Those red berries on the bushes are safe. Pick some with E, then hold them and press the right button to eat.",
|
"text": "Hungry? Them red berries on the bushes are safe enough. Pick some with E, then hold them with the number keys and click the right mouse button to eat. Go on, have a few.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"all": [
|
"all": [
|
||||||
{
|
{
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
"id": "mushroom",
|
"id": "mushroom",
|
||||||
"chapter": "food",
|
"chapter": "food",
|
||||||
"title": "Roast a mushroom",
|
"title": "Roast a mushroom",
|
||||||
"text": "Mushrooms grow under the trees. Raw ones are all right; roasted at the fire, much better. Find one and roast it (stand by the fire and open your pack).",
|
"text": "Mushrooms grow in the shade, under the trees. Raw ones are all right, but roasted at the fire they're a sight better. Find one, then stand by your fire and open your pack to roast it.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"all": [
|
"all": [
|
||||||
{
|
{
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
"id": "diet",
|
"id": "diet",
|
||||||
"chapter": "food",
|
"chapter": "food",
|
||||||
"title": "Mind your diet",
|
"title": "Mind your diet",
|
||||||
"text": "See the three Diet bars, bottom left? Meat, Fish, Greens. Each food fills one. Let one run low and you stop healing; let two, and you'll waste away on a full belly. Keep all three up and you heal faster. Press Enter when you've got that.",
|
"text": "Now, see them three Diet bars, bottom left? Meat, fish and greens. Each food fills one of them. Let one run low and you'll stop healing. Let two run low, and you'll waste away on a full belly. Keep all three topped up and you'll heal faster. Press Enter when you've got all that.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"continue": true
|
"continue": true
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
"id": "workbench",
|
"id": "workbench",
|
||||||
"chapter": "hunt",
|
"chapter": "hunt",
|
||||||
"title": "Build a workbench",
|
"title": "Build a workbench",
|
||||||
"text": "Better tools want a workbench nearby. Ten wood and two flint. Chop a few more trees if you're short.",
|
"text": "Better tools want a workbench close by. That's ten wood and two flint. If you're short, go and chop down a few more trees. Then put it down near your fire.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"placed": "workbench"
|
"placed": "workbench"
|
||||||
}
|
}
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
"id": "weapon",
|
"id": "weapon",
|
||||||
"chapter": "hunt",
|
"chapter": "hunt",
|
||||||
"title": "Make a weapon",
|
"title": "Make a weapon",
|
||||||
"text": "You'll not catch your supper with your bare hands. A club is four wood. A spear, at the bench, reaches further.",
|
"text": "You'll not catch your supper with your bare hands, will you? A club is four wood, and you can make that anywhere. A spear reaches further, but you'll need to be at the bench for that one.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"any": [
|
"any": [
|
||||||
{
|
{
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
"id": "rabbit",
|
"id": "rabbit",
|
||||||
"chapter": "hunt",
|
"chapter": "hunt",
|
||||||
"title": "Hunt a rabbit",
|
"title": "Hunt a rabbit",
|
||||||
"text": "There, rabbits! They bolt if you run at them. Walk up quiet, weapon in hand, and strike.",
|
"text": "There! Rabbits! Run at them and they'll bolt, so creep up quiet, weapon in hand, and give them a whack.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"killed": "rabbit"
|
"killed": "rabbit"
|
||||||
},
|
},
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
"id": "cook",
|
"id": "cook",
|
||||||
"chapter": "hunt",
|
"chapter": "hunt",
|
||||||
"title": "Butcher it and cook the meat",
|
"title": "Butcher it and cook the meat",
|
||||||
"text": "E on the carcass to butcher it. Raw meat turns your stomach; cooked at the fire it heals you and keeps you Well fed.",
|
"text": "Now look at it and press E to butcher it. Don't you go eating that meat raw, it'll turn your stomach. Cook it at the fire, and it'll heal you up and keep you Well fed.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"all": [
|
"all": [
|
||||||
{
|
{
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
"id": "animals",
|
"id": "animals",
|
||||||
"chapter": "hunt",
|
"chapter": "hunt",
|
||||||
"title": "Know your animals",
|
"title": "Know your animals",
|
||||||
"text": "Deer are quick: a bow's your friend there. Boars are calm until you hit one, then they come for you. Wolves you'll meet soon enough. Press Enter.",
|
"text": "Deer are quick, so a bow's your friend there. Boars are calm enough until you hit one, then they'll come right for you. And wolves? Well, you'll meet them soon enough. Press Enter when you're ready.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"continue": true
|
"continue": true
|
||||||
}
|
}
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
"id": "rod",
|
"id": "rod",
|
||||||
"chapter": "fish",
|
"chapter": "fish",
|
||||||
"title": "Make a fishing rod",
|
"title": "Make a fishing rod",
|
||||||
"text": "Two sticks, three fibre and a flint, at the workbench.",
|
"text": "Fancy a bit of fish? Make yourself a rod at the workbench, from two sticks, three fibre and a flint.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"crafted": "fishing_rod"
|
"crafted": "fishing_rod"
|
||||||
}
|
}
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
"id": "fish",
|
"id": "fish",
|
||||||
"chapter": "fish",
|
"chapter": "fish",
|
||||||
"title": "Catch a fish and grill it",
|
"title": "Catch a fish and grill it",
|
||||||
"text": "Down to the lake. Rod in hand, the left button casts. When the float dips, click quick, before it gets away. Grill what you catch at the fire.",
|
"text": "Down to the lake with you. Rod in hand, click the left mouse button to cast. When the float dips, click again, quick as you can, before it gets away. Then grill what you catch at the fire.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"all": [
|
"all": [
|
||||||
{
|
{
|
||||||
@@ -285,7 +285,7 @@
|
|||||||
"id": "hut",
|
"id": "hut",
|
||||||
"chapter": "night",
|
"chapter": "night",
|
||||||
"title": "Build a little hut",
|
"title": "Build a little hut",
|
||||||
"text": "Nights get cold, and things prowl. Build a hut: I've marked out a good spot by your fire, pale as a ghost. Floors, walls and a door, then the roof, all from the bench. Put a piece near its place in the outline and it'll go right in. Done when you can stand inside out of the weather.",
|
"text": "Nights get cold out here, and things prowl about. You'll want a hut. I've marked out a good spot by your fire, pale as a ghost. Floors, walls and a door, then the roof, all made at the bench. Put each piece near its place in the outline and it'll go right in. You're done when you can stand inside, out of the weather.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"shelter": true
|
"shelter": true
|
||||||
},
|
},
|
||||||
@@ -300,7 +300,7 @@
|
|||||||
"id": "bed",
|
"id": "bed",
|
||||||
"chapter": "night",
|
"chapter": "night",
|
||||||
"title": "Make a bed and sleep spot",
|
"title": "Make a bed and sleep spot",
|
||||||
"text": "Eight wood and six fibre make a bed. Put it inside, then E on it: that's where you'll wake if anything happens to you.",
|
"text": "Now a bed. That's eight wood and six fibre. Put it inside your hut, then look at it and press E. That's where you'll wake up if anything happens to you.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"all": [
|
"all": [
|
||||||
{
|
{
|
||||||
@@ -316,7 +316,7 @@
|
|||||||
"id": "torch",
|
"id": "torch",
|
||||||
"chapter": "night",
|
"chapter": "night",
|
||||||
"title": "Night is coming: make a torch",
|
"title": "Night is coming: make a torch",
|
||||||
"text": "There, the sun's going down. It gets dark and cold. A torch (a stick and two fibre) lights the way and keeps you a little warm.",
|
"text": "There, the sun's going down. It gets dark and cold, mind. Make yourself a torch, from a stick and two fibre. It lights the way, and keeps you a little warm besides.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"crafted": "torch"
|
"crafted": "torch"
|
||||||
},
|
},
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
"id": "wolf",
|
"id": "wolf",
|
||||||
"chapter": "night",
|
"chapter": "night",
|
||||||
"title": "Wolves",
|
"title": "Wolves",
|
||||||
"text": "Hush. See it, at the edge of the light? Wolves. From the fourth night they hunt. They won't come close to a lit fire, a torch makes them think twice, and they can't bite through walls. Just watch.",
|
"text": "Hush now. See it, there, at the edge of the light? A wolf. From the fourth night they hunt. They won't come near a lit fire, a torch makes them think twice, and they can't bite through walls. Just you watch.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"demo_wolf": true
|
"demo_wolf": true
|
||||||
},
|
},
|
||||||
@@ -340,7 +340,7 @@
|
|||||||
"id": "sleep",
|
"id": "sleep",
|
||||||
"chapter": "night",
|
"chapter": "night",
|
||||||
"title": "Sleep till morning",
|
"title": "Sleep till morning",
|
||||||
"text": "Off to bed with you. You can only sleep at night, under a roof with walls round you, and not with wolves about.",
|
"text": "Off to bed with you. You can only sleep at night, under a roof with walls round you, and not with wolves about. Look at your bed and press E.",
|
||||||
"goal": {
|
"goal": {
|
||||||
"slept": true
|
"slept": true
|
||||||
},
|
},
|
||||||
@@ -351,6 +351,6 @@
|
|||||||
],
|
],
|
||||||
"finish": {
|
"finish": {
|
||||||
"title": "You'll do",
|
"title": "You'll do",
|
||||||
"text": "That's the island, near enough: gather, craft, eat a bit of everything, keep warm and dry, and be indoors or by a fire when the wolves are about. E picks up and uses, Tab is your pack, X takes a thing down, R turns what you're placing, the right button eats or wears. Off you go, then."
|
"text": "Well, you'll do. That's the island, near enough. Gather, craft, eat a bit of everything, keep warm and dry, and be indoors or by a fire when the wolves are about. Remember: E picks up and uses, Tab is your pack, X takes a thing down, R turns what you're placing, and the right mouse button eats or wears. Off you go, then."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,12 +95,15 @@ phases M2.0-M2.7).
|
|||||||
land, players in different lands at once, chat, the player list, admin
|
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,
|
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
|
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,
|
rowing boat moored at the pier; Report a bug in the pause menu. *(0.6.1)*
|
||||||
|
the new name, Cinderwild. *(0.6.2)* playtest fixes: animals off the cliffs,
|
||||||
|
wider and odd-shaped houses, real thunder, the tutorial's daylight, and Old
|
||||||
|
Tam rewritten in a new voice. Next J2,
|
||||||
then J4 (the Highlands by sail).
|
then J4 (the Highlands by sail).
|
||||||
- Audio, music, weather. *Done (0.2.0-0.2.1):* mood music (calm, night,
|
- Audio, music, weather. *Done (0.2.0-0.2.1):* mood music (calm, night,
|
||||||
storm, danger, boss) that crossfades with what's happening, ambience (CC0
|
storm, danger, boss) that crossfades with what's happening, ambience (CC0
|
||||||
birdsong, crickets, owls, fire), sound effects for every action (Kenney
|
birdsong, crickets, owls, fire), sound effects for every action (Kenney
|
||||||
CC0 plus synthesised), creature voices, Old Tam's spoken lines (Kokoro).
|
CC0 plus synthesised), creature voices, Old Tam's spoken lines (Kokoro, then Qwen3-TTS from 0.6.2).
|
||||||
Sources and licences: `assets/audio/CREDITS.md`; tools in `tools/audio/`
|
Sources and licences: `assets/audio/CREDITS.md`; tools in `tools/audio/`
|
||||||
and `tools/voice/`.
|
and `tools/voice/`.
|
||||||
- Help: the player guide on the download site (`tools/site/`), written
|
- Help: the player guide on the download site (`tools/site/`), written
|
||||||
@@ -114,6 +117,66 @@ phases M2.0-M2.7).
|
|||||||
game icon on the .exe (rcedit), Linux, and the update check below.
|
game icon on the .exe (rcedit), Linux, and the update check below.
|
||||||
- **Updates**: the game (and a dedicated server) needs to notice when a new
|
- **Updates**: the game (and a dedicated server) needs to notice when a new
|
||||||
version is out, download what's needed and install it. See below.
|
version is out, download what's needed and install it. See below.
|
||||||
|
- **A single-player web version** (Paul, Nov 2026: after the playtest):
|
||||||
|
play straight from the download page, with "Download for the full game
|
||||||
|
and multiplayer" beside it. Godot's web export, single-threaded, on the
|
||||||
|
Compatibility renderer (WebGL 2). What we found (0.6.1):
|
||||||
|
- *The look:* rendered with that renderer (`build/web_look/`, from
|
||||||
|
`tools/site/shots.gd` run with `--rendering-method gl_compatibility`),
|
||||||
|
it's still clearly the game, but harsher: no volumetric fog or haze,
|
||||||
|
no SSAO/SSIL (darker, sharper shadows), deeper greens. Night and fire
|
||||||
|
light hold up. **The sea renders flat white**: `shaders/water.gdshader`
|
||||||
|
needs a Compatibility path. Ordinary distance fog would bring some
|
||||||
|
atmosphere back; grass density and tree counts likely need a lower
|
||||||
|
setting for browsers (speed not measured yet).
|
||||||
|
- *Single-player only:* browsers can't use ENet (UDP), and can't start
|
||||||
|
the Host Game or per-land server processes. Joining from a browser
|
||||||
|
would mean a WebSocket transport on the server, served over wss://
|
||||||
|
(a certificate per server): a separate, bigger job, if ever.
|
||||||
|
- *Saves* go in the browser's storage (IndexedDB): kept, but per browser
|
||||||
|
and lost if site data is cleared. Maybe an export/import button.
|
||||||
|
- *Input:* the pointer lock needs a click, and Esc releases it in the
|
||||||
|
browser, so the pause menu needs another key too (P).
|
||||||
|
- *Size and hosting:* the engine (godot.wasm, 39.5 MB) plus the game data
|
||||||
|
(about 22 MB) download first, then cache. Cloudflare Pages refuses files
|
||||||
|
over 25 MiB, so the .wasm has to live elsewhere (the Gitea release, or
|
||||||
|
another host) or be split.
|
||||||
|
- *Size of the job:* one step to build it and see how it runs, another to
|
||||||
|
fix the water, tune the graphics and controls, and put it on the page.
|
||||||
|
- **Cloud saves** (Paul, Nov 2026: with the web version, and useful before
|
||||||
|
it, desktop to desktop): carry a single-player world between the desktop
|
||||||
|
game and the browser, **both ways**.
|
||||||
|
- *In the game:* pause menu **Save to cloud** (a cloud name and a password
|
||||||
|
the first time; after that it can upload on each save: autosave, sleep,
|
||||||
|
quit); title screen **Load from cloud** (name and password, pick a
|
||||||
|
world, it downloads and you carry on). What travels: the world folder
|
||||||
|
(`meta.json`, `world.json`), its explored map (`maps/`), and which of
|
||||||
|
Old Tam's tips you've seen. Not online worlds (they live on their server).
|
||||||
|
- *Size:* a world is 8-32 KB on disk, a few KB compressed: uploads are
|
||||||
|
instant and storage is next to free.
|
||||||
|
- *Where:* a Cloudflare Worker beside the site (`/api/...`), saves in R2
|
||||||
|
(or D1). Free tier is plenty. (Paul's own server would do, but is one
|
||||||
|
more service to keep running.)
|
||||||
|
- *Safety:* passwords never stored, only a salted hash (PBKDF2, WebCrypto
|
||||||
|
in the Worker); HTTPS only; wrong guesses lock a name out for a while;
|
||||||
|
a size cap and a world count per name. Option: encrypt the save with
|
||||||
|
the password in the game before upload, so the server holds only
|
||||||
|
ciphertext (but a forgotten password loses the cloud copy; the local
|
||||||
|
copy is unaffected).
|
||||||
|
- *Playing in two places:* each upload carries a counter. If the cloud's
|
||||||
|
copy is newer than the one you last downloaded, the game asks ("The
|
||||||
|
cloud has a newer save (from the web, 20 minutes ago): use that, or
|
||||||
|
keep this one?") instead of overwriting it.
|
||||||
|
- *Versions:* the same save format on desktop and web; a save from a
|
||||||
|
newer version than the game says so rather than loading it badly.
|
||||||
|
- *Size of the job:* one step for the Worker and storage (with tests), one
|
||||||
|
for the game side (name and password, the buttons, the newer-copy
|
||||||
|
question). Paul creates the Worker and its storage in his Cloudflare
|
||||||
|
account; the code and setup steps come with it.
|
||||||
|
- **Later, maybe:** Android tablets (Nov 2026: left for now). Most input is
|
||||||
|
already named actions, so a touch layer (stick, look, contextual buttons)
|
||||||
|
is possible; the Mobile renderer has the same lost effects as the web, and
|
||||||
|
a tablet can join but not host (no server processes).
|
||||||
|
|
||||||
### Updates (to design with the packaged builds)
|
### Updates (to design with the packaged builds)
|
||||||
Needed because every network change bumps the protocol (12 as of P5), and
|
Needed because every network change bumps the protocol (12 as of P5), and
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ application/icon_interpolation=4
|
|||||||
application/bundle_identifier="com.pbailey.cinderwild"
|
application/bundle_identifier="com.pbailey.cinderwild"
|
||||||
application/signature=""
|
application/signature=""
|
||||||
application/app_category="Games"
|
application/app_category="Games"
|
||||||
application/short_version="0.6.1"
|
application/short_version="0.6.2"
|
||||||
application/version="0.6.1"
|
application/version="0.6.2"
|
||||||
application/copyright=""
|
application/copyright=""
|
||||||
display/high_res=true
|
display/high_res=true
|
||||||
codesign/codesign=1
|
codesign/codesign=1
|
||||||
@@ -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.6.1"
|
application/file_version="0.6.2"
|
||||||
application/product_version="0.6.1"
|
application/product_version="0.6.2"
|
||||||
application/company_name=""
|
application/company_name=""
|
||||||
application/product_name="Cinderwild"
|
application/product_name="Cinderwild"
|
||||||
application/file_description="Cinderwild"
|
application/file_description="Cinderwild"
|
||||||
|
|||||||
@@ -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.6.1"
|
config/version="0.6.2"
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ weather_wind={
|
|||||||
|
|
||||||
[cinderwild]
|
[cinderwild]
|
||||||
|
|
||||||
updates/feed="https://git.psbailey.uk/api/v1/repos/Admin/Emberwild/releases/latest"
|
updates/feed="https://git.psbailey.uk/api/v1/repos/Admin/Cinderwild/releases/latest"
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ static func _define() -> Dictionary:
|
|||||||
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(1, 1, 0)],
|
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(1, 1, 0)],
|
||||||
"center": Vector3(0.33, 0.33, 0),
|
"center": Vector3(0.33, 0.33, 0),
|
||||||
}
|
}
|
||||||
|
# Half wall: 2 m wide, 1 m tall (0.6.2, Gitea #7). On a wall's top, under
|
||||||
|
# a gable, it closes the end of a taller roof (a wider house, with a
|
||||||
|
# second row of roof panels); on a floor, it's a low wall or a railing.
|
||||||
|
k["half_wall"] = {
|
||||||
|
"shapes": [_box(Vector3(0, 0.5, 0), Vector3(2, 1, 0.2))],
|
||||||
|
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0),
|
||||||
|
Vector3(-1, 1, 0), Vector3(0, 1, 0), Vector3(1, 1, 0)],
|
||||||
|
"center": Vector3(0, 0.5, 0),
|
||||||
|
}
|
||||||
# --- P2 (docs/PROGRESSION_DESIGN.md §3) ---
|
# --- P2 (docs/PROGRESSION_DESIGN.md §3) ---
|
||||||
# Pillar: a 0.4 m post standing on a floor corner or edge, carrying what's
|
# Pillar: a 0.4 m post standing on a floor corner or edge, carrying what's
|
||||||
# on top (a floor's corner meets its top).
|
# on top (a floor's corner meets its top).
|
||||||
@@ -95,6 +104,29 @@ static func _define() -> Dictionary:
|
|||||||
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0)],
|
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0)],
|
||||||
"center": Vector3(0, 1.5, 0),
|
"center": Vector3(0, 1.5, 0),
|
||||||
}
|
}
|
||||||
|
# Roof ridge (0.6.2, Gitea #7): a 2 m cap, an upside-down V at the roof's
|
||||||
|
# slope, for houses whose width leaves a 2 m gap between the two sides'
|
||||||
|
# top rows (6 m, 10 m). Origin = centre at the level of the rows' top
|
||||||
|
# edges (local z = +-1), rising 0.5 m to the peak along z = 0.
|
||||||
|
var ns := Vector3(0, cos(ROOF_ANGLE), sin(ROOF_ANGLE))
|
||||||
|
var nn := Vector3(0, cos(ROOF_ANGLE), -sin(ROOF_ANGLE))
|
||||||
|
var half := sqrt(1.25)
|
||||||
|
k["roof_ridge"] = {
|
||||||
|
"shapes": [
|
||||||
|
{"type": "box", "center": Vector3(0, 0.25, 0.5) + ns * ROOF_T * 0.5, "size": Vector3(2, ROOF_T, half), "basis": Basis(Vector3.RIGHT, ROOF_ANGLE)},
|
||||||
|
{"type": "box", "center": Vector3(0, 0.25, -0.5) + nn * ROOF_T * 0.5, "size": Vector3(2, ROOF_T, half), "basis": Basis(Vector3.RIGHT, -ROOF_ANGLE)},
|
||||||
|
_box(Vector3(0, 0.55, 0), Vector3(2, 0.08, 0.3))], # (the ridge board: no gap at the peak for rain, or the shelter test)
|
||||||
|
"snaps": [Vector3(-1, 0, 1), Vector3(0, 0, 1), Vector3(1, 0, 1),
|
||||||
|
Vector3(-1, 0, -1), Vector3(0, 0, -1), Vector3(1, 0, -1)],
|
||||||
|
"center": Vector3(0, 0.25, 0),
|
||||||
|
}
|
||||||
|
# Ridge gable: the small triangle (2 m wide, 0.5 m tall, peak in the
|
||||||
|
# middle) that closes the end under a roof ridge, on a half wall or a wall.
|
||||||
|
k["ridge_gable"] = {
|
||||||
|
"shapes": [_prism([Vector2(-1, 0), Vector2(1, 0), Vector2(0, 0.5)], 0.2)],
|
||||||
|
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(0, 0.5, 0)],
|
||||||
|
"center": Vector3(0, 0.17, 0),
|
||||||
|
}
|
||||||
var n := Vector3(0, cos(ROOF_ANGLE), sin(ROOF_ANGLE)) # panel normal (up and out)
|
var n := Vector3(0, cos(ROOF_ANGLE), sin(ROOF_ANGLE)) # panel normal (up and out)
|
||||||
# (Ends exactly at the ridge. The seam where two roofs meet is handled by
|
# (Ends exactly at the ridge. The seam where two roofs meet is handled by
|
||||||
# build_system.roofed_at sampling several rays.)
|
# build_system.roofed_at sampling several rays.)
|
||||||
|
|||||||
@@ -162,6 +162,23 @@ func recompute() -> Array:
|
|||||||
|
|
||||||
# --- placement --------------------------------------------------------------
|
# --- placement --------------------------------------------------------------
|
||||||
|
|
||||||
|
## Roof pieces: these may pass through one another.
|
||||||
|
const ROOF_KINDS := ["roof", "roof_ridge"]
|
||||||
|
|
||||||
|
## A roof piece of `kind` already sitting at `xf` (the same place, facing
|
||||||
|
## the same way), so a second one isn't put inside the first.
|
||||||
|
func _same_roof_at(kind: String, xf: Transform3D, exclude: Node) -> bool:
|
||||||
|
for p in build_pieces():
|
||||||
|
if p == exclude or p.kind != kind:
|
||||||
|
continue
|
||||||
|
var pxf: Transform3D = p.global_transform
|
||||||
|
var facing: float = pxf.basis.z.normalized().dot(xf.basis.z.normalized())
|
||||||
|
if kind == "roof_ridge":
|
||||||
|
facing = absf(facing) # (it's the same both ways round)
|
||||||
|
if pxf.origin.distance_to(xf.origin) < 0.15 and facing > 0.99:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
## Why a building piece can't go at `xf` ("" = it can).
|
## Why a building piece can't go at `xf` ("" = it can).
|
||||||
func check(def: Dictionary, xf: Transform3D, exclude: Node = null) -> String:
|
func check(def: Dictionary, xf: Transform3D, exclude: Node = null) -> String:
|
||||||
var kind: String = def.place.kind
|
var kind: String = def.place.kind
|
||||||
@@ -172,6 +189,9 @@ func check(def: Dictionary, xf: Transform3D, exclude: Node = null) -> String:
|
|||||||
if c.y < terrain.water_level + 0.1:
|
if c.y < terrain.water_level + 0.1:
|
||||||
return "Can't build in water"
|
return "Can't build in water"
|
||||||
var space := world.get_world_3d().direct_space_state
|
var space := world.get_world_3d().direct_space_state
|
||||||
|
var roofy: bool = kind in ROOF_KINDS
|
||||||
|
if roofy and _same_roof_at(kind, xf, exclude):
|
||||||
|
return "There's already a roof there"
|
||||||
for s in info.shapes:
|
for s in info.shapes:
|
||||||
var made := BuildPiece.make_shape(s, OVERLAP_MARGIN)
|
var made := BuildPiece.make_shape(s, OVERLAP_MARGIN)
|
||||||
var q := PhysicsShapeQueryParameters3D.new()
|
var q := PhysicsShapeQueryParameters3D.new()
|
||||||
@@ -182,9 +202,13 @@ func check(def: Dictionary, xf: Transform3D, exclude: Node = null) -> String:
|
|||||||
if exclude != null:
|
if exclude != null:
|
||||||
ex.append(exclude.get_rid())
|
ex.append(exclude.get_rid())
|
||||||
q.exclude = ex
|
q.exclude = ex
|
||||||
var hits := space.intersect_shape(q, 1)
|
var hits := space.intersect_shape(q, 8)
|
||||||
if not hits.is_empty():
|
for h in hits:
|
||||||
last_blocker = hits[0].collider
|
# Roofs can run through other roofs (Gitea #7: an L, a wing at an
|
||||||
|
# angle, roofs at odd heights), but nothing else.
|
||||||
|
if roofy and h.collider != null and String(h.collider.get("kind")) in ROOF_KINDS:
|
||||||
|
continue
|
||||||
|
last_blocker = h.collider
|
||||||
return "Something's in the way"
|
return "Something's in the way"
|
||||||
var mat := BuildPiece.material_of(def)
|
var mat := BuildPiece.material_of(def)
|
||||||
if support_for(kind, xf, exclude, mat) < MIN_SUPPORT:
|
if support_for(kind, xf, exclude, mat) < MIN_SUPPORT:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ extends RefCounted
|
|||||||
## crash or an error: the pause menu shows where it is and opens its folder,
|
## crash or an error: the pause menu shows where it is and opens its folder,
|
||||||
## to attach the file to the issue.
|
## to attach the file to the issue.
|
||||||
|
|
||||||
const REPO := "https://git.psbailey.uk/Admin/Emberwild"
|
const REPO := "https://git.psbailey.uk/Admin/Cinderwild"
|
||||||
|
|
||||||
## An autoload (Net, Updates), looked up at run time: this script is also
|
## An autoload (Net, Updates), looked up at run time: this script is also
|
||||||
## loaded by tests that start before the autoloads are compiled in.
|
## loaded by tests that start before the autoloads are compiled in.
|
||||||
|
|||||||
@@ -324,12 +324,34 @@ func _process(delta: float) -> void:
|
|||||||
if d > 0.01 and d < 5.0: # (not a teleport or a respawn)
|
if d > 0.01 and d < 5.0: # (not a teleport or a respawn)
|
||||||
_count("walk", d)
|
_count("walk", d)
|
||||||
_last_pos = p
|
_last_pos = p
|
||||||
|
hold_daylight()
|
||||||
_marker_t -= delta
|
_marker_t -= delta
|
||||||
if _marker_t <= 0.0:
|
if _marker_t <= 0.0:
|
||||||
_marker_t = 0.5
|
_marker_t = 0.5
|
||||||
_marker = find_marker()
|
_marker = find_marker()
|
||||||
poll()
|
poll()
|
||||||
|
|
||||||
|
## The sun waits for you (Gitea #5): until a step that sets the clock comes
|
||||||
|
## up (the torch step, at dusk), the afternoon goes no later than
|
||||||
|
## DAYLIGHT_UNTIL, however long the earlier steps take. Then dusk comes on
|
||||||
|
## when the tutorial means it to.
|
||||||
|
const DAYLIGHT_UNTIL := 16.5
|
||||||
|
func hold_daylight() -> void:
|
||||||
|
if not active or world == null or not is_instance_valid(world) or world.day_night == null:
|
||||||
|
return
|
||||||
|
if _clock_step_reached():
|
||||||
|
return
|
||||||
|
var latest := DAYLIGHT_UNTIL / 24.0
|
||||||
|
if world.day_night.time > latest:
|
||||||
|
world.day_night.time = latest
|
||||||
|
|
||||||
|
## Whether the current step, or one before it, moves the clock on.
|
||||||
|
func _clock_step_reached() -> bool:
|
||||||
|
for i in range(mini(index + 1, steps.size())):
|
||||||
|
if Dictionary(steps[i].get("setup", {})).has("time"):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
## Goals that are states, not events: checked twice a second.
|
## Goals that are states, not events: checked twice a second.
|
||||||
func poll() -> void:
|
func poll() -> void:
|
||||||
if not active or world == null or not is_instance_valid(world):
|
if not active or world == null or not is_instance_valid(world):
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ extends Node
|
|||||||
const K := "res://assets/audio/sfx/kenney/"
|
const K := "res://assets/audio/sfx/kenney/"
|
||||||
const S := "res://assets/audio/sfx/synth/"
|
const S := "res://assets/audio/sfx/synth/"
|
||||||
const C := "res://assets/audio/sfx/creatures/"
|
const C := "res://assets/audio/sfx/creatures/"
|
||||||
|
const T := "res://assets/audio/sfx/thunder/"
|
||||||
const EARSHOT := 45.0 ## a server sends a sound to players this close
|
const EARSHOT := 45.0 ## a server sends a sound to players this close
|
||||||
|
|
||||||
## id -> files (or a [prefix, count] pair), volume (dB), pitch range, reach (m),
|
## id -> files (or a [prefix, count] pair), volume (dB), pitch range, reach (m),
|
||||||
@@ -28,6 +29,10 @@ const SOUNDS := {
|
|||||||
"tree_fall": {"set": [K + "wood_heavy_%d.ogg", 5], "db": 0.0, "pitch": [0.6, 0.7], "reach": 60.0, "with": "creak"},
|
"tree_fall": {"set": [K + "wood_heavy_%d.ogg", 5], "db": 0.0, "pitch": [0.6, 0.7], "reach": 60.0, "with": "creak"},
|
||||||
"creak": {"set": [K + "creak_%d.ogg", 3], "db": -2.0, "pitch": [0.8, 0.95], "reach": 50.0},
|
"creak": {"set": [K + "creak_%d.ogg", 3], "db": -2.0, "pitch": [0.8, 0.95], "reach": 50.0},
|
||||||
"mine": {"set": [K + "mine_%d.ogg", 5], "db": -1.0, "pitch": [0.9, 1.1]},
|
"mine": {"set": [K + "mine_%d.ogg", 5], "db": -1.0, "pitch": [0.9, 1.1]},
|
||||||
|
# Thunder (0.6.2, CC0 recordings: assets/audio/CREDITS.md). The weather
|
||||||
|
# plays the storm's own (weather_fx.gd); a strike on a tree cracks from it.
|
||||||
|
"thunder_near": {"set": [T + "thunder_near_%d.ogg", 3], "db": 0.0, "pitch": [0.92, 1.05], "reach": 400.0},
|
||||||
|
"thunder_far": {"set": [T + "thunder_far_%d.ogg", 3], "db": -5.0, "pitch": [0.9, 1.05], "reach": 400.0},
|
||||||
"rock_break": {"set": [K + "mine_%d.ogg", 5], "db": 1.0, "pitch": [0.6, 0.7], "reach": 50.0},
|
"rock_break": {"set": [K + "mine_%d.ogg", 5], "db": 1.0, "pitch": [0.6, 0.7], "reach": 50.0},
|
||||||
"rustle": {"set": [K + "cloth_%d.ogg", 4], "db": -4.0, "pitch": [0.7, 0.9]},
|
"rustle": {"set": [K + "cloth_%d.ogg", 4], "db": -4.0, "pitch": [0.7, 0.9]},
|
||||||
"pickup": {"set": [K + "leather_%d.ogg", 3], "db": -6.0, "pitch": [0.95, 1.15], "reach": 15.0},
|
"pickup": {"set": [K + "leather_%d.ogg", 3], "db": -6.0, "pitch": [0.95, 1.15], "reach": 15.0},
|
||||||
@@ -196,6 +201,17 @@ func me(id: String) -> void:
|
|||||||
_log(id, null)
|
_log(id, null)
|
||||||
_play2(id)
|
_play2(id)
|
||||||
|
|
||||||
|
## A random one of sound `id`'s files, and its volume: for a player of your
|
||||||
|
## own (the weather's thunder). Not logged: call log_heard() when it plays.
|
||||||
|
func stream_for(id: String) -> AudioStream:
|
||||||
|
return _pick(id) if SOUNDS.has(id) else null
|
||||||
|
|
||||||
|
func db_for(id: String) -> float:
|
||||||
|
return float(SOUNDS.get(id, {}).get("db", 0.0))
|
||||||
|
|
||||||
|
func log_heard(id: String) -> void:
|
||||||
|
_log(id, null)
|
||||||
|
|
||||||
## Menus and the HUD.
|
## Menus and the HUD.
|
||||||
func ui(id: String) -> void:
|
func ui(id: String) -> void:
|
||||||
_log(id, null)
|
_log(id, null)
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ const KINDS := {
|
|||||||
"drops": {"raw_meat": [5, 7], "bear_hide": [1, 1], "bear_fat": [2, 3]}},
|
"drops": {"raw_meat": [5, 7], "bear_hide": [1, 1], "bear_fat": [2, 3]}},
|
||||||
}
|
}
|
||||||
const CARCASS_SECONDS := 300.0
|
const CARCASS_SECONDS := 300.0
|
||||||
|
## The steepest ground an animal walks up: 35 degrees (the ground's normal
|
||||||
|
## at least this upright). The player's limit is about 45.
|
||||||
|
const MAX_CLIMB_NY := 0.82
|
||||||
|
const MAX_CLIMB_RISE := 0.7 ## (tan 35 degrees: metres up per metre along)
|
||||||
|
## A carcass on ground steeper than this slides down to where it's flatter,
|
||||||
|
## so it can be reached and butchered (Gitea #6).
|
||||||
|
const CARCASS_REST_NY := 0.8
|
||||||
|
|
||||||
var kind := "deer"
|
var kind := "deer"
|
||||||
var def: Dictionary
|
var def: Dictionary
|
||||||
@@ -384,6 +391,18 @@ func _blocked(dir: Vector3) -> bool:
|
|||||||
return true
|
return true
|
||||||
if absf(h - global_position.y) > 1.1:
|
if absf(h - global_position.y) > 1.1:
|
||||||
return true
|
return true
|
||||||
|
# Nothing climbs steep ground, or walks along it (the mountain rim: Gitea
|
||||||
|
# #6). Going down it is fine, so one that's somehow up there can always
|
||||||
|
# come back.
|
||||||
|
if h > global_position.y - 0.3:
|
||||||
|
var run := Vector2(probe.x - global_position.x, probe.z - global_position.z).length()
|
||||||
|
if (h - global_position.y) > run * MAX_CLIMB_RISE:
|
||||||
|
return true
|
||||||
|
# The slope along the way: just ahead, halfway, and at the probe.
|
||||||
|
for f in [0.2, 0.5, 1.0]:
|
||||||
|
var s: Vector3 = global_position.lerp(probe, f)
|
||||||
|
if terrain.sample_normal(s.x, s.z).y < MAX_CLIMB_NY and terrain.sample_height(s.x, s.z) > global_position.y - 0.3 * f:
|
||||||
|
return true
|
||||||
# Trees, rocks and buildings at chest height (the terrain isn't on this test).
|
# Trees, rocks and buildings at chest height (the terrain isn't on this test).
|
||||||
var from := global_position + Vector3(0, 0.45, 0)
|
var from := global_position + Vector3(0, 0.45, 0)
|
||||||
var q := PhysicsRayQueryParameters3D.create(from, Vector3(probe.x, h + 0.45, probe.z), 1)
|
var q := PhysicsRayQueryParameters3D.create(from, Vector3(probe.x, h + 0.45, probe.z), 1)
|
||||||
@@ -468,6 +487,7 @@ func _die(from: Vector3) -> void:
|
|||||||
if puppet:
|
if puppet:
|
||||||
_visual.rotation.z = -PI * 0.5
|
_visual.rotation.z = -PI * 0.5
|
||||||
return
|
return
|
||||||
|
_settle()
|
||||||
# Topple onto its side, away from the blow.
|
# Topple onto its side, away from the blow.
|
||||||
var side := 1.0 if (global_position - from).dot(global_basis.x) > 0.0 else -1.0
|
var side := 1.0 if (global_position - from).dot(global_basis.x) > 0.0 else -1.0
|
||||||
if is_inside_tree() and DisplayServer.get_name() != "headless":
|
if is_inside_tree() and DisplayServer.get_name() != "headless":
|
||||||
@@ -477,6 +497,28 @@ func _die(from: Vector3) -> void:
|
|||||||
_visual.rotation.z = -side * PI * 0.5
|
_visual.rotation.z = -side * PI * 0.5
|
||||||
died.emit(self)
|
died.emit(self)
|
||||||
|
|
||||||
|
## A carcass on steep ground (half into a cliff face, out of reach) slides
|
||||||
|
## downhill, half a metre at a time, until the ground's flatter: up to 20 m,
|
||||||
|
## and never out past the edge of the play area. Online the server does this
|
||||||
|
## and players see it where it ends up.
|
||||||
|
func _settle() -> void:
|
||||||
|
if terrain == null:
|
||||||
|
return
|
||||||
|
var p := global_position
|
||||||
|
for _i in range(40):
|
||||||
|
var n: Vector3 = terrain.sample_normal(p.x, p.z)
|
||||||
|
if n.y >= CARCASS_REST_NY:
|
||||||
|
break
|
||||||
|
var down := Vector3(n.x, 0.0, n.z) # (a slope's normal leans downhill)
|
||||||
|
if down.length() < 0.001:
|
||||||
|
break
|
||||||
|
var q := p + down.normalized() * 0.5
|
||||||
|
if not terrain.inside_boundary(q.x, q.z, 1.0):
|
||||||
|
break
|
||||||
|
p = Vector3(q.x, terrain.sample_height(q.x, q.z), q.z)
|
||||||
|
if p.distance_to(global_position) > 0.01:
|
||||||
|
global_position = p
|
||||||
|
|
||||||
## What butchering gives (rolled once).
|
## What butchering gives (rolled once).
|
||||||
func butcher_drops() -> Dictionary:
|
func butcher_drops() -> Dictionary:
|
||||||
var out := {}
|
var out := {}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ static func build(key: String) -> ArrayMesh:
|
|||||||
"wood_door_panel": m = _door_panel()
|
"wood_door_panel": m = _door_panel()
|
||||||
"wood_roof": m = _roof()
|
"wood_roof": m = _roof()
|
||||||
"wood_gable": m = _gable()
|
"wood_gable": m = _gable()
|
||||||
|
"wood_half_wall": m = _half_wall()
|
||||||
|
"wood_roof_ridge": m = _roof_ridge()
|
||||||
|
"wood_ridge_gable": m = _ridge_gable()
|
||||||
"bed": m = _bed()
|
"bed": m = _bed()
|
||||||
"raw_meat": m = _meat(MEAT_RAW)
|
"raw_meat": m = _meat(MEAT_RAW)
|
||||||
"cooked_meat": m = _meat(MEAT_COOKED)
|
"cooked_meat": m = _meat(MEAT_COOKED)
|
||||||
@@ -546,6 +549,17 @@ static func _wall() -> ArrayMesh:
|
|||||||
_box(st, Vector3(0, y, 0), Vector3(1.68, 0.12, 0.2), BEAM)
|
_box(st, Vector3(0, y, 0), Vector3(1.68, 0.12, 0.2), BEAM)
|
||||||
return P._commit(st)
|
return P._commit(st)
|
||||||
|
|
||||||
|
## Half wall: the wall's boards and frame, 2 x 1 m.
|
||||||
|
static func _half_wall() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(75)
|
||||||
|
_wall_body(st, r, -0.84, 0.84, 0.1, 0.9)
|
||||||
|
for x in [-0.92, 0.92]:
|
||||||
|
_box(st, Vector3(x, 0.5, 0), Vector3(0.16, 1.0, 0.2), BEAM)
|
||||||
|
for y in [0.06, 0.94]:
|
||||||
|
_box(st, Vector3(0, y, 0), Vector3(1.68, 0.12, 0.2), BEAM)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
## Door frame (1.1 x 1.9 m opening); `with_panel` adds the closed door, for the
|
## Door frame (1.1 x 1.9 m opening); `with_panel` adds the closed door, for the
|
||||||
## inventory icon and the placement ghost.
|
## inventory icon and the placement ghost.
|
||||||
static func _door(with_panel: bool) -> ArrayMesh:
|
static func _door(with_panel: bool) -> ArrayMesh:
|
||||||
@@ -597,6 +611,37 @@ static func _roof() -> ArrayMesh:
|
|||||||
_box(st, along * s + n * 0.085, Vector3(2.04, 0.05, (length + 0.3) / rows + 0.1), _board(r, SHINGLE), tilt)
|
_box(st, along * s + n * 0.085, Vector3(2.04, 0.05, (length + 0.3) / rows + 0.1), _board(r, SHINGLE), tilt)
|
||||||
return P._commit(st)
|
return P._commit(st)
|
||||||
|
|
||||||
|
## Roof ridge: two short shingled panels meeting at a peak 0.5 m up (an
|
||||||
|
## upside-down V across local Z), origin at the centre of the low edges'
|
||||||
|
## level. Bridges the gap between two roof rows on a 6 m-wide house.
|
||||||
|
static func _roof_ridge() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(91)
|
||||||
|
var a := 0.4636476
|
||||||
|
var half := sqrt(1.25)
|
||||||
|
for side in [1.0, -1.0]:
|
||||||
|
var b := Basis(Vector3.RIGHT, a * side)
|
||||||
|
var n := Vector3(0, cos(a), sin(a) * side)
|
||||||
|
var mid := Vector3(0, 0.25, 0.5 * side)
|
||||||
|
_box(st, mid + n * 0.03, Vector3(2.0, 0.06, half + 0.06), _board(r, BEAM), b)
|
||||||
|
for i in range(2):
|
||||||
|
var along := Vector3(0, -sin(a), cos(a) * side) * (half * (0.25 - 0.5 * i))
|
||||||
|
_box(st, mid + along + n * 0.085, Vector3(2.04, 0.05, half * 0.5 + 0.1), _board(r, SHINGLE), Basis(Vector3.RIGHT, (a + 0.06) * side))
|
||||||
|
_box(st, Vector3(0, 0.55, 0), Vector3(2.06, 0.08, 0.16), BEAM) # (the ridge board)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## Ridge gable: a small triangle, 2 m wide and 0.5 m tall, peak in the middle.
|
||||||
|
static func _ridge_gable() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(98)
|
||||||
|
for i in range(2):
|
||||||
|
var y0 := 0.25 * i
|
||||||
|
var hw := 0.92 * (1.0 - y0 / 0.5) - 0.06
|
||||||
|
_box(st, Vector3(0, y0 + 0.125, 0), Vector3(hw * 2.0, 0.25 - 0.015, 0.12), _board(r))
|
||||||
|
_box(st, Vector3(0, 0.06, 0), Vector3(2.0, 0.12, 0.2), BEAM)
|
||||||
|
_box(st, Vector3(0, 0.25, 0), Vector3(0.14, 0.5, 0.2), BEAM)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
## Triangular gable end: 0 m tall at -X, 1 m tall at +X.
|
## Triangular gable end: 0 m tall at -X, 1 m tall at +X.
|
||||||
static func _gable() -> ArrayMesh:
|
static func _gable() -> ArrayMesh:
|
||||||
var st := P._begin()
|
var st := P._begin()
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
|
|||||||
const Identity = preload("res://scripts/net/identity.gd")
|
const Identity = preload("res://scripts/net/identity.gd")
|
||||||
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
||||||
|
|
||||||
const PROTOCOL := 18 ## 18: the Cove's own (the wreck and treasure, the drowned crew, crabs and gulls, boats at the pier); 17: lands on a server (crossings, the tunnel online, the roster); 16: worldlets (the Cove), sea fish (mackerel, cod); 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand)
|
const PROTOCOL := 19 ## 19: bigger houses (half wall, roof ridge, ridge gable; roofs through roofs); 18: the Cove's own (the wreck and treasure, the drowned crew, crabs and gulls, boats at the pier); 17: lands on a server (crossings, the tunnel online, the roster); 16: worldlets (the Cove), sea fish (mackerel, cod); 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand)
|
||||||
const DEFAULT_PORT := 24242
|
const DEFAULT_PORT := 24242
|
||||||
const HANDSHAKE_TIMEOUT := 10.0
|
const HANDSHAKE_TIMEOUT := 10.0
|
||||||
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
|
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
|
||||||
@@ -673,7 +673,7 @@ func _wall_hit(space: PhysicsDirectSpaceState3D, a: Vector3, b: Vector3) -> bool
|
|||||||
var piece: Node = c as Node
|
var piece: Node = c as Node
|
||||||
while piece != null and piece.get("kind") == null:
|
while piece != null and piece.get("kind") == null:
|
||||||
piece = piece.get_parent()
|
piece = piece.get_parent()
|
||||||
if piece != null and String(piece.kind) in ["wall", "door", "gable", "window", "palisade", "pillar"]:
|
if piece != null and String(piece.kind) in ["wall", "half_wall", "door", "gable", "ridge_gable", "window", "palisade", "pillar"]:
|
||||||
return true
|
return true
|
||||||
ex.append(hit.rid) # a floor or roof edge: look past it
|
ex.append(hit.rid) # a floor or roof edge: look past it
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ func pick_tree() -> Node3D:
|
|||||||
func strike(n: Node3D) -> void:
|
func strike(n: Node3D) -> void:
|
||||||
strikes += 1
|
strikes += 1
|
||||||
show_strike(n)
|
show_strike(n)
|
||||||
|
Sfx.at("thunder_near", n.global_position + Vector3(0, 6, 0)) # (a crack from the tree itself)
|
||||||
if world.server_mode:
|
if world.server_mode:
|
||||||
WorldNet.struck(n)
|
WorldNet.struck(n)
|
||||||
for p in world.players():
|
for p in world.players():
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ func clear_under(piece: Node3D) -> void:
|
|||||||
if not is_instance_valid(piece) or not is_inside_tree():
|
if not is_instance_valid(piece) or not is_inside_tree():
|
||||||
return
|
return
|
||||||
await get_tree().physics_frame # (the piece's collider is in the world from the next step)
|
await get_tree().physics_frame # (the piece's collider is in the world from the next step)
|
||||||
if not is_instance_valid(piece):
|
if not is_instance_valid(piece) or not piece.is_inside_tree():
|
||||||
return
|
return # (dismantled straight away)
|
||||||
var space := get_world_3d().direct_space_state
|
var space := get_world_3d().direct_space_state
|
||||||
var at := piece.global_position
|
var at := piece.global_position
|
||||||
for pk in pickups.values():
|
for pk in pickups.values():
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ extends Node3D
|
|||||||
## - Ground wetness, settled snow and wind strength are global shader uniforms
|
## - Ground wetness, settled snow and wind strength are global shader uniforms
|
||||||
## (weather_wet, weather_snow, weather_wind) read by the terrain, grass,
|
## (weather_wet, weather_snow, weather_wind) read by the terrain, grass,
|
||||||
## foliage and rock shaders.
|
## foliage and rock shaders.
|
||||||
## - Sound is synthesised: filtered noise for rain and wind, a decaying
|
## - Rain and wind are synthesised (filtered noise). Thunder is recorded
|
||||||
## low rumble for thunder. No audio assets, nothing to license.
|
## (CC0, 0.6.2: assets/audio/sfx/thunder, Sfx "thunder_far"/"thunder_near"):
|
||||||
|
## after each flash in a storm, usually a distant roll a few seconds later,
|
||||||
|
## sometimes a nearer crack sooner. Quieter under a roof. (A strike on a
|
||||||
|
## tree near you cracks from the tree itself: hazards.gd.)
|
||||||
##
|
##
|
||||||
## Client-side only: in M2 the server runs weather.gd and never this.
|
## Client-side only: in M2 the server runs weather.gd and never this.
|
||||||
|
|
||||||
@@ -31,10 +34,11 @@ var _rng := RandomNumberGenerator.new()
|
|||||||
var _rain_lp := 0.0
|
var _rain_lp := 0.0
|
||||||
var _rain_hp := 0.0
|
var _rain_hp := 0.0
|
||||||
var _brown := 0.0
|
var _brown := 0.0
|
||||||
var _brown2 := 0.0
|
|
||||||
var _gust_t := 0.0
|
var _gust_t := 0.0
|
||||||
var _thunder_env := 0.0
|
|
||||||
var _thunder_delay := -1.0
|
var _thunder_delay := -1.0
|
||||||
|
var _thunder_id := ""
|
||||||
|
var _thunder: AudioStreamPlayer
|
||||||
|
const NEAR_CHANCE := 0.3 ## of a storm's flashes, the share that are close
|
||||||
var _seen_strikes := 0
|
var _seen_strikes := 0
|
||||||
var _rain_amp := 0.0
|
var _rain_amp := 0.0
|
||||||
var _wind_amp := 0.0
|
var _wind_amp := 0.0
|
||||||
@@ -83,6 +87,10 @@ func setup(w: Node, p: Node3D) -> void:
|
|||||||
add_child(_audio)
|
add_child(_audio)
|
||||||
_audio.play()
|
_audio.play()
|
||||||
_playback = _audio.get_stream_playback()
|
_playback = _audio.get_stream_playback()
|
||||||
|
_thunder = AudioStreamPlayer.new()
|
||||||
|
_thunder.name = "Thunder"
|
||||||
|
_thunder.bus = "SFX" if AudioServer.get_bus_index("SFX") >= 0 else "Master"
|
||||||
|
add_child(_thunder)
|
||||||
|
|
||||||
func _make_particles(amount: int, life: float, pm: ParticleProcessMaterial, mesh: Mesh) -> GPUParticles3D:
|
func _make_particles(amount: int, life: float, pm: ParticleProcessMaterial, mesh: Mesh) -> GPUParticles3D:
|
||||||
var p := GPUParticles3D.new()
|
var p := GPUParticles3D.new()
|
||||||
@@ -173,13 +181,32 @@ func _process(delta: float) -> void:
|
|||||||
_wind_amp = weather.wind * weather.wind * 0.22 * (0.35 if sheltered else 1.0)
|
_wind_amp = weather.wind * weather.wind * 0.22 * (0.35 if sheltered else 1.0)
|
||||||
if weather.lightning_count != _seen_strikes:
|
if weather.lightning_count != _seen_strikes:
|
||||||
_seen_strikes = weather.lightning_count
|
_seen_strikes = weather.lightning_count
|
||||||
_thunder_delay = _rng.randf_range(0.5, 2.5)
|
lightning()
|
||||||
if _thunder_delay >= 0.0:
|
if _thunder_delay >= 0.0:
|
||||||
_thunder_delay -= delta
|
_thunder_delay -= delta
|
||||||
if _thunder_delay < 0.0:
|
if _thunder_delay < 0.0:
|
||||||
_thunder_env = 1.0
|
_thunder_now()
|
||||||
_fill_audio()
|
_fill_audio()
|
||||||
|
|
||||||
|
## A flash in the sky: its thunder follows, after the time the sound takes
|
||||||
|
## to come (a near one sooner, a distant one later).
|
||||||
|
func lightning() -> void:
|
||||||
|
var near := _rng.randf() < NEAR_CHANCE
|
||||||
|
_thunder_id = "thunder_near" if near else "thunder_far"
|
||||||
|
_thunder_delay = _rng.randf_range(0.3, 0.9) if near else _rng.randf_range(1.5, 4.0)
|
||||||
|
|
||||||
|
func _thunder_now() -> void:
|
||||||
|
Sfx.log_heard(_thunder_id)
|
||||||
|
if _thunder == null:
|
||||||
|
return
|
||||||
|
var st: AudioStream = Sfx.stream_for(_thunder_id)
|
||||||
|
if st == null:
|
||||||
|
return
|
||||||
|
_thunder.stream = st
|
||||||
|
_thunder.volume_db = Sfx.db_for(_thunder_id) - 8.0 * _muffle
|
||||||
|
_thunder.pitch_scale = _rng.randf_range(0.9, 1.05)
|
||||||
|
_thunder.play()
|
||||||
|
|
||||||
func _set_amount(p: GPUParticles3D, a: float) -> void:
|
func _set_amount(p: GPUParticles3D, a: float) -> void:
|
||||||
p.amount_ratio = a
|
p.amount_ratio = a
|
||||||
p.emitting = a > 0.0
|
p.emitting = a > 0.0
|
||||||
@@ -194,7 +221,6 @@ func _fill_audio() -> void:
|
|||||||
var rain_cut := lerpf(0.55, 0.12, _muffle)
|
var rain_cut := lerpf(0.55, 0.12, _muffle)
|
||||||
var buf := PackedVector2Array()
|
var buf := PackedVector2Array()
|
||||||
buf.resize(n)
|
buf.resize(n)
|
||||||
var thunder_decay := exp(-1.0 / (MIX_RATE * 0.9))
|
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
var white := _rng.randf() * 2.0 - 1.0
|
var white := _rng.randf() * 2.0 - 1.0
|
||||||
# Rain: band-limited hiss (low-pass, minus a slower low-pass).
|
# Rain: band-limited hiss (low-pass, minus a slower low-pass).
|
||||||
@@ -206,12 +232,6 @@ func _fill_audio() -> void:
|
|||||||
_gust_t += 1.0 / MIX_RATE
|
_gust_t += 1.0 / MIX_RATE
|
||||||
var gust := 0.6 + 0.4 * sin(_gust_t * 0.7) * sin(_gust_t * 0.23 + 1.0)
|
var gust := 0.6 + 0.4 * sin(_gust_t * 0.7) * sin(_gust_t * 0.23 + 1.0)
|
||||||
var wind_s := _brown * _wind_amp * gust * 2.0
|
var wind_s := _brown * _wind_amp * gust * 2.0
|
||||||
# Thunder: a heavier brown rumble with a long decay.
|
var s := clampf(rain + wind_s, -1.0, 1.0)
|
||||||
var thunder := 0.0
|
|
||||||
if _thunder_env > 0.001:
|
|
||||||
_brown2 = clampf(_brown2 * 0.9995 + white * 0.02, -1.0, 1.0)
|
|
||||||
thunder = _brown2 * _thunder_env * 2.2
|
|
||||||
_thunder_env *= thunder_decay
|
|
||||||
var s := clampf(rain + wind_s + thunder, -1.0, 1.0)
|
|
||||||
buf[i] = Vector2(s, s)
|
buf[i] = Vector2(s, s)
|
||||||
_playback.push_buffer(buf)
|
_playback.push_buffer(buf)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
|
|||||||
echo "Importing the project first (fresh copy)..."
|
echo "Importing the project first (fresh copy)..."
|
||||||
godot --headless --path . --import >/dev/null 2>&1
|
godot --headless --path . --import >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
for t in test_boot test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_net_lands test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_wreck test_shore test_boat test_bug_report test_tunnel test_map test_terrain_edges test_boundary; do
|
for t in test_boot test_survival_core test_survival_stats test_save_load test_weather_building test_roofs test_creatures_combat test_cliffs test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_net_lands test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_wreck test_shore test_boat test_bug_report test_tunnel test_map test_terrain_edges test_boundary; do
|
||||||
extra=""
|
extra=""
|
||||||
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
||||||
# A suite that waits on a world that failed to load would hang forever,
|
# A suite that waits on a world that failed to load would hang forever,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func _body() -> void:
|
|||||||
|
|
||||||
print("the link")
|
print("the link")
|
||||||
var url: String = BugReport.issue_url(w)
|
var url: String = BugReport.issue_url(w)
|
||||||
check(url.begins_with("https://git.psbailey.uk/Admin/Emberwild/issues/new?title=Bug%3A%20&body="), "a new issue on the Cinderwild repo")
|
check(url.begins_with("https://git.psbailey.uk/Admin/Cinderwild/issues/new?title=Bug%3A%20&body="), "a new issue on the Cinderwild repo")
|
||||||
check(url.length() < 4000, "short enough for any browser (%d characters)" % url.length())
|
check(url.length() < 4000, "short enough for any browser (%d characters)" % url.length())
|
||||||
var body := url.get_slice("&body=", 1).uri_decode()
|
var body := url.get_slice("&body=", 1).uri_decode()
|
||||||
var ver: String = String(ProjectSettings.get_setting("application/config/version"))
|
var ver: String = String(ProjectSettings.get_setting("application/config/version"))
|
||||||
|
|||||||
129
tests/test_cliffs.gd
Normal file
129
tests/test_cliffs.gd
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Gitea #6: animals stay off the cliffs at the edge of the world. A deer
|
||||||
|
## chased outward toward the mountain rim, all the way round, never climbs
|
||||||
|
## steep ground; and one killed on a steep face comes to rest where it's
|
||||||
|
## flatter, so you can reach it and butcher it.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_cliffs.gd
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var S: Node
|
||||||
|
var A: Node
|
||||||
|
|
||||||
|
func check(cond: bool, what: String) -> void:
|
||||||
|
print((" ok " if cond else " FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
_fails += 1
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
S = root.get_node("Saves")
|
||||||
|
A = root.get_node("Actions")
|
||||||
|
S.root_dir = "user://test_cliffs_worlds"
|
||||||
|
for x in S.list_worlds():
|
||||||
|
S.delete_world(x.id)
|
||||||
|
S.current_id = S.create_world("Valley", 1337)
|
||||||
|
var w: Node3D = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(w)
|
||||||
|
for _i in range(8):
|
||||||
|
await physics_frame
|
||||||
|
w.creatures.enabled = false
|
||||||
|
for c in w.creatures.get_children():
|
||||||
|
c.queue_free()
|
||||||
|
if w.wolves != null:
|
||||||
|
w.wolves.enabled = false
|
||||||
|
w.player.set_physics_process(false)
|
||||||
|
w.player.survival.set_process(false)
|
||||||
|
await _body(w)
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
for x in S.list_worlds():
|
||||||
|
S.delete_world(x.id)
|
||||||
|
DirAccess.remove_absolute(S.root_dir)
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
func _body(w: Node3D) -> void:
|
||||||
|
var t: Node3D = w.terrain
|
||||||
|
var p: Node3D = w.player
|
||||||
|
print("chased toward the mountains")
|
||||||
|
# How far into the mountain rim each deer gets (the terrain's own rim
|
||||||
|
# distance), and the steepest ground it walks UP onto.
|
||||||
|
# (Running down a steep bank is allowed: that's how one gets back down.)
|
||||||
|
var steepest := 0.0
|
||||||
|
var highest := 0.0
|
||||||
|
var climb := 0.0
|
||||||
|
var where := ""
|
||||||
|
for i in range(36):
|
||||||
|
var a := TAU * i / 36.0
|
||||||
|
var dir := Vector3(cos(a), 0, sin(a))
|
||||||
|
# (12 m inside the rim, which wobbles in and out by up to 16 m.)
|
||||||
|
var start: Vector3 = dir * (t.play_radius - 12.0 - t._rim_offset(dir.x, dir.z))
|
||||||
|
var d: Node3D = w.creatures.spawn("deer", start)
|
||||||
|
d.set_physics_process(false)
|
||||||
|
for _s in range(600): # (20 s at a run, with you right behind it)
|
||||||
|
p.global_position = d.global_position - dir * 8.0
|
||||||
|
d.player = p
|
||||||
|
d._threat = p.global_position
|
||||||
|
d.state = "flee"
|
||||||
|
d._state_t = 4.0
|
||||||
|
var y0: float = d.global_position.y
|
||||||
|
d.step(1.0 / 30.0)
|
||||||
|
var g: Vector3 = d.global_position
|
||||||
|
if g.y > y0 + 0.0005:
|
||||||
|
var deg := rad_to_deg(acos(clampf(t.sample_normal(g.x, g.z).y, -1.0, 1.0)))
|
||||||
|
if deg > steepest:
|
||||||
|
steepest = deg
|
||||||
|
where = "%d degrees round" % (i * 10)
|
||||||
|
highest = maxf(highest, t._rim_d(g.x, g.z) - t.play_radius)
|
||||||
|
climb = maxf(climb, g.y - start.y - t.sample_height(start.x, start.z) + start.y)
|
||||||
|
d.free()
|
||||||
|
if i % 6 == 0:
|
||||||
|
await process_frame
|
||||||
|
# (Its limit is 35 degrees, checked at a few points ahead, so a few
|
||||||
|
# degrees more can slip through between them; the player's limit is 45:
|
||||||
|
# anywhere a deer goes, you can follow. Before the fix: 71 degrees.)
|
||||||
|
check(steepest < 45.0, "a deer chased outward never walks up ground steeper than you can (steepest %.0f, at %s)" % [steepest, where])
|
||||||
|
# (The rim rises 58 m over 30 m; its first few metres are still gentle.
|
||||||
|
# Before the fix: 8 m in and up onto the cliff face.)
|
||||||
|
check(highest < 6.0 and climb < 6.0, "and stays at the foot of the mountain rim (%.1f m in, %.1f m above where it started)" % [highest, climb])
|
||||||
|
|
||||||
|
print("killed on a cliff")
|
||||||
|
# A steep spot on the rim, inside the play area: where a deer used to end up.
|
||||||
|
var spot := Vector3.INF
|
||||||
|
for i in range(72):
|
||||||
|
var a := TAU * i / 72.0
|
||||||
|
for r in range(int(t.play_radius) - 5, int(t.play_radius) + 15):
|
||||||
|
var x := cos(a) * r
|
||||||
|
var z := sin(a) * r
|
||||||
|
if t.inside_boundary(x, z, 3.0) and t.sample_normal(x, z).y < 0.55:
|
||||||
|
spot = Vector3(x, t.sample_height(x, z), z)
|
||||||
|
break
|
||||||
|
if spot != Vector3.INF:
|
||||||
|
break
|
||||||
|
check(spot != Vector3.INF, "(a 55-degree face on the rim to try it on)")
|
||||||
|
if spot == Vector3.INF:
|
||||||
|
return
|
||||||
|
var deer: Node3D = w.creatures.spawn("deer", spot)
|
||||||
|
deer.set_physics_process(false)
|
||||||
|
deer.take_hit(999.0, spot + Vector3(0, 1, 0))
|
||||||
|
var at: Vector3 = deer.global_position
|
||||||
|
var ny: float = t.sample_normal(at.x, at.z).y
|
||||||
|
check(deer.is_dead() and ny >= deer.CARCASS_REST_NY, "it comes to rest on flatter ground (%.0f degrees)" % rad_to_deg(acos(ny)))
|
||||||
|
check(at.distance_to(spot) < 20.5 and at.y < spot.y, "a little way down the slope (%.1f m)" % at.distance_to(spot))
|
||||||
|
check(absf(at.y - t.sample_height(at.x, at.z)) < 0.05, "lying on the ground, not inside it")
|
||||||
|
p.global_position = at + Vector3(0.8, 0, 0)
|
||||||
|
p.global_position.y = t.sample_height(p.global_position.x, p.global_position.z)
|
||||||
|
var before: int = p.inventory.count("raw_meat")
|
||||||
|
check(A.butcher(p, deer) and p.inventory.count("raw_meat") > before, "and you can butcher it")
|
||||||
|
|
||||||
|
print("on flat ground")
|
||||||
|
var flat: Vector3 = w.build.debug_find_site(w._spawn, 3.0)
|
||||||
|
var d2: Node3D = w.creatures.spawn("deer", flat)
|
||||||
|
d2.set_physics_process(false)
|
||||||
|
var was: Vector3 = d2.global_position
|
||||||
|
d2.take_hit(999.0, was + Vector3(0, 1, 0))
|
||||||
|
check(d2.global_position.distance_to(was) < 0.01, "a deer killed on the flat stays where it fell")
|
||||||
|
d2.free()
|
||||||
|
_finished = true
|
||||||
1
tests/test_cliffs.gd.uid
Normal file
1
tests/test_cliffs.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b8pt3n5krip7j
|
||||||
@@ -269,6 +269,31 @@ func _lightning() -> void:
|
|||||||
var tree: Node3D = hz._burning.keys()[0]
|
var tree: Node3D = hz._burning.keys()[0]
|
||||||
check(tree.global_position.distance_to(at) < hz.STRIKE_RANGE, "near you (%.0f m away)" % tree.global_position.distance_to(at))
|
check(tree.global_position.distance_to(at) < hz.STRIKE_RANGE, "near you (%.0f m away)" % tree.global_position.distance_to(at))
|
||||||
check(_toast_has("Lightning struck"), "you see it (%s)" % [_toasts])
|
check(_toast_has("Lightning struck"), "you see it (%s)" % [_toasts])
|
||||||
|
# Gitea #4: thunder is recorded now; the strike cracks from the tree.
|
||||||
|
var SX: Node = root.get_node("Sfx")
|
||||||
|
var cracks: Array = SX.heard.filter(func(h): return h[0] == "thunder_near" and h[1] is Vector3 and (h[1] as Vector3).distance_to(tree.global_position) < 8.0)
|
||||||
|
check(cracks.size() == 1, "and hear it: a thunder crack from the tree")
|
||||||
|
check(SX.files("thunder_near").size() == 3 and SX.files("thunder_far").size() == 3 and
|
||||||
|
SX.files("thunder_near").all(func(f): return ResourceLoader.exists(f)) and SX.files("thunder_far").all(func(f): return ResourceLoader.exists(f)),
|
||||||
|
"(three recorded cracks and three distant rolls)")
|
||||||
|
var fx: Node3D = load("res://scripts/world/weather_fx.gd").new()
|
||||||
|
world.add_child(fx)
|
||||||
|
fx.setup(wx, player)
|
||||||
|
var kinds := {}
|
||||||
|
var delays := {"thunder_near": [], "thunder_far": []}
|
||||||
|
for _i in range(60):
|
||||||
|
fx.lightning()
|
||||||
|
kinds[fx._thunder_id] = int(kinds.get(fx._thunder_id, 0)) + 1
|
||||||
|
delays[fx._thunder_id].append(fx._thunder_delay)
|
||||||
|
check(kinds.has("thunder_near") and kinds.has("thunder_far") and int(kinds.thunder_far) > int(kinds.thunder_near),
|
||||||
|
"a storm's flashes: mostly distant rolls, sometimes a crack close by (%s)" % [kinds])
|
||||||
|
check(delays.thunder_near.all(func(d): return d < 1.0) and delays.thunder_far.all(func(d): return d >= 1.5),
|
||||||
|
"a close one follows its flash quickly, a distant one seconds later")
|
||||||
|
var h0: int = SX.heard.size()
|
||||||
|
fx._thunder_delay = 0.01
|
||||||
|
fx._process(0.05)
|
||||||
|
check(SX.heard.size() > h0 and SX.heard.slice(h0).any(func(h): return String(h[0]).begins_with("thunder_")), "and then you hear it")
|
||||||
|
fx.queue_free()
|
||||||
check(world.heat_at(tree.global_position + Vector3(2.5, 0, 0)) > 0.3, "it burns: you can warm yourself by it")
|
check(world.heat_at(tree.global_position + Vector3(2.5, 0, 0)) > 0.3, "it burns: you can warm yourself by it")
|
||||||
check(not tree.depleted, "still standing while it burns")
|
check(not tree.depleted, "still standing while it burns")
|
||||||
for _i in range(41):
|
for _i in range(41):
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func _body() -> void:
|
|||||||
sv.wet = 60.0
|
sv.wet = 60.0
|
||||||
await _for(3.0)
|
await _for(3.0)
|
||||||
check(got == ["wet"], "soaked: the wet tip")
|
check(got == ["wet"], "soaked: the wet tip")
|
||||||
check(card.is_showing() and card._title.text == "Soaked" and card._text.text.begins_with("\"Wet clothes"), "the card shows it, in Old Tam's words")
|
check(card.is_showing() and card._title.text == "Soaked" and card._text.text.begins_with("\"You're soaked"), "the card shows it, in Old Tam's words")
|
||||||
sv.wet = 0.0
|
sv.wet = 0.0
|
||||||
|
|
||||||
sv.hunger = 10.0
|
sv.hunger = 10.0
|
||||||
|
|||||||
302
tests/test_roofs.gd
Normal file
302
tests/test_roofs.gd
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Gitea #7: growing a house. A longer one (along the ridge): take out the
|
||||||
|
## end wall and gables, add floors, walls and a roof bay, and the gables go
|
||||||
|
## on the new end. A wider one (across the ridge): the roof gets a second row
|
||||||
|
## of panels on top of the first, and its taller ends close with a half wall
|
||||||
|
## under a gable. Inside both you're under a roof and sheltered.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_roofs.gd
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var S: Node
|
||||||
|
var w: Node3D
|
||||||
|
var B: Node
|
||||||
|
const Q := PI * 0.5
|
||||||
|
|
||||||
|
func check(cond: bool, what: String) -> void:
|
||||||
|
print((" ok " if cond else " FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
_fails += 1
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
S = root.get_node("Saves")
|
||||||
|
S.root_dir = "user://test_roofs_worlds"
|
||||||
|
for x in S.list_worlds():
|
||||||
|
S.delete_world(x.id)
|
||||||
|
S.current_id = S.create_world("Roofs", 1337)
|
||||||
|
w = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(w)
|
||||||
|
for _i in range(8):
|
||||||
|
await physics_frame
|
||||||
|
w.creatures.enabled = false
|
||||||
|
w.player.set_physics_process(false)
|
||||||
|
w.player.survival.set_process(false)
|
||||||
|
B = w.build
|
||||||
|
await _longer()
|
||||||
|
await _wider()
|
||||||
|
await _six()
|
||||||
|
print("an L: a wing off the side of the hut")
|
||||||
|
var l: Dictionary = await _l_shape()
|
||||||
|
print(" (wing walls failed: %s)" % [l.get("walls_failed", [])])
|
||||||
|
check(l.failed.is_empty() and l.placed == 8, "the wing's roof and gables, where it meets the hut too (%d placed%s)" % [l.placed, "" if l.failed.is_empty() else ": %s" % [l.failed]])
|
||||||
|
if l.has("c"):
|
||||||
|
var inside := Vector3(l.c.x, l.f0.global_position.y, l.c.z + 4.0)
|
||||||
|
check(B.roofed_at(inside) and B.sheltered_at(inside), "sheltered in the wing (roof %s, walls %.2f)" % [B.roofed_at(inside), B.enclosure_at(inside)])
|
||||||
|
print("haphazard: a wider wing, its roof carried on over the hut's")
|
||||||
|
var o: Dictionary = await _odd_wing()
|
||||||
|
check(o.has("floors_walls") and o.floors_walls.failed.is_empty(), "(the wing's floors and walls: %s)" % [o.get("floors_walls", {}).get("failed", o.failed)])
|
||||||
|
check(o.failed.is_empty() and o.placed == 9, "its roof goes on, the last bay over the hut's roof (%d placed%s)" % [o.placed, "" if o.failed.is_empty() else ": %s" % [o.failed]])
|
||||||
|
if o.has("c"):
|
||||||
|
var want := [Vector3(1, 2, 3), Vector3(1, 2, -3), Vector3(1, 3, 0)]
|
||||||
|
var top: float = o.top
|
||||||
|
check(want.all(func(v): return o.where.any(func(p): return p.distance_to(Vector3(v.x, top + v.y, v.z)) < 0.05)),
|
||||||
|
"(that bay is where it's meant to be, over the hut's roof)")
|
||||||
|
print("roofs through roofs")
|
||||||
|
var hut2: Array = B.build_pieces().filter(func(p): return p.kind == "roof")
|
||||||
|
var r0: Node3D = hut2[0]
|
||||||
|
var roof_def: Dictionary = DB().get_item("wood_roof")
|
||||||
|
var across := Transform3D(r0.global_basis.rotated(Vector3.UP, Q), r0.global_transform * Vector3(0, 0.5, -1.0))
|
||||||
|
check(B.check(roof_def, across) != "Something's in the way", "a roof crossing another at right angles isn't in its way (%s)" % B.check(roof_def, across))
|
||||||
|
check(B.check(roof_def, r0.global_transform) == "There's already a roof there", "but not one roof inside another, the same way round")
|
||||||
|
var wall_def: Dictionary = DB().get_item("wood_wall")
|
||||||
|
check(B.check(wall_def, Transform3D(r0.global_basis, r0.global_transform * Vector3(0, -0.4, -1.0))) == "Something's in the way", "and a wall still can't go through a roof")
|
||||||
|
_finished = true
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
for x in S.list_worlds():
|
||||||
|
S.delete_world(x.id)
|
||||||
|
DirAccess.remove_absolute(S.root_dir)
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
## One piece, aimed as a player would (build_system.gd's hut helper).
|
||||||
|
func _place(out: Dictionary, c: Vector3, id: String, local: Vector3, ryaw: float, on: Node3D) -> Node3D:
|
||||||
|
return await B._hut_place(out, c, 0.0, id, local, ryaw, on)
|
||||||
|
|
||||||
|
func DB() -> Node:
|
||||||
|
return root.get_node("ItemDB")
|
||||||
|
|
||||||
|
func _new_out() -> Dictionary:
|
||||||
|
return {"placed": 0, "failed": [], "pieces": []}
|
||||||
|
|
||||||
|
func _longer() -> void:
|
||||||
|
print("a longer house (along the ridge)")
|
||||||
|
var c: Vector3 = B.debug_find_site(w._spawn + Vector3(20, 0, 0), 5.0)
|
||||||
|
var hut: Dictionary = await B.debug_build_hut(c, 0.0)
|
||||||
|
check(hut.failed.is_empty(), "the 4 x 4 hut (%d pieces)" % hut.placed)
|
||||||
|
var f0: Node3D = hut.pieces[0]
|
||||||
|
var top: float = f0.global_position.y - c.y
|
||||||
|
for p in hut.pieces.duplicate():
|
||||||
|
var l: Vector3 = p.global_position - c
|
||||||
|
if (p.kind == "wall" or p.kind == "gable") and l.x > 1.5:
|
||||||
|
w.remove_piece(p)
|
||||||
|
for _i in range(3):
|
||||||
|
await physics_frame
|
||||||
|
var out := _new_out()
|
||||||
|
for z in [-1.0, 1.0]:
|
||||||
|
await _place(out, c, "wood_floor", Vector3(3, 0, z), 0.0, null)
|
||||||
|
await _place(out, c, "wood_wall", Vector3(3, top, -1.9), 0.0, f0)
|
||||||
|
await _place(out, c, "wood_wall", Vector3(3, top, 1.9), 0.0, f0)
|
||||||
|
for z in [-1.0, 1.0]:
|
||||||
|
await _place(out, c, "wood_wall", Vector3(3.9, top, z), Q, f0)
|
||||||
|
await _place(out, c, "wood_roof", Vector3(3, top + 2.0, 2.0), 0.0, f0)
|
||||||
|
await _place(out, c, "wood_roof", Vector3(3, top + 2.0, -2.0), PI, f0)
|
||||||
|
await _place(out, c, "wood_gable", Vector3(4, top + 2.0, 1.0), Q, f0)
|
||||||
|
await _place(out, c, "wood_gable", Vector3(4, top + 2.0, -1.0), -Q, f0)
|
||||||
|
check(out.failed.is_empty() and out.placed == 10, "a bay on the end, and its gables (%d placed%s)" % [out.placed, "" if out.failed.is_empty() else ": %s" % [out.failed]])
|
||||||
|
var inside := Vector3(c.x + 3.0, f0.global_position.y, c.z)
|
||||||
|
check(B.roofed_at(inside) and B.sheltered_at(inside), "sheltered in the new end")
|
||||||
|
|
||||||
|
func _wider() -> void:
|
||||||
|
print("a wider house (across the ridge): 4 m along, 8 m across")
|
||||||
|
var c := Vector3.INF
|
||||||
|
for near in [Vector3(-30, 0, 0), Vector3(0, 0, 30), Vector3(30, 0, 25), Vector3(-25, 0, -25), Vector3(0, 0, -35)]:
|
||||||
|
var at: Vector3 = w._spawn + near
|
||||||
|
var got: Vector3 = B.debug_find_site(at, 5.0)
|
||||||
|
if got != at: # (it hands back where you asked when there's no site)
|
||||||
|
c = got
|
||||||
|
break
|
||||||
|
check(c != Vector3.INF, "(a site for it)")
|
||||||
|
if c == Vector3.INF:
|
||||||
|
return
|
||||||
|
var out := _new_out()
|
||||||
|
# Floors: 2 x 4. The first sits on the ground, the rest snap to it.
|
||||||
|
var f0: Node3D = await _place(out, c, "wood_floor", Vector3(-1, 0, -3), 0.0, null)
|
||||||
|
check(f0 != null, "(the first floor: %s)" % [out.failed])
|
||||||
|
if f0 == null:
|
||||||
|
return
|
||||||
|
var top: float = f0.global_position.y - c.y
|
||||||
|
for x in [-1.0, 1.0]:
|
||||||
|
for z in [-3.0, -1.0, 1.0, 3.0]:
|
||||||
|
if x == -1.0 and z == -3.0:
|
||||||
|
continue
|
||||||
|
await _place(out, c, "wood_floor", Vector3(x, 0, z), 0.0, null)
|
||||||
|
# Walls all round.
|
||||||
|
for x in [-1.0, 1.0]:
|
||||||
|
await _place(out, c, "wood_wall", Vector3(x, top, -3.9), 0.0, f0)
|
||||||
|
await _place(out, c, "wood_wall", Vector3(x, top, 3.9), 0.0, f0)
|
||||||
|
for z in [-3.0, -1.0, 1.0, 3.0]:
|
||||||
|
await _place(out, c, "wood_wall", Vector3(1.9, top, z), Q, f0)
|
||||||
|
await _place(out, c, "wood_wall", Vector3(-1.9, top, z), Q, f0)
|
||||||
|
check(out.failed.is_empty(), "floors and walls (%d placed%s)" % [out.placed, "" if out.failed.is_empty() else ": %s" % [out.failed]])
|
||||||
|
var n0: int = out.placed
|
||||||
|
# The roof: the first row on the long walls, the second row on top of it,
|
||||||
|
# meeting at the ridge 2 m up.
|
||||||
|
for x in [-1.0, 1.0]:
|
||||||
|
await _place(out, c, "wood_roof", Vector3(x, top + 2.0, 4.0), 0.0, f0)
|
||||||
|
await _place(out, c, "wood_roof", Vector3(x, top + 2.0, -4.0), PI, f0)
|
||||||
|
var n1: int = out.placed
|
||||||
|
for x in [-1.0, 1.0]:
|
||||||
|
await _place(out, c, "wood_roof", Vector3(x, top + 3.0, 2.0), 0.0, f0)
|
||||||
|
await _place(out, c, "wood_roof", Vector3(x, top + 3.0, -2.0), PI, f0)
|
||||||
|
check(out.placed - n1 == 4 and out.failed.is_empty(), "a second row of roof on top of the first, up to the ridge (%d placed%s)" % [out.placed - n1, "" if out.failed.is_empty() else ": %s" % [out.failed]])
|
||||||
|
var ridge: Array = out.pieces.filter(func(p): return p.kind == "roof" and absf(p.global_position.y - (f0.global_position.y + 3.0)) < 0.2)
|
||||||
|
check(ridge.size() == 4 and ridge.all(func(p): return p.support >= B.MIN_SUPPORT), "(held up: support %s)" % [ridge.map(func(p): return snappedf(p.support, 0.01))])
|
||||||
|
# The ends: a gable on the wall at the eaves; nearer the ridge, a half
|
||||||
|
# wall on the wall and a gable on the half wall.
|
||||||
|
var n2: int = out.placed
|
||||||
|
for x in [-2.0, 2.0]:
|
||||||
|
await _place(out, c, "wood_gable", Vector3(x, top + 2.0, 3.0), Q, f0)
|
||||||
|
await _place(out, c, "wood_gable", Vector3(x, top + 2.0, -3.0), -Q, f0)
|
||||||
|
await _place(out, c, "wood_half_wall", Vector3(x, top + 2.0, 1.0), Q, f0)
|
||||||
|
await _place(out, c, "wood_half_wall", Vector3(x, top + 2.0, -1.0), Q, f0)
|
||||||
|
await _place(out, c, "wood_gable", Vector3(x, top + 3.0, 1.0), Q, f0)
|
||||||
|
await _place(out, c, "wood_gable", Vector3(x, top + 3.0, -1.0), -Q, f0)
|
||||||
|
check(out.placed - n2 == 12 and out.failed.is_empty(), "both ends closed: gables, and half walls under the upper gables (%d placed%s)" % [out.placed - n2, "" if out.failed.is_empty() else ": %s" % [out.failed]])
|
||||||
|
var halves: Array = out.pieces.filter(func(p): return p.kind == "half_wall")
|
||||||
|
check(halves.size() == 4 and halves.all(func(p): return absf(p.global_position.y - (f0.global_position.y + 2.0)) < 0.05),
|
||||||
|
"the half walls stand on the walls' tops")
|
||||||
|
var uppers: Array = out.pieces.filter(func(p): return p.kind == "gable" and absf(p.global_position.y - (f0.global_position.y + 3.0)) < 0.05)
|
||||||
|
check(uppers.size() == 4, "and the upper gables stand on the half walls (%d)" % uppers.size())
|
||||||
|
for z in [-3.0, 0.0, 3.0]:
|
||||||
|
var inside := Vector3(c.x, f0.global_position.y, c.z + z)
|
||||||
|
check(B.roofed_at(inside) and B.sheltered_at(inside), "sheltered inside, %+.0f m across (roof %s, walls %.2f)" % [z, B.roofed_at(inside), B.enclosure_at(inside)])
|
||||||
|
# A half wall on its own: a low wall on a floor.
|
||||||
|
var out2 := _new_out()
|
||||||
|
var c2: Vector3 = B.debug_find_site(w._spawn + Vector3(-25, 0, 10), 3.0)
|
||||||
|
var g0: Node3D = await _place(out2, c2, "wood_floor", Vector3(0, 0, 0), 0.0, null)
|
||||||
|
if g0 != null:
|
||||||
|
var t2: float = g0.global_position.y - c2.y
|
||||||
|
var hw: Node3D = await _place(out2, c2, "wood_half_wall", Vector3(0, t2, 0.9), 0.0, g0)
|
||||||
|
check(hw != null and absf(hw.global_position.y - g0.global_position.y) < 0.05, "a half wall on a floor's edge: a low wall (%s)" % [out2.failed])
|
||||||
|
|
||||||
|
## A site with room for a house `half` metres each way from its middle.
|
||||||
|
func _site(half: float, tries: Array) -> Vector3:
|
||||||
|
for near in tries:
|
||||||
|
var at: Vector3 = w._spawn + near
|
||||||
|
var got: Vector3 = B.debug_find_site(at, half)
|
||||||
|
if got != at:
|
||||||
|
return got
|
||||||
|
return Vector3.INF
|
||||||
|
|
||||||
|
func _six() -> void:
|
||||||
|
print("a 6 x 6 house: a roof ridge bridges the middle")
|
||||||
|
var c := _site(4.0, [Vector3(30, 0, -20), Vector3(-30, 0, 20), Vector3(25, 0, 30), Vector3(-35, 0, -10)])
|
||||||
|
check(c != Vector3.INF, "(a site for it)")
|
||||||
|
if c == Vector3.INF:
|
||||||
|
return
|
||||||
|
var out := _new_out()
|
||||||
|
var f0: Node3D = await _place(out, c, "wood_floor", Vector3(-2, 0, -2), 0.0, null)
|
||||||
|
if f0 == null:
|
||||||
|
check(false, "(the first floor: %s)" % [out.failed])
|
||||||
|
return
|
||||||
|
var top: float = f0.global_position.y - c.y
|
||||||
|
for x in [-2.0, 0.0, 2.0]:
|
||||||
|
for z in [-2.0, 0.0, 2.0]:
|
||||||
|
if not (x == -2.0 and z == -2.0):
|
||||||
|
await _place(out, c, "wood_floor", Vector3(x, 0, z), 0.0, null)
|
||||||
|
for x in [-2.0, 0.0, 2.0]:
|
||||||
|
await _place(out, c, "wood_wall", Vector3(x, top, -2.9), 0.0, f0)
|
||||||
|
await _place(out, c, "wood_wall", Vector3(x, top, 2.9), 0.0, f0)
|
||||||
|
for z in [-2.0, 0.0, 2.0]:
|
||||||
|
await _place(out, c, "wood_wall", Vector3(2.9, top, z), Q, f0)
|
||||||
|
await _place(out, c, "wood_wall", Vector3(-2.9, top, z), Q, f0)
|
||||||
|
check(out.failed.is_empty() and out.placed == 21, "9 floors and 12 walls (%d placed%s)" % [out.placed, "" if out.failed.is_empty() else ": %s" % [out.failed]])
|
||||||
|
var n0: int = out.placed
|
||||||
|
for x in [-2.0, 0.0, 2.0]:
|
||||||
|
await _place(out, c, "wood_roof", Vector3(x, top + 2.0, 3.0), 0.0, f0)
|
||||||
|
await _place(out, c, "wood_roof", Vector3(x, top + 2.0, -3.0), PI, f0)
|
||||||
|
check(out.placed - n0 == 6, "a row of roof up from each long wall, leaving a 2 m gap in the middle (%d%s)" % [out.placed - n0, "" if out.failed.is_empty() else ": %s" % [out.failed]])
|
||||||
|
var n1: int = out.placed
|
||||||
|
for x in [-2.0, 0.0, 2.0]:
|
||||||
|
await _place(out, c, "wood_roof_ridge", Vector3(x, top + 3.0, 0.0), 0.0, f0)
|
||||||
|
check(out.placed - n1 == 3 and out.failed.is_empty(), "roof ridges bridge it (%d placed%s)" % [out.placed - n1, "" if out.failed.is_empty() else ": %s" % [out.failed]])
|
||||||
|
var ridges: Array = out.pieces.filter(func(p): return p.kind == "roof_ridge")
|
||||||
|
check(ridges.size() == 3 and ridges.all(func(p): return absf(p.global_position.y - (f0.global_position.y + 3.0)) < 0.05 and absf(p.global_position.z - c.z) < 0.05),
|
||||||
|
"on the rows' top edges, along the middle")
|
||||||
|
check(ridges.all(func(p): return p.support >= B.MIN_SUPPORT), "(held up: support %s)" % [ridges.map(func(p): return snappedf(p.support, 0.01))])
|
||||||
|
var n2: int = out.placed
|
||||||
|
for x in [-3.0, 3.0]:
|
||||||
|
await _place(out, c, "wood_gable", Vector3(x, top + 2.0, 2.0), Q, f0)
|
||||||
|
await _place(out, c, "wood_gable", Vector3(x, top + 2.0, -2.0), -Q, f0)
|
||||||
|
await _place(out, c, "wood_half_wall", Vector3(x, top + 2.0, 0.0), Q, f0)
|
||||||
|
await _place(out, c, "wood_ridge_gable", Vector3(x, top + 3.0, 0.0), Q, f0)
|
||||||
|
check(out.placed - n2 == 8 and out.failed.is_empty(), "the ends closed: gables, and a half wall and ridge gable in the middle (%d placed%s)" % [out.placed - n2, "" if out.failed.is_empty() else ": %s" % [out.failed]])
|
||||||
|
for z in [-2.0, 0.0, 2.0]:
|
||||||
|
var inside := Vector3(c.x, f0.global_position.y, c.z + z)
|
||||||
|
check(B.roofed_at(inside) and B.sheltered_at(inside), "sheltered inside, %+.0f m across (roof %s, walls %.2f)" % [z, B.roofed_at(inside), B.enclosure_at(inside)])
|
||||||
|
|
||||||
|
## An L: a wing off the side of the 4 x 4 hut, its ridge at right angles to
|
||||||
|
## the hut's. Returns {placed, failed} for the wing's roof and gables.
|
||||||
|
func _l_shape() -> Dictionary:
|
||||||
|
var c := _site(4.5, [Vector3(-30, 0, -30), Vector3(35, 0, 10), Vector3(-10, 0, 40), Vector3(20, 0, -35),
|
||||||
|
Vector3(-40, 0, 5), Vector3(10, 0, 45), Vector3(45, 0, -10), Vector3(-20, 0, -45)])
|
||||||
|
if c == Vector3.INF:
|
||||||
|
return {"placed": 0, "failed": ["no site"]}
|
||||||
|
var hut: Dictionary = await B.debug_build_hut(c, 0.0)
|
||||||
|
var f0: Node3D = hut.pieces[0]
|
||||||
|
var top: float = f0.global_position.y - c.y
|
||||||
|
var out := _new_out()
|
||||||
|
# The wing: 4 m wide, 4 m out from the hut's south wall (z = 2 to 6).
|
||||||
|
for x in [-1.0, 1.0]:
|
||||||
|
for z in [3.0, 5.0]:
|
||||||
|
await _place(out, c, "wood_floor", Vector3(x, 0, z), 0.0, null)
|
||||||
|
for z in [3.0, 5.0]:
|
||||||
|
await _place(out, c, "wood_wall", Vector3(1.9, top, z), Q, f0)
|
||||||
|
await _place(out, c, "wood_wall", Vector3(-1.9, top, z), Q, f0)
|
||||||
|
for x in [-1.0, 1.0]:
|
||||||
|
await _place(out, c, "wood_wall", Vector3(x, top, 5.9), 0.0, f0)
|
||||||
|
var walls_failed: Array = out.failed.duplicate()
|
||||||
|
var roof := _new_out()
|
||||||
|
# Its roof: ridge along z, panels up from the east and west walls.
|
||||||
|
for z in [3.0, 5.0]:
|
||||||
|
await _place(roof, c, "wood_roof", Vector3(2.0, top + 2.0, z), Q, f0)
|
||||||
|
await _place(roof, c, "wood_roof", Vector3(-2.0, top + 2.0, z), -Q, f0)
|
||||||
|
# The far end's gables, and the gables where the wing meets the hut (on
|
||||||
|
# the hut's south wall, under the wing's roof).
|
||||||
|
for zz in [6.0, 2.0]:
|
||||||
|
await _place(roof, c, "wood_gable", Vector3(1.0, top + 2.0, zz), 0.0, f0)
|
||||||
|
await _place(roof, c, "wood_gable", Vector3(-1.0, top + 2.0, zz), PI, f0)
|
||||||
|
return {"placed": roof.placed, "failed": roof.failed, "walls_failed": walls_failed, "c": c, "f0": f0}
|
||||||
|
|
||||||
|
## Haphazard: a 6 m-wide wing off the east end of the 4 x 4 hut. Its roof
|
||||||
|
## is taller than the hut's, so to close the gap where they meet, its roof
|
||||||
|
## carries on one bay over the hut's roof.
|
||||||
|
func _odd_wing() -> Dictionary:
|
||||||
|
var c := _site(4.5, [Vector3(40, 0, 30), Vector3(-45, 0, -20), Vector3(30, 0, 40), Vector3(-15, 0, -50),
|
||||||
|
Vector3(50, 0, 0), Vector3(-50, 0, 15), Vector3(0, 0, 50), Vector3(15, 0, -50)])
|
||||||
|
if c == Vector3.INF:
|
||||||
|
return {"placed": 0, "failed": ["no site"]}
|
||||||
|
var hut: Dictionary = await B.debug_build_hut(c, 0.0)
|
||||||
|
var f0: Node3D = hut.pieces[0]
|
||||||
|
var top: float = f0.global_position.y - c.y
|
||||||
|
var out := _new_out()
|
||||||
|
for x in [3.0, 5.0]:
|
||||||
|
for z in [-2.0, 0.0, 2.0]:
|
||||||
|
await _place(out, c, "wood_floor", Vector3(x, 0, z), 0.0, null)
|
||||||
|
for x in [3.0, 5.0]:
|
||||||
|
await _place(out, c, "wood_wall", Vector3(x, top, -2.9), 0.0, f0)
|
||||||
|
await _place(out, c, "wood_wall", Vector3(x, top, 2.9), 0.0, f0)
|
||||||
|
for z in [-2.0, 0.0, 2.0]:
|
||||||
|
await _place(out, c, "wood_wall", Vector3(5.9, top, z), Q, f0)
|
||||||
|
var roof := _new_out()
|
||||||
|
# The wing's roof (6 m across: a row each side and a ridge), and one bay
|
||||||
|
# more, at x = 1, through the hut's own roof.
|
||||||
|
for x in [5.0, 3.0, 1.0]:
|
||||||
|
await _place(roof, c, "wood_roof", Vector3(x, top + 2.0, 3.0), 0.0, f0)
|
||||||
|
await _place(roof, c, "wood_roof", Vector3(x, top + 2.0, -3.0), PI, f0)
|
||||||
|
await _place(roof, c, "wood_roof_ridge", Vector3(x, top + 3.0, 0.0), 0.0, f0)
|
||||||
|
var where: Array = roof.pieces.map(func(p): return (p.global_position - c).snapped(Vector3(0.01, 0.01, 0.01)))
|
||||||
|
return {"placed": roof.placed, "failed": roof.failed, "floors_walls": out, "c": c, "f0": f0, "where": where, "top": top}
|
||||||
1
tests/test_roofs.gd.uid
Normal file
1
tests/test_roofs.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ceh70qtahkoa0
|
||||||
@@ -135,6 +135,23 @@ func _starts() -> void:
|
|||||||
"\"%s\" / \"%s\"" % [pn._chapter.text, pn._title.text])
|
"\"%s\" / \"%s\"" % [pn._chapter.text, pn._title.text])
|
||||||
check(G.guide_name == "Old Tam" and pn._text.text.begins_with("Old Tam: \""), "Old Tam the hermit talks you through it: %s" % pn._text.text)
|
check(G.guide_name == "Old Tam" and pn._text.text.begins_with("Old Tam: \""), "Old Tam the hermit talks you through it: %s" % pn._text.text)
|
||||||
check(G.find_marker() == Vector3.INF, "(no marker for that one)")
|
check(G.find_marker() == Vector3.INF, "(no marker for that one)")
|
||||||
|
# Gitea #5: the sun waits for you until the torch step brings dusk.
|
||||||
|
var dn: Node = world.day_night
|
||||||
|
var t0: float = dn.time
|
||||||
|
dn.time = 19.0 / 24.0
|
||||||
|
G.hold_daylight()
|
||||||
|
check(absf(dn.time * 24.0 - G.DAYLIGHT_UNTIL) < 0.01, "early in the tutorial the afternoon goes no later than %.1f h (%.2f)" % [G.DAYLIGHT_UNTIL, dn.time * 24.0])
|
||||||
|
var torch_i := -1
|
||||||
|
for i in range(G.steps.size()):
|
||||||
|
if String(G.steps[i].id) == "torch":
|
||||||
|
torch_i = i
|
||||||
|
var was_i: int = G.index
|
||||||
|
G.index = torch_i
|
||||||
|
dn.time = 19.0 / 24.0
|
||||||
|
G.hold_daylight()
|
||||||
|
check(torch_i > 0 and absf(dn.time * 24.0 - 19.0) < 0.01, "from the torch step on, night comes as usual")
|
||||||
|
G.index = was_i
|
||||||
|
dn.time = t0
|
||||||
|
|
||||||
func _chapter_one() -> void:
|
func _chapter_one() -> void:
|
||||||
print("1. look around")
|
print("1. look around")
|
||||||
|
|||||||
@@ -699,6 +699,44 @@ def _decode(src, sr, af=None):
|
|||||||
return [x * 0.06 / rms for x in left], [x * 0.06 / rms for x in right]
|
return [x * 0.06 / rms for x in left], [x * 0.06 / rms for x in right]
|
||||||
|
|
||||||
|
|
||||||
|
def make_thunder():
|
||||||
|
"""Thunder (0.6.2, Gitea #4): CC0 recordings (tools/audio/sources/
|
||||||
|
thunder_near_* and thunder_far_*, credited in assets/audio/CREDITS.md),
|
||||||
|
each cut round its loudest strike, faded out and evened out in level, into
|
||||||
|
assets/audio/sfx/thunder/thunder_near_N.ogg (a crack close by) and
|
||||||
|
thunder_far_N.ogg (a roll in the distance). Replaces the synthesised rumble."""
|
||||||
|
d = os.path.join(OUT, "sfx", "thunder")
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
print("thunder ->", os.path.relpath(d, ROOT))
|
||||||
|
sr = 44100
|
||||||
|
for kind, lead, most in (("near", 0.15, 7.0), ("far", 0.6, 10.0)):
|
||||||
|
for n, src in enumerate(sources("thunder_" + kind)):
|
||||||
|
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", src, "-ac", "1", "-ar", str(sr), "-f", "s16le", "-"],
|
||||||
|
capture_output=True, check=True).stdout
|
||||||
|
x = [struct.unpack_from("<h", raw, i * 2)[0] / 32768.0 for i in range(len(raw) // 2)]
|
||||||
|
win = sr // 20
|
||||||
|
env = [math.sqrt(sum(v * v for v in x[i:i + win]) / win) + 1e-9 for i in range(0, len(x) - win, win)]
|
||||||
|
ip = max(range(len(env)), key=lambda i: env[i])
|
||||||
|
top = env[ip]
|
||||||
|
# Back from the loudest moment to where this strike starts (20 dB down).
|
||||||
|
j = ip
|
||||||
|
while j > 0 and env[j] > top * 0.1:
|
||||||
|
j -= 1
|
||||||
|
start = max(0, int((j * 0.05 - lead) * sr))
|
||||||
|
# On until it's died away (40 dB down), or `most` seconds.
|
||||||
|
k = ip
|
||||||
|
while k < len(env) - 1 and env[k] > top * 0.01 and (k - j) * 0.05 < most:
|
||||||
|
k += 1
|
||||||
|
end = min(len(x), int(k * 0.05 * sr) + win)
|
||||||
|
seg = x[start:end]
|
||||||
|
fade_in, fade_out = int(0.02 * sr), min(int(1.5 * sr), len(seg) // 3)
|
||||||
|
for i in range(fade_in):
|
||||||
|
seg[i] *= i / fade_in
|
||||||
|
for i in range(fade_out):
|
||||||
|
seg[-1 - i] *= i / fade_out
|
||||||
|
_encode(os.path.join(d, "thunder_%s_%d.ogg" % (kind, n)), [seg], sr, 5)
|
||||||
|
|
||||||
|
|
||||||
def make_recorded(name, srcs=None):
|
def make_recorded(name, srcs=None):
|
||||||
"""A field-recorded ambience loop (assets/audio/ambience/<name>.ogg) from
|
"""A field-recorded ambience loop (assets/audio/ambience/<name>.ogg) from
|
||||||
CC0 recordings (tools/audio/sources/<name>_*, or the files given): each
|
CC0 recordings (tools/audio/sources/<name>_*, or the files given): each
|
||||||
@@ -808,6 +846,9 @@ if __name__ == "__main__":
|
|||||||
if what in RECORDED: # one recorded loop: birds | crickets
|
if what in RECORDED: # one recorded loop: birds | crickets
|
||||||
make_recorded(what)
|
make_recorded(what)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
if what == "thunder": # the recorded thunder (not part of "all": it's from sources/)
|
||||||
|
make_thunder()
|
||||||
|
sys.exit(0)
|
||||||
if what in ("all", "sfx"):
|
if what in ("all", "sfx"):
|
||||||
make_sfx()
|
make_sfx()
|
||||||
if what in ("all", "sfx", "voices"):
|
if what in ("all", "sfx", "voices"):
|
||||||
|
|||||||
BIN
tools/audio/sources/thunder_far_davewelsh_194364.mp3
Normal file
BIN
tools/audio/sources/thunder_far_davewelsh_194364.mp3
Normal file
Binary file not shown.
BIN
tools/audio/sources/thunder_far_nednednerb_38250.mp3
Normal file
BIN
tools/audio/sources/thunder_far_nednednerb_38250.mp3
Normal file
Binary file not shown.
BIN
tools/audio/sources/thunder_far_sholecoltis_683421.mp3
Normal file
BIN
tools/audio/sources/thunder_far_sholecoltis_683421.mp3
Normal file
Binary file not shown.
BIN
tools/audio/sources/thunder_near_ayadrevis_652690.mp3
Normal file
BIN
tools/audio/sources/thunder_near_ayadrevis_652690.mp3
Normal file
Binary file not shown.
BIN
tools/audio/sources/thunder_near_emulius_744723.mp3
Normal file
BIN
tools/audio/sources/thunder_near_emulius_744723.mp3
Normal file
Binary file not shown.
BIN
tools/audio/sources/thunder_near_saha213131_696550.mp3
Normal file
BIN
tools/audio/sources/thunder_near_saha213131_696550.mp3
Normal file
Binary file not shown.
@@ -25,6 +25,10 @@ for v in $(grep -E '^application/(short_version|version|file_version|product_ver
|
|||||||
done
|
done
|
||||||
which=${1:-all}
|
which=${1:-all}
|
||||||
mkdir -p build
|
mkdir -p build
|
||||||
|
# Godot mustn't import build/ (screenshots, samples, the site): with
|
||||||
|
# all_resources, anything it imported there went into the game too.
|
||||||
|
touch build/.gdignore
|
||||||
|
find build -name "*.import" -delete
|
||||||
"$GODOT" --headless --path . --import >/dev/null 2>&1
|
"$GODOT" --headless --path . --import >/dev/null 2>&1
|
||||||
|
|
||||||
if [ "$which" = all ] || [ "$which" = macOS ]; then
|
if [ "$which" = all ] || [ "$which" = macOS ]; then
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
# Applications). It's read from the environment, never from the command line.
|
# Applications). It's read from the environment, never from the command line.
|
||||||
|
|
||||||
cd "$(dirname "$0")/.." || exit 1
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
API=${API:-https://git.psbailey.uk/api/v1/repos/Admin/Emberwild}
|
API=${API:-https://git.psbailey.uk/api/v1/repos/Admin/Cinderwild}
|
||||||
[ -n "$GITEA_TOKEN" ] || { echo "set GITEA_TOKEN first: read -s GITEA_TOKEN && export GITEA_TOKEN"; exit 1; }
|
[ -n "$GITEA_TOKEN" ] || { echo "set GITEA_TOKEN first: read -s GITEA_TOKEN && export GITEA_TOKEN"; exit 1; }
|
||||||
[ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; }
|
[ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; }
|
||||||
VERSION=$(python3 -c "import json; print(json.load(open('build/version.json'))['version'])")
|
VERSION=$(python3 -c "import json; print(json.load(open('build/version.json'))['version'])")
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ Select a piece on your hotbar and a ghost shows where it will go: green if it fi
|
|||||||
|
|
||||||
Building pieces (floors, walls, roofs, gables, doors, windows, stairs, posts, palisades) are 2 m on a side and **snap** to each other: aim near a piece and the ghost lines up with it. On open ground a floor sits level and a wall sits flush. You can't build in water. A roof rises 1 m over 2 m, so two from opposite walls of a 4 m hut meet at the ridge; gables close the ends.
|
Building pieces (floors, walls, roofs, gables, doors, windows, stairs, posts, palisades) are 2 m on a side and **snap** to each other: aim near a piece and the ghost lines up with it. On open ground a floor sits level and a wall sits flush. You can't build in water. A roof rises 1 m over 2 m, so two from opposite walls of a 4 m hut meet at the ridge; gables close the ends.
|
||||||
|
|
||||||
|
To make a house **longer**, add bays along the ridge: more roof, and the gables go on the new end. To make it **wider**, the roof climbs higher: on an 8 m-wide house, a second row of roof sits on top of the first; on a 6 m-wide one, a **Roof Ridge** bridges the 2 m gap at the top. The taller ends close with a **Half Wall** (2 m x 1 m) on top of the wall, then a gable on the half wall, or a small **Ridge Gable** under a roof ridge. A half wall on its own makes a low wall or a railing.
|
||||||
|
|
||||||
### Support
|
### Support
|
||||||
|
|
||||||
Look at a piece to see its **support**. Anything on the ground has full support; each piece above or beside takes support from the best piece it's joined to, losing a little going up and a lot going sideways. Below 30% a piece can't be placed, and pieces left without support collapse back into your inventory. A hut, a second storey or a short overhang works; a bridge in mid-air doesn't. Posts and pillars carry their load straight down.
|
Look at a piece to see its **support**. Anything on the ground has full support; each piece above or beside takes support from the best piece it's joined to, losing a little going up and a lot going sideways. Below 30% a piece can't be placed, and pieces left without support collapse back into your inventory. A hut, a second storey or a short overhang works; a bridge in mid-air doesn't. Posts and pillars carry their load straight down.
|
||||||
@@ -430,7 +432,7 @@ The music follows what's happening: calm by day, a night theme after dark, a sto
|
|||||||
|
|
||||||
### Reporting a bug
|
### Reporting a bug
|
||||||
|
|
||||||
**Report a bug** in the pause menu opens a new issue on Cinderwild's [Gitea page](https://git.psbailey.uk/Admin/Emberwild/issues) in your browser, already filled in with your version, computer, mode, land, the in-game time and where you are. Say what happened and how to make it happen again, then press **Create Issue** (you'll need to be signed in there). Nothing is sent from the game itself. **Copy the details** puts the same details on your clipboard instead, and **Show the log file** opens the folder with `godot.log`: attach it for a crash or an error.
|
**Report a bug** in the pause menu opens a new issue on Cinderwild's [Gitea page](https://git.psbailey.uk/Admin/Cinderwild/issues) in your browser, already filled in with your version, computer, mode, land, the in-game time and where you are. Say what happened and how to make it happen again, then press **Create Issue** (you'll need to be signed in there). Nothing is sent from the game itself. **Copy the details** puts the same details on your clipboard instead, and **Show the log file** opens the folder with `godot.log`: attach it for a crash or an error.
|
||||||
|
|
||||||
### Updates
|
### Updates
|
||||||
|
|
||||||
|
|||||||
@@ -213,15 +213,15 @@
|
|||||||
<section id="new" aria-labelledby="new-title">
|
<section id="new" aria-labelledby="new-title">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h2 id="new-title">New in {{VERSION}}</h2>
|
<h2 id="new-title">New in {{VERSION}}</h2>
|
||||||
<p class="lead">The Cove gets its own. This is a playtest build: thanks for trying it.</p>
|
<p class="lead">Fixes from the first playtest. Thanks for every report.</p>
|
||||||
<div class="new">
|
<div class="new">
|
||||||
<ul class="ticks">
|
<ul class="ticks">
|
||||||
<li>A new name: Emberwild is now Cinderwild (another game already had the old one). Your worlds come with you.</li>
|
<li>Old Tam has a new voice and a new script: a chuckling old West Country farmer.</li>
|
||||||
<li>A wreck on the Cove's beach, with a treasure map in the captain's sea chest for each player.</li>
|
<li>Bigger houses: wider ones get a second row of roof or a roof ridge, with half walls and gables to close the ends. L-shapes and odd wings work too.</li>
|
||||||
<li>Your own X on your map: dig up the pirate chest for a ship's compass, a spyglass, a cutlass and gold.</li>
|
<li>Real thunder, near cracks and distant rolls.</li>
|
||||||
<li>The drowned crew rise to guard it. Dig by day if you're wise.</li>
|
<li>Animals no longer climb the cliffs at the edge of the world.</li>
|
||||||
<li>Crabs on the sand from dusk till dawn, and gulls after any food you leave out.</li>
|
<li>The tutorial's sun waits for you until it's time for a torch.</li>
|
||||||
<li>Build a rowing boat from driftwood, seagrass rope and pitch, and tie it up at the pier.</li>
|
<li>Still new from 0.6: the Cove's wreck, a pirate treasure each, the drowned crew, crabs, gulls and a rowing boat. And a new name: Emberwild is now Cinderwild.</li>
|
||||||
<li>Report a bug, straight from the pause menu.</li>
|
<li>Report a bug, straight from the pause menu.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<aside class="callout" aria-labelledby="bug-title">
|
<aside class="callout" aria-labelledby="bug-title">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
# Rebuild it after each release (the links name the version).
|
# Rebuild it after each release (the links name the version).
|
||||||
|
|
||||||
cd "$(dirname "$0")/../.." || exit 1
|
cd "$(dirname "$0")/../.." || exit 1
|
||||||
REPO=${REPO:-https://git.psbailey.uk/Admin/Emberwild}
|
REPO=${REPO:-https://git.psbailey.uk/Admin/Cinderwild}
|
||||||
SITE=${SITE:-https://cinderwild.psbailey.uk} # (where the page is served: for link previews)
|
SITE=${SITE:-https://cinderwild.psbailey.uk} # (where the page is served: for link previews)
|
||||||
SHOTS="meadow hut wolves winter cove treasure crabs"
|
SHOTS="meadow hut wolves winter cove treasure crabs"
|
||||||
[ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; }
|
[ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; }
|
||||||
|
|||||||
@@ -28,8 +28,18 @@ OUT_AT=$(echo "$D - 2.5" | bc)
|
|||||||
# A slow A-minor-ish drone, left and right a little apart, echoed and soft.
|
# A slow A-minor-ish drone, left and right a little apart, echoed and soft.
|
||||||
PAD="0.06*(sin(2*PI*110*t)+0.7*sin(2*PI*164.8*t)+0.5*sin(2*PI*220*t)+0.35*sin(2*PI*261.6*t)*(0.5+0.5*sin(2*PI*0.08*t)))*(0.75+0.25*sin(2*PI*0.21*t))"
|
PAD="0.06*(sin(2*PI*110*t)+0.7*sin(2*PI*164.8*t)+0.5*sin(2*PI*220*t)+0.35*sin(2*PI*261.6*t)*(0.5+0.5*sin(2*PI*0.08*t)))*(0.75+0.25*sin(2*PI*0.21*t))"
|
||||||
PAD_R="0.06*(sin(2*PI*110.4*t)+0.7*sin(2*PI*165.2*t)+0.5*sin(2*PI*219.6*t)+0.35*sin(2*PI*262*t)*(0.5+0.5*cos(2*PI*0.08*t)))*(0.75+0.25*cos(2*PI*0.21*t))"
|
PAD_R="0.06*(sin(2*PI*110.4*t)+0.7*sin(2*PI*165.2*t)+0.5*sin(2*PI*219.6*t)+0.35*sin(2*PI*262*t)*(0.5+0.5*cos(2*PI*0.08*t)))*(0.75+0.25*cos(2*PI*0.21*t))"
|
||||||
|
# Old Tam (0.6.2: tools/trailer/voice, made by make_trailer_voice.py), each
|
||||||
|
# line a moment into its shot: the dawn flyover (shot 0), the wolves (3) and
|
||||||
|
# the Cove (6). The shots' start times are in the render log.
|
||||||
|
at() { awk -v n="$1" -v d="$2" '$1 == "[shot]" && $2 == n { printf "%d", ($3 + d) * 1000 }' build/trailer/render.log; }
|
||||||
|
V=tools/trailer/voice
|
||||||
|
T_DAWN=$(at 0 2.2); T_WOLVES=$(at 3 1.0); T_COVE=$(at 6 1.2)
|
||||||
|
[ -n "$T_DAWN" ] && [ -n "$T_WOLVES" ] && [ -n "$T_COVE" ] || { echo "no shot times in build/trailer/render.log"; exit 1; }
|
||||||
ffmpeg -v error -y -i "$RAW" -f lavfi -i "aevalsrc='$PAD|$PAD_R':s=48000:d=$D" \
|
ffmpeg -v error -y -i "$RAW" -f lavfi -i "aevalsrc='$PAD|$PAD_R':s=48000:d=$D" \
|
||||||
-filter_complex "[1:a]lowpass=f=1800,aecho=0.8:0.7:180|340:0.35|0.25,afade=t=in:d=2.5,afade=t=out:st=$OUT_AT:d=2.5[m];[0:a]volume=0.8[g];[g][m]amix=inputs=2:duration=first:normalize=0[a]" \
|
-i "$V/dawn.ogg" -i "$V/wolves.ogg" -i "$V/cove.ogg" \
|
||||||
|
-filter_complex "[1:a]lowpass=f=1800,aecho=0.8:0.7:180|340:0.35|0.25,afade=t=in:d=2.5,afade=t=out:st=$OUT_AT:d=2.5[m];[0:a]volume=0.8[g];\
|
||||||
|
[2:a]aresample=48000,adelay=$T_DAWN|$T_DAWN,volume=1.6[v0];[3:a]aresample=48000,adelay=$T_WOLVES|$T_WOLVES,volume=1.6[v1];[4:a]aresample=48000,adelay=$T_COVE|$T_COVE,volume=1.6[v2];\
|
||||||
|
[v0][v1][v2]amix=inputs=3:duration=longest:normalize=0[v];[g][m][v]amix=inputs=3:duration=first:normalize=0[a]" \
|
||||||
-map 0:v -map "[a]" -vf "scale=in_range=pc:out_range=tv,format=yuv420p" -color_range tv \
|
-map 0:v -map "[a]" -vf "scale=in_range=pc:out_range=tv,format=yuv420p" -color_range tv \
|
||||||
-colorspace bt709 -color_primaries bt709 -color_trc bt709 \
|
-colorspace bt709 -color_primaries bt709 -color_trc bt709 \
|
||||||
-c:v libx264 -profile:v high -level 4.0 -preset slow -crf 23 -maxrate 3500k -bufsize 7000k -tag:v avc1 \
|
-c:v libx264 -profile:v high -level 4.0 -preset slow -crf 23 -maxrate 3500k -bufsize 7000k -tag:v avc1 \
|
||||||
|
|||||||
51
tools/trailer/make_trailer_voice.py
Normal file
51
tools/trailer/make_trailer_voice.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Old Tam's lines for the trailer (tools/trailer/make_trailer.sh mixes them
|
||||||
|
in): short ones, in his voice (the same clone as tools/voice/make_voice.py),
|
||||||
|
each checked by Whisper and the best of three takes kept. Output:
|
||||||
|
tools/trailer/voice/<name>.ogg. Run with the voice environment:
|
||||||
|
tools/voice/.venv-qwen/bin/python tools/trailer/make_trailer_voice.py
|
||||||
|
"""
|
||||||
|
import json, os, subprocess, sys, tempfile
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(HERE, "..", "voice"))
|
||||||
|
import make_voice as mv # (its voice, its checks)
|
||||||
|
|
||||||
|
OUT = os.path.join(HERE, "voice")
|
||||||
|
## name -> what he says (make_trailer.sh times each against the shots).
|
||||||
|
LINES = {
|
||||||
|
"dawn": "Ha! Awake at last! Washed up on my island, eh?",
|
||||||
|
"wolves": "Hush now. See it, there, at the edge of the light?",
|
||||||
|
"cove": "Well, you made it down!",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
import mlx_whisper
|
||||||
|
from mlx_audio.tts.utils import load_model
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
model = load_model(mv.MODEL)
|
||||||
|
sr = getattr(model, "sample_rate", 24000)
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
for name, text in LINES.items():
|
||||||
|
best = None
|
||||||
|
for k in range(3):
|
||||||
|
res = list(model.generate(text=text, ref_audio=mv.REF_AUDIO, ref_text=mv.REF_TEXT))
|
||||||
|
audio = np.concatenate([np.array(r.audio) for r in res])
|
||||||
|
wav = os.path.join(tmp, "%s_%d.wav" % (name, k))
|
||||||
|
sf.write(wav, audio, sr)
|
||||||
|
heard = mlx_whisper.transcribe(wav, path_or_hf_repo=mv.WHISPER, language="en")["text"].strip()
|
||||||
|
score = mv.match(text, heard)
|
||||||
|
if best is None or score > best[0]:
|
||||||
|
best = (score, wav, len(audio) / sr)
|
||||||
|
if score >= 0.98:
|
||||||
|
break
|
||||||
|
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", best[1], "-ac", "2", "-c:a", "vorbis", "-strict", "-2",
|
||||||
|
"-q:a", "5", os.path.join(OUT, name + ".ogg")], check=True)
|
||||||
|
print(" %s (%.1f s, words %.0f%%)" % (name, best[2], best[0] * 100), flush=True)
|
||||||
|
json.dump(LINES, open(os.path.join(OUT, "lines.json"), "w"), indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -19,7 +19,11 @@ var hut_c := Vector3.ZERO
|
|||||||
var fire_p := Vector3.ZERO
|
var fire_p := Vector3.ZERO
|
||||||
var yaw := 0.0
|
var yaw := 0.0
|
||||||
|
|
||||||
|
var _shots := 0
|
||||||
|
var _frame0 := 0
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
|
_frame0 = Engine.get_process_frames()
|
||||||
_run.call_deferred()
|
_run.call_deferred()
|
||||||
|
|
||||||
func _h(x: float, z: float) -> float:
|
func _h(x: float, z: float) -> float:
|
||||||
@@ -167,6 +171,9 @@ static func _env(t: float, a: float, b: float, fin: float = 0.6, fout: float = 0
|
|||||||
## from and to black at its ends.
|
## from and to black at its ends.
|
||||||
func _shot(secs: float, f: Callable, caption: String = "", fade: float = 0.5) -> void:
|
func _shot(secs: float, f: Callable, caption: String = "", fade: float = 0.5) -> void:
|
||||||
var n := int(secs * FPS)
|
var n := int(secs * FPS)
|
||||||
|
# (When each shot starts, in the film: make_trailer.sh times Old Tam's lines by it.)
|
||||||
|
print("[shot] %d %.3f" % [_shots, (Engine.get_process_frames() - _frame0) / FPS])
|
||||||
|
_shots += 1
|
||||||
cap.text = caption
|
cap.text = caption
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
var t := i / FPS
|
var t := i / FPS
|
||||||
|
|||||||
BIN
tools/trailer/voice/cove.ogg
Normal file
BIN
tools/trailer/voice/cove.ogg
Normal file
Binary file not shown.
BIN
tools/trailer/voice/dawn.ogg
Normal file
BIN
tools/trailer/voice/dawn.ogg
Normal file
Binary file not shown.
5
tools/trailer/voice/lines.json
Normal file
5
tools/trailer/voice/lines.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dawn": "Ha! Awake at last! Washed up on my island, eh?",
|
||||||
|
"wolves": "Hush now. See it, there, at the edge of the light?",
|
||||||
|
"cove": "Well, you made it down!"
|
||||||
|
}
|
||||||
BIN
tools/trailer/voice/wolves.ogg
Normal file
BIN
tools/trailer/voice/wolves.ogg
Normal file
Binary file not shown.
@@ -1,23 +1,33 @@
|
|||||||
"""Old Tam's voice: every line he says (the tutorial's steps and goodbye in
|
"""Old Tam's voice: every line he says (the tutorial's steps and goodbye in
|
||||||
data/tutorial.json, his tips in data/hints.json), spoken by Kokoro (Kokoro-82M,
|
data/tutorial.json, his tips in data/hints.json), in his own voice: Qwen3-TTS
|
||||||
Apache-2.0, hexgrad) with its British "George" voice, then made a little older:
|
(Alibaba's Qwen team, Apache-2.0) cloning tools/voice/old_tam/reference.wav,
|
||||||
deeper, slower and warmer. Output: assets/audio/voice/<line id>.ogg plus
|
a reedy old West Country farmer that Qwen3's VoiceDesign made from a written
|
||||||
lines.json, the text each file was made from (the game only plays a file
|
description (old_tam/voice.json). Cloning that one recording, rather than
|
||||||
whose text still matches, so an edited line goes quiet rather than saying
|
describing him again for each line, keeps him the same man throughout.
|
||||||
the old words; tests/test_tutorial.gd checks they're all up to date).
|
|
||||||
|
|
||||||
Run it with tools/voice/make_voice.sh (sets up the Kokoro environment once).
|
Each line is recorded TAKES times; Whisper (mlx-whisper) writes down what
|
||||||
Only lines whose text changed are re-made; --all re-makes everything.
|
each take says, and the take whose words best match the line is kept (a take
|
||||||
|
that drops or garbles words loses). Output: assets/audio/voice/<line id>.ogg,
|
||||||
|
lines.json (the written text each file was made from: the game only plays a
|
||||||
|
file whose text still matches, so an edited line goes quiet rather than
|
||||||
|
saying the old words; tests/test_tutorial.gd checks they're all up to date)
|
||||||
|
and spoken.json (what each one says).
|
||||||
|
|
||||||
|
Run it with tools/voice/make_voice.sh (sets up the environment once, on an
|
||||||
|
Apple-silicon Mac: MLX). Only lines whose text, or how they're spoken, changed
|
||||||
|
are re-made; --all re-makes everything; --takes N for more takes a line.
|
||||||
"""
|
"""
|
||||||
import hashlib, json, os, re, subprocess, sys, tempfile
|
import difflib, hashlib, json, os, re, subprocess, sys, tempfile
|
||||||
|
|
||||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
OUT = os.path.join(ROOT, "assets", "audio", "voice")
|
OUT = os.path.join(ROOT, "assets", "audio", "voice")
|
||||||
VOICE = "bm_george"
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
SPEED = 0.9
|
VOICE = json.load(open(os.path.join(HERE, "old_tam", "voice.json")))
|
||||||
# Older: down about 1.4 semitones (and so a touch slower), then a little
|
MODEL = VOICE["model"]
|
||||||
# warmth and the top taken off.
|
REF_AUDIO = os.path.join(HERE, "old_tam", VOICE["reference_audio"])
|
||||||
AGE = "asetrate=24000*0.92,aresample=24000,atempo=1.04,lowpass=f=6500,bass=g=2:f=180"
|
REF_TEXT = VOICE["reference_text"]
|
||||||
|
WHISPER = "mlx-community/whisper-large-v3-turbo"
|
||||||
|
TAKES = 3
|
||||||
|
|
||||||
|
|
||||||
def lines():
|
def lines():
|
||||||
@@ -45,40 +55,83 @@ def spoken(text):
|
|||||||
s = s.replace(" · ", ", ").replace("&", "and")
|
s = s.replace(" · ", ", ").replace("&", "and")
|
||||||
s = re.sub(r"\bLMB\b", "left click", s)
|
s = re.sub(r"\bLMB\b", "left click", s)
|
||||||
s = re.sub(r"\bRMB\b", "right click", s)
|
s = re.sub(r"\bRMB\b", "right click", s)
|
||||||
|
# (Gitea #3: spelt out, "W, A, S and D" comes out rushed; the screen shows the keys.)
|
||||||
|
s = re.sub(r"\bWASD\b", "the keys", s)
|
||||||
s = re.sub(r"\s+,", ",", s)
|
s = re.sub(r"\s+,", ",", s)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _words(s):
|
||||||
|
return re.sub(r"[^a-z' ]", " ", s.lower().replace("-", " ")).split()
|
||||||
|
|
||||||
|
|
||||||
|
def match(want, heard):
|
||||||
|
"""How closely what Whisper heard matches the line (0..1)."""
|
||||||
|
return difflib.SequenceMatcher(None, _words(want), _words(heard)).ratio()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
redo_all = "--all" in sys.argv
|
redo_all = "--all" in sys.argv
|
||||||
|
takes = int(sys.argv[sys.argv.index("--takes") + 1]) if "--takes" in sys.argv else TAKES
|
||||||
os.makedirs(OUT, exist_ok=True)
|
os.makedirs(OUT, exist_ok=True)
|
||||||
manifest_path = os.path.join(OUT, "lines.json")
|
manifest_path = os.path.join(OUT, "lines.json")
|
||||||
old = json.load(open(manifest_path)) if os.path.exists(manifest_path) else {}
|
old = json.load(open(manifest_path)) if os.path.exists(manifest_path) else {}
|
||||||
|
# What each recording SAYS (spoken() of its text), so a change to how a
|
||||||
|
# line is spoken re-records it too. (lines.json keeps the written text's
|
||||||
|
# key: that's what the game checks against the screen.)
|
||||||
|
said_path = os.path.join(OUT, "spoken.json")
|
||||||
|
said = json.load(open(said_path)) if os.path.exists(said_path) else {}
|
||||||
|
voice_key = key(json.dumps(VOICE, sort_keys=True))
|
||||||
|
new_voice = said.get("_voice") != voice_key
|
||||||
todo = [(i, t) for i, t in lines()
|
todo = [(i, t) for i, t in lines()
|
||||||
if redo_all or old.get(i) != key(t) or not os.path.exists(os.path.join(OUT, i + ".ogg"))]
|
if redo_all or new_voice or old.get(i) != key(t) or said.get(i) != key(spoken(t))
|
||||||
|
or not os.path.exists(os.path.join(OUT, i + ".ogg"))]
|
||||||
keep = {i for i, _ in lines()}
|
keep = {i for i, _ in lines()}
|
||||||
for f in os.listdir(OUT): # lines that no longer exist
|
for f in os.listdir(OUT): # lines that no longer exist
|
||||||
if f.endswith(".ogg") and f[:-4] not in keep:
|
if f.endswith(".ogg") and f[:-4] not in keep:
|
||||||
os.remove(os.path.join(OUT, f))
|
os.remove(os.path.join(OUT, f))
|
||||||
|
report = []
|
||||||
if todo:
|
if todo:
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
from kokoro import KPipeline
|
import mlx_whisper
|
||||||
pipe = KPipeline(lang_code="b", repo_id="hexgrad/Kokoro-82M")
|
from mlx_audio.tts.utils import load_model
|
||||||
for i, text in todo:
|
model = load_model(MODEL)
|
||||||
audio = np.concatenate([a for _, _, a in pipe(spoken(text), voice=VOICE, speed=SPEED)])
|
sr = getattr(model, "sample_rate", 24000)
|
||||||
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
tmpdir = tempfile.mkdtemp()
|
||||||
sf.write(tmp, audio, 24000)
|
for n, (i, text) in enumerate(todo, 1):
|
||||||
|
say = spoken(text)
|
||||||
|
best = None
|
||||||
|
for k in range(takes):
|
||||||
|
res = list(model.generate(text=say, ref_audio=REF_AUDIO, ref_text=REF_TEXT))
|
||||||
|
audio = np.concatenate([np.array(r.audio) for r in res])
|
||||||
|
wav = os.path.join(tmpdir, "%s_%d.wav" % (i, k))
|
||||||
|
sf.write(wav, audio, sr)
|
||||||
|
heard = mlx_whisper.transcribe(wav, path_or_hf_repo=WHISPER, language="en")["text"].strip()
|
||||||
|
score = match(say, heard)
|
||||||
|
if best is None or score > best[0]:
|
||||||
|
best = (score, wav, heard, len(audio) / sr)
|
||||||
|
if score >= 0.98:
|
||||||
|
break # (word for word: no need for another)
|
||||||
|
score, wav, heard, secs = best
|
||||||
path = os.path.join(OUT, i + ".ogg")
|
path = os.path.join(OUT, i + ".ogg")
|
||||||
# (ffmpeg's own Vorbis encoder only writes stereo)
|
# (ffmpeg's own Vorbis encoder only writes stereo)
|
||||||
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", tmp, "-af", AGE + ",apad=pad_dur=0.25", "-ac", "2",
|
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", wav, "-af", "apad=pad_dur=0.25", "-ac", "2",
|
||||||
"-c:a", "vorbis", "-strict", "-2", "-q:a", "4", path], check=True)
|
"-c:a", "vorbis", "-strict", "-2", "-q:a", "5", path], check=True)
|
||||||
os.remove(tmp)
|
|
||||||
old[i] = key(text)
|
old[i] = key(text)
|
||||||
print(" %s (%.1f s)" % (i, len(audio) / 24000 / 0.92 / 1.04))
|
said[i] = key(say)
|
||||||
|
flag = "" if score >= 0.9 else " <-- check this one: heard \"%s\"" % heard
|
||||||
|
report.append((score, i, heard))
|
||||||
|
print(" [%d/%d] %s (%.1f s, words %.0f%%)%s" % (n, len(todo), i, secs, score * 100, flag))
|
||||||
manifest = {i: key(t) for i, t in lines()}
|
manifest = {i: key(t) for i, t in lines()}
|
||||||
json.dump(manifest, open(manifest_path, "w"), indent="\t", sort_keys=True)
|
json.dump(manifest, open(manifest_path, "w"), indent="\t", sort_keys=True)
|
||||||
|
out_said = {i: said.get(i, key(spoken(t))) for i, t in lines()}
|
||||||
|
out_said["_voice"] = voice_key
|
||||||
|
json.dump(out_said, open(said_path, "w"), indent="\t", sort_keys=True)
|
||||||
print("%d lines, %d made now -> %s" % (len(manifest), len(todo), os.path.relpath(OUT, ROOT)))
|
print("%d lines, %d made now -> %s" % (len(manifest), len(todo), os.path.relpath(OUT, ROOT)))
|
||||||
|
weak = [r for r in report if r[0] < 0.9]
|
||||||
|
if weak:
|
||||||
|
print("%d to listen to (the words didn't quite match): %s" % (len(weak), ", ".join(r[1] for r in weak)))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user