14 Commits

Author SHA1 Message Date
Paul Bailey
da29308522 Roadmap: cloud saves, desktop and web both ways (a Cloudflare Worker, a name and password, the newer-copy check) 2026-11-07 16:48:52 +00:00
Paul Bailey
79061d8572 Roadmap: a single-player web version after the playtest (what the web renderer looks like, what's needed), and Android as a maybe 2026-11-07 16:31:20 +00:00
Paul Bailey
1f67d58b92 The Gitea repo is Admin/Cinderwild: the update feed, Report a bug, the site's links and publishing (0.6.0 and 0.6.1 games still reach it through Gitea's redirect from Admin/Emberwild) 2026-11-07 16:21:08 +00:00
Paul Bailey
c2c86b5f78 0.6.1: Emberwild is now Cinderwild (the old name's taken on Steam). Saves, identity and settings are copied across from the Emberwild folder on first start; the site moves to cinderwild.psbailey.uk; same protocol (18), so 0.6.0 and 0.6.1 play together 2026-11-07 16:14:11 +00:00
Paul Bailey
04b9d17826 A better download page: a hero over the Cove with the download for your computer, the trailer, what you'll do, what's new and how to report a bug, screenshots (tools/site/make_shots.sh), downloads, and playing with friends with the ports 2026-11-07 13:24:46 +00:00
Paul Bailey
3b611a6d34 0.6.0: the Cove's own, for a playtest (the wreck and a treasure for each player, the drowned crew, crabs and gulls, a rowing boat at the pier, Report a bug); protocol 18 2026-11-07 13:04:36 +00:00
Paul Bailey
27040d6805 Report a bug in the pause menu (a new Gitea issue filled in with the version, computer, mode, land and where you are; copy the details; the log file), the playtest guide (docs/PLAYTEST.md), and the Cove's 0.6.0 parts in the player guide 2026-11-07 12:45:37 +00:00
Paul Bailey
2004fe193c The rowing boat: seagrass at the tide line and resin from pines; rope, pitch, and a boat at the workbench (driftwood, rope, pitch, nails); tied up at a berth of its own at the end of the pier, one each, saved; Set sail needs the ship's compass and the Highlands aren't charted yet; online too 2026-11-07 11:16:15 +00:00
Paul Bailey
9709d2c424 The Cove's shore life: crabs up the beach from dusk till dawn (keep to the sand, pinch if you get close, crab meat to cook), gulls after food on the sand and fish on a smoking rack; online too 2026-11-07 10:23:49 +00:00
Paul Bailey
5bcfa23710 The pirate treasure: each player's own mound at their X, dug with a pickaxe; the drowned crew rise at night or when the chest comes up; the ship's compass, a spyglass, a cutlass and pirate gold; online too 2026-11-07 09:54:51 +00:00
Paul Bailey
74bd8f5898 The wreck in dark weathered oak; the map's key lists only this land's places 2026-11-07 02:04:48 +00:00
Paul Bailey
419c71e638 The Cove's wreck: driftwood and salt on the beach, the captain's sea chest (a treasure map, nails, salt; once each), each player's own X on the map; online too 2026-11-07 02:00:31 +00:00
Paul Bailey
ec8caa9ef4 Each land's set pieces from its data: the barrow, the standing stones and the bear are the Valley's alone, the Cove keeps its wolves; the Cove's 0.6.0 plan (treasure: one each, each in its own place) 2026-11-07 01:14:32 +00:00
Paul Bailey
1bf33935e0 Download page: forward UDP 24243 too for the Cove; test_net_lands can run against an exported build (EMBERWILD_SERVER_BIN) 2026-11-07 00:33:03 +00:00
97 changed files with 3724 additions and 291 deletions

View File

@@ -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.5.0** is released (download page and player guide: **Cinderwild 0.6.1** 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
@@ -33,7 +35,12 @@ https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
with a save file per worldlet; on a server, a server per land (the Valley's with a save file per worldlet; on a server, a server per land (the Valley's
starts the Cove's on the next UDP port), with chat, the player list, starts the Cove's on the next UDP port), with chat, the player list,
admin commands and sleep across lands, and one clock everywhere. admin commands and sleep across lands, and one clock everywhere.
Next: J2 (each tunnel's own gate and supplies), then the Highlands (by sail). - 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.
@@ -55,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
@@ -64,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.

View File

@@ -46,7 +46,7 @@ made a little older (deeper, slower, warmer) by `tools/voice/make_voice.py`.
The recordings are generated from our own text; rebuild them with The recordings are generated from our own text; rebuild them with
`tools/voice/make_voice.sh` after changing any line. `tools/voice/make_voice.sh` after changing any line.
## Emberwild's own (CC0) ## Cinderwild's own (CC0)
`music/`, `ambience/` and `sfx/synth/` are synthesised from scratch by `music/`, `ambience/` and `sfx/synth/` are synthesised from scratch by
`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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,7 @@
{ {
"hint_cold": "91c5088e453fb0e6", "hint_cold": "91c5088e453fb0e6",
"hint_cove": "126f0b5e977fbef7", "hint_cove": "f7e5c0283b31e1cc",
"hint_crab": "283542496ecd180c",
"hint_died": "e865f8a63622b3c3", "hint_died": "e865f8a63622b3c3",
"hint_diet": "3178b319c44e9fa0", "hint_diet": "3178b319c44e9fa0",
"hint_freezing": "48b1e505119a0fc9", "hint_freezing": "48b1e505119a0fc9",

View File

@@ -37,6 +37,8 @@
{"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 iron only scratches. Out on the clifftop, near the edge, there's a seam of cracked rock, and a way down to the sea behind it if you dig. Take wood to prop it as you go, and lights: it's black as pitch in there."},
{"id": "cove", "when": "worldlet:cove", "hold": 3, "title": "The Cove", {"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": "You made it down. Sand, salt and sea fish. Fish off the end of the pier: mackerel bite close in, cod further out where it's deep. The sea cave takes you back up the tunnel, and home's waiting at the top. Mind the gulls: they'll have any food you leave on the sand, and fish off a smoking rack. And at dusk the crabs come up the beach: give them room, or they pinch."},
{"id": "crab", "when": "has:crab_meat", "hold": 2, "title": "Crab",
"text": "Two lots of crab meat cook into a good crab supper at a campfire. Raw, it's as risky as raw fish."}
] ]
} }

View File

@@ -593,6 +593,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 +1106,119 @@
"description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.", "description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.",
"mesh": "salt" "mesh": "salt"
}, },
{
"id": "driftwood",
"name": "Driftwood",
"category": "resource",
"stack": 30,
"description": "Sea-bleached wood washed up on the Cove's beach. It burns well, and it's light and tough: what a rowing boat is built from.",
"mesh": "driftwood",
"fuel": 48.0
},
{
"id": "seagrass",
"name": "Seagrass",
"category": "resource",
"stack": 50,
"description": "Long, tough ribbons of seagrass washed up in the Cove's shallows. Twisted together it makes a rope that won't rot in salt water.",
"mesh": "seagrass"
},
{
"id": "rope",
"name": "Seagrass Rope",
"category": "resource",
"stack": 30,
"description": "Twisted from seagrass: for rigging and lashing a boat.",
"mesh": "rope"
},
{
"id": "pine_resin",
"name": "Pine Resin",
"category": "resource",
"stack": 50,
"description": "Sticky amber resin from a felled pine. Boiled with salt it makes pitch.",
"mesh": "pine_resin"
},
{
"id": "pitch",
"name": "Pitch",
"category": "resource",
"stack": 30,
"description": "Black, tarry pitch from pine resin and salt, boiled over a fire. It keeps the sea out of a boat's seams.",
"mesh": "pitch"
},
{
"id": "rowing_boat",
"name": "Rowing Boat",
"category": "tool",
"stack": 1,
"description": "A small driftwood boat, seams pitched tight. Carry it to the mooring post at the end of the Cove's pier to tie it up. Where it'll take you, a ship's compass will tell.",
"mesh": "rowing_boat"
},
{
"id": "treasure_map",
"name": "Treasure Map",
"category": "tool",
"stack": 1,
"description": "A torn chart from a wrecked ship's sea chest, with an X inked in the hills above the Cove. Right-click to read it: the X goes on your map (N).",
"mesh": "treasure_map",
"read": {
"marks": "treasure"
}
},
{
"id": "ship_compass",
"name": "Ship's Compass",
"category": "tool",
"stack": 1,
"description": "A brass sea compass from the pirate chest, its needle still true. No boat leaves for the Highlands without one.",
"mesh": "ship_compass"
},
{
"id": "spyglass",
"name": "Spyglass",
"category": "tool",
"stack": 1,
"description": "A captain's brass telescope. Hold right-click to look through it.",
"mesh": "spyglass",
"zoom": 4.0
},
{
"id": "pirate_gold",
"name": "Pirate Gold",
"category": "placeable",
"stack": 20,
"description": "Old gold coins from the pirate chest. Nothing on the island takes coin (yet), but a heap of it looks fine in a hut.",
"mesh": "pirate_gold",
"place": {
"piece": "trophy",
"footprint": [
0.6,
0.6
]
}
},
{
"id": "cutlass",
"name": "Cutlass",
"category": "weapon",
"stack": 1,
"description": "A pirate's curved blade: quick, and it hits harder than the iron spear.",
"mesh": "cutlass",
"weapon": {
"damage": 30,
"reach": 2.3,
"stamina": 8,
"cooldown": 0.45
},
"durability": 220,
"repair": {
"inputs": {
"iron_bar": 1
},
"station": "forge"
}
},
{ {
"id": "smoking_rack", "id": "smoking_rack",
"name": "Smoking Rack", "name": "Smoking Rack",

View File

@@ -259,6 +259,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 +733,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
}
} }
] ]
} }

View File

@@ -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"}

View File

@@ -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"}]
} }

View File

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

80
docs/PLAYTEST.md Normal file
View 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.

View File

@@ -93,7 +93,10 @@ phases M2.0-M2.7).
*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). *(0.5.0):* J3, the journey on a server: a server per (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 land, players in different lands at once, chat, the player list, admin
and sleep across lands. Next J2, then J4 (the Highlands by sail). and sleep across lands. *(0.6.0, a playtest):* the Cove's own: the wreck,
a treasure dig for each player and its drowned crew, crabs, gulls, and a
rowing boat moored at the pier; Report a bug in the pause menu. Next J2,
then J4 (the Highlands by sail).
- Audio, music, weather. *Done (0.2.0-0.2.1):* mood music (calm, night, - 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
@@ -111,6 +114,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
@@ -118,7 +181,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).

View File

@@ -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.

View File

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

View File

@@ -283,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`.

View File

@@ -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.5.0" application/short_version="0.6.1"
application/version="0.5.0" application/version="0.6.1"
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.5.0" application/file_version="0.6.1"
application/product_version="0.5.0" application/product_version="0.6.1"
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

View File

@@ -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
# #

View File

@@ -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.5.0" config/version="0.6.1"
[autoload] [autoload]
@@ -93,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]

View File

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

View 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

View File

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

View File

@@ -87,6 +87,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}

View File

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

View File

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

View File

@@ -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,
@@ -140,7 +156,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 +180,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)
@@ -516,8 +532,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"]

View File

@@ -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:

View File

@@ -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
View 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 = {}

View File

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

106
scripts/creatures/shore.gd Normal file
View 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

View File

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

View File

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

View File

@@ -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()
@@ -78,6 +89,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 +197,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)
@@ -1109,6 +1263,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)

View File

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

View File

@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
const Identity = preload("res://scripts/net/identity.gd") const Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.gd") const ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 17 ## 17: lands on a server (crossings, the tunnel online, the roster); 16: worldlets (the Cove), sea fish (mackerel, cod); 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand) const PROTOCOL := 18 ## 18: the Cove's own (the wreck and treasure, the drowned crew, crabs and gulls, boats at the pier); 17: lands on a server (crossings, the tunnel online, the roster); 16: worldlets (the Cove), sea fish (mackerel, cod); 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand)
const 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 ""])
@@ -694,7 +694,7 @@ 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 # (A crossing to another land comes with a one-time pass instead, checked

View File

@@ -354,7 +354,11 @@ 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 w.tunnel != null: 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) 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(): if f != null and at.is_finite():
Actions.harvest_hit(p, f, at) # (the tunnel tells everyone: tunnel.gd) Actions.harvest_hit(p, f, at) # (the tunnel tells everyone: tunnel.gd)
@@ -363,6 +367,16 @@ func _run(p: Node3D, what: String, a: Array) -> void:
"tunnel_prop": "tunnel_prop":
if w.tunnel != null: if w.tunnel != null:
w.tunnel.set_prop(p, _i(a, 0)) 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": "tunnel_go":
# Down into the cliff tunnel, or back out at the top: a teleport # Down into the cliff tunnel, or back out at the top: a teleport
# the server makes (the tunnel's under the island), from nearby. # the server makes (the tunnel's under the island), from nearby.
@@ -524,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);

View File

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

View File

@@ -377,6 +377,41 @@ func tunnel_state(d: Dictionary) -> void:
if Lands.hub: if Lands.hub:
Lands.push_tunnel(int(d.get("dug", 0))) 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") @rpc("authority", "call_remote", "reliable")
func _s2c_tunnel(d: Dictionary) -> void: func _s2c_tunnel(d: Dictionary) -> void:
if not is_client() or world.tunnel == null: if not is_client() or world.tunnel == null:

View File

@@ -145,6 +145,7 @@ func _target() -> Object:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_swing_cd = maxf(_swing_cd - delta, 0.0) _swing_cd = maxf(_swing_cd - delta, 0.0)
_zoom(delta)
if player.ui_open: if player.ui_open:
_ghost.visible = false _ghost.visible = false
_set_prompt("") _set_prompt("")
@@ -174,6 +175,10 @@ func _physics_process(delta: float) -> void:
_set_prompt("[RMB] Eat %s" % def.name) _set_prompt("[RMB] Eat %s" % def.name)
elif def.has("wear"): elif def.has("wear"):
_set_prompt("[RMB] Wear %s" % def.name) _set_prompt("[RMB] Wear %s" % def.name)
elif def.has("read"):
_set_prompt("[RMB] Read the %s" % String(def.name).to_lower())
elif def.has("zoom"):
_set_prompt("[hold RMB] Look through the %s" % String(def.name).to_lower())
elif def.has("bow"): elif def.has("bow"):
var n: int = Actions.ammo_count(player, def.bow) var n: int = Actions.ammo_count(player, def.bow)
_set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n) _set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n)
@@ -232,6 +237,21 @@ func _update_ghost(def: Dictionary) -> void:
_ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat _ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat
_ghost.visible = true _ghost.visible = true
## The spyglass (0.6.0): holding right-click with it narrows the view by its
## "zoom", eased in and out.
var _base_fov := 0.0
func _zoom(delta: float) -> void:
var cam := get_parent() as Camera3D
if cam == null:
return
if _base_fov <= 0.0:
_base_fov = cam.fov
var z := float(_held_def().get("zoom", 0.0))
var looking: bool = z > 1.0 and not player.ui_open and Input.is_action_pressed("use_item") \
and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED
var want: float = _base_fov / z if looking else _base_fov
cam.fov = move_toward(cam.fov, want, delta * 160.0)
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if player.ui_open: if player.ui_open:
return return
@@ -262,6 +282,8 @@ func _unhandled_input(event: InputEvent) -> void:
Actions.eat(player, player.selected) Actions.eat(player, player.selected)
elif d.has("wear"): elif d.has("wear"):
Actions.wear(player, player.selected) Actions.wear(player, player.selected)
elif d.has("read"):
player.get_parent().read_map(player) # (the treasure map: your X, on your map)
elif d.get("weapon", {}).has("throw"): elif d.get("weapon", {}).has("throw"):
_throw() _throw()
elif event.is_action_pressed("rotate_piece"): elif event.is_action_pressed("rotate_piece"):

View File

@@ -23,6 +23,8 @@ const MARKS := {
"build": [Color(0.62, 0.45, 0.28), "Your buildings"], "build": [Color(0.62, 0.45, 0.28), "Your buildings"],
"camp": [Color(1.00, 0.80, 0.35), "Camp"], "camp": [Color(1.00, 0.80, 0.35), "Camp"],
"grave": [Color(0.85, 0.85, 0.85), "Your things"], "grave": [Color(0.85, 0.85, 0.85), "Your things"],
"wreck": [Color(0.55, 0.38, 0.22), "The wreck"],
"treasure": [Color(0.9, 0.12, 0.1), "X: your treasure"],
} }
var world: Node3D var world: Node3D
@@ -32,6 +34,7 @@ var _marks: Control
var _title: Label var _title: Label
var _facing: Label var _facing: Label
var _others_key: Label var _others_key: Label
var _key: Dictionary = {} ## mark kind -> its line in the key
func setup(w: Node3D, p: Node3D) -> void: func setup(w: Node3D, p: Node3D) -> void:
world = w world = w
@@ -83,6 +86,7 @@ func setup(w: Node3D, p: Node3D) -> void:
var l := UITheme.label("■ %s" % MARKS[k][1], 14) var l := UITheme.label("■ %s" % MARKS[k][1], 14)
l.add_theme_color_override("font_color", MARKS[k][0]) l.add_theme_color_override("font_color", MARKS[k][0])
legend.add_child(l) legend.add_child(l)
_key[k] = l
_facing = UITheme.label("", 15, UITheme.TEXT_DIM) _facing = UITheme.label("", 15, UITheme.TEXT_DIM)
_facing.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _facing.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_facing) box.add_child(_facing)
@@ -97,6 +101,13 @@ func open() -> void:
_tex.texture = ImageTexture.create_from_image(render(world)) _tex.texture = ImageTexture.create_from_image(render(world))
_title.text = "The %s" % preload("res://scripts/world/terrain.gd").worldlet_name(world.worldlet_id()) _title.text = "The %s" % preload("res://scripts/world/terrain.gd").worldlet_name(world.worldlet_id())
_others_key.visible = world.is_online() and not bool(Net.server_info.get("pvp", true)) _others_key.visible = world.is_online() and not bool(Net.server_info.get("pvp", true))
# The key lists this land's places only (the Cove has no barrow...).
var here := {"den": world.den != null and world.den.found, "barrow": world.barrow != null,
"stones": world.stones != null, "seam": world.tunnel != null, "pier": world.pier != null,
"cave": world.sea_cave != null, "wreck": world.wreck != null,
"treasure": world.wreck != null and player.inventory.count("treasure_map") > 0}
for k in _key:
_key[k].visible = bool(here.get(k, true))
visible = true visible = true
player.ui_open = true player.ui_open = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
@@ -132,10 +143,16 @@ func _draw_marks() -> void:
for m in world.map_marks(): for m in world.map_marks():
var at := _to_map(m.pos) var at := _to_map(m.pos)
var col: Color = MARKS.get(m.kind, [Color.WHITE])[0] var col: Color = MARKS.get(m.kind, [Color.WHITE])[0]
if m.kind == "treasure":
# An inked X, as on the old chart.
for d in [Vector2(1, 1), Vector2(1, -1)]:
_marks.draw_line(at - d * 8.0, at + d * 8.0, Color(0, 0, 0, 0.8), 5.0)
_marks.draw_line(at - d * 8.0, at + d * 8.0, col, 3.0)
continue
var r := 3.0 if m.kind == "build" else 6.0 var r := 3.0 if m.kind == "build" else 6.0
_marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), col) _marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), col)
_marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), Color(0, 0, 0, 0.7), false, 1.0) _marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), Color(0, 0, 0, 0.7), false, 1.0)
if m.kind in ["den", "barrow", "stones", "seam", "pier", "cave"]: if m.kind in ["den", "barrow", "stones", "seam", "pier", "cave", "wreck"]:
_marks.draw_string(font, at + Vector2(9, 5), String(m.label), HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col) _marks.draw_string(font, at + Vector2(9, 5), String(m.label), HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col)
# Other players in this land: only on a co-op server (with PvP on, the # Other players in this land: only on a co-op server (with PvP on, the
# map never gives anyone away). Green dots with their names. # map never gives anyone away). Green dots with their names.

View File

@@ -1,9 +1,11 @@
extends Control extends Control
## Pause menu (ESC): resume, controls, fullscreen, mouse sensitivity, quit. ## Pause menu (ESC): resume, controls, report a bug, fullscreen, mouse
## sensitivity, volumes, quit.
## Pauses the scene tree while open; runs with PROCESS_MODE_ALWAYS so it still ## Pauses the scene tree while open; runs with PROCESS_MODE_ALWAYS so it still
## gets input. ## gets input.
const UITheme = preload("res://scripts/ui/theme.gd") const UITheme = preload("res://scripts/ui/theme.gd")
const BugReport = preload("res://scripts/core/bug_report.gd")
const CONTROLS := [ const CONTROLS := [
["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"], ["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"],
@@ -23,6 +25,8 @@ const CONTROLS := [
var player: CharacterBody3D var player: CharacterBody3D
var _main: VBoxContainer var _main: VBoxContainer
var _controls: VBoxContainer var _controls: VBoxContainer
var _report: VBoxContainer
var _report_note: Label
var _quit_btn: Button var _quit_btn: Button
var _quit_armed := false var _quit_armed := false
var _online := false ## on a server: no pausing, no saving here var _online := false ## on a server: no pausing, no saving here
@@ -60,6 +64,7 @@ func bind(p: CharacterBody3D) -> void:
_main.add_child(title) _main.add_child(title)
_main.add_child(_button("Resume", close)) _main.add_child(_button("Resume", close))
_main.add_child(_button("Controls", _show_controls.bind(true))) _main.add_child(_button("Controls", _show_controls.bind(true)))
_main.add_child(_button("Report a bug", _show_report.bind(true)))
var fs := CheckButton.new() var fs := CheckButton.new()
fs.text = "Fullscreen" fs.text = "Fullscreen"
@@ -122,7 +127,7 @@ func bind(p: CharacterBody3D) -> void:
_main.add_child(tips_row) _main.add_child(tips_row)
var upd := CheckButton.new() var upd := CheckButton.new()
upd.text = "Check for updates at start (Emberwild %s)" % Updates.version upd.text = "Check for updates at start (Cinderwild %s)" % Updates.version
upd.focus_mode = Control.FOCUS_NONE upd.focus_mode = Control.FOCUS_NONE
upd.button_pressed = Updates.enabled upd.button_pressed = Updates.enabled
upd.add_theme_font_size_override("font_size", 16) upd.add_theme_font_size_override("font_size", 16)
@@ -174,6 +179,31 @@ func bind(p: CharacterBody3D) -> void:
_controls.add_child(grid) _controls.add_child(grid)
_controls.add_child(_button("Back", _show_controls.bind(false))) _controls.add_child(_button("Back", _show_controls.bind(false)))
# Report a bug page (0.6.0, the playtest: bug_report.gd).
_report = VBoxContainer.new()
_report.add_theme_constant_override("separation", 10)
_report.visible = false
stack.add_child(_report)
var rt := UITheme.label("Report a bug", 26)
rt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_report.add_child(rt)
var how := UITheme.label("This opens a new issue on Cinderwild's Gitea page in your browser, filled in with your version, computer and where you are in the game. Say what happened, then press Create Issue. (You'll need to be signed in there.) Nothing is sent from the game itself.", 15)
how.autowrap_mode = TextServer.AUTOWRAP_WORD
how.custom_minimum_size = Vector2(380, 0)
_report.add_child(how)
_report.add_child(_button("Open the bug report page", _on_report_open))
_report.add_child(_button("Copy the details", _on_report_copy))
_report.add_child(_button("Show the log file", _on_report_log))
var lp := UITheme.label("For a crash or an error, attach the log:\n%s" % BugReport.log_path(), 13, UITheme.TEXT_DIM)
lp.autowrap_mode = TextServer.AUTOWRAP_ARBITRARY
lp.custom_minimum_size = Vector2(380, 0)
_report.add_child(lp)
_report_note = UITheme.label("", 14, UITheme.SELECTED)
_report_note.autowrap_mode = TextServer.AUTOWRAP_WORD
_report_note.custom_minimum_size = Vector2(380, 0)
_report.add_child(_report_note)
_report.add_child(_button("Back", _show_report.bind(false)))
func _button(text: String, cb: Callable) -> Button: func _button(text: String, cb: Callable) -> Button:
var b := Button.new() var b := Button.new()
b.text = text b.text = text
@@ -189,6 +219,7 @@ func is_open() -> bool:
func open() -> void: func open() -> void:
visible = true visible = true
_show_controls(false) _show_controls(false)
_show_report(false)
_disarm_quit() _disarm_quit()
player.ui_open = true player.ui_open = true
get_tree().paused = not _online # a shared world can't be paused by one player get_tree().paused = not _online # a shared world can't be paused by one player
@@ -215,10 +246,12 @@ func close() -> void:
get_tree().paused = false get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
## ESC on the controls page goes back; on the main page it resumes. ## ESC on the controls or bug report page goes back; on the main page it resumes.
func back() -> void: func back() -> void:
if _controls.visible: if _controls.visible:
_show_controls(false) _show_controls(false)
elif _report.visible:
_show_report(false)
else: else:
close() close()
@@ -226,6 +259,31 @@ func _show_controls(on: bool) -> void:
_controls.visible = on _controls.visible = on
_main.visible = not on _main.visible = not on
func _show_report(on: bool) -> void:
_report.visible = on
_main.visible = not on
_report_note.text = ""
func _world() -> Node:
return player.get_parent() if player != null else null
func _on_report_open() -> void:
if DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_FULLSCREEN:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) # (so the browser isn't hidden behind the game)
if BugReport.open(_world()):
_report_note.text = "Opened in your browser. (Can't post there? Copy the details and send them to whoever gave you the game.)"
else:
DisplayServer.clipboard_set(BugReport.issue_url(_world()))
_report_note.text = "Couldn't open a browser: the link's on your clipboard to paste into one."
func _on_report_copy() -> void:
DisplayServer.clipboard_set(BugReport.details_text(_world()))
_report_note.text = "Copied: paste them into your message."
func _on_report_log() -> void:
OS.shell_open(BugReport.log_dir())
_report_note.text = "The log is godot.log in that folder."
func _on_fullscreen(on: bool) -> void: func _on_fullscreen(on: bool) -> void:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if on else DisplayServer.WINDOW_MODE_WINDOWED) DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if on else DisplayServer.WINDOW_MODE_WINDOWED)

View File

@@ -80,7 +80,7 @@ func _ready() -> void:
col.offset_right = 520 col.offset_right = 520
col.add_theme_constant_override("separation", 14) col.add_theme_constant_override("separation", 14)
root.add_child(col) root.add_child(col)
col.add_child(UITheme.label("EMBERWILD", 54)) col.add_child(UITheme.label("CINDERWILD", 54))
col.add_child(UITheme.label("Survive the island. Build a home.", 18, UITheme.TEXT_DIM)) col.add_child(UITheme.label("Survive the island. Build a home.", 18, UITheme.TEXT_DIM))
var spacer := Control.new() var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 24) spacer.custom_minimum_size = Vector2(0, 24)
@@ -413,7 +413,7 @@ func _host_page() -> Control:
func _fill_host() -> void: func _fill_host() -> void:
var c: RefCounted = load("res://scripts/net/server_config.gd").open(Net.host_config) var c: RefCounted = load("res://scripts/net/server_config.gd").open(Net.host_config)
var n: String = c.data.server_name var n: String = c.data.server_name
_host_name.text = "%s's island" % Net.get_identity().name if n == "Emberwild server" else n _host_name.text = "%s's island" % Net.get_identity().name if n in ["Cinderwild server", "Emberwild server"] else n
_host_port.text = str(int(c.data.port)) _host_port.text = str(int(c.data.port))
_host_pw.text = String(c.data.password) _host_pw.text = String(c.data.password)
var preset := "co-op" var preset := "co-op"
@@ -801,7 +801,7 @@ var version_label: Label
## The version, bottom left; and a card bottom right when a newer one is out. ## The version, bottom left; and a card bottom right when a newer one is out.
func _build_update_banner(root: Control) -> void: func _build_update_banner(root: Control) -> void:
# (bottom right: the menu column runs down the left and would cover it) # (bottom right: the menu column runs down the left and would cover it)
version_label = UITheme.label("Emberwild %s" % Updates.version, 13, UITheme.TEXT_DIM) version_label = UITheme.label("Cinderwild %s" % Updates.version, 13, UITheme.TEXT_DIM)
version_label.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT) version_label.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
version_label.grow_horizontal = Control.GROW_DIRECTION_BEGIN version_label.grow_horizontal = Control.GROW_DIRECTION_BEGIN
version_label.grow_vertical = Control.GROW_DIRECTION_BEGIN version_label.grow_vertical = Control.GROW_DIRECTION_BEGIN
@@ -842,7 +842,7 @@ func _on_update_checked(s: String) -> void:
return return
var notes := String(Updates.latest.get("notes", "")) var notes := String(Updates.latest.get("notes", ""))
var how := "" if Updates.packaged() else "\nRunning from the source: git pull in the game folder, then restart." var how := "" if Updates.packaged() else "\nRunning from the source: git pull in the game folder, then restart."
_update_text.text = "Emberwild %s is out (you have %s).%s%s" % [Updates.latest_version(), Updates.version, _update_text.text = "Cinderwild %s is out (you have %s).%s%s" % [Updates.latest_version(), Updates.version,
("\n" + notes) if not notes.is_empty() else "", how] ("\n" + notes) if not notes.is_empty() else "", how]
_update_btn.text = "Download" if Updates.packaged() and not String(Updates.download().get("url", "")).is_empty() else "Release page" _update_btn.text = "Download" if Updates.packaged() and not String(Updates.download().get("url", "")).is_empty() else "Release page"
_update_btn.disabled = Updates.download_url().is_empty() _update_btn.disabled = Updates.download_url().is_empty()
@@ -858,7 +858,7 @@ static func version_advice(reason: String) -> String:
if not reason.begins_with("Different game version"): if not reason.begins_with("Different game version"):
return "" return ""
if Updates.state == "available": if Updates.state == "available":
return "\nEmberwild %s is out: Download is on the title page." % Updates.latest_version() return "\nCinderwild %s is out: Download is on the title page." % Updates.latest_version()
if reason.ends_with("Update the game to join."): if reason.ends_with("Update the game to join."):
return "\nThere's no newer release yet: the server may be running a newer copy from the source." return "\nThere's no newer release yet: the server may be running a newer copy from the source."
return "" return ""

22
scripts/world/boat.gd Normal file
View File

@@ -0,0 +1,22 @@
extends StaticBody3D
## A rowing boat tied up at the pier (moorings.gd): whose it is, and, for
## its owner, "Set sail".
const PostScript = preload("res://scripts/world/mooring_post.gd")
var moorings: Node3D
var owner_id := ""
var rest := Transform3D() ## where it's tied up (it bobs about this)
func mine(player: Node) -> bool:
return PostScript.id_of(player) == owner_id
func interact_prompt(player: Node) -> String:
if mine(player):
return "Your rowing boat [E] Set sail"
var who := String(moorings.moored.get(owner_id, {}).get("name", ""))
return "%s's rowing boat" % who if not who.is_empty() else "Someone's rowing boat"
func interact(player: Node) -> void:
if mine(player):
moorings.set_sail(player)

View File

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

View File

@@ -0,0 +1,47 @@
extends Node3D
## Where a band of the drowned crew rose (treasure.gd): what their guard
## brains (guard.gd) ask of their "barrow". They're up and angry from the
## start, chase whoever's nearest within LEASH of the dig, and go back to
## stand round it when nobody is. By day, once nobody's been near for a
## while, they sink back into the ground.
const LEASH := 20.0
const SINK_AFTER := 30.0 ## seconds with nobody near, by day
var world: Node3D
var spot := Vector3.ZERO
var alarmed := true
var crew: Array = []
var _alone := 0.0
func center() -> Vector3:
return spot
func wakes_for(p: Node3D) -> bool:
return alarmed and not p.survival.dead
func alarm() -> void:
alarmed = true
## (Out in the open: they walk on the ground.)
func floor_y_at(_p: Vector3) -> float:
return -INF
func is_night() -> bool:
var h: float = world.day_night.hours()
return h >= 19.5 or h < 5.5
func alive() -> Array:
return crew.filter(func(g): return is_instance_valid(g) and not g.is_dead())
func _physics_process(delta: float) -> void:
var anyone := false
for p in world.players():
if not p.survival.dead and p.global_position.distance_to(spot) < LEASH:
anyone = true
_alone = 0.0 if anyone or is_night() else _alone + delta
if alive().is_empty() or _alone > SINK_AFTER:
for g in crew:
if is_instance_valid(g) and not g.is_dead():
g.queue_free()
queue_free()

View File

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

View File

@@ -0,0 +1,25 @@
extends StaticBody3D
## The mooring post at the end of the pier (moorings.gd): E ties up the
## rowing boat in your bag. Online, your game asks the server.
var moorings: Node3D
## Whose boat: the player's id (a server's character, or, at home, yours).
static func id_of(player: Node) -> String:
var pid: Variant = player.get("player_id")
if pid != null and not String(pid).is_empty():
return String(pid)
return String(Net.get_identity().player_id)
func interact_prompt(player: Node) -> String:
if moorings.has_boat(id_of(player)):
return "Mooring post (your boat's tied up here)"
if player.inventory.count(moorings.BOAT_ITEM) > 0:
return "Mooring post [E] Tie up your boat"
return "Mooring post (build a rowing boat at a workbench, and tie it up here)"
func interact(player: Node) -> void:
if moorings.world.is_online():
PlayerNet.ask("moor", [])
return
moorings.moor(player, id_of(player))

View File

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

158
scripts/world/moorings.gd Normal file
View File

@@ -0,0 +1,158 @@
extends Node3D
## The Cove's moorings (0.6.0, docs/WORLDLETS_DESIGN.md §5): berths along
## the end of the pier, and a mooring post on the deck. Build a rowing boat at
## a workbench, carry it down to the pier and press E at the post: it's tied
## up at a berth of its own, one boat for each player, and it stays there
## (saved with the Cove; online the server decides, and everyone sees
## everyone's boat). Your own boat's prompt is "Set sail", but nobody's
## charted the way to the Highlands yet: that's the next part of the journey,
## and the ship's compass from the pirate chest is what it'll need.
const ItemMeshes = preload("res://scripts/items/item_meshes.gd")
const BoatScript = preload("res://scripts/world/boat.gd")
const PostScript = preload("res://scripts/world/mooring_post.gd")
const BERTHS := 6 ## three each side (any further in, it shallows)
const BOAT_SCALE := 8.0 ## the item's model (0.55 m) up to a boat (4.4 m)
const BERTH_GAP := 5.0
const BOAT_ITEM := "rowing_boat"
var world: Node3D
var pier: Node3D
var moored: Dictionary = {} ## player id -> {"berth": i, "name": who}
var boats: Dictionary = {} ## player id -> boat.gd
var post: StaticBody3D
var _t := 0.0
func setup(w: Node3D) -> bool:
world = w
pier = w.pier
if pier == null:
return false
add_to_group("moorings")
post = StaticBody3D.new()
post.name = "MooringPost"
post.collision_layer = 1 | 2
post.set_script(PostScript)
post.moorings = self
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
pier._box(st, Vector3(0, 0.45, 0), Vector3(0.24, 0.9, 0.24), pier.POST)
pier._box(st, Vector3(0, 0.8, 0), Vector3(0.32, 0.08, 0.32), pier.POST.darkened(0.2))
pier._box(st, Vector3(0, 0.62, 0), Vector3(0.27, 0.1, 0.27), Color(0.62, 0.55, 0.38)) # (a coil of rope)
st.generate_normals()
var mi := MeshInstance3D.new()
mi.mesh = st.commit()
var mat := StandardMaterial3D.new()
mat.vertex_color_use_as_albedo = true
mat.roughness = 0.9
mi.material_override = mat
post.add_child(mi)
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(0.3, 0.9, 0.3)
cs.shape = box
cs.position = Vector3(0, 0.45, 0)
post.add_child(cs)
add_child(post)
post.global_position = pier.end - pier.dir * 1.2 + _right() * (pier.WIDTH * 0.5 - 0.3)
return true
func _right() -> Vector3:
return pier.dir.cross(Vector3.UP).normalized()
## Where berth `i` is: alongside the end of the pier, in pairs, bow out to sea.
func berth_xf(i: int) -> Transform3D:
var side := 1.0 if i % 2 == 0 else -1.0
var along: Vector3 = pier.end - pier.dir * (2.0 + (i / 2) * BERTH_GAP)
var p: Vector3 = along + _right() * side * (pier.WIDTH * 0.5 + 1.0)
p.y = world.terrain.sea_level - 0.08
return Transform3D(Basis.looking_at(pier.dir, Vector3.UP), p)
func has_boat(id: String) -> bool:
return moored.has(id)
func _free_berth() -> int:
var used := moored.values().map(func(m): return int(m.berth))
for i in range(BERTHS):
if not i in used:
return i
return -1
## Player `p` (id `id`) ties up the boat in their bag. Says why not and
## returns false when they can't.
func moor(p: Node, id: String) -> bool:
if has_boat(id):
Actions.tell(p, "Your boat's already tied up here")
return false
if p.inventory.count(BOAT_ITEM) <= 0:
Actions.tell(p, "Build a rowing boat at a workbench and bring it here to tie it up")
return false
var b := _free_berth()
if b < 0:
Actions.tell(p, "Every berth along the pier is taken")
return false
p.inventory.remove(BOAT_ITEM, 1)
var who := String(p.get("display")) if p.get("display") != null else ""
moored[id] = {"berth": b, "name": who}
_sync()
Actions.tell(p, "You tie your boat up at the end of the pier")
Sfx.at("creak", post.global_position)
if Net.is_server():
WorldNet.mark_changed()
return true
## Your boat: off to the Highlands. Not yet.
func set_sail(p: Node) -> void:
if p.inventory.count("ship_compass") <= 0:
Actions.tell(p, "Past the bay it's open sea: without a ship's compass you'd be lost within the hour")
return
Actions.tell(p, "The compass needle points north over the water, but nobody's charted the way to the Highlands yet. (That's the next part of the journey: coming in a later version.)")
func _sync() -> void:
for id in boats.keys():
if not moored.has(id):
boats[id].queue_free()
boats.erase(id)
for id in moored:
if boats.has(id):
continue
var b: StaticBody3D = StaticBody3D.new()
b.name = "Boat_%d" % int(moored[id].berth)
b.collision_layer = 1 | 2
b.set_script(BoatScript)
b.moorings = self
b.owner_id = String(id)
var mi := MeshInstance3D.new()
mi.name = "Hull"
mi.mesh = ItemMeshes.build(BOAT_ITEM)
mi.material_override = ItemDB.item_material()
mi.scale = Vector3.ONE * BOAT_SCALE
b.add_child(mi)
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(1.4, 0.6, 4.2)
cs.shape = box
cs.position = Vector3(0, 0.3, 0)
b.add_child(cs)
add_child(b)
b.global_transform = berth_xf(int(moored[id].berth))
b.rest = b.global_transform
boats[id] = b
## A gentle bob on the swell (just the look).
func _process(delta: float) -> void:
_t += delta
for id in boats:
var b: Node3D = boats[id]
var k := float(int(moored[id].berth)) * 1.7
b.global_transform = b.rest.translated(Vector3(0, sin(_t * 1.1 + k) * 0.05, 0)) * Transform3D(Basis(Vector3(0, 0, 1), sin(_t * 0.8 + k) * 0.03), Vector3.ZERO)
func to_data() -> Dictionary:
return {"moored": moored.duplicate(true)}
func load_data(d: Dictionary) -> void:
moored = {}
var m: Dictionary = d.get("moored", {})
for id in m:
moored[String(id)] = {"berth": int(m[id].get("berth", 0)), "name": String(m[id].get("name", ""))}
_sync()

View File

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

View File

@@ -409,6 +409,8 @@ func _scatter_trees() -> void:
node.drops_final = {"wood": [2, 3], "stick": [2, 4]} node.drops_final = {"wood": [2, 3], "stick": [2, 4]}
node.max_hp = 3.0 node.max_hp = 3.0
node.hp = 3.0 node.hp = 3.0
elif vi in conifers:
node.drops_final["pine_resin"] = [1, 2] # (0.6.0: pitch for a boat)
var cs := CollisionShape3D.new() var cs := CollisionShape3D.new()
cs.shape = cap cs.shape = cap
cs.position = Vector3(0, cap.height * 0.5 + 0.1, 0) cs.position = Vector3(0, cap.height * 0.5 + 0.1, 0)
@@ -664,9 +666,9 @@ func _scatter_pickups() -> void:
rng.seed = world_seed + 6 rng.seed = world_seed + 6
# Order matters: new kinds go at the END so existing saves keep their ids. # Order matters: new kinds go at the END so existing saves keep their ids.
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150, var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150,
"iron_nugget": 70, "clay": 60, "salt": 40} "iron_nugget": 70, "clay": 60, "salt": 40, "driftwood": 45, "seagrass": 40}
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45, var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45,
"iron_nugget": 0.25, "clay": 0.3, "salt": 0.25} "iron_nugget": 0.25, "clay": 0.3, "salt": 0.25, "driftwood": 0.5, "seagrass": 0.4}
for id: String in want: for id: String in want:
# Fibre grows as a plant (wild flax) you pick, 2 fibre a clump. # Fibre grows as a plant (wild flax) you pick, 2 fibre a clump.
var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else ItemDB.mesh_for(id) var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else ItemDB.mesh_for(id)
@@ -709,10 +711,20 @@ func _scatter_pickups() -> void:
var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h) var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h)
chance = shore * 0.95 if by_lake else 0.0 chance = shore * 0.95 if by_lake else 0.0
"salt": "salt":
# Crusts on the cliff tops above the sea (P4). # Crusts on the cliff tops above the sea (P4), or (the Cove)
# on the sand just above the waterline.
chance = 0.95 if terrain.sea_weight(x, z) > 0.6 and _near_rim(x, z) > 0.35 else 0.0 chance = 0.95 if terrain.sea_weight(x, z) > 0.6 and _near_rim(x, z) > 0.35 else 0.0
if terrain.beach_weight(x, z) > 0.6 and h < terrain.water_level + 1.2:
chance = 0.6
"driftwood":
# Washed up along a beach (the Cove), near the waterline:
# what a boat's hull is made of.
chance = 0.95 if terrain.beach_weight(x, z) > 0.6 and h < terrain.water_level + 1.6 else 0.0
"seagrass":
# (0.6.0) Washed up at the tide line, for a boat's rope.
chance = 0.95 if terrain.beach_weight(x, z) > 0.6 and h < terrain.water_level + 0.8 else 0.0
# A few right by the spawn so the first craft is quick. # A few right by the spawn so the first craft is quick.
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt"]: if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt", "driftwood", "seagrass"]:
chance += 0.5 chance += 0.5
if keep > chance: if keep > chance:
continue continue

View File

@@ -768,6 +768,14 @@ func sea_weight(x: float, z: float) -> float:
_ensure_noise() _ensure_noise()
return _sea_w(x, z) return _sea_w(x, z)
## Whether (x, z) is on the sand (0.6.0): the beach side of the rim, low
## enough for the sandy shoreline (terrain.gdshader paints sand below
## water_level + SAND_TOP), or under the water off it.
const SAND_TOP := 1.2
func on_sand(x: float, z: float) -> bool:
_ensure_noise()
return _beach_w(x, z) > 0.5 and sample_height(x, z) < water_level + SAND_TOP
## How strongly (x, z) lies on the beach side of the rim (0..1; W2). ## How strongly (x, z) lies on the beach side of the rim (0..1; W2).
func beach_weight(x: float, z: float) -> float: func beach_weight(x: float, z: float) -> float:
_ensure_noise() _ensure_noise()

209
scripts/world/treasure.gd Normal file
View File

@@ -0,0 +1,209 @@
extends Node3D
## The pirate treasure (0.6.0, docs/WORLDLETS_DESIGN.md §5). Each player's
## treasure map (the wreck's sea chest) has its own X (world.treasure_spot).
## While you carry your map, a mound of loose earth by a marker stone shows
## at your X (only to you); a pickaxe digs it, and the pirate chest comes up:
## a ship's compass, a spyglass, a cutlass and pirate gold. The map's used up.
## Once for each player.
##
## The drowned crew guard it: digging at night brings them up round you on
## the first blow; by day they rise as the chest comes up. They're the Cove's
## barrow guards (guard.gd, kind "drowned", at a crew_site.gd).
##
## Single-player, all here. Online, the server keeps whose treasure's been
## dug and how far, gives the loot and runs the crew; your game only shows
## your own mound and asks the server to dig it.
const MoundScript = preload("res://scripts/world/treasure_mound.gd")
const CrewSite = preload("res://scripts/world/crew_site.gd")
const GuardScript = preload("res://scripts/creatures/guard.gd")
const RockPile = preload("res://scripts/world/rock_pile.gd")
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
const LOOT := {"ship_compass": 1, "spyglass": 1, "cutlass": 1, "pirate_gold": 3}
const CREW := 3
const DIG_HP := 18.0
var world: Node3D
var dug: Array = [] ## the player ids whose treasure's been dug up
var left: Dictionary = {} ## player id -> what's left of their mound (dug a bit)
var mounds: Dictionary = {} ## player id -> treasure_mound.gd
var crews: Dictionary = {} ## player id -> crew_site.gd, while they're up
var force_crew := false ## (tests) raise the crew even with creatures off
var _look_left := 0.0
func setup(w: Node3D) -> bool:
world = w
if w.wreck == null:
return false
Actions.harvested.connect(_on_harvested)
add_to_group("treasure")
return true
# --- the mounds ---------------------------------------------------------------------
## Player `id`'s mound (made if need be), or null if theirs is dug or they
## have no X here.
func mound_for(id: String) -> StaticBody3D:
if id in dug:
return null
if mounds.has(id) and is_instance_valid(mounds[id]):
return mounds[id]
var at: Vector3 = world.treasure_spot(id)
if at == Vector3.INF:
return null
var m: StaticBody3D = MoundScript.new()
m.name = "Mound_%s" % id.substr(0, 8)
m.treasure = self
m.owner_id = id
m.uid = "treasure:" + id
m.max_hp = DIG_HP
m.hp = clampf(float(left.get(id, DIG_HP)), 0.5, DIG_HP)
m.collision_layer = 1
m.add_to_group("harvestable")
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(1.6, 0.6, 1.6)
cs.shape = box
cs.position = Vector3(0, 0.2, 0)
m.add_child(cs)
if not world.server_mode:
_dress(m, id)
add_child(m)
m.global_position = at
mounds[id] = m
return m
## How a mound looks: loose earth, a marker stone, two sticks crossed in it.
func _dress(m: Node3D, id: String) -> void:
var st := ProcMesh._begin()
var r := RandomNumberGenerator.new()
r.seed = hash(id)
var n := FastNoiseLite.new()
n.seed = hash(id) % 1000
n.frequency = 1.5
ProcMesh._blob(st, Vector3(0, 0.05, 0), Vector3(0.9, 0.28, 0.8), n, 0.3, Color(0.34, 0.25, 0.16), r, 0)
ProcMesh._cylinder(st, Vector3(-0.25, 0.1, -0.25), Vector3(0.25, 0.7, 0.25), 0.03, 0.025, 4, Color(0.4, 0.3, 0.18), r)
ProcMesh._cylinder(st, Vector3(0.25, 0.1, -0.25), Vector3(-0.25, 0.7, 0.25), 0.03, 0.025, 4, Color(0.4, 0.3, 0.18), r)
var mi := MeshInstance3D.new()
mi.mesh = ProcMesh._commit(st)
var mat := StandardMaterial3D.new()
mat.vertex_color_use_as_albedo = true
mat.vertex_color_is_srgb = true
mat.roughness = 1.0
mi.material_override = mat
m.add_child(mi)
RockPile.add(m, null, hash(id) % 997, Vector3(1.3, 0.35, 0.2), Vector3(0.35, 0.8, 0.3), 0.6, RockPile.material(world.terrain), true)
## (Your game) your mound shows while you carry your map and it's not dug.
func _process(delta: float) -> void:
if world.server_mode:
return
_look_left -= delta
if _look_left > 0.0:
return
_look_left = 0.5
var me: String = world.my_id()
var want: bool = not me in dug and world.player.inventory.count("treasure_map") > 0
if want:
mound_for(me)
elif mounds.has(me) and is_instance_valid(mounds[me]):
mounds[me].queue_free()
mounds.erase(me)
# --- digging ------------------------------------------------------------------------
## A blow on a mound (treasure_mound.gd): remembered, and at night the first
## one brings the crew up.
func mound_hit(m: Node) -> void:
left[m.owner_id] = m.hp
if Net.is_server():
WorldNet.treasure_state(to_data())
if _night() and not crews.has(m.owner_id):
_raise_crew(m.owner_id, m.global_position)
func _on_harvested(p: Node, node: Node, felled: bool) -> void:
if not felled or node == null or node.get_script() != MoundScript or node.treasure != self:
return
_dug_up(p, String(node.owner_id), node.global_position)
## The chest comes up: the loot for the player who dug it, the map used up.
func _dug_up(p: Node, id: String, at: Vector3) -> void:
if id in dug:
return
dug.append(id)
left.erase(id)
if mounds.has(id) and is_instance_valid(mounds[id]):
mounds[id].queue_free()
mounds.erase(id)
p.inventory.remove("treasure_map", 1)
var spilled := false
for item in LOOT:
var rest: int = p.inventory.add(item, int(LOOT[item]))
if rest > 0:
spilled = true
world.drop_item(item, rest, Transform3D(Basis(), at + Vector3(0, 0.3, 0)))
Actions.tell(p, "The pirate chest! A ship's compass, a spyglass, a cutlass and %d pirate gold%s" % [
int(LOOT.pirate_gold), " (some at your feet: your bag's full)" if spilled else ""])
Sfx.at("door_open", at)
if not crews.has(id):
_raise_crew(id, at)
if crews.has(id):
Actions.tell(p, "Something's clawing its way up out of the ground...")
if Net.is_server():
WorldNet.treasure_state(to_data())
func _night() -> bool:
var h: float = world.day_night.hours()
return h >= 19.5 or h < 5.5
## The drowned crew rise round `at` (where creatures run: here alone, or the
## server; not with --no-creatures, unless a test asks).
func _raise_crew(id: String, at: Vector3) -> void:
if world.is_online():
return
var running: bool = force_crew or (not "--no-creatures" in OS.get_cmdline_user_args() \
and (world.server_mode or world.creatures.enabled))
if not running:
return
var site: Node3D = CrewSite.new()
site.name = "Crew_%s" % id.substr(0, 8)
site.world = world
site.spot = at
add_child(site)
var t: Node3D = world.terrain
for i in range(CREW):
var a := TAU * i / CREW + float(hash(id) % 100) * 0.06
var post := at + Vector3(cos(a), 0, sin(a)) * 5.0
post.y = t.sample_height(post.x, post.z)
var g: AnimatableBody3D = GuardScript.new()
g.setup("drowned", world, post, hash(id) % 10000 + i)
g.barrow = site
g.post = post
g.post_yaw = atan2(-(at - post).x, -(at - post).z)
g.state = "charge"
g.yaw = g.post_yaw
g.rotation.y = g.yaw
g.top_level = true
site.add_child(g)
g.global_position = post
site.crew.append(g)
crews[id] = site
site.tree_exited.connect(func(): crews.erase(id))
# --- saving ------------------------------------------------------------------------
func to_data() -> Dictionary:
return {"dug": dug.duplicate(), "left": left.duplicate()}
func load_data(d: Dictionary) -> void:
dug = (d.get("dug", []) as Array).duplicate()
left = (d.get("left", {}) as Dictionary).duplicate()
for id in mounds.keys():
var m: Node = mounds[id]
if not is_instance_valid(m):
mounds.erase(id)
elif id in dug:
m.queue_free()
mounds.erase(id)
else:
m.hp = clampf(float(left.get(id, DIG_HP)), 0.5, DIG_HP)

View File

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

View File

@@ -0,0 +1,35 @@
extends StaticBody3D
## Your X, on the ground (treasure.gd): a mound of loose earth by a marker
## stone, with two sticks crossed in it. It answers like a boulder does
## (Actions.harvest_hit), so a pickaxe digs it; the treasure decides the rest.
var treasure: Node3D
var owner_id := ""
var uid := ""
var tool_kind := "pickaxe"
var min_tier := 1
var max_hp := 18.0
var hp := 18.0
var depleted := false
var kind := "rock"
var chip_color := Color(0.36, 0.27, 0.18)
func display_name() -> String:
return "Loose earth"
func refusal() -> String:
return ""
func interact_prompt(_player: Node) -> String:
return "Loose earth by a marker stone: X marks the spot. [LMB] Dig with a pickaxe (about %d blows left)" % ceili(hp)
func interact(player: Node) -> void:
Actions.tell(player, interact_prompt(player))
## One blow (from Actions.harvest_hit).
func hit(damage: float, _at: Vector3, _from: Vector3) -> Dictionary:
hp = maxf(hp - damage, 0.0)
treasure.mound_hit(self)
if hp <= 0.0 and not depleted:
depleted = true
return {}

View File

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

View File

@@ -55,6 +55,9 @@ var _pieces: Node3D
var hud: CanvasLayer var hud: CanvasLayer
var pier: Node3D ## the Cove's pier (scripts/world/pier.gd), or null var pier: Node3D ## the Cove's pier (scripts/world/pier.gd), or null
var tunnel: Node3D ## the cliff tunnel (scripts/world/tunnel.gd, J1), or null var tunnel: Node3D ## the cliff tunnel (scripts/world/tunnel.gd, J1), or null
var wreck: Node3D ## the Cove's wreck (scripts/world/wreck.gd, 0.6.0), or null
var treasure: Node3D ## the pirate treasure (scripts/world/treasure.gd), or null
var moorings: Node3D ## the boats tied up at the pier (scripts/world/moorings.gd), or null
var sea_cave: Node3D ## the Cove's end of it (scripts/world/sea_cave.gd), or null var sea_cave: Node3D ## the Cove's end of it (scripts/world/sea_cave.gd), or null
var traveling := false ## on the way to another worldlet (the scene is about to change) var traveling := false ## on the way to another worldlet (the scene is about to change)
var _session_time := 0.0 ## seconds played since the last save var _session_time := 0.0 ## seconds played since the last save
@@ -72,6 +75,7 @@ var bobber: Node3D ## bobber.gd: the cast fishing float, if any
var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site) var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site)
var bears: Node3D ## bear_range.gd: the island's bear (null online as a client) var bears: Node3D ## bear_range.gd: the island's bear (null online as a client)
var thieves: Node3D ## thieves.gd: crows and foxes after food left out var thieves: Node3D ## thieves.gd: crows and foxes after food left out
var shore: Node3D ## shore.gd: the Cove's crabs (null in a land without them)
var hazards: Node3D ## hazards.gd: lightning on trees, hard-winter ice (P4) var hazards: Node3D ## hazards.gd: lightning on trees, hard-winter ice (P4)
var stones: Node3D ## standing_stones.gd: the altar and the Old Stag (P5) var stones: Node3D ## standing_stones.gd: the altar and the Old Stag (P5)
var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run) var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run)
@@ -365,6 +369,31 @@ func _on_terrain_ready() -> void:
props.pier = pr props.pier = pr
else: else:
pr.free() pr.free()
# The moorings (0.6.0): rowing boats tied up at the end of the pier.
if pier != null and not menu_mode:
var mo := preload("res://scripts/world/moorings.gd").new()
mo.name = "Moorings"
add_child(mo)
if mo.setup(self):
moorings = mo
else:
mo.free()
# The wreck (0.6.0): on the Cove's beach, with the sea chest's treasure maps.
var wk := preload("res://scripts/world/wreck.gd").new()
wk.name = "Wreck"
add_child(wk)
if not menu_mode and wk.place(self):
wreck = wk
props.keep_clear.append([wk.global_position, 9.0])
var tr := preload("res://scripts/world/treasure.gd").new()
tr.name = "Treasure"
add_child(tr)
if tr.setup(self):
treasure = tr
else:
tr.free()
else:
wk.free()
# The journey's ways on (J1): the Valley's cliff tunnel and the Cove's sea # The journey's ways on (J1): the Valley's cliff tunnel and the Cove's sea
# cave. On a server too (J3): the server decides the digging and the # cave. On a server too (J3): the server decides the digging and the
# crossings, and every player's game has the same rock to see and walk. # crossings, and every player's game has the same rock to see and walk.
@@ -394,13 +423,16 @@ func _on_terrain_ready() -> void:
props.static_cuts.append([den.global_transform, 2.2, 3.4]) props.static_cuts.append([den.global_transform, 2.2, 3.4])
_update_grass_cuts() _update_grass_cuts()
# The barrow: scenery too (P3), out on open ground. Its guards only run # The barrow: scenery too (P3), out on open ground. Its guards only run
# where creatures do (_setup_bear). # where creatures do (_setup_bear). (Each land has its own set pieces:
# the barrow and the stones are the Valley's: has_place().)
if has_place("barrow"):
barrow = preload("res://scripts/world/barrow.gd").new() barrow = preload("res://scripts/world/barrow.gd").new()
barrow.name = "Barrow" barrow.name = "Barrow"
add_child(barrow) add_child(barrow)
if barrow.place(self): if barrow.place(self):
_update_grass_cuts() _update_grass_cuts()
# The standing stones (P5): where the Old Stag is called. # The standing stones (P5): where the Old Stag is called.
if has_place("stones"):
stones = preload("res://scripts/world/standing_stones.gd").new() stones = preload("res://scripts/world/standing_stones.gd").new()
stones.name = "StandingStones" stones.name = "StandingStones"
add_child(stones) add_child(stones)
@@ -420,6 +452,7 @@ func _on_terrain_ready() -> void:
hazards.name = "Hazards" hazards.name = "Hazards"
add_child(hazards) add_child(hazards)
hazards.setup(self, not is_online()) hazards.setup(self, not is_online())
if stones != null:
stones.deciding = not is_online() stones.deciding = not is_online()
if server_mode: if server_mode:
if Saves.has_world(): if Saves.has_world():
@@ -491,6 +524,14 @@ func _setup_bear() -> void:
thieves.enabled = not "--no-creatures" in OS.get_cmdline_user_args() thieves.enabled = not "--no-creatures" in OS.get_cmdline_user_args()
if barrow != null and barrow.found and thieves.enabled: if barrow != null and barrow.found and thieves.enabled:
barrow.start() barrow.start()
if has_wildlife("crabs"):
shore = preload("res://scripts/creatures/shore.gd").new()
shore.name = "Shore"
add_child(shore)
shore.setup(self)
shore.enabled = thieves.enabled
if not has_place("bear"):
return # (bears only in the lands that list one: the Valley)
bears = preload("res://scripts/creatures/bear_range.gd").new() bears = preload("res://scripts/creatures/bear_range.gd").new()
bears.name = "Bear" bears.name = "Bear"
add_child(bears) add_child(bears)
@@ -684,6 +725,9 @@ func collect_save() -> Dictionary:
"barrow": barrow.save_data() if barrow != null else {}, "barrow": barrow.save_data() if barrow != null else {},
"stones": stones.save_data() if stones != null else {}, "stones": stones.save_data() if stones != null else {},
"tunnel": tunnel.to_data() if tunnel != null else {}, "tunnel": tunnel.to_data() if tunnel != null else {},
"wreck": wreck.to_data() if wreck != null else {},
"treasure": treasure.to_data() if treasure != null else {},
"moorings": moorings.to_data() if moorings != null else {},
"map": map_data(), "map": map_data(),
} }
@@ -700,6 +744,9 @@ func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, drop
"barrow": barrow.save_data() if barrow != null else {}, "barrow": barrow.save_data() if barrow != null else {},
"stones": stones.save_data() if stones != null else {}, "stones": stones.save_data() if stones != null else {},
"tunnel": tunnel.to_data() if tunnel != null else {}, "tunnel": tunnel.to_data() if tunnel != null else {},
"wreck": wreck.to_data() if wreck != null else {},
"treasure": treasure.to_data() if treasure != null else {},
"moorings": moorings.to_data() if moorings != null else {},
} }
## The live pieces (placed and not being removed). ## The live pieces (placed and not being removed).
@@ -833,6 +880,12 @@ func _apply_save(s: Dictionary, with_player: bool) -> void:
stones.load_data(s.get("stones", {})) stones.load_data(s.get("stones", {}))
if tunnel != null: if tunnel != null:
tunnel.load_data(s.get("tunnel", {})) tunnel.load_data(s.get("tunnel", {}))
if wreck != null:
wreck.load_data(s.get("wreck", {}))
if treasure != null:
treasure.load_data(s.get("treasure", {}))
if moorings != null:
moorings.load_data(s.get("moorings", {}))
if with_player: if with_player:
load_map_data(String(s.get("map", ""))) load_map_data(String(s.get("map", "")))
# The bear (bear_range.gd): applied when it's set up, after loading. # The bear (bear_range.gd): applied when it's set up, after loading.
@@ -965,6 +1018,12 @@ func map_marks() -> Array:
add.call("pier", (pier.start + pier.end) * 0.5, "The pier") add.call("pier", (pier.start + pier.end) * 0.5, "The pier")
if sea_cave != null: if sea_cave != null:
add.call("cave", sea_cave.global_position, "Sea cave") add.call("cave", sea_cave.global_position, "Sea cave")
if wreck != null:
add.call("wreck", wreck.global_position, "The wreck")
# Your X: shown while you carry the map (even where you haven't been).
var x := treasure_spot(my_id())
if x != Vector3.INF and player.inventory.count("treasure_map") > 0:
out.append({"kind": "treasure", "pos": x, "label": "X"})
for piece in all_pieces(): for piece in all_pieces():
var id: String = String(piece.get("item_id")) var id: String = String(piece.get("item_id"))
if piece.is_in_group("bed"): if piece.is_in_group("bed"):
@@ -977,6 +1036,70 @@ func map_marks() -> Array:
add.call("grave", piece.global_position, "Your things") add.call("grave", piece.global_position, "Your things")
return out return out
# --- the pirate treasure (0.6.0, docs/WORLDLETS_DESIGN.md §5) ----------------------
var _treasure_spots: Dictionary = {} ## player id -> Vector3 (worked out once)
## Where player `id`'s treasure is buried (each player's map has its own X):
## a dry, gentle spot in the Cove's hills, picked from the world's seed and
## the player's id, clear of the water, the stream and the other places.
## INF where there's no treasure (no wreck in this land).
func treasure_spot(id: String) -> Vector3:
if wreck == null:
return Vector3.INF
if _treasure_spots.has(id):
return _treasure_spots[id]
var spots := treasure_spots()
var at: Vector3 = Vector3.INF if spots.is_empty() else spots[posmod(hash(id), spots.size())]
_treasure_spots[id] = at
return at
## The world's hiding places for treasure: up to 12, at least 25 m apart,
## picked from its seed (so each player's X is one of these, and different
## players' are usually far apart).
var _hiding: Array = []
func treasure_spots() -> Array:
if not _hiding.is_empty() or wreck == null:
return _hiding
var t: Node3D = terrain
var rng := RandomNumberGenerator.new()
rng.seed = t.noise_seed * 7919 + 2024
var avoid: Array = [wreck.global_position, _spawn]
for n in [den, pier, sea_cave]:
if n != null and n.is_inside_tree():
avoid.append(n.global_position if n != pier else (pier.start + pier.end) * 0.5)
for _try in range(3000):
if _hiding.size() >= 12:
break
var a := rng.randf() * TAU
var r: float = sqrt(rng.randf()) * t.play_radius * 0.85
var x: float = cos(a) * r
var z: float = sin(a) * r
if not t.inside_boundary(x, z, 14.0) or t.beach_weight(x, z) > 0.3 or t.near_stream(x, z, 4.0):
continue
var h: float = t.sample_height(x, z)
if h < t.water_level + 2.0 or t.sample_normal(x, z).y < 0.92 or h > t.snow_height - 6.0:
continue
if avoid.any(func(q): return Vector2(q.x - x, q.z - z).length() < 22.0):
continue
if _hiding.any(func(q): return Vector2(q.x - x, q.z - z).length() < 25.0):
continue
_hiding.append(Vector3(x, h, z))
return _hiding
## The id of the player at this game (yours; a server's character's own).
func my_id() -> String:
return String(Net.get_identity().player_id)
## Right-click with the treasure map: your X goes on your map, and it opens.
func read_map(p: Node3D) -> void:
if wreck == null:
Actions.notify.emit("The X is in the hills above the Cove: read it there")
return
Actions.notify.emit("X marks the spot, in the hills above the bay: it's on your map now (N). Take a pickaxe")
if hud != null and hud.map != null and not hud.map.is_open():
hud.map.open()
## In the cliff tunnel (J1): no daylight, no weather, sheltered. ## In the cliff tunnel (J1): no daylight, no weather, sheltered.
func underground_at(p: Vector3) -> bool: func underground_at(p: Vector3) -> bool:
return tunnel != null and tunnel.contains(p) return tunnel != null and tunnel.contains(p)
@@ -1202,6 +1325,21 @@ func water_at(x: float, z: float) -> Dictionary:
# and its ways out. (W0 adds these; systems move over to them as each worldlet # and its ways out. (W0 adds these; systems move over to them as each worldlet
# needs them to be different.) # needs them to be different.)
## Whether this land has set piece `kind` ("den", "barrow", "stones",
## "bear", "pier", "wreck"...): its worldlet's "places". A worldlet that
## doesn't list them has the Valley's set (den, barrow, stones, bear).
const DEFAULT_PLACES := ["den", "barrow", "stones", "bear"]
func has_place(kind: String) -> bool:
var places: Array = terrain.worldlet.get("places", [])
if places.is_empty():
return kind in DEFAULT_PLACES
return places.any(func(p): return p is Dictionary and String(p.get("type", "")) == kind)
## Whether this land has wildlife `kind` beyond the usual ("crabs",
## "gulls"): its worldlet's "wildlife" list (0.6.0; the Cove's).
func has_wildlife(kind: String) -> bool:
return kind in Array(terrain.worldlet.get("wildlife", []))
## This worldlet's id ("valley") and its whole definition. ## This worldlet's id ("valley") and its whole definition.
func worldlet_id() -> String: func worldlet_id() -> String:
return terrain.worldlet_id return terrain.worldlet_id

150
scripts/world/wreck.gd Normal file
View File

@@ -0,0 +1,150 @@
extends Node3D
## The Cove's wreck (docs/WORLDLETS_DESIGN.md §5, 0.6.0): an old ship driven
## up on the sand at the far end of the bay from the sea cave, her bow in the
## shallows, one side stove in. Inside, the captain's sea chest: a torn
## treasure map and a few useful things, **once for each player** (single-
## player and online alike: who's had theirs is saved with the Cove).
##
## Placed from the terrain alone, like the pier, so it's the same for
## everyone; only `opened` (who's had their share) is saved.
const Pier = preload("res://scripts/world/pier.gd")
const OAK := Color(0.36, 0.25, 0.16)
const DARK := Color(0.24, 0.17, 0.11)
const WET := Color(0.2, 0.18, 0.15)
const IRON := Color(0.22, 0.22, 0.24)
## What's in the sea chest, for each player once.
const LOOT := {"treasure_map": 1, "nails": 4, "salt": 2}
var world: Node3D
var opened: Array = [] ## the player ids who've had their share
var chest: StaticBody3D
var front := Vector3.ZERO ## where you stand to reach into the chest
func place(w: Node3D) -> bool:
world = w
var t: Node3D = w.terrain
if not w.has_place("wreck") or not t._has_beach:
return false
# The far end of the beach from the sea cave, where the sand meets the water.
var a := deg_to_rad(t.beach_angle_deg - t.beach_half_width_deg * 0.8)
var dir := Vector2(cos(a), sin(a))
var r: float = t._shore_d() - t._rim_offset(dir.x, dir.y)
var at2 := dir * (r - 1.0)
var at := Vector3(at2.x, maxf(t.sample_height(at2.x, at2.y), t.water_level - 0.3), at2.y)
# Bow out to sea (+Z local), a little askew, heeled over on her side.
var out := Vector3(dir.x, 0.0, dir.y).rotated(Vector3.UP, 0.35)
global_transform = Transform3D(Basis.looking_at(-out, Vector3.UP), at)
_build()
add_to_group("wreck")
return true
func _build() -> void:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var body := StaticBody3D.new()
body.name = "Hull"
add_child(body)
var heel := Basis(Vector3(0, 0, 1), deg_to_rad(-14.0))
var parts: Array = [
# [centre, size, colour] in the hull's frame (+Z the bow, +X starboard)
[Vector3(0, 0.12, 0), Vector3(0.35, 0.25, 9.0), DARK], # keel
[Vector3(-1.45, 0.75, 0), Vector3(0.14, 1.3, 8.2), OAK], # port side, whole
[Vector3(1.45, 0.75, 2.6), Vector3(0.14, 1.3, 3.0), OAK], # starboard, forward
[Vector3(1.45, 0.55, -3.0), Vector3(0.14, 0.9, 2.2), OAK], # starboard, aft (stove in between)
[Vector3(0, 0.75, -4.1), Vector3(2.9, 1.3, 0.14), OAK], # transom
[Vector3(0, 0.05, 0), Vector3(2.8, 0.1, 7.6), WET], # bilge boards
[Vector3(0, 1.8, 1.4), Vector3(0.22, 2.6, 0.22), DARK], # mast stump
]
for p in parts:
var c: Vector3 = heel * p[0]
Pier._box(st, c, p[1], p[2])
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = p[1]
cs.shape = box
cs.transform = Transform3D(heel, c)
body.add_child(cs)
# Ribs over the open hull, some broken off short.
for i in range(6):
var z := -3.4 + i * 1.3
for side in [-1.0, 1.0]:
if side > 0.0 and z > -1.8 and z < 1.0:
continue # (the stove-in side)
var h := 1.9 if (i + int(side)) % 3 != 0 else 1.2
Pier._box(st, heel * Vector3(side * 1.3, h * 0.5 + 0.5, z), Vector3(0.12, h, 0.14), DARK)
# Planks strewn on the sand on the open side.
var rng := RandomNumberGenerator.new()
rng.seed = 919
for i in range(5):
var off := Vector3(2.6 + rng.randf() * 2.5, 0.05, -2.0 + rng.randf() * 4.0)
Pier._box(st, off, Vector3(0.3, 0.06, 1.6 + rng.randf()), OAK.darkened(rng.randf() * 0.3))
st.generate_normals()
var mi := MeshInstance3D.new()
mi.name = "Ship"
mi.mesh = st.commit()
var mat := StandardMaterial3D.new()
mat.vertex_color_use_as_albedo = true
mat.vertex_color_is_srgb = true # (the colours above are picked by eye: dark, weathered oak)
mat.roughness = 0.95
mi.material_override = mat
add_child(mi)
# The captain's sea chest, wedged inside by the break.
chest = StaticBody3D.new()
chest.name = "SeaChest"
chest.collision_layer = 1 | 2
chest.set_script(preload("res://scripts/world/wreck_chest.gd"))
chest.wreck = self
var cst := SurfaceTool.new()
cst.begin(Mesh.PRIMITIVE_TRIANGLES)
Pier._box(cst, Vector3(0, 0.25, 0), Vector3(0.9, 0.5, 0.55), Color(0.3, 0.2, 0.12))
Pier._box(cst, Vector3(0, 0.55, 0), Vector3(0.92, 0.12, 0.57), Color(0.27, 0.18, 0.1))
for x in [-0.32, 0.32]:
Pier._box(cst, Vector3(x, 0.32, 0), Vector3(0.06, 0.64, 0.6), IRON)
cst.generate_normals()
var cmi := MeshInstance3D.new()
cmi.mesh = cst.commit()
cmi.material_override = mat
chest.add_child(cmi)
var ccs := CollisionShape3D.new()
var cb := BoxShape3D.new()
cb.size = Vector3(0.9, 0.62, 0.55)
ccs.shape = cb
ccs.position = Vector3(0, 0.31, 0)
chest.add_child(ccs)
add_child(chest)
chest.transform = Transform3D(heel * Basis(Vector3.UP, 0.4), heel * Vector3(0.5, 0.12, -0.4))
front = global_transform * (heel * Vector3(2.4, 0.0, -0.4))
front.y = world.terrain.sample_height(front.x, front.z)
# --- the sea chest -------------------------------------------------------------
## Whether player `id` has had their share of the chest.
func had(id: String) -> bool:
return id in opened
## Gives player `p` (id `id`) their share: the map and the rest, once.
## Returns false (and says why) when they've had it, or their bag's too full.
func open_for(p: Node, id: String) -> bool:
if had(id):
Actions.tell(p, "The sea chest's empty: you've had what was in it")
return false
for item in LOOT:
if p.inventory.space_for(item) < int(LOOT[item]):
Actions.tell(p, "Make room in your bag first")
return false
for item in LOOT:
p.inventory.add(item, int(LOOT[item]))
opened.append(id)
Actions.tell(p, "In the captain's sea chest: a torn treasure map, %d nails and %d salt. Right-click the map to read it" % [
int(LOOT.nails), int(LOOT.salt)])
Sfx.at("door_open", chest.global_position)
if Net.is_server():
WorldNet.mark_changed()
return true
func to_data() -> Dictionary:
return {"opened": opened.duplicate()}
func load_data(d: Dictionary) -> void:
opened = (d.get("opened", []) as Array).duplicate()

View File

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

View File

@@ -0,0 +1,23 @@
extends StaticBody3D
## The wreck's sea chest (wreck.gd): what you look at and press E on. Online,
## your game asks the server, which gives you your share once.
var wreck: Node3D
func interact_prompt(player: Node) -> String:
if wreck.had(_id(player)):
return "The captain's sea chest (empty: you've had what was in it)"
return "[E] Open the captain's sea chest"
func interact(player: Node) -> void:
if wreck.world.is_online():
PlayerNet.ask("wreck_chest", [])
return
wreck.open_for(player, _id(player))
## Whose share: the player's id (a server's character, or, at home, yours).
static func _id(player: Node) -> String:
var pid: Variant = player.get("player_id")
if pid != null and not String(pid).is_empty():
return String(pid)
return String(Net.get_identity().player_id)

View File

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

View File

@@ -0,0 +1,36 @@
# Cinderwild dedicated server as a systemd service (Linux).
#
# 1. Put the game where it'll live, e.g. /opt/cinderwild (a git clone), and
# install Godot 4.7+ as /usr/local/bin/godot.
# 2. Make a user for it: sudo useradd -r -m -d /var/lib/cinderwild cinderwild
# 3. Copy this file: sudo cp server/cinderwild-server.service /etc/systemd/system/
# and edit User, WorkingDirectory and GODOT below if yours differ.
# 4. Start it, and on boot: sudo systemctl enable --now cinderwild-server
# Logs: journalctl -u cinderwild-server -f
# Stop / restart: sudo systemctl stop|restart cinderwild-server
#
# server.json and the world live in the user's Godot data folder:
# /var/lib/cinderwild/.local/share/godot/app_userdata/Cinderwild/server/
# Admin commands: join the game and use chat (/help), or edit server.json and
# restart. (The console needs a terminal, so it isn't used here.)
[Unit]
Description=Cinderwild dedicated server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=cinderwild
WorkingDirectory=/opt/cinderwild
Environment=GODOT=/usr/local/bin/godot
# run_server.sh checks the Godot version and imports a fresh copy first.
ExecStart=/opt/cinderwild/server/run_server.sh
Restart=on-failure
RestartSec=10
# The world saves within 10 s of any change, so a stop loses at most that.
KillSignal=SIGINT
TimeoutStopSec=20
[Install]
WantedBy=multi-user.target

View File

@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
Emberwild dedicated server as a launchd agent (macOS): starts when you log Cinderwild dedicated server as a launchd agent (macOS): starts when you log
in, and restarts if it stops. in, and restarts if it stops.
1. Edit the two paths below (the game folder, and your user name in the log 1. Edit the two paths below (the game folder, and your user name in the log
path) if yours differ. path) if yours differ.
2. Copy it: cp server/com.emberwild.server.plist ~/Library/LaunchAgents/ 2. Copy it: cp server/com.cinderwild.server.plist ~/Library/LaunchAgents/
3. Start it: launchctl load -w ~/Library/LaunchAgents/com.emberwild.server.plist 3. Start it: launchctl load -w ~/Library/LaunchAgents/com.cinderwild.server.plist
Stop it: launchctl unload ~/Library/LaunchAgents/com.emberwild.server.plist Stop it: launchctl unload ~/Library/LaunchAgents/com.cinderwild.server.plist
Log: tail -f ~/Library/Logs/emberwild-server.log Log: tail -f ~/Library/Logs/cinderwild-server.log
server.json and the world are in server.json and the world are in
~/Library/Application Support/Godot/app_userdata/Emberwild/server/ ~/Library/Application Support/Godot/app_userdata/Cinderwild/server/
For admin commands, join the game and use chat (/help), or edit server.json For admin commands, join the game and use chat (/help), or edit server.json
and stop/start it. (For a console, run server/run_server.sh in Terminal and stop/start it. (For a console, run server/run_server.sh in Terminal
instead.) instead.)
@@ -20,7 +20,7 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>Label</key> <key>Label</key>
<string>com.emberwild.server</string> <string>com.cinderwild.server</string>
<key>ProgramArguments</key> <key>ProgramArguments</key>
<array> <array>
<string>/Users/home/valheimlike/server/run_server.sh</string> <string>/Users/home/valheimlike/server/run_server.sh</string>
@@ -43,8 +43,8 @@
<key>ThrottleInterval</key> <key>ThrottleInterval</key>
<integer>15</integer> <integer>15</integer>
<key>StandardOutPath</key> <key>StandardOutPath</key>
<string>/Users/home/Library/Logs/emberwild-server.log</string> <string>/Users/home/Library/Logs/cinderwild-server.log</string>
<key>StandardErrorPath</key> <key>StandardErrorPath</key>
<string>/Users/home/Library/Logs/emberwild-server.log</string> <string>/Users/home/Library/Logs/cinderwild-server.log</string>
</dict> </dict>
</plist> </plist>

View File

@@ -1,36 +0,0 @@
# Emberwild dedicated server as a systemd service (Linux).
#
# 1. Put the game where it'll live, e.g. /opt/emberwild (a git clone), and
# install Godot 4.7+ as /usr/local/bin/godot.
# 2. Make a user for it: sudo useradd -r -m -d /var/lib/emberwild emberwild
# 3. Copy this file: sudo cp server/emberwild-server.service /etc/systemd/system/
# and edit User, WorkingDirectory and GODOT below if yours differ.
# 4. Start it, and on boot: sudo systemctl enable --now emberwild-server
# Logs: journalctl -u emberwild-server -f
# Stop / restart: sudo systemctl stop|restart emberwild-server
#
# server.json and the world live in the user's Godot data folder:
# /var/lib/emberwild/.local/share/godot/app_userdata/Emberwild/server/
# Admin commands: join the game and use chat (/help), or edit server.json and
# restart. (The console needs a terminal, so it isn't used here.)
[Unit]
Description=Emberwild dedicated server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=emberwild
WorkingDirectory=/opt/emberwild
Environment=GODOT=/usr/local/bin/godot
# run_server.sh checks the Godot version and imports a fresh copy first.
ExecStart=/opt/emberwild/server/run_server.sh
Restart=on-failure
RestartSec=10
# The world saves within 10 s of any change, so a stop loses at most that.
KillSignal=SIGINT
TimeoutStopSec=20
[Install]
WantedBy=multi-user.target

View File

@@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
# Starts a dedicated Emberwild server (no window, no local player). # Starts a dedicated Cinderwild server (no window, no local player).
# #
# server/run_server.sh defaults from server.json # server/run_server.sh defaults from server.json
# server/run_server.sh --port 24242 --server-name "Paul's island" --password secret # server/run_server.sh --port 24242 --server-name "Paul's island" --password secret

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..." echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1 godot --headless --path . --import >/dev/null 2>&1
fi fi
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_net_lands test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_tunnel test_map test_terrain_edges test_boundary; do for t in test_boot test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_net_lands test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_wreck test_shore test_boat test_bug_report test_tunnel test_map test_terrain_edges test_boundary; do
extra="" extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever, # A suite that waits on a world that failed to load would hang forever,

160
tests/test_boat.gd Normal file
View File

@@ -0,0 +1,160 @@
extends SceneTree
## 0.6.0 (docs/WORLDLETS_DESIGN.md §5): the rowing boat. Seagrass washes up
## at the Cove's tide line and pine trees give resin when felled; seagrass
## twists into rope, resin and salt boil into pitch at a campfire, and
## driftwood, rope, pitch and nails make a rowing boat at a workbench. The
## mooring post at the end of the pier ties it up at a berth of its own, one
## for each player, and it's saved there. "Set sail" needs the ship's
## compass, and even then the Highlands aren't charted yet. No pier, no
## moorings: none in the Valley.
## Run: godot --headless --path . --script res://tests/test_boat.gd
var _fails := 0
var _finished := false
var S: Node
var A: Node
var DB: Node
var _toasts: Array[String] = []
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
S = root.get_node("Saves")
A = root.get_node("Actions")
DB = root.get_node("ItemDB")
A.notify.connect(func(t): _toasts.append(t))
S.root_dir = "user://test_boat_worlds"
for x in S.list_worlds():
S.delete_world(x.id)
await _body()
check(_finished, "test ran to the end (no script errors)")
for x in S.list_worlds():
S.delete_world(x.id)
DirAccess.remove_absolute(S.root_dir)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _load() -> Node3D:
var x: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(x)
for _i in range(8):
await physics_frame
x.creatures.enabled = false
x.player.set_physics_process(false)
x.player.survival.set_process(false)
return x
func _drop(x: Node3D) -> void:
x.queue_free()
for _i in range(3):
await process_frame
func _toast_has(s: String) -> bool:
return _toasts.any(func(t): return String(t).contains(s))
func _body() -> void:
print("the Valley")
S.current_id = S.create_world("Valley", 1337)
var w: Node3D = await _load()
check(w.moorings == null, "no pier, no moorings")
var resin: Array = w.props.resource_nodes.values().filter(func(n): return n.drops_final.has("pine_resin"))
check(resin.size() > 20, "its pines give resin when felled (%d trees)" % resin.size())
await _drop(w)
print("the Cove")
var cove: String = S.create_world("Cove", 1337, {"worldlet": "cove"})
S.current_id = cove
w = await _load()
var t: Node3D = w.terrain
var mo: Node3D = w.moorings
check(mo != null, "moorings at the pier")
if mo == null:
return
check(mo.post.global_position.distance_to(w.pier.end) < 2.0, "a mooring post at the end of the pier")
var deep := true
var depths: Array = []
for i in range(mo.BERTHS):
var b: Transform3D = mo.berth_xf(i)
var wa: Dictionary = w.water_at(b.origin.x, b.origin.z)
depths.append("%.1f" % float(wa.get("depth", -1.0)))
deep = deep and not wa.is_empty() and float(wa.depth) > 0.6 and not w.pier.covers(b.origin.x, b.origin.z, 0.2)
check(deep, "%d berths alongside it, all in open water (%s m deep)" % [mo.BERTHS, ", ".join(depths)])
var grass: Array = w.props.pickups.values().filter(func(p): return p.item_id == "seagrass")
check(grass.size() >= 15 and grass.all(func(p): return t.on_sand(p.global_position.x, p.global_position.z)),
"seagrass at the tide line (%d)" % grass.size())
print("building it")
var p: Node3D = w.player
p.global_position = w.build.debug_find_site(w._spawn, 3.0)
var inv = p.inventory
inv.add("seagrass", 12)
inv.add("pine_resin", 6)
inv.add("salt", 3)
inv.add("driftwood", 12)
inv.add("nails", 6)
var ok := true
for i in range(4):
ok = A.craft(p, "rope") and ok
check(ok and inv.count("rope") == 4 and inv.count("seagrass") == 0, "3 seagrass twist into a rope, by hand")
check(not A.craft(p, "pitch"), "pitch needs a fire")
var fire: Node3D = w.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), p.global_position + Vector3(2, 0, 0)))
for i in range(3):
ok = A.craft(p, "pitch") and ok
check(ok and inv.count("pitch") == 3 and inv.count("salt") == 0, "2 resin and a salt boil into pitch at a campfire")
check(not A.craft(p, "rowing_boat"), "the boat needs a workbench")
w.spawn_piece(DB.get_item("workbench"), Transform3D(Basis(), p.global_position + Vector3(-2, 0, 0)))
check(A.craft(p, "rowing_boat") and inv.count("rowing_boat") == 1, "driftwood, rope, pitch and nails: a rowing boat")
check(inv.count("driftwood") == 0 and inv.count("rope") == 0 and inv.count("pitch") == 0 and inv.count("nails") == 0, "(all used)")
w.remove_piece(fire)
print("tying it up")
p.global_position = mo.post.global_position + w.pier.dir * -1.0
var me: String = mo.post.id_of(p)
check(mo.post.interact_prompt(p).contains("[E] Tie up your boat"), "the post: [E] Tie up your boat")
_toasts.clear()
mo.post.interact(p)
check(mo.has_boat(me) and inv.count("rowing_boat") == 0, "it's tied up, out of your bag")
check(_toast_has("tie your boat up"), "(and it says so)")
var boat: Node3D = mo.boats.get(me)
check(boat != null and boat.global_position.distance_to(mo.berth_xf(int(mo.moored[me].berth)).origin) < 0.3, "at a berth by the pier")
check(boat != null and boat.interact_prompt(p) == "Your rowing boat [E] Set sail", "\"Your rowing boat [E] Set sail\"")
check(mo.post.interact_prompt(p).contains("your boat's tied up"), "the post knows")
inv.add("rowing_boat", 1)
_toasts.clear()
check(not mo.moor(p, me) and inv.count("rowing_boat") == 1, "one boat each: a second doesn't go in")
inv.remove("rowing_boat", 1)
print("setting sail")
_toasts.clear()
boat.interact(p)
check(_toast_has("without a ship's compass"), "without a ship's compass you'd be lost (%s)" % [_toasts])
inv.add("ship_compass", 1)
_toasts.clear()
boat.interact(p)
check(_toast_has("nobody's charted the way to the Highlands"), "with it: the Highlands aren't charted yet")
check(mo.has_boat(me), "(and it stays tied up)")
print("someone else's")
inv.add("rowing_boat", 1)
check(mo.moor(p, "someone-else"), "someone else ties up theirs")
check(mo.boats.size() == 2 and int(mo.moored["someone-else"].berth) != int(mo.moored[me].berth), "at a berth of its own")
check(mo.boats["someone-else"].interact_prompt(p) == "Someone's rowing boat", "not yours: \"Someone's rowing boat\"")
_toasts.clear()
mo.boats["someone-else"].interact(p)
check(_toasts.is_empty(), "and you can't sail it")
print("saved")
var state: Dictionary = w.collect_save()
S.save_state(cove, state, 1.0)
await _drop(w)
w = await _load()
mo = w.moorings
check(mo != null and mo.has_boat(me) and mo.has_boat("someone-else") and mo.boats.size() == 2, "both boats are still there after a reload")
await _drop(w)
_finished = true

1
tests/test_boat.gd.uid Normal file
View File

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

71
tests/test_boot.gd Normal file
View File

@@ -0,0 +1,71 @@
extends SceneTree
## The rename (Emberwild -> Cinderwild, 0.6.1): the first start copies your
## saves, identity and settings across from the old user-data folder, once,
## without overwriting anything already there, and only from the newest old
## name (so a world you deleted under Emberwild doesn't come back from the
## Valheimlike folder).
## Run: godot --headless --path . --script res://tests/test_boot.gd
var _fails := 0
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _write(path: String, text: String) -> void:
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(text)
f.close()
func _read(path: String) -> String:
return FileAccess.get_file_as_string(path) if FileAccess.file_exists(path) else ""
func _rm(dir: String) -> void:
if not DirAccess.dir_exists_absolute(dir):
return
var da := DirAccess.open(dir)
for f in da.get_files():
DirAccess.remove_absolute(dir.path_join(f))
for d in da.get_directories():
_rm(dir.path_join(d))
DirAccess.remove_absolute(dir)
func _initialize() -> void:
var B: Node = root.get_node("Boot")
var base := OS.get_user_data_dir().path_join("test_boot")
_rm(base)
var ember := base.path_join("Emberwild")
var valheim := base.path_join("Valheimlike")
var cinder := base.path_join("Cinderwild")
_write(ember.path_join("worlds/home/state.json"), "ember home")
_write(ember.path_join("identity.json"), "me")
_write(ember.path_join("logs/godot.log"), "old log")
_write(ember.path_join("migrated_from_valheimlike.txt"), "old marker")
_write(valheim.path_join("worlds/deleted/state.json"), "gone")
_write(cinder.path_join("audio.json"), "new settings")
_write(ember.path_join("audio.json"), "old settings")
print("the first start")
var n: int = 0
for old_name in B.OLD_NAMES:
var old_dir := base.path_join(old_name)
if DirAccess.dir_exists_absolute(old_dir):
n = B.migrate_from(old_dir, cinder, old_name)
break
check(n == 2, "copies the old folder's files (%d)" % n)
check(_read(cinder.path_join("worlds/home/state.json")) == "ember home" and _read(cinder.path_join("identity.json")) == "me",
"your worlds and identity come across")
check(_read(cinder.path_join("audio.json")) == "new settings", "nothing already there is overwritten")
check(not FileAccess.file_exists(cinder.path_join("logs/godot.log")), "logs stay behind")
check(not FileAccess.file_exists(cinder.path_join("worlds/deleted/state.json")), "the older Valheimlike folder isn't raked over")
check(FileAccess.file_exists(ember.path_join("identity.json")), "the old folder's left as it was")
check(FileAccess.file_exists(cinder.path_join("migrated_from_emberwild.txt")) and not FileAccess.file_exists(cinder.path_join("migrated_from_valheimlike.txt")),
"a marker says it's done")
print("the next start")
_write(ember.path_join("worlds/later/state.json"), "later")
check(B.migrate_from(ember, cinder, "Emberwild") == 0 and not FileAccess.file_exists(cinder.path_join("worlds/later/state.json")), "it only happens once")
_rm(base)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)

1
tests/test_boot.gd.uid Normal file
View File

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

76
tests/test_bug_report.gd Normal file
View File

@@ -0,0 +1,76 @@
extends SceneTree
## 0.6.0, the playtest: Report a bug. The pause menu has a "Report a bug"
## page; its link is a new issue on the Cinderwild repo, filled in with
## questions to answer and the details (version, computer, mode, land, time,
## where you are), short enough for a browser; the log's path is shown.
## Run: godot --headless --path . --script res://tests/test_bug_report.gd
const BugReport = preload("res://scripts/core/bug_report.gd")
var _fails := 0
var _finished := false
var S: Node
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
S = root.get_node("Saves")
S.root_dir = "user://test_bug_worlds"
for x in S.list_worlds():
S.delete_world(x.id)
await _body()
check(_finished, "test ran to the end (no script errors)")
for x in S.list_worlds():
S.delete_world(x.id)
DirAccess.remove_absolute(S.root_dir)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _body() -> void:
S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"})
var w: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(w)
for _i in range(8):
await physics_frame
w.creatures.enabled = false
w.player.set_physics_process(false)
print("the link")
var url: String = BugReport.issue_url(w)
check(url.begins_with("https://git.psbailey.uk/Admin/Cinderwild/issues/new?title=Bug%3A%20&body="), "a new issue on the Cinderwild repo")
check(url.length() < 4000, "short enough for any browser (%d characters)" % url.length())
var body := url.get_slice("&body=", 1).uri_decode()
var ver: String = String(ProjectSettings.get_setting("application/config/version"))
for want in ["**What happened?**", "**How can we make it happen again?**", "- Version: Cinderwild %s" % ver,
"- Computer: ", "- Mode: single-player", "- Land: Cove", "- Time: day ", "- Where: ", "- Seed: 1337", "godot.log"]:
check(body.contains(want), "it says %s" % want)
check(BugReport.log_path().ends_with("logs/godot.log") and BugReport.log_path().begins_with("/"), "the log's full path (%s)" % BugReport.log_path())
check(not BugReport.details_text(w).contains("**"), "Copy gives just the details")
check(not body.contains(OS.get_environment("HOME")) and body.contains("~/"), "and not your user name (the home folder is ~)")
print("the pause menu")
var menu: Control = load("res://scripts/ui/pause_menu.gd").new() # (headless there's no HUD: build the menu as it does)
w.add_child(menu)
menu.bind(w.player)
var btn: Button = null
for c in menu._main.get_children():
if c is Button and c.text == "Report a bug":
btn = c
check(btn != null, "a Report a bug button")
if btn != null:
menu.open()
btn.pressed.emit()
check(menu._report.visible and not menu._main.visible, "it opens the Report a bug page")
var labels: Array = menu._report.get_children().filter(func(c): return c is Button).map(func(c): return c.text)
check("Open the bug report page" in labels and "Copy the details" in labels and "Show the log file" in labels, "with %s" % [labels])
menu.back()
check(menu._main.visible and not menu._report.visible, "Esc goes back")
menu.close()
menu.queue_free()
_finished = true

View File

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

View File

@@ -59,7 +59,9 @@ func _body() -> void:
check(world.crossings().any(func(c): return c.to == "valley") and world.crossings().any(func(c): return c.by == "sail"), check(world.crossings().any(func(c): return c.to == "valley") and world.crossings().any(func(c): return c.by == "sail"),
"its ways on: back to the Valley, and on by sail") "its ways on: back to the Valley, and on by sail")
check(world._spawn.y > t.water_level + 1.0, "you start on dry land") check(world._spawn.y > t.water_level + 1.0, "you start on dry land")
check(world.den != null and world.barrow != null and world.stones != null, "the den, the barrow and the stones found places") check(world.den != null and world.den.found, "the Cove's wolves have a den")
check(world.barrow == null and world.stones == null and world.bears == null,
"but the barrow, the standing stones and the bear are the Valley's alone")
check(t.get_node_or_null("Water") == null, "one water surface (the bay is the sea)") check(t.get_node_or_null("Water") == null, "one water surface (the bay is the sea)")
print("the stream") print("the stream")
@@ -77,9 +79,9 @@ func _body() -> void:
"its banks are dry, and above the water") "its banks are dry, and above the water")
check(DB.water_fishing("stream").get("fish", []).any(func(f): return f.id == "trout"), "trout in the stream") check(DB.water_fishing("stream").get("fish", []).any(func(f): return f.id == "trout"), "trout in the stream")
var away := true var away := true
for p in [world.den.global_position, world.barrow.global_position, world.stones.global_position, world._spawn]: for p in [world.den.global_position, world._spawn]:
away = away and not t.near_stream(p.x, p.z, 3.0) away = away and not t.near_stream(p.x, p.z, 3.0)
check(away, "the den, the barrow, the stones and the start are all clear of it") check(away, "the den and the start are clear of it")
var in_bed := 0 var in_bed := 0
for n in world.props.resource_nodes.values(): for n in world.props.resource_nodes.values():
if is_instance_valid(n) and t.near_stream(n.global_position.x, n.global_position.z, 0.0): if is_instance_valid(n) and t.near_stream(n.global_position.x, n.global_position.z, 0.0):

View File

@@ -53,8 +53,11 @@ func _run() -> void:
Net.arrived.connect(func(l): _arrived = l) Net.arrived.connect(func(l): _arrived = l)
for d in [SRV, BOBS]: for d in [SRV, BOBS]:
_rm_tree(d) _rm_tree(d)
_spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json", # (CINDERWILD_SERVER_BIN=<an exported game>: the hub, and the land servers
"--server-name", "Lands Test", "--seed", str(SEED), "--debug-cheats", "--no-creatures"]) # it starts, are that build: checks a release does it too.)
_spawn(["--server", "--port", str(PORT), "--config", ProjectSettings.globalize_path(SRV + "/server.json"),
"--server-name", "Lands Test", "--seed", str(SEED), "--debug-cheats", "--no-creatures"], "",
OS.get_environment("CINDERWILD_SERVER_BIN"))
await _sleep(4.0) await _sleep(4.0)
await _body() await _body()
if world != null and is_instance_valid(world): if world != null and is_instance_valid(world):
@@ -145,6 +148,49 @@ func _body() -> void:
check(world.sea_cave.open(), "the sea cave leads back up (the hub says the tunnel's through)") check(world.sea_cave.open(), "the sea cave leads back up (the hub says the tunnel's through)")
var bob := await _bob({"do": "report"}) var bob := await _bob({"do": "report"})
check(not bob.has("error") and not bob.is_empty(), "Bob carries on in the Valley, on the hub (%s)" % bob.get("error", "ok")) check(not bob.has("error") and not bob.is_empty(), "Bob carries on in the Valley, on the hub (%s)" % bob.get("error", "ok"))
print("the wreck's sea chest, online")
await _tp(world.wreck.front + Vector3(0, 0.1, 0))
_toasts.clear()
world.wreck.chest.interact(player)
await _until(func(): return player.inventory.count("treasure_map") == 1, 4.0)
check(player.inventory.count("treasure_map") == 1, "Alice's share: the server gives her a treasure map")
await _until(func(): return world.wreck.had(world.my_id()), 3.0)
check(world.wreck.chest.interact_prompt(player).contains("empty"), "and her game knows the chest's empty for her now")
check(world.map_marks().any(func(m): return m.kind == "treasure"), "her X is on her map")
print("digging up the treasure, online")
await _sleep(0.8)
var mound: Node3D = world.treasure.mounds.get(world.my_id())
check(mound != null, "her mound shows at her X")
if mound != null:
await _tp(mound.global_position + Vector3(1.4, 0.2, 0))
_hold("antler_pickaxe")
await _sleep(0.3)
for _i in range(12):
if player.inventory.count("ship_compass") > 0:
break
PN.ask("debug_stats", [{"stamina": 100.0}])
if is_instance_valid(mound):
A.harvest_hit(player, mound, mound.global_position)
await _sleep(0.15)
await _until(func(): return player.inventory.count("ship_compass") == 1, 4.0)
check(player.inventory.count("ship_compass") == 1 and player.inventory.count("cutlass") == 1,
"the server digs it up for her: the compass and the cutlass in her bag")
await _until(func(): return world.my_id() in world.treasure.dug, 3.0)
check(world.my_id() in world.treasure.dug and not world.treasure.mounds.has(world.my_id()), "and her mound's gone")
print("tying up a boat, online")
PN.ask("debug_give", ["rowing_boat", 1])
await _until(func(): return player.inventory.count("rowing_boat") == 1, 3.0)
var mo: Node3D = world.moorings
var was: Vector3 = player.global_position
await _tp(mo.post.global_position - world.pier.dir * 1.0 + Vector3(0, 0.2, 0))
check(mo.post.interact_prompt(player).contains("Tie up your boat"), "at the mooring post with her boat")
mo.post.interact(player)
await _until(func(): return mo.has_boat(world.my_id()), 4.0)
check(mo.has_boat(world.my_id()) and mo.boats.has(world.my_id()), "the server ties it up, and her game shows it at its berth")
await _until(func(): return player.inventory.count("rowing_boat") == 0, 3.0)
check(player.inventory.count("rowing_boat") == 0, "(out of her bag)")
check(mo.boats[world.my_id()].interact_prompt(player).contains("Set sail"), "it's hers: [E] Set sail")
await _tp(was + Vector3(0, 0.2, 0)) # (back up the beach, where there's room for a hut)
await _across_lands(bob_bed, bob_bed_at) await _across_lands(bob_bed, bob_bed_at)
print("back up to the Valley") print("back up to the Valley")
@@ -370,15 +416,17 @@ func _bob_result(n: int, limit: float) -> Dictionary:
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null
return d if d is Dictionary else {"error": "Bob gave no answer to command %d" % n} return d if d is Dictionary else {"error": "Bob gave no answer to command %d" % n}
func _spawn(user_args: Array, script: String = "") -> int: func _spawn(user_args: Array, script: String = "", bin: String = "") -> int:
var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://")]) var args := PackedStringArray(["--headless"])
if bin.is_empty():
args.append_array(["--path", ProjectSettings.globalize_path("res://")])
if not script.is_empty(): if not script.is_empty():
args.append_array(["--script", script]) args.append_array(["--script", script])
args.append("--") args.append("--")
for x in user_args: for x in user_args:
args.append(str(x)) args.append(str(x))
args.append_array(["--exit-with", str(OS.get_process_id())]) args.append_array(["--exit-with", str(OS.get_process_id())])
var pid := OS.create_process(OS.get_executable_path(), args) var pid := OS.create_process(OS.get_executable_path() if bin.is_empty() else bin, args)
_pids.append(pid) _pids.append(pid)
return pid return pid

312
tests/test_shore.gd Normal file
View File

@@ -0,0 +1,312 @@
extends SceneTree
## 0.6.0 (docs/WORLDLETS_DESIGN.md §5): the Cove's own wildlife. Crabs come
## up the beach at dusk (never in the Valley, never by day), keep to the
## sand, pinch anyone who walks too close and let them go once they back
## off; a dead one gives crab meat, which cooks; at dawn they go back into
## the sea. Gulls are the Cove's crows: food left on the sand draws a gull
## (inland it's still a crow), and so does fish on a smoking rack nobody's
## watching; a gull snatches one and flies off, and drops it if you shoot it.
## Run: godot --headless --path . --script res://tests/test_shore.gd
var _fails := 0
var _finished := false
var S: Node
var A: Node
var DB: Node
var _toasts: Array[String] = []
var w: Node3D
var player: CharacterBody3D
var sh: Node
var th: Node
var beach := Vector3.ZERO ## a spot on the sand, just above the waterline
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
S = root.get_node("Saves")
A = root.get_node("Actions")
DB = root.get_node("ItemDB")
A.notify.connect(func(t): _toasts.append(t))
S.root_dir = "user://test_shore_worlds"
for x in S.list_worlds():
S.delete_world(x.id)
await _body()
check(_finished, "test ran to the end (no script errors)")
for x in S.list_worlds():
S.delete_world(x.id)
DirAccess.remove_absolute(S.root_dir)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _load() -> Node3D:
var x: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(x)
for _i in range(8):
await physics_frame
return x
func _set_time(hours: float) -> void:
w.day_night.day = 3
w.day_night.time = hours / 24.0
w.day_night.apply(true)
func _ground(p: Vector3) -> Vector3:
return Vector3(p.x, w.terrain.sample_height(p.x, p.z), p.z)
func _toast_has(s: String) -> bool:
return _toasts.any(func(t): return String(t).contains(s))
## Runs the crabs (shore.gd and each crab) for up to `secs`.
func _sim_crabs(secs: float, until: Callable = Callable()) -> float:
var t := 0.0
while t < secs:
sh.step(0.1)
for c in sh.get_children():
if is_instance_valid(c) and not c.is_queued_for_deletion():
c.set_physics_process(false)
c.step(0.1)
t += 0.1
if until.is_valid() and until.call():
break
if int(t * 10) % 10 == 0:
await physics_frame
return t
## Runs the thieves for up to `secs`.
func _sim_thieves(secs: float, until: Callable = Callable()) -> float:
var t := 0.0
while t < secs:
th.step(0.1)
for c in th.get_children():
if is_instance_valid(c) and not c.is_queued_for_deletion():
c.set_physics_process(false)
c.step(0.1)
t += 0.1
if until.is_valid() and until.call():
break
if int(t * 10) % 10 == 0:
await physics_frame
return t
func _thief(kind: String) -> Node3D:
for c in th.alive():
if c.kind == kind:
return c
return null
func _clear_thieves() -> void:
for c in th.get_children():
c.queue_free()
for d in w.all_dropped():
d.queue_free()
await physics_frame
## A spot on the sand just above the waterline, in the middle of the bay.
func _find_beach() -> Vector3:
var t: Node3D = w.terrain
var a := deg_to_rad(float(t.beach_angle_deg))
for off in [0.0, 0.25, -0.25, 0.5, -0.5]:
for r in range(20, 140):
var x := cos(a + off) * r
var z := sin(a + off) * r
var h: float = t.sample_height(x, z)
if t.beach_weight(x, z) > 0.85 and h > t.water_level + 0.5 and h < t.water_level + 0.9:
return Vector3(x, h, z)
return Vector3.INF
func _body() -> void:
print("the Valley")
S.current_id = S.create_world("Valley", 1337)
w = await _load()
check(w.shore == null, "no crabs in the Valley")
check(w.thieves != null and not w.thieves.gulls, "and no gulls")
w.queue_free()
for _i in range(3):
await process_frame
S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"})
w = await _load()
player = w.player
player.set_physics_process(false)
player.survival.set_process(false)
w.day_night.frozen = true
w.weather.set_process(false)
w.weather.force("clear")
if w.wolves != null:
w.wolves.enabled = false
for c in w.creatures.alive():
c.queue_free()
sh = w.shore
th = w.thieves
check(sh != null and th.gulls, "the Cove has crabs and gulls")
if sh == null:
return
sh.set_physics_process(false)
th.enabled = false
w.creatures.set_process(false) # (keeps rabbits out of the way; creatures.enabled stays true)
beach = _find_beach()
check(beach != Vector3.INF, "a spot on the beach (%s)" % [beach])
if beach == Vector3.INF:
return
await _crabs()
await _gulls()
_net()
_finished = true
func _crabs() -> void:
print("crabs")
_set_time(12.0)
player.global_position = beach
await _sim_crabs(10.0)
check(sh.alive().is_empty(), "none by day")
_set_time(18.0)
await _sim_crabs(20.0, func(): return sh.alive().size() >= sh.BUDGET)
var crabs: Array = sh.alive()
check(crabs.size() == sh.BUDGET, "at dusk they come up the beach (%d)" % crabs.size())
var t: Node3D = w.terrain
check(crabs.all(func(c): return t.on_sand(c.global_position.x, c.global_position.z)),
"on the sand at the waterline (%s)" % [crabs.map(func(c): return "%.2f" % (c.global_position.y - t.water_level))])
check(crabs.all(func(c): return c.global_position.distance_to(beach) >= sh.NOT_WITHIN), "and not right beside you")
print("keeping to the sand")
player.global_position = beach
await _sim_crabs(40.0)
crabs = sh.alive()
check(crabs.size() >= 3 and crabs.all(func(c): return t.water_level + 0.1 < c.global_position.y and c.global_position.y < t.water_level + t.SAND_TOP + 0.2),
"40 s on, they're all still on the sand (%d)" % crabs.size())
print("a pinch")
for c in crabs:
c.queue_free()
await physics_frame
var crab: Node3D = sh.spawn(beach + Vector3(1.6, 0, 0))
crab.set_physics_process(false)
player.global_position = _ground(beach)
player.survival.health = player.survival.MAX
var h0: float = player.survival.health
sh.enabled = false # (just this one)
await _sim_crabs(3.0)
check(crab.state == "charge", "walk within a couple of metres and it comes at you")
check(player.survival.health < h0 and player.survival.health > h0 - 20.0, "and pinches (%.0f -> %.0f)" % [h0, player.survival.health])
check(crab.interact_prompt(player).contains("claws up"), "(\"%s\")" % crab.interact_prompt(player))
player.global_position = _ground(crab.global_position + Vector3(9, 0, 0))
await _sim_crabs(8.0)
check(crab.state in ["wander", "idle"], "back off and it lets you go (%s)" % crab.state)
var h1: float = player.survival.health
await _sim_crabs(5.0)
check(is_equal_approx(player.survival.health, h1), "no more pinches from over there")
print("crab supper")
crab.take_hit(20.0, player.global_position)
check(crab.is_dead() and crab.interact_prompt(player).contains("Crack it open"), "a blow kills it: [E] Crack it open")
player.global_position = _ground(crab.global_position + Vector3(0.8, 0, 0))
var before: int = player.inventory.count("crab_meat")
A.butcher(player, crab)
var got: int = player.inventory.count("crab_meat") - before
check(got >= 1 and got <= 2, "crab meat (%d)" % got)
check(DB.get_item("crab_meat").has("food") and DB.get_item("cooked_crab").has("food"), "it's food, raw and cooked")
var r: Dictionary = DB.recipes.get("cooked_crab", {})
check(not r.is_empty() and String(r.get("station", "")) == "campfire" and int(Dictionary(r.inputs).get("crab_meat", 0)) == 2,
"two of it cook into a Cooked Crab at a campfire")
print("dawn")
sh.enabled = true
_set_time(19.0)
await _sim_crabs(20.0, func(): return sh.alive().size() >= 3)
check(sh.alive().size() >= 3, "(a few more come up)")
_set_time(7.0)
await _sim_crabs(2.0)
check(sh.alive().all(func(c): return c.state == "home"), "at dawn they head back to the sea")
await _sim_crabs(45.0, func(): return sh.get_children().filter(func(c): return not c.is_queued_for_deletion()).is_empty())
check(sh.get_children().filter(func(c): return not c.is_queued_for_deletion()).is_empty(), "and are gone")
await _sim_crabs(10.0)
check(sh.alive().is_empty(), "none come up by day")
sh.enabled = false
func _gulls() -> void:
print("gulls on the sand")
_set_time(12.0)
_toasts.clear()
var fish: Node3D = w.drop_item("mackerel", 3, Transform3D(Basis(), beach + Vector3(0, 0.15, 0)))
player.global_position = _ground(beach + Vector3(-25, 0, 0))
check(not th.on_sand(w.build.debug_find_site(w._spawn, 6.0)), "(the grass above the beach isn't sand)")
await _sim_thieves(30.0, func(): return _thief("gull") != null or _thief("crow") != null)
var gull := _thief("gull")
check(gull != null and _thief("crow") == null, "food left on the sand draws a gull, not a crow")
if gull == null:
return
await _sim_thieves(30.0, func(): return not gull.carry.is_empty())
check(String(gull.carry.get("id", "")) == "mackerel" and int(fish.amount) == 2, "it snatches one mackerel (%d left)" % int(fish.amount))
check(_toast_has("gull has snatched"), "and you hear about it (%s)" % [_toasts])
check(gull.state == "fly_off", "then it's off with it")
print("shooting one")
await _sim_thieves(1.0)
gull.take_hit(10.0, player.global_position)
check(gull.is_dead(), "an arrow brings it down")
var back: Array = w.all_dropped().filter(func(d): return d.item_id == "mackerel" and d != fish)
check(back.size() == 1 and int(back[0].amount) == 1, "and it drops the mackerel")
check(int(gull.butcher_drops().get("feather", 0)) >= 1, "(feathers)")
await _clear_thieves()
print("inland")
var site: Vector3 = w.build.debug_find_site(w._spawn, 6.0)
if th.on_sand(site):
site = _ground(Vector3(-40, 0, -10))
var berries: Node3D = w.drop_item("berries", 3, Transform3D(Basis(), _ground(site) + Vector3(0, 0.15, 0)))
player.global_position = _ground(site + Vector3(25, 0, 0))
await _sim_thieves(30.0, func(): return not th.alive().is_empty())
check(_thief("crow") != null and _thief("gull") == null, "food left inland still draws a crow")
berries.queue_free()
await _clear_thieves()
print("a smoking rack")
var rp := _ground(site + Vector3(3, 0, 3))
var rack: Node3D = w.spawn_piece(DB.get_item("smoking_rack"), Transform3D(Basis(), rp))
rack.set_process(false)
rack.inventory.add("mackerel", 1) # (one: not enough to smoke, so it stays raw)
rack.inventory.add("smoked_fish", 2)
player.global_position = _ground(rp + Vector3(6, 0, 0))
await _sim_thieves(40.0)
check(th.alive().is_empty(), "with you beside it, nothing comes")
player.global_position = _ground(rp + Vector3(25, 0, 0))
_toasts.clear()
await _sim_thieves(30.0, func(): return _thief("gull") != null)
gull = _thief("gull")
check(gull != null, "left alone, a gull comes for the fish on it")
if gull != null:
await _sim_thieves(30.0, func(): return not gull.carry.is_empty())
check(rack.inventory.count("mackerel") + rack.inventory.count("smoked_fish") == 2, "and takes one (%s)" % [gull.carry])
check(_toast_has("smoking rack"), "(\"A gull's at the fish on your smoking rack!\")")
await _clear_thieves()
rack.inventory.remove("mackerel", 5)
rack.inventory.remove("smoked_fish", 5)
rack.inventory.add("wood", 2)
await _sim_thieves(40.0)
check(th.alive().is_empty(), "no fish on it, no gull")
print("at night")
_set_time(22.0)
var meat: Node3D = w.drop_item("raw_meat", 2, Transform3D(Basis(), beach + Vector3(0, 0.15, 0)))
player.global_position = _ground(beach + Vector3(25, 0, 0))
await _sim_thieves(30.0, func(): return not th.alive().is_empty())
check(_thief("fox") != null and _thief("gull") == null, "at night it's a fox, sand or not")
meat.queue_free()
await _clear_thieves()
func _net() -> void:
print("online")
var CN: Node = root.get_node("CreatureNet")
for k in [["crab", "home"], ["crab", "charge"], ["gull", "peck"]]:
var c: Node3D = (sh.CrabScript if k[0] == "crab" else th.GullScript).new()
c.setup(k[0], w, beach, 5)
c.set_physics_process(false)
w.add_child(c)
c.set_meta("cid", 42)
c.state = k[1]
var buf := PackedByteArray()
buf.resize(CN.ENTRY)
CN._encode(buf, 0, c)
var e: Array = CN._decode(buf, 0)
check(e[1] == k[0] and e[4] == k[1], "a %s goes over the network (%s, %s)" % [k[0], e[1], e[4]])
c.free()

1
tests/test_shore.gd.uid Normal file
View File

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

View File

@@ -58,7 +58,7 @@ func _body() -> void:
var plat: String = C.platform_key() var plat: String = C.platform_key()
print("a version.json feed") print("a version.json feed")
pages[U.feed] = JSON.stringify({"version": "9.1.0", "protocol": 99, "notes": "The Cove.", "page": "https://example.test/r/9.1.0", pages[U.feed] = JSON.stringify({"version": "9.1.0", "protocol": 99, "notes": "The Cove.", "page": "https://example.test/r/9.1.0",
"downloads": {plat: {"file": "Emberwild-9.1.0-%s.zip" % plat, "url": "https://example.test/dl/%s.zip" % plat, "sha256": "ab"}}}) "downloads": {plat: {"file": "Cinderwild-9.1.0-%s.zip" % plat, "url": "https://example.test/dl/%s.zip" % plat, "sha256": "ab"}}})
check(await _check_now() == "available", "9.1.0 is out: available") check(await _check_now() == "available", "9.1.0 is out: available")
check(U.latest_version() == "9.1.0" and U.latest_protocol() == 99, "its version and protocol") check(U.latest_version() == "9.1.0" and U.latest_protocol() == 99, "its version and protocol")
check(U.download_url() == "https://example.test/dl/%s.zip" % plat and U.download().sha256 == "ab", "this platform's download (%s), with its checksum" % plat) check(U.download_url() == "https://example.test/dl/%s.zip" % plat and U.download().sha256 == "ab", "this platform's download (%s), with its checksum" % plat)
@@ -67,9 +67,9 @@ func _body() -> void:
U.feed = "https://example.test/api/releases/latest" U.feed = "https://example.test/api/releases/latest"
pages[U.feed] = JSON.stringify({"tag_name": "v9.2.0", "html_url": "https://example.test/releases/v9.2.0", "assets": [ pages[U.feed] = JSON.stringify({"tag_name": "v9.2.0", "html_url": "https://example.test/releases/v9.2.0", "assets": [
{"name": "version.json", "browser_download_url": "https://example.test/a/version.json"}, {"name": "version.json", "browser_download_url": "https://example.test/a/version.json"},
{"name": "Emberwild-9.2.0-%s.zip" % plat, "browser_download_url": "https://example.test/a/game-%s.zip" % plat}]}) {"name": "Cinderwild-9.2.0-%s.zip" % plat, "browser_download_url": "https://example.test/a/game-%s.zip" % plat}]})
pages["https://example.test/a/version.json"] = JSON.stringify({"version": "9.2.0", "protocol": 99, pages["https://example.test/a/version.json"] = JSON.stringify({"version": "9.2.0", "protocol": 99,
"downloads": {plat: {"file": "Emberwild-9.2.0-%s.zip" % plat, "sha256": "cd"}}, "windows_only_key": true}) "downloads": {plat: {"file": "Cinderwild-9.2.0-%s.zip" % plat, "sha256": "cd"}}, "windows_only_key": true})
check(await _check_now() == "available" and U.latest_version() == "9.2.0", "the release's version.json is read: 9.2.0") check(await _check_now() == "available" and U.latest_version() == "9.2.0", "the release's version.json is read: 9.2.0")
check(U.download_url() == "https://example.test/a/game-%s.zip" % plat, "the download is the release asset with that file name") check(U.download_url() == "https://example.test/a/game-%s.zip" % plat, "the download is the release asset with that file name")
check(U.page == "https://example.test/releases/v9.2.0", "and the release page is kept") check(U.page == "https://example.test/releases/v9.2.0", "and the release page is kept")
@@ -97,7 +97,7 @@ func _body() -> void:
print("the title screen") print("the title screen")
var TS: Script = load("res://scripts/ui/title_screen.gd") var TS: Script = load("res://scripts/ui/title_screen.gd")
var msg := "Different game version: this server runs Emberwild 9.3.0 (protocol 99), you have protocol 12. Update the game to join." var msg := "Different game version: this server runs Cinderwild 9.3.0 (protocol 99), you have protocol 12. Update the game to join."
check(TS.version_advice(msg).contains("9.3.0 is out"), "refused for an old version: it says the new one is out") check(TS.version_advice(msg).contains("9.3.0 is out"), "refused for an old version: it says the new one is out")
check(TS.version_advice("Server is full").is_empty(), "(other refusals: nothing added)") check(TS.version_advice("Server is full").is_empty(), "(other refusals: nothing added)")
pages[U.feed] = JSON.stringify({"version": "9.4.0", "notes": "Sea fishing."}) pages[U.feed] = JSON.stringify({"version": "9.4.0", "notes": "Sea fishing."})

183
tests/test_wreck.gd Normal file
View File

@@ -0,0 +1,183 @@
extends SceneTree
## 0.6.0 (docs/WORLDLETS_DESIGN.md §5): the Cove's wreck and the treasure map.
## The wreck is on the Cove's beach (and not in the Valley); driftwood and
## salt wash up on the sand; the captain's sea chest gives each player a
## treasure map, nails and salt, once; the map's X is that player's own (and
## somewhere else for the next player); reading the map tells you, and the X
## is on your N map while you carry it; who's had their share is saved.
## Run: godot --headless --path . --script res://tests/test_wreck.gd
var _fails := 0
var _finished := false
var S: Node
var A: Node
var _toasts: Array[String] = []
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
S = root.get_node("Saves")
A = root.get_node("Actions")
A.notify.connect(func(t): _toasts.append(t))
S.root_dir = "user://test_wreck_worlds"
for w in S.list_worlds():
S.delete_world(w.id)
await _body()
check(_finished, "test ran to the end (no script errors)")
for w in S.list_worlds():
S.delete_world(w.id)
DirAccess.remove_absolute(S.root_dir)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _load() -> Node3D:
var w: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(w)
for _i in range(8):
await physics_frame
w.creatures.enabled = false
w.player.set_physics_process(false)
w.player.survival.set_process(false)
return w
func _drop(w: Node3D) -> void:
w.queue_free()
for _i in range(3):
await process_frame
func _body() -> void:
print("the Valley")
S.current_id = S.create_world("Valley", 1337)
var w: Node3D = await _load()
check(w.wreck == null, "no wreck in the Valley")
check(not w.props.pickups.values().any(func(p): return p.item_id == "driftwood"), "and no driftwood")
await _drop(w)
print("the wreck")
S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"})
w = await _load()
var t: Node3D = w.terrain
var wk: Node3D = w.wreck
check(wk != null and t.beach_weight(wk.global_position.x, wk.global_position.z) > 0.5, "a wreck on the Cove's beach")
if wk == null:
return
check(wk.global_position.distance_to(w.sea_cave.global_position) > 60.0, "at the far end of the bay from the sea cave (%.0f m)" % wk.global_position.distance_to(w.sea_cave.global_position))
check(w.water_at(wk.front.x, wk.front.z).is_empty() or float(w.water_at(wk.front.x, wk.front.z).depth) < 0.6, "you can stand beside it to reach the chest")
var drift: Array = w.props.pickups.values().filter(func(p): return p.item_id == "driftwood")
check(drift.size() >= 20 and drift.all(func(p): return t.beach_weight(p.global_position.x, p.global_position.z) > 0.5),
"driftwood along the sand (%d)" % drift.size())
check(w.props.pickups.values().any(func(p): return p.item_id == "salt"), "and salt crusts by the waterline")
print("the captain's sea chest")
var p: Node3D = w.player
var me: String = w.my_id()
p.global_position = wk.front
check(wk.chest.interact_prompt(p).begins_with("[E] Open"), "[E] Open the captain's sea chest")
wk.chest.interact(p)
check(p.inventory.count("treasure_map") == 1 and p.inventory.count("nails") >= 4 and p.inventory.count("salt") >= 2,
"a torn treasure map, nails and salt")
check(_toasts.any(func(s): return String(s).begins_with("In the captain's sea chest")), "(and it says so)")
_toasts.clear()
wk.chest.interact(p)
check(p.inventory.count("treasure_map") == 1 and _toasts.has("The sea chest's empty: you've had what was in it"), "once: the second time it's empty")
check(wk.open_for(p, "someone-else"), "someone else gets their own share")
p.inventory.remove("treasure_map", 1) # (it went in this bag: theirs, not ours)
print("the map")
var x: Vector3 = w.treasure_spot(me)
check(x != Vector3.INF and t.inside_boundary(x.x, x.z, 10.0) and x.y > t.water_level + 1.5, "your X: dry ground in the hills (%s)" % x)
var others := 0
for i in range(8):
if w.treasure_spot("player-%d" % i).distance_to(x) > 20.0:
others += 1
check(others >= 5, "and other players' X are mostly somewhere else (%d of 8)" % others)
check(w.treasure_spot(me) == x, "(the same every time for you)")
check(w.map_marks().any(func(m): return m.kind == "treasure" and m.pos == x), "on your map while you carry it, even where you haven't been")
_toasts.clear()
w.read_map(p)
check(_toasts.any(func(s): return String(s).begins_with("X marks the spot")), "reading it: X marks the spot")
p.inventory.remove("treasure_map", 1)
check(not w.map_marks().any(func(m): return m.kind == "treasure"), "(no map, no X)")
p.inventory.add("treasure_map", 1)
w.explore(wk.global_position)
check(w.map_marks().any(func(m): return m.kind == "wreck"), "the wreck's on the map once you've seen it")
print("the dig")
var tr: Node3D = w.treasure
check(tr != null, "the Cove has the pirate treasure")
for _i in range(3):
await process_frame
await create_timer(0.6).timeout
var m: Node3D = tr.mounds.get(me)
check(m != null and m.global_position.distance_to(x) < 0.1, "with your map, a mound of loose earth at your X")
check(tr.mounds.size() == 1, "(only yours: nobody else's shows)")
if m == null:
return
p.inventory.add("stone_pickaxe", 1)
_hold(p, "stone_pickaxe")
tr.force_crew = true
w.day_night.time = 21.0 / 24.0 # night
w.day_night.apply(true)
p.global_position = m.global_position + Vector3(1.4, 0, 0)
p.survival.stamina = 100.0
A.harvest_hit(p, m, m.global_position)
check(tr.crews.has(me) and tr.crews[me].alive().size() == 3, "at night, the first blow brings up the drowned crew (%d)" % (tr.crews[me].alive().size() if tr.crews.has(me) else 0))
if tr.crews.has(me):
var g: Node3D = tr.crews[me].alive()[0]
check(g.kind == "drowned" and g.state == "charge", "drowned sailors, coming for you")
g.set_physics_process(false)
for c in tr.crews[me].alive():
c.queue_free()
tr.crews[me].queue_free()
await process_frame
var blows := 1
while is_instance_valid(m) and not m.depleted and blows < 40:
p.survival.stamina = 100.0
A.harvest_hit(p, m, m.global_position)
blows += 1
check(blows == 18, "18 blows of a stone pickaxe (%d)" % blows)
check(p.inventory.count("ship_compass") == 1 and p.inventory.count("spyglass") == 1 and p.inventory.count("cutlass") == 1 \
and p.inventory.count("pirate_gold") == 3, "the pirate chest: a ship's compass, a spyglass, a cutlass and 3 pirate gold")
check(p.inventory.count("treasure_map") == 0, "and the map's used up")
check(me in tr.dug and not tr.mounds.has(me), "your treasure's dug: no more mound")
check(not w.map_marks().any(func(mk): return mk.kind == "treasure"), "(and no X)")
_hold(p, "cutlass")
var ms: Dictionary = A.melee_stats(p)
check(bool(ms.armed) and float(ms.damage) >= 30.0, "the cutlass hits hard (%s)" % ms.damage)
var other: Node3D = tr.mound_for("someone-else")
check(other != null and other.global_position.distance_to(x) > 1.0, "someone else's treasure is still in the ground, somewhere else")
check(DB_has("pirate_gold") and A.can_place(p, _slot(p, "pirate_gold")), "pirate gold can be set down as a trophy")
print("saved")
w.save_now()
await _drop(w)
w = await _load()
check(w.wreck.had(me) and w.wreck.had("someone-else") and not w.wreck.had("a-stranger"), "who's had their share is kept")
check(me in w.treasure.dug, "and whose treasure's been dug")
await _drop(w)
_finished = true
func DB_has(id: String) -> bool:
return not root.get_node("ItemDB").get_item(id).is_empty()
func _slot(p: Node, id: String) -> int:
for i in range(p.inventory.size()):
var s = p.inventory.get_slot(i)
if s != null and s.id == id:
return i
return -1
func _hold(p: Node, id: String) -> void:
var i := _slot(p, id)
if i < 0:
return
var tmp = p.inventory.get_slot(0)
p.inventory.set_slot(0, p.inventory.get_slot(i))
p.inventory.set_slot(i, tmp)
p.select(0)

1
tests/test_wreck.gd.uid Normal file
View File

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

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Emberwild's own sounds: the music, the ambience beds and the sound effects """Cinderwild's own sounds: the music, the ambience beds and the sound effects
that the Kenney CC0 packs don't cover, all synthesised here (no samples, no that the Kenney CC0 packs don't cover, all synthesised here (no samples, no
third-party audio), so they're ours to release under CC0 too. third-party audio), so they're ours to release under CC0 too.
@@ -616,6 +616,29 @@ def make_voices():
_encode(os.path.join(d, "fox_yip_%d.ogg" % i), _encode(os.path.join(d, "fox_yip_%d.ogg" % i),
[voice(200 + i, 0.18, lambda q, k=i: 800 + 80 * k + 500 * sin(math.pi * q), _swell(0.05, 0.5), [voice(200 + i, 0.18, lambda q, k=i: 800 + 80 * k + 500 * sin(math.pi * q), _swell(0.05, 0.5),
[(1100, 3, 1.0), (2600, 4, 0.4)], breath=0.2, rasp=0.2, jitter=0.02)], SR_FX, 5) [(1100, 3, 1.0), (2600, 4, 0.4)], breath=0.2, rasp=0.2, jitter=0.02)], SR_FX, 5)
# 0.6.0, the Cove: a gull's "kee-ow" (and a laughing "ha-ha-ha"), and a
# crab's claws clicking.
_encode(os.path.join(d, "gull_cry_0.ogg"),
[voice(210, 0.55, lambda q: 1350 - 500 * q ** 1.4 + 120 * sin(math.pi * min(1, q * 3)), _swell(0.06, 0.55),
[(1700, 4, 1.0), (3300, 5, 0.5)], breath=0.25, rasp=0.25, rasp_hz=70, jitter=0.03)], SR_FX, 5)
hs = [0.0, 0.14, 0.28, 0.42]
def genv(q, ps=hs):
t = q * 0.6
return max(max(0.0, min(1, (t - p) / 0.015)) * max(0.0, 1 - (t - p) / 0.11) if t >= p else 0.0 for p in ps)
_encode(os.path.join(d, "gull_cry_1.ogg"),
[voice(211, 0.6, lambda q: 1150 - 150 * q, genv, [(1600, 4, 1.0), (3100, 5, 0.5)],
breath=0.3, rasp=0.3, rasp_hz=70, jitter=0.03)], SR_FX, 5)
for i in range(2):
rng = random.Random(220 + i)
cb = fx_buf(0.3)
for t in [0.0, 0.07, 0.11, 0.2][: 2 + i * 2]:
i0 = int(t * SR_FX)
f = rng.uniform(2600, 3600)
for j in range(int(0.012 * SR_FX)):
if i0 + j < len(cb):
cb[i0 + j] += sin(TAU * f * j / SR_FX) * exp(-j / 60) * 0.9
add_noise(cb, SR_FX, t, 0.004, 0.7, lp=0.95, hp=0.4, env=lambda q: 1 - q, rng=rng)
_encode(os.path.join(d, "crab_click_%d.ogg" % i), [cb], SR_FX, 5)
def make_sfx(): def make_sfx():

View File

@@ -4,9 +4,9 @@
# tools/export.sh # macOS and Windows # tools/export.sh # macOS and Windows
# tools/export.sh macOS # just one ("macOS" or "Windows") # tools/export.sh macOS # just one ("macOS" or "Windows")
# #
# build/Emberwild-<version>-macos.zip the .app (universal: Apple silicon + Intel), # build/Cinderwild-<version>-macos.zip the .app (universal: Apple silicon + Intel),
# ad-hoc signed, not notarised # ad-hoc signed, not notarised
# build/Emberwild-<version>-windows.zip Emberwild.exe + Emberwild.pck (+ a console # build/Cinderwild-<version>-windows.zip Cinderwild.exe + Cinderwild.pck (+ a console
# wrapper), unsigned # wrapper), unsigned
# #
# Needs the Godot export templates for this Godot's version (Editor > Manage # Needs the Godot export templates for this Godot's version (Editor > Manage
@@ -29,21 +29,21 @@ mkdir -p build
if [ "$which" = all ] || [ "$which" = macOS ]; then if [ "$which" = all ] || [ "$which" = macOS ]; then
rm -rf build/macos && mkdir -p build/macos rm -rf build/macos && mkdir -p build/macos
"$GODOT" --headless --path . --export-release "macOS" build/macos/Emberwild.zip > build/export-macos.log 2>&1 "$GODOT" --headless --path . --export-release "macOS" build/macos/Cinderwild.zip > build/export-macos.log 2>&1
if [ -f build/macos/Emberwild.zip ]; then if [ -f build/macos/Cinderwild.zip ]; then
mv build/macos/Emberwild.zip "build/Emberwild-$VERSION-macos.zip" mv build/macos/Cinderwild.zip "build/Cinderwild-$VERSION-macos.zip"
echo "built build/Emberwild-$VERSION-macos.zip" echo "built build/Cinderwild-$VERSION-macos.zip"
else else
echo "macOS export failed: see build/export-macos.log"; exit 1 echo "macOS export failed: see build/export-macos.log"; exit 1
fi fi
fi fi
if [ "$which" = all ] || [ "$which" = Windows ]; then if [ "$which" = all ] || [ "$which" = Windows ]; then
rm -rf build/windows && mkdir -p build/windows/Emberwild rm -rf build/windows && mkdir -p build/windows/Cinderwild
"$GODOT" --headless --path . --export-release "Windows" build/windows/Emberwild/Emberwild.exe > build/export-windows.log 2>&1 "$GODOT" --headless --path . --export-release "Windows" build/windows/Cinderwild/Cinderwild.exe > build/export-windows.log 2>&1
if [ -f build/windows/Emberwild/Emberwild.exe ]; then if [ -f build/windows/Cinderwild/Cinderwild.exe ]; then
(cd build/windows && rm -f "../Emberwild-$VERSION-windows.zip" && zip -qr "../Emberwild-$VERSION-windows.zip" Emberwild) (cd build/windows && rm -f "../Cinderwild-$VERSION-windows.zip" && zip -qr "../Cinderwild-$VERSION-windows.zip" Cinderwild)
echo "built build/Emberwild-$VERSION-windows.zip" echo "built build/Cinderwild-$VERSION-windows.zip"
else else
echo "Windows export failed: see build/export-windows.log"; exit 1 echo "Windows export failed: see build/export-windows.log"; exit 1
fi fi
@@ -58,7 +58,7 @@ out = {"version": version, "protocol": protocol, "date": datetime.date.today().i
if notes: if notes:
out["notes"] = notes out["notes"] = notes
for plat in ("macos", "windows"): for plat in ("macos", "windows"):
f = "build/Emberwild-%s-%s.zip" % (version, plat) f = "build/Cinderwild-%s-%s.zip" % (version, plat)
if os.path.exists(f): if os.path.exists(f):
h = hashlib.sha256(open(f, "rb").read()).hexdigest() h = hashlib.sha256(open(f, "rb").read()).hexdigest()
out["downloads"][plat] = {"file": os.path.basename(f), "sha256": h, "size": os.path.getsize(f)} out["downloads"][plat] = {"file": os.path.basename(f), "sha256": h, "size": os.path.getsize(f)}

View File

@@ -10,7 +10,7 @@
# Applications). It's read from the environment, never from the command line. # Applications). It's read from the environment, never from the command line.
cd "$(dirname "$0")/.." || exit 1 cd "$(dirname "$0")/.." || exit 1
API=${API:-https://git.psbailey.uk/api/v1/repos/Admin/Emberwild} API=${API:-https://git.psbailey.uk/api/v1/repos/Admin/Cinderwild}
[ -n "$GITEA_TOKEN" ] || { echo "set GITEA_TOKEN first: read -s GITEA_TOKEN && export GITEA_TOKEN"; exit 1; } [ -n "$GITEA_TOKEN" ] || { echo "set GITEA_TOKEN first: read -s GITEA_TOKEN && export GITEA_TOKEN"; exit 1; }
[ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; } [ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; }
VERSION=$(python3 -c "import json; print(json.load(open('build/version.json'))['version'])") VERSION=$(python3 -c "import json; print(json.load(open('build/version.json'))['version'])")
@@ -20,12 +20,12 @@ AUTH="Authorization: token $GITEA_TOKEN"
git ls-remote --exit-code origin "refs/tags/$TAG" >/dev/null || { echo "push the tag first: git push origin $TAG"; exit 1; } git ls-remote --exit-code origin "refs/tags/$TAG" >/dev/null || { echo "push the tag first: git push origin $TAG"; exit 1; }
BODY=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': 'Emberwild ' + sys.argv[2], 'body': sys.argv[3], 'draft': False, 'prerelease': False}))" "$TAG" "$VERSION" "$NOTES") BODY=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': 'Cinderwild ' + sys.argv[2], 'body': sys.argv[3], 'draft': False, 'prerelease': False}))" "$TAG" "$VERSION" "$NOTES")
ID=$(curl -s -H "$AUTH" -H "Content-Type: application/json" -d "$BODY" "$API/releases" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id') or ''); sys.stderr.write(d.get('message','') + '\n') if not d.get('id') else None") ID=$(curl -s -H "$AUTH" -H "Content-Type: application/json" -d "$BODY" "$API/releases" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id') or ''); sys.stderr.write(d.get('message','') + '\n') if not d.get('id') else None")
[ -n "$ID" ] || { echo "couldn't create the release (above)"; exit 1; } [ -n "$ID" ] || { echo "couldn't create the release (above)"; exit 1; }
echo "release $TAG created (id $ID)" echo "release $TAG created (id $ID)"
for f in version.json "Emberwild-$VERSION-macos.zip" "Emberwild-$VERSION-windows.zip"; do for f in version.json "Cinderwild-$VERSION-macos.zip" "Cinderwild-$VERSION-windows.zip"; do
[ -f "build/$f" ] || { echo "missing build/$f"; exit 1; } [ -f "build/$f" ] || { echo "missing build/$f"; exit 1; }
R=$(curl -s -H "$AUTH" -F "attachment=@build/$f" "$API/releases/$ID/assets?name=$f") R=$(curl -s -H "$AUTH" -F "attachment=@build/$f" "$API/releases/$ID/assets?name=$f")
echo "$R" | grep -q '"browser_download_url"' && echo " uploaded $f" || { echo " upload of $f failed: $R"; exit 1; } echo "$R" | grep -q '"browser_download_url"' && echo " uploaded $f" || { echo " upload of $f failed: $R"; exit 1; }
@@ -34,7 +34,7 @@ done
# Check what anyone downloads is what was built. # Check what anyone downloads is what was built.
REPO=${API%/api/v1/repos/*}/$(echo "$API" | sed 's#.*/repos/##') REPO=${API%/api/v1/repos/*}/$(echo "$API" | sed 's#.*/repos/##')
for p in macos windows; do for p in macos windows; do
f="Emberwild-$VERSION-$p.zip" f="Cinderwild-$VERSION-$p.zip"
want=$(python3 -c "import json,sys; print(json.load(open('build/version.json'))['downloads'][sys.argv[1]]['sha256'])" $p) want=$(python3 -c "import json,sys; print(json.load(open('build/version.json'))['downloads'][sys.argv[1]]['sha256'])" $p)
got=$(curl -s -L "$REPO/releases/download/$TAG/$f" | shasum -a 256 | cut -d' ' -f1) got=$(curl -s -L "$REPO/releases/download/$TAG/$f" | shasum -a 256 | cut -d' ' -f1)
[ "$want" = "$got" ] && echo " $f: checksum ok" || { echo " $f: CHECKSUM MISMATCH"; exit 1; } [ "$want" = "$got" ] && echo " $f: checksum ok" || { echo " $f: CHECKSUM MISMATCH"; exit 1; }

View File

@@ -1,11 +1,11 @@
## Getting started {#getting-started} ## Getting started {#getting-started}
Emberwild drops you on a wooded island ringed by mountains, with a lake in the middle and sea cliffs on one side. You start with nothing: everything comes from the island. Cinderwild drops you on a wooded island ringed by mountains, with a lake in the middle and sea cliffs on one side. You start with nothing: everything comes from the island.
### Installing ### Installing
- **Mac:** unzip and move Emberwild to Applications. It isn't notarised by Apple, so the first time, right-click it and choose **Open** (or System Settings > Privacy & Security > Open Anyway). - **Mac:** unzip and move Cinderwild to Applications. It isn't notarised by Apple, so the first time, right-click it and choose **Open** (or System Settings > Privacy & Security > Open Anyway).
- **Windows:** unzip and run `Emberwild.exe`. Keep `Emberwild.pck` in the same folder or the game won't start. It's unsigned, so if SmartScreen says "Windows protected your PC", choose **More info**, then **Run anyway**. - **Windows:** unzip and run `Cinderwild.exe`. Keep `Cinderwild.pck` in the same folder or the game won't start. It's unsigned, so if SmartScreen says "Windows protected your PC", choose **More info**, then **Run anyway**.
### The title screen ### The title screen
@@ -349,6 +349,13 @@ Low hills run down to a sheltered sandy bay, with mountains behind, a small pond
- **The pier** runs out over the bay. Fish off its end for **mackerel**, the usual catch. **Cod** are bigger but only bite where the water's at least 2.2 m deep, so cast well out. Both bite best at dawn and dusk, and grill at a campfire, salt or smoke like lake fish. - **The pier** runs out over the bay. Fish off its end for **mackerel**, the usual catch. **Cod** are bigger but only bite where the water's at least 2.2 m deep, so cast well out. Both bite best at dawn and dusk, and grill at a campfire, salt or smoke like lake fish.
- **The sea cave** at the end of the beach is the way back: `E` goes into it and up the same tunnel, arriving at the bottom. - **The sea cave** at the end of the beach is the way back: `E` goes into it and up the same tunnel, arriving at the bottom.
- **The beach** has its own pickings: **driftwood** (it burns, and it's what a boat is built from), **salt** crusted above the waterline, and **seagrass** at the tide line.
- **The wreck** lies at the far end of the bay. Inside is the captain's **sea chest**: a torn **treasure map**, 4 nails and 2 salt, once for each player.
- **The treasure:** right-click the map to read it, and an **X** goes on your map (`N`). Every player's map has its own X. There, only you can see a low mound by a marker stone; dig it with any pickaxe (18 blows with a stone one). Inside the pirate chest are the **ship's compass**, a **spyglass** (hold right-click to look through it), a **cutlass** (quick, and harder-hitting than the iron spear) and **pirate gold** (nothing takes coin yet, but you can set a heap of it in your hut). The map's used up.
- **The drowned crew:** three of the ship's crew guard it. Dig at night and they rise at the first blow; by day, when the chest comes up. They're slow but hit hard; bone, so blunt weapons do more.
- **Gulls** are the Cove's crows: by day they come for food left on the sand, and for fish on a smoking rack that nobody's watching. A gull snatches one and flies off with it; shoot it down and it drops it.
- **Crabs** come up the beach from dusk till dawn and keep to the sand. Walk within a couple of metres and one comes at you and pinches; back off and it lets you go. A dead one gives **crab meat**: two cook into a **cooked crab** at a campfire. At dawn they go back into the sea.
- **The rowing boat:** twist 3 seagrass into a **rope** by hand; boil 2 **pine resin** (from felled pines, in any land) and a salt into **pitch** at a campfire. A **rowing boat** is 12 driftwood, 4 rope, 3 pitch and 6 nails at a workbench. Take it to the **mooring post** at the end of the pier and `E` ties it up at a berth of its own, one boat each. Its **Set sail** needs the ship's compass, and even then the way to the Highlands isn't charted yet: that's the next part of the journey.
Each land keeps its own state, and there's one clock for them all. What you build in the Valley waits for you while you're away, and when you come back the time you were gone catches up, as after a night's sleep: kilns and smelters will have done their work, fires burned down and food gone off. Nothing roams a land while you're away, so your base won't have been raided. Each land keeps its own state, and there's one clock for them all. What you build in the Valley waits for you while you're away, and when you come back the time you were gone catches up, as after a night's sleep: kilns and smelters will have done their work, fires burned down and food gone off. Nothing roams a land while you're away, so your base won't have been raided.
@@ -411,7 +418,7 @@ Each extra player at the stones when it's called gives the Old Stag 60% more hea
### The pause menu ### The pause menu
`Esc` opens it (and pauses your own world). It has **Resume**, **Controls**, **Fullscreen**, **Mouse sensitivity**, volumes for **Music**, **Sounds**, **Ambience** and **Old Tam's voice**, **Old Tam's tips** on or off with **Show again**, **Check for updates at start**, and **Save & Quit** to the title or desktop. The game autosaves every 2 minutes and when you sleep. In the tutorial it also offers **Skip this tutorial step** and **Leave the tutorial**. `Esc` opens it (and pauses your own world). It has **Resume**, **Controls**, **Report a bug**, **Fullscreen**, **Mouse sensitivity**, volumes for **Music**, **Sounds**, **Ambience** and **Old Tam's voice**, **Old Tam's tips** on or off with **Show again**, **Check for updates at start**, and **Save & Quit** to the title or desktop. The game autosaves every 2 minutes and when you sleep. In the tutorial it also offers **Skip this tutorial step** and **Leave the tutorial**.
### Old Tam's tips ### Old Tam's tips
@@ -421,9 +428,13 @@ In your own worlds Old Tam offers a one-off tip the first time something comes u
The music follows what's happening: calm by day, a night theme after dark, a storm theme in storms, blizzards and wolf moons, danger music when something is coming for you, and a boss theme for the Old Stag. Between pieces there's ambience: birds by day, crickets and owls at night. Indoors everything is quieter, and the music dips while Old Tam speaks. The music follows what's happening: calm by day, a night theme after dark, a storm theme in storms, blizzards and wolf moons, danger music when something is coming for you, and a boss theme for the Old Stag. Between pieces there's ambience: birds by day, crickets and owls at night. Indoors everything is quieter, and the music dips while Old Tam speaks.
### Reporting a bug
**Report a bug** in the pause menu opens a new issue on Cinderwild's [Gitea page](https://git.psbailey.uk/Admin/Cinderwild/issues) in your browser, already filled in with your version, computer, mode, land, the in-game time and where you are. Say what happened and how to make it happen again, then press **Create Issue** (you'll need to be signed in there). Nothing is sent from the game itself. **Copy the details** puts the same details on your clipboard instead, and **Show the log file** opens the folder with `godot.log`: attach it for a crash or an error.
### Updates ### Updates
With the update check on, the title screen tells you when a newer Emberwild is out and offers a **Download** button or the release page. With the update check on, the title screen tells you when a newer Cinderwild is out and offers a **Download** button or the release page.
## Tips for your first days {#tips} ## Tips for your first days {#tips}

View File

@@ -3,119 +3,340 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Emberwild: a self-hosted co-op survival game</title> <title>Cinderwild: a self-hosted co-op survival game</title>
<meta name="description" content="Emberwild is a low-poly survival game: gather, hunt, build a home and hold out through the night, alone or with friends on your own server."> <meta name="description" content="Cinderwild is a low-poly survival game: gather, hunt, build a home and hold out through the night, then dig through the sea cliffs to the Cove. Alone, or with friends on your own server.">
<meta property="og:title" content="Emberwild"> <meta name="theme-color" content="#15110d">
<meta property="og:description" content="Survive the island. Build a home. Self-hosted co-op survival."> <meta property="og:type" content="website">
<meta property="og:image" content="poster.jpg"> <meta property="og:url" content="{{SITE_URL}}/">
<meta property="og:title" content="Cinderwild">
<meta property="og:description" content="Survive the island. Build a home. Self-hosted co-op survival for Mac and Windows.">
<meta property="og:image" content="{{SITE_URL}}/poster.jpg">
<meta name="twitter:card" content="summary_large_image">
<style> <style>
:root { :root {
--bg: #15110d; --panel: #221b14; --line: #3a2e22; --bg: #15110d; --bg2: #1b1510; --panel: #221b14; --line: #3a2e22;
--text: #f1e8da; --dim: #b8a892; --ember: #e8833a; --ember-hi: #f6a35c; --text: #f1e8da; --dim: #bcae98; --ember: #e8833a; --ember-hi: #f6a35c;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; background: var(--bg); color: var(--text); body { margin: 0; background: var(--bg); color: var(--text);
font: 17px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } font: 17px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
a { color: var(--ember-hi); } a { color: var(--ember-hi); }
a:focus-visible, button:focus-visible { outline: 3px solid var(--ember-hi); outline-offset: 3px; } a:focus-visible, button:focus-visible, video:focus-visible { outline: 3px solid var(--ember-hi); outline-offset: 3px; }
.wrap { max-width: 980px; margin: 0 auto; padding: 0 20px; } img { max-width: 100%; height: auto; display: block; }
header { padding: 48px 0 20px; text-align: center; } .skip { position: absolute; left: -9999px; top: 8px; background: var(--ember); color: #1a120a; padding: 8px 14px; border-radius: 6px; z-index: 10; }
h1 { font-size: clamp(44px, 9vw, 84px); letter-spacing: .06em; margin: 0; font-weight: 600; } .skip:focus { left: 8px; }
.tag { color: var(--dim); font-size: 20px; margin: 6px 0 0; } .wrap { max-width: 1080px; margin: 0 auto; padding: 0 22px; }
.guide-link { margin: 10px 0 0; font-size: 17px; }
.video { margin: 28px 0 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; /* Top bar */
background: #000; aspect-ratio: 16 / 9; } nav.top { position: absolute; inset: 0 0 auto 0; z-index: 2; }
.video video { width: 100%; height: 100%; display: block; } nav.top .wrap { display: flex; align-items: center; gap: 22px; padding-top: 16px; padding-bottom: 16px; }
section { margin: 44px 0; } nav.top .brand { font-weight: 700; letter-spacing: .14em; color: var(--text); text-decoration: none; margin-right: auto; }
h2 { font-size: 26px; margin: 0 0 14px; font-weight: 600; } nav.top a.l { color: var(--text); text-decoration: none; font-size: 15px; opacity: .9; }
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; } nav.top a.l:hover { color: var(--ember-hi); }
.dl { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 20px; } @media (max-width: 620px) { nav.top a.l.opt { display: none; } }
.dl.mine { border-color: var(--ember); }
.dl h3 { margin: 0 0 4px; font-size: 20px; } /* Hero */
.dl p { margin: 0 0 14px; color: var(--dim); font-size: 15px; } .hero { position: relative; min-height: min(88vh, 760px); display: flex; align-items: flex-end;
background: #0d0a07 url("shots/cove.jpg") center 35% / cover no-repeat; }
.hero::before { content: ""; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(13,10,7,.55) 0%, rgba(13,10,7,.15) 35%, rgba(21,17,13,.85) 78%, var(--bg) 100%); }
.hero .wrap { position: relative; padding-bottom: 56px; width: 100%; }
.pill { display: inline-block; font-size: 14px; padding: 3px 12px; border-radius: 999px;
background: rgba(232,131,58,.18); border: 1px solid rgba(246,163,92,.55); color: var(--ember-hi); }
.hero h1 { font-size: clamp(52px, 11vw, 112px); line-height: 1; letter-spacing: .07em; margin: 14px 0 8px; font-weight: 700;
text-shadow: 0 2px 24px rgba(0,0,0,.55); }
.hero .tag { font-size: clamp(19px, 2.6vw, 25px); margin: 0 0 6px; text-shadow: 0 1px 10px rgba(0,0,0,.7); }
.hero .sub { color: var(--dim); margin: 0 0 26px; max-width: 640px; text-shadow: 0 1px 8px rgba(0,0,0,.8); }
.ctas { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.btn { display: inline-block; background: var(--ember); color: #1a120a; text-decoration: none; .btn { display: inline-block; background: var(--ember); color: #1a120a; text-decoration: none;
font-weight: 600; padding: 11px 20px; border-radius: 8px; } font-weight: 650; padding: 12px 22px; border-radius: 9px; border: 1px solid transparent; }
.btn:hover { background: var(--ember-hi); } .btn:hover { background: var(--ember-hi); }
.note { color: var(--dim); font-size: 14px; margin-top: 12px; } .btn.big { font-size: 19px; padding: 15px 28px; }
.note code { color: var(--text); } .btn.ghost { background: rgba(21,17,13,.55); color: var(--text); border-color: rgba(241,232,218,.35); }
ul.features { columns: 2 260px; padding-left: 20px; margin: 0; } .btn.ghost:hover { border-color: var(--ember-hi); color: var(--ember-hi); background: rgba(21,17,13,.75); }
ul.features li { margin: 0 0 6px; break-inside: avoid; } .hero .small { color: var(--dim); font-size: 14px; margin: 12px 0 0; }
footer { border-top: 1px solid var(--line); padding: 24px 0 40px; color: var(--dim); font-size: 14px; } .hero .small a { color: var(--text); }
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto; } }
section { padding: 56px 0; }
section.alt { background: var(--bg2); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
h2 { font-size: clamp(26px, 3.4vw, 34px); line-height: 1.2; margin: 0 0 8px; font-weight: 650; }
.lead { color: var(--dim); margin: 0 0 26px; max-width: 720px; }
/* Trailer */
.video { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: #000; aspect-ratio: 16 / 9;
box-shadow: 0 20px 60px rgba(0,0,0,.45); }
.video video { width: 100%; height: 100%; display: block; }
/* Feature cards */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
.card img { aspect-ratio: 16 / 9; object-fit: cover; width: 100%; }
.card .body { padding: 16px 18px 18px; }
.card h3 { margin: 0 0 6px; font-size: 20px; }
.card p { margin: 0; color: var(--dim); font-size: 15.5px; }
/* What's new */
.new { display: grid; grid-template-columns: 1.3fr 1fr; gap: 28px; align-items: start; }
@media (max-width: 820px) { .new { grid-template-columns: 1fr; } }
ul.ticks { list-style: none; padding: 0; margin: 0; }
ul.ticks li { padding: 8px 0 8px 30px; position: relative; border-bottom: 1px solid var(--line); }
ul.ticks li:last-child { border-bottom: 0; }
ul.ticks li::before { content: ""; position: absolute; left: 4px; top: 17px; width: 10px; height: 10px; border-radius: 50%;
background: var(--ember); box-shadow: 0 0 10px rgba(232,131,58,.8); }
.callout { background: var(--panel); border: 1px solid var(--ember); border-radius: 12px; padding: 20px 22px; }
.callout h3 { margin: 0 0 8px; font-size: 20px; }
.callout p { margin: 0 0 10px; color: var(--dim); font-size: 15.5px; }
.callout p:last-child { margin: 0; }
kbd { font: 600 13px/1 ui-monospace, SFMono-Regular, Menlo, monospace; background: #2f251b; border: 1px solid #4a3b2c;
border-bottom-width: 2px; border-radius: 5px; padding: 3px 6px; color: var(--text); }
/* Gallery */
.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; margin: 0; }
.gallery figure { margin: 0; position: relative; border-radius: 10px; overflow: hidden; border: 1px solid var(--line); background: #000; }
.gallery a { display: block; }
.gallery img { aspect-ratio: 16 / 9; object-fit: cover; width: 100%; transition: transform .35s ease; }
.gallery a:hover img { transform: scale(1.03); }
.gallery figcaption { position: absolute; left: 0; right: 0; bottom: 0; padding: 22px 14px 10px; font-size: 14.5px;
background: linear-gradient(transparent, rgba(0,0,0,.78)); pointer-events: none; }
/* Downloads */
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px; }
.dl { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 22px; }
.dl.mine { border-color: var(--ember); box-shadow: 0 0 0 1px var(--ember) inset; }
.dl h3 { margin: 0 0 2px; font-size: 21px; }
.dl .meta { margin: 0 0 16px; color: var(--dim); font-size: 15px; }
.dl .yours { display: none; font-size: 13px; color: var(--ember-hi); margin-left: 8px; font-weight: 600; }
.dl.mine .yours { display: inline; }
.note { color: var(--dim); font-size: 14.5px; margin: 14px 0 0; }
.note code, td code, p code { color: var(--text); background: #2a2119; padding: 1px 6px; border-radius: 5px; font-size: .92em; }
/* Friends */
.steps { counter-reset: s; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; margin: 0 0 22px; padding: 0; list-style: none; }
.steps li { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 18px 18px 18px 60px; position: relative; color: var(--dim); font-size: 15.5px; }
.steps li b { color: var(--text); }
.steps li::before { counter-increment: s; content: counter(s); position: absolute; left: 18px; top: 16px; width: 28px; height: 28px;
border-radius: 50%; background: var(--ember); color: #1a120a; font-weight: 700; display: grid; place-items: center; font-size: 15px; }
table { border-collapse: collapse; width: 100%; max-width: 640px; font-size: 15.5px; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); }
th { color: var(--dim); font-weight: 600; }
footer { border-top: 1px solid var(--line); padding: 28px 0 44px; color: var(--dim); font-size: 14.5px; }
footer .wrap { display: flex; flex-wrap: wrap; gap: 10px 26px; justify-content: space-between; }
footer nav a { margin-right: 18px; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.gallery img { transition: none; }
.gallery a:hover img { transform: none; }
}
</style> </style>
</head> </head>
<body> <body>
<header class="wrap"> <a class="skip" href="#main">Skip to the page</a>
<h1>EMBERWILD</h1>
<p class="tag">Survive the island. Build a home.</p> <nav class="top" aria-label="Site">
<p class="guide-link"><a href="guide.html">Read the guide</a>: how to play, and everything in the game.</p> <div class="wrap">
<a class="brand" href="#">CINDERWILD</a>
<a class="l opt" href="#trailer">Trailer</a>
<a class="l opt" href="#world">The game</a>
<a class="l" href="guide.html">Guide</a>
<a class="l" href="#download">Download</a>
</div>
</nav>
<header class="hero">
<div class="wrap">
<span class="pill">Version {{VERSION}} · playtest</span>
<h1>CINDERWILD</h1>
<p class="tag">Survive the island. Build a home. Find what lies beyond.</p>
<p class="sub">A low-poly survival game for Mac and Windows. Gather, hunt and build, hold out
through wolf moons and hard winters, then dig down through the sea cliffs to the Cove.
Alone, or with friends on a server you run yourself.</p>
<div class="ctas">
<a class="btn big" id="hero-dl" href="#download">Download free</a>
<a class="btn ghost" href="#trailer">Watch the trailer</a>
<a class="btn ghost" href="guide.html">Read the guide</a>
</div>
<p class="small" id="hero-other">For macOS (Apple silicon and Intel) and Windows (64-bit).</p>
</div>
</header>
<main id="main">
<section id="trailer" aria-labelledby="trailer-title">
<div class="wrap">
<h2 id="trailer-title">The trailer</h2>
<p class="lead">Under a minute, recorded in the game.</p>
<div class="video"> <div class="video">
<video controls preload="metadata" poster="poster.jpg" aria-label="Emberwild trailer"> <video controls preload="metadata" poster="poster.jpg" aria-label="Cinderwild trailer">
<source src="trailer.mp4" type="video/mp4"> <source src="trailer.mp4" type="video/mp4">
Your browser can't play this video. <a href="trailer.mp4">Download the trailer</a>. Your browser can't play this video. <a href="trailer.mp4">Download the trailer</a>.
</video> </video>
</div> </div>
</header> </div>
</section>
<main class="wrap"> <section id="world" class="alt" aria-labelledby="world-title">
<section aria-labelledby="dl-title"> <div class="wrap">
<h2 id="world-title">What you'll do</h2>
<p class="lead">Every tree, animal and hut is built from low-poly shapes in the game's
own code, under a lot of weather.</p>
<div class="cards">
<article class="card">
<img src="shots/meadow.jpg" alt="A sunny meadow with deer among the trees" width="1280" height="720" loading="lazy">
<div class="body"><h3>Survive</h3>
<p>Gather, craft and cook. Keep warm, fed and dry, and eat a mix of meat, fish and
greens. Food goes off; salt and smoke keep it.</p></div>
</article>
<article class="card">
<img src="shots/hut.jpg" alt="A wooden hut with a campfire at dusk" width="1280" height="720" loading="lazy">
<div class="body"><h3>Build</h3>
<p>Floors, walls and roofs that snap together, in wood and then stone. A roof, a
fire and a bed make a home you'll be glad of at night.</p></div>
</article>
<article class="card">
<img src="shots/wolves.jpg" alt="Wolves circling a campfire under a red moon" width="1280" height="720" loading="lazy">
<div class="body"><h3>Hold out</h3>
<p>Wolves move in, and every seventh night the moon turns red. There's a bear in
the deep woods, a barrow, and the Old Stag at the standing stones.</p></div>
</article>
<article class="card">
<img src="shots/treasure.jpg" alt="A marker stone in the Cove's hills at dusk, with a drowned sailor among the trees" width="1280" height="720" loading="lazy">
<div class="body"><h3>Explore</h3>
<p>Dig through the sea cliffs to the Cove: a sandy bay, a pier for sea fishing, a
wreck, and a pirate treasure for each player, guarded by the drowned crew.</p></div>
</article>
</div>
</div>
</section>
<section id="new" aria-labelledby="new-title">
<div class="wrap">
<h2 id="new-title">New in {{VERSION}}</h2>
<p class="lead">The Cove gets its own. This is a playtest build: thanks for trying it.</p>
<div class="new">
<ul class="ticks">
<li>A new name: Emberwild is now Cinderwild (another game already had the old one). Your worlds come with you.</li>
<li>A wreck on the Cove's beach, with a treasure map in the captain's sea chest for each player.</li>
<li>Your own X on your map: dig up the pirate chest for a ship's compass, a spyglass, a cutlass and gold.</li>
<li>The drowned crew rise to guard it. Dig by day if you're wise.</li>
<li>Crabs on the sand from dusk till dawn, and gulls after any food you leave out.</li>
<li>Build a rowing boat from driftwood, seagrass rope and pitch, and tie it up at the pier.</li>
<li>Report a bug, straight from the pause menu.</li>
</ul>
<aside class="callout" aria-labelledby="bug-title">
<h3 id="bug-title">Found a bug?</h3>
<p>Press <kbd>Esc</kbd> in the game, then <b>Report a bug</b>. It opens a new issue
on <a href="{{REPO_URL}}/issues">our Gitea</a>, already filled in with your
version, computer and where you are. Say what happened and press Create Issue.</p>
<p>You'll need a free account there. <a href="{{REPO_URL}}/src/branch/master/docs/PLAYTEST.md">What to try, and what isn't a bug yet</a>.</p>
</aside>
</div>
</div>
</section>
<section class="alt" aria-labelledby="gallery-title">
<div class="wrap">
<h2 id="gallery-title">Screenshots</h2>
<p class="lead">Straight from the game. Click one to see it full size.</p>
<div class="gallery">
<figure><a href="shots/cove.jpg"><img src="shots/cove.jpg" alt="The Cove's bay and pier on a clear morning, a rowing boat tied up" width="1280" height="720" loading="lazy"></a>
<figcaption>The Cove, and a boat at the pier</figcaption></figure>
<figure><a href="shots/crabs.jpg"><img src="shots/crabs.jpg" alt="Red crabs on the sand at dusk" width="1280" height="720" loading="lazy"></a>
<figcaption>Crabs come up the beach at dusk</figcaption></figure>
<figure><a href="shots/winter.jpg"><img src="shots/winter.jpg" alt="A hut in a snowy clearing in a blizzard" width="1280" height="720" loading="lazy"></a>
<figcaption>Weather the winter</figcaption></figure>
<figure><a href="shots/hut.jpg"><img src="shots/hut.jpg" alt="A wooden hut with a campfire at dusk" width="1280" height="720" loading="lazy"></a>
<figcaption>A home of your own</figcaption></figure>
<figure><a href="shots/wolves.jpg"><img src="shots/wolves.jpg" alt="Wolves circling a campfire under a red moon" width="1280" height="720" loading="lazy"></a>
<figcaption>The wolf moon</figcaption></figure>
<figure><a href="shots/treasure.jpg"><img src="shots/treasure.jpg" alt="A marker stone and a mound of loose earth in the hills at dusk" width="1280" height="720" loading="lazy"></a>
<figcaption>X marks the spot</figcaption></figure>
</div>
</div>
</section>
<section id="download" aria-labelledby="dl-title">
<div class="wrap">
<h2 id="dl-title">Download version {{VERSION}}</h2> <h2 id="dl-title">Download version {{VERSION}}</h2>
<p class="lead">Free. Released {{DATE}}. The game tells you on its title screen when a newer version is out.</p>
<div class="downloads"> <div class="downloads">
<div class="dl" data-os="mac"> <div class="dl" data-os="mac">
<h3>macOS</h3> <h3>macOS <span class="yours">· your computer</span></h3>
<p>Apple silicon and Intel · {{MAC_SIZE}}</p> <p class="meta">Apple silicon and Intel · {{MAC_SIZE}}</p>
<a class="btn" href="{{MAC_URL}}">Download for Mac</a> <a class="btn" href="{{MAC_URL}}">Download for Mac</a>
<p class="note">First launch: right-click the app and choose <b>Open</b> <p class="note">Unzip it. The first time, right-click the app and choose <b>Open</b>
(it isn't notarised by Apple yet).</p> (it isn't notarised by Apple yet).</p>
</div> </div>
<div class="dl" data-os="win"> <div class="dl" data-os="win">
<h3>Windows</h3> <h3>Windows <span class="yours">· your computer</span></h3>
<p>64-bit · {{WIN_SIZE}}</p> <p class="meta">64-bit · {{WIN_SIZE}}</p>
<a class="btn" href="{{WIN_URL}}">Download for Windows</a> <a class="btn" href="{{WIN_URL}}">Download for Windows</a>
<p class="note">Unzip, then run <code>Emberwild.exe</code>. If SmartScreen <p class="note">Unzip, then run <code>Cinderwild.exe</code>. If SmartScreen warns,
warns, choose <b>More info</b> &gt; <b>Run anyway</b> (it isn't signed yet).</p> choose <b>More info</b> &gt; <b>Run anyway</b> (it isn't signed yet).</p>
</div> </div>
</div> </div>
<p class="note">Released {{DATE}}. {{NOTES}} The game tells you on its title <p class="note"><a href="{{RELEASES_URL}}">All releases, release notes and checksums</a>.</p>
screen when a newer version is out. </div>
<a href="{{RELEASES_URL}}">All releases and checksums</a>.</p>
</section> </section>
<section aria-labelledby="what-title"> <section id="friends" class="alt" aria-labelledby="host-title">
<h2 id="what-title">What it is</h2> <div class="wrap">
<ul class="features">
<li>Gather, craft and cook; keep warm, fed and dry.</li>
<li>Hunt deer, boar and rabbits; fish the lake.</li>
<li>Build a home from floors, walls and roofs that snap together.</li>
<li>Wolves move in, and every seventh night the moon turns red.</li>
<li>Weather that matters: storms, blizzards and hard winters.</li>
<li>A bear, a barrow, and the Old Stag at the standing stones.</li>
<li>Dig down through the sea cliffs to the Cove, and fish off its pier.</li>
<li>On a server, friends can be in different lands at once.</li>
<li>A map that fills in as you explore, and a compass.</li>
<li>Play alone, or host a server for friends on your own machine.</li>
<li>A guided tutorial with Old Tam the hermit.</li>
</ul>
</section>
<section aria-labelledby="host-title">
<h2 id="host-title">Playing with friends</h2> <h2 id="host-title">Playing with friends</h2>
<p>Choose <b>Host Game</b> on the title screen, and friends choose <b>Join Game</b> <p class="lead">No accounts and no company servers: one of you hosts, on your own computer.</p>
with your address (UDP port 24242). Everyone needs the same version. A spare <ol class="steps">
computer can run a dedicated server from the same download: <li><b>Host Game</b> on the title screen. Pick a name, a rules preset (co-op, rivals
<code>Emberwild --headless -- --server</code>.</p> or adversarial) and a password if you like.</li>
<li>Friends choose <b>Join Game</b> and type your address. Everyone needs the same
version.</li>
<li>On the same network, or over Tailscale, that's it. Over the internet, forward the
ports below on your router.</li>
</ol>
<table>
<caption class="note" style="caption-side: bottom; text-align: left;">The Cove runs as its own server on the next port, started when someone first goes there.</caption>
<thead><tr><th scope="col">UDP port</th><th scope="col">For</th></tr></thead>
<tbody>
<tr><td><code>24242</code></td><td>The Valley (the server you join)</td></tr>
<tr><td><code>24243</code></td><td>The Cove</td></tr>
</tbody>
</table>
<p class="note">A spare computer can run a dedicated server from the same download:
<code>Cinderwild --headless -- --server</code>.
<a href="guide.html#multiplayer">More in the guide</a>.</p>
</div>
</section> </section>
</main> </main>
<footer class="wrap">Emberwild {{VERSION}}. Made with Godot. <a href="guide.html">The guide</a>.</footer> <footer>
<div class="wrap">
<nav aria-label="More">
<a href="guide.html">The guide</a>
<a href="{{RELEASES_URL}}">Releases</a>
<a href="{{REPO_URL}}/issues">Report a bug</a>
<a href="{{REPO_URL}}">Source</a>
</nav>
<span>Cinderwild {{VERSION}}. Made with Godot.</span>
</div>
</footer>
<script> <script>
// Point out the download for the visitor's own computer (it still works without this). // The visitor's own computer: its download first and marked, and the
// hero's button goes straight to it. (Without this, the page still works.)
(function () { (function () {
var ua = navigator.userAgent; var ua = navigator.userAgent;
var os = /Mac/i.test(ua) && !/iPhone|iPad/i.test(ua) ? "mac" : /Win/i.test(ua) ? "win" : ""; var os = /iPhone|iPad|Android/i.test(ua) ? "" : /Mac/i.test(ua) ? "mac" : /Win/i.test(ua) ? "win" : "";
var card = os && document.querySelector('.dl[data-os="' + os + '"]'); if (!os) return;
if (card) { card.classList.add("mine"); card.parentNode.insertBefore(card, card.parentNode.firstChild); } var card = document.querySelector('.dl[data-os="' + os + '"]');
if (!card) return;
card.classList.add("mine");
card.parentNode.insertBefore(card, card.parentNode.firstChild);
var link = card.querySelector("a.btn");
var hero = document.getElementById("hero-dl");
hero.href = link.href;
hero.textContent = os === "mac" ? "Download for Mac" : "Download for Windows";
var other = document.getElementById("hero-other");
other.innerHTML = "Free · {{VERSION}} · " + card.querySelector(".meta").textContent.split("·").pop().trim() +
' · <a href="#download">' + (os === "mac" ? "Windows" : "Mac") + " and install notes</a>";
})(); })();
</script> </script>
</body> </body>

View File

@@ -21,6 +21,8 @@ def inline(s):
s = esc(s) s = esc(s)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s) s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", s) s = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", s)
# [text](https://...) links (after esc: the URL has no quotes or brackets).
s = re.sub(r"\[([^\]]+)\]\((https://[^)\s\"<>]+)\)", r'<a href="\2">\1</a>', s)
return s return s
@@ -219,8 +221,8 @@ PAGE = """<!doctype html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Emberwild guide: everything in the game</title> <title>Cinderwild guide: everything in the game</title>
<meta name="description" content="How to play Emberwild: controls, survival, food, building, weather, wildlife, hunting, fishing, iron, the Old Stag, the journey to the Cove and multiplayer, with every item, recipe and creature."> <meta name="description" content="How to play Cinderwild: controls, survival, food, building, weather, wildlife, hunting, fishing, iron, the Old Stag, the journey to the Cove and multiplayer, with every item, recipe and creature.">
<style> <style>
:root { --bg: #15110d; --panel: #221b14; --line: #3a2e22; --text: #f1e8da; --dim: #b8a892; :root { --bg: #15110d; --panel: #221b14; --line: #3a2e22; --text: #f1e8da; --dim: #b8a892;
--ember: #e8833a; --ember-hi: #f6a35c; --mark: #5a3a14; } --ember: #e8833a; --ember-hi: #f6a35c; --mark: #5a3a14; }
@@ -260,7 +262,7 @@ PAGE = """<!doctype html>
<a class="skip" href="#content">Skip to the guide</a> <a class="skip" href="#content">Skip to the guide</a>
<div class="layout"> <div class="layout">
<nav class="toc" aria-label="Guide contents"> <nav class="toc" aria-label="Guide contents">
<a class="home" href="index.html">EMBERWILD</a> <a class="home" href="index.html">CINDERWILD</a>
<label for="q" class="group" style="display:block;margin:0 0 6px">Search the guide</label> <label for="q" class="group" style="display:block;margin:0 0 6px">Search the guide</label>
<input id="q" type="search" placeholder="wolves, bed, iron..." autocomplete="off"> <input id="q" type="search" placeholder="wolves, bed, iron..." autocomplete="off">
<ol> <ol>
@@ -271,12 +273,12 @@ PAGE = """<!doctype html>
</ol> </ol>
</nav> </nav>
<main id="content"> <main id="content">
<h1>The Emberwild guide</h1> <h1>The Cinderwild guide</h1>
<p class="lead">Everything in Emberwild {VERSION}: how to play, what can happen, and every item, recipe and <p class="lead">Everything in Cinderwild {VERSION}: how to play, what can happen, and every item, recipe and
creature. <a href="index.html">Download the game</a>.</p> creature. <a href="index.html">Download the game</a>.</p>
<p id="none" class="none hidden" role="status">Nothing matches that.</p> <p id="none" class="none hidden" role="status">Nothing matches that.</p>
{SECTIONS} {SECTIONS}
<footer>Emberwild {VERSION}. The reference tables are made from the game's own data each time this page is built.</footer> <footer>Cinderwild {VERSION}. The reference tables are made from the game's own data each time this page is built.</footer>
</main> </main>
</div> </div>
<script> <script>

16
tools/site/make_shots.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# The download page's screenshots (tools/site/shots.gd), rendered in a window:
#
# tools/site/make_shots.sh # -> build/shots/*.jpg (make_site.sh copies them)
#
# Takes under a minute. Run it again when the game's look changes.
cd "$(dirname "$0")/../.." || exit 1
. tools/godot_env.sh
mkdir -p build/shots
rm -f build/shots/*.jpg
"$GODOT" --path . --resolution 1280x720 --script tools/site/shots.gd -- "$PWD/build/shots" > build/shots/render.log 2>&1
if grep -q "SCRIPT ERROR" build/shots/render.log || [ -z "$(ls build/shots/*.jpg 2>/dev/null)" ]; then
echo "rendering the screenshots failed: see build/shots/render.log"; exit 1
fi
grep "^shots:" build/shots/render.log

View File

@@ -7,22 +7,31 @@
# build/site/index.html the page, with this release's download links # build/site/index.html the page, with this release's download links
# build/site/poster.jpg the trailer's title frame # build/site/poster.jpg the trailer's title frame
# build/site/trailer.mp4 the trailer (tools/trailer/make_trailer.sh) # build/site/trailer.mp4 the trailer (tools/trailer/make_trailer.sh)
# build/site/shots/ the screenshots (tools/site/make_shots.sh)
# #
# Copy the folder to any static web host. Gitea itself won't serve HTML, so # Copy the folder to any static web host. Gitea itself won't serve HTML, so
# the page lives elsewhere and links to the files on the Gitea release. # the page lives elsewhere and links to the files on the Gitea release.
# Rebuild it after each release (the links name the version). # Rebuild it after each release (the links name the version).
cd "$(dirname "$0")/../.." || exit 1 cd "$(dirname "$0")/../.." || exit 1
REPO=${REPO:-https://git.psbailey.uk/Admin/Emberwild} REPO=${REPO:-https://git.psbailey.uk/Admin/Cinderwild}
SITE=${SITE:-https://cinderwild.psbailey.uk} # (where the page is served: for link previews)
SHOTS="meadow hut wolves winter cove treasure crabs"
[ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; } [ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; }
[ -f build/Emberwild-trailer.mp4 ] || { echo "no trailer: run tools/trailer/make_trailer.sh first"; exit 1; } [ -f build/Cinderwild-trailer.mp4 ] || { echo "no trailer: run tools/trailer/make_trailer.sh first"; exit 1; }
for s in $SHOTS; do
[ -f "build/shots/$s.jpg" ] || { echo "no build/shots/$s.jpg: run tools/site/make_shots.sh first"; exit 1; }
done
rm -rf build/site && mkdir -p build/site rm -rf build/site && mkdir -p build/site
cp build/Emberwild-trailer.mp4 build/site/trailer.mp4 cp build/Cinderwild-trailer.mp4 build/site/trailer.mp4
cp build/trailer/poster.jpg build/site/poster.jpg cp build/trailer/poster.jpg build/site/poster.jpg
python3 - "$REPO" <<'EOF' mkdir -p build/site/shots
for s in $SHOTS; do cp "build/shots/$s.jpg" build/site/shots/; done
python3 - "$REPO" "$SITE" <<'EOF'
import html, json, sys import html, json, sys
repo = sys.argv[1].rstrip("/") repo = sys.argv[1].rstrip("/")
site = sys.argv[2].rstrip("/")
v = json.load(open("build/version.json")) v = json.load(open("build/version.json"))
tag = "v" + v["version"] tag = "v" + v["version"]
def dl(plat): def dl(plat):
@@ -36,7 +45,7 @@ page = open("tools/site/index.html").read()
for k, val in { for k, val in {
"VERSION": v["version"], "DATE": v.get("date", ""), "NOTES": v.get("notes", ""), "VERSION": v["version"], "DATE": v.get("date", ""), "NOTES": v.get("notes", ""),
"MAC_URL": mac_url, "MAC_SIZE": mac_size, "WIN_URL": win_url, "WIN_SIZE": win_size, "MAC_URL": mac_url, "MAC_SIZE": mac_size, "WIN_URL": win_url, "WIN_SIZE": win_size,
"RELEASES_URL": repo + "/releases", "RELEASES_URL": repo + "/releases", "REPO_URL": repo, "SITE_URL": site,
}.items(): }.items():
page = page.replace("{{%s}}" % k, html.escape(str(val), quote=True)) page = page.replace("{{%s}}" % k, html.escape(str(val), quote=True))
assert "{{" not in page, "a placeholder was left unfilled" assert "{{" not in page, "a placeholder was left unfilled"
@@ -55,7 +64,8 @@ rm -f "$DATA"
# Cloudflare Pages refuses any file over 25 MiB: catch it here, not at deploy. # Cloudflare Pages refuses any file over 25 MiB: catch it here, not at deploy.
LIMIT=26214400 LIMIT=26214400
for f in build/site/*; do for f in build/site/* build/site/shots/*; do
[ -f "$f" ] || continue
s=$(stat -f %z "$f" 2>/dev/null || stat -c %s "$f") s=$(stat -f %z "$f" 2>/dev/null || stat -c %s "$f")
if [ "$s" -ge "$LIMIT" ]; then if [ "$s" -ge "$LIMIT" ]; then
echo "$f is $s bytes: over Cloudflare Pages' 25 MiB limit (re-encode the trailer smaller)"; exit 1 echo "$f is $s bytes: over Cloudflare Pages' 25 MiB limit (re-encode the trailer smaller)"; exit 1

126
tools/site/shots.gd Normal file
View File

@@ -0,0 +1,126 @@
extends "res://tools/trailer/trailer.gd"
## The download page's screenshots (tools/site/make_shots.sh): the trailer's
## shots, each caught as one still instead of filmed, plus a couple of the
## Cove's own (the treasure dig and its drowned crew, crabs on the sand).
## godot --path . --resolution 1280x720 --script tools/site/shots.gd -- <out dir>
## Writes <name>.jpg for each, 1280x720.
## How far into each trailer shot the still is taken (0..1).
const AT := {"meadow": 0.3, "hut": 0.8, "wolves": 0.15, "winter": 0.5, "cove": 0.85}
var _out := ""
var _name := ""
var made: Array[String] = []
func _run() -> void:
var args := OS.get_cmdline_user_args()
_out = args[0] if not args.is_empty() else ProjectSettings.globalize_path("res://build/shots")
DirAccess.make_dir_recursive_absolute(_out)
var H: Node = root.get_node("Hints")
H.path = "user://shots_hints.json"
H.load_prefs()
H.enabled = false
var S: Node = root.get_node("Saves")
S.root_dir = "user://shots_worlds"
_overlay()
await _enter_world("")
_calm()
await _stage()
for s in [["meadow", _shot_meadow], ["hut", _shot_hut], ["wolves", _shot_wolves], ["winter", _shot_blizzard]]:
_name = s[0]
await (s[1] as Callable).call()
await _enter_world("cove")
_calm()
_name = "cove"
await _shot_cove()
_name = "treasure"
await _shot_treasure()
_name = "crabs"
await _shot_crabs()
for w in S.list_worlds():
S.delete_world(w.id)
DirAccess.remove_absolute(S.root_dir)
DirAccess.remove_absolute(H.path)
print("shots: ", ", ".join(made))
quit(0)
## No wildlife wandering into frame, nothing coming for food left out.
func _calm() -> void:
world.creatures.enabled = false
for c in world.creatures.get_children():
c.queue_free()
if world.thieves != null:
world.thieves.enabled = false
if world.shore != null:
world.shore.enabled = false
## One still, part-way through the shot `f` would film; no captions, no fades.
func _shot(secs: float, f: Callable, _caption: String = "", _fade: float = 0.5) -> void:
var t := secs * float(AT.get(_name, 0.6))
for _i in range(4):
f.call(t, t / secs)
_bare()
await process_frame
world.props._update_grass(100000)
var wait := 1.2 if _name == "winter" else 0.6 # (let the snow fill the air)
var left := wait
while left > 0.0:
f.call(t, t / secs)
_bare()
await process_frame
left -= 1.0 / 60.0
await RenderingServer.frame_post_draw
var img := root.get_viewport().get_texture().get_image()
var path := _out.path_join(_name + ".jpg")
img.save_jpg(path, 0.84)
made.append(_name)
func _bare() -> void:
for l in [big, sub, cap]:
l.modulate.a = 0.0
black.modulate.a = 0.0
## The Cove's treasure at dusk: your mound by its marker stone, and the
## drowned crew risen round it.
func _shot_treasure() -> void:
var p: Node3D = world.player
p.inventory.add("treasure_map", 1)
await create_timer(1.0).timeout # (the mound shows once the map's in your bag)
var tr: Node3D = world.treasure
var m: Node3D = tr.mounds.get(world.my_id()) if tr != null else null
if m == null:
return
tr.force_crew = true
tr._raise_crew(world.my_id(), m.global_position)
var eye: Vector3 = _g(m.global_position + Vector3(3.6, 0, 2.8), 1.6)
for g in tr.crews[world.my_id()].crew:
g.set_physics_process(false)
var to: Vector3 = eye - g.global_position
g.rotation.y = atan2(-to.x, -to.z)
await _shot(1.0, func(_t: float, _k: float):
_clock(18.1)
cam.global_position = eye
cam.look_at(m.global_position + Vector3(0, 0.6, 0))
_park_player(m.global_position + Vector3(1.2, 0, 0)))
## Crabs on the sand at dusk, the pier behind.
func _shot_crabs() -> void:
var b := _g(Vector3(4.0, 0, 77.0))
var offs := [Vector3(0, 0, 0), Vector3(1.2, 0, 0.6), Vector3(-0.9, 0, 1.1), Vector3(2.2, 0, -0.5)]
for i in range(offs.size()):
var c: Node3D = world.shore.spawn(b + offs[i]) if world.shore != null else null
if c == null:
continue
c.set_physics_process(false)
c.yaw = 0.6 * i
c.rotation.y = c.yaw
if i == 0:
c.state = "charge"
c.speed = 1.2
c._animate(0.1)
var eye := _g(b + Vector3(-1.0, 0, 2.4), 1.7) # (from down the sand, looking back up the beach)
await _shot(1.0, func(_t: float, _k: float):
_clock(18.4)
cam.global_position = eye
cam.look_at(b + Vector3(0.5, 0.0, 0.2))
_park_player(eye))

1
tools/site/shots.gd.uid Normal file
View File

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

View File

@@ -2,7 +2,7 @@
# Records the test trailer (tools/trailer/trailer.gd) with Godot's Movie # Records the test trailer (tools/trailer/trailer.gd) with Godot's Movie
# Maker and encodes it, with a generated music pad under the game's sound: # Maker and encodes it, with a generated music pad under the game's sound:
# #
# tools/trailer/make_trailer.sh # -> build/Emberwild-trailer.mp4 (+ poster.jpg) # tools/trailer/make_trailer.sh # -> build/Cinderwild-trailer.mp4 (+ poster.jpg)
# #
# Needs ffmpeg. Takes about a minute: the shots are rendered frame by frame # Needs ffmpeg. Takes about a minute: the shots are rendered frame by frame
# at 30 fps whatever the machine's speed, so the result never stutters. # at 30 fps whatever the machine's speed, so the result never stutters.
@@ -13,7 +13,7 @@ command -v ffmpeg >/dev/null || { echo "needs ffmpeg (brew install ffmpeg)"; exi
mkdir -p build/trailer mkdir -p build/trailer
RAW=build/trailer/raw.avi RAW=build/trailer/raw.avi
OUT=build/Emberwild-trailer.mp4 OUT=build/Cinderwild-trailer.mp4
rm -f "$RAW" rm -f "$RAW"
"$GODOT" --path . --resolution 1280x720 --fixed-fps 30 --write-movie "$RAW" \ "$GODOT" --path . --resolution 1280x720 --fixed-fps 30 --write-movie "$RAW" \
--script tools/trailer/trailer.gd -- --no-creatures > build/trailer/render.log 2>&1 --script tools/trailer/trailer.gd -- --no-creatures > build/trailer/render.log 2>&1

View File

@@ -182,7 +182,7 @@ func _clock(hours: float) -> void:
# --- the shots ------------------------------------------------------------------------- # --- the shots -------------------------------------------------------------------------
func _shot_dawn() -> void: func _shot_dawn() -> void:
big.text = "EMBERWILD" big.text = "CINDERWILD"
sub.text = "Survive the island. Build a home." sub.text = "Survive the island. Build a home."
var c := Vector3(0, 0, -10) var c := Vector3(0, 0, -10)
await _shot(7.5, func(t: float, k: float): await _shot(7.5, func(t: float, k: float):
@@ -325,6 +325,8 @@ func _shot_cove() -> void:
# (From out over the bay, with the stream's mouth along the shore.) # (From out over the bay, with the stream's mouth along the shore.)
var from: Vector3 = pier.end + pier.dir * 18.0 + side * 14.0 + Vector3(0, 2.5, 0) var from: Vector3 = pier.end + pier.dir * 18.0 + side * 14.0 + Vector3(0, 2.5, 0)
var to: Vector3 = pier.end + pier.dir * 6.0 + side * 7.0 + Vector3(0, 1.6, 0) var to: Vector3 = pier.end + pier.dir * 6.0 + side * 7.0 + Vector3(0, 1.6, 0)
if world.moorings != null: # (0.6.0: a couple of rowing boats tied up at the end)
world.moorings.load_data({"moored": {"a": {"berth": 0, "name": ""}, "b": {"berth": 3, "name": ""}}})
await _shot(6.0, func(_t: float, k: float): await _shot(6.0, func(_t: float, k: float):
_clock(8.2 + k * 0.3) _clock(8.2 + k * 0.3)
var e := k * k * (3.0 - 2.0 * k) var e := k * k * (3.0 - 2.0 * k)
@@ -333,7 +335,7 @@ func _shot_cove() -> void:
_park_player(Vector3(pier.start.x, pier.start.y + 1.0, pier.start.z)), "Find what lies beyond.") _park_player(Vector3(pier.start.x, pier.start.y + 1.0, pier.start.z)), "Find what lies beyond.")
func _shot_end() -> void: func _shot_end() -> void:
big.text = "EMBERWILD" big.text = "CINDERWILD"
sub.text = "Self-hosted co-op survival · %s" % String(ProjectSettings.get_setting("application/config/version", "")) sub.text = "Self-hosted co-op survival · %s" % String(ProjectSettings.get_setting("application/config/version", ""))
# Rising over the Cove's bay, looking back at the land. # Rising over the Cove's bay, looking back at the land.
var pier: Node3D = world.pier var pier: Node3D = world.pier