Compare commits
29 Commits
109fff22d0
...
v0.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5e7c0e343 | ||
|
|
73599401bf | ||
|
|
a629ddba49 | ||
|
|
a3660f91cf | ||
|
|
4a691b72e1 | ||
|
|
8f3c58c810 | ||
|
|
07b8db1683 | ||
|
|
5672f0713d | ||
|
|
31f8e0d22c | ||
|
|
415e673b28 | ||
|
|
da29308522 | ||
|
|
79061d8572 | ||
|
|
1f67d58b92 | ||
|
|
c2c86b5f78 | ||
|
|
04b9d17826 | ||
|
|
3b611a6d34 | ||
|
|
27040d6805 | ||
|
|
2004fe193c | ||
|
|
9709d2c424 | ||
|
|
5bcfa23710 | ||
|
|
74bd8f5898 | ||
|
|
419c71e638 | ||
|
|
ec8caa9ef4 | ||
|
|
1bf33935e0 | ||
|
|
62b3d6c47f | ||
|
|
f9b138b304 | ||
|
|
86ebe07899 | ||
|
|
85bbed249b | ||
|
|
6544862885 |
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__/
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -16,8 +16,10 @@ addon SDK with guard rails.
|
|||||||
## Status
|
## Status
|
||||||
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
|
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
|
||||||
|
|
||||||
**Emberwild 0.4.1** is released (download page and player guide:
|
**Cinderwild 0.6.2** is released (download page and player guide:
|
||||||
https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
|
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.
|
||||||
|
Built so far:
|
||||||
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
|
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
|
||||||
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
|
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
|
||||||
- P1-P5 (`docs/PROGRESSION_DESIGN.md`): iron, stone building, the wolf moon and
|
- P1-P5 (`docs/PROGRESSION_DESIGN.md`): iron, stone building, the wolf moon and
|
||||||
@@ -28,10 +30,17 @@ https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
|
|||||||
- M4 so far: packaged builds, the update check, music, ambience, sound effects
|
- M4 so far: packaged builds, the update check, music, ambience, sound effects
|
||||||
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages,
|
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages,
|
||||||
the map (N) and compass.
|
the map (N) and compass.
|
||||||
- W0-W3, J1 (`docs/WORLDLETS_DESIGN.md`): worldlets as data, the Cove (beach,
|
- W0-W3, J1, J3 (`docs/WORLDLETS_DESIGN.md`): worldlets as data, the Cove
|
||||||
pier, sea fishing), and the cliff tunnel between them, with a save file per
|
(beach, pier, sea fishing, a stream), and the cliff tunnel between them,
|
||||||
worldlet (single-player).
|
with a save file per worldlet; on a server, a server per land (the Valley's
|
||||||
Next: J3, the journey on a server ("spread out"), then the Highlands.
|
starts the Cove's on the next UDP port), with chat, the player list,
|
||||||
|
admin commands and sleep across lands, and one clock everywhere.
|
||||||
|
- 0.6.0, the Cove's own (`docs/WORLDLETS_DESIGN.md` §5): the wreck, a
|
||||||
|
treasure map and a dig for each player, the drowned crew, crabs and gulls,
|
||||||
|
and a rowing boat to build and moor at the pier (sailing comes with the
|
||||||
|
Highlands). Report a bug in the pause menu; `docs/PLAYTEST.md` for testers.
|
||||||
|
Next: after the playtest, J2 (each tunnel's own gate and supplies), then the
|
||||||
|
Highlands (by sail).
|
||||||
Running from the source, `godot --path . -- --dev-worldlets` lets New World
|
Running from the source, `godot --path . -- --dev-worldlets` lets New World
|
||||||
start in any worldlet, to try one directly.
|
start in any worldlet, to try one directly.
|
||||||
|
|
||||||
@@ -53,7 +62,7 @@ godot --headless --path . -- --server
|
|||||||
|
|
||||||
## Packaged builds
|
## Packaged builds
|
||||||
```bash
|
```bash
|
||||||
tools/export.sh # build/Emberwild-<version>-macos.zip and -windows.zip
|
tools/export.sh # build/Cinderwild-<version>-macos.zip and -windows.zip
|
||||||
tools/export.sh macOS # or just one ("macOS" / "Windows")
|
tools/export.sh macOS # or just one ("macOS" / "Windows")
|
||||||
```
|
```
|
||||||
Needs the Godot export templates for your Godot version (Editor > Manage
|
Needs the Godot export templates for your Godot version (Editor > Manage
|
||||||
@@ -62,18 +71,19 @@ Export Templates). The version number is `application/short_version` in
|
|||||||
- **Mac:** a universal .app, ad-hoc signed but not notarised. On another Mac,
|
- **Mac:** a universal .app, ad-hoc signed but not notarised. On another Mac,
|
||||||
the first launch needs right-click > Open (or System Settings > Privacy &
|
the first launch needs right-click > Open (or System Settings > Privacy &
|
||||||
Security > Open Anyway).
|
Security > Open Anyway).
|
||||||
- **Windows:** unzip and run `Emberwild.exe` (keep `Emberwild.pck` beside
|
- **Windows:** unzip and run `Cinderwild.exe` (keep `Cinderwild.pck` beside
|
||||||
it). It's unsigned, so SmartScreen says "Windows protected your PC":
|
it). It's unsigned, so SmartScreen says "Windows protected your PC":
|
||||||
More info > Run anyway.
|
More info > Run anyway.
|
||||||
- A packaged copy is also a dedicated server:
|
- A packaged copy is also a dedicated server:
|
||||||
`Emberwild.app/Contents/MacOS/Emberwild --headless -- --server` (Mac) or
|
`Cinderwild.app/Contents/MacOS/Cinderwild --headless -- --server` (Mac) or
|
||||||
`Emberwild.exe --headless -- --server` (Windows). Saves and settings live in
|
`Cinderwild.exe --headless -- --server` (Windows). Saves and settings live in
|
||||||
the same user folder as a copy run from source.
|
the same user folder as a copy run from source.
|
||||||
|
|
||||||
## Trailer and download page
|
## Trailer and download page
|
||||||
```bash
|
```bash
|
||||||
tools/trailer/make_trailer.sh # build/Emberwild-trailer.mp4: recorded in-game (Movie Maker), needs ffmpeg
|
tools/trailer/make_trailer.sh # build/Cinderwild-trailer.mp4: recorded in-game (Movie Maker), needs ffmpeg
|
||||||
tools/site/make_site.sh # build/site/: index.html + poster.jpg + trailer.mp4 for the current release
|
tools/site/make_shots.sh # build/shots/*.jpg: the page's screenshots, rendered in a window
|
||||||
|
tools/site/make_site.sh # build/site/: index.html, guide.html, the shots, poster and trailer for the current release
|
||||||
```
|
```
|
||||||
The page's download links point at the Gitea release named in
|
The page's download links point at the Gitea release named in
|
||||||
`build/version.json`, so rebuild it after each `tools/export.sh` release.
|
`build/version.json`, so rebuild it after each `tools/export.sh` release.
|
||||||
|
|||||||
@@ -39,14 +39,32 @@ 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.
|
||||||
|
|
||||||
## Old Tam's voice
|
## Thunder (CC0)
|
||||||
`voice/` is spoken by **Kokoro** (Kokoro-82M by hexgrad, Apache-2.0,
|
`sfx/thunder/` is cut from six Freesound recordings, all License: Creative
|
||||||
https://huggingface.co/hexgrad/Kokoro-82M) with its British "George" voice,
|
Commons 0 (the previews are in `tools/audio/sources/thunder_*`), each round
|
||||||
made a little older (deeper, slower, warmer) by `tools/voice/make_voice.py`.
|
its loudest strike, faded out and levelled by
|
||||||
The recordings are generated from our own text; rebuild them with
|
`tools/audio/make_audio.py thunder`. Close cracks: "Thunder 08" by Emulius
|
||||||
`tools/voice/make_voice.sh` after changing any line.
|
(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.
|
||||||
|
|
||||||
## Emberwild's own (CC0)
|
## Old Tam's voice
|
||||||
|
`voice/` is spoken by **Qwen3-TTS** (Qwen3-TTS-12Hz-1.7B by Alibaba's Qwen
|
||||||
|
team, Apache-2.0, https://huggingface.co/Qwen; run through mlx-audio, MIT).
|
||||||
|
His voice, a reedy old West Country farmer, was designed once from a written
|
||||||
|
description by Qwen3-TTS VoiceDesign (`tools/voice/old_tam/`), and every 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)
|
||||||
`music/`, `ambience/` and `sfx/synth/` are synthesised from scratch by
|
`music/`, `ambience/` and `sfx/synth/` are synthesised from scratch by
|
||||||
`tools/audio/make_audio.py` (no samples): the five music tracks (calm,
|
`tools/audio/make_audio.py` (no samples): the five music tracks (calm,
|
||||||
night, storm, danger, boss), the fire crackle, the creature voices, and the
|
night, storm, danger, boss), the fire crackle, the creature voices, and the
|
||||||
|
|||||||
BIN
assets/audio/sfx/creatures/crab_click_0.ogg
Normal file
BIN
assets/audio/sfx/creatures/crab_click_0.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/creatures/crab_click_1.ogg
Normal file
BIN
assets/audio/sfx/creatures/crab_click_1.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/creatures/gull_cry_0.ogg
Normal file
BIN
assets/audio/sfx/creatures/gull_cry_0.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/creatures/gull_cry_1.ogg
Normal file
BIN
assets/audio/sfx/creatures/gull_cry_1.ogg
Normal file
Binary file not shown.
BIN
assets/audio/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.
BIN
assets/audio/voice/hint_crab.ogg
Normal file
BIN
assets/audio/voice/hint_crab.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.
@@ -1,43 +1,44 @@
|
|||||||
{
|
{
|
||||||
"hint_cold": "91c5088e453fb0e6",
|
"hint_cold": "ac4139441b7a347a",
|
||||||
"hint_cove": "126f0b5e977fbef7",
|
"hint_cove": "91eb0dcbc24afe5c",
|
||||||
"hint_died": "e865f8a63622b3c3",
|
"hint_crab": "3a88cd0772a7676d",
|
||||||
"hint_diet": "3178b319c44e9fa0",
|
"hint_died": "7b60d478f6fdbcef",
|
||||||
"hint_freezing": "48b1e505119a0fc9",
|
"hint_diet": "b5e9600c2da38bdf",
|
||||||
"hint_hard_winter": "5513aca1655bc278",
|
"hint_freezing": "0a63a53be413d808",
|
||||||
|
"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,40 +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."}
|
"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",
|
||||||
|
"text": "Two lots of crab meat cook up into a good crab supper at a campfire. Raw, it's as risky as raw fish."}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
184
data/items.json
184
data/items.json
@@ -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",
|
||||||
@@ -593,6 +629,39 @@
|
|||||||
},
|
},
|
||||||
"keeps": 4
|
"keeps": 4
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "crab_meat",
|
||||||
|
"name": "Crab Meat",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Sweet white meat from a crab off the Cove's beach. Cook it at a campfire. (Fish)",
|
||||||
|
"mesh": "crab_meat",
|
||||||
|
"food": {
|
||||||
|
"hunger": 4,
|
||||||
|
"health": -2,
|
||||||
|
"diet": {
|
||||||
|
"fish": 8
|
||||||
|
},
|
||||||
|
"risk": 0.2
|
||||||
|
},
|
||||||
|
"keeps": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cooked_crab",
|
||||||
|
"name": "Cooked Crab",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Boiled pink in its shell. Worth the pinches. (Fish)",
|
||||||
|
"mesh": "cooked_crab",
|
||||||
|
"food": {
|
||||||
|
"hunger": 20,
|
||||||
|
"health": 10,
|
||||||
|
"diet": {
|
||||||
|
"fish": 32
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"keeps": 3
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "grilled_cod",
|
"id": "grilled_cod",
|
||||||
"name": "Grilled Cod",
|
"name": "Grilled Cod",
|
||||||
@@ -1073,6 +1142,119 @@
|
|||||||
"description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.",
|
"description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.",
|
||||||
"mesh": "salt"
|
"mesh": "salt"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "driftwood",
|
||||||
|
"name": "Driftwood",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Sea-bleached wood washed up on the Cove's beach. It burns well, and it's light and tough: what a rowing boat is built from.",
|
||||||
|
"mesh": "driftwood",
|
||||||
|
"fuel": 48.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seagrass",
|
||||||
|
"name": "Seagrass",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "Long, tough ribbons of seagrass washed up in the Cove's shallows. Twisted together it makes a rope that won't rot in salt water.",
|
||||||
|
"mesh": "seagrass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rope",
|
||||||
|
"name": "Seagrass Rope",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Twisted from seagrass: for rigging and lashing a boat.",
|
||||||
|
"mesh": "rope"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pine_resin",
|
||||||
|
"name": "Pine Resin",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "Sticky amber resin from a felled pine. Boiled with salt it makes pitch.",
|
||||||
|
"mesh": "pine_resin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pitch",
|
||||||
|
"name": "Pitch",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Black, tarry pitch from pine resin and salt, boiled over a fire. It keeps the sea out of a boat's seams.",
|
||||||
|
"mesh": "pitch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rowing_boat",
|
||||||
|
"name": "Rowing Boat",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A small driftwood boat, seams pitched tight. Carry it to the mooring post at the end of the Cove's pier to tie it up. Where it'll take you, a ship's compass will tell.",
|
||||||
|
"mesh": "rowing_boat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "treasure_map",
|
||||||
|
"name": "Treasure Map",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A torn chart from a wrecked ship's sea chest, with an X inked in the hills above the Cove. Right-click to read it: the X goes on your map (N).",
|
||||||
|
"mesh": "treasure_map",
|
||||||
|
"read": {
|
||||||
|
"marks": "treasure"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ship_compass",
|
||||||
|
"name": "Ship's Compass",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A brass sea compass from the pirate chest, its needle still true. No boat leaves for the Highlands without one.",
|
||||||
|
"mesh": "ship_compass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spyglass",
|
||||||
|
"name": "Spyglass",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A captain's brass telescope. Hold right-click to look through it.",
|
||||||
|
"mesh": "spyglass",
|
||||||
|
"zoom": 4.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pirate_gold",
|
||||||
|
"name": "Pirate Gold",
|
||||||
|
"category": "placeable",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Old gold coins from the pirate chest. Nothing on the island takes coin (yet), but a heap of it looks fine in a hut.",
|
||||||
|
"mesh": "pirate_gold",
|
||||||
|
"place": {
|
||||||
|
"piece": "trophy",
|
||||||
|
"footprint": [
|
||||||
|
0.6,
|
||||||
|
0.6
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cutlass",
|
||||||
|
"name": "Cutlass",
|
||||||
|
"category": "weapon",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A pirate's curved blade: quick, and it hits harder than the iron spear.",
|
||||||
|
"mesh": "cutlass",
|
||||||
|
"weapon": {
|
||||||
|
"damage": 30,
|
||||||
|
"reach": 2.3,
|
||||||
|
"stamina": 8,
|
||||||
|
"cooldown": 0.45
|
||||||
|
},
|
||||||
|
"durability": 220,
|
||||||
|
"repair": {
|
||||||
|
"inputs": {
|
||||||
|
"iron_bar": 1
|
||||||
|
},
|
||||||
|
"station": "forge"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "smoking_rack",
|
"id": "smoking_rack",
|
||||||
"name": "Smoking Rack",
|
"name": "Smoking Rack",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -259,6 +290,16 @@
|
|||||||
"mackerel": 1
|
"mackerel": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "cooked_crab",
|
||||||
|
"output": "cooked_crab",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"crab_meat": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "grilled_cod",
|
"id": "grilled_cod",
|
||||||
"output": "grilled_cod",
|
"output": "grilled_cod",
|
||||||
@@ -723,6 +764,40 @@
|
|||||||
"stick": 2,
|
"stick": 2,
|
||||||
"fiber": 2
|
"fiber": 2
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rope",
|
||||||
|
"output": "rope",
|
||||||
|
"count": 1,
|
||||||
|
"station": "",
|
||||||
|
"category": "tools",
|
||||||
|
"inputs": {
|
||||||
|
"seagrass": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pitch",
|
||||||
|
"output": "pitch",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"category": "building",
|
||||||
|
"inputs": {
|
||||||
|
"pine_resin": 2,
|
||||||
|
"salt": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rowing_boat",
|
||||||
|
"output": "rowing_boat",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"category": "building",
|
||||||
|
"inputs": {
|
||||||
|
"driftwood": 12,
|
||||||
|
"rope": 4,
|
||||||
|
"pitch": 3,
|
||||||
|
"nails": 6
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
{"type": "ocean_edge"}
|
{"type": "ocean_edge"}
|
||||||
],
|
],
|
||||||
"climate": {"snowline": 42.0},
|
"climate": {"snowline": 42.0},
|
||||||
"places": [{"type": "pier", "at": "beach"}],
|
"places": [{"type": "den"}, {"type": "pier", "at": "beach"}, {"type": "wreck"}],
|
||||||
|
"wildlife": ["crabs", "gulls"],
|
||||||
"next": [
|
"next": [
|
||||||
{"to": "valley", "by": "tunnel", "where": "sea_cave"},
|
{"to": "valley", "by": "tunnel", "where": "sea_cave"},
|
||||||
{"to": "highlands", "by": "sail", "where": "pier"}
|
{"to": "highlands", "by": "sail", "where": "pier"}
|
||||||
|
|||||||
@@ -14,5 +14,6 @@
|
|||||||
{"type": "ocean_edge"}
|
{"type": "ocean_edge"}
|
||||||
],
|
],
|
||||||
"climate": {"snowline": 40.0},
|
"climate": {"snowline": 40.0},
|
||||||
|
"places": [{"type": "den"}, {"type": "barrow"}, {"type": "stones"}, {"type": "bear"}],
|
||||||
"next": [{"to": "cove", "by": "tunnel", "where": "sea_cliff"}]
|
"next": [{"to": "cove", "by": "tunnel", "where": "sea_cliff"}]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ elsewhere, use **Tailscale** (recommended) or port forwarding (below).
|
|||||||
- **Leaving** (Disconnect, or quitting the game) saves the island and
|
- **Leaving** (Disconnect, or quitting the game) saves the island and
|
||||||
stops the server. Next time, Host Game carries on with the same island
|
stops the server. Next time, Host Game carries on with the same island
|
||||||
(the seed field is locked once it exists).
|
(the seed field is locked once it exists).
|
||||||
- Its files: `~/Library/Application Support/Godot/app_userdata/Emberwild/host/`
|
- Its files: `~/Library/Application Support/Godot/app_userdata/Cinderwild/host/`
|
||||||
(macOS; `%APPDATA%\Godot\app_userdata\Emberwild\host\` on Windows):
|
(macOS; `%APPDATA%\Godot\app_userdata\Cinderwild\host\` on Windows):
|
||||||
`server.json`, `server.log`, and the world in `worlds/server-world/`.
|
`server.json`, `server.log`, and the world in `worlds/server-world/`.
|
||||||
- macOS may ask once whether Godot may accept incoming network
|
- macOS may ask once whether Godot may accept incoming network
|
||||||
connections: say **Allow**, or friends can't connect.
|
connections: say **Allow**, or friends can't connect.
|
||||||
@@ -47,14 +47,14 @@ The first player to join a new server becomes its admin (so join it
|
|||||||
yourself before telling friends the address). More admins: `/admin <name>`.
|
yourself before telling friends the address). More admins: `/admin <name>`.
|
||||||
|
|
||||||
**As a service** (starts on boot, restarts if it stops):
|
**As a service** (starts on boot, restarts if it stops):
|
||||||
- **macOS:** `server/com.emberwild.server.plist`: edit the paths in it,
|
- **macOS:** `server/com.cinderwild.server.plist`: edit the paths in it,
|
||||||
then `cp server/com.emberwild.server.plist ~/Library/LaunchAgents/` and
|
then `cp server/com.cinderwild.server.plist ~/Library/LaunchAgents/` and
|
||||||
`launchctl load -w ~/Library/LaunchAgents/com.emberwild.server.plist`.
|
`launchctl load -w ~/Library/LaunchAgents/com.cinderwild.server.plist`.
|
||||||
It runs while you're logged in (set the Mac to log in automatically for an
|
It runs while you're logged in (set the Mac to log in automatically for an
|
||||||
always-on server). Log: `~/Library/Logs/emberwild-server.log`.
|
always-on server). Log: `~/Library/Logs/cinderwild-server.log`.
|
||||||
- **Linux:** `server/emberwild-server.service`: the steps are in the file
|
- **Linux:** `server/cinderwild-server.service`: the steps are in the file
|
||||||
(`sudo systemctl enable --now emberwild-server`; logs with
|
(`sudo systemctl enable --now cinderwild-server`; logs with
|
||||||
`journalctl -u emberwild-server -f`).
|
`journalctl -u cinderwild-server -f`).
|
||||||
|
|
||||||
As a service there's no terminal to type commands into: use chat in game,
|
As a service there's no terminal to type commands into: use chat in game,
|
||||||
or edit `server.json` and restart.
|
or edit `server.json` and restart.
|
||||||
@@ -76,7 +76,7 @@ the ones you'll want first:
|
|||||||
| `difficulty` | normal | easy / normal / hard |
|
| `difficulty` | normal | easy / normal / hard |
|
||||||
| `sleep` | all_online | or `half`, `anyone`, `off` |
|
| `sleep` | all_online | or `half`, `anyone`, `off` |
|
||||||
| `backups_kept`, `backup_hours` | 10, 6 | see below |
|
| `backups_kept`, `backup_hours` | 10, 6 | see below |
|
||||||
| `check_updates` | true | at start-up, say in the log if a newer Emberwild is out (and whether its protocol differs, so new players couldn't join) |
|
| `check_updates` | true | at start-up, say in the log if a newer Cinderwild is out (and whether its protocol differs, so new players couldn't join) |
|
||||||
| `allowlist`, `banned`, `admins` | | player ids (use `/ban`, `/admin` rather than editing) |
|
| `allowlist`, `banned`, `admins` | | player ids (use `/ban`, `/admin` rather than editing) |
|
||||||
|
|
||||||
## Backups
|
## Backups
|
||||||
@@ -113,15 +113,22 @@ To check it works: `tailscale ping <server name>` from the friend's
|
|||||||
computer.
|
computer.
|
||||||
|
|
||||||
### Port forwarding
|
### Port forwarding
|
||||||
On your router, forward **UDP 24242** to the server computer's local
|
On your router, forward **UDP 24242 and 24243** to the server computer's
|
||||||
address (give that computer a fixed local address first). Friends join
|
local address (give that computer a fixed local address first). Friends
|
||||||
with your public IP address. It needs no extra software, but your home IP
|
join with your public IP address. It needs no extra software, but your
|
||||||
and that port are public, so set a `password`.
|
home IP and those ports are public, so set a `password`.
|
||||||
|
|
||||||
|
Why two: each land of the journey runs as its own server on the next port
|
||||||
|
up (24243 is the Cove's, when the server's on 24242). The server players
|
||||||
|
join (the Valley's) starts it when someone first goes there and stops it
|
||||||
|
when it's been empty a couple of minutes; everyone still joins on 24242.
|
||||||
|
Host Game and the server's log both say which ports to forward. With
|
||||||
|
Tailscale, or on the same Wi-Fi, there's nothing to do.
|
||||||
|
|
||||||
### A cloud server
|
### A cloud server
|
||||||
Any small Linux server works (it needs little: a couple of GB of memory).
|
Any small Linux server works (it needs little: a couple of GB of memory).
|
||||||
Install Godot, clone the game, use the systemd file, and open UDP 24242 in
|
Install Godot, clone the game, use the systemd file, and open UDP 24242
|
||||||
the provider's firewall.
|
and 24243 in the provider's firewall.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
- **"Different game version":** the server and the player are on different
|
- **"Different game version":** the server and the player are on different
|
||||||
@@ -132,6 +139,11 @@ the provider's firewall.
|
|||||||
- **"Port 24242 is already in use":** another server is running on this
|
- **"Port 24242 is already in use":** another server is running on this
|
||||||
computer (a dedicated one, or a Host Game that didn't stop). Stop it, or
|
computer (a dedicated one, or a Host Game that didn't stop). Stop it, or
|
||||||
host on another port.
|
host on another port.
|
||||||
|
- **"Lost the connection to the Cove":** that land's server stopped (it
|
||||||
|
logs to `land-cove.log` beside `server.json`). Join again: the server
|
||||||
|
starts it afresh and sends you back there.
|
||||||
|
- **Stuck going on to the Cove from the internet:** UDP 24243 isn't
|
||||||
|
forwarded (see above).
|
||||||
- **The server says the game's scripts didn't load:** it's a fresh copy
|
- **The server says the game's scripts didn't load:** it's a fresh copy
|
||||||
that hasn't been imported, or Godot is older than 4.7. `run_server.sh`
|
that hasn't been imported, or Godot is older than 4.7. `run_server.sh`
|
||||||
handles both; see `docs/SETUP_ON_NEW_MACHINE.md`.
|
handles both; see `docs/SETUP_ON_NEW_MACHINE.md`.
|
||||||
|
|||||||
80
docs/PLAYTEST.md
Normal file
80
docs/PLAYTEST.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Cinderwild 0.6.0 playtest
|
||||||
|
|
||||||
|
Thanks for playing. This build is the **Valley** and the **Cove**, and
|
||||||
|
we'd like to know what breaks, what's confusing, and what's no fun. The
|
||||||
|
game's player guide is on the download page: https://cinderwild.psbailey.uk
|
||||||
|
|
||||||
|
## Reporting a bug
|
||||||
|
|
||||||
|
Press **Esc** in the game, then **Report a bug**:
|
||||||
|
|
||||||
|
- **Open the bug report page** opens a new issue on the Cinderwild Gitea page
|
||||||
|
in your browser, already filled in with your version, computer, mode,
|
||||||
|
land, the in-game time and where you are. Say what happened, what you
|
||||||
|
expected, and how to make it happen again, then press **Create Issue**.
|
||||||
|
Posting needs a (free) account on the Gitea server, and you need to be
|
||||||
|
signed in. Nothing is sent from the game itself.
|
||||||
|
- **Copy the details** puts the same details on your clipboard, if you'd
|
||||||
|
rather send them by message.
|
||||||
|
- **Show the log file** opens the folder with `godot.log`. For a crash or an
|
||||||
|
error message, attach it to the issue (drag it into the comment box).
|
||||||
|
`godot.log` is the latest run; the last few runs before it are kept
|
||||||
|
beside it, named with the date and time.
|
||||||
|
|
||||||
|
Where the log is:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Mac | `~/Library/Application Support/Godot/app_userdata/Cinderwild/logs/godot.log` |
|
||||||
|
| Windows | `%APPDATA%\Godot\app_userdata\Cinderwild\logs\godot.log` |
|
||||||
|
| Linux | `~/.local/share/godot/app_userdata/Cinderwild/logs/godot.log` |
|
||||||
|
|
||||||
|
A screenshot helps for anything that looks wrong (Cmd+Shift+4 on a Mac,
|
||||||
|
Win+Shift+S on Windows).
|
||||||
|
|
||||||
|
One problem per issue, please; a second issue is better than a long one.
|
||||||
|
|
||||||
|
## What to try
|
||||||
|
|
||||||
|
Play however you like; these are the parts that are new or that we'd most
|
||||||
|
like tried.
|
||||||
|
|
||||||
|
**The Valley**
|
||||||
|
- The tutorial with Old Tam (New World, Tutorial), or start a normal world
|
||||||
|
and listen for his tips.
|
||||||
|
- Make a camp, survive a few nights, a winter, a wolf moon.
|
||||||
|
- Iron, stone building, the barrow, the standing stones and the Old Stag.
|
||||||
|
- Out on the clifftop, the cracked seam: dig the cliff tunnel down to the
|
||||||
|
Cove (an antler pickaxe, wood to prop it, lights).
|
||||||
|
|
||||||
|
**The Cove (new in 0.6.0)**
|
||||||
|
- The wreck at the far end of the beach: the captain's sea chest has a
|
||||||
|
treasure map for each player. Right-click the map, find your X (N for the
|
||||||
|
map), and dig it up with a pickaxe. Don't dig at night, or do.
|
||||||
|
- The pier: mackerel close in, cod from the far end.
|
||||||
|
- Gulls, after food on the sand and fish on a smoking rack.
|
||||||
|
- Crabs on the beach from dusk till dawn.
|
||||||
|
- Build a rowing boat (seagrass rope, pitch from pine resin and salt,
|
||||||
|
driftwood and nails, at a workbench) and tie it up at the end of the pier.
|
||||||
|
|
||||||
|
**Online**
|
||||||
|
- Host Game from the title screen, and have a friend join. Over the
|
||||||
|
internet, forward UDP ports **24242** and **24243** (the Cove has its own
|
||||||
|
server on the next port).
|
||||||
|
- Go between the lands together and apart, chat, sleep (everyone in every
|
||||||
|
land has to be in bed), and leave and come back.
|
||||||
|
|
||||||
|
## Not in this build (these aren't bugs)
|
||||||
|
|
||||||
|
- **Setting sail.** The boat ties up but can't leave yet: the Highlands are
|
||||||
|
the next part of the journey.
|
||||||
|
- **Pirate gold** has no use yet beyond being a trophy: there's no trader.
|
||||||
|
- **The ship's compass** does nothing yet; it's what the boat will need.
|
||||||
|
|
||||||
|
## Things we'd especially like to hear
|
||||||
|
|
||||||
|
- Anywhere you got stuck, or didn't know what to do next.
|
||||||
|
- Anything that felt too hard, too easy, or too slow (night, hunger, cold,
|
||||||
|
the drowned crew, the crabs).
|
||||||
|
- Frame-rate drops, and on what computer.
|
||||||
|
- Anything online that went out of step between players.
|
||||||
@@ -91,11 +91,19 @@ phases M2.0-M2.7).
|
|||||||
compass along the top (0.4.0, Gitea issue #1).
|
compass along the top (0.4.0, Gitea issue #1).
|
||||||
- More places: the worldlets and the journey (`docs/WORLDLETS_DESIGN.md`).
|
- More places: the worldlets and the journey (`docs/WORLDLETS_DESIGN.md`).
|
||||||
*Done (0.4.0):* W0-W3 and J1, the Cove reached down the cliff tunnel
|
*Done (0.4.0):* W0-W3 and J1, the Cove reached down the cliff tunnel
|
||||||
(single-player). Next J3 (multiplayer, "spread out"), then J2/J4.
|
(single-player). *(0.5.0):* J3, the journey on a server: a server per
|
||||||
|
land, players in different lands at once, chat, the player list, admin
|
||||||
|
and sleep across lands. *(0.6.0, a playtest):* the Cove's own: the wreck,
|
||||||
|
a treasure dig for each player and its drowned crew, crabs, gulls, and a
|
||||||
|
rowing boat moored at the pier; Report a bug in the pause menu. *(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).
|
||||||
- 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
|
||||||
@@ -109,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
|
||||||
@@ -116,7 +184,7 @@ an out-of-date game can't join an up-to-date server: today that means
|
|||||||
`git pull` and a restart on every machine.
|
`git pull` and a restart on every machine.
|
||||||
|
|
||||||
*Built so far (the check):* `scripts/core/updates.gd` asks the feed in
|
*Built so far (the check):* `scripts/core/updates.gd` asks the feed in
|
||||||
Project Settings (`emberwild/updates/feed`, now the git server's
|
Project Settings (`cinderwild/updates/feed`, now the git server's
|
||||||
`releases/latest` API; Gitea, Forgejo and GitHub answer alike) once at
|
`releases/latest` API; Gitea, Forgejo and GitHub answer alike) once at
|
||||||
start-up. It reads the release's `version.json` asset (written by
|
start-up. It reads the release's `version.json` asset (written by
|
||||||
`tools/export.sh`: version, protocol, per-platform file, sha256 and size).
|
`tools/export.sh`: version, protocol, per-platform file, sha256 and size).
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ server/run_server.sh --server-name "Paul's island" --password secret --port 2424
|
|||||||
It checks the Godot version, imports a fresh copy, then starts the server on
|
It checks the Godot version, imports a fresh copy, then starts the server on
|
||||||
UDP 24242 with no window. Settings live in `server.json` (its path is printed
|
UDP 24242 with no window. Settings live in `server.json` (its path is printed
|
||||||
at start-up; on macOS it's under
|
at start-up; on macOS it's under
|
||||||
`~/Library/Application Support/Godot/app_userdata/Emberwild/server/`).
|
`~/Library/Application Support/Godot/app_userdata/Cinderwild/server/`).
|
||||||
The shared island is saved in `worlds/server-world/` in that same folder,
|
The shared island is saved in `worlds/server-world/` in that same folder,
|
||||||
within 10 seconds of any change (and when someone leaves). Stop it with
|
within 10 seconds of any change (and when someone leaves). Stop it with
|
||||||
Ctrl+C. Players join with **Join Game** on the title screen. Over
|
Ctrl+C. Players join with **Join Game** on the title screen. Over
|
||||||
@@ -82,13 +82,13 @@ godot --headless --path . --script res://tests/test_survival_stats.gd
|
|||||||
|
|
||||||
## Save files
|
## Save files
|
||||||
Worlds are per machine, under
|
Worlds are per machine, under
|
||||||
`~/Library/Application Support/Godot/app_userdata/Emberwild/worlds/` on
|
`~/Library/Application Support/Godot/app_userdata/Cinderwild/worlds/` on
|
||||||
macOS (`%APPDATA%\Godot\app_userdata\Emberwild\worlds\` on Windows). Copy a
|
macOS (`%APPDATA%\Godot\app_userdata\Cinderwild\worlds\` on Windows). Copy a
|
||||||
world's folder across to move it to another machine.
|
world's folder across to move it to another machine.
|
||||||
|
|
||||||
The game used to be called Valheimlike, and its data lived in
|
The game used to be called Valheimlike, and its data lived in
|
||||||
`app_userdata/Valheimlike/`. The first time Emberwild starts it copies worlds,
|
`app_userdata/Valheimlike/`. The first time Cinderwild starts it copies worlds,
|
||||||
your identity and server settings across to `app_userdata/Emberwild/`
|
your identity and server settings across to `app_userdata/Cinderwild/`
|
||||||
(`scripts/core/boot.gd`). The old folder is left alone as a backup, and you
|
(`scripts/core/boot.gd`). The old folder is left alone as a backup, and you
|
||||||
can delete it once you're happy.
|
can delete it once you're happy.
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ small Diet bars, and a low group blinks.
|
|||||||
- **Autosave** every 2 minutes, on Save & Quit (to the title or desktop) and
|
- **Autosave** every 2 minutes, on Save & Quit (to the title or desktop) and
|
||||||
when the window is closed.
|
when the window is closed.
|
||||||
- Saves live in `user://worlds/<id>/` (on macOS:
|
- Saves live in `user://worlds/<id>/` (on macOS:
|
||||||
`~/Library/Application Support/Godot/app_userdata/Emberwild/worlds/`) as
|
`~/Library/Application Support/Godot/app_userdata/Cinderwild/worlds/`) as
|
||||||
`meta.json` + `world.json`. Writes are atomic, and the previous save is
|
`meta.json` + `world.json`. Writes are atomic, and the previous save is
|
||||||
kept as `world.json.bak`.
|
kept as `world.json.bak`.
|
||||||
- A save stores only what the player changed: player position and look,
|
- A save stores only what the player changed: player position and look,
|
||||||
|
|||||||
@@ -1,7 +1,72 @@
|
|||||||
# Worldlets design
|
# Worldlets design
|
||||||
|
|
||||||
Status: **W0-W3 and J1 built** (the Cove without its stream yet; the
|
Status: **W0-W3, J1 and J3a built**: the Cove (with its stream), the cliff
|
||||||
tunnel single-player only); the rest is planned.
|
tunnel, and the journey on a server (a server per land; chat and the
|
||||||
|
player list across lands are J3b). The rest is planned.
|
||||||
|
|
||||||
|
*As built in J3a (a server per land):*
|
||||||
|
- `scripts/net/lands.gd` (autoload `Lands`): the server players join is the
|
||||||
|
hub. `Lands.ensure(land)` starts a land server (`--server --land cove
|
||||||
|
--port <hub+1..> --hub-port <hub>`, same config and world folder, output
|
||||||
|
to `land-<land>.log` beside server.json) and waits for it to say it's
|
||||||
|
listening. Hub and land servers talk over TCP on 127.0.0.1, port hub +
|
||||||
|
100, one JSON object per line: hello/ready, the player count, the clock
|
||||||
|
(the hub's, every 10 s: one clock everywhere), how far the tunnel's dug,
|
||||||
|
one-time passes, "stop", "host_left". A land server stops after 2 minutes
|
||||||
|
empty, when the hub stops, or if it loses the hub.
|
||||||
|
- A land server's saves: `Saves.land` makes it load and save its own
|
||||||
|
worldlet's file (`worldlets/cove.json`) in the hub's world folder.
|
||||||
|
Characters (`players/<id>.json`) are shared, and say which land they're
|
||||||
|
in (`land`) and, mid-crossing, where they'll come out (`arrive`).
|
||||||
|
- Crossing (`PlayerNet.cross`): the server saves the character as heading
|
||||||
|
there, makes sure the other server's up, and sends the game
|
||||||
|
`_s2c_travel {land, port, pass}`; the game drops the connection quietly
|
||||||
|
and joins that port with the pass (`Net.join(address, "", pass)`), checked
|
||||||
|
after the identity proof: a land server lets nobody in without one. The
|
||||||
|
world reloads on `Net.arrived`. Joining the hub when your character's in
|
||||||
|
another land sends you on there; a crossing that fails goes back to the
|
||||||
|
title. A hosted game: the host crossing doesn't stop the hub; the host
|
||||||
|
leaving the game from another land stops everything.
|
||||||
|
- The tunnel online: built on the server and in every game. The server
|
||||||
|
decides blows on its faces (`"hit"` finds them by uid), props
|
||||||
|
(`"tunnel_prop"`), going in and out (`"tunnel_go"`: a server teleport;
|
||||||
|
the move check allows the tunnel only from inside it) and the crossings
|
||||||
|
(`"cross"`), and tells everyone how it stands (`_s2c_tunnel`; it's in the
|
||||||
|
shared state a joining player gets, and saved with the Valley).
|
||||||
|
- `tests/test_net_lands.gd`: Alice digs and props online, lights it,
|
||||||
|
crosses to the Cove (bag and day kept) while Bob stays in the Valley,
|
||||||
|
comes back up, then leaves from the Cove and rejoining sends her there.
|
||||||
|
|
||||||
|
*As built in J3b (everyone, everywhere):*
|
||||||
|
- Chat and server messages go to every land (`Chat._all` relays through
|
||||||
|
the hub, `Lands.relay_line`).
|
||||||
|
- The hub keeps the **roster** (everyone on every land, with their land);
|
||||||
|
every land and every player's game gets it (`Net.roster`,
|
||||||
|
`Net.everyone()`): the pause menu's player list and `/players` show
|
||||||
|
where each player is.
|
||||||
|
- Commands typed in another land run **on the hub** (it alone keeps
|
||||||
|
server.json) and answer back there; after one that changes server.json
|
||||||
|
(`/set`, `/ban`, `/admin`, `/friend`...) every land reloads it.
|
||||||
|
`/kick` and `/ban` reach the land the player's in; `/weather`, `/save`
|
||||||
|
and `/backup` reach every land.
|
||||||
|
- **Sleep needs everyone in every land:** land servers report their beds,
|
||||||
|
the hub decides, and every land passes the night together. Getting into
|
||||||
|
bed while others are up: "You're in bed: waiting for everyone else to go
|
||||||
|
to bed (1 of 2 asleep)", and every land hears "Bob is in bed (1 of 2
|
||||||
|
asleep)" in chat.
|
||||||
|
- `tests/test_net_lands.gd` covers it all, Alice in the Cove and Bob in the
|
||||||
|
Valley.
|
||||||
|
|
||||||
|
*As built in J3c:*
|
||||||
|
- A land server that stops (crashed, or killed) tells its players "Lost the
|
||||||
|
connection to the Cove. Join the server again to carry on there."; the
|
||||||
|
hub notices it's gone (even mid-join) and joining again starts it afresh
|
||||||
|
and sends them back (their characters were saved by its autosave).
|
||||||
|
- The extra ports: `Lands.extra_ports()` / `ports_text()` ("UDP 24242 and
|
||||||
|
24243 (the Cove)"), on the Host page, in the "You're hosting" message, in
|
||||||
|
a hub's start-up log, and in docs/HOSTING.md.
|
||||||
|
- Tested in `test_net_lands.gd` (the Cove's server killed, Alice rejoins)
|
||||||
|
and `test_worldlet_pick.gd` (the Host page).
|
||||||
|
|
||||||
*As built in J1:*
|
*As built in J1:*
|
||||||
- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip,
|
- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip,
|
||||||
@@ -218,6 +283,68 @@ The steps:
|
|||||||
- Later: tides (sea level shifting slowly), a raft or rowing boat, and
|
- Later: tides (sea level shifting slowly), a raft or rowing boat, and
|
||||||
driftwood on the beach.
|
driftwood on the beach.
|
||||||
|
|
||||||
|
### The Cove's own dangers and the pirate treasure (Paul, Nov 2026, for 0.6.0)
|
||||||
|
Paused at the Cove for a playtest before the Highlands. The Cove gets its
|
||||||
|
own set pieces instead of copies of the Valley's:
|
||||||
|
- **Set pieces per land**, listed in each worldlet's data (`"places"`). The
|
||||||
|
Valley keeps the wolf den, the barrow, the standing stones (the Old Stag)
|
||||||
|
and the bear; the Cove keeps the wolves only, and gets the wreck and the
|
||||||
|
treasure.
|
||||||
|
- **The wreck:** a beached ship on the rocks at the far end of the bay, its
|
||||||
|
timbers along the sand (driftwood). Its captain's sea chest: some nails,
|
||||||
|
rope, coins and a **torn treasure map**.
|
||||||
|
- **The map:** reading it puts an X on your N map: a mound of loose earth
|
||||||
|
by a marker stone in the Cove's hills (your own spot: see below).
|
||||||
|
- **The dig:** a pickaxe, many blows, turns up the **pirate chest**: a
|
||||||
|
**ship's compass** (what the boat will need to sail to the Highlands: the
|
||||||
|
next crossing's gate, J2), a **spyglass** (zooms your view while held),
|
||||||
|
**pirate gold**, and a **cutlass** (quick, harder-hitting than the iron
|
||||||
|
spear).
|
||||||
|
- **One treasure each, each in its own place** (online too): the wreck's
|
||||||
|
chest gives each player their own map, once, and each map's X is
|
||||||
|
somewhere different: picked from the world's seed and the player's id
|
||||||
|
(so it's fixed for them), among dry, gentle spots in the Cove's hills
|
||||||
|
away from the other places. You only see your own mound; the server knows
|
||||||
|
every player's spot and what each has found, and checks the right one
|
||||||
|
when you dig.
|
||||||
|
- **Gold** is a **trophy you can place** in your hut (a heap of coins) for
|
||||||
|
now; what else it's for waits until there's a trader.
|
||||||
|
- **The drowned crew:** the treasure's guards (the Cove's barrow guards).
|
||||||
|
Two or three rise from the shallows round the dig at night, or as the
|
||||||
|
chest comes up, for whoever's digging. Slow, hit hard, unafraid of fire,
|
||||||
|
they drag at you in the water.
|
||||||
|
- **Crabs** on the beach at dusk (pinch if trodden on; food), and **gulls**,
|
||||||
|
thieves like the crows (fish off drying racks, things dropped on sand).
|
||||||
|
- **The boat** (J2's first part): driftwood, seagrass rope and pitch (pine
|
||||||
|
resin and salt), at a workbench, placed at the pier. One each, moored,
|
||||||
|
yours and your friends'. Setting sail says the Highlands are uncharted
|
||||||
|
for now.
|
||||||
|
- **Report a bug** in the pause menu, and a short playtest guide.
|
||||||
|
- Order: set pieces per land, the wreck and map, the dig and the drowned
|
||||||
|
crew, crabs and gulls, the boat, report a bug; then 0.6.0 (protocol 18).
|
||||||
|
|
||||||
|
**Built (Nov 2026), and where it differs from the plan above:**
|
||||||
|
- Places: `"places"` in `data/worldlets/*.json` (`world.has_place()`); the
|
||||||
|
Cove's extra wildlife in `"wildlife"` (`world.has_wildlife()`).
|
||||||
|
- The wreck (`scripts/world/wreck.gd`): the sea chest gives a map, 4 nails
|
||||||
|
and 2 salt (no rope or coins: gold comes from the dig). Each X is one of
|
||||||
|
12 hiding spots picked from the seed (`world.treasure_spots()`), chosen
|
||||||
|
per player by their id.
|
||||||
|
- The dig (`treasure.gd`, `treasure_mound.gd`, `crew_site.gd`): 18 HP, any
|
||||||
|
pickaxe; three drowned rise round the mound itself (not from the
|
||||||
|
shallows), at the first blow by night or as the chest comes up by day.
|
||||||
|
- Crabs (`crab.gd`, `shore.gd`): up to 5 per player on the sand from 17:30
|
||||||
|
to 06:30, pinch for 5 within about 2 m, let you go past 6 m; crab meat.
|
||||||
|
Gulls (`gull.gd`, `thieves.gd`): food on the sand, and fish on a smoking
|
||||||
|
rack; they take one and fly off (only food, not tools left on the sand).
|
||||||
|
- The boat (`moorings.gd`, `boat.gd`, `mooring_post.gd`): a rowing boat
|
||||||
|
item (12 driftwood, 4 rope, 3 pitch, 6 nails), tied up at the post at the
|
||||||
|
end of the pier rather than placed freely: 6 berths, one boat each, saved
|
||||||
|
with the Cove. Pine resin drops from felled pines in every land.
|
||||||
|
- Report a bug (`scripts/core/bug_report.gd`): opens the repo's new-issue
|
||||||
|
page filled in (the tester posts it, signed in); no token in the game.
|
||||||
|
Testers' notes: `docs/PLAYTEST.md`.
|
||||||
|
|
||||||
## 6. Done already (groundwork)
|
## 6. Done already (groundwork)
|
||||||
- `world.water_at(x, z)` returns `{type, surface, depth}` for the lake and
|
- `world.water_at(x, z)` returns `{type, surface, depth}` for the lake and
|
||||||
the sea, and fishing uses it instead of `terrain.water_level`.
|
the sea, and fishing uses it instead of `terrain.water_level`.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ custom_features=""
|
|||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter="data/*.json, data/worldlets/*.json"
|
include_filter="data/*.json, data/worldlets/*.json"
|
||||||
exclude_filter="tests/*, docs/*, tools/*, server/*"
|
exclude_filter="tests/*, docs/*, tools/*, server/*"
|
||||||
export_path="build/macos/Emberwild.zip"
|
export_path="build/macos/Cinderwild.zip"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
encryption_include_filters=""
|
encryption_include_filters=""
|
||||||
encryption_exclude_filters=""
|
encryption_exclude_filters=""
|
||||||
@@ -26,11 +26,11 @@ custom_template/release=""
|
|||||||
debug/export_console_wrapper=0
|
debug/export_console_wrapper=0
|
||||||
application/icon=""
|
application/icon=""
|
||||||
application/icon_interpolation=4
|
application/icon_interpolation=4
|
||||||
application/bundle_identifier="com.pbailey.emberwild"
|
application/bundle_identifier="com.pbailey.cinderwild"
|
||||||
application/signature=""
|
application/signature=""
|
||||||
application/app_category="Games"
|
application/app_category="Games"
|
||||||
application/short_version="0.4.1"
|
application/short_version="0.6.2"
|
||||||
application/version="0.4.1"
|
application/version="0.6.2"
|
||||||
application/copyright=""
|
application/copyright=""
|
||||||
display/high_res=true
|
display/high_res=true
|
||||||
codesign/codesign=1
|
codesign/codesign=1
|
||||||
@@ -66,7 +66,7 @@ custom_features=""
|
|||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter="data/*.json, data/worldlets/*.json"
|
include_filter="data/*.json, data/worldlets/*.json"
|
||||||
exclude_filter="tests/*, docs/*, tools/*, server/*"
|
exclude_filter="tests/*, docs/*, tools/*, server/*"
|
||||||
export_path="build/windows/Emberwild/Emberwild.exe"
|
export_path="build/windows/Cinderwild/Cinderwild.exe"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
encryption_include_filters=""
|
encryption_include_filters=""
|
||||||
encryption_exclude_filters=""
|
encryption_exclude_filters=""
|
||||||
@@ -89,11 +89,11 @@ 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.4.1"
|
application/file_version="0.6.2"
|
||||||
application/product_version="0.4.1"
|
application/product_version="0.6.2"
|
||||||
application/company_name=""
|
application/company_name=""
|
||||||
application/product_name="Emberwild"
|
application/product_name="Cinderwild"
|
||||||
application/file_description="Emberwild"
|
application/file_description="Cinderwild"
|
||||||
application/copyright=""
|
application/copyright=""
|
||||||
application/trademarks=""
|
application/trademarks=""
|
||||||
application/export_angle=0
|
application/export_angle=0
|
||||||
|
|||||||
2
play.sh
2
play.sh
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Starts Emberwild (the game, with its window and title screen).
|
# Starts Cinderwild (the game, with its window and title screen).
|
||||||
#
|
#
|
||||||
# ./play.sh
|
# ./play.sh
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
|||||||
|
|
||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="Emberwild"
|
config/name="Cinderwild"
|
||||||
config/description="Low-poly survival game — Valheim look, Rust depth, self-hosted, moddable."
|
config/description="Low-poly survival game — Valheim look, Rust depth, self-hosted, moddable."
|
||||||
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.4.1"
|
config/version="0.6.2"
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ Hints="*res://scripts/core/hints.gd"
|
|||||||
Updates="*res://scripts/core/updates.gd"
|
Updates="*res://scripts/core/updates.gd"
|
||||||
Saves="*res://scripts/core/saves.gd"
|
Saves="*res://scripts/core/saves.gd"
|
||||||
Net="*res://scripts/net/net.gd"
|
Net="*res://scripts/net/net.gd"
|
||||||
|
Lands="*res://scripts/net/lands.gd"
|
||||||
WorldNet="*res://scripts/net/world_net.gd"
|
WorldNet="*res://scripts/net/world_net.gd"
|
||||||
PlayerNet="*res://scripts/net/player_net.gd"
|
PlayerNet="*res://scripts/net/player_net.gd"
|
||||||
CreatureNet="*res://scripts/net/creature_net.gd"
|
CreatureNet="*res://scripts/net/creature_net.gd"
|
||||||
@@ -92,9 +93,9 @@ weather_wind={
|
|||||||
"value": 1.0
|
"value": 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
[emberwild]
|
[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:
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ extends Node
|
|||||||
## Autoload "Boot", first in the list: one-off start-up housekeeping that has
|
## Autoload "Boot", first in the list: one-off start-up housekeeping that has
|
||||||
## to happen before anything reads user://.
|
## to happen before anything reads user://.
|
||||||
##
|
##
|
||||||
## The game was called "Valheimlike" until it became "Emberwild". Godot names
|
## The game has been renamed twice: "Valheimlike", then "Emberwild" (0.1.0 to
|
||||||
## the user-data folder after the project, so the rename moved it from
|
## 0.6.0), now "Cinderwild". Godot names the user-data folder after the
|
||||||
## .../app_userdata/Valheimlike to .../app_userdata/Emberwild. The first time
|
## project, so each rename moved it (.../app_userdata/<name>). The first time
|
||||||
## Emberwild starts, this copies saved worlds, your identity (key, name, look)
|
## Cinderwild starts, this copies saved worlds, your identity (key, name,
|
||||||
## and server settings across. It copies rather than moves, so the old folder
|
## look), settings and server settings across from the newest old folder there
|
||||||
## stays as a backup, and it never overwrites anything already in the new
|
## is (it already holds what came from the one before). It copies rather than moves, so the old folders stay
|
||||||
## folder. Test folders (test_*) and logs are skipped.
|
## as a backup, and it never overwrites anything already in the new folder.
|
||||||
|
## Test folders (test_*) and logs are skipped.
|
||||||
|
|
||||||
const OLD_NAME := "Valheimlike"
|
const OLD_NAMES := ["Emberwild", "Valheimlike"] ## newest first
|
||||||
const MARKER := "migrated_from_valheimlike.txt"
|
const MARKER := "migrated_from_%s.txt"
|
||||||
const SKIP := ["logs", "shader_cache", "vulkan", "test_worlds", "test_net"]
|
const SKIP := ["logs", "shader_cache", "vulkan", "test_worlds", "test_net"]
|
||||||
|
|
||||||
var copied := 0
|
var copied := 0
|
||||||
@@ -22,14 +23,25 @@ func _enter_tree() -> void:
|
|||||||
## Returns the number of files copied (0 if there was nothing to do).
|
## Returns the number of files copied (0 if there was nothing to do).
|
||||||
func migrate_user_data() -> int:
|
func migrate_user_data() -> int:
|
||||||
var new_dir := OS.get_user_data_dir()
|
var new_dir := OS.get_user_data_dir()
|
||||||
var old_dir := new_dir.get_base_dir().path_join(OLD_NAME)
|
for old_name in OLD_NAMES:
|
||||||
|
var old_dir := new_dir.get_base_dir().path_join(old_name)
|
||||||
|
if DirAccess.dir_exists_absolute(old_dir) and old_dir != new_dir:
|
||||||
|
# Only the newest: it already holds whatever came across from the
|
||||||
|
# one before (a world deleted since shouldn't come back).
|
||||||
|
return migrate_from(old_dir, new_dir, old_name)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
## Copies `old_dir` into `new_dir` once (a marker file in `new_dir` says it's
|
||||||
|
## been done). Returns the number of files copied.
|
||||||
|
func migrate_from(old_dir: String, new_dir: String, old_name: String) -> int:
|
||||||
if new_dir == old_dir or not DirAccess.dir_exists_absolute(old_dir):
|
if new_dir == old_dir or not DirAccess.dir_exists_absolute(old_dir):
|
||||||
return 0
|
return 0
|
||||||
if FileAccess.file_exists(new_dir.path_join(MARKER)):
|
var marker := new_dir.path_join(MARKER % old_name.to_lower())
|
||||||
|
if FileAccess.file_exists(marker):
|
||||||
return 0
|
return 0
|
||||||
copied = 0
|
copied = 0
|
||||||
_copy_tree(old_dir, new_dir)
|
_copy_tree(old_dir, new_dir)
|
||||||
var f := FileAccess.open(new_dir.path_join(MARKER), FileAccess.WRITE)
|
var f := FileAccess.open(marker, FileAccess.WRITE)
|
||||||
if f != null:
|
if f != null:
|
||||||
f.store_string("Copied %d files from %s on %s. The old folder was left as it was.\n" % [
|
f.store_string("Copied %d files from %s on %s. The old folder was left as it was.\n" % [
|
||||||
copied, old_dir, Time.get_datetime_string_from_system()])
|
copied, old_dir, Time.get_datetime_string_from_system()])
|
||||||
@@ -43,6 +55,8 @@ func _copy_tree(from: String, to: String) -> void:
|
|||||||
if da == null:
|
if da == null:
|
||||||
return
|
return
|
||||||
for f in da.get_files():
|
for f in da.get_files():
|
||||||
|
if f.begins_with("migrated_from_"):
|
||||||
|
continue # (the old folder's own markers)
|
||||||
var dst := to.path_join(f)
|
var dst := to.path_join(f)
|
||||||
if not FileAccess.file_exists(dst) and DirAccess.copy_absolute(from.path_join(f), dst) == OK:
|
if not FileAccess.file_exists(dst) and DirAccess.copy_absolute(from.path_join(f), dst) == OK:
|
||||||
copied += 1
|
copied += 1
|
||||||
|
|||||||
88
scripts/core/bug_report.gd
Normal file
88
scripts/core/bug_report.gd
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Report a bug (0.6.0, the playtest; pause menu): opens a new issue on the
|
||||||
|
## Cinderwild repo in your browser, already filled in with a title to edit,
|
||||||
|
## a few questions to answer, and the details we'd ask for anyway (version,
|
||||||
|
## computer, single-player or online, which land, the day and time, where you
|
||||||
|
## are). You read it, add what happened, and press Create Issue: nothing is
|
||||||
|
## sent from the game itself. Posting needs an account on the Gitea server.
|
||||||
|
##
|
||||||
|
## The game's log (Godot writes it to user://logs/godot.log) helps with a
|
||||||
|
## crash or an error: the pause menu shows where it is and opens its folder,
|
||||||
|
## to attach the file to the issue.
|
||||||
|
|
||||||
|
const REPO := "https://git.psbailey.uk/Admin/Cinderwild"
|
||||||
|
|
||||||
|
## An autoload (Net, Updates), looked up at run time: this script is also
|
||||||
|
## loaded by tests that start before the autoloads are compiled in.
|
||||||
|
static func _al(n: String) -> Node:
|
||||||
|
return (Engine.get_main_loop() as SceneTree).root.get_node_or_null(n)
|
||||||
|
|
||||||
|
## Where this game's log is, on disk.
|
||||||
|
static func log_path() -> String:
|
||||||
|
return ProjectSettings.globalize_path("user://logs/godot.log")
|
||||||
|
|
||||||
|
## The same, with your home folder as "~" (so your user name isn't in an
|
||||||
|
## issue anyone can read).
|
||||||
|
static func log_path_short() -> String:
|
||||||
|
var home := OS.get_environment("HOME") if OS.get_name() != "Windows" else OS.get_environment("USERPROFILE")
|
||||||
|
var p := log_path()
|
||||||
|
if not home.is_empty() and p.begins_with(home):
|
||||||
|
p = "~" + p.substr(home.length())
|
||||||
|
return p
|
||||||
|
|
||||||
|
static func log_dir() -> String:
|
||||||
|
return ProjectSettings.globalize_path("user://logs")
|
||||||
|
|
||||||
|
## How you're playing: "single-player", or online with the server's rules.
|
||||||
|
static func mode(world: Node) -> String:
|
||||||
|
if world == null or not world.has_method("is_online") or not world.is_online():
|
||||||
|
return "single-player"
|
||||||
|
var net := _al("Net")
|
||||||
|
var rules := "PvP" if bool(net.server_info.get("pvp", false)) else "co-op"
|
||||||
|
if bool(net.server_info.get("raiding", false)):
|
||||||
|
rules += ", raiding"
|
||||||
|
return "online (%s%s)" % [rules, ", hosting" if net.hosting else ""]
|
||||||
|
|
||||||
|
## The details, one per line (what's in the issue, and what "Copy" copies).
|
||||||
|
static func details(world: Node) -> Dictionary:
|
||||||
|
var d := {
|
||||||
|
"Version": "Cinderwild %s (protocol %d)" % [String(ProjectSettings.get_setting("application/config/version", "?")), int(_al("Net").PROTOCOL)],
|
||||||
|
"Computer": "%s %s" % [OS.get_name(), OS.get_version()],
|
||||||
|
"Graphics": "%s (%s)" % [RenderingServer.get_video_adapter_name(), RenderingServer.get_current_rendering_method()],
|
||||||
|
"Mode": mode(world),
|
||||||
|
}
|
||||||
|
if world != null and is_instance_valid(world) and world.has_method("worldlet_id"):
|
||||||
|
d["Land"] = preload("res://scripts/world/terrain.gd").worldlet_name(String(world.worldlet_id()))
|
||||||
|
if world.get("day_night") != null:
|
||||||
|
var h: float = world.day_night.hours()
|
||||||
|
d["Time"] = "day %d, %02d:%02d" % [int(world.day_night.day), int(h), int(fmod(h, 1.0) * 60.0)]
|
||||||
|
if world.get("player") != null and is_instance_valid(world.player):
|
||||||
|
var p: Vector3 = world.player.global_position
|
||||||
|
d["Where"] = "%.0f, %.0f, %.0f" % [p.x, p.y, p.z]
|
||||||
|
if world.get("terrain") != null:
|
||||||
|
d["Seed"] = str(world.terrain.noise_seed)
|
||||||
|
return d
|
||||||
|
|
||||||
|
static func details_text(world: Node) -> String:
|
||||||
|
var d := details(world)
|
||||||
|
var lines: Array[String] = []
|
||||||
|
for k in d:
|
||||||
|
lines.append("- %s: %s" % [k, d[k]])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
static func body(world: Node) -> String:
|
||||||
|
return "\n".join([
|
||||||
|
"**What happened?**", "", "",
|
||||||
|
"**What did you expect to happen?**", "", "",
|
||||||
|
"**How can we make it happen again?** (what you did just before)", "1. ", "",
|
||||||
|
"**Screenshot or log** (optional: drag a screenshot here, or the log file from %s)" % log_path_short(), "",
|
||||||
|
"---", details_text(world)])
|
||||||
|
|
||||||
|
## The new-issue page, filled in.
|
||||||
|
static func issue_url(world: Node, title: String = "") -> String:
|
||||||
|
var t := title if not title.is_empty() else "Bug: "
|
||||||
|
return "%s/issues/new?title=%s&body=%s" % [REPO, t.uri_encode(), body(world).uri_encode()]
|
||||||
|
|
||||||
|
## Opens it in the browser. Returns false when the browser couldn't be opened.
|
||||||
|
static func open(world: Node) -> bool:
|
||||||
|
return OS.shell_open(issue_url(world)) == OK
|
||||||
1
scripts/core/bug_report.gd.uid
Normal file
1
scripts/core/bug_report.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://tnlssmningwq
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -102,8 +102,14 @@ func read_meta(id: String) -> Dictionary:
|
|||||||
# saves are unchanged; the others are worldlets/<id>.json. meta.json's
|
# saves are unchanged; the others are worldlets/<id>.json. meta.json's
|
||||||
# "worldlet" is where you are now, so that's the one loaded.
|
# "worldlet" is where you are now, so that's the one loaded.
|
||||||
|
|
||||||
|
## A land server (J3, `--land cove`) runs one worldlet of a server's world:
|
||||||
|
## this is that worldlet ("" = whatever the world's meta says).
|
||||||
|
var land := ""
|
||||||
|
|
||||||
## The worldlet world `id` is being played in now.
|
## The worldlet world `id` is being played in now.
|
||||||
func worldlet_of(id: String) -> String:
|
func worldlet_of(id: String) -> String:
|
||||||
|
if not land.is_empty():
|
||||||
|
return land
|
||||||
return String(read_meta(id).get("worldlet", "valley"))
|
return String(read_meta(id).get("worldlet", "valley"))
|
||||||
|
|
||||||
## The worldlet world `id` began in (its world.json).
|
## The worldlet world `id` began in (its world.json).
|
||||||
|
|||||||
@@ -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},
|
||||||
@@ -87,6 +92,8 @@ const SOUNDS := {
|
|||||||
"guard_rattle": {"set": [C + "guard_rattle_%d.ogg", 2], "db": -2.0, "pitch": [0.85, 1.1], "reach": 25.0},
|
"guard_rattle": {"set": [C + "guard_rattle_%d.ogg", 2], "db": -2.0, "pitch": [0.85, 1.1], "reach": 25.0},
|
||||||
"crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0},
|
"crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0},
|
||||||
"fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0},
|
"fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0},
|
||||||
|
"gull_cry": {"set": [C + "gull_cry_%d.ogg", 2], "db": -5.0, "pitch": [0.9, 1.12], "reach": 50.0},
|
||||||
|
"crab_click": {"set": [C + "crab_click_%d.ogg", 2], "db": -6.0, "pitch": [0.85, 1.15], "reach": 14.0},
|
||||||
}
|
}
|
||||||
const BUSES := ["Music", "SFX", "Ambience", "Voice"]
|
const BUSES := ["Music", "SFX", "Ambience", "Voice"]
|
||||||
const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7, "Voice": 1.0}
|
const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7, "Voice": 1.0}
|
||||||
@@ -194,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)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## The update check (docs/ROADMAP.md, M4 "Updates", first step): is there a
|
## The update check (docs/ROADMAP.md, M4 "Updates", first step): is there a
|
||||||
## newer Emberwild than this one? Asked once at start-up, of the feed in
|
## newer Cinderwild than this one? Asked once at start-up, of the feed in
|
||||||
## Project Settings (`emberwild/updates/feed`). The feed is either
|
## Project Settings (`cinderwild/updates/feed`). The feed is either
|
||||||
## - a release API answer (Gitea, Forgejo or GitHub `releases/latest`): its
|
## - a release API answer (Gitea, Forgejo or GitHub `releases/latest`): its
|
||||||
## `version.json` asset is fetched next, and its other assets are the
|
## `version.json` asset is fetched next, and its other assets are the
|
||||||
## downloads, by file name; or
|
## downloads, by file name; or
|
||||||
## - a `version.json` itself:
|
## - a `version.json` itself:
|
||||||
## {"version": "0.2.0", "protocol": 13, "notes": "...", "page": "https://...",
|
## {"version": "0.2.0", "protocol": 13, "notes": "...", "page": "https://...",
|
||||||
## "downloads": {"macos": {"file": "Emberwild-0.2.0-macos.zip", "sha256": "...",
|
## "downloads": {"macos": {"file": "Cinderwild-0.2.0-macos.zip", "sha256": "...",
|
||||||
## "size": 61164958, "url": "(optional; else the asset)"},
|
## "size": 61164958, "url": "(optional; else the asset)"},
|
||||||
## "windows": {...}}}
|
## "windows": {...}}}
|
||||||
## tools/export.sh writes that file next to the zips, for the release.
|
## tools/export.sh writes that file next to the zips, for the release.
|
||||||
@@ -22,7 +22,7 @@ signal checked(state: String)
|
|||||||
const TIMEOUT := 10.0
|
const TIMEOUT := 10.0
|
||||||
|
|
||||||
var version: String = String(ProjectSettings.get_setting("application/config/version", "0.0.0"))
|
var version: String = String(ProjectSettings.get_setting("application/config/version", "0.0.0"))
|
||||||
var feed: String = String(ProjectSettings.get_setting("emberwild/updates/feed", ""))
|
var feed: String = String(ProjectSettings.get_setting("cinderwild/updates/feed", ""))
|
||||||
var prefs_path := "user://updates.json"
|
var prefs_path := "user://updates.json"
|
||||||
var enabled := true
|
var enabled := true
|
||||||
## idle | off | checking | current | available | failed
|
## idle | off | checking | current | available | failed
|
||||||
@@ -101,7 +101,7 @@ func _finish(s: String) -> void:
|
|||||||
if s == "failed":
|
if s == "failed":
|
||||||
print("[updates] couldn't check for updates: ", error)
|
print("[updates] couldn't check for updates: ", error)
|
||||||
elif s == "available":
|
elif s == "available":
|
||||||
print("[updates] Emberwild %s is out (this is %s)" % [latest_version(), version])
|
print("[updates] Cinderwild %s is out (this is %s)" % [latest_version(), version])
|
||||||
checked.emit(s)
|
checked.emit(s)
|
||||||
|
|
||||||
func _get_json(url: String) -> Variant:
|
func _get_json(url: String) -> Variant:
|
||||||
@@ -112,7 +112,7 @@ func _get_json(url: String) -> Variant:
|
|||||||
var http := HTTPRequest.new()
|
var http := HTTPRequest.new()
|
||||||
http.timeout = TIMEOUT
|
http.timeout = TIMEOUT
|
||||||
add_child(http)
|
add_child(http)
|
||||||
var err := http.request(url, ["Accept: application/json", "User-Agent: Emberwild/" + version])
|
var err := http.request(url, ["Accept: application/json", "User-Agent: Cinderwild/" + version])
|
||||||
if err != OK:
|
if err != OK:
|
||||||
http.queue_free()
|
http.queue_free()
|
||||||
error = "couldn't ask %s (%d)" % [url, err]
|
error = "couldn't ask %s (%d)" % [url, err]
|
||||||
|
|||||||
113
scripts/creatures/crab.gd
Normal file
113
scripts/creatures/crab.gd
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
extends "res://scripts/creatures/creature.gd"
|
||||||
|
## A crab (0.6.0, the Cove only): shore.gd brings them up out of the sea
|
||||||
|
## onto the beach at dusk. They scuttle about the sand sideways and keep to
|
||||||
|
## it. Walk within NIP of one and it comes at you, claws up, and pinches
|
||||||
|
## (not much, but it adds up with three of them about). Leave it NIP_GIVE_UP
|
||||||
|
## behind and it forgets you. Hit one hard enough and it runs for it. At
|
||||||
|
## dawn they all go back into the sea. A dead one gives crab meat.
|
||||||
|
|
||||||
|
const NIP := 2.2 ## this close and it comes for you
|
||||||
|
const NIP_GIVE_UP := 6.0 ## this far and it lets you go
|
||||||
|
const HOME_FOR := 40.0 ## at dawn: this long to reach the water before it simply goes
|
||||||
|
|
||||||
|
func interact_prompt(p: Node) -> String:
|
||||||
|
if is_dead():
|
||||||
|
return "Crab [E] Crack it open"
|
||||||
|
match state:
|
||||||
|
"charge": return "Crab (claws up!)"
|
||||||
|
"home": return "Crab (heading back to the sea)"
|
||||||
|
return "Crab (keep your distance, or it'll pinch)"
|
||||||
|
|
||||||
|
## Daytime: back to the sea.
|
||||||
|
func _daytime() -> bool:
|
||||||
|
var h: float = world.day_night.hours()
|
||||||
|
return h >= 6.5 and h < 17.5
|
||||||
|
|
||||||
|
## Which way the sea is: straight out from the middle of the bay.
|
||||||
|
func _seaward() -> Vector3:
|
||||||
|
var a := deg_to_rad(float(terrain.beach_angle_deg))
|
||||||
|
return Vector3(cos(a), 0.0, sin(a))
|
||||||
|
|
||||||
|
func _think(dt: float) -> void:
|
||||||
|
if state != "home" and state != "flee" and _daytime():
|
||||||
|
state = "home"
|
||||||
|
_state_t = HOME_FOR
|
||||||
|
var pp: Vector3 = player.global_position
|
||||||
|
var d := global_position.distance_to(pp)
|
||||||
|
match state:
|
||||||
|
"home":
|
||||||
|
var to := _seaward()
|
||||||
|
steer(atan2(-to.x, -to.z))
|
||||||
|
_state_t -= dt
|
||||||
|
if _state_t <= 0.0 or global_position.y < terrain.water_level - 0.25:
|
||||||
|
queue_free() # (into the waves)
|
||||||
|
return
|
||||||
|
"idle", "wander":
|
||||||
|
if d < NIP:
|
||||||
|
state = "charge"
|
||||||
|
_aggro = def.aggro
|
||||||
|
_attack_cd = 0.5 # (a moment to see it coming)
|
||||||
|
return
|
||||||
|
"charge":
|
||||||
|
if d < NIP_GIVE_UP:
|
||||||
|
_aggro = def.aggro # (it keeps on at you while you're close)
|
||||||
|
else:
|
||||||
|
_aggro = 0.0
|
||||||
|
super._think(dt)
|
||||||
|
|
||||||
|
func _desired_speed() -> float:
|
||||||
|
if state == "home":
|
||||||
|
return def.walk * 1.6
|
||||||
|
return super._desired_speed()
|
||||||
|
|
||||||
|
## Keeps to the sand (and out of the water) until it's going home.
|
||||||
|
func _blocked(dir: Vector3) -> bool:
|
||||||
|
var probe := global_position + dir * (0.8 + speed * 0.15)
|
||||||
|
if not terrain.inside_boundary(probe.x, probe.z, 4.0):
|
||||||
|
return true
|
||||||
|
if state == "home":
|
||||||
|
return false # (straight down the beach and into the sea)
|
||||||
|
if not terrain.on_sand(probe.x, probe.z):
|
||||||
|
return true
|
||||||
|
return super._blocked(dir)
|
||||||
|
|
||||||
|
## Hit: it fights back while it can, then runs.
|
||||||
|
func _react(from: Vector3) -> void:
|
||||||
|
if hp > def.hp * 0.35:
|
||||||
|
state = "charge"
|
||||||
|
_aggro = def.aggro
|
||||||
|
_attack_cd = 0.3
|
||||||
|
else:
|
||||||
|
_start_flee(from)
|
||||||
|
_state_t = 5.0
|
||||||
|
|
||||||
|
## Sideways, legs a-flicker; the claws come up when it's after you.
|
||||||
|
func _animate(dt: float) -> void:
|
||||||
|
_visual.rotation.y = PI * 0.5 # (it walks sideways)
|
||||||
|
if is_dead():
|
||||||
|
return
|
||||||
|
var k := clampf(speed / maxf(def.run, 0.01), 0.0, 1.0)
|
||||||
|
_phase += dt * (6.0 + speed * 14.0)
|
||||||
|
for i in range(_legs.size()):
|
||||||
|
var l: Node3D = _legs[i]
|
||||||
|
var s := -1.0 if l.position.x < 0.0 else 1.0
|
||||||
|
l.rotation.z = s * (1.0 + sin(_phase + i * 1.9) * (0.08 + 0.3 * k))
|
||||||
|
l.rotation.x = cos(_phase + i * 1.9) * 0.25 * k
|
||||||
|
var up := 0.12 if state == "charge" or (puppet and speed > 1.5) else 0.0
|
||||||
|
_visual.rotation.x = lerpf(_visual.rotation.x, -up, minf(dt * 6.0, 1.0))
|
||||||
|
_visual.position.y = absf(sin(_phase * 0.5)) * 0.01 * k
|
||||||
|
|
||||||
|
## A crab doesn't topple onto its side: it ends up on its back.
|
||||||
|
func _die(_from: Vector3) -> void:
|
||||||
|
state = "dead"
|
||||||
|
speed = 0.0
|
||||||
|
hp = 0.0
|
||||||
|
_flash = 0.0
|
||||||
|
_set_material(_mat)
|
||||||
|
remove_from_group("creature")
|
||||||
|
add_to_group("carcass")
|
||||||
|
collision_layer = 2
|
||||||
|
_visual.rotation = Vector3(0.0, PI * 0.5, PI)
|
||||||
|
_visual.position.y = 0.1
|
||||||
|
if not puppet:
|
||||||
|
died.emit(self)
|
||||||
1
scripts/creatures/crab.gd.uid
Normal file
1
scripts/creatures/crab.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://kqumi2x6lfai
|
||||||
@@ -54,12 +54,28 @@ const KINDS := {
|
|||||||
"fox": {"name": "Fox", "hp": 25.0, "walk": 1.5, "run": 7.6, "turn": 7.0,
|
"fox": {"name": "Fox", "hp": 25.0, "walk": 1.5, "run": 7.6, "turn": 7.0,
|
||||||
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
||||||
"drops": {"raw_meat": [1, 1], "hide": [1, 1]}},
|
"drops": {"raw_meat": [1, 1], "hide": [1, 1]}},
|
||||||
|
# 0.6.0, the Cove only: crabs come up the beach at dusk and pinch anyone
|
||||||
|
# who walks too close (crab.gd, shore.gd); gulls are the Cove's crows,
|
||||||
|
# after food left on the sand and fish on a smoking rack (gull.gd).
|
||||||
|
"crab": {"name": "Crab", "hp": 14.0, "walk": 0.9, "run": 2.6, "turn": 7.0,
|
||||||
|
"notice": 0.0, "notice_loud": 0.0, "calm": 20.0,
|
||||||
|
"damage": 5.0, "attack_range": 1.7, "attack_cd": 1.3, "aggro": 5.0,
|
||||||
|
"drops": {"crab_meat": [1, 2]}},
|
||||||
|
"gull": {"name": "Gull", "hp": 8.0, "walk": 1.0, "run": 11.0, "turn": 8.0,
|
||||||
|
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
||||||
|
"drops": {"feather": [1, 3]}},
|
||||||
# The barrow's skeletal guards (guard.gd, barrow.gd). Slow (you can walk
|
# The barrow's skeletal guards (guard.gd, barrow.gd). Slow (you can walk
|
||||||
# away from one) but they hit hard; bone, so blunt weapons do +50%.
|
# away from one) but they hit hard; bone, so blunt weapons do +50%.
|
||||||
"guard": {"name": "Barrow Guard", "hp": 80.0, "walk": 1.4, "run": 3.4, "turn": 4.0,
|
"guard": {"name": "Barrow Guard", "hp": 80.0, "walk": 1.4, "run": 3.4, "turn": 4.0,
|
||||||
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
||||||
"damage": 20.0, "attack_range": 2.0, "attack_cd": 2.0, "bone": true,
|
"damage": 20.0, "attack_range": 2.0, "attack_cd": 2.0, "bone": true,
|
||||||
"drops": {"iron_nugget": [1, 2]}},
|
"drops": {"iron_nugget": [1, 2]}},
|
||||||
|
# 0.6.0: the drowned crew (guard.gd, at a treasure dig: treasure.gd). The
|
||||||
|
# Cove's barrow guards: slow, hard-hitting, unafraid of fire.
|
||||||
|
"drowned": {"name": "Drowned Sailor", "hp": 70.0, "walk": 1.3, "run": 3.1, "turn": 4.0,
|
||||||
|
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
||||||
|
"damage": 16.0, "attack_range": 2.0, "attack_cd": 1.8, "bone": true,
|
||||||
|
"drops": {"salt": [0, 1]}},
|
||||||
# P5: the Old Stag (old_stag.gd), called by the standing stones, and the
|
# P5: the Old Stag (old_stag.gd), called by the standing stones, and the
|
||||||
# spirit wolves it summons (they just run at you: the base "charge").
|
# spirit wolves it summons (they just run at you: the base "charge").
|
||||||
"old_stag": {"name": "Old Stag", "hp": 900.0, "walk": 2.0, "run": 13.0, "turn": 3.0,
|
"old_stag": {"name": "Old Stag", "hp": 900.0, "walk": 2.0, "run": 13.0, "turn": 3.0,
|
||||||
@@ -76,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
|
||||||
@@ -140,7 +163,7 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
|
|||||||
var eyes := MeshInstance3D.new()
|
var eyes := MeshInstance3D.new()
|
||||||
eyes.mesh = parts.eyes
|
eyes.mesh = parts.eyes
|
||||||
var em := StandardMaterial3D.new()
|
var em := StandardMaterial3D.new()
|
||||||
var eye: Color = {"alpha": Color(1.0, 0.18, 0.1), "guard": Color(0.45, 0.8, 1.0), "old_stag": Color(0.55, 0.95, 1.0),
|
var eye: Color = {"alpha": Color(1.0, 0.18, 0.1), "guard": Color(0.45, 0.8, 1.0), "drowned": Color(0.5, 1.0, 0.6), "old_stag": Color(0.55, 0.95, 1.0),
|
||||||
"spirit_wolf": Color(0.55, 0.95, 1.0)}.get(k, Color(1.0, 0.75, 0.3))
|
"spirit_wolf": Color(0.55, 0.95, 1.0)}.get(k, Color(1.0, 0.75, 0.3))
|
||||||
em.albedo_color = eye.lerp(Color.WHITE, 0.2)
|
em.albedo_color = eye.lerp(Color.WHITE, 0.2)
|
||||||
em.emission_enabled = true
|
em.emission_enabled = true
|
||||||
@@ -164,9 +187,9 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
|
|||||||
cap.height = maxf(parts.height, parts.radius * 2.0 + 0.01)
|
cap.height = maxf(parts.height, parts.radius * 2.0 + 0.01)
|
||||||
cs.shape = cap
|
cs.shape = cap
|
||||||
cs.position = Vector3(0, parts.height * 0.5, 0)
|
cs.position = Vector3(0, parts.height * 0.5, 0)
|
||||||
if k != "rabbit" and k != "guard": # (rabbits are round, guards stand upright)
|
if not k in ["rabbit", "guard", "drowned"]: # (rabbits are round, guards stand upright)
|
||||||
# Long bodies: lay the capsule along the spine.
|
# Long bodies: lay the capsule along the spine.
|
||||||
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0, "fox": 0.8, "crow": 0.3, "old_stag": 3.3, "spirit_wolf": 1.25}.get(k, 1.4)
|
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0, "fox": 0.8, "crow": 0.3, "gull": 0.4, "crab": 0.45, "old_stag": 3.3, "spirit_wolf": 1.25}.get(k, 1.4)
|
||||||
cs.rotation.x = PI * 0.5
|
cs.rotation.x = PI * 0.5
|
||||||
cs.position = Vector3(0, parts.height * 0.55, 0)
|
cs.position = Vector3(0, parts.height * 0.55, 0)
|
||||||
add_child(cs)
|
add_child(cs)
|
||||||
@@ -368,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)
|
||||||
@@ -452,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":
|
||||||
@@ -461,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 := {}
|
||||||
@@ -516,8 +574,11 @@ const VOICES := {
|
|||||||
"boar": {"alert": "boar_grunt", "angry": "boar_grunt", "attack": "boar_grunt", "hurt": "boar_squeal", "idle": "boar_grunt"},
|
"boar": {"alert": "boar_grunt", "angry": "boar_grunt", "attack": "boar_grunt", "hurt": "boar_squeal", "idle": "boar_grunt"},
|
||||||
"old_stag": {"alert": "stag_bellow", "angry": "stag_bellow", "attack": "stag_snort", "hurt": "stag_snort"},
|
"old_stag": {"alert": "stag_bellow", "angry": "stag_bellow", "attack": "stag_snort", "hurt": "stag_snort"},
|
||||||
"guard": {"alert": "guard_rattle", "angry": "guard_rattle", "attack": "guard_rattle"},
|
"guard": {"alert": "guard_rattle", "angry": "guard_rattle", "attack": "guard_rattle"},
|
||||||
|
"drowned": {"alert": "guard_rattle", "angry": "guard_rattle", "attack": "guard_rattle"},
|
||||||
"crow": {"alert": "crow_caw", "hurt": "crow_caw", "idle": "crow_caw"},
|
"crow": {"alert": "crow_caw", "hurt": "crow_caw", "idle": "crow_caw"},
|
||||||
"fox": {"alert": "fox_yip", "hurt": "fox_yip"},
|
"fox": {"alert": "fox_yip", "hurt": "fox_yip"},
|
||||||
|
"gull": {"alert": "gull_cry", "hurt": "gull_cry", "idle": "gull_cry"},
|
||||||
|
"crab": {"alert": "crab_click", "attack": "crab_click"},
|
||||||
}
|
}
|
||||||
## States a creature is in when it's after someone (music.gd's list too).
|
## States a creature is in when it's after someone (music.gd's list too).
|
||||||
const ANGRY := ["charge", "stalk", "lunge", "bash", "stomp", "telegraph", "warn", "raid", "summon"]
|
const ANGRY := ["charge", "stalk", "lunge", "bash", "stomp", "telegraph", "warn", "raid", "summon"]
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ static func build(kind: String) -> Dictionary:
|
|||||||
"bear": d = _bear()
|
"bear": d = _bear()
|
||||||
"fox": d = _scaled(_wolf_parts(Color(0.78, 0.4, 0.15), Color(0.92, 0.88, 0.8), 281), 0.6)
|
"fox": d = _scaled(_wolf_parts(Color(0.78, 0.4, 0.15), Color(0.92, 0.88, 0.8), 281), 0.6)
|
||||||
"crow": d = _crow()
|
"crow": d = _crow()
|
||||||
|
"gull": d = _gull()
|
||||||
|
"crab": d = _crab()
|
||||||
"guard": d = _guard()
|
"guard": d = _guard()
|
||||||
|
"drowned": d = _drowned()
|
||||||
"old_stag": d = _scaled(_stag(), 2.1)
|
"old_stag": d = _scaled(_stag(), 2.1)
|
||||||
"spirit_wolf": d = _wolf_parts(Color(0.62, 0.72, 0.78), Color(0.85, 0.92, 0.95), 351)
|
"spirit_wolf": d = _wolf_parts(Color(0.62, 0.72, 0.78), Color(0.85, 0.92, 0.95), 351)
|
||||||
_: d = _boar()
|
_: d = _boar()
|
||||||
@@ -207,6 +210,61 @@ static func _crow() -> Dictionary:
|
|||||||
"hips": [Vector3(-0.06, 0.19, -0.01), Vector3(0.06, 0.19, -0.01)],
|
"hips": [Vector3(-0.06, 0.19, -0.01), Vector3(0.06, 0.19, -0.01)],
|
||||||
"radius": 0.12, "height": 0.3}
|
"radius": 0.12, "height": 0.3}
|
||||||
|
|
||||||
|
## Gull: a crow's build, bigger, white with grey wings and a yellow beak.
|
||||||
|
static func _gull() -> Dictionary:
|
||||||
|
var white := Color(0.93, 0.93, 0.9)
|
||||||
|
var grey := Color(0.6, 0.63, 0.67)
|
||||||
|
var tip := Color(0.12, 0.12, 0.13)
|
||||||
|
var beak := Color(0.95, 0.75, 0.2)
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(295)
|
||||||
|
var n := _noise(295, 2.0)
|
||||||
|
P._blob(st, Vector3(0, 0.2, 0), Vector3(0.09, 0.085, 0.18), n, 0.08, white, r, 1)
|
||||||
|
P._blob(st, Vector3(0, 0.25, 0.04), Vector3(0.075, 0.05, 0.14), n, 0.06, grey, r, 1)
|
||||||
|
P._blob(st, Vector3(0, 0.28, -0.16), Vector3(0.06, 0.06, 0.065), n, 0.08, white, r, 1)
|
||||||
|
P._cylinder(st, Vector3(0, 0.275, -0.21), Vector3(0, 0.255, -0.3), 0.017, 0.004, 4, beak, r)
|
||||||
|
P._cylinder(st, Vector3(0, 0.22, 0.15), Vector3(0, 0.2, 0.3), 0.035, 0.05, 4, tip, r)
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
P._cylinder(st, Vector3(s * 0.03, 0.13, 0.0), Vector3(s * 0.035, 0.0, 0.02), 0.008, 0.007, 3, beak, r)
|
||||||
|
P._blob(st, Vector3(s * 0.04, 0.295, -0.19), Vector3.ONE * 0.009, n, 0.0, Color(0.1, 0.1, 0.1), r, 0)
|
||||||
|
var wst := P._begin()
|
||||||
|
P._blob(wst, Vector3(0, -0.2, 0.03), Vector3(0.014, 0.21, 0.09), n, 0.04, grey, r, 0)
|
||||||
|
P._blob(wst, Vector3(0, -0.38, 0.05), Vector3(0.012, 0.05, 0.06), n, 0.0, tip, r, 0)
|
||||||
|
return {"body": P._commit(st), "leg": P._commit(wst),
|
||||||
|
"hips": [Vector3(-0.08, 0.25, -0.01), Vector3(0.08, 0.25, -0.01)],
|
||||||
|
"radius": 0.15, "height": 0.38}
|
||||||
|
|
||||||
|
## Crab: a wide, flat red-brown shell facing -Z, two big claws in front and
|
||||||
|
## eyes on stalks; six thin legs splayed out to the sides (crab.gd turns the
|
||||||
|
## whole crab side-on to walk, and flickers the legs).
|
||||||
|
static func _crab() -> Dictionary:
|
||||||
|
var shell := Color(0.66, 0.22, 0.12)
|
||||||
|
var dark := Color(0.42, 0.13, 0.08)
|
||||||
|
var pale := Color(0.9, 0.72, 0.55)
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(297)
|
||||||
|
var n := _noise(297, 2.4)
|
||||||
|
P._blob(st, Vector3(0, 0.11, 0), Vector3(0.2, 0.06, 0.14), n, 0.1, shell, r, 1)
|
||||||
|
P._blob(st, Vector3(0, 0.08, 0.01), Vector3(0.17, 0.035, 0.12), n, 0.05, pale, r, 1)
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
# Arm, then the claw: a fat pincer and a thin finger.
|
||||||
|
P._cylinder(st, Vector3(s * 0.12, 0.1, -0.09), Vector3(s * 0.17, 0.13, -0.2), 0.025, 0.02, 4, shell, r)
|
||||||
|
P._blob(st, Vector3(s * 0.17, 0.14, -0.26), Vector3(0.05, 0.04, 0.07), n, 0.1, shell, r, 1)
|
||||||
|
P._cylinder(st, Vector3(s * 0.17, 0.15, -0.31), Vector3(s * 0.15, 0.16, -0.37), 0.02, 0.006, 4, dark, r)
|
||||||
|
P._cylinder(st, Vector3(s * 0.19, 0.12, -0.31), Vector3(s * 0.18, 0.12, -0.36), 0.014, 0.004, 4, dark, r)
|
||||||
|
# Eyes on stalks.
|
||||||
|
P._cylinder(st, Vector3(s * 0.04, 0.15, -0.11), Vector3(s * 0.05, 0.21, -0.13), 0.008, 0.006, 3, shell, r)
|
||||||
|
P._blob(st, Vector3(s * 0.05, 0.22, -0.13), Vector3.ONE * 0.014, n, 0.0, Color(0.05, 0.05, 0.05), r, 0)
|
||||||
|
var lst := P._begin()
|
||||||
|
P._cylinder(lst, Vector3(0, 0, 0), Vector3(0, -0.11, 0.0), 0.014, 0.01, 3, shell, r)
|
||||||
|
P._cylinder(lst, Vector3(0, -0.11, 0.0), Vector3(0, -0.2, 0.0), 0.01, 0.003, 3, dark, r)
|
||||||
|
var hips: Array = []
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
for z in [-0.05, 0.03, 0.1]:
|
||||||
|
hips.append(Vector3(s * 0.17, 0.1, z))
|
||||||
|
return {"body": P._commit(st), "leg": P._commit(lst), "hips": hips,
|
||||||
|
"radius": 0.2, "height": 0.25}
|
||||||
|
|
||||||
## Barrow guard: an upright skeleton facing -Z, in a rusted helm, an old
|
## Barrow guard: an upright skeleton facing -Z, in a rusted helm, an old
|
||||||
## sword in its right hand and a round shield on its left arm. Two legs
|
## sword in its right hand and a round shield on its left arm. Two legs
|
||||||
## (creature.gd walks them like a pair of a quadruped's).
|
## (creature.gd walks them like a pair of a quadruped's).
|
||||||
@@ -253,6 +311,53 @@ static func _guard() -> Dictionary:
|
|||||||
"hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)],
|
"hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)],
|
||||||
"radius": 0.3, "height": 1.85}
|
"radius": 0.3, "height": 1.85}
|
||||||
|
|
||||||
|
## A drowned sailor (0.6.0): the barrow guard's bones, sea-greyed and hung
|
||||||
|
## with weed, a rag of a shirt, a bandana, and a cutlass; no shield or helm.
|
||||||
|
static func _drowned() -> Dictionary:
|
||||||
|
var bone := Color(0.62, 0.66, 0.58)
|
||||||
|
var old := Color(0.44, 0.5, 0.42)
|
||||||
|
var weed := Color(0.16, 0.32, 0.18)
|
||||||
|
var rag := Color(0.36, 0.33, 0.27)
|
||||||
|
var red := Color(0.45, 0.12, 0.1)
|
||||||
|
var steel := Color(0.5, 0.52, 0.55)
|
||||||
|
var dark := Color(0.05, 0.07, 0.06)
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(331)
|
||||||
|
var n := _noise(331, 2.2)
|
||||||
|
P._blob(st, Vector3(0, 0.95, 0), Vector3(0.17, 0.07, 0.1), n, 0.2, old, r, 0) # pelvis
|
||||||
|
P._cylinder(st, Vector3(0, 0.97, 0.02), Vector3(0, 1.45, 0.0), 0.035, 0.03, 5, bone, r) # spine
|
||||||
|
for i in range(4): # ribs
|
||||||
|
var y := 1.18 + 0.075 * i
|
||||||
|
P._cylinder(st, Vector3(-0.15 + 0.01 * i, y, -0.02), Vector3(0.15 - 0.01 * i, y, -0.02), 0.018, 0.018, 4, bone, r)
|
||||||
|
P._blob(st, Vector3(0.02, 1.3, 0.0), Vector3(0.19, 0.17, 0.12), n, 0.45, rag, r, 0) # shirt, in tatters
|
||||||
|
P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(0.22, 1.47, 0), 0.025, 0.025, 4, bone, r) # shoulders
|
||||||
|
P._blob(st, Vector3(0, 1.66, -0.02), Vector3(0.11, 0.13, 0.12), n, 0.12, bone, r, 1) # skull
|
||||||
|
P._blob(st, Vector3(0, 1.56, -0.08), Vector3(0.07, 0.04, 0.06), n, 0.1, old, r, 0) # jaw
|
||||||
|
P._blob(st, Vector3(0, 1.75, 0.0), Vector3(0.12, 0.05, 0.13), n, 0.15, red, r, 0) # bandana
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
P._blob(st, Vector3(s * 0.045, 1.67, -0.115), Vector3(0.03, 0.025, 0.012), n, 0.0, dark, r, 0) # sockets
|
||||||
|
for i in range(5): # weed hanging off
|
||||||
|
var x := -0.16 + i * 0.08
|
||||||
|
P._cylinder(st, Vector3(x, 1.45, -0.04), Vector3(x + 0.02, 1.05 - 0.05 * (i % 2), -0.06), 0.012, 0.004, 3, weed, r)
|
||||||
|
# Right arm forward with the cutlass, left hanging.
|
||||||
|
P._cylinder(st, Vector3(0.22, 1.47, 0), Vector3(0.28, 1.2, -0.12), 0.025, 0.022, 4, bone, r)
|
||||||
|
P._cylinder(st, Vector3(0.28, 1.2, -0.12), Vector3(0.27, 1.08, -0.38), 0.022, 0.02, 4, bone, r)
|
||||||
|
P._cylinder(st, Vector3(0.27, 1.08, -0.4), Vector3(0.33, 1.42, -0.85), 0.035, 0.008, 4, steel, r) # curved blade
|
||||||
|
P._cylinder(st, Vector3(0.21, 1.06, -0.38), Vector3(0.33, 1.1, -0.4), 0.02, 0.02, 4, steel, r) # guard
|
||||||
|
P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(-0.27, 1.15, 0.02), 0.025, 0.022, 4, bone, r)
|
||||||
|
P._cylinder(st, Vector3(-0.27, 1.15, 0.02), Vector3(-0.26, 0.9, -0.02), 0.022, 0.02, 4, bone, r)
|
||||||
|
var est := P._begin()
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
P._blob(est, Vector3(s * 0.045, 1.67, -0.125), Vector3.ONE * 0.012, n, 0.0, Color(0.5, 1.0, 0.6), r, 0)
|
||||||
|
var lst := P._begin()
|
||||||
|
P._cylinder(lst, Vector3(0, 0, 0), Vector3(0, -0.45, 0.02), 0.03, 0.025, 4, bone, r)
|
||||||
|
P._cylinder(lst, Vector3(0, -0.45, 0.02), Vector3(0, -0.9, 0), 0.025, 0.022, 4, bone, r)
|
||||||
|
P._blob(lst, Vector3(0, -0.2, 0), Vector3(0.07, 0.18, 0.07), n, 0.4, rag, r, 0) # breeches
|
||||||
|
P._blob(lst, Vector3(0, -0.9, -0.05), Vector3(0.04, 0.025, 0.08), n, 0.1, old, r, 0)
|
||||||
|
return {"body": P._commit(st), "eyes": P._commit(est), "leg": P._commit(lst),
|
||||||
|
"hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)],
|
||||||
|
"radius": 0.3, "height": 1.85}
|
||||||
|
|
||||||
## The Old Stag (before scaling up): a pale, moss-hung spirit deer with a
|
## The Old Stag (before scaling up): a pale, moss-hung spirit deer with a
|
||||||
## huge crown of antlers.
|
## huge crown of antlers.
|
||||||
static func _stag() -> Dictionary:
|
static func _stag() -> Dictionary:
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ func display_name() -> String:
|
|||||||
func interact_prompt(p: Node) -> String:
|
func interact_prompt(p: Node) -> String:
|
||||||
if is_dead():
|
if is_dead():
|
||||||
return "Bones [E] Search"
|
return "Bones [E] Search"
|
||||||
var s := "Barrow guard"
|
# (A barrow guard, or a drowned sailor at a treasure dig: treasure.gd.)
|
||||||
|
var s: String = def.name
|
||||||
match state:
|
match state:
|
||||||
"rest": s = "Barrow guard (still... for now)"
|
"rest": s += " (still... for now)"
|
||||||
"charge": s = "Barrow guard (coming for you!)"
|
"charge": s += " (coming for you!)"
|
||||||
"home": s = "Barrow guard (going back to its post)"
|
"home": s += " (going back to its post)"
|
||||||
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
|
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
|
||||||
s += " (you need a weapon: a club cracks bone)"
|
s += " (you need a weapon: a club cracks bone)"
|
||||||
return s
|
return s
|
||||||
@@ -46,7 +47,7 @@ func _think(dt: float) -> void:
|
|||||||
_goal_d = d
|
_goal_d = d
|
||||||
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player):
|
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player):
|
||||||
_attack_cd = def.attack_cd
|
_attack_cd = def.attack_cd
|
||||||
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a barrow guard")
|
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a %s" % String(def.name).to_lower())
|
||||||
"home":
|
"home":
|
||||||
if barrow.wakes_for(player) and barrow.center().distance_to(pp) < barrow.LEASH:
|
if barrow.wakes_for(player) and barrow.center().distance_to(pp) < barrow.LEASH:
|
||||||
state = "charge"
|
state = "charge"
|
||||||
|
|||||||
56
scripts/creatures/gull.gd
Normal file
56
scripts/creatures/gull.gd
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
extends "res://scripts/creatures/crow.gd"
|
||||||
|
## A gull (0.6.0, the Cove only): the Cove's crow, sent by thieves.gd by
|
||||||
|
## day. It comes for food left on the sand and for fish on a smoking rack
|
||||||
|
## that nobody's watching. Bolder and quicker than a crow: it lands, snatches
|
||||||
|
## one and is off with it, and another soon follows. Shoot it down while
|
||||||
|
## it's carrying and it drops what it took. Feathers.
|
||||||
|
|
||||||
|
const SNATCH_AFTER := 1.2 ## seconds on the ground before it grabs one
|
||||||
|
const GULL_SCARE := 6.0 ## (bolder than a crow)
|
||||||
|
|
||||||
|
var carry: Dictionary = {} ## {id, count, extra} once it has something
|
||||||
|
|
||||||
|
func display_name() -> String:
|
||||||
|
return "Gull"
|
||||||
|
|
||||||
|
func interact_prompt(_p: Node) -> String:
|
||||||
|
if is_dead():
|
||||||
|
return "Gull [E] Pluck"
|
||||||
|
if not carry.is_empty():
|
||||||
|
return "Gull (off with your %s!)" % ItemDB.item_name(String(carry.id)).to_lower()
|
||||||
|
return {"peck": "Gull (after your food!)", "fly": "Gull (circling your food)"}.get(state, "Gull")
|
||||||
|
|
||||||
|
func _think(dt: float) -> void:
|
||||||
|
match state:
|
||||||
|
"fly":
|
||||||
|
if not thieves.gull_still_there(food) or thieves.watched(thieves.spot(food), GULL_SCARE):
|
||||||
|
_leave()
|
||||||
|
return
|
||||||
|
_goal = thieves.spot(food)
|
||||||
|
var ground: float = terrain.sample_height(global_position.x, global_position.z)
|
||||||
|
if Vector2(_goal.x - global_position.x, _goal.z - global_position.z).length() < 0.35 and global_position.y - ground < 0.15:
|
||||||
|
state = "peck"
|
||||||
|
_state_t = SNATCH_AFTER
|
||||||
|
thieves.gull_landed(self, food)
|
||||||
|
"peck":
|
||||||
|
if not thieves.gull_still_there(food) or thieves.watched(global_position, GULL_SCARE):
|
||||||
|
_leave()
|
||||||
|
return
|
||||||
|
_state_t -= dt
|
||||||
|
if _state_t <= 0.0:
|
||||||
|
_peck_anim = 0.4
|
||||||
|
carry = thieves.snatch(self, food)
|
||||||
|
_leave()
|
||||||
|
"fly_off":
|
||||||
|
if thieves.out_of_sight(global_position):
|
||||||
|
thieves.gone(self)
|
||||||
|
_:
|
||||||
|
_leave()
|
||||||
|
|
||||||
|
## Shot down with something in its beak: it drops it where it falls.
|
||||||
|
func _die(from: Vector3) -> void:
|
||||||
|
super._die(from)
|
||||||
|
if not carry.is_empty() and not puppet:
|
||||||
|
var at := global_position + Vector3(0, 0.3, 0)
|
||||||
|
world.drop_item(String(carry.id), int(carry.count), Transform3D(Basis(), at), carry.get("extra", {}))
|
||||||
|
carry = {}
|
||||||
1
scripts/creatures/gull.gd.uid
Normal file
1
scripts/creatures/gull.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://draw1wvqi3cif
|
||||||
106
scripts/creatures/shore.gd
Normal file
106
scripts/creatures/shore.gd
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
extends Node3D
|
||||||
|
## The shore's own wildlife (0.6.0, a land whose worldlet lists "crabs" in
|
||||||
|
## its "wildlife": the Cove). From dusk till dawn crabs come up out of the
|
||||||
|
## sea onto the beach, a few around each player who's down on the sand, at
|
||||||
|
## the waterline and out of the way. At dawn they go back in (crab.gd).
|
||||||
|
## Not saved: like the other wildlife they simply carry on around you.
|
||||||
|
##
|
||||||
|
## On a server this runs for everyone, and players see the crabs through
|
||||||
|
## creature_net.gd.
|
||||||
|
|
||||||
|
const CrabScript = preload("res://scripts/creatures/crab.gd")
|
||||||
|
|
||||||
|
const BUDGET := 5 ## per player on the beach, up to twice it
|
||||||
|
const CHECK_EVERY := 2.0
|
||||||
|
const RING_MIN := 14.0
|
||||||
|
const RING_MAX := 45.0
|
||||||
|
const NOT_WITHIN := 10.0 ## never comes up right beside anyone
|
||||||
|
const DESPAWN := 80.0
|
||||||
|
const BEACH_NEAR := 40.0 ## a player this close to the sand counts as on the beach
|
||||||
|
|
||||||
|
var world: Node3D
|
||||||
|
var terrain: Node3D
|
||||||
|
var enabled := true
|
||||||
|
var _rng := RandomNumberGenerator.new()
|
||||||
|
var _acc := 0.0
|
||||||
|
var _serial := 0
|
||||||
|
|
||||||
|
func setup(w: Node3D) -> void:
|
||||||
|
world = w
|
||||||
|
terrain = w.terrain
|
||||||
|
_rng.seed = terrain.noise_seed + 1201
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
step(delta)
|
||||||
|
|
||||||
|
## Dusk till dawn.
|
||||||
|
func night() -> bool:
|
||||||
|
var h: float = world.day_night.hours()
|
||||||
|
return h >= 17.5 or h < 6.5
|
||||||
|
|
||||||
|
## Living crabs.
|
||||||
|
func alive() -> Array:
|
||||||
|
return get_children().filter(func(c): return is_instance_valid(c) and not c.is_queued_for_deletion() and not c.is_dead())
|
||||||
|
|
||||||
|
func step(dt: float) -> void:
|
||||||
|
if not enabled or world.creatures == null or not world.creatures.enabled:
|
||||||
|
return
|
||||||
|
_acc += dt
|
||||||
|
if _acc < CHECK_EVERY:
|
||||||
|
return
|
||||||
|
_acc = 0.0
|
||||||
|
var everyone: Array = world.players()
|
||||||
|
for c in get_children():
|
||||||
|
if not everyone.any(func(p): return p.global_position.distance_to(c.global_position) <= DESPAWN):
|
||||||
|
c.queue_free()
|
||||||
|
if not night() or everyone.is_empty():
|
||||||
|
return
|
||||||
|
var on_beach := everyone.filter(func(p): return _near_sand(p.global_position))
|
||||||
|
if on_beach.is_empty():
|
||||||
|
return
|
||||||
|
if alive().size() < BUDGET * clampi(on_beach.size(), 1, 2):
|
||||||
|
var who: Node3D = on_beach[_rng.randi() % on_beach.size()]
|
||||||
|
spawn_near(who.global_position) # (one at a time: they trickle up out of the sea)
|
||||||
|
|
||||||
|
func _near_sand(p: Vector3) -> bool:
|
||||||
|
for a in [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]:
|
||||||
|
var q := p + Vector3(cos(a), 0.0, sin(a)) * BEACH_NEAR * 0.5
|
||||||
|
if terrain.on_sand(p.x, p.z) or terrain.on_sand(q.x, q.z):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
## A spot on the sand at the waterline, RING_MIN..RING_MAX from `around`
|
||||||
|
## and not right beside anyone, or Vector3.INF.
|
||||||
|
func find_spot(around: Vector3) -> Vector3:
|
||||||
|
for _try in range(40):
|
||||||
|
var a := _rng.randf() * TAU
|
||||||
|
var r := _rng.randf_range(RING_MIN, RING_MAX)
|
||||||
|
var x := around.x + cos(a) * r
|
||||||
|
var z := around.z + sin(a) * r
|
||||||
|
if not terrain.inside_boundary(x, z, 6.0) or terrain.beach_weight(x, z) < 0.75:
|
||||||
|
continue
|
||||||
|
var h: float = terrain.sample_height(x, z)
|
||||||
|
if h < terrain.water_level + 0.25 or h > terrain.water_level + 0.9:
|
||||||
|
continue
|
||||||
|
var p := Vector3(x, h, z)
|
||||||
|
if world.players().any(func(pl): return pl.global_position.distance_to(p) < NOT_WITHIN):
|
||||||
|
continue
|
||||||
|
return p
|
||||||
|
return Vector3.INF
|
||||||
|
|
||||||
|
func spawn_near(around: Vector3) -> Node3D:
|
||||||
|
var p := find_spot(around)
|
||||||
|
if p == Vector3.INF:
|
||||||
|
return null
|
||||||
|
return spawn(p)
|
||||||
|
|
||||||
|
func spawn(pos: Vector3) -> Node3D:
|
||||||
|
var c: AnimatableBody3D = CrabScript.new()
|
||||||
|
_serial += 1
|
||||||
|
c.setup("crab", world, pos, _rng.randi() + _serial)
|
||||||
|
add_child(c)
|
||||||
|
c.global_position = Vector3(pos.x, terrain.sample_height(pos.x, pos.z), pos.z)
|
||||||
|
c.state = "wander"
|
||||||
|
c.yaw = _rng.randf() * TAU
|
||||||
|
c.target_yaw = c.yaw
|
||||||
|
return c
|
||||||
1
scripts/creatures/shore.gd.uid
Normal file
1
scripts/creatures/shore.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b6eeco4cs028q
|
||||||
@@ -9,10 +9,16 @@ extends Node3D
|
|||||||
## fox.gd). Food in a chest is never touched (that's what chests are for);
|
## fox.gd). Food in a chest is never touched (that's what chests are for);
|
||||||
## the bear (bear_range.gd) is another matter.
|
## the bear (bear_range.gd) is another matter.
|
||||||
##
|
##
|
||||||
|
## In a land with gulls (the Cove, 0.6.0: its worldlet's "wildlife"), food
|
||||||
|
## left on the sand draws a gull by day instead of a crow (gull.gd), and so
|
||||||
|
## does fish on a smoking rack that nobody's watching: a gull snatches one
|
||||||
|
## and flies off with it.
|
||||||
|
##
|
||||||
## On a server this runs for everyone (thieves are wildlife: creature_net.gd).
|
## On a server this runs for everyone (thieves are wildlife: creature_net.gd).
|
||||||
|
|
||||||
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
||||||
const FoxScript = preload("res://scripts/creatures/fox.gd")
|
const FoxScript = preload("res://scripts/creatures/fox.gd")
|
||||||
|
const GullScript = preload("res://scripts/creatures/gull.gd")
|
||||||
|
|
||||||
const CHECK_EVERY := 5.0
|
const CHECK_EVERY := 5.0
|
||||||
const UNWATCHED := 10.0
|
const UNWATCHED := 10.0
|
||||||
@@ -25,6 +31,7 @@ signal stolen(thief: Node, id: String, count: int)
|
|||||||
|
|
||||||
var world: Node3D
|
var world: Node3D
|
||||||
var enabled := true
|
var enabled := true
|
||||||
|
var gulls := false ## this land has gulls (the Cove)
|
||||||
var _acc := 0.0
|
var _acc := 0.0
|
||||||
var _unwatched: Dictionary = {} ## pickup instance id -> seconds left alone
|
var _unwatched: Dictionary = {} ## pickup instance id -> seconds left alone
|
||||||
var _rng := RandomNumberGenerator.new()
|
var _rng := RandomNumberGenerator.new()
|
||||||
@@ -32,6 +39,7 @@ var _rng := RandomNumberGenerator.new()
|
|||||||
func setup(w: Node3D) -> void:
|
func setup(w: Node3D) -> void:
|
||||||
world = w
|
world = w
|
||||||
_rng.seed = w.terrain.noise_seed + 997
|
_rng.seed = w.terrain.noise_seed + 997
|
||||||
|
gulls = w.has_wildlife("gulls")
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
if enabled:
|
if enabled:
|
||||||
@@ -85,14 +93,28 @@ func step(dt: float) -> void:
|
|||||||
_unwatched[k] = float(_unwatched.get(k, 0.0)) + since
|
_unwatched[k] = float(_unwatched.get(k, 0.0)) + since
|
||||||
if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(d) and alive().size() < MAX_THIEVES:
|
if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(d) and alive().size() < MAX_THIEVES:
|
||||||
send(d)
|
send(d)
|
||||||
|
if gulls and not night():
|
||||||
|
for r in racks():
|
||||||
|
if not gull_still_there(r):
|
||||||
|
continue
|
||||||
|
var k: int = r.get_instance_id()
|
||||||
|
seen[k] = true
|
||||||
|
if watched(r.global_position, UNWATCHED):
|
||||||
|
_unwatched.erase(k)
|
||||||
|
continue
|
||||||
|
_unwatched[k] = float(_unwatched.get(k, 0.0)) + since
|
||||||
|
if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(r) and alive().size() < MAX_THIEVES:
|
||||||
|
send(r)
|
||||||
for k in _unwatched.keys():
|
for k in _unwatched.keys():
|
||||||
if not seen.has(k):
|
if not seen.has(k):
|
||||||
_unwatched.erase(k)
|
_unwatched.erase(k)
|
||||||
|
|
||||||
## Sends a thief after `food`: a fox at dusk and night, otherwise a crow.
|
## Sends a thief after `food`: a fox at dusk and night, otherwise a crow (a
|
||||||
|
## gull on the sand, or at a smoking rack, where there are gulls).
|
||||||
func send(food: Node3D) -> Node3D:
|
func send(food: Node3D) -> Node3D:
|
||||||
var fox := night()
|
var fox := night()
|
||||||
var fp: Vector3 = food.global_position
|
var gull := not fox and gulls and (food.is_in_group("processor") or on_sand(food.global_position))
|
||||||
|
var fp: Vector3 = spot(food)
|
||||||
var from := Vector3.INF
|
var from := Vector3.INF
|
||||||
for _i in range(12):
|
for _i in range(12):
|
||||||
var a := _rng.randf() * TAU
|
var a := _rng.randf() * TAU
|
||||||
@@ -104,8 +126,8 @@ func send(food: Node3D) -> Node3D:
|
|||||||
break
|
break
|
||||||
if from == Vector3.INF:
|
if from == Vector3.INF:
|
||||||
return null
|
return null
|
||||||
var t: AnimatableBody3D = (FoxScript if fox else CrowScript).new()
|
var t: AnimatableBody3D = (FoxScript if fox else (GullScript if gull else CrowScript)).new()
|
||||||
t.setup("fox" if fox else "crow", world, from, _rng.randi())
|
t.setup("fox" if fox else ("gull" if gull else "crow"), world, from, _rng.randi())
|
||||||
t.thieves = self
|
t.thieves = self
|
||||||
t.food = food
|
t.food = food
|
||||||
t.state = "sneak" if fox else "fly"
|
t.state = "sneak" if fox else "fly"
|
||||||
@@ -148,3 +170,63 @@ func grab(fox: Node, food: Node3D) -> Dictionary:
|
|||||||
## A thief left with what it took (or empty-handed).
|
## A thief left with what it took (or empty-handed).
|
||||||
func gone(t: Node) -> void:
|
func gone(t: Node) -> void:
|
||||||
t.queue_free()
|
t.queue_free()
|
||||||
|
|
||||||
|
# --- gulls (the Cove) ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func on_sand(pos: Vector3) -> bool:
|
||||||
|
return world.terrain.on_sand(pos.x, pos.z)
|
||||||
|
|
||||||
|
static func is_fish(id: String) -> bool:
|
||||||
|
return Dictionary(ItemDB.get_item(id).get("food", {})).get("diet", {}).has("fish")
|
||||||
|
|
||||||
|
## This land's smoking racks.
|
||||||
|
func racks() -> Array:
|
||||||
|
return get_tree().get_nodes_in_group("processor").filter(func(r): return is_instance_valid(r) and not r.is_queued_for_deletion() \
|
||||||
|
and String(r.item_id) == "smoking_rack" and world.is_ancestor_of(r))
|
||||||
|
|
||||||
|
## Fish on a rack: the first id there is, or "".
|
||||||
|
func _rack_fish(r: Node3D) -> String:
|
||||||
|
for st in r.inventory.slots:
|
||||||
|
if st != null and is_fish(String(st.id)):
|
||||||
|
return String(st.id)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
## Whether a gull's target is still worth it: food on the ground, or a rack
|
||||||
|
## with fish on it.
|
||||||
|
func gull_still_there(t: Node3D) -> bool:
|
||||||
|
if t == null or not is_instance_valid(t) or t.is_queued_for_deletion():
|
||||||
|
return false
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
return not _rack_fish(t).is_empty()
|
||||||
|
return still_there(t)
|
||||||
|
|
||||||
|
## Where a thief lands for `t`: on food, or on the ground beside a rack.
|
||||||
|
func spot(t: Node3D) -> Vector3:
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
var p: Vector3 = t.global_position + t.global_basis.z * 0.8
|
||||||
|
return Vector3(p.x, world.terrain.sample_height(p.x, p.z), p.z)
|
||||||
|
return t.global_position
|
||||||
|
|
||||||
|
func gull_landed(_gull: Node, t: Node3D) -> void:
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
_tell(t.global_position, "A gull's at the fish on your smoking rack!")
|
||||||
|
else:
|
||||||
|
_tell(t.global_position, "A gull's landed by your %s!" % ItemDB.item_name(t.item_id).to_lower())
|
||||||
|
|
||||||
|
## A gull takes one and flies off with it. Returns what it's carrying.
|
||||||
|
func snatch(gull: Node, t: Node3D) -> Dictionary:
|
||||||
|
if not gull_still_there(t):
|
||||||
|
return {}
|
||||||
|
var c := {}
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
var id := _rack_fish(t)
|
||||||
|
t.inventory.remove(id, 1)
|
||||||
|
c = {"id": id, "count": 1, "extra": {}}
|
||||||
|
else:
|
||||||
|
c = {"id": String(t.item_id), "count": 1, "extra": t.extra.duplicate()}
|
||||||
|
t.amount = int(t.amount) - 1
|
||||||
|
if int(t.amount) <= 0:
|
||||||
|
world.take_dropped(t)
|
||||||
|
_tell(t.global_position, "A gull has snatched your %s!" % ItemDB.item_name(String(c.id)).to_lower())
|
||||||
|
stolen.emit(gull, String(c.id), 1)
|
||||||
|
return c
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ static func build(key: String) -> ArrayMesh:
|
|||||||
var m: ArrayMesh
|
var m: ArrayMesh
|
||||||
match key:
|
match key:
|
||||||
"stick": m = _stick()
|
"stick": m = _stick()
|
||||||
|
"driftwood": m = _driftwood()
|
||||||
|
"seagrass": m = _seagrass()
|
||||||
|
"rope": m = _rope()
|
||||||
|
"pine_resin": m = _pine_resin()
|
||||||
|
"pitch": m = _pitch()
|
||||||
|
"rowing_boat": m = _rowing_boat()
|
||||||
|
"treasure_map": m = _treasure_map()
|
||||||
|
"ship_compass": m = _ship_compass()
|
||||||
|
"spyglass": m = _spyglass()
|
||||||
|
"pirate_gold": m = _pirate_gold()
|
||||||
|
"cutlass": m = _cutlass()
|
||||||
"stone": m = _pebble(11, STONE, Vector3(0.14, 0.10, 0.12))
|
"stone": m = _pebble(11, STONE, Vector3(0.14, 0.10, 0.12))
|
||||||
"flint": m = _pebble(23, FLINT, Vector3(0.12, 0.07, 0.15), 0.5)
|
"flint": m = _pebble(23, FLINT, Vector3(0.12, 0.07, 0.15), 0.5)
|
||||||
"wood": m = _wood()
|
"wood": m = _wood()
|
||||||
@@ -58,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)
|
||||||
@@ -78,6 +92,8 @@ static func build(key: String) -> ArrayMesh:
|
|||||||
"mackerel": m = _fish(MACKEREL, 0.2, false)
|
"mackerel": m = _fish(MACKEREL, 0.2, false)
|
||||||
"cod": m = _fish(COD, 0.3, false)
|
"cod": m = _fish(COD, 0.3, false)
|
||||||
"grilled_mackerel": m = _fish(MACKEREL, 0.2, true)
|
"grilled_mackerel": m = _fish(MACKEREL, 0.2, true)
|
||||||
|
"crab_meat": m = _crab_meat()
|
||||||
|
"cooked_crab": m = _cooked_crab()
|
||||||
"grilled_cod": m = _fish(COD, 0.3, true)
|
"grilled_cod": m = _fish(COD, 0.3, true)
|
||||||
"fiber_plant": m = _fiber_plant()
|
"fiber_plant": m = _fiber_plant()
|
||||||
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
|
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
|
||||||
@@ -184,6 +200,147 @@ static func _stick() -> ArrayMesh:
|
|||||||
P._cylinder(st, Vector3(0.05, 0.03, 0.0), Vector3(0.2, 0.05, 0.1), 0.012, 0.006, 4, HANDLE, r)
|
P._cylinder(st, Vector3(0.05, 0.03, 0.0), Vector3(0.2, 0.05, 0.1), 0.012, 0.006, 4, HANDLE, r)
|
||||||
return P._commit(st)
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A sea-bleached branch, twisted, with a stub of a side branch.
|
||||||
|
static func _driftwood() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(41)
|
||||||
|
const BLEACHED := Color(0.72, 0.68, 0.6)
|
||||||
|
const GREY := Color(0.58, 0.55, 0.5)
|
||||||
|
P._cylinder(st, Vector3(-0.5, 0.06, -0.04), Vector3(0.05, 0.08, 0.05), 0.07, 0.06, 6, BLEACHED, r, -1.0, GREY)
|
||||||
|
P._cylinder(st, Vector3(0.05, 0.08, 0.05), Vector3(0.48, 0.05, -0.02), 0.06, 0.035, 6, BLEACHED, r, -1.0, GREY)
|
||||||
|
P._cylinder(st, Vector3(0.1, 0.09, 0.05), Vector3(0.22, 0.12, 0.24), 0.03, 0.015, 5, GREY, r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A tangle of long, flat ribbons of seagrass.
|
||||||
|
static func _seagrass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(45)
|
||||||
|
for i in range(6):
|
||||||
|
var a := r.randf() * TAU
|
||||||
|
var col := Color(0.28, 0.42, 0.2).lerp(Color(0.5, 0.48, 0.25), r.randf())
|
||||||
|
_box(st, Vector3(r.randf_range(-0.03, 0.03), 0.01 + i * 0.006, r.randf_range(-0.03, 0.03)), Vector3(0.03, 0.006, 0.38),
|
||||||
|
col, Basis(Vector3.UP, a))
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A coil of seagrass rope.
|
||||||
|
static func _rope() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(47)
|
||||||
|
const ROPE := Color(0.6, 0.55, 0.36)
|
||||||
|
var n := 12
|
||||||
|
for k in range(2):
|
||||||
|
for i in range(n):
|
||||||
|
var a0 := TAU * i / n
|
||||||
|
var a1 := TAU * (i + 1) / n
|
||||||
|
var rr := 0.1 - k * 0.035
|
||||||
|
P._cylinder(st, Vector3(cos(a0) * rr, 0.025 + k * 0.03, sin(a0) * rr), Vector3(cos(a1) * rr, 0.025 + k * 0.03, sin(a1) * rr),
|
||||||
|
0.02, 0.02, 5, ROPE.darkened(r.randf() * 0.12), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A few lumps of sticky amber resin.
|
||||||
|
static func _pine_resin() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(49)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 49
|
||||||
|
n.frequency = 3.0
|
||||||
|
for i in range(4):
|
||||||
|
var c := Vector3(r.randf_range(-0.05, 0.05), 0.03, r.randf_range(-0.05, 0.05))
|
||||||
|
P._blob(st, c, Vector3.ONE * r.randf_range(0.025, 0.045), n, 0.25, Color(0.8, 0.5, 0.12).darkened(r.randf() * 0.25), r, 1)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A small clay pot of black pitch, a stick stood in it.
|
||||||
|
static func _pitch() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(51)
|
||||||
|
P._cylinder(st, Vector3(0, 0, 0), Vector3(0, 0.12, 0), 0.07, 0.085, 8, Color(0.55, 0.36, 0.24), r, 0.0)
|
||||||
|
P._cylinder(st, Vector3(0, 0.115, 0), Vector3(0, 0.125, 0), 0.078, 0.078, 8, Color(0.06, 0.05, 0.05), r, -1.0, Color(0.06, 0.05, 0.05))
|
||||||
|
P._cylinder(st, Vector3(0.01, 0.06, 0.0), Vector3(0.05, 0.24, 0.02), 0.008, 0.007, 4, HANDLE, r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A rowing boat, bow to -Z: a model 0.55 m long (moorings.gd shows it eight
|
||||||
|
## times over at the pier). Driftwood planks, pitch-black seams, two thwarts
|
||||||
|
## and a pair of oars.
|
||||||
|
static func _rowing_boat() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(53)
|
||||||
|
const WOOD := Color(0.66, 0.6, 0.5)
|
||||||
|
const SEAM := Color(0.1, 0.08, 0.07)
|
||||||
|
const OAR := Color(0.55, 0.42, 0.28)
|
||||||
|
var hw := 0.085 # half the beam amidships
|
||||||
|
# Bottom: a wide plank aft, narrowing to the bow.
|
||||||
|
_box(st, Vector3(0, 0.01, 0.07), Vector3(hw * 1.7, 0.012, 0.36), WOOD)
|
||||||
|
_box(st, Vector3(0, 0.014, -0.16), Vector3(hw * 1.1, 0.012, 0.12), WOOD)
|
||||||
|
for side in [-1.0, 1.0]:
|
||||||
|
# Sides amidships and aft, two strakes with a dark seam between.
|
||||||
|
_box(st, Vector3(side * hw, 0.03, 0.06), Vector3(0.008, 0.03, 0.38), WOOD.darkened(0.05))
|
||||||
|
_box(st, Vector3(side * hw, 0.047, 0.06), Vector3(0.0085, 0.004, 0.38), SEAM)
|
||||||
|
_box(st, Vector3(side * hw, 0.062, 0.06), Vector3(0.008, 0.028, 0.38), WOOD)
|
||||||
|
# Bow: the sides swept in to meet at the stem.
|
||||||
|
var bow := Basis(Vector3.UP, side * 0.53)
|
||||||
|
_box(st, Vector3(side * hw * 0.5, 0.048, -0.193), Vector3(0.008, 0.07, 0.17), WOOD, bow)
|
||||||
|
_box(st, Vector3(0, 0.055, -0.265), Vector3(0.014, 0.085, 0.014), WOOD.darkened(0.15)) # stem
|
||||||
|
_box(st, Vector3(0, 0.045, 0.25), Vector3(hw * 2.0, 0.06, 0.008), WOOD.darkened(0.08)) # transom
|
||||||
|
for z in [-0.02, 0.15]:
|
||||||
|
_box(st, Vector3(0, 0.055, z), Vector3(hw * 2.0, 0.008, 0.035), WOOD.darkened(0.12)) # thwarts
|
||||||
|
for side in [-1.0, 1.0]:
|
||||||
|
P._cylinder(st, Vector3(side * 0.04, 0.07, 0.17), Vector3(side * 0.05, 0.075, -0.13), 0.004, 0.004, 4, OAR, r)
|
||||||
|
_box(st, Vector3(side * 0.05, 0.075, -0.15), Vector3(0.022, 0.003, 0.05), OAR)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A rolled-up, torn sheet of old parchment tied with string.
|
||||||
|
static func _treasure_map() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(43)
|
||||||
|
const PARCH := Color(0.82, 0.74, 0.55)
|
||||||
|
P._cylinder(st, Vector3(-0.16, 0.04, 0), Vector3(0.16, 0.04, 0), 0.04, 0.04, 8, PARCH, r, -1.0, PARCH.darkened(0.15))
|
||||||
|
P._cylinder(st, Vector3(-0.01, 0.04, 0), Vector3(0.01, 0.04, 0), 0.043, 0.043, 8, Color(0.45, 0.12, 0.08), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
const BRASS := Color(0.78, 0.6, 0.25)
|
||||||
|
const GOLD := Color(0.95, 0.75, 0.2)
|
||||||
|
|
||||||
|
## A brass sea compass: a flat round case, a glass face, a red needle.
|
||||||
|
static func _ship_compass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(47)
|
||||||
|
P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 0.05, 0), 0.11, 0.11, 12, BRASS, r, -1.0, BRASS)
|
||||||
|
P._cylinder(st, Vector3(0, 0.05, 0), Vector3(0, 0.056, 0), 0.095, 0.095, 12, Color(0.9, 0.88, 0.8), r, -1.0, Color(0.9, 0.88, 0.8))
|
||||||
|
P._cylinder(st, Vector3(0, 0.06, 0.07), Vector3(0, 0.06, -0.07), 0.012, 0.004, 4, Color(0.75, 0.1, 0.08), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A brass telescope, drawn out: three tubes, the big end forward.
|
||||||
|
static func _spyglass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(49)
|
||||||
|
P._cylinder(st, Vector3(0, 0.04, 0.22), Vector3(0, 0.04, 0.06), 0.03, 0.03, 8, BRASS, r, -1.0, BRASS)
|
||||||
|
P._cylinder(st, Vector3(0, 0.04, 0.06), Vector3(0, 0.04, -0.1), 0.036, 0.036, 8, Color(0.3, 0.2, 0.12), r, -1.0, BRASS)
|
||||||
|
P._cylinder(st, Vector3(0, 0.04, -0.1), Vector3(0, 0.04, -0.26), 0.044, 0.048, 8, BRASS, r, -1.0, Color(0.6, 0.7, 0.8))
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A heap of old gold coins, and a few spilled.
|
||||||
|
static func _pirate_gold() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(53)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 53
|
||||||
|
P._blob(st, Vector3(0, 0.06, 0), Vector3(0.2, 0.09, 0.18), n, 0.25, GOLD, r, 1)
|
||||||
|
for i in range(7):
|
||||||
|
var a := i * 0.9
|
||||||
|
var c := Vector3(cos(a) * (0.16 + 0.05 * (i % 3)), 0.01, sin(a) * (0.16 + 0.05 * (i % 3)))
|
||||||
|
P._cylinder(st, c, c + Vector3(0, 0.012, 0), 0.03, 0.03, 8, GOLD.darkened(0.1 * (i % 3)), r, -1.0, GOLD)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A cutlass: a curved, broad blade, a brass basket guard, a dark grip.
|
||||||
|
static func _cutlass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(59)
|
||||||
|
_handle(st, 0.14, r)
|
||||||
|
P._cylinder(st, Vector3(-0.06, 0.15, 0), Vector3(0.06, 0.15, 0), 0.025, 0.025, 5, BRASS, r)
|
||||||
|
P._cylinder(st, Vector3(0, 0.16, 0), Vector3(0.02, 0.45, 0), 0.03, 0.028, 4, Color(0.72, 0.74, 0.78), r)
|
||||||
|
P._cylinder(st, Vector3(0.02, 0.45, 0), Vector3(0.08, 0.72, 0), 0.028, 0.006, 4, Color(0.72, 0.74, 0.78), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
static func _wood() -> ArrayMesh:
|
static func _wood() -> ArrayMesh:
|
||||||
var st := P._begin()
|
var st := P._begin()
|
||||||
var r := _rng(9)
|
var r := _rng(9)
|
||||||
@@ -392,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:
|
||||||
@@ -443,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()
|
||||||
@@ -1109,6 +1308,34 @@ static func _barrow_key() -> ArrayMesh:
|
|||||||
# --- P4 food keeping ---------------------------------------------------------------------
|
# --- P4 food keeping ---------------------------------------------------------------------
|
||||||
|
|
||||||
## A crust of white salt crystals.
|
## A crust of white salt crystals.
|
||||||
|
## A few chunks of white crab meat in a cracked bit of shell.
|
||||||
|
static func _crab_meat() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(341)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 341
|
||||||
|
n.frequency = 2.6
|
||||||
|
P._blob(st, Vector3(0, 0.03, 0), Vector3(0.11, 0.03, 0.08), n, 0.2, Color(0.62, 0.22, 0.12), r, 1)
|
||||||
|
for i in range(4):
|
||||||
|
var c := Vector3(r.randf_range(-0.05, 0.05), 0.06, r.randf_range(-0.035, 0.035))
|
||||||
|
P._blob(st, c, Vector3(0.035, 0.022, 0.025), n, 0.25, Color(0.95, 0.9, 0.84).lerp(Color(0.95, 0.7, 0.62), r.randf() * 0.4), r, 0)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A whole crab, boiled pink-red, legs tucked.
|
||||||
|
static func _cooked_crab() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(343)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 343
|
||||||
|
n.frequency = 2.4
|
||||||
|
var shell := Color(0.9, 0.35, 0.22)
|
||||||
|
P._blob(st, Vector3(0, 0.05, 0), Vector3(0.12, 0.04, 0.09), n, 0.1, shell, r, 1)
|
||||||
|
for sx in [-1.0, 1.0]:
|
||||||
|
P._blob(st, Vector3(sx * 0.1, 0.04, -0.1), Vector3(0.03, 0.025, 0.045), n, 0.1, shell, r, 0)
|
||||||
|
for z in [-0.03, 0.01, 0.05]:
|
||||||
|
P._cylinder(st, Vector3(sx * 0.1, 0.03, z), Vector3(sx * 0.15, 0.01, z + 0.02), 0.008, 0.004, 3, P._mul(shell, 0.85), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
static func _salt() -> ArrayMesh:
|
static func _salt() -> ArrayMesh:
|
||||||
var st := P._begin()
|
var st := P._begin()
|
||||||
var r := _rng(331)
|
var r := _rng(331)
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ func _c2s_say(text: String) -> void:
|
|||||||
var who: String = Net.players[peer].name
|
var who: String = Net.players[peer].name
|
||||||
if text.begins_with("/"):
|
if text.begins_with("/"):
|
||||||
print("[server] %s: %s" % [who, text])
|
print("[server] %s: %s" % [who, text])
|
||||||
|
if not Lands.land.is_empty():
|
||||||
|
# (Another land's server: commands run on the hub, which keeps
|
||||||
|
# server.json, and answers back here.)
|
||||||
|
Lands.send_hub({"t": "cmd", "line": text.substr(1), "id": String(Net.players[peer].id), "name": who, "peer": peer})
|
||||||
|
return
|
||||||
run(text.substr(1), peer)
|
run(text.substr(1), peer)
|
||||||
return
|
return
|
||||||
# Plain chat only: a line every GAP seconds, so nobody can flood it.
|
# Plain chat only: a line every GAP seconds, so nobody can flood it.
|
||||||
@@ -68,22 +73,44 @@ func _c2s_say(text: String) -> void:
|
|||||||
print("[chat] %s: %s" % [who, text])
|
print("[chat] %s: %s" % [who, text])
|
||||||
_all("%s: %s" % [who, text], "chat")
|
_all("%s: %s" % [who, text], "chat")
|
||||||
|
|
||||||
func _all(text: String, kind: String) -> void:
|
## A line for everyone here, and (`relay`) in every other land too (J3b).
|
||||||
|
func _all(text: String, kind: String, relay: bool = true) -> void:
|
||||||
if kind == "server":
|
if kind == "server":
|
||||||
print("[server] ", text)
|
print("[server] ", text)
|
||||||
for peer in Net.players:
|
for peer in Net.players:
|
||||||
_s2c_line.rpc_id(peer, text, kind)
|
_s2c_line.rpc_id(peer, text, kind)
|
||||||
|
if relay:
|
||||||
|
Lands.relay_line(text, kind)
|
||||||
|
|
||||||
|
## A command typed by someone in another land (J3b): who they are, and where
|
||||||
|
## to send the answer. `run()` sees them as REMOTE.
|
||||||
|
const Terrain = preload("res://scripts/world/terrain.gd")
|
||||||
|
const REMOTE := -1
|
||||||
|
var _caller: Dictionary = {}
|
||||||
|
|
||||||
|
func run_remote(line: String, caller: Dictionary) -> void:
|
||||||
|
print("[server] %s (in the %s): /%s" % [caller.name, caller.land, line])
|
||||||
|
_caller = caller
|
||||||
|
run(line, REMOTE)
|
||||||
|
_caller = {}
|
||||||
|
|
||||||
## An answer to whoever gave the command (peer 0 = the console).
|
## An answer to whoever gave the command (peer 0 = the console).
|
||||||
func _reply(peer: int, text: String) -> void:
|
func _reply(peer: int, text: String) -> void:
|
||||||
if peer == 0:
|
if peer == 0:
|
||||||
print(text)
|
print(text)
|
||||||
|
elif peer == REMOTE:
|
||||||
|
Lands.tell_land(String(_caller.land), {"t": "reply", "peer": int(_caller.peer), "text": text})
|
||||||
else:
|
else:
|
||||||
_s2c_line.rpc_id(peer, text, "reply")
|
_s2c_line.rpc_id(peer, text, "reply")
|
||||||
|
|
||||||
func _is_admin(peer: int) -> bool:
|
func _is_admin(peer: int) -> bool:
|
||||||
|
if peer == REMOTE:
|
||||||
|
return Net.config.is_admin(String(_caller.id))
|
||||||
return peer == 0 or (Net.players.has(peer) and Net.config.is_admin(String(Net.players[peer].id)))
|
return peer == 0 or (Net.players.has(peer) and Net.config.is_admin(String(Net.players[peer].id)))
|
||||||
|
|
||||||
|
## Another land's server should know: this command changed server.json.
|
||||||
|
const CHANGES_CONFIG := ["friend", "unfriend", "ban", "unban", "admin", "set", "release-name"]
|
||||||
|
|
||||||
## The player id a name belongs to (online or reserved), or "".
|
## The player id a name belongs to (online or reserved), or "".
|
||||||
func _id_of(n: String) -> String:
|
func _id_of(n: String) -> String:
|
||||||
var peer := Net.peer_by_name(n)
|
var peer := Net.peer_by_name(n)
|
||||||
@@ -102,16 +129,24 @@ func run(line: String, peer: int) -> void:
|
|||||||
if cmd in ADMIN_ONLY and not _is_admin(peer):
|
if cmd in ADMIN_ONLY and not _is_admin(peer):
|
||||||
_reply(peer, "Only admins can use /%s" % cmd)
|
_reply(peer, "Only admins can use /%s" % cmd)
|
||||||
return
|
return
|
||||||
var me: String = String(Net.players[peer].id) if Net.players.has(peer) else ""
|
var me: String = String(Net.players[peer].id) if Net.players.has(peer) else String(_caller.get("id", ""))
|
||||||
var by: String = String(Net.players[peer].name) if Net.players.has(peer) else "The server"
|
var by: String = String(Net.players[peer].name) if Net.players.has(peer) else String(_caller.get("name", "The server"))
|
||||||
|
_run_cmd(cmd, parts, rest, arg, peer, me, by)
|
||||||
|
if cmd in CHANGES_CONFIG:
|
||||||
|
Lands.tell_lands({"t": "config"})
|
||||||
|
|
||||||
|
func _run_cmd(cmd: String, parts: PackedStringArray, rest: String, arg: String, peer: int, me: String, by: String) -> void:
|
||||||
match cmd:
|
match cmd:
|
||||||
"help":
|
"help":
|
||||||
_reply(peer, "Commands: /players /rules /friend <name> /unfriend <name> /friends" +
|
_reply(peer, "Commands: /players /rules /friend <name> /unfriend <name> /friends" +
|
||||||
(" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else ""))
|
(" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else ""))
|
||||||
"players":
|
"players":
|
||||||
|
# Everyone, in every land (J3b: the hub's roster), with where they are.
|
||||||
var names: Array[String] = []
|
var names: Array[String] = []
|
||||||
for p in Net.players.values():
|
var many := Lands.roster.any(func(e): return String(e.land) != Lands.here())
|
||||||
names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else ""))
|
for p in (Lands.roster if not Lands.roster.is_empty() else Lands._here_list()):
|
||||||
|
names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else "")
|
||||||
|
+ (" in the %s" % Terrain.worldlet_name(String(p.land)) if many else ""))
|
||||||
_reply(peer, "%d online: %s" % [names.size(), ", ".join(names)])
|
_reply(peer, "%d online: %s" % [names.size(), ", ".join(names)])
|
||||||
"rules":
|
"rules":
|
||||||
var c: RefCounted = Net.config
|
var c: RefCounted = Net.config
|
||||||
@@ -142,10 +177,14 @@ func run(line: String, peer: int) -> void:
|
|||||||
_reply(peer, "Your friends: %s" % (", ".join(names) if not names.is_empty() else "nobody yet (/friend <name>)"))
|
_reply(peer, "Your friends: %s" % (", ".join(names) if not names.is_empty() else "nobody yet (/friend <name>)"))
|
||||||
"kick":
|
"kick":
|
||||||
var kp := Net.peer_by_name(arg)
|
var kp := Net.peer_by_name(arg)
|
||||||
if kp == 0:
|
var away := Lands.land_of_name(arg) if kp == 0 else ""
|
||||||
|
if kp == 0 and (away.is_empty() or away == Lands.here()):
|
||||||
_reply(peer, "Nobody called '%s' is online" % arg)
|
_reply(peer, "Nobody called '%s' is online" % arg)
|
||||||
return
|
return
|
||||||
|
if kp != 0:
|
||||||
Net.kick(kp, "You were kicked by %s" % by)
|
Net.kick(kp, "You were kicked by %s" % by)
|
||||||
|
else:
|
||||||
|
Lands.tell_land(away, {"t": "kick", "name": arg, "reason": "You were kicked by %s" % by})
|
||||||
_all("%s was kicked" % arg, "server")
|
_all("%s was kicked" % arg, "server")
|
||||||
"ban":
|
"ban":
|
||||||
var bid := _id_of(arg)
|
var bid := _id_of(arg)
|
||||||
@@ -156,6 +195,8 @@ func run(line: String, peer: int) -> void:
|
|||||||
var bp := Net.peer_by_name(arg)
|
var bp := Net.peer_by_name(arg)
|
||||||
if bp != 0:
|
if bp != 0:
|
||||||
Net.kick(bp, "You were banned by %s" % by)
|
Net.kick(bp, "You were banned by %s" % by)
|
||||||
|
elif not Lands.land_of_name(arg).is_empty():
|
||||||
|
Lands.tell_land(Lands.land_of_name(arg), {"t": "kick", "name": arg, "reason": "You were banned by %s" % by})
|
||||||
_all("%s was banned" % arg, "server")
|
_all("%s was banned" % arg, "server")
|
||||||
"unban":
|
"unban":
|
||||||
var uid: String = Net.config.name_owner(arg)
|
var uid: String = Net.config.name_owner(arg)
|
||||||
@@ -173,9 +214,11 @@ func run(line: String, peer: int) -> void:
|
|||||||
_reply(ap, "%s made you an admin (/help for the commands)" % by)
|
_reply(ap, "%s made you an admin (/help for the commands)" % by)
|
||||||
"save":
|
"save":
|
||||||
WorldNet.save()
|
WorldNet.save()
|
||||||
|
Lands.tell_lands({"t": "save"})
|
||||||
_reply(peer, "Saved")
|
_reply(peer, "Saved")
|
||||||
"backup":
|
"backup":
|
||||||
WorldNet.save()
|
WorldNet.save()
|
||||||
|
Lands.tell_lands({"t": "save"})
|
||||||
var b: String = WorldNet.backup("by %s" % by)
|
var b: String = WorldNet.backup("by %s" % by)
|
||||||
_reply(peer, "Backed up as %s (%d kept)" % [b, WorldNet.list_backups().size()] if not b.is_empty() else "Nothing to back up yet")
|
_reply(peer, "Backed up as %s (%d kept)" % [b, WorldNet.list_backups().size()] if not b.is_empty() else "Nothing to back up yet")
|
||||||
"backups":
|
"backups":
|
||||||
@@ -198,6 +241,7 @@ func run(line: String, peer: int) -> void:
|
|||||||
_reply(peer, "Use /weather %s or auto" % "|".join(PackedStringArray(states)))
|
_reply(peer, "Use /weather %s or auto" % "|".join(PackedStringArray(states)))
|
||||||
return
|
return
|
||||||
Net.set_weather("" if arg == "auto" else arg)
|
Net.set_weather("" if arg == "auto" else arg)
|
||||||
|
Lands.tell_lands({"t": "weather", "state": "" if arg == "auto" else arg})
|
||||||
_all("%s set the weather to %s" % [by, arg], "server")
|
_all("%s set the weather to %s" % [by, arg], "server")
|
||||||
"set":
|
"set":
|
||||||
if parts.size() < 3:
|
if parts.size() < 3:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const Creature = preload("res://scripts/creatures/creature.gd")
|
|||||||
const BearScript = preload("res://scripts/creatures/bear.gd")
|
const BearScript = preload("res://scripts/creatures/bear.gd")
|
||||||
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
||||||
const GuardScript = preload("res://scripts/creatures/guard.gd")
|
const GuardScript = preload("res://scripts/creatures/guard.gd")
|
||||||
|
const GullScript = preload("res://scripts/creatures/gull.gd")
|
||||||
|
const CrabScript = preload("res://scripts/creatures/crab.gd")
|
||||||
const StagScript = preload("res://scripts/creatures/old_stag.gd") # (rears and lowers its antlers) # (falls apart in its own way)
|
const StagScript = preload("res://scripts/creatures/old_stag.gd") # (rears and lowers its antlers) # (falls apart in its own way)
|
||||||
const Bobber = preload("res://scripts/fishing/bobber.gd")
|
const Bobber = preload("res://scripts/fishing/bobber.gd")
|
||||||
const Projectile = preload("res://scripts/creatures/projectile.gd")
|
const Projectile = preload("res://scripts/creatures/projectile.gd")
|
||||||
@@ -99,7 +101,7 @@ func _server_tick(w: Node3D, delta: float) -> void:
|
|||||||
|
|
||||||
const PER_PACKET := 40
|
const PER_PACKET := 40
|
||||||
const ENTRY := 23
|
const ENTRY := 23
|
||||||
const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf"]
|
const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf", "drowned", "crab", "gull"]
|
||||||
const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash",
|
const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash",
|
||||||
"warn", "raid", "eat", "fly", "fly_off", "peck", "sneak", "carry", "circle", "telegraph", "stunned", "summon", "stomp"]
|
"warn", "raid", "eat", "fly", "fly_off", "peck", "sneak", "carry", "circle", "telegraph", "stunned", "summon", "stomp"]
|
||||||
|
|
||||||
@@ -198,7 +200,8 @@ func _s2c_life(packed: PackedByteArray, fl: Array) -> void:
|
|||||||
var c: Node3D = got if is_instance_valid(got) else null
|
var c: Node3D = got if is_instance_valid(got) else null
|
||||||
if c == null:
|
if c == null:
|
||||||
# (Kinds with their own look: the bear rears up, a crow flaps.)
|
# (Kinds with their own look: the bear rears up, a crow flaps.)
|
||||||
var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript, "old_stag": StagScript}.get(kind, Creature)
|
var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript, "drowned": GuardScript, "old_stag": StagScript,
|
||||||
|
"gull": GullScript, "crab": CrabScript}.get(kind, Creature)
|
||||||
c = script.new()
|
c = script.new()
|
||||||
c.puppet = true
|
c.puppet = true
|
||||||
c.setup(kind, world, e[2], cid)
|
c.setup(kind, world, e[2], cid)
|
||||||
|
|||||||
534
scripts/net/lands.gd
Normal file
534
scripts/net/lands.gd
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
extends Node
|
||||||
|
## Autoload "Lands" (J3, docs/WORLDLETS_DESIGN.md: a server per land).
|
||||||
|
##
|
||||||
|
## A server's world can span several lands (worldlets). The server players
|
||||||
|
## join, on the usual port, runs the world's home land (the Valley) and is the
|
||||||
|
## **hub**. When someone first heads for another land, the hub starts a
|
||||||
|
## **land server** for it: the same game, headless, on the next port (24243
|
||||||
|
## for the Cove, and so on), with `--land cove`. It shares the hub's config
|
||||||
|
## and world folder (worlds/<world>/: that land's state is
|
||||||
|
## worldlets/<land>.json, and characters are in players/, shared by all), so
|
||||||
|
## a character saved by one is loaded by the next. A land server stops a
|
||||||
|
## couple of minutes after its last player leaves, and whenever the hub
|
||||||
|
## stops.
|
||||||
|
##
|
||||||
|
## Hub and land servers talk over a private TCP link on 127.0.0.1 (one JSON
|
||||||
|
## object per line), never seen from outside. J3a carries: the land's
|
||||||
|
## "ready" (listening), its player count, the hub's clock, one-time passes
|
||||||
|
## for players crossing over, and asks about the tunnel.
|
||||||
|
##
|
||||||
|
## A crossing (PlayerNet, server side): the player's character is saved with
|
||||||
|
## where they're heading; the hub (or the land server, back the other way)
|
||||||
|
## makes a one-time pass, makes sure the other server is up, and sends the
|
||||||
|
## player's game `{port, pass, land}`. The game reconnects there with the
|
||||||
|
## pass (Net.join(..., pass)), and that server lets them straight in.
|
||||||
|
|
||||||
|
signal land_ready(land: String) ## hub: a land server is listening
|
||||||
|
signal hub_message(msg: Dictionary) ## land server: something from the hub
|
||||||
|
|
||||||
|
const LINK_PORT_OFFSET := 100 ## the hub's private link: its game port + 100 (24342)
|
||||||
|
const IDLE_STOP_SECONDS := 120.0 ## a land server with nobody on stops after this
|
||||||
|
const PASS_SECONDS := 60.0 ## a pass not used by then is no good
|
||||||
|
|
||||||
|
var hub := false ## this server is the hub
|
||||||
|
var land := "" ## this server is a land server, running this land
|
||||||
|
var hub_port := 0 ## (land server) the hub's game port
|
||||||
|
|
||||||
|
## Hub: the land servers it's running. land -> {"pid", "port", "io", "conn", "players", "ready", "idle"}
|
||||||
|
var servers: Dictionary = {}
|
||||||
|
var _link: TCPServer
|
||||||
|
var _unclaimed: Array = [] ## links not yet saying which land they are
|
||||||
|
## One-time passes, wherever a player is arriving: pass -> {"id", "t", "land"}
|
||||||
|
var passes: Dictionary = {}
|
||||||
|
|
||||||
|
## Land server: the link to the hub.
|
||||||
|
var _hub: StreamPeerTCP
|
||||||
|
var _hub_link: Dictionary = {} ## {"conn": _hub, "buf": the partial line}
|
||||||
|
|
||||||
|
# --- setup (called by Net.start_server) ------------------------------------------------
|
||||||
|
|
||||||
|
## Called once a server is listening. `args` are the command-line flags.
|
||||||
|
func server_started(args: Dictionary) -> void:
|
||||||
|
land = String(args.get("land", ""))
|
||||||
|
roster = []
|
||||||
|
if not Net.players_changed.is_connected(rebuild_roster):
|
||||||
|
Net.players_changed.connect(rebuild_roster)
|
||||||
|
if not land.is_empty():
|
||||||
|
hub_port = int(args.get("hub-port", Net.DEFAULT_PORT))
|
||||||
|
_hub = StreamPeerTCP.new()
|
||||||
|
_hub.connect_to_host("127.0.0.1", hub_port + LINK_PORT_OFFSET)
|
||||||
|
_hub_link = {"conn": _hub, "buf": ""}
|
||||||
|
return
|
||||||
|
hub = true
|
||||||
|
_link = TCPServer.new()
|
||||||
|
var err := _link.listen(game_port() + LINK_PORT_OFFSET, "127.0.0.1")
|
||||||
|
if err != OK:
|
||||||
|
push_warning("[lands] couldn't open the private link on %d (error %d): no other lands this run" % [game_port() + LINK_PORT_OFFSET, err])
|
||||||
|
_link = null
|
||||||
|
elif not _order().is_empty():
|
||||||
|
print("[lands] other lands start on demand. Players over the internet need %s forwarded" % ports_text(home(), game_port()))
|
||||||
|
|
||||||
|
## Called as a server stops: stops every land server it started.
|
||||||
|
func server_stopped() -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "stop"})
|
||||||
|
var pids: Array = servers.values().map(func(s): return int(s.pid))
|
||||||
|
if not pids.is_empty():
|
||||||
|
get_tree().create_timer(6.0).timeout.connect(func():
|
||||||
|
for pid in pids:
|
||||||
|
if pid != 0 and OS.is_process_running(pid):
|
||||||
|
OS.kill(pid))
|
||||||
|
servers.clear()
|
||||||
|
_unclaimed.clear()
|
||||||
|
if _link != null:
|
||||||
|
_link.stop()
|
||||||
|
_link = null
|
||||||
|
if _hub != null:
|
||||||
|
_hub.disconnect_from_host()
|
||||||
|
_hub = null
|
||||||
|
hub = false
|
||||||
|
land = ""
|
||||||
|
passes.clear()
|
||||||
|
|
||||||
|
func game_port() -> int:
|
||||||
|
return int(Net.config.v("port")) if Net.config != null else Net.DEFAULT_PORT
|
||||||
|
|
||||||
|
## The worldlet this server runs.
|
||||||
|
func here() -> String:
|
||||||
|
return land if not land.is_empty() else String(Net.config.v("worldlet")) if Net.config != null else "valley"
|
||||||
|
|
||||||
|
## The port land `l` runs on: the hub's, then one up for each land after it
|
||||||
|
## in the journey's order (data/worldlets: next).
|
||||||
|
func port_for(l: String) -> int:
|
||||||
|
if l == home():
|
||||||
|
return hub_port if not land.is_empty() else game_port()
|
||||||
|
var base := hub_port if not land.is_empty() else game_port()
|
||||||
|
return base + 1 + maxi(_order().find(l), 0)
|
||||||
|
|
||||||
|
## The world's home land (the hub's).
|
||||||
|
func home() -> String:
|
||||||
|
return String(Net.config.v("worldlet")) if Net.config != null else "valley"
|
||||||
|
|
||||||
|
## Every land after home, in the order the journey reaches them.
|
||||||
|
func _order() -> Array:
|
||||||
|
return journey_from(home())
|
||||||
|
|
||||||
|
## The lands a server whose home is `home` and whose port is `base` may run,
|
||||||
|
## after home: [[land, port]] (24243 for the Cove when the server's 24242).
|
||||||
|
## Friends playing over the internet need these forwarded too.
|
||||||
|
static func extra_ports(home_land: String, base: int) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
var i := 1
|
||||||
|
for l in journey_from(home_land):
|
||||||
|
out.append([l, base + i])
|
||||||
|
i += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
## "UDP 24242 and 24243 (the Cove)": what to forward for a server on `base`.
|
||||||
|
static func ports_text(home_land: String, base: int) -> String:
|
||||||
|
var extra := extra_ports(home_land, base)
|
||||||
|
if extra.is_empty():
|
||||||
|
return "UDP %d" % base
|
||||||
|
var T: Script = load("res://scripts/world/terrain.gd")
|
||||||
|
var parts: Array[String] = []
|
||||||
|
for e in extra:
|
||||||
|
parts.append("%d (the %s)" % [int(e[1]), T.worldlet_name(String(e[0]))])
|
||||||
|
return "UDP %d and %s" % [base, ", ".join(parts)]
|
||||||
|
|
||||||
|
static func journey_from(home_land: String) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
var at := home_land
|
||||||
|
for _i in range(16):
|
||||||
|
var d: Dictionary = load("res://scripts/world/terrain.gd").load_worldlet(at)
|
||||||
|
var nxt := ""
|
||||||
|
for c in d.get("next", []):
|
||||||
|
if c is Dictionary and not String(c.get("to", "")) in out and String(c.get("to", "")) != home_land \
|
||||||
|
and not load("res://scripts/world/terrain.gd").load_worldlet(String(c.get("to", ""))).is_empty():
|
||||||
|
nxt = String(c.to)
|
||||||
|
break
|
||||||
|
if nxt.is_empty():
|
||||||
|
break
|
||||||
|
out.append(nxt)
|
||||||
|
at = nxt
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- passes -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## A one-time pass for player `id` to come into land `l`.
|
||||||
|
func make_pass(id: String, l: String) -> String:
|
||||||
|
var p := Crypto.new().generate_random_bytes(16).hex_encode()
|
||||||
|
passes[p] = {"id": id, "t": 0.0, "land": l}
|
||||||
|
return p
|
||||||
|
|
||||||
|
## Whether `p` lets player `id` in here (it's used up either way).
|
||||||
|
func take_pass(p: String, id: String) -> bool:
|
||||||
|
if not passes.has(p):
|
||||||
|
return false
|
||||||
|
var e: Dictionary = passes[p]
|
||||||
|
passes.erase(p)
|
||||||
|
return String(e.id) == id and float(e.t) < PASS_SECONDS
|
||||||
|
|
||||||
|
# --- the hub ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Makes sure land `l` has a server (starting one if not). Calls `then`
|
||||||
|
## (with "" or what went wrong) once it's listening.
|
||||||
|
func ensure(l: String, then: Callable) -> void:
|
||||||
|
if not hub:
|
||||||
|
then.call("this isn't the hub")
|
||||||
|
return
|
||||||
|
if servers.has(l) and int(servers[l].pid) != 0 and not OS.is_process_running(int(servers[l].pid)):
|
||||||
|
print("[lands] the %s's server has gone: starting it again" % l)
|
||||||
|
servers.erase(l) # (crashed, and not noticed yet: start afresh)
|
||||||
|
if servers.has(l) and bool(servers[l].ready):
|
||||||
|
servers[l].idle = 0.0
|
||||||
|
then.call("")
|
||||||
|
return
|
||||||
|
if not servers.has(l):
|
||||||
|
var why := _start(l)
|
||||||
|
if not why.is_empty():
|
||||||
|
then.call(why)
|
||||||
|
return
|
||||||
|
var waiting: Callable
|
||||||
|
waiting = func(which: String):
|
||||||
|
if which == l:
|
||||||
|
land_ready.disconnect(waiting)
|
||||||
|
then.call("")
|
||||||
|
land_ready.connect(waiting)
|
||||||
|
|
||||||
|
func _start(l: String) -> String:
|
||||||
|
var args := PackedStringArray(["--headless"])
|
||||||
|
if not OS.has_feature("template"):
|
||||||
|
args.append_array(["--path", ProjectSettings.globalize_path("res://")])
|
||||||
|
args.append_array(["--", "--server", "--land", l, "--port", str(port_for(l)), "--hub-port", str(game_port()),
|
||||||
|
"--config", ProjectSettings.globalize_path(Net.config.path), "--exit-with", str(OS.get_process_id())])
|
||||||
|
for k in ["no-creatures", "debug-cheats"]:
|
||||||
|
if Net._args.has(k):
|
||||||
|
args.append("--" + k)
|
||||||
|
if Net._args.has("host-id"):
|
||||||
|
args.append_array(["--host-id", String(Net._args["host-id"])]) # (a hosted game: the host leaving stops it all)
|
||||||
|
var p: Dictionary = OS.execute_with_pipe(OS.get_executable_path(), args, false)
|
||||||
|
if p.is_empty():
|
||||||
|
return "couldn't start the %s's server" % l
|
||||||
|
var log := FileAccess.open(Net.config.path.get_base_dir().path_join("land-%s.log" % l), FileAccess.WRITE)
|
||||||
|
servers[l] = {"pid": int(p.pid), "port": port_for(l), "io": p.stdio, "log": log, "conn": null,
|
||||||
|
"players": 0, "ready": false, "idle": 0.0}
|
||||||
|
print("[lands] starting the %s's server on UDP %d" % [l, port_for(l)])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
for p in passes.keys():
|
||||||
|
passes[p].t += delta
|
||||||
|
if float(passes[p].t) > PASS_SECONDS:
|
||||||
|
passes.erase(p)
|
||||||
|
if hub:
|
||||||
|
_hub_tick(delta)
|
||||||
|
elif not land.is_empty():
|
||||||
|
_land_tick()
|
||||||
|
|
||||||
|
func _hub_tick(delta: float) -> void:
|
||||||
|
if _link != null:
|
||||||
|
while _link.is_connection_available():
|
||||||
|
var c := _link.take_connection()
|
||||||
|
_unclaimed.append({"conn": c, "buf": ""})
|
||||||
|
for u in _unclaimed.duplicate():
|
||||||
|
var claimed := ""
|
||||||
|
for msg in _read(u):
|
||||||
|
if claimed.is_empty():
|
||||||
|
if String(msg.get("t", "")) == "hello" and servers.has(String(msg.land)):
|
||||||
|
claimed = String(msg.land)
|
||||||
|
servers[claimed].conn = u.conn
|
||||||
|
_unclaimed.erase(u)
|
||||||
|
else:
|
||||||
|
_from_land(claimed, msg) # (what came in the same read: "ready", most likely)
|
||||||
|
if not claimed.is_empty():
|
||||||
|
servers[claimed].buf = u.buf
|
||||||
|
for l in servers.keys():
|
||||||
|
var s: Dictionary = servers[l]
|
||||||
|
_drain_output(s)
|
||||||
|
if int(s.pid) != 0 and not OS.is_process_running(int(s.pid)):
|
||||||
|
print("[lands] the %s's server stopped" % l)
|
||||||
|
_drain_output(s)
|
||||||
|
servers.erase(l)
|
||||||
|
rebuild_roster()
|
||||||
|
PlayerNet.beds_changed()
|
||||||
|
continue
|
||||||
|
if s.conn != null:
|
||||||
|
for msg in _read(s):
|
||||||
|
_from_land(l, msg)
|
||||||
|
s.idle = float(s.idle) + delta if int(s.players) == 0 and bool(s.ready) else 0.0
|
||||||
|
if float(s.idle) > IDLE_STOP_SECONDS:
|
||||||
|
print("[lands] nobody in the %s for %d s: stopping its server" % [l, int(IDLE_STOP_SECONDS)])
|
||||||
|
_send_land(l, {"t": "stop"})
|
||||||
|
s.idle = -INF # (asked once)
|
||||||
|
|
||||||
|
## A land server's output goes to land-<land>.log beside server.json.
|
||||||
|
func _drain_output(s: Dictionary) -> void:
|
||||||
|
if s.io == null:
|
||||||
|
return
|
||||||
|
for _i in range(16):
|
||||||
|
var chunk: PackedByteArray = s.io.get_buffer(8192)
|
||||||
|
if chunk.is_empty():
|
||||||
|
break
|
||||||
|
if s.log != null:
|
||||||
|
s.log.store_string(chunk.get_string_from_utf8())
|
||||||
|
s.log.flush()
|
||||||
|
|
||||||
|
func _from_land(l: String, msg: Dictionary) -> void:
|
||||||
|
match String(msg.get("t", "")):
|
||||||
|
"ready":
|
||||||
|
servers[l].ready = true
|
||||||
|
_send_land(l, {"t": "clock", "day": Net._world.day_night.day, "time": Net._world.day_night.time})
|
||||||
|
_send_land(l, {"t": "tunnel", "dug": _tunnel_dug_here()})
|
||||||
|
land_ready.emit(l)
|
||||||
|
"players":
|
||||||
|
servers[l].players = int(msg.n)
|
||||||
|
"pass":
|
||||||
|
# A player there is coming back here: let them in with this pass.
|
||||||
|
passes[String(msg.pass)] = {"id": String(msg.id), "t": 0.0, "land": home()}
|
||||||
|
"tunnel?":
|
||||||
|
_send_land(l, {"t": "tunnel", "dug": _tunnel_dug_here()})
|
||||||
|
"host_left":
|
||||||
|
Net._host_left.call_deferred()
|
||||||
|
"roster":
|
||||||
|
servers[l].list = msg.get("list", [])
|
||||||
|
rebuild_roster()
|
||||||
|
"line":
|
||||||
|
# Chat from that land: everyone here, and every other land.
|
||||||
|
Chat._all(String(msg.text), String(msg.kind), false)
|
||||||
|
for other in servers:
|
||||||
|
if other != l:
|
||||||
|
_send_land(other, msg)
|
||||||
|
"cmd":
|
||||||
|
Chat.run_remote(String(msg.line), {"id": String(msg.id), "name": String(msg.name), "land": l, "peer": int(msg.peer)})
|
||||||
|
"beds":
|
||||||
|
servers[l].beds = [int(msg.alive), int(msg.asleep)]
|
||||||
|
# Tell the lands the running total (minus their own), so each can
|
||||||
|
# say "N of M asleep", then see if the night passes.
|
||||||
|
_push_bed_totals()
|
||||||
|
PlayerNet.beds_changed()
|
||||||
|
|
||||||
|
## Sends `msg` to land `l`'s server.
|
||||||
|
func _send_land(l: String, msg: Dictionary) -> void:
|
||||||
|
if not servers.has(l) or servers[l].conn == null:
|
||||||
|
return
|
||||||
|
var c: StreamPeerTCP = servers[l].conn
|
||||||
|
c.poll()
|
||||||
|
if c.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
c.put_data((JSON.stringify(msg) + "\n").to_utf8_buffer())
|
||||||
|
|
||||||
|
## Hub: tells every land how far the cliff tunnel's dug (the Cove's sea
|
||||||
|
## cave leads up it once it's through).
|
||||||
|
func push_tunnel(dug: int) -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "tunnel", "dug": dug})
|
||||||
|
|
||||||
|
func _tunnel_dug_here() -> int:
|
||||||
|
var t: Node3D = Net._world.tunnel if Net._world != null else null
|
||||||
|
return int(t.dug) if t != null else 0
|
||||||
|
|
||||||
|
## Hub: tells every land the clock (so there's one clock everywhere).
|
||||||
|
func push_clock(day: int, time: float) -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "clock", "day": day, "time": time})
|
||||||
|
|
||||||
|
## Hub: a pass for player `id` to go to land `l` (the land server learns it).
|
||||||
|
func pass_to(l: String, id: String) -> String:
|
||||||
|
var p := make_pass(id, l)
|
||||||
|
_send_land(l, {"t": "pass", "pass": p, "id": id})
|
||||||
|
passes.erase(p) # (it's the land server's to take, not ours)
|
||||||
|
return p
|
||||||
|
|
||||||
|
# --- a land server --------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _said_hello := false
|
||||||
|
var _last_players := -1
|
||||||
|
var tunnel_dug := -1 ## (land server) how far the hub's tunnel is dug, last asked
|
||||||
|
|
||||||
|
func _land_tick() -> void:
|
||||||
|
if _hub == null:
|
||||||
|
return
|
||||||
|
_hub.poll()
|
||||||
|
var st := _hub.get_status()
|
||||||
|
if st == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
if not _said_hello:
|
||||||
|
_said_hello = true
|
||||||
|
send_hub({"t": "hello", "land": land})
|
||||||
|
send_hub({"t": "ready"})
|
||||||
|
var n := Net.players.size()
|
||||||
|
if n != _last_players or _roster_dirty:
|
||||||
|
_last_players = n
|
||||||
|
_roster_dirty = false
|
||||||
|
send_hub({"t": "players", "n": n})
|
||||||
|
send_hub({"t": "roster", "list": _here_list()})
|
||||||
|
for msg in _read(_hub_link):
|
||||||
|
_from_hub(msg)
|
||||||
|
elif st == StreamPeerTCP.STATUS_ERROR or (st == StreamPeerTCP.STATUS_NONE and _said_hello):
|
||||||
|
print("[lands] lost the hub: saving and stopping")
|
||||||
|
_hub = null
|
||||||
|
Net.stop()
|
||||||
|
get_tree().quit()
|
||||||
|
|
||||||
|
func _from_hub(msg: Dictionary) -> void:
|
||||||
|
match String(msg.get("t", "")):
|
||||||
|
"stop":
|
||||||
|
print("[lands] the hub asked us to stop: saving")
|
||||||
|
_hub = null
|
||||||
|
Net.stop()
|
||||||
|
get_tree().quit()
|
||||||
|
"pass":
|
||||||
|
passes[String(msg.pass)] = {"id": String(msg.id), "t": 0.0, "land": land}
|
||||||
|
"clock":
|
||||||
|
if Net._world != null:
|
||||||
|
var dn: Node = Net._world.day_night
|
||||||
|
var was_day: int = dn.day
|
||||||
|
var was_t: float = dn.time
|
||||||
|
var secs: float = ((int(msg.day) - was_day) + (float(msg.time) - was_t)) * dn.day_length
|
||||||
|
if secs > 1.0:
|
||||||
|
Net._world.skip_time(secs) # (catching up: a slept night, or time nobody was here)
|
||||||
|
dn.day = int(msg.day)
|
||||||
|
dn.time = float(msg.time)
|
||||||
|
dn.apply(true)
|
||||||
|
Net.push_clock()
|
||||||
|
"tunnel":
|
||||||
|
tunnel_dug = int(msg.dug)
|
||||||
|
"roster":
|
||||||
|
roster = msg.get("list", [])
|
||||||
|
Net.push_roster()
|
||||||
|
"line":
|
||||||
|
Chat._all(String(msg.text), String(msg.kind), false)
|
||||||
|
"reply":
|
||||||
|
if Net.players.has(int(msg.peer)):
|
||||||
|
Chat._s2c_line.rpc_id(int(msg.peer), String(msg.text), "reply")
|
||||||
|
"kick":
|
||||||
|
var kp: int = Net.peer_by_name(String(msg.name))
|
||||||
|
if kp != 0:
|
||||||
|
Net.kick(kp, String(msg.reason))
|
||||||
|
"config":
|
||||||
|
Net.reload_config()
|
||||||
|
Net.broadcast_rules()
|
||||||
|
"weather":
|
||||||
|
Net.set_weather(String(msg.state))
|
||||||
|
"save":
|
||||||
|
WorldNet.save()
|
||||||
|
"night":
|
||||||
|
PlayerNet.pass_night_here(float(msg.secs))
|
||||||
|
"beds_total":
|
||||||
|
PlayerNet._hub_beds = [int(msg.alive), int(msg.asleep)]
|
||||||
|
hub_message.emit(msg)
|
||||||
|
|
||||||
|
func send_hub(msg: Dictionary) -> void:
|
||||||
|
if _hub != null and _hub.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
_hub.put_data((JSON.stringify(msg) + "\n").to_utf8_buffer())
|
||||||
|
|
||||||
|
# --- everyone, everywhere (J3b) ------------------------------------------------------------
|
||||||
|
|
||||||
|
## Everyone on every land: [{"name", "id", "land", "admin"}]. The hub builds
|
||||||
|
## it from its own players and what each land reports; every land and every
|
||||||
|
## player's game gets a copy (Net.roster), for the player list and /players.
|
||||||
|
var roster: Array = []
|
||||||
|
var _roster_dirty := false
|
||||||
|
|
||||||
|
## The players on this server, as the roster lists them.
|
||||||
|
func _here_list() -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
for p in Net.players.values():
|
||||||
|
out.append({"name": String(p.name), "id": String(p.id), "land": here(), "admin": bool(p.get("admin", false))})
|
||||||
|
return out
|
||||||
|
|
||||||
|
## Hub: the roster again (its players changed, or a land's did), sent out.
|
||||||
|
func rebuild_roster() -> void:
|
||||||
|
if not hub:
|
||||||
|
if not land.is_empty():
|
||||||
|
_roster_dirty = true # (a land server: tell the hub, next tick)
|
||||||
|
return
|
||||||
|
var out: Array = _here_list()
|
||||||
|
for l in servers:
|
||||||
|
for e in servers[l].get("list", []):
|
||||||
|
var e2: Dictionary = (e as Dictionary).duplicate()
|
||||||
|
e2.land = l
|
||||||
|
out.append(e2)
|
||||||
|
roster = out
|
||||||
|
Net.push_roster()
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "roster", "list": out})
|
||||||
|
|
||||||
|
## The land a player called `n` is in ("" = not on anywhere).
|
||||||
|
func land_of_name(n: String) -> String:
|
||||||
|
for e in roster:
|
||||||
|
if String(e.name).to_lower() == n.to_lower():
|
||||||
|
return String(e.land)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
## Chat or a server message, from here to every other land.
|
||||||
|
func relay_line(text: String, kind: String) -> void:
|
||||||
|
if hub:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "line", "text": text, "kind": kind})
|
||||||
|
elif not land.is_empty():
|
||||||
|
send_hub({"t": "line", "text": text, "kind": kind})
|
||||||
|
|
||||||
|
## Hub: sends `msg` to every land.
|
||||||
|
func tell_lands(msg: Dictionary) -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, msg)
|
||||||
|
|
||||||
|
## Hub: sends `msg` to land `l`.
|
||||||
|
func tell_land(l: String, msg: Dictionary) -> void:
|
||||||
|
_send_land(l, msg)
|
||||||
|
|
||||||
|
## Hub: tells each land the alive/asleep everywhere BUT that land, so a land
|
||||||
|
## server can show the right "N of M" and know when all are abed.
|
||||||
|
func _push_bed_totals() -> void:
|
||||||
|
var here_alive: int = PlayerNet._alive().size()
|
||||||
|
var here_asleep: int = PlayerNet._alive().filter(func(q): return q.in_bed).size()
|
||||||
|
for l in servers:
|
||||||
|
var a := here_alive
|
||||||
|
var s := here_asleep
|
||||||
|
for other in servers:
|
||||||
|
if other != l:
|
||||||
|
var b: Array = servers[other].get("beds", [0, 0])
|
||||||
|
a += int(b[0])
|
||||||
|
s += int(b[1])
|
||||||
|
_send_land(l, {"t": "beds_total", "alive": a, "asleep": s})
|
||||||
|
|
||||||
|
## Hub: how many are alive, and how many asleep, in every other land.
|
||||||
|
func beds_elsewhere() -> Array:
|
||||||
|
var alive := 0
|
||||||
|
var asleep := 0
|
||||||
|
for l in servers:
|
||||||
|
var b: Array = servers[l].get("beds", [0, 0])
|
||||||
|
alive += int(b[0])
|
||||||
|
asleep += int(b[1])
|
||||||
|
return [alive, asleep]
|
||||||
|
|
||||||
|
## Land server: a pass for player `id` to go back to the hub.
|
||||||
|
func pass_home(id: String) -> String:
|
||||||
|
var p := Crypto.new().generate_random_bytes(16).hex_encode()
|
||||||
|
send_hub({"t": "pass", "pass": p, "id": id})
|
||||||
|
return p
|
||||||
|
|
||||||
|
# --- the line protocol ------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Whole JSON lines waiting on `link` ({"conn", "buf"}); keeps the rest.
|
||||||
|
func _read(link: Dictionary) -> Array:
|
||||||
|
var c: StreamPeerTCP = link.conn
|
||||||
|
if c == null:
|
||||||
|
return []
|
||||||
|
c.poll()
|
||||||
|
if c.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
return []
|
||||||
|
var n := c.get_available_bytes()
|
||||||
|
if n > 0:
|
||||||
|
var got: Array = c.get_data(n)
|
||||||
|
if int(got[0]) == OK:
|
||||||
|
link.buf = String(link.get("buf", "")) + (got[1] as PackedByteArray).get_string_from_utf8()
|
||||||
|
var out: Array = []
|
||||||
|
var buf := String(link.get("buf", ""))
|
||||||
|
while buf.contains("\n"):
|
||||||
|
var line := buf.get_slice("\n", 0)
|
||||||
|
buf = buf.substr(line.length() + 1)
|
||||||
|
var m: Variant = JSON.parse_string(line)
|
||||||
|
if m is Dictionary:
|
||||||
|
out.append(m)
|
||||||
|
link.buf = buf
|
||||||
|
return out
|
||||||
1
scripts/net/lands.gd.uid
Normal file
1
scripts/net/lands.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cl2iw0a328ja8
|
||||||
@@ -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 := 16 ## 16: worldlets (the Cove), sea fish (mackerel, cod); 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand)
|
const 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
|
||||||
@@ -176,7 +176,7 @@ func _start_dedicated() -> void:
|
|||||||
|
|
||||||
func _on_update_checked(s: String) -> void:
|
func _on_update_checked(s: String) -> void:
|
||||||
if s == "available":
|
if s == "available":
|
||||||
print("[server] Emberwild %s is out (protocol %d); this server runs %s (protocol %d). %s" % [
|
print("[server] Cinderwild %s is out (protocol %d); this server runs %s (protocol %d). %s" % [
|
||||||
Updates.latest_version(), Updates.latest_protocol(), Updates.version, PROTOCOL,
|
Updates.latest_version(), Updates.latest_protocol(), Updates.version, PROTOCOL,
|
||||||
"Players with the new version can't join until it's updated." if Updates.latest_protocol() != PROTOCOL else ""])
|
"Players with the new version can't join until it's updated." if Updates.latest_protocol() != PROTOCOL else ""])
|
||||||
|
|
||||||
@@ -191,6 +191,10 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
|
|||||||
if overrides.has(flag):
|
if overrides.has(flag):
|
||||||
var v: Variant = overrides[flag]
|
var v: Variant = overrides[flag]
|
||||||
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v)
|
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v)
|
||||||
|
# A land server (J3, lands.gd) runs another land of the hub's world.
|
||||||
|
Saves.land = String(overrides.get("land", ""))
|
||||||
|
if not Saves.land.is_empty():
|
||||||
|
config.overrides["port"] = int(overrides.get("port", config.v("port")))
|
||||||
# The shared island (world_net.gd): the same terrain and scenery every
|
# The shared island (world_net.gd): the same terrain and scenery every
|
||||||
# client generates from the seed, plus everything players changed.
|
# client generates from the seed, plus everything players changed.
|
||||||
_world = WorldNet.open_server_world(config)
|
_world = WorldNet.open_server_world(config)
|
||||||
@@ -211,8 +215,10 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
|
|||||||
players.clear()
|
players.clear()
|
||||||
_pending.clear()
|
_pending.clear()
|
||||||
_set_state("server")
|
_set_state("server")
|
||||||
print("[server] '%s' listening on UDP %d (max %d players%s). Config: %s" % [config.v("server_name"), port,
|
Lands.server_started(overrides)
|
||||||
|
print("[server] '%s' listening on UDP %d (max %d players%s%s). Config: %s" % [config.v("server_name"), port,
|
||||||
int(config.v("max_players")), ", password set" if not String(config.v("password")).is_empty() else "",
|
int(config.v("max_players")), ", password set" if not String(config.v("password")).is_empty() else "",
|
||||||
|
", the %s (a land server)" % Saves.land if not Saves.land.is_empty() else "",
|
||||||
ProjectSettings.globalize_path(config.path)])
|
ProjectSettings.globalize_path(config.path)])
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -226,11 +232,14 @@ func stop() -> void:
|
|||||||
_states.clear()
|
_states.clear()
|
||||||
server_info.clear()
|
server_info.clear()
|
||||||
world_info.clear()
|
world_info.clear()
|
||||||
|
roster.clear()
|
||||||
_out_q.clear()
|
_out_q.clear()
|
||||||
_in_q.clear()
|
_in_q.clear()
|
||||||
local_peer = 0
|
local_peer = 0
|
||||||
if _world != null:
|
if _world != null:
|
||||||
WorldNet.close_server_world() # saves it first
|
WorldNet.close_server_world() # saves it first
|
||||||
|
Lands.server_stopped() # (and the hub's land servers with it)
|
||||||
|
Saves.land = ""
|
||||||
_world = null
|
_world = null
|
||||||
_terrain = null
|
_terrain = null
|
||||||
if state != "offline":
|
if state != "offline":
|
||||||
@@ -252,7 +261,8 @@ func _on_peer_disconnected(id: int) -> void:
|
|||||||
_s2c_player_left.rpc(id)
|
_s2c_player_left.rpc(id)
|
||||||
PlayerNet.server_leave(id) # saves their character
|
PlayerNet.server_leave(id) # saves their character
|
||||||
WorldNet.peer_left(id)
|
WorldNet.peer_left(id)
|
||||||
if _hosted_by(String(p.id)):
|
# (The host crossing to another land hasn't left: only leaving the game does.)
|
||||||
|
if _hosted_by(String(p.id)) and PlayerNet.land_of(String(p.id)) in ["", Lands.here()]:
|
||||||
_host_left.call_deferred()
|
_host_left.call_deferred()
|
||||||
players_changed.emit()
|
players_changed.emit()
|
||||||
|
|
||||||
@@ -282,10 +292,12 @@ func _process(delta: float) -> void:
|
|||||||
## it every few seconds.
|
## it every few seconds.
|
||||||
func _tick_clock(delta: float) -> void:
|
func _tick_clock(delta: float) -> void:
|
||||||
_clock_left -= delta
|
_clock_left -= delta
|
||||||
if _clock_left <= 0.0 and not players.is_empty():
|
if _clock_left <= 0.0 and (not players.is_empty() or not Lands.servers.is_empty()):
|
||||||
_clock_left = CLOCK_INTERVAL
|
_clock_left = CLOCK_INTERVAL
|
||||||
for peer in players:
|
for peer in players:
|
||||||
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
||||||
|
if Lands.hub:
|
||||||
|
Lands.push_clock(_world.day_night.day, _world.day_night.time) # (one clock for every land)
|
||||||
|
|
||||||
# --- hosting from the title screen (M2.7) ------------------------------------------
|
# --- hosting from the title screen (M2.7) ------------------------------------------
|
||||||
# "Host Game" runs a dedicated server as a separate, windowless copy of the
|
# "Host Game" runs a dedicated server as a separate, windowless copy of the
|
||||||
@@ -414,7 +426,9 @@ func _hosted_by(id: String) -> bool:
|
|||||||
|
|
||||||
func _host_left() -> void:
|
func _host_left() -> void:
|
||||||
print("[server] the host left: saving and stopping")
|
print("[server] the host left: saving and stopping")
|
||||||
stop() # (saves the world and everyone's characters)
|
if not Lands.land.is_empty():
|
||||||
|
Lands.send_hub({"t": "host_left"}) # (they left from another land: the hub stops too)
|
||||||
|
stop() # (saves the world and everyone's characters, and stops the other lands)
|
||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
|
|
||||||
# --- admin (chat.gd) ---------------------------------------------------------------
|
# --- admin (chat.gd) ---------------------------------------------------------------
|
||||||
@@ -456,11 +470,94 @@ func _s2c_weather(state: String) -> void:
|
|||||||
|
|
||||||
signal weather_changed(state: String)
|
signal weather_changed(state: String)
|
||||||
|
|
||||||
|
# --- crossing to another land (J3, lands.gd) ------------------------------------------
|
||||||
|
|
||||||
|
signal travelling(land: String) ## client: on our way to another land's server
|
||||||
|
signal arrived(land: String) ## client: in, at the other land (the world reloads)
|
||||||
|
|
||||||
|
var _travel: Dictionary = {} ## client: {"land", "address", "pass"} while crossing
|
||||||
|
var _hub_address := "" ## client: the address we joined (the hub's), for the host part
|
||||||
|
|
||||||
|
## Server: sends player `peer`'s game on to land `l` (its server's port, and
|
||||||
|
## the pass that lets them in), then lets them go.
|
||||||
|
func send_travel(peer: int, l: String, pass_: String) -> void:
|
||||||
|
_s2c_travel.rpc_id(peer, {"land": l, "port": Lands.port_for(l), "pass": pass_})
|
||||||
|
# (Their game goes on its own; this only tidies up one that doesn't.)
|
||||||
|
get_tree().create_timer(3.0).timeout.connect(func():
|
||||||
|
if _peer != null and is_server() and (players.has(peer) or _pending.has(peer)):
|
||||||
|
_peer.disconnect_peer(peer))
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_travel(d: Dictionary) -> void:
|
||||||
|
var host := String(parse_address(_hub_address if not _hub_address.is_empty() else get_identity().last_server)[0])
|
||||||
|
if host.is_empty():
|
||||||
|
host = "127.0.0.1"
|
||||||
|
_travel = {"land": String(d.land), "address": "%s:%d" % [host, int(d.port)], "pass": String(d.pass)}
|
||||||
|
print("[net] on to the %s (%s)" % [d.land, _travel.address])
|
||||||
|
travelling.emit(String(d.land))
|
||||||
|
var pw := _pending_password
|
||||||
|
_quiet_stop()
|
||||||
|
join(String(_travel.address), pw, String(_travel.pass))
|
||||||
|
|
||||||
|
## Closes the connection without telling anyone we left (a crossing).
|
||||||
|
func _quiet_stop() -> void:
|
||||||
|
if _peer != null:
|
||||||
|
_peer.close()
|
||||||
|
_peer = null
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
players.clear()
|
||||||
|
_states.clear()
|
||||||
|
_out_q.clear()
|
||||||
|
_in_q.clear()
|
||||||
|
local_peer = 0
|
||||||
|
_set_state("offline")
|
||||||
|
|
||||||
|
func is_travelling() -> bool:
|
||||||
|
return not _travel.is_empty()
|
||||||
|
|
||||||
|
# --- everyone on every land (J3b, lands.gd) -------------------------------------------
|
||||||
|
|
||||||
|
## Client: everyone on the server, whichever land they're in: [{"name",
|
||||||
|
## "id", "land", "admin"}] (the hub's roster). Empty on a server with only
|
||||||
|
## one land running: then `players` is everyone.
|
||||||
|
var roster: Array = []
|
||||||
|
|
||||||
|
## Server: everyone here gets the roster.
|
||||||
|
func push_roster() -> void:
|
||||||
|
for peer in players:
|
||||||
|
_s2c_roster.rpc_id(peer, Lands.roster)
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_roster(list: Array) -> void:
|
||||||
|
roster = list
|
||||||
|
players_changed.emit()
|
||||||
|
|
||||||
|
## Everyone on the server to show in a list: the roster, or (one land) just
|
||||||
|
## those here. [{"name", "admin", "land"}]
|
||||||
|
func everyone() -> Array:
|
||||||
|
if not roster.is_empty():
|
||||||
|
return roster
|
||||||
|
var out: Array = []
|
||||||
|
for p in players.values():
|
||||||
|
out.append({"name": String(p.name), "admin": bool(p.get("admin", false)), "land": String(world_info.get("worldlet", ""))})
|
||||||
|
return out
|
||||||
|
|
||||||
|
## Server: server.json read again (the hub changed it: a /set, a ban, a
|
||||||
|
## new admin, a friend), keeping this run's command-line overrides.
|
||||||
|
func reload_config() -> void:
|
||||||
|
if config == null:
|
||||||
|
return
|
||||||
|
var ov: Dictionary = config.overrides
|
||||||
|
config = ServerConfig.open(config.path)
|
||||||
|
config.overrides = ov
|
||||||
|
|
||||||
## Everyone gets the clock now (after a night's sleep, an admin's /time).
|
## Everyone gets the clock now (after a night's sleep, an admin's /time).
|
||||||
func push_clock() -> void:
|
func push_clock() -> void:
|
||||||
_clock_left = CLOCK_INTERVAL
|
_clock_left = CLOCK_INTERVAL
|
||||||
for peer in players:
|
for peer in players:
|
||||||
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
||||||
|
if Lands.hub:
|
||||||
|
Lands.push_clock(_world.day_night.day, _world.day_night.time)
|
||||||
|
|
||||||
## Where a player is, as far as the server knows.
|
## Where a player is, as far as the server knows.
|
||||||
func player_pos(peer: int) -> Vector3:
|
func player_pos(peer: int) -> Vector3:
|
||||||
@@ -541,6 +638,12 @@ func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> St
|
|||||||
return "too fast (%.0f m in %.2f s)" % [flat, dt]
|
return "too fast (%.0f m in %.2f s)" % [flat, dt]
|
||||||
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
|
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
|
||||||
return "rising too fast"
|
return "rising too fast"
|
||||||
|
# (Down the cliff tunnel, J1: its own space under the island, where its
|
||||||
|
# rock is the ground. The way in and out is a server teleport.)
|
||||||
|
if _world != null and _world.underground_at(to):
|
||||||
|
return "" if teleported or _world.underground_at(from) else "into the tunnel without going in"
|
||||||
|
if _world != null and _world.underground_at(from):
|
||||||
|
return "out of the tunnel without climbing out"
|
||||||
if not _terrain.inside_boundary(to.x, to.z, -3.0):
|
if not _terrain.inside_boundary(to.x, to.z, -3.0):
|
||||||
return "outside the island"
|
return "outside the island"
|
||||||
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
|
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
|
||||||
@@ -570,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
|
||||||
@@ -591,11 +694,13 @@ func _c2s_hello(d: Dictionary) -> void:
|
|||||||
return
|
return
|
||||||
if int(d.get("protocol", 0)) != PROTOCOL:
|
if int(d.get("protocol", 0)) != PROTOCOL:
|
||||||
var theirs := int(d.get("protocol", 0))
|
var theirs := int(d.get("protocol", 0))
|
||||||
_reject(peer, "Different game version: this server runs Emberwild %s (protocol %d), you have protocol %d. %s" % [
|
_reject(peer, "Different game version: this server runs Cinderwild %s (protocol %d), you have protocol %d. %s" % [
|
||||||
Updates.version, PROTOCOL, theirs, "Update the game to join." if theirs < PROTOCOL else "The server needs updating."])
|
Updates.version, PROTOCOL, theirs, "Update the game to join." if theirs < PROTOCOL else "The server needs updating."])
|
||||||
return
|
return
|
||||||
|
# (A crossing to another land comes with a one-time pass instead, checked
|
||||||
|
# once they've proved who they are: _c2s_proof.)
|
||||||
var pw := String(config.v("password"))
|
var pw := String(config.v("password"))
|
||||||
if not pw.is_empty() and String(d.get("password", "")) != pw:
|
if not pw.is_empty() and String(d.get("password", "")) != pw and String(d.get("pass", "")).is_empty():
|
||||||
_reject(peer, "Wrong password")
|
_reject(peer, "Wrong password")
|
||||||
return
|
return
|
||||||
var nonce := Crypto.new().generate_random_bytes(32)
|
var nonce := Crypto.new().generate_random_bytes(32)
|
||||||
@@ -619,6 +724,23 @@ func _c2s_proof(sig: PackedByteArray) -> void:
|
|||||||
if not why.is_empty():
|
if not why.is_empty():
|
||||||
_reject(peer, why)
|
_reject(peer, why)
|
||||||
return
|
return
|
||||||
|
# Lands (J3): a land server only lets in someone crossing with a pass the
|
||||||
|
# hub gave them; a pass anywhere must be theirs and fresh. And on the
|
||||||
|
# hub, a character who's in another land is sent straight on there.
|
||||||
|
var pass_ := String(hello.get("pass", ""))
|
||||||
|
if not pass_.is_empty():
|
||||||
|
if not Lands.take_pass(pass_, id):
|
||||||
|
_reject(peer, "That way in has expired: join the server again")
|
||||||
|
return
|
||||||
|
elif not Lands.land.is_empty():
|
||||||
|
_reject(peer, "Join this server's main address (this is one of its other lands)")
|
||||||
|
return
|
||||||
|
elif Lands.hub:
|
||||||
|
var away: String = PlayerNet.land_of(id)
|
||||||
|
if not away.is_empty() and away != Lands.home():
|
||||||
|
_pending.erase(peer)
|
||||||
|
PlayerNet.send_on(peer, id, away)
|
||||||
|
return
|
||||||
_pending.erase(peer)
|
_pending.erase(peer)
|
||||||
config.reserve_name(name, id)
|
config.reserve_name(name, id)
|
||||||
if config.data.admins.is_empty():
|
if config.data.admins.is_empty():
|
||||||
@@ -641,7 +763,8 @@ func _c2s_proof(sig: PackedByteArray) -> void:
|
|||||||
# (The world's own seed: an existing world keeps the seed it was made with.)
|
# (The world's own seed: an existing world keeps the seed it was made with.)
|
||||||
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(_terrain.worldlet_id), "spawn": start, "yaw": yaw,
|
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(_terrain.worldlet_id), "spawn": start, "yaw": yaw,
|
||||||
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
|
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
|
||||||
"weather": _world.weather.override}
|
"weather": _world.weather.override,
|
||||||
|
"tunnel_dug": Lands.tunnel_dug if not Lands.land.is_empty() else Lands._tunnel_dug_here()}
|
||||||
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
||||||
for other in players:
|
for other in players:
|
||||||
if other != peer:
|
if other != peer:
|
||||||
@@ -683,7 +806,12 @@ static func parse_address(a: String) -> Array:
|
|||||||
return [a, DEFAULT_PORT]
|
return [a, DEFAULT_PORT]
|
||||||
|
|
||||||
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
|
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
|
||||||
func join(address: String, password: String = "") -> void:
|
## `pass_` is a one-time pass from a crossing to another land (J3).
|
||||||
|
func join(address: String, password: String = "", pass_: String = "") -> void:
|
||||||
|
_pending_pass = pass_
|
||||||
|
if pass_.is_empty():
|
||||||
|
_hub_address = address.strip_edges()
|
||||||
|
_travel = {}
|
||||||
if state != "offline":
|
if state != "offline":
|
||||||
stop()
|
stop()
|
||||||
var me := get_identity()
|
var me := get_identity()
|
||||||
@@ -705,11 +833,13 @@ func join(address: String, password: String = "") -> void:
|
|||||||
_reject_reason = ""
|
_reject_reason = ""
|
||||||
_pending_password = password
|
_pending_password = password
|
||||||
_join_left = JOIN_TIMEOUT
|
_join_left = JOIN_TIMEOUT
|
||||||
me.last_server = address.strip_edges()
|
if pass_.is_empty():
|
||||||
|
me.last_server = address.strip_edges() # (always the hub's: rejoining sends you on)
|
||||||
me.save_profile()
|
me.save_profile()
|
||||||
_set_state("connecting")
|
_set_state("connecting")
|
||||||
|
|
||||||
var _pending_password := ""
|
var _pending_password := ""
|
||||||
|
var _pending_pass := ""
|
||||||
|
|
||||||
func leave() -> void:
|
func leave() -> void:
|
||||||
var was := state
|
var was := state
|
||||||
@@ -722,7 +852,7 @@ func leave() -> void:
|
|||||||
func _on_connected() -> void:
|
func _on_connected() -> void:
|
||||||
var me := get_identity()
|
var me := get_identity()
|
||||||
_c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name,
|
_c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name,
|
||||||
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password})
|
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password, "pass": _pending_pass})
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_challenge(nonce: PackedByteArray) -> void:
|
func _s2c_challenge(nonce: PackedByteArray) -> void:
|
||||||
@@ -745,6 +875,10 @@ func _s2c_welcome(d: Dictionary) -> void:
|
|||||||
_set_state("connected")
|
_set_state("connected")
|
||||||
joined.emit()
|
joined.emit()
|
||||||
players_changed.emit()
|
players_changed.emit()
|
||||||
|
if not _travel.is_empty():
|
||||||
|
var l := String(_travel.land)
|
||||||
|
_travel = {}
|
||||||
|
arrived.emit(l)
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_rejected(reason: String) -> void:
|
func _s2c_rejected(reason: String) -> void:
|
||||||
@@ -807,10 +941,17 @@ func _run_queue(q: Array) -> void:
|
|||||||
f.call()
|
f.call()
|
||||||
|
|
||||||
func _fail(reason: String) -> void:
|
func _fail(reason: String) -> void:
|
||||||
|
var crossing := not _travel.is_empty()
|
||||||
|
_travel = {}
|
||||||
|
if not crossing:
|
||||||
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
|
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
|
||||||
stop()
|
stop()
|
||||||
last_reason = reason
|
last_reason = reason
|
||||||
join_failed.emit(reason)
|
join_failed.emit(reason)
|
||||||
|
if crossing:
|
||||||
|
# (The world we were in is still on screen: it goes back to the title.)
|
||||||
|
last_reason = "Couldn't get through to the next land (%s). Join again to carry on." % reason
|
||||||
|
left.emit(last_reason)
|
||||||
|
|
||||||
func _on_connection_failed() -> void:
|
func _on_connection_failed() -> void:
|
||||||
_fail("Couldn't reach the server")
|
_fail("Couldn't reach the server")
|
||||||
@@ -818,6 +959,11 @@ func _on_connection_failed() -> void:
|
|||||||
func _on_server_disconnected() -> void:
|
func _on_server_disconnected() -> void:
|
||||||
var was := state
|
var was := state
|
||||||
var reason := _reject_reason
|
var reason := _reject_reason
|
||||||
|
if not _travel.is_empty():
|
||||||
|
if was == "connecting":
|
||||||
|
_fail(reason if not reason.is_empty() else "the server hung up") # (the next land wouldn't have us)
|
||||||
|
return # (the land we're leaving letting us go)
|
||||||
|
var land_name := on_land()
|
||||||
stop_hosting()
|
stop_hosting()
|
||||||
stop()
|
stop()
|
||||||
if was == "connecting":
|
if was == "connecting":
|
||||||
@@ -825,8 +971,19 @@ func _on_server_disconnected() -> void:
|
|||||||
join_failed.emit(last_reason)
|
join_failed.emit(last_reason)
|
||||||
else:
|
else:
|
||||||
last_reason = reason if not reason.is_empty() else "The server closed the connection"
|
last_reason = reason if not reason.is_empty() else "The server closed the connection"
|
||||||
|
if reason.is_empty() and not land_name.is_empty():
|
||||||
|
# (Another land's server went: the rest of the server's still up,
|
||||||
|
# and joining again starts that land's server afresh.)
|
||||||
|
last_reason = "Lost the connection to the %s. Join the server again to carry on there." % \
|
||||||
|
preload("res://scripts/world/terrain.gd").worldlet_name(land_name)
|
||||||
left.emit(last_reason)
|
left.emit(last_reason)
|
||||||
|
|
||||||
|
## Client: the land we're in when it's not the server's home land (we're on
|
||||||
|
## one of its other lands' servers), else "".
|
||||||
|
func on_land() -> String:
|
||||||
|
var here := String(world_info.get("worldlet", ""))
|
||||||
|
return here if not here.is_empty() and here != String(server_info.get("worldlet", here)) else ""
|
||||||
|
|
||||||
func _notification(what: int) -> void:
|
func _notification(what: int) -> void:
|
||||||
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
|
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
|
||||||
if _peer != null:
|
if _peer != null:
|
||||||
|
|||||||
@@ -79,11 +79,20 @@ func server_join(peer: int, id: String, display: String) -> Vector3:
|
|||||||
p.load_data(d)
|
p.load_data(d)
|
||||||
var t: Node3D = WorldNet.world.terrain
|
var t: Node3D = WorldNet.world.terrain
|
||||||
var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF
|
var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF
|
||||||
if pos.is_finite() and t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0:
|
var here_ := String(d.get("land", Lands.here())) == Lands.here()
|
||||||
|
if here_ and d.has("arrive"):
|
||||||
|
# Just crossed over: where the way in comes out (J3).
|
||||||
|
pos = WorldNet.world.arrival_point(String(d.arrive))
|
||||||
|
elif not here_:
|
||||||
|
pos = Vector3.INF # (saved somewhere else: start at this land's start)
|
||||||
|
var ok_spot: bool = pos.is_finite() and (WorldNet.world.underground_at(pos) \
|
||||||
|
or (t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0))
|
||||||
|
if ok_spot:
|
||||||
at = pos
|
at = pos
|
||||||
print("[server] %s's character loaded" % display)
|
print("[server] %s's character loaded" % display)
|
||||||
if at != Vector3.INF:
|
if at != Vector3.INF:
|
||||||
p.global_position = at
|
p.global_position = at
|
||||||
|
beds_changed.call_deferred() # (one more awake: everyone's count changes)
|
||||||
return at
|
return at
|
||||||
|
|
||||||
func place_at(peer: int, pos: Vector3) -> void:
|
func place_at(peer: int, pos: Vector3) -> void:
|
||||||
@@ -127,10 +136,54 @@ func _save(p: Node3D) -> void:
|
|||||||
if f == null:
|
if f == null:
|
||||||
printerr("[server] can't save %s's character" % p.display)
|
printerr("[server] can't save %s's character" % p.display)
|
||||||
return
|
return
|
||||||
f.store_string(JSON.stringify(p.to_data(), "\t"))
|
var d: Dictionary = p.to_data()
|
||||||
|
# Which land the character's in (J3: characters are shared by every land
|
||||||
|
# of the world); heading somewhere, it's there they'll turn up next.
|
||||||
|
d["land"] = String(p.get_meta("heading", Lands.here()))
|
||||||
|
if p.has_meta("arrive"):
|
||||||
|
d["arrive"] = String(p.get_meta("arrive"))
|
||||||
|
f.store_string(JSON.stringify(d, "\t"))
|
||||||
f.close()
|
f.close()
|
||||||
DirAccess.rename_absolute(tmp, path)
|
DirAccess.rename_absolute(tmp, path)
|
||||||
|
|
||||||
|
## Which land player `id`'s character is in ("" = not saved yet, or here).
|
||||||
|
func land_of(id: String) -> String:
|
||||||
|
return String(_read(_file(id)).get("land", ""))
|
||||||
|
|
||||||
|
## The hub: player `id` (at `peer`, not let in yet) left their character in
|
||||||
|
## land `l`: start its server if need be and send them on there.
|
||||||
|
func send_on(peer: int, id: String, l: String) -> void:
|
||||||
|
print("[server] %s's character is in the %s: sending them on" % [id.substr(0, 8), l])
|
||||||
|
Lands.ensure(l, func(why: String):
|
||||||
|
if not why.is_empty():
|
||||||
|
Net._reject(peer, "The %s isn't open right now (%s)" % [l, why])
|
||||||
|
return
|
||||||
|
Net.send_travel(peer, l, Lands.pass_to(l, id)))
|
||||||
|
|
||||||
|
## A crossing, on the server (the tunnel's sea cave, the cave back up): the
|
||||||
|
## character is saved as heading for `to`, arriving at `arrive`, and their
|
||||||
|
## game is sent on (to a land server, or back to the hub).
|
||||||
|
func cross(peer: int, to: String, arrive: String) -> void:
|
||||||
|
var p: Node3D = proxies.get(peer)
|
||||||
|
if p == null or p.has_meta("heading"):
|
||||||
|
return
|
||||||
|
p.set_meta("heading", to)
|
||||||
|
p.set_meta("arrive", arrive)
|
||||||
|
_save(p)
|
||||||
|
var id: String = p.player_id
|
||||||
|
print("[server] %s crosses to the %s" % [p.display, to])
|
||||||
|
if to == Lands.home() and not Lands.land.is_empty():
|
||||||
|
Net.send_travel(peer, to, Lands.pass_home(id))
|
||||||
|
return
|
||||||
|
Lands.ensure(to, func(why: String):
|
||||||
|
if not why.is_empty():
|
||||||
|
p.remove_meta("heading")
|
||||||
|
p.remove_meta("arrive")
|
||||||
|
_save(p)
|
||||||
|
tell(peer, "The way's blocked for now (%s)" % why)
|
||||||
|
return
|
||||||
|
Net.send_travel(peer, to, Lands.pass_to(to, id)))
|
||||||
|
|
||||||
static func _read(path: String) -> Dictionary:
|
static func _read(path: String) -> Dictionary:
|
||||||
if not FileAccess.file_exists(path):
|
if not FileAccess.file_exists(path):
|
||||||
return {}
|
return {}
|
||||||
@@ -160,6 +213,7 @@ func server_moved(peer: int, from: Vector3, to: Vector3, yaw: float, anim: int,
|
|||||||
if p.in_bed and to.distance_to(p.bed_at) > 1.5:
|
if p.in_bed and to.distance_to(p.bed_at) > 1.5:
|
||||||
p.in_bed = false
|
p.in_bed = false
|
||||||
tell(p.peer, "You got out of bed")
|
tell(p.peer, "You got out of bed")
|
||||||
|
beds_changed()
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if not Net.is_server() or proxies.is_empty():
|
if not Net.is_server() or proxies.is_empty():
|
||||||
@@ -300,8 +354,56 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
"hit":
|
"hit":
|
||||||
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
|
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
|
||||||
var at: Vector3 = _v(a, 1)
|
var at: Vector3 = _v(a, 1)
|
||||||
if n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
|
if n == null and _s(a, 0) == "treasure:" + String(p.player_id) and w.treasure != null:
|
||||||
|
var m: Node3D = w.treasure.mound_for(String(p.player_id)) # (your own X: treasure.gd)
|
||||||
|
if m != null and at.is_finite() and at.distance_to(m.global_position) < 2.5:
|
||||||
|
Actions.harvest_hit(p, m, at)
|
||||||
|
elif n == null and w.tunnel != null:
|
||||||
|
var f: Node3D = w.tunnel.face_by_uid(_s(a, 0)) # (the cliff tunnel's seam or face)
|
||||||
|
if f != null and at.is_finite():
|
||||||
|
Actions.harvest_hit(p, f, at) # (the tunnel tells everyone: tunnel.gd)
|
||||||
|
elif n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
|
||||||
WorldNet.node_hit(n, at, p.global_position)
|
WorldNet.node_hit(n, at, p.global_position)
|
||||||
|
"tunnel_prop":
|
||||||
|
if w.tunnel != null:
|
||||||
|
w.tunnel.set_prop(p, _i(a, 0))
|
||||||
|
"wreck_chest":
|
||||||
|
# The wreck's sea chest: each player's share, once.
|
||||||
|
if w.wreck != null and p.global_position.distance_to(w.wreck.chest.global_position) < 4.0:
|
||||||
|
if w.wreck.open_for(p, String(p.player_id)):
|
||||||
|
WorldNet.wreck_state(w.wreck.to_data())
|
||||||
|
"moor":
|
||||||
|
# The mooring post at the end of the pier: tie up your boat.
|
||||||
|
if w.moorings != null and p.global_position.distance_to(w.moorings.post.global_position) < 4.0:
|
||||||
|
if w.moorings.moor(p, String(p.player_id)):
|
||||||
|
WorldNet.boats_state(w.moorings.to_data())
|
||||||
|
"tunnel_go":
|
||||||
|
# Down into the cliff tunnel, or back out at the top: a teleport
|
||||||
|
# the server makes (the tunnel's under the island), from nearby.
|
||||||
|
var t: Node3D = w.tunnel
|
||||||
|
if t == null or not t.opened:
|
||||||
|
return
|
||||||
|
var go_in := _s(a, 0) == "in"
|
||||||
|
var from: Vector3 = t.seam_out if go_in else t.top
|
||||||
|
if p.global_position.distance_to(from) > 4.5:
|
||||||
|
return
|
||||||
|
if go_in:
|
||||||
|
t.go_in(p)
|
||||||
|
else:
|
||||||
|
t.go_out(p)
|
||||||
|
Net.teleport(p.peer, p.global_position)
|
||||||
|
_s2c_respawn.rpc_id(p.peer, p.global_position, p.rotation.y, "")
|
||||||
|
"cross":
|
||||||
|
# The sea cave's mouth (the Valley), or the sea cave (the Cove).
|
||||||
|
var where := _s(a, 0)
|
||||||
|
if where == "tunnel" and w.tunnel != null and w.tunnel.dug >= w.tunnel.STRETCHES \
|
||||||
|
and p.global_position.distance_to(w.tunnel.cave_mouth) < 4.0:
|
||||||
|
cross(p.peer, w.tunnel.to, "sea_cave")
|
||||||
|
elif where == "sea_cave" and w.sea_cave != null and w.sea_cave.open() \
|
||||||
|
and p.global_position.distance_to(w.sea_cave.front) < 6.0:
|
||||||
|
cross(p.peer, w.sea_cave.to, "tunnel_bottom")
|
||||||
|
elif where == "sea_cave" and w.sea_cave != null:
|
||||||
|
tell(p.peer, w.sea_cave.interact_prompt(p))
|
||||||
"pickup":
|
"pickup":
|
||||||
var key := _s(a, 0)
|
var key := _s(a, 0)
|
||||||
var pk: Node3D = WorldNet.find_pickup(key)
|
var pk: Node3D = WorldNet.find_pickup(key)
|
||||||
@@ -436,7 +538,7 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
for m in w.get_tree().get_nodes_in_group("processor"):
|
for m in w.get_tree().get_nodes_in_group("processor"):
|
||||||
m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0)
|
m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0)
|
||||||
"debug_spawn":
|
"debug_spawn":
|
||||||
if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf"]) and _v(a, 1).is_finite():
|
if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf", "drowned", "crab", "gull"]) and _v(a, 1).is_finite():
|
||||||
w.creatures.spawn(_s(a, 0), _v(a, 1))
|
w.creatures.spawn(_s(a, 0), _v(a, 1))
|
||||||
"debug_boss":
|
"debug_boss":
|
||||||
# Tests: ["hold", secs] stands the Old Stag still (stunned);
|
# Tests: ["hold", secs] stands the Old Stag still (stunned);
|
||||||
@@ -456,6 +558,12 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
w.day_night.apply(true)
|
w.day_night.apply(true)
|
||||||
w.weather.snap()
|
w.weather.snap()
|
||||||
Net.push_clock()
|
Net.push_clock()
|
||||||
|
"debug_tunnel":
|
||||||
|
# (tests) dig the cliff tunnel to `n` stretches, all propped.
|
||||||
|
if cheats and w.tunnel != null:
|
||||||
|
var n := clampi(_i(a, 0), 0, w.tunnel.STRETCHES)
|
||||||
|
w.tunnel.load_data({"opened": true, "dug": n, "props": range(1, n + 1)})
|
||||||
|
w.tunnel._tell_everyone()
|
||||||
"debug_tp":
|
"debug_tp":
|
||||||
var to := _v(a, 0)
|
var to := _v(a, 0)
|
||||||
if cheats and to.is_finite():
|
if cheats and to.is_finite():
|
||||||
@@ -515,28 +623,66 @@ func go_to_bed(p: Node3D) -> void:
|
|||||||
p.in_bed = true
|
p.in_bed = true
|
||||||
p.bed_at = p.global_position
|
p.bed_at = p.global_position
|
||||||
if not _maybe_pass_night():
|
if not _maybe_pass_night():
|
||||||
var n: int = _alive().size()
|
# (Counting everyone, in every land: on another land's server, the
|
||||||
var asleep: int = _alive().filter(func(q): return q.in_bed).size()
|
# hub's last count, with this bed in it.)
|
||||||
tell(p.peer, "You're in bed (%d of %d asleep). The night passes when %s." % [asleep, n,
|
var counts := sleep_counts()
|
||||||
"everyone's in bed" if _beds_needed(n) >= n else "%d are" % _beds_needed(n)])
|
if not Lands.land.is_empty():
|
||||||
for q in _alive():
|
counts = [_alive().size() + _hub_beds[0], _alive().filter(func(q): return q.in_bed).size() + _hub_beds[1]]
|
||||||
if q != p:
|
var n: int = counts[0]
|
||||||
tell(q.peer, "%s is in bed (%d of %d asleep)" % [p.display, asleep, n])
|
var asleep: int = counts[1]
|
||||||
|
var need: int = _beds_needed(n)
|
||||||
|
tell(p.peer, waiting_text(asleep, n, need))
|
||||||
|
# Everyone else, in every land, hears it in chat (so whoever's still
|
||||||
|
# up knows they're being waited for).
|
||||||
|
Chat._all("%s is in bed (%d of %d asleep%s)" % [p.display, asleep, n,
|
||||||
|
"" if need >= n else ", %d needed" % need], "server")
|
||||||
|
|
||||||
|
## What a player who's just got into bed is told while the others are up.
|
||||||
|
static func waiting_text(asleep: int, n: int, need: int) -> String:
|
||||||
|
var left := need - asleep
|
||||||
|
var who := "everyone else" if need >= n else ("%d more" % left)
|
||||||
|
return "You're in bed: waiting for %s to go to bed (%d of %d asleep)" % [who, asleep, n]
|
||||||
|
|
||||||
func _alive() -> Array:
|
func _alive() -> Array:
|
||||||
return proxies.values().filter(func(q): return not q.survival.dead)
|
return proxies.values().filter(func(q): return not q.survival.dead)
|
||||||
|
|
||||||
## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires,
|
## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires,
|
||||||
## regrowth and weather move on; sleepers wake rested.
|
## regrowth and weather move on; sleepers wake rested.
|
||||||
|
## One clock for every land (J3b): the night passes when enough of everyone,
|
||||||
|
## in every land, is in bed. Another land's server reports its beds to the
|
||||||
|
## hub, which decides, and tells every land to pass the night too.
|
||||||
func _maybe_pass_night() -> bool:
|
func _maybe_pass_night() -> bool:
|
||||||
var w: Node3D = WorldNet.world
|
var w: Node3D = WorldNet.world
|
||||||
var live := _alive()
|
if w == null:
|
||||||
if w == null or live.is_empty():
|
|
||||||
return false
|
return false
|
||||||
var asleep := live.filter(func(q): return q.in_bed)
|
if not Lands.land.is_empty():
|
||||||
if asleep.size() < _beds_needed(live.size()):
|
_report_beds()
|
||||||
|
return false
|
||||||
|
Lands._push_bed_totals()
|
||||||
|
var counts := sleep_counts()
|
||||||
|
if counts[0] == 0 or counts[1] < _beds_needed(counts[0]):
|
||||||
return false
|
return false
|
||||||
var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length
|
var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length
|
||||||
|
Lands.tell_lands({"t": "night", "secs": secs})
|
||||||
|
pass_night_here(secs)
|
||||||
|
return true
|
||||||
|
|
||||||
|
## [alive, asleep] on this server and (the hub) every other land.
|
||||||
|
func sleep_counts() -> Array:
|
||||||
|
var live := _alive()
|
||||||
|
var counts := [live.size(), live.filter(func(q): return q.in_bed).size()]
|
||||||
|
if Lands.hub:
|
||||||
|
var e: Array = Lands.beds_elsewhere()
|
||||||
|
counts = [counts[0] + int(e[0]), counts[1] + int(e[1])]
|
||||||
|
return counts
|
||||||
|
|
||||||
|
## The night passes here: until 06:00 for everyone on this server.
|
||||||
|
func pass_night_here(secs: float) -> void:
|
||||||
|
var w: Node3D = WorldNet.world
|
||||||
|
if w == null:
|
||||||
|
return
|
||||||
|
var live := _alive()
|
||||||
|
var asleep := live.filter(func(q): return q.in_bed)
|
||||||
w.skip_time(secs)
|
w.skip_time(secs)
|
||||||
for q in live:
|
for q in live:
|
||||||
if q.in_bed:
|
if q.in_bed:
|
||||||
@@ -545,7 +691,20 @@ func _maybe_pass_night() -> bool:
|
|||||||
_s2c_slept.rpc_id(q.peer, asleep.has(q))
|
_s2c_slept.rpc_id(q.peer, asleep.has(q))
|
||||||
Net.push_clock()
|
Net.push_clock()
|
||||||
WorldNet.save()
|
WorldNet.save()
|
||||||
return true
|
if not Lands.land.is_empty():
|
||||||
|
_report_beds()
|
||||||
|
|
||||||
|
## Someone got into or out of bed, or came or went: (a land server) the hub
|
||||||
|
## hears the new count; (the hub) the night may pass.
|
||||||
|
func beds_changed() -> void:
|
||||||
|
if not Lands.land.is_empty():
|
||||||
|
_report_beds()
|
||||||
|
elif Net.is_server():
|
||||||
|
_maybe_pass_night()
|
||||||
|
|
||||||
|
func _report_beds() -> void:
|
||||||
|
var live := _alive()
|
||||||
|
Lands.send_hub({"t": "beds", "alive": live.size(), "asleep": live.filter(func(q): return q.in_bed).size()})
|
||||||
|
|
||||||
## The chest or grave `nid`, if this player may use it right now.
|
## The chest or grave `nid`, if this player may use it right now.
|
||||||
func _container(p: Node3D, nid: int) -> Node3D:
|
func _container(p: Node3D, nid: int) -> Node3D:
|
||||||
@@ -556,6 +715,10 @@ func _container(p: Node3D, nid: int) -> Node3D:
|
|||||||
|
|
||||||
const Rules = preload("res://scripts/net/rules.gd")
|
const Rules = preload("res://scripts/net/rules.gd")
|
||||||
|
|
||||||
|
## (Land server) the hub's latest [alive, asleep] in every OTHER land, for
|
||||||
|
## the "N of M asleep" message and the all-abed check here.
|
||||||
|
var _hub_beds := [0, 0]
|
||||||
|
|
||||||
## Tells the player why not, if there's a reason.
|
## Tells the player why not, if there's a reason.
|
||||||
func _allowed(why: String) -> bool:
|
func _allowed(why: String) -> bool:
|
||||||
if why.is_empty():
|
if why.is_empty():
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ extends RefCounted
|
|||||||
|
|
||||||
const DEFAULT_PATH := "user://server/server.json"
|
const DEFAULT_PATH := "user://server/server.json"
|
||||||
const DEFAULTS := {
|
const DEFAULTS := {
|
||||||
"server_name": "Emberwild server",
|
"server_name": "Cinderwild server",
|
||||||
"motd": "Welcome!",
|
"motd": "Welcome!",
|
||||||
"port": 24242,
|
"port": 24242,
|
||||||
"max_players": 8,
|
"max_players": 8,
|
||||||
|
|||||||
@@ -369,6 +369,55 @@ func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void:
|
|||||||
func stones_state(d: Dictionary) -> void:
|
func stones_state(d: Dictionary) -> void:
|
||||||
_all("_s2c_stones", [d])
|
_all("_s2c_stones", [d])
|
||||||
|
|
||||||
|
## The cliff tunnel changed (a blow on its face, a stretch dug, a prop set):
|
||||||
|
## everyone here sees it, and the other lands hear how far it's dug.
|
||||||
|
func tunnel_state(d: Dictionary) -> void:
|
||||||
|
_all("_s2c_tunnel", [d])
|
||||||
|
mark_changed()
|
||||||
|
if Lands.hub:
|
||||||
|
Lands.push_tunnel(int(d.get("dug", 0)))
|
||||||
|
|
||||||
|
## Whose treasure's been dug (and how far): everyone here sees their own
|
||||||
|
## mound go down, or go.
|
||||||
|
func treasure_state(d: Dictionary) -> void:
|
||||||
|
_all("_s2c_treasure", [d])
|
||||||
|
mark_changed()
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_treasure(d: Dictionary) -> void:
|
||||||
|
if not is_client() or world.treasure == null:
|
||||||
|
return
|
||||||
|
world.treasure.load_data(d)
|
||||||
|
|
||||||
|
## Someone tied up a boat at the pier: everyone here sees it.
|
||||||
|
func boats_state(d: Dictionary) -> void:
|
||||||
|
_all("_s2c_boats", [d])
|
||||||
|
mark_changed()
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_boats(d: Dictionary) -> void:
|
||||||
|
if not is_client() or world.moorings == null:
|
||||||
|
return
|
||||||
|
world.moorings.load_data(d)
|
||||||
|
|
||||||
|
## Who's had their share of the wreck's sea chest changed: everyone here
|
||||||
|
## sees whether it's empty for them.
|
||||||
|
func wreck_state(d: Dictionary) -> void:
|
||||||
|
_all("_s2c_wreck", [d])
|
||||||
|
mark_changed()
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_wreck(d: Dictionary) -> void:
|
||||||
|
if not is_client() or world.wreck == null:
|
||||||
|
return
|
||||||
|
world.wreck.load_data(d)
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_tunnel(d: Dictionary) -> void:
|
||||||
|
if not is_client() or world.tunnel == null:
|
||||||
|
return
|
||||||
|
world.tunnel.load_data(d)
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_stones(d: Dictionary) -> void:
|
func _s2c_stones(d: Dictionary) -> void:
|
||||||
if not is_client() or world.stones == null:
|
if not is_client() or world.stones == null:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user