21 Commits

Author SHA1 Message Date
Paul Bailey
62b3d6c47f 0.5.0: the journey on a server (a server per land; chat, the player list, admin and sleep across lands); protocol 17 2026-11-07 00:23:43 +00:00
Paul Bailey
f9b138b304 J3c: a land server stopping tells its players and is started afresh when they rejoin; the extra ports to forward on the Host page, the hosting message, the server log and HOSTING.md 2026-11-07 00:08:05 +00:00
Paul Bailey
86ebe07899 J3b: chat, the player list (with each player's land) and admin commands across lands; sleep needs everyone in every land, with a 'waiting for everyone else' message 2026-11-06 23:42:50 +00:00
Paul Bailey
85bbed249b J3a: the journey on a server. A server per land (Lands: the hub starts land servers on demand over a private link), the cliff tunnel online, crossings with one-time passes, shared characters that remember their land, one clock from the hub; test_net_lands 2026-11-06 23:14:11 +00:00
Paul Bailey
6544862885 Map: other players in your land shown on a co-op server, never with PvP on 2026-11-06 22:33:14 +00:00
Paul Bailey
109fff22d0 0.4.1: the Cove's stream (trout), the seam and sea cave in real rock, one clock for every land (going home no longer rewinds the day) 2026-11-06 22:02:16 +00:00
Paul Bailey
b938e045cb One clock for every land: going back to a land catches it up on the time away (like a night's sleep) instead of rewinding to when you left; J3 decided: a server per land with the Valley's as hub, sleep needs everyone in every land 2026-11-06 21:57:37 +00:00
Paul Bailey
ad47cd53da The Cove's stream: a stream block whose water only runs downhill (spring to the bay), its own water ribbon, trout, on the map; scenery and landmarks keep clear 2026-11-06 21:35:04 +00:00
Paul Bailey
0209c6149a The seam and the sea cave built from real boulders (rock_pile.gd: the scenery's rocks with matching colliders), the seam a cleft between two slabs with pick marks and loose stones 2026-11-06 21:26:15 +00:00
Paul Bailey
b86fb879d2 Site: trailer encoded smaller (CRF 23, 3.5 Mbit/s cap) to fit Cloudflare Pages' 25 MiB file limit; make_site.sh refuses an oversized file 2026-11-06 21:06:08 +00:00
Paul Bailey
2b7e52cd82 0.4.0: map (N) that fills in as you explore and a compass (closes #1); Old Tam's seam and Cove tips (voiced); the journey and sea fish in the guide; trailer with the tunnel and the Cove; protocol 16 2026-11-06 18:36:20 +00:00
Paul Bailey
980ee53579 J1: the cliff tunnel (seam, six propped stretches, dark, lights), travel Valley <-> Cove through the sea cave, a save file per worldlet; New World has no Where (--dev-worldlets to try others) 2026-11-06 17:51:58 +00:00
Paul Bailey
793b214786 W3: the Cove (bay, pond, pier for sea fishing), mackerel and cod (cod only in deep water), non-start worldlets offered for testing when run from source 2026-11-06 17:08:42 +00:00
Paul Bailey
9b88a10609 W2: several lakes per worldlet, a beach block shelving into the sea with a current that turns swimmers back; testbay test worldlet through the water, edge and boundary suites; the two ways to play (journey, one land) recorded 2026-11-06 16:48:20 +00:00
Paul Bailey
68e6d814d9 W1: choose a worldlet (New World and Host Game pages, kept once an island exists), worldlet shown in the Load list; Godot .uid files 2026-11-06 16:10:32 +00:00
Paul Bailey
db4a659236 W0: terrain from worldlet building blocks (Valley in data/worldlets, identical heights), WorldInfo API, worldlet in saves and servers; the cliff tunnel plan 2026-11-06 15:51:43 +00:00
Paul Bailey
f4df2bc99d Journey: one boat each, no passengers; tunnels, ropes and burrows shared 2026-11-06 14:30:51 +00:00
Paul Bailey
5584f74a8b Journey: multiplayer spread out (decided) 2026-11-06 14:17:56 +00:00
Paul Bailey
77517be778 Journey decisions: extendable ending, four crossings, biome hazards; server cost measured 2026-11-06 14:16:07 +00:00
Paul Bailey
40f5e25c41 Version 0.3.1 2026-11-06 14:05:24 +00:00
Paul Bailey
eaeb841c1d Lights to place indoors (tallow candle, torch stand); docs brought up to date; the journey plan; sticky teleports in net tests; protocol 15 2026-11-06 13:56:43 +00:00
179 changed files with 5687 additions and 185 deletions

View File

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

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,6 @@
{
"hint_cold": "91c5088e453fb0e6",
"hint_cove": "126f0b5e977fbef7",
"hint_died": "e865f8a63622b3c3",
"hint_diet": "3178b319c44e9fa0",
"hint_freezing": "48b1e505119a0fc9",
@@ -8,6 +9,7 @@
"hint_hungry": "ad76f22cb8e034a0",
"hint_night": "2a07d9c0bfae4bf9",
"hint_rotten": "98b69de03dbee089",
"hint_seam": "b353f46816d329b7",
"hint_sheltered": "ba6d6ff8bc4a1c80",
"hint_sick": "ec07963c46638ffc",
"hint_snow": "8f0ac7d093f73840",

View File

@@ -1,5 +1,5 @@
{
"_comment": "Fishing by water type. world.water_at() says which type a spot is; a type with no entry has no fish. Weights are relative. 'dawn_dusk' replaces the weight at dawn (05:00-08:30) and dusk (17:00-20:30). Bite waits are seconds, scaled by the modifiers.",
"_comment": "Fishing by water type. world.water_at() says which type a spot is; a type with no entry has no fish. Weights are relative. 'dawn_dusk' replaces the weight at dawn (05:00-08:30) and dusk (17:00-20:30). Bite waits are seconds, scaled by the modifiers. A fish with its own 'min_depth' only bites in water at least that deep (cod: cast well out from the pier).",
"modifiers": {
"dawn_dusk_wait": 0.6,
"rain_wait": 0.8,
@@ -24,6 +24,40 @@
"dawn_dusk": 40
}
]
},
"sea": {
"min_depth": 1.0,
"bite_wait": [
6.0,
16.0
],
"fish": [
{
"id": "mackerel",
"weight": 75,
"dawn_dusk": 65
},
{
"id": "cod",
"weight": 25,
"dawn_dusk": 35,
"min_depth": 2.2
}
]
},
"stream": {
"min_depth": 0.3,
"bite_wait": [
8.0,
18.0
],
"fish": [
{
"id": "trout",
"weight": 100,
"dawn_dusk": 100
}
]
}
}
}

View File

@@ -1,6 +1,6 @@
{
"guide": "Old Tam",
"_doc": "T4 (docs/TUTORIAL_DESIGN.md §5): one-off tips in normal worlds, each shown once per player (user://hints.json), in list order when several are due. 'when' is kind:value, with | for alternatives: status (a survival status, by prefix), weather (a state), has (an item in your bag), event (howl, died), or a plain check (night, stamina_low, wolf_moon, hard_winter). 'hold' is how long it must stay true first (seconds, default 1).",
"_doc": "T4 (docs/TUTORIAL_DESIGN.md §5): one-off tips in normal worlds, each shown once per player (user://hints.json), in list order when several are due. 'when' is kind:value, with | for alternatives: status (a survival status, by prefix), weather (a state), has (an item in your bag), event (howl, died), worldlet (where you are on the journey), or a plain check (night, stamina_low, wolf_moon, hard_winter, seam: holding the antler pickaxe with the cliff tunnel's seam still to dig). 'hold' is how long it must stay true first (seconds, default 1).",
"hints": [
{"id": "died", "when": "event:died", "title": "Back on your feet",
"text": "Dying's not the end out here. Whatever you carried is waiting where you fell, so go back for it before anything else. Sleep in a bed once and that's where you'll wake."},
@@ -33,6 +33,10 @@
{"id": "stamina", "when": "stamina_low", "title": "Out of breath",
"text": "You're spent. Stop swinging and running a moment and your stamina comes back. Rested by a fire, it comes back faster."},
{"id": "sheltered", "when": "status:Sheltered", "hold": 3, "title": "A proper shelter",
"text": "Roof and walls: the weather can't touch you here. Sit by a fire in here a little while and you'll be Rested."}
"text": "Roof and walls: the weather can't touch you here. Sit by a fire in here a little while and you'll be Rested."},
{"id": "seam", "when": "seam", "hold": 3, "title": "The cracked seam",
"text": "That antler pick bites rock iron only scratches. Out on the clifftop, near the edge, there's a seam of cracked rock, and a way down to the sea behind it if you dig. Take wood to prop it as you go, and lights: it's black as pitch in there."},
{"id": "cove", "when": "worldlet:cove", "hold": 3, "title": "The Cove",
"text": "You made it down. Sand, salt and sea fish. Fish off the end of the pier: mackerel bite close in, cod further out where it's deep. The sea cave takes you back up the tunnel, and home's waiting at the top."}
]
}

View File

@@ -543,6 +543,72 @@
},
"keeps": 4
},
{
"id": "mackerel",
"name": "Mackerel",
"category": "food",
"stack": 20,
"description": "A quick, striped sea fish, caught off the pier. Grill it at a campfire. (Fish)",
"mesh": "mackerel",
"food": {
"hunger": 5,
"health": -2,
"diet": {
"fish": 10
},
"risk": 0.2
},
"keeps": 2
},
{
"id": "cod",
"name": "Cod",
"category": "food",
"stack": 20,
"description": "A big sea fish from deeper water: cast well out. (Fish)",
"mesh": "cod",
"food": {
"hunger": 8,
"health": -2,
"diet": {
"fish": 16
},
"risk": 0.2
},
"keeps": 2
},
{
"id": "grilled_mackerel",
"name": "Grilled Mackerel",
"category": "food",
"stack": 20,
"description": "Oily and good. (Fish)",
"mesh": "grilled_mackerel",
"food": {
"hunger": 22,
"health": 8,
"diet": {
"fish": 36
}
},
"keeps": 4
},
{
"id": "grilled_cod",
"name": "Grilled Cod",
"category": "food",
"stack": 20,
"description": "Thick white flakes: a proper meal. (Fish)",
"mesh": "grilled_cod",
"food": {
"hunger": 32,
"health": 12,
"diet": {
"fish": 52
}
},
"keeps": 4
},
{
"id": "iron_nugget",
"name": "Iron Nugget",
@@ -964,7 +1030,7 @@
"name": "Bear Fat",
"category": "resource",
"stack": 20,
"description": "Thick white fat. Burns long on a fire, and makes pemmican.",
"description": "Thick white fat. Burns long on a fire, and makes pemmican and tallow candles.",
"mesh": "bear_fat",
"fuel": 180
},
@@ -1216,6 +1282,61 @@
"plants": 28
}
}
},
{
"id": "tallow_candle",
"name": "Tallow Candle",
"category": "placeable",
"stack": 20,
"description": "Bear fat and a fibre wick. Set it on a floor or a step and it lights a small room, steadily, for good.",
"mesh": "tallow_candle",
"place": {
"piece": "lamp",
"footprint": [
0.2,
0.2
]
},
"lamp": {
"range": 7.0,
"energy": 1.2,
"height": 0.2,
"flicker": 0.08,
"flame": 0.022,
"color": [
1.0,
0.82,
0.6
]
}
},
{
"id": "torch_stand",
"name": "Torch Stand",
"category": "placeable",
"stack": 5,
"description": "A torch on a wooden stand. Lights a whole room or a doorway, and never goes out.",
"mesh": "torch_stand",
"place": {
"piece": "lamp",
"footprint": [
0.5,
0.5
]
},
"lamp": {
"range": 11.0,
"energy": 1.6,
"height": 1.45,
"flicker": 0.22,
"flame": 0.06,
"color": [
1.0,
0.74,
0.5
],
"shadows": true
}
}
]
}

View File

@@ -249,6 +249,26 @@
"trout": 1
}
},
{
"id": "grilled_mackerel",
"output": "grilled_mackerel",
"count": 1,
"station": "campfire",
"category": "cooking",
"inputs": {
"mackerel": 1
}
},
{
"id": "grilled_cod",
"output": "grilled_cod",
"count": 1,
"station": "campfire",
"category": "cooking",
"inputs": {
"cod": 1
}
},
{
"id": "charcoal_kiln",
"output": "charcoal_kiln",
@@ -578,6 +598,28 @@
"salt": 1
}
},
{
"id": "salted_mackerel",
"output": "salted_fish",
"count": 2,
"station": "",
"category": "cooking",
"inputs": {
"mackerel": 2,
"salt": 1
}
},
{
"id": "salted_cod",
"output": "salted_fish",
"count": 2,
"station": "",
"category": "cooking",
"inputs": {
"cod": 1,
"salt": 1
}
},
{
"id": "antler_pickaxe",
"output": "antler_pickaxe",
@@ -658,6 +700,29 @@
"berries": 3,
"bear_fat": 1
}
},
{
"id": "tallow_candle",
"output": "tallow_candle",
"count": 4,
"station": "",
"category": "furniture",
"inputs": {
"bear_fat": 1,
"fiber": 1
}
},
{
"id": "torch_stand",
"output": "torch_stand",
"count": 1,
"station": "",
"category": "furniture",
"inputs": {
"wood": 2,
"stick": 2,
"fiber": 2
}
}
]
}

23
data/worldlets/cove.json Normal file
View File

@@ -0,0 +1,23 @@
{
"_doc": "The Cove (docs/WORLDLETS_DESIGN.md §5, W3): the second worldlet, reached from the Valley down the cliff tunnel (J1). Low hills run down to a wide sandy bay with a pier (scripts/world/pier.gd) for sea fishing; a small pond inland; a stream from a spring under the mountains down to the bay (a 'stream' block: its points, spring first, in world metres); mountains behind. Not a start: you get here on the journey.",
"id": "cove",
"name": "Cove",
"description": "Low hills running down to a sheltered sandy bay, with a pier out over the water for sea fishing.",
"start": false,
"size": 320,
"resolution": 256,
"terrain": [
{"type": "hills", "height_scale": 5.5, "detail": 1.2},
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 52.0, "rim_warp": 14.0, "wall_frac": 0.45},
{"type": "beach", "angle_deg": 90.0, "half_width_deg": 55.0, "fade_deg": 22.0, "shore_offset": -16.0, "slope": 0.08, "current_out": 22.0, "wall_out": 36.0},
{"type": "lake", "center": [-30.0, -40.0], "radius": 11.0, "water_level": -2.6},
{"type": "stream", "points": [[46.0, -80.0], [34.0, -48.0], [40.0, -14.0], [30.0, 22.0], [36.0, 52.0], [33.0, 78.0], [33.0, 104.0]], "width": 2.4, "depth": 0.6},
{"type": "ocean_edge"}
],
"climate": {"snowline": 42.0},
"places": [{"type": "pier", "at": "beach"}],
"next": [
{"to": "valley", "by": "tunnel", "where": "sea_cave"},
{"to": "highlands", "by": "sail", "where": "pier"}
]
}

View File

@@ -0,0 +1,18 @@
{
"_doc": "A worldlet (docs/WORLDLETS_DESIGN.md): its name and description (the New World page), whether a new world can start here (\"start\"; the others are reached on the journey), its size, and its terrain as building blocks applied in order (scripts/world/terrain.gd: hills, rim, sea_cliff, lake, ocean_edge). The numbers here are the Valley exactly as it was hand-coded: tests/test_worldlets.gd checks it still makes the same height grid.",
"id": "valley",
"name": "Valley",
"description": "A sheltered valley ringed by mountains, with a lake in the middle and sea cliffs on one side.",
"start": true,
"size": 320,
"resolution": 256,
"terrain": [
{"type": "hills", "height_scale": 9.0, "detail": 1.4},
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 58.0, "rim_warp": 16.0, "wall_frac": 0.45},
{"type": "sea_cliff", "angle_deg": -75.0, "half_width_deg": 38.0, "fade_deg": 22.0, "sea_level": -24.0, "cliff_top": 0.5, "cliff_offset": 4.0, "drop_width": 6.0},
{"type": "lake", "center": [14.0, -46.0], "radius": 24.0, "water_level": -2.6},
{"type": "ocean_edge"}
],
"climate": {"snowline": 40.0},
"next": [{"to": "cove", "by": "tunnel", "where": "sea_cliff"}]
}

View File

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

View File

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

View File

@@ -85,8 +85,24 @@ phases M2.0-M2.7).
## M4 — Content & polish · ongoing
- More biomes, creatures, items, building pieces
- PvP / raiding rules, building damage
- Better UI, settings, controls
- Audio, music, weather
- Better UI, settings, controls. *Done so far:* volume sliders, tips and
update settings in the pause menu; `Q` drops items; `K`/`M` put any bag row
on the 1-8 keys (0.3.0); the map on `N`, filling in as you explore, and a
compass along the top (0.4.0, Gitea issue #1).
- More places: the worldlets and the journey (`docs/WORLDLETS_DESIGN.md`).
*Done (0.4.0):* W0-W3 and J1, the Cove reached down the cliff tunnel
(single-player). *(0.5.0):* J3, the journey on a server: a server per
land, players in different lands at once, chat, the player list, admin
and sleep across lands. Next J2, then J4 (the Highlands by sail).
- Audio, music, weather. *Done (0.2.0-0.2.1):* mood music (calm, night,
storm, danger, boss) that crossfades with what's happening, ambience (CC0
birdsong, crickets, owls, fire), sound effects for every action (Kenney
CC0 plus synthesised), creature voices, Old Tam's spoken lines (Kokoro).
Sources and licences: `assets/audio/CREDITS.md`; tools in `tools/audio/`
and `tools/voice/`.
- Help: the player guide on the download site (`tools/site/`), written
sections plus item, recipe, creature, fish and server-setting tables made
from the game's own data each build. The trailer (`tools/trailer/`).
- Packaged builds for Win/Mac/Linux (+ Mac signing/notarisation).
*First cut done:* `tools/export.sh` makes a universal macOS .app (ad-hoc
signed, not notarised) and a Windows x86_64 .exe + .pck (unsigned), zipped

View File

@@ -1,7 +1,168 @@
# Worldlets design
Status: **proposal**. Nothing here is built yet, apart from the first piece
of groundwork (`world.water_at()` and `data/fish.json`, see §6).
Status: **W0-W3, J1 and J3a built**: the Cove (with its stream), the cliff
tunnel, and the journey on a server (a server per land; chat and the
player list across lands are J3b). The rest is planned.
*As built in J3a (a server per land):*
- `scripts/net/lands.gd` (autoload `Lands`): the server players join is the
hub. `Lands.ensure(land)` starts a land server (`--server --land cove
--port <hub+1..> --hub-port <hub>`, same config and world folder, output
to `land-<land>.log` beside server.json) and waits for it to say it's
listening. Hub and land servers talk over TCP on 127.0.0.1, port hub +
100, one JSON object per line: hello/ready, the player count, the clock
(the hub's, every 10 s: one clock everywhere), how far the tunnel's dug,
one-time passes, "stop", "host_left". A land server stops after 2 minutes
empty, when the hub stops, or if it loses the hub.
- A land server's saves: `Saves.land` makes it load and save its own
worldlet's file (`worldlets/cove.json`) in the hub's world folder.
Characters (`players/<id>.json`) are shared, and say which land they're
in (`land`) and, mid-crossing, where they'll come out (`arrive`).
- Crossing (`PlayerNet.cross`): the server saves the character as heading
there, makes sure the other server's up, and sends the game
`_s2c_travel {land, port, pass}`; the game drops the connection quietly
and joins that port with the pass (`Net.join(address, "", pass)`), checked
after the identity proof: a land server lets nobody in without one. The
world reloads on `Net.arrived`. Joining the hub when your character's in
another land sends you on there; a crossing that fails goes back to the
title. A hosted game: the host crossing doesn't stop the hub; the host
leaving the game from another land stops everything.
- The tunnel online: built on the server and in every game. The server
decides blows on its faces (`"hit"` finds them by uid), props
(`"tunnel_prop"`), going in and out (`"tunnel_go"`: a server teleport;
the move check allows the tunnel only from inside it) and the crossings
(`"cross"`), and tells everyone how it stands (`_s2c_tunnel`; it's in the
shared state a joining player gets, and saved with the Valley).
- `tests/test_net_lands.gd`: Alice digs and props online, lights it,
crosses to the Cove (bag and day kept) while Bob stays in the Valley,
comes back up, then leaves from the Cove and rejoining sends her there.
*As built in J3b (everyone, everywhere):*
- Chat and server messages go to every land (`Chat._all` relays through
the hub, `Lands.relay_line`).
- The hub keeps the **roster** (everyone on every land, with their land);
every land and every player's game gets it (`Net.roster`,
`Net.everyone()`): the pause menu's player list and `/players` show
where each player is.
- Commands typed in another land run **on the hub** (it alone keeps
server.json) and answer back there; after one that changes server.json
(`/set`, `/ban`, `/admin`, `/friend`...) every land reloads it.
`/kick` and `/ban` reach the land the player's in; `/weather`, `/save`
and `/backup` reach every land.
- **Sleep needs everyone in every land:** land servers report their beds,
the hub decides, and every land passes the night together. Getting into
bed while others are up: "You're in bed: waiting for everyone else to go
to bed (1 of 2 asleep)", and every land hears "Bob is in bed (1 of 2
asleep)" in chat.
- `tests/test_net_lands.gd` covers it all, Alice in the Cove and Bob in the
Valley.
*As built in J3c:*
- A land server that stops (crashed, or killed) tells its players "Lost the
connection to the Cove. Join the server again to carry on there."; the
hub notices it's gone (even mid-join) and joining again starts it afresh
and sends them back (their characters were saved by its autosave).
- The extra ports: `Lands.extra_ports()` / `ports_text()` ("UDP 24242 and
24243 (the Cove)"), on the Host page, in the "You're hosting" message, in
a hub's start-up log, and in docs/HOSTING.md.
- Tested in `test_net_lands.gd` (the Cove's server killed, Alice rejoins)
and `test_worldlet_pick.gd` (the Host page).
*As built in J1:*
- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip,
7 m in from the edge, its angle from the seed) and the tunnel space, out
of sight 260 m under the island: a CSG rock block with the landings and
six sloping stretches cut out (6 m on, 4 m down each, switching back on
two lanes: 24 m down), then a sea cave. Faces (`tunnel_face.gd`) answer
like boulders, so the pick works on them: tier 3 only ("Too hard: you
need a better pick"), 10 blows for the seam and 15 per stretch. Each
stretch must be propped (2 wood, [E] at its mouth) before the next face
takes a blow ("It's loose..."): 10 wood all the way down.
- Underground: no sun, moon, sky reflections or sky-tinted fog
(`day_night.underground`), no weather, no lake water, sheltered. Candles
and torch stands place anywhere on its floor and save as ordinary pieces.
- Walking out of the sea cave's mouth travels (`world.travel()`): the
worldlet you leave is saved, your character and the clock go with you,
and the other loads, arriving at its sea cave (`sea_cave.gd`, on the
Cove's beach where the headland starts). [E] at the cave goes back up the
same tunnel to its bottom, once it's dug through.
- Saves: `world.json` is the home worldlet's state, as before;
others are `worldlets/<id>.json`; meta.json's `worldlet` is where you
are, `home` where you began, `arrive` where the next load puts you.
- `tests/test_tunnel.gd`: dig through, walk every stretch down and back
up, light it, reload, out to the Cove, back up, out at the top, and the
chest at home is still there.
- Not yet: Old Tam's tip about the seam, the Guide page, and travel on a
server (J3: the tunnel isn't built online). The journey between
worldlets is §10, and the two ways to play (the journey, or one land) are in
its decisions.
*As built in W3:*
- `data/worldlets/cove.json`: low hills, mountains behind, a wide sandy
bay (a `beach` block) and a small pond. Not a start; its `next` is back
to the Valley (tunnel, J1) and on to the Highlands (sail).
- The pier (`scripts/world/pier.gd`), a world "place" (`"places":
[{"type": "pier"}]`): planks on posts from where the sand is at deck
height to 24 m past the waterline. Worked out from the terrain, so it's
the same for everyone and never saved or dismantled; scenery keeps clear
of it. ~1.9 m of sea at its end, ~2.7 m a cast beyond.
- Sea fish in `data/fish.json`: mackerel (common) and cod, which only bites
in water 2.2 m or deeper (a per-fish `min_depth`). Grilled at a campfire,
salted, or smoked, like the lake's.
- New World has no "Where": the journey always begins in the Valley (the
Host page keeps it, for One land mode). Started with
`godot --path . -- --dev-worldlets`, New World and Host offer every
worldlet, the others marked "(testing)", to try one directly.
- No sea stacks without a sea cliff.
- Tests: `test_cove` (walk onto the pier, catch a mackerel off its end, cod
only in deep water), and the Cove in `test_terrain_edges` and
`test_boundary`.
- The stream (after 0.4.0): a `stream` block, a line of points from a
spring under the mountains to the bay. Its water surface is the running
minimum of the ground along it (so it only ever runs downhill, down to
sea level), the bed is cut 0.6 m under that and the banks rise a little
above it; its water is its own ribbon mesh (`terrain._stream_mesh()`),
murkier than the lake. `water_at()` answers "stream" (trout bite there,
`data/fish.json`), the map shows it, and scenery, the den, the barrow and
the stones keep clear of it (`terrain.near_stream()`).
*As built in W2:*
- `lake` blocks can repeat: every lake and pond is its own body
(`terrain.lakes`, `world.water_bodies()`), all at one surface
(`water_level`, set by the first). The first is still "the lake" to the
older code (ice, its fish, the tutorial).
- A `beach` block (angle, width, `shore_offset`, `slope`, `current_out`,
`wall_out`): the land eases to a waterline and shelves gently into the
sea, which is then the same surface as the ponds (one ocean sheet, no
inland disc). The invisible wall stands well out in the water, and before
it a current (`terrain.current_at()`, `world._current()`) carries
swimmers back faster than they can swim, with a toast.
- `water_at()` tells the sea off a beach from a pond.
- Test-only worldlets live in `tests/worldlets/` (never listed or offered).
`testbay.json` (two ponds and a beach) runs through
`test_water_bodies`, `test_terrain_edges` (8 seeds) and `test_boundary`
(swimming out across the beach).
*As built in W1:* a "Where" picker on the New World and Host Game pages
(worldlets with `"start": true`; only the Valley, so it's greyed out), the
worldlet in the Load list, and a hosted island keeps its worldlet like its
seed.
*As built in W0:*
- `data/worldlets/valley.json`: the Valley as data, its terrain as five
building blocks in order (`hills`, `rim`, `sea_cliff`, `lake`,
`ocean_edge`) with every number that used to be hard-coded.
- `terrain.gd` runs a worldlet's blocks (`use_worldlet()`); each block is
the old code's arithmetic, unchanged. `tests/test_worldlets.gd` checks
four seeds give exactly the same height grid as before (SHA-256 of the
grid, taken from the old code).
- The worldlet is chosen like the seed: a server's (it sends its real one),
else the save's `meta.json` "worldlet", else the Valley (worlds from
before W0). New worlds and servers record it.
- The WorldInfo API on the world: `worldlet_id()`, `water_bodies()`,
`climate()`, `crossings()` (the planned ways on), beside `water_at()`.
Systems still read the terrain's numbers directly; they move over to
these as each new worldlet needs them to differ (W1-W3).
## 1. What a worldlet is
A **worldlet** is a small, complete world type: a ~320 m playable area with
@@ -164,3 +325,227 @@ its edge or animals that spawn in the sea.
wolves sooner, or keep the same grace period everywhere?
4. **Which second worldlet first:** Cove (sea fishing, the beach you
mentioned) is my suggestion.
## 10. The journey: tunnelling out (proposal, Nov 2026)
A goal for the game, from Paul: once you have a good enough pick, you tunnel
through the mountain rim into the next, slightly harder worldlet, and now and
then go back to earlier ones for what only they have. This is option B
(linked worldlets) from §2, reached by digging rather than a path.
### The shape of it
- **A chain of worldlets**, each a little harder: Valley (today's island) →
Cove (sea, salt, mackerel) → Highlands (cold, deep snow, bigger packs) →
Marsh (bog iron, sickness, mists) → the Ember, the volcanic heart the game
is named for: the end of the journey.
- **The goal**, something to tell players on day 1: Old Tam talks of the
land beyond the mountains, where the fire in the earth still burns. Reach
the Ember and light the old beacon there. (Whether there's an ending or
it's open-ended is a question below.)
- **Gates:** each tunnel needs the best tool from the worldlet you're in.
The antler pickaxe (from the Old Stag) opens the Valley's tunnel; the
Highlands need a pick made from something found in the Cove, and so on.
Each worldlet has its own boss or trial, like the Old Stag.
### A different crossing each time (Paul, Nov 2026)
Players have to work out how to get from each worldlet to the next, and
it's a different way every time: tunnelling, climbing, burrowing, sailing.
Each crossing is that worldlet's puzzle, and what it needs comes from what
you've learned and gathered there:
| From → to | Crossing | Needs (a first idea) |
|---|---|---|
| Valley → Cove | **Tunnel** through the rim at a seam | the antler pickaxe, timber props, light |
| Cove → Highlands | **Sail** out of the bay and round the headland | a boat (planks, nails, a hide sail, rope), calm weather, food that keeps |
| Highlands → Marsh | **Climb** a frozen cliff | climbing picks and rope, warmth to last the climb (bear cloak, pemmican) |
| Marsh → Ember | **Burrow** under the old lava crust | a digging tool from bog iron, and a way through the heat and gas |
- **The hint, not the answer:** each worldlet shows its way out without
explaining it: a seam in the rock with old pick marks, a wrecked boat on
the beach, rope frayed on a cliff ledge. Old Tam (and his tips) nudge,
and the guide can keep it vague.
- **Coming back** uses the same crossing in reverse (the tunnel stays dug,
the boat stays moored, the rope stays fixed), so supply runs get easier
once you've made the way.
- **New systems this needs, one per crossing:** digging into a rim seam
(tunnel and burrow share most of it), a boat you build and steer on the
water, and climbing a marked face. Each is a phase of its own (J1 tunnel;
later the sail, the climb, the burrow).
### Tunnelling (the first crossing)
- **Where:** each worldlet's rim has one or two **seams**: cracked, darker
rock faces with a few loose stones at the foot, findable by exploring
(and Old Tam's tips). The rim stays a wall everywhere else.
- **How:** hit a seam with a good enough pick, many times. Each stretch dug
needs **timber props** (wood) or it falls back in, and it's dark in there:
candles, torch stands or a lantern. The tunnel is a short, built-for-it
cave (a few bends, maybe a side chamber with ore or a hazard), so it
stays hand-made rather than a hole through the terrain mesh, which the
heightmap can't do.
- **Through:** the far end opens onto the next worldlet. Walking out loads
it (a short fade, "The Cove"), with your bag, cloak and stats as they are.
### The first tunnel: down the sea cliff to the Cove
The Valley's sea side ends at a sheer cliff, its top about level with the
valley and the sea some 25 m below. The Cove is down there, so the first
tunnel goes **down**, inside the cliff, from the clifftop to the shore.
- **The seam:** a cracked outcrop on the clifftop strip near the edge (the
sea_cliff block marks where), with old pick marks and loose stones at its
foot. It can't be dug until you have the antler pickaxe (tier 3); a lesser
pick just chips it ("Too hard: you need a better pick").
- **Digging down:** the tunnel is about six stretches, each a few metres on
and a few down: a sloping passage that turns back on itself like a
stair, so the 25 m drop fits inside the cliff. A stretch takes many pick
blows (stamina, and wear on the pick) to open.
- **Props:** each new stretch needs a **timber prop** (2 wood) set at its
mouth before you can dig the next. Leave one out and the face won't take
another blow ("It's loose: prop it first"); nothing caves in on you, so
it's a cost, not a trap. That's 10 wood all the way down.
- **Light:** the tunnel has no daylight at all. Every stretch is pitch dark
until you bring light: a torch in your hand works, but your hands are full
with the pick, so the way to do it is to set **candles and torch stands**
as you go (tallow candles from bear fat, torch stands from wood). Placed
lights stay, so the way is lit for the trip back and for friends.
- **The bottom:** the last stretch breaks out into a sea cave, its mouth
opening on the Cove's shingle beach. Walking out of the mouth is the
crossing: a fade, then the Cove, arriving at the cave's Cove end. The way
back starts there and climbs the same tunnel.
- **What's saved:** the tunnel belongs to the Valley's save: how far it's
dug, the props, the lights. On a server it's shared: anyone can dig on
from where someone stopped, and the lights are everyone's.
- **How it's built:** the tunnel is its own small space (a "between" scene
next to the worldlet), made from a kit of rock pieces (straight, turn,
slope down, the cave mouth) laid out from the seed, with box-shaped
colliders. The heightmap can't have holes or overhangs, so the passage is
never cut into the terrain itself: you walk into the seam and you're in
the tunnel scene.
### Why go back
- **Each worldlet has things the others lack.** Clay and salt only in the
Valley and Cove; little wood in the Highlands; no bears outside the
Valley (so bear fat for candles and pemmican is a supply run); a fish or
herb only one place has. Food that keeps (pemmican, salted and smoked
meat, clay jars) becomes what you carry between them.
- **Your bases stay** where you built them: each worldlet keeps its own
save file, so your Valley hut, chests and jars are waiting.
- **Time:** there's one clock for every land (see the decisions below). A
worldlet you're not in doesn't run, but catches up on the time when you
come back, as after a night's sleep; nothing roams it meanwhile, so a base
you left isn't raided.
### Building it (phases)
| Phase | Delivers | Done when |
|---|---|---|
| **W0-W2** | the worldlet groundwork above (terrain pipeline, worldlet in the save, several water bodies) | as §8 |
| **J1** | seams in the rim, digging with props and light, a tunnel scene, travel between two worldlets in single-player, per-worldlet save files | dig through from the Valley, walk to the Cove, walk back to your hut |
| **W3** | the Cove (§5) as the second worldlet | its suites pass, and its tunnel back works |
| **J2** | gates and supply needs: tool tiers per tunnel, resources only some worldlets have | a test bot can't open the second tunnel without the Cove's pick |
| **J3** | multiplayer travel (see below) | two players in different worldlets on one server, or the rule we choose |
| **J4** | Highlands, Marsh, the Ember and its ending | the journey can be finished |
### Multiplayer: the hard part
A server today runs one island. Options:
- **Everyone together:** travel needs every player at the tunnel (like the
sleep rule). Simplest; one worldlet runs at a time.
- **Spread out:** the server runs each worldlet that has someone in it.
The most freedom, and the most work and CPU.
- **Party leader:** whoever goes through brings the others with a vote.
### Decisions (Paul, Nov 2026)
- **Extendable, not finished:** the Ember has a last boss, and after it you
keep playing. The chain is built so more worldlets (and crossings) can be
added after the Ember later: each worldlet names the next, nothing assumes
the Ember is the end.
- **The crossings:** tunnel, sail, climb, burrow, in that order.
- **Harder by biome, not just by numbers:** each worldlet has its own,
stronger animals and its own hazards. The Marsh, for one: mosquitoes whose
bites can make you sick, and bog, mist and the like. Others in the same
spirit per biome (to design with each worldlet).
- **Multiplayer: spread out.** Each player goes where they like; the server
runs every worldlet someone is in. Built in J3, after single-player travel
works.
- **A server per land (Paul, Nov 2026)**, rather than one process holding
several worldlets (which would mean making every network module, the
saves and the actions handle several worlds at once):
- the Valley's server is the **hub**: the address everyone joins. It
starts a server for another land (the next UDP port: 24243 the Cove,
and so on) when someone first goes there, and stops it a couple of
minutes after its last player leaves;
- each land server is today's server, with its own worldlet, save file,
weather and creatures; it keeps a private link to the hub on the host
machine;
- characters live in one shared folder. A crossing saves your character
first, marks you as on your way, and hands your game the land's port
and a one-time pass; your game reconnects there ("On to the Cove...").
Nothing can be lost or copied on the way: crash mid-way and you come
back where you were going. Joining the hub when your character's in
another land sends you straight on there;
- chat, the player list (with where each player is) and admin commands
go through the hub to every land;
- the tunnel belongs to the Valley's server; the Cove's asks the hub
whether it's dug through;
- from the internet, friends need those extra ports forwarded too (on a
home network, nothing to do). The Host page will list them;
- the Old Stag and other bosses scale with the players in their land.
- Phases: **J3a** the tunnel online, land servers on demand, the
crossing and the shared characters; **J3b** chat, the player list and
admin across lands; **J3c** the awkward cases (a land server crashing,
rejoining mid-crossing, the Host page's ports).
- **One clock for every land (Paul, Nov 2026).** The day and hour are the
same everywhere, so the lands never drift apart:
- sleeping until morning needs **everyone, in every land**, in bed (on a
server the hub counts them all, and every land skips to morning
together);
- a land nobody is in doesn't run, but when someone arrives it catches up
on the time since anyone was last there, as after a night's sleep:
kilns and smelters work, fires burn down, food goes off, trees and
berries grow back. (Built for single-player already: `world.catch_up()`.)
No creatures roam it meanwhile, so a base you left isn't raided.
- **Boats: one each, no passengers.** A boat belongs to whoever built it
(like a bed): only the owner, and their friends under the server's locks
rule, can sail it, and nobody else can dismantle it. It stays moored where
it was last left (the Cove end or the Highlands end), so nobody is
stranded by someone else taking the only boat. Lose or wreck it and you
build another, which is harder where wood is scarce (the Highlands), so
plan the trip back. Each player has to earn their own crossing by boat.
- **The other crossings are shared:** a dug tunnel, a fixed climbing rope
and a dug burrow are changes to the world, there for everyone once one
player has made them (like a door or a bridge).
- **Two ways to play (Paul, Nov 2026).**
- **The journey** (the standard game), single-player and multiplayer
alike: every new world starts in the Valley and the worldlets come in
their fixed order (Valley, Cove, Highlands, Marsh, Ember). Each
worldlet's shape is set by its definition; the seed only changes the
detail (hills, trees, where the den is), so the journey is the same
adventure for everyone.
- **One land** (a hosting choice): no journey, no crossings, the server
is locked to one worldlet, and the host picks which, from all of them.
Co-op or PvP as now (the rules preset). In code: a `mode` in
`server.json` ("journey" / "one_land"); in one-land mode the Host
page's "Where" lists every worldlet rather than only the starts, and
the crossings (`world.crossings()`) answer none, so a tunnel seam
can't be dug. Kept, like the seed and the worldlet, once the island
exists. Worth building once the Cove exists (until then there's only
the Valley to pick).
### Server cost, measured (0.3.1)
One worldlet, a dedicated server on the M5 Pro, never pausing: about
**12% of one CPU core and 270 MB of memory**, with nobody on. Players add
creatures, weather and pickups around them. So with *spread out*, the worst
case of 8 players in all 5 worldlets is about five of today's servers in
one process: roughly 1.4 GB and a little over half a core before players,
and worldlets with nobody in them aren't running and cost nothing (each starts its own server when someone goes there).
Comfortable on the home Mac; a small cloud server (2 GB) would be tight.
### (Answered) questions
1. **The ending:** a real ending at the Ember (light the beacon, a last
boss, the credits) or open-ended (the journey is the goal, keep living)?
2. **The crossings:** do the four above fit (tunnel, sail, climb, burrow),
and in that order? Should a worldlet have more than one way out (a hard
one and a long one)? And for the tunnel: only at marked seams, or
anywhere in the rim (more freedom, harder to make good tunnels)?
3. **Multiplayer:** everyone together, spread out, or a party vote?
4. **How much harder** each worldlet: stronger animals and weather only, or
new rules too (thirst in the Ember, sickness in the Marsh)?
5. **Order:** Cove second (sea fishing, gentle) as planned, or straight
into the Highlands for a harder step?

View File

@@ -6,7 +6,7 @@ runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="data/*.json"
include_filter="data/*.json, data/worldlets/*.json"
exclude_filter="tests/*, docs/*, tools/*, server/*"
export_path="build/macos/Emberwild.zip"
patches=PackedStringArray()
@@ -29,8 +29,8 @@ application/icon_interpolation=4
application/bundle_identifier="com.pbailey.emberwild"
application/signature=""
application/app_category="Games"
application/short_version="0.3.0"
application/version="0.3.0"
application/short_version="0.5.0"
application/version="0.5.0"
application/copyright=""
display/high_res=true
codesign/codesign=1
@@ -64,7 +64,7 @@ runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="data/*.json"
include_filter="data/*.json, data/worldlets/*.json"
exclude_filter="tests/*, docs/*, tools/*, server/*"
export_path="build/windows/Emberwild/Emberwild.exe"
patches=PackedStringArray()
@@ -89,8 +89,8 @@ application/modify_resources=false
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version="0.3.0"
application/product_version="0.3.0"
application/file_version="0.5.0"
application/product_version="0.5.0"
application/company_name=""
application/product_name="Emberwild"
application/file_description="Emberwild"

View File

@@ -19,7 +19,7 @@ config/description="Low-poly survival game — Valheim look, Rust depth, self-ho
run/main_scene="res://scenes/title.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
config/version="0.3.0"
config/version="0.5.0"
[autoload]
@@ -31,6 +31,7 @@ Hints="*res://scripts/core/hints.gd"
Updates="*res://scripts/core/updates.gd"
Saves="*res://scripts/core/saves.gd"
Net="*res://scripts/net/net.gd"
Lands="*res://scripts/net/lands.gd"
WorldNet="*res://scripts/net/world_net.gd"
PlayerNet="*res://scripts/net/player_net.gd"
CreatureNet="*res://scripts/net/creature_net.gd"

View File

@@ -43,5 +43,5 @@ offset_left = 16.0
offset_top = 12.0
offset_right = 600.0
offset_bottom = 60.0
text = "E interact · LMB use/place · RMB eat · R rotate · X dismantle · Q drop · K/M bag row · Tab inventory & crafting · Esc menu & controls"
text = "E interact · LMB use/place · RMB eat · R rotate · X dismantle · Q drop · K/M bag row · N map · Tab inventory & crafting · Esc menu & controls"
label_settings = SubResource("Label_1")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -23,6 +23,8 @@ const RECIPES := {
{"inputs": {"raw_meat": 2, "wood": 1}, "output": "smoked_meat", "count": 2, "seconds": 60.0},
{"inputs": {"perch": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
{"inputs": {"trout": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
{"inputs": {"mackerel": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
{"inputs": {"cod": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
],
}
## What the prompt says it makes, and what it needs when idle.

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,7 @@ func _ready() -> void:
_bind("drop", [KEY_Q]) # (shift+Q drops just one)
_bind("row_up", [KEY_K]) # the 1-8 keys pick from the bag row above...
_bind("row_down", [KEY_M]) # ...or below
_bind("map", [KEY_N]) # the map (Gitea issue #1)
for i in range(8):
_bind("hotbar_%d" % (i + 1), [KEY_1 + i])
_bind_mouse("attack", MOUSE_BUTTON_LEFT)
@@ -257,6 +258,14 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
return false
var held: Dictionary = ItemDB.get_item(player.held_item_id())
var tool: Dictionary = held.get("tool", {})
if node.has_method("refusal"): # (the cliff tunnel's faces: their own rules first)
var why: String = node.refusal()
if not why.is_empty():
notify.emit(why)
return false
if tool.get("kind", "") == node.tool_kind and int(tool.get("tier", 0)) < node.min_tier:
notify.emit("Too hard: you need a better pick")
return false
if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier:
notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
return false

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

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

View File

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

View File

@@ -135,4 +135,9 @@ func _met(when: String) -> bool:
return not dead and world.day_night.is_night() and world.day_night.wolf_moon_now()
"hard_winter":
return not dead and world.weather != null and world.weather.in_hard_winter()
"worldlet": # (where you are on the journey: "worldlet:cove")
return not dead and world.has_method("worldlet_id") and String(world.worldlet_id()) in vals
"seam": # (an antler pickaxe in your bag, and the cliff tunnel's seam still to dig)
return not dead and world.get("tunnel") != null and not world.tunnel.opened \
and player.inventory.count("antler_pickaxe") > 0
return false

View File

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

View File

@@ -92,7 +92,8 @@ func define_water(water: String, d: Dictionary) -> void:
if not items.has(f.get("id", "")):
push_error("ItemDB: fish '%s' in water '%s' is not an item" % [f.get("id", "?"), water])
continue
fish.append({"id": f.id, "weight": float(f.get("weight", 1)), "dawn_dusk": float(f.get("dawn_dusk", f.get("weight", 1)))})
fish.append({"id": f.id, "weight": float(f.get("weight", 1)), "dawn_dusk": float(f.get("dawn_dusk", f.get("weight", 1))),
"min_depth": float(f.get("min_depth", 0.0))}) # (a fish of deeper water: cod)
var w: Array = d.get("bite_wait", [5.0, 14.0])
fishing.waters[water] = {"min_depth": float(d.get("min_depth", 0.5)), "bite_wait": [float(w[0]), float(w[1])], "fish": fish}

View File

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

View File

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

View File

@@ -56,16 +56,17 @@ func create_world(world_name: String, seed: int, options: Dictionary = {}) -> St
var now := Time.get_unix_time_from_system()
var meta := {
"name": world_name, "seed": seed, "created": now, "last_played": now,
"play_time": 0.0, "version": VERSION,
"play_time": 0.0, "version": VERSION, "worldlet": "valley",
}
for k in options:
meta[k] = options[k]
meta["home"] = String(meta.worldlet)
_write_json(_dir(id).path_join("meta.json"), meta)
return id
## The world slot called exactly `id` (a server's world), made with `seed` if
## it doesn't exist yet. An existing world keeps its own seed.
func open_or_create(id: String, seed: int) -> String:
func open_or_create(id: String, seed: int, worldlet: String = "valley") -> String:
if not read_meta(id).is_empty():
var had := int(read_meta(id).get("seed", seed))
if had != seed:
@@ -75,7 +76,7 @@ func open_or_create(id: String, seed: int) -> String:
var now := Time.get_unix_time_from_system()
_write_json(_dir(id).path_join("meta.json"), {
"name": id, "seed": seed, "created": now, "last_played": now,
"play_time": 0.0, "version": VERSION,
"play_time": 0.0, "version": VERSION, "worldlet": worldlet,
})
return id
@@ -84,6 +85,8 @@ func delete_world(id: String) -> void:
var d := DirAccess.open(dir)
if d == null:
return
for sub in d.get_directories(): # (worldlets/, and a server's backups/)
_remove_tree(dir.path_join(sub))
for f in d.get_files():
d.remove(f)
DirAccess.remove_absolute(dir)
@@ -93,16 +96,70 @@ func delete_world(id: String) -> void:
func read_meta(id: String) -> Dictionary:
return _read_json(_dir(id).path_join("meta.json"))
func load_state(id: String) -> Dictionary:
var s := _read_json(_dir(id).path_join("world.json"))
if s.is_empty() and FileAccess.file_exists(_dir(id).path_join("world.json.bak")):
push_warning("Saves: world.json unreadable, using backup")
s = _read_json(_dir(id).path_join("world.json.bak"))
# --- worldlets in one world (J1, docs/WORLDLETS_DESIGN.md §10) --------------------
# A world can span several worldlets (the journey). Each keeps its own state
# file: the one it began in (its "home") is world.json, as before, so older
# saves are unchanged; the others are worldlets/<id>.json. meta.json's
# "worldlet" is where you are now, so that's the one loaded.
## A land server (J3, `--land cove`) runs one worldlet of a server's world:
## this is that worldlet ("" = whatever the world's meta says).
var land := ""
## The worldlet world `id` is being played in now.
func worldlet_of(id: String) -> String:
if not land.is_empty():
return land
return String(read_meta(id).get("worldlet", "valley"))
## The worldlet world `id` began in (its world.json).
func home_of(id: String) -> String:
var m := read_meta(id)
return String(m.get("home", m.get("worldlet", "valley")))
## The state file for worldlet `w` of world `id`.
func state_path(id: String, w: String) -> String:
if w == home_of(id):
return _dir(id).path_join("world.json")
return _dir(id).path_join("worldlets/%s.json" % w)
## Whether world `id` has been to worldlet `w` (has a state for it).
func has_state(id: String, w: String) -> bool:
return FileAccess.file_exists(state_path(id, w))
## Moves world `id` to worldlet `to` (the next load is there), arriving at
## `arrive` (a place the arriving world puts you: "sea_cave", "tunnel_bottom").
func set_worldlet(id: String, to: String, arrive: String) -> void:
var m := read_meta(id)
if not m.has("home"):
m["home"] = String(m.get("worldlet", "valley")) # (pin it before "worldlet" moves)
m["worldlet"] = to
m["arrive"] = arrive
_write_json(_dir(id).path_join("meta.json"), m)
## Where the next load should put you, once (then forgotten).
func take_arrival(id: String) -> String:
var m := read_meta(id)
var a := String(m.get("arrive", ""))
if not a.is_empty():
m.erase("arrive")
_write_json(_dir(id).path_join("meta.json"), m)
return a
## The state of the worldlet being played (or of worldlet `w`).
func load_state(id: String, w: String = "") -> Dictionary:
var path := state_path(id, worldlet_of(id) if w.is_empty() else w)
var s := _read_json(path)
if s.is_empty() and FileAccess.file_exists(path + ".bak"):
push_warning("Saves: %s unreadable, using backup" % path.get_file())
s = _read_json(path + ".bak")
return s
## Writes the state for `id` and updates its meta. Returns true on success.
func save_state(id: String, state: Dictionary, session_seconds: float) -> bool:
var path := _dir(id).path_join("world.json")
## Writes the state for `id` (the worldlet being played, or `w`) and
## updates its meta. Returns true on success.
func save_state(id: String, state: Dictionary, session_seconds: float, w: String = "") -> bool:
var path := state_path(id, worldlet_of(id) if w.is_empty() else w)
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
if FileAccess.file_exists(path):
DirAccess.copy_absolute(path, path + ".bak")
state["version"] = VERSION
@@ -129,3 +186,10 @@ func _read_json(path: String) -> Dictionary:
return {}
var data = JSON.parse_string(FileAccess.get_file_as_string(path))
return data if typeof(data) == TYPE_DICTIONARY else {}
static func _remove_tree(d: String) -> void:
for sub in DirAccess.get_directories_at(d):
_remove_tree(d.path_join(sub))
for f in DirAccess.get_files_at(d):
DirAccess.remove_absolute(d.path_join(f))
DirAccess.remove_absolute(d)

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

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

View File

@@ -0,0 +1 @@
uid://3lflq11qfg00

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,7 +90,7 @@ func _check_site(world: Node3D, p: Vector2) -> Vector3:
var hs: Array[float] = []
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
hs.append(terrain.sample_height(p.x + d.x * 4.0, p.y + d.y * 4.0))
if hs.max() - hs.min() > 2.2 or hs.min() < terrain.water_level + 1.0:
if hs.max() - hs.min() > 2.2 or hs.min() < terrain.water_level + 1.0 or terrain.near_stream(p.x, p.y, 7.0):
return Vector3.INF
# Trees and boulders in the way are cleared (_clear_area); just avoid
# the thickest woods. Counted by position, because their colliders

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
uid://024u453fdbml

View File

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

View File

@@ -15,6 +15,7 @@ const BITE_WINDOW := 1.5
const LINE_LENGTH := 26.0
var water_type := "" ## "lake", "sea"... (world.water_at)
var water_depth := 0.0 ## how deep it is where the float came down
var table: Dictionary = {} ## that water's fishing table (ItemDB.water_fishing)
var world: Node3D
@@ -91,8 +92,9 @@ func _next_wait() -> float:
return rng.randf_range(w[0], w[1]) * k
## A weighted pick from this water's fish (dawn/dusk weights when they apply).
## (A fish with its own "min_depth" only bites in water that deep: cod.)
func _pick_fish() -> String:
var fish: Array = table.get("fish", [])
var fish: Array = table.get("fish", []).filter(func(f): return water_depth >= float(f.get("min_depth", 0.0)))
var key := "dawn_dusk" if _dawn_dusk() else "weight"
var total := 0.0
for f in fish:
@@ -120,6 +122,7 @@ func step(dt: float) -> void:
if not water.is_empty() and p.y <= float(water.surface):
p.y = water.surface
water_type = water.type
water_depth = float(water.depth)
table = ItemDB.water_fishing(water_type)
if table.is_empty() or table.fish.is_empty():
state = "landed"

View File

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

View File

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

View File

@@ -75,6 +75,10 @@ static func build(key: String) -> ArrayMesh:
"trout": m = _fish(TROUT, 0.24, false)
"grilled_perch": m = _fish(PERCH, 0.16, true)
"grilled_trout": m = _fish(TROUT, 0.24, true)
"mackerel": m = _fish(MACKEREL, 0.2, false)
"cod": m = _fish(COD, 0.3, false)
"grilled_mackerel": m = _fish(MACKEREL, 0.2, true)
"grilled_cod": m = _fish(COD, 0.3, true)
"fiber_plant": m = _fiber_plant()
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
"iron_ore": m = _ore()
@@ -118,6 +122,8 @@ static func build(key: String) -> ArrayMesh:
"pelt_rug": m = _rug(WOLF_FUR, 0.85)
"bear_cloak": m = _cloak(BEAR_FUR)
"clay_jar": m = _jar()
"tallow_candle": m = _candle()
"torch_stand": m = _torch_stand()
"bedroll": m = _bedroll()
"fang_necklace": m = _necklace()
"pemmican": m = _pebble(331, Color(0.42, 0.24, 0.16), Vector3(0.1, 0.05, 0.07), 0.15)
@@ -692,6 +698,8 @@ static func _cloak(col: Color = HIDE) -> ArrayMesh:
const PERCH := Color(0.45, 0.52, 0.30)
const TROUT := Color(0.50, 0.55, 0.58)
const MACKEREL := Color(0.22, 0.42, 0.48)
const COD := Color(0.56, 0.52, 0.40)
const GRILLED := Color(0.55, 0.36, 0.18)
const FLOAT_RED := Color(0.85, 0.15, 0.12)
@@ -1033,6 +1041,29 @@ static func _jar() -> ArrayMesh:
_box(st, Vector3(0, 0.63, 0), Vector3(0.08, 0.04, 0.05), PLANK_DARK)
return P._commit(st)
## A squat tallow candle on a clay dish; the wick's top is at y = 0.2
## (lamp.gd puts the flame there).
static func _candle() -> ArrayMesh:
var st := P._begin()
var r := _rng(359)
P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 0.02, 0), 0.08, 0.09, 8, CLAY_FIRED, r, -1.0, P._mul(CLAY_FIRED, 1.1))
P._cylinder(st, Vector3(0, 0.02, 0), Vector3(0, 0.16, 0), 0.04, 0.036, 8, Color(0.93, 0.89, 0.74), r, -1.0, Color(0.96, 0.93, 0.8))
P._cylinder(st, Vector3(0, 0.16, 0), Vector3(0, 0.19, 0), 0.004, 0.003, 4, ASH, r)
return P._commit(st)
## A torch in a wooden stand: three splayed feet, a post, the head at 1.4 m
## (lamp.gd's flame sits just above it).
static func _torch_stand() -> ArrayMesh:
var st := P._begin()
var r := _rng(367)
for i in range(3):
var a := TAU * i / 3.0
P._cylinder(st, Vector3(cos(a) * 0.28, 0.0, sin(a) * 0.28), Vector3(0, 0.45, 0), 0.03, 0.035, 5, _board(r, PLANK_DARK), r)
P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 1.3, 0), 0.04, 0.035, 6, _board(r), r)
_box(st, Vector3(0, 0.5, 0), Vector3(0.12, 0.05, 0.12), BINDING)
P._cylinder(st, Vector3(0, 1.28, 0), Vector3(0, 1.42, 0), 0.05, 0.06, 6, CLOTH, r, -1.0, ASH)
return P._commit(st)
## A hide bedroll: a flat mat with its rolled-up end and a feather pillow.
static func _bedroll() -> ArrayMesh:
var st := P._begin()

View File

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

View File

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

View File

@@ -57,6 +57,11 @@ func _c2s_say(text: String) -> void:
var who: String = Net.players[peer].name
if text.begins_with("/"):
print("[server] %s: %s" % [who, text])
if not Lands.land.is_empty():
# (Another land's server: commands run on the hub, which keeps
# server.json, and answers back here.)
Lands.send_hub({"t": "cmd", "line": text.substr(1), "id": String(Net.players[peer].id), "name": who, "peer": peer})
return
run(text.substr(1), peer)
return
# Plain chat only: a line every GAP seconds, so nobody can flood it.
@@ -68,22 +73,44 @@ func _c2s_say(text: String) -> void:
print("[chat] %s: %s" % [who, text])
_all("%s: %s" % [who, text], "chat")
func _all(text: String, kind: String) -> void:
## A line for everyone here, and (`relay`) in every other land too (J3b).
func _all(text: String, kind: String, relay: bool = true) -> void:
if kind == "server":
print("[server] ", text)
for peer in Net.players:
_s2c_line.rpc_id(peer, text, kind)
if relay:
Lands.relay_line(text, kind)
## A command typed by someone in another land (J3b): who they are, and where
## to send the answer. `run()` sees them as REMOTE.
const Terrain = preload("res://scripts/world/terrain.gd")
const REMOTE := -1
var _caller: Dictionary = {}
func run_remote(line: String, caller: Dictionary) -> void:
print("[server] %s (in the %s): /%s" % [caller.name, caller.land, line])
_caller = caller
run(line, REMOTE)
_caller = {}
## An answer to whoever gave the command (peer 0 = the console).
func _reply(peer: int, text: String) -> void:
if peer == 0:
print(text)
elif peer == REMOTE:
Lands.tell_land(String(_caller.land), {"t": "reply", "peer": int(_caller.peer), "text": text})
else:
_s2c_line.rpc_id(peer, text, "reply")
func _is_admin(peer: int) -> bool:
if peer == REMOTE:
return Net.config.is_admin(String(_caller.id))
return peer == 0 or (Net.players.has(peer) and Net.config.is_admin(String(Net.players[peer].id)))
## Another land's server should know: this command changed server.json.
const CHANGES_CONFIG := ["friend", "unfriend", "ban", "unban", "admin", "set", "release-name"]
## The player id a name belongs to (online or reserved), or "".
func _id_of(n: String) -> String:
var peer := Net.peer_by_name(n)
@@ -102,16 +129,24 @@ func run(line: String, peer: int) -> void:
if cmd in ADMIN_ONLY and not _is_admin(peer):
_reply(peer, "Only admins can use /%s" % cmd)
return
var me: String = String(Net.players[peer].id) if Net.players.has(peer) else ""
var by: String = String(Net.players[peer].name) if Net.players.has(peer) else "The server"
var me: String = String(Net.players[peer].id) if Net.players.has(peer) else String(_caller.get("id", ""))
var by: String = String(Net.players[peer].name) if Net.players.has(peer) else String(_caller.get("name", "The server"))
_run_cmd(cmd, parts, rest, arg, peer, me, by)
if cmd in CHANGES_CONFIG:
Lands.tell_lands({"t": "config"})
func _run_cmd(cmd: String, parts: PackedStringArray, rest: String, arg: String, peer: int, me: String, by: String) -> void:
match cmd:
"help":
_reply(peer, "Commands: /players /rules /friend <name> /unfriend <name> /friends" +
(" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else ""))
"players":
# Everyone, in every land (J3b: the hub's roster), with where they are.
var names: Array[String] = []
for p in Net.players.values():
names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else ""))
var many := Lands.roster.any(func(e): return String(e.land) != Lands.here())
for p in (Lands.roster if not Lands.roster.is_empty() else Lands._here_list()):
names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else "")
+ (" in the %s" % Terrain.worldlet_name(String(p.land)) if many else ""))
_reply(peer, "%d online: %s" % [names.size(), ", ".join(names)])
"rules":
var c: RefCounted = Net.config
@@ -142,10 +177,14 @@ func run(line: String, peer: int) -> void:
_reply(peer, "Your friends: %s" % (", ".join(names) if not names.is_empty() else "nobody yet (/friend <name>)"))
"kick":
var kp := Net.peer_by_name(arg)
if kp == 0:
var away := Lands.land_of_name(arg) if kp == 0 else ""
if kp == 0 and (away.is_empty() or away == Lands.here()):
_reply(peer, "Nobody called '%s' is online" % arg)
return
Net.kick(kp, "You were kicked by %s" % by)
if kp != 0:
Net.kick(kp, "You were kicked by %s" % by)
else:
Lands.tell_land(away, {"t": "kick", "name": arg, "reason": "You were kicked by %s" % by})
_all("%s was kicked" % arg, "server")
"ban":
var bid := _id_of(arg)
@@ -156,6 +195,8 @@ func run(line: String, peer: int) -> void:
var bp := Net.peer_by_name(arg)
if bp != 0:
Net.kick(bp, "You were banned by %s" % by)
elif not Lands.land_of_name(arg).is_empty():
Lands.tell_land(Lands.land_of_name(arg), {"t": "kick", "name": arg, "reason": "You were banned by %s" % by})
_all("%s was banned" % arg, "server")
"unban":
var uid: String = Net.config.name_owner(arg)
@@ -173,9 +214,11 @@ func run(line: String, peer: int) -> void:
_reply(ap, "%s made you an admin (/help for the commands)" % by)
"save":
WorldNet.save()
Lands.tell_lands({"t": "save"})
_reply(peer, "Saved")
"backup":
WorldNet.save()
Lands.tell_lands({"t": "save"})
var b: String = WorldNet.backup("by %s" % by)
_reply(peer, "Backed up as %s (%d kept)" % [b, WorldNet.list_backups().size()] if not b.is_empty() else "Nothing to back up yet")
"backups":
@@ -198,6 +241,7 @@ func run(line: String, peer: int) -> void:
_reply(peer, "Use /weather %s or auto" % "|".join(PackedStringArray(states)))
return
Net.set_weather("" if arg == "auto" else arg)
Lands.tell_lands({"t": "weather", "state": "" if arg == "auto" else arg})
_all("%s set the weather to %s" % [by, arg], "server")
"set":
if parts.size() < 3:

1
scripts/net/chat.gd.uid Normal file
View File

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

View File

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

View File

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

534
scripts/net/lands.gd Normal file
View File

@@ -0,0 +1,534 @@
extends Node
## Autoload "Lands" (J3, docs/WORLDLETS_DESIGN.md: a server per land).
##
## A server's world can span several lands (worldlets). The server players
## join, on the usual port, runs the world's home land (the Valley) and is the
## **hub**. When someone first heads for another land, the hub starts a
## **land server** for it: the same game, headless, on the next port (24243
## for the Cove, and so on), with `--land cove`. It shares the hub's config
## and world folder (worlds/<world>/: that land's state is
## worldlets/<land>.json, and characters are in players/, shared by all), so
## a character saved by one is loaded by the next. A land server stops a
## couple of minutes after its last player leaves, and whenever the hub
## stops.
##
## Hub and land servers talk over a private TCP link on 127.0.0.1 (one JSON
## object per line), never seen from outside. J3a carries: the land's
## "ready" (listening), its player count, the hub's clock, one-time passes
## for players crossing over, and asks about the tunnel.
##
## A crossing (PlayerNet, server side): the player's character is saved with
## where they're heading; the hub (or the land server, back the other way)
## makes a one-time pass, makes sure the other server is up, and sends the
## player's game `{port, pass, land}`. The game reconnects there with the
## pass (Net.join(..., pass)), and that server lets them straight in.
signal land_ready(land: String) ## hub: a land server is listening
signal hub_message(msg: Dictionary) ## land server: something from the hub
const LINK_PORT_OFFSET := 100 ## the hub's private link: its game port + 100 (24342)
const IDLE_STOP_SECONDS := 120.0 ## a land server with nobody on stops after this
const PASS_SECONDS := 60.0 ## a pass not used by then is no good
var hub := false ## this server is the hub
var land := "" ## this server is a land server, running this land
var hub_port := 0 ## (land server) the hub's game port
## Hub: the land servers it's running. land -> {"pid", "port", "io", "conn", "players", "ready", "idle"}
var servers: Dictionary = {}
var _link: TCPServer
var _unclaimed: Array = [] ## links not yet saying which land they are
## One-time passes, wherever a player is arriving: pass -> {"id", "t", "land"}
var passes: Dictionary = {}
## Land server: the link to the hub.
var _hub: StreamPeerTCP
var _hub_link: Dictionary = {} ## {"conn": _hub, "buf": the partial line}
# --- setup (called by Net.start_server) ------------------------------------------------
## Called once a server is listening. `args` are the command-line flags.
func server_started(args: Dictionary) -> void:
land = String(args.get("land", ""))
roster = []
if not Net.players_changed.is_connected(rebuild_roster):
Net.players_changed.connect(rebuild_roster)
if not land.is_empty():
hub_port = int(args.get("hub-port", Net.DEFAULT_PORT))
_hub = StreamPeerTCP.new()
_hub.connect_to_host("127.0.0.1", hub_port + LINK_PORT_OFFSET)
_hub_link = {"conn": _hub, "buf": ""}
return
hub = true
_link = TCPServer.new()
var err := _link.listen(game_port() + LINK_PORT_OFFSET, "127.0.0.1")
if err != OK:
push_warning("[lands] couldn't open the private link on %d (error %d): no other lands this run" % [game_port() + LINK_PORT_OFFSET, err])
_link = null
elif not _order().is_empty():
print("[lands] other lands start on demand. Players over the internet need %s forwarded" % ports_text(home(), game_port()))
## Called as a server stops: stops every land server it started.
func server_stopped() -> void:
for l in servers:
_send_land(l, {"t": "stop"})
var pids: Array = servers.values().map(func(s): return int(s.pid))
if not pids.is_empty():
get_tree().create_timer(6.0).timeout.connect(func():
for pid in pids:
if pid != 0 and OS.is_process_running(pid):
OS.kill(pid))
servers.clear()
_unclaimed.clear()
if _link != null:
_link.stop()
_link = null
if _hub != null:
_hub.disconnect_from_host()
_hub = null
hub = false
land = ""
passes.clear()
func game_port() -> int:
return int(Net.config.v("port")) if Net.config != null else Net.DEFAULT_PORT
## The worldlet this server runs.
func here() -> String:
return land if not land.is_empty() else String(Net.config.v("worldlet")) if Net.config != null else "valley"
## The port land `l` runs on: the hub's, then one up for each land after it
## in the journey's order (data/worldlets: next).
func port_for(l: String) -> int:
if l == home():
return hub_port if not land.is_empty() else game_port()
var base := hub_port if not land.is_empty() else game_port()
return base + 1 + maxi(_order().find(l), 0)
## The world's home land (the hub's).
func home() -> String:
return String(Net.config.v("worldlet")) if Net.config != null else "valley"
## Every land after home, in the order the journey reaches them.
func _order() -> Array:
return journey_from(home())
## The lands a server whose home is `home` and whose port is `base` may run,
## after home: [[land, port]] (24243 for the Cove when the server's 24242).
## Friends playing over the internet need these forwarded too.
static func extra_ports(home_land: String, base: int) -> Array:
var out: Array = []
var i := 1
for l in journey_from(home_land):
out.append([l, base + i])
i += 1
return out
## "UDP 24242 and 24243 (the Cove)": what to forward for a server on `base`.
static func ports_text(home_land: String, base: int) -> String:
var extra := extra_ports(home_land, base)
if extra.is_empty():
return "UDP %d" % base
var T: Script = load("res://scripts/world/terrain.gd")
var parts: Array[String] = []
for e in extra:
parts.append("%d (the %s)" % [int(e[1]), T.worldlet_name(String(e[0]))])
return "UDP %d and %s" % [base, ", ".join(parts)]
static func journey_from(home_land: String) -> Array:
var out: Array = []
var at := home_land
for _i in range(16):
var d: Dictionary = load("res://scripts/world/terrain.gd").load_worldlet(at)
var nxt := ""
for c in d.get("next", []):
if c is Dictionary and not String(c.get("to", "")) in out and String(c.get("to", "")) != home_land \
and not load("res://scripts/world/terrain.gd").load_worldlet(String(c.get("to", ""))).is_empty():
nxt = String(c.to)
break
if nxt.is_empty():
break
out.append(nxt)
at = nxt
return out
# --- passes -------------------------------------------------------------------------
## A one-time pass for player `id` to come into land `l`.
func make_pass(id: String, l: String) -> String:
var p := Crypto.new().generate_random_bytes(16).hex_encode()
passes[p] = {"id": id, "t": 0.0, "land": l}
return p
## Whether `p` lets player `id` in here (it's used up either way).
func take_pass(p: String, id: String) -> bool:
if not passes.has(p):
return false
var e: Dictionary = passes[p]
passes.erase(p)
return String(e.id) == id and float(e.t) < PASS_SECONDS
# --- the hub ------------------------------------------------------------------------
## Makes sure land `l` has a server (starting one if not). Calls `then`
## (with "" or what went wrong) once it's listening.
func ensure(l: String, then: Callable) -> void:
if not hub:
then.call("this isn't the hub")
return
if servers.has(l) and int(servers[l].pid) != 0 and not OS.is_process_running(int(servers[l].pid)):
print("[lands] the %s's server has gone: starting it again" % l)
servers.erase(l) # (crashed, and not noticed yet: start afresh)
if servers.has(l) and bool(servers[l].ready):
servers[l].idle = 0.0
then.call("")
return
if not servers.has(l):
var why := _start(l)
if not why.is_empty():
then.call(why)
return
var waiting: Callable
waiting = func(which: String):
if which == l:
land_ready.disconnect(waiting)
then.call("")
land_ready.connect(waiting)
func _start(l: String) -> String:
var args := PackedStringArray(["--headless"])
if not OS.has_feature("template"):
args.append_array(["--path", ProjectSettings.globalize_path("res://")])
args.append_array(["--", "--server", "--land", l, "--port", str(port_for(l)), "--hub-port", str(game_port()),
"--config", ProjectSettings.globalize_path(Net.config.path), "--exit-with", str(OS.get_process_id())])
for k in ["no-creatures", "debug-cheats"]:
if Net._args.has(k):
args.append("--" + k)
if Net._args.has("host-id"):
args.append_array(["--host-id", String(Net._args["host-id"])]) # (a hosted game: the host leaving stops it all)
var p: Dictionary = OS.execute_with_pipe(OS.get_executable_path(), args, false)
if p.is_empty():
return "couldn't start the %s's server" % l
var log := FileAccess.open(Net.config.path.get_base_dir().path_join("land-%s.log" % l), FileAccess.WRITE)
servers[l] = {"pid": int(p.pid), "port": port_for(l), "io": p.stdio, "log": log, "conn": null,
"players": 0, "ready": false, "idle": 0.0}
print("[lands] starting the %s's server on UDP %d" % [l, port_for(l)])
return ""
func _process(delta: float) -> void:
for p in passes.keys():
passes[p].t += delta
if float(passes[p].t) > PASS_SECONDS:
passes.erase(p)
if hub:
_hub_tick(delta)
elif not land.is_empty():
_land_tick()
func _hub_tick(delta: float) -> void:
if _link != null:
while _link.is_connection_available():
var c := _link.take_connection()
_unclaimed.append({"conn": c, "buf": ""})
for u in _unclaimed.duplicate():
var claimed := ""
for msg in _read(u):
if claimed.is_empty():
if String(msg.get("t", "")) == "hello" and servers.has(String(msg.land)):
claimed = String(msg.land)
servers[claimed].conn = u.conn
_unclaimed.erase(u)
else:
_from_land(claimed, msg) # (what came in the same read: "ready", most likely)
if not claimed.is_empty():
servers[claimed].buf = u.buf
for l in servers.keys():
var s: Dictionary = servers[l]
_drain_output(s)
if int(s.pid) != 0 and not OS.is_process_running(int(s.pid)):
print("[lands] the %s's server stopped" % l)
_drain_output(s)
servers.erase(l)
rebuild_roster()
PlayerNet.beds_changed()
continue
if s.conn != null:
for msg in _read(s):
_from_land(l, msg)
s.idle = float(s.idle) + delta if int(s.players) == 0 and bool(s.ready) else 0.0
if float(s.idle) > IDLE_STOP_SECONDS:
print("[lands] nobody in the %s for %d s: stopping its server" % [l, int(IDLE_STOP_SECONDS)])
_send_land(l, {"t": "stop"})
s.idle = -INF # (asked once)
## A land server's output goes to land-<land>.log beside server.json.
func _drain_output(s: Dictionary) -> void:
if s.io == null:
return
for _i in range(16):
var chunk: PackedByteArray = s.io.get_buffer(8192)
if chunk.is_empty():
break
if s.log != null:
s.log.store_string(chunk.get_string_from_utf8())
s.log.flush()
func _from_land(l: String, msg: Dictionary) -> void:
match String(msg.get("t", "")):
"ready":
servers[l].ready = true
_send_land(l, {"t": "clock", "day": Net._world.day_night.day, "time": Net._world.day_night.time})
_send_land(l, {"t": "tunnel", "dug": _tunnel_dug_here()})
land_ready.emit(l)
"players":
servers[l].players = int(msg.n)
"pass":
# A player there is coming back here: let them in with this pass.
passes[String(msg.pass)] = {"id": String(msg.id), "t": 0.0, "land": home()}
"tunnel?":
_send_land(l, {"t": "tunnel", "dug": _tunnel_dug_here()})
"host_left":
Net._host_left.call_deferred()
"roster":
servers[l].list = msg.get("list", [])
rebuild_roster()
"line":
# Chat from that land: everyone here, and every other land.
Chat._all(String(msg.text), String(msg.kind), false)
for other in servers:
if other != l:
_send_land(other, msg)
"cmd":
Chat.run_remote(String(msg.line), {"id": String(msg.id), "name": String(msg.name), "land": l, "peer": int(msg.peer)})
"beds":
servers[l].beds = [int(msg.alive), int(msg.asleep)]
# Tell the lands the running total (minus their own), so each can
# say "N of M asleep", then see if the night passes.
_push_bed_totals()
PlayerNet.beds_changed()
## Sends `msg` to land `l`'s server.
func _send_land(l: String, msg: Dictionary) -> void:
if not servers.has(l) or servers[l].conn == null:
return
var c: StreamPeerTCP = servers[l].conn
c.poll()
if c.get_status() == StreamPeerTCP.STATUS_CONNECTED:
c.put_data((JSON.stringify(msg) + "\n").to_utf8_buffer())
## Hub: tells every land how far the cliff tunnel's dug (the Cove's sea
## cave leads up it once it's through).
func push_tunnel(dug: int) -> void:
for l in servers:
_send_land(l, {"t": "tunnel", "dug": dug})
func _tunnel_dug_here() -> int:
var t: Node3D = Net._world.tunnel if Net._world != null else null
return int(t.dug) if t != null else 0
## Hub: tells every land the clock (so there's one clock everywhere).
func push_clock(day: int, time: float) -> void:
for l in servers:
_send_land(l, {"t": "clock", "day": day, "time": time})
## Hub: a pass for player `id` to go to land `l` (the land server learns it).
func pass_to(l: String, id: String) -> String:
var p := make_pass(id, l)
_send_land(l, {"t": "pass", "pass": p, "id": id})
passes.erase(p) # (it's the land server's to take, not ours)
return p
# --- a land server --------------------------------------------------------------------
var _said_hello := false
var _last_players := -1
var tunnel_dug := -1 ## (land server) how far the hub's tunnel is dug, last asked
func _land_tick() -> void:
if _hub == null:
return
_hub.poll()
var st := _hub.get_status()
if st == StreamPeerTCP.STATUS_CONNECTED:
if not _said_hello:
_said_hello = true
send_hub({"t": "hello", "land": land})
send_hub({"t": "ready"})
var n := Net.players.size()
if n != _last_players or _roster_dirty:
_last_players = n
_roster_dirty = false
send_hub({"t": "players", "n": n})
send_hub({"t": "roster", "list": _here_list()})
for msg in _read(_hub_link):
_from_hub(msg)
elif st == StreamPeerTCP.STATUS_ERROR or (st == StreamPeerTCP.STATUS_NONE and _said_hello):
print("[lands] lost the hub: saving and stopping")
_hub = null
Net.stop()
get_tree().quit()
func _from_hub(msg: Dictionary) -> void:
match String(msg.get("t", "")):
"stop":
print("[lands] the hub asked us to stop: saving")
_hub = null
Net.stop()
get_tree().quit()
"pass":
passes[String(msg.pass)] = {"id": String(msg.id), "t": 0.0, "land": land}
"clock":
if Net._world != null:
var dn: Node = Net._world.day_night
var was_day: int = dn.day
var was_t: float = dn.time
var secs: float = ((int(msg.day) - was_day) + (float(msg.time) - was_t)) * dn.day_length
if secs > 1.0:
Net._world.skip_time(secs) # (catching up: a slept night, or time nobody was here)
dn.day = int(msg.day)
dn.time = float(msg.time)
dn.apply(true)
Net.push_clock()
"tunnel":
tunnel_dug = int(msg.dug)
"roster":
roster = msg.get("list", [])
Net.push_roster()
"line":
Chat._all(String(msg.text), String(msg.kind), false)
"reply":
if Net.players.has(int(msg.peer)):
Chat._s2c_line.rpc_id(int(msg.peer), String(msg.text), "reply")
"kick":
var kp: int = Net.peer_by_name(String(msg.name))
if kp != 0:
Net.kick(kp, String(msg.reason))
"config":
Net.reload_config()
Net.broadcast_rules()
"weather":
Net.set_weather(String(msg.state))
"save":
WorldNet.save()
"night":
PlayerNet.pass_night_here(float(msg.secs))
"beds_total":
PlayerNet._hub_beds = [int(msg.alive), int(msg.asleep)]
hub_message.emit(msg)
func send_hub(msg: Dictionary) -> void:
if _hub != null and _hub.get_status() == StreamPeerTCP.STATUS_CONNECTED:
_hub.put_data((JSON.stringify(msg) + "\n").to_utf8_buffer())
# --- everyone, everywhere (J3b) ------------------------------------------------------------
## Everyone on every land: [{"name", "id", "land", "admin"}]. The hub builds
## it from its own players and what each land reports; every land and every
## player's game gets a copy (Net.roster), for the player list and /players.
var roster: Array = []
var _roster_dirty := false
## The players on this server, as the roster lists them.
func _here_list() -> Array:
var out: Array = []
for p in Net.players.values():
out.append({"name": String(p.name), "id": String(p.id), "land": here(), "admin": bool(p.get("admin", false))})
return out
## Hub: the roster again (its players changed, or a land's did), sent out.
func rebuild_roster() -> void:
if not hub:
if not land.is_empty():
_roster_dirty = true # (a land server: tell the hub, next tick)
return
var out: Array = _here_list()
for l in servers:
for e in servers[l].get("list", []):
var e2: Dictionary = (e as Dictionary).duplicate()
e2.land = l
out.append(e2)
roster = out
Net.push_roster()
for l in servers:
_send_land(l, {"t": "roster", "list": out})
## The land a player called `n` is in ("" = not on anywhere).
func land_of_name(n: String) -> String:
for e in roster:
if String(e.name).to_lower() == n.to_lower():
return String(e.land)
return ""
## Chat or a server message, from here to every other land.
func relay_line(text: String, kind: String) -> void:
if hub:
for l in servers:
_send_land(l, {"t": "line", "text": text, "kind": kind})
elif not land.is_empty():
send_hub({"t": "line", "text": text, "kind": kind})
## Hub: sends `msg` to every land.
func tell_lands(msg: Dictionary) -> void:
for l in servers:
_send_land(l, msg)
## Hub: sends `msg` to land `l`.
func tell_land(l: String, msg: Dictionary) -> void:
_send_land(l, msg)
## Hub: tells each land the alive/asleep everywhere BUT that land, so a land
## server can show the right "N of M" and know when all are abed.
func _push_bed_totals() -> void:
var here_alive: int = PlayerNet._alive().size()
var here_asleep: int = PlayerNet._alive().filter(func(q): return q.in_bed).size()
for l in servers:
var a := here_alive
var s := here_asleep
for other in servers:
if other != l:
var b: Array = servers[other].get("beds", [0, 0])
a += int(b[0])
s += int(b[1])
_send_land(l, {"t": "beds_total", "alive": a, "asleep": s})
## Hub: how many are alive, and how many asleep, in every other land.
func beds_elsewhere() -> Array:
var alive := 0
var asleep := 0
for l in servers:
var b: Array = servers[l].get("beds", [0, 0])
alive += int(b[0])
asleep += int(b[1])
return [alive, asleep]
## Land server: a pass for player `id` to go back to the hub.
func pass_home(id: String) -> String:
var p := Crypto.new().generate_random_bytes(16).hex_encode()
send_hub({"t": "pass", "pass": p, "id": id})
return p
# --- the line protocol ------------------------------------------------------------------
## Whole JSON lines waiting on `link` ({"conn", "buf"}); keeps the rest.
func _read(link: Dictionary) -> Array:
var c: StreamPeerTCP = link.conn
if c == null:
return []
c.poll()
if c.get_status() != StreamPeerTCP.STATUS_CONNECTED:
return []
var n := c.get_available_bytes()
if n > 0:
var got: Array = c.get_data(n)
if int(got[0]) == OK:
link.buf = String(link.get("buf", "")) + (got[1] as PackedByteArray).get_string_from_utf8()
var out: Array = []
var buf := String(link.get("buf", ""))
while buf.contains("\n"):
var line := buf.get_slice("\n", 0)
buf = buf.substr(line.length() + 1)
var m: Variant = JSON.parse_string(line)
if m is Dictionary:
out.append(m)
link.buf = buf
return out

1
scripts/net/lands.gd.uid Normal file
View File

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

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 ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 14 ## 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)
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 DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
@@ -191,6 +191,10 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
if overrides.has(flag):
var v: Variant = overrides[flag]
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v)
# A land server (J3, lands.gd) runs another land of the hub's world.
Saves.land = String(overrides.get("land", ""))
if not Saves.land.is_empty():
config.overrides["port"] = int(overrides.get("port", config.v("port")))
# The shared island (world_net.gd): the same terrain and scenery every
# client generates from the seed, plus everything players changed.
_world = WorldNet.open_server_world(config)
@@ -211,8 +215,10 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
players.clear()
_pending.clear()
_set_state("server")
print("[server] '%s' listening on UDP %d (max %d players%s). Config: %s" % [config.v("server_name"), port,
Lands.server_started(overrides)
print("[server] '%s' listening on UDP %d (max %d players%s%s). Config: %s" % [config.v("server_name"), port,
int(config.v("max_players")), ", password set" if not String(config.v("password")).is_empty() else "",
", the %s (a land server)" % Saves.land if not Saves.land.is_empty() else "",
ProjectSettings.globalize_path(config.path)])
return ""
@@ -226,11 +232,14 @@ func stop() -> void:
_states.clear()
server_info.clear()
world_info.clear()
roster.clear()
_out_q.clear()
_in_q.clear()
local_peer = 0
if _world != null:
WorldNet.close_server_world() # saves it first
Lands.server_stopped() # (and the hub's land servers with it)
Saves.land = ""
_world = null
_terrain = null
if state != "offline":
@@ -252,7 +261,8 @@ func _on_peer_disconnected(id: int) -> void:
_s2c_player_left.rpc(id)
PlayerNet.server_leave(id) # saves their character
WorldNet.peer_left(id)
if _hosted_by(String(p.id)):
# (The host crossing to another land hasn't left: only leaving the game does.)
if _hosted_by(String(p.id)) and PlayerNet.land_of(String(p.id)) in ["", Lands.here()]:
_host_left.call_deferred()
players_changed.emit()
@@ -282,10 +292,12 @@ func _process(delta: float) -> void:
## it every few seconds.
func _tick_clock(delta: float) -> void:
_clock_left -= delta
if _clock_left <= 0.0 and not players.is_empty():
if _clock_left <= 0.0 and (not players.is_empty() or not Lands.servers.is_empty()):
_clock_left = CLOCK_INTERVAL
for peer in players:
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
if Lands.hub:
Lands.push_clock(_world.day_night.day, _world.day_night.time) # (one clock for every land)
# --- hosting from the title screen (M2.7) ------------------------------------------
# "Host Game" runs a dedicated server as a separate, windowless copy of the
@@ -304,14 +316,14 @@ var _host_out := ""
var _host_port := DEFAULT_PORT
## Settings for a hosted game: name, password, port, a rules preset
## (co-op / rivals / adversarial) and the seed for a new island.
## (co-op / rivals / adversarial) and the seed and worldlet for a new island.
const PRESETS := {
"co-op": {"pvp": "false", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"rivals": {"pvp": "true", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"adversarial": {"pvp": "true", "raiding": "true", "locks": "owner", "death_penalty": "drop_all"},
}
func host(server_name: String, password: String, port: int, preset: String, seed: int) -> void:
func host(server_name: String, password: String, port: int, preset: String, seed: int, worldlet: String = "valley") -> void:
if hosting or state != "offline":
return
if _host_pid != 0 and OS.is_process_running(_host_pid):
@@ -324,8 +336,9 @@ func host(server_name: String, password: String, port: int, preset: String, seed
c.data.password = password
c.data.port = port
var fresh := not FileAccess.file_exists(host_config.get_base_dir().path_join("worlds/server-world/meta.json"))
if fresh:
if fresh: # (an island keeps its seed and worldlet once it's made)
c.data.seed = seed
c.data.worldlet = worldlet
c.save()
_host_port = port
var args := PackedStringArray(["--headless"])
@@ -413,7 +426,9 @@ func _hosted_by(id: String) -> bool:
func _host_left() -> void:
print("[server] the host left: saving and stopping")
stop() # (saves the world and everyone's characters)
if not Lands.land.is_empty():
Lands.send_hub({"t": "host_left"}) # (they left from another land: the hub stops too)
stop() # (saves the world and everyone's characters, and stops the other lands)
get_tree().quit()
# --- admin (chat.gd) ---------------------------------------------------------------
@@ -455,11 +470,94 @@ func _s2c_weather(state: String) -> void:
signal weather_changed(state: String)
# --- crossing to another land (J3, lands.gd) ------------------------------------------
signal travelling(land: String) ## client: on our way to another land's server
signal arrived(land: String) ## client: in, at the other land (the world reloads)
var _travel: Dictionary = {} ## client: {"land", "address", "pass"} while crossing
var _hub_address := "" ## client: the address we joined (the hub's), for the host part
## Server: sends player `peer`'s game on to land `l` (its server's port, and
## the pass that lets them in), then lets them go.
func send_travel(peer: int, l: String, pass_: String) -> void:
_s2c_travel.rpc_id(peer, {"land": l, "port": Lands.port_for(l), "pass": pass_})
# (Their game goes on its own; this only tidies up one that doesn't.)
get_tree().create_timer(3.0).timeout.connect(func():
if _peer != null and is_server() and (players.has(peer) or _pending.has(peer)):
_peer.disconnect_peer(peer))
@rpc("authority", "call_remote", "reliable")
func _s2c_travel(d: Dictionary) -> void:
var host := String(parse_address(_hub_address if not _hub_address.is_empty() else get_identity().last_server)[0])
if host.is_empty():
host = "127.0.0.1"
_travel = {"land": String(d.land), "address": "%s:%d" % [host, int(d.port)], "pass": String(d.pass)}
print("[net] on to the %s (%s)" % [d.land, _travel.address])
travelling.emit(String(d.land))
var pw := _pending_password
_quiet_stop()
join(String(_travel.address), pw, String(_travel.pass))
## Closes the connection without telling anyone we left (a crossing).
func _quiet_stop() -> void:
if _peer != null:
_peer.close()
_peer = null
multiplayer.multiplayer_peer = null
players.clear()
_states.clear()
_out_q.clear()
_in_q.clear()
local_peer = 0
_set_state("offline")
func is_travelling() -> bool:
return not _travel.is_empty()
# --- everyone on every land (J3b, lands.gd) -------------------------------------------
## Client: everyone on the server, whichever land they're in: [{"name",
## "id", "land", "admin"}] (the hub's roster). Empty on a server with only
## one land running: then `players` is everyone.
var roster: Array = []
## Server: everyone here gets the roster.
func push_roster() -> void:
for peer in players:
_s2c_roster.rpc_id(peer, Lands.roster)
@rpc("authority", "call_remote", "reliable")
func _s2c_roster(list: Array) -> void:
roster = list
players_changed.emit()
## Everyone on the server to show in a list: the roster, or (one land) just
## those here. [{"name", "admin", "land"}]
func everyone() -> Array:
if not roster.is_empty():
return roster
var out: Array = []
for p in players.values():
out.append({"name": String(p.name), "admin": bool(p.get("admin", false)), "land": String(world_info.get("worldlet", ""))})
return out
## Server: server.json read again (the hub changed it: a /set, a ban, a
## new admin, a friend), keeping this run's command-line overrides.
func reload_config() -> void:
if config == null:
return
var ov: Dictionary = config.overrides
config = ServerConfig.open(config.path)
config.overrides = ov
## Everyone gets the clock now (after a night's sleep, an admin's /time).
func push_clock() -> void:
_clock_left = CLOCK_INTERVAL
for peer in players:
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
if Lands.hub:
Lands.push_clock(_world.day_night.day, _world.day_night.time)
## Where a player is, as far as the server knows.
func player_pos(peer: int) -> Vector3:
@@ -540,6 +638,12 @@ func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> St
return "too fast (%.0f m in %.2f s)" % [flat, dt]
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
return "rising too fast"
# (Down the cliff tunnel, J1: its own space under the island, where its
# rock is the ground. The way in and out is a server teleport.)
if _world != null and _world.underground_at(to):
return "" if teleported or _world.underground_at(from) else "into the tunnel without going in"
if _world != null and _world.underground_at(from):
return "out of the tunnel without climbing out"
if not _terrain.inside_boundary(to.x, to.z, -3.0):
return "outside the island"
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
@@ -593,8 +697,10 @@ func _c2s_hello(d: Dictionary) -> void:
_reject(peer, "Different game version: this server runs Emberwild %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."])
return
# (A crossing to another land comes with a one-time pass instead, checked
# once they've proved who they are: _c2s_proof.)
var pw := String(config.v("password"))
if not pw.is_empty() and String(d.get("password", "")) != pw:
if not pw.is_empty() and String(d.get("password", "")) != pw and String(d.get("pass", "")).is_empty():
_reject(peer, "Wrong password")
return
var nonce := Crypto.new().generate_random_bytes(32)
@@ -618,6 +724,23 @@ func _c2s_proof(sig: PackedByteArray) -> void:
if not why.is_empty():
_reject(peer, why)
return
# Lands (J3): a land server only lets in someone crossing with a pass the
# hub gave them; a pass anywhere must be theirs and fresh. And on the
# hub, a character who's in another land is sent straight on there.
var pass_ := String(hello.get("pass", ""))
if not pass_.is_empty():
if not Lands.take_pass(pass_, id):
_reject(peer, "That way in has expired: join the server again")
return
elif not Lands.land.is_empty():
_reject(peer, "Join this server's main address (this is one of its other lands)")
return
elif Lands.hub:
var away: String = PlayerNet.land_of(id)
if not away.is_empty() and away != Lands.home():
_pending.erase(peer)
PlayerNet.send_on(peer, id, away)
return
_pending.erase(peer)
config.reserve_name(name, id)
if config.data.admins.is_empty():
@@ -638,9 +761,10 @@ func _c2s_proof(sig: PackedByteArray) -> void:
print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
players.size(), int(config.v("max_players"))])
# (The world's own seed: an existing world keeps the seed it was made with.)
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, "yaw": yaw,
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(_terrain.worldlet_id), "spawn": start, "yaw": yaw,
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
"weather": _world.weather.override}
"weather": _world.weather.override,
"tunnel_dug": Lands.tunnel_dug if not Lands.land.is_empty() else Lands._tunnel_dug_here()}
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
for other in players:
if other != peer:
@@ -682,7 +806,12 @@ static func parse_address(a: String) -> Array:
return [a, DEFAULT_PORT]
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
func join(address: String, password: String = "") -> void:
## `pass_` is a one-time pass from a crossing to another land (J3).
func join(address: String, password: String = "", pass_: String = "") -> void:
_pending_pass = pass_
if pass_.is_empty():
_hub_address = address.strip_edges()
_travel = {}
if state != "offline":
stop()
var me := get_identity()
@@ -704,11 +833,13 @@ func join(address: String, password: String = "") -> void:
_reject_reason = ""
_pending_password = password
_join_left = JOIN_TIMEOUT
me.last_server = address.strip_edges()
me.save_profile()
if pass_.is_empty():
me.last_server = address.strip_edges() # (always the hub's: rejoining sends you on)
me.save_profile()
_set_state("connecting")
var _pending_password := ""
var _pending_pass := ""
func leave() -> void:
var was := state
@@ -721,7 +852,7 @@ func leave() -> void:
func _on_connected() -> void:
var me := get_identity()
_c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name,
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password})
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password, "pass": _pending_pass})
@rpc("authority", "call_remote", "reliable")
func _s2c_challenge(nonce: PackedByteArray) -> void:
@@ -744,6 +875,10 @@ func _s2c_welcome(d: Dictionary) -> void:
_set_state("connected")
joined.emit()
players_changed.emit()
if not _travel.is_empty():
var l := String(_travel.land)
_travel = {}
arrived.emit(l)
@rpc("authority", "call_remote", "reliable")
func _s2c_rejected(reason: String) -> void:
@@ -806,10 +941,17 @@ func _run_queue(q: Array) -> void:
f.call()
func _fail(reason: String) -> void:
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
var crossing := not _travel.is_empty()
_travel = {}
if not crossing:
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
stop()
last_reason = reason
join_failed.emit(reason)
if crossing:
# (The world we were in is still on screen: it goes back to the title.)
last_reason = "Couldn't get through to the next land (%s). Join again to carry on." % reason
left.emit(last_reason)
func _on_connection_failed() -> void:
_fail("Couldn't reach the server")
@@ -817,6 +959,11 @@ func _on_connection_failed() -> void:
func _on_server_disconnected() -> void:
var was := state
var reason := _reject_reason
if not _travel.is_empty():
if was == "connecting":
_fail(reason if not reason.is_empty() else "the server hung up") # (the next land wouldn't have us)
return # (the land we're leaving letting us go)
var land_name := on_land()
stop_hosting()
stop()
if was == "connecting":
@@ -824,8 +971,19 @@ func _on_server_disconnected() -> void:
join_failed.emit(last_reason)
else:
last_reason = reason if not reason.is_empty() else "The server closed the connection"
if reason.is_empty() and not land_name.is_empty():
# (Another land's server went: the rest of the server's still up,
# and joining again starts that land's server afresh.)
last_reason = "Lost the connection to the %s. Join the server again to carry on there." % \
preload("res://scripts/world/terrain.gd").worldlet_name(land_name)
left.emit(last_reason)
## Client: the land we're in when it's not the server's home land (we're on
## one of its other lands' servers), else "".
func on_land() -> String:
var here := String(world_info.get("worldlet", ""))
return here if not here.is_empty() and here != String(server_info.get("worldlet", here)) else ""
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
if _peer != null:

1
scripts/net/net.gd.uid Normal file
View File

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

View File

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

View File

@@ -79,11 +79,20 @@ func server_join(peer: int, id: String, display: String) -> Vector3:
p.load_data(d)
var t: Node3D = WorldNet.world.terrain
var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF
if pos.is_finite() and t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0:
var here_ := String(d.get("land", Lands.here())) == Lands.here()
if here_ and d.has("arrive"):
# Just crossed over: where the way in comes out (J3).
pos = WorldNet.world.arrival_point(String(d.arrive))
elif not here_:
pos = Vector3.INF # (saved somewhere else: start at this land's start)
var ok_spot: bool = pos.is_finite() and (WorldNet.world.underground_at(pos) \
or (t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0))
if ok_spot:
at = pos
print("[server] %s's character loaded" % display)
if at != Vector3.INF:
p.global_position = at
beds_changed.call_deferred() # (one more awake: everyone's count changes)
return at
func place_at(peer: int, pos: Vector3) -> void:
@@ -127,10 +136,54 @@ func _save(p: Node3D) -> void:
if f == null:
printerr("[server] can't save %s's character" % p.display)
return
f.store_string(JSON.stringify(p.to_data(), "\t"))
var d: Dictionary = p.to_data()
# Which land the character's in (J3: characters are shared by every land
# of the world); heading somewhere, it's there they'll turn up next.
d["land"] = String(p.get_meta("heading", Lands.here()))
if p.has_meta("arrive"):
d["arrive"] = String(p.get_meta("arrive"))
f.store_string(JSON.stringify(d, "\t"))
f.close()
DirAccess.rename_absolute(tmp, path)
## Which land player `id`'s character is in ("" = not saved yet, or here).
func land_of(id: String) -> String:
return String(_read(_file(id)).get("land", ""))
## The hub: player `id` (at `peer`, not let in yet) left their character in
## land `l`: start its server if need be and send them on there.
func send_on(peer: int, id: String, l: String) -> void:
print("[server] %s's character is in the %s: sending them on" % [id.substr(0, 8), l])
Lands.ensure(l, func(why: String):
if not why.is_empty():
Net._reject(peer, "The %s isn't open right now (%s)" % [l, why])
return
Net.send_travel(peer, l, Lands.pass_to(l, id)))
## A crossing, on the server (the tunnel's sea cave, the cave back up): the
## character is saved as heading for `to`, arriving at `arrive`, and their
## game is sent on (to a land server, or back to the hub).
func cross(peer: int, to: String, arrive: String) -> void:
var p: Node3D = proxies.get(peer)
if p == null or p.has_meta("heading"):
return
p.set_meta("heading", to)
p.set_meta("arrive", arrive)
_save(p)
var id: String = p.player_id
print("[server] %s crosses to the %s" % [p.display, to])
if to == Lands.home() and not Lands.land.is_empty():
Net.send_travel(peer, to, Lands.pass_home(id))
return
Lands.ensure(to, func(why: String):
if not why.is_empty():
p.remove_meta("heading")
p.remove_meta("arrive")
_save(p)
tell(peer, "The way's blocked for now (%s)" % why)
return
Net.send_travel(peer, to, Lands.pass_to(to, id)))
static func _read(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
@@ -160,6 +213,7 @@ func server_moved(peer: int, from: Vector3, to: Vector3, yaw: float, anim: int,
if p.in_bed and to.distance_to(p.bed_at) > 1.5:
p.in_bed = false
tell(p.peer, "You got out of bed")
beds_changed()
func _process(delta: float) -> void:
if not Net.is_server() or proxies.is_empty():
@@ -300,8 +354,42 @@ func _run(p: Node3D, what: String, a: Array) -> void:
"hit":
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
var at: Vector3 = _v(a, 1)
if n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
if n == null and w.tunnel != null:
var f: Node3D = w.tunnel.face_by_uid(_s(a, 0)) # (the cliff tunnel's seam or face)
if f != null and at.is_finite():
Actions.harvest_hit(p, f, at) # (the tunnel tells everyone: tunnel.gd)
elif n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
WorldNet.node_hit(n, at, p.global_position)
"tunnel_prop":
if w.tunnel != null:
w.tunnel.set_prop(p, _i(a, 0))
"tunnel_go":
# Down into the cliff tunnel, or back out at the top: a teleport
# the server makes (the tunnel's under the island), from nearby.
var t: Node3D = w.tunnel
if t == null or not t.opened:
return
var go_in := _s(a, 0) == "in"
var from: Vector3 = t.seam_out if go_in else t.top
if p.global_position.distance_to(from) > 4.5:
return
if go_in:
t.go_in(p)
else:
t.go_out(p)
Net.teleport(p.peer, p.global_position)
_s2c_respawn.rpc_id(p.peer, p.global_position, p.rotation.y, "")
"cross":
# The sea cave's mouth (the Valley), or the sea cave (the Cove).
var where := _s(a, 0)
if where == "tunnel" and w.tunnel != null and w.tunnel.dug >= w.tunnel.STRETCHES \
and p.global_position.distance_to(w.tunnel.cave_mouth) < 4.0:
cross(p.peer, w.tunnel.to, "sea_cave")
elif where == "sea_cave" and w.sea_cave != null and w.sea_cave.open() \
and p.global_position.distance_to(w.sea_cave.front) < 6.0:
cross(p.peer, w.sea_cave.to, "tunnel_bottom")
elif where == "sea_cave" and w.sea_cave != null:
tell(p.peer, w.sea_cave.interact_prompt(p))
"pickup":
var key := _s(a, 0)
var pk: Node3D = WorldNet.find_pickup(key)
@@ -456,6 +544,12 @@ func _run(p: Node3D, what: String, a: Array) -> void:
w.day_night.apply(true)
w.weather.snap()
Net.push_clock()
"debug_tunnel":
# (tests) dig the cliff tunnel to `n` stretches, all propped.
if cheats and w.tunnel != null:
var n := clampi(_i(a, 0), 0, w.tunnel.STRETCHES)
w.tunnel.load_data({"opened": true, "dug": n, "props": range(1, n + 1)})
w.tunnel._tell_everyone()
"debug_tp":
var to := _v(a, 0)
if cheats and to.is_finite():
@@ -515,28 +609,66 @@ func go_to_bed(p: Node3D) -> void:
p.in_bed = true
p.bed_at = p.global_position
if not _maybe_pass_night():
var n: int = _alive().size()
var asleep: int = _alive().filter(func(q): return q.in_bed).size()
tell(p.peer, "You're in bed (%d of %d asleep). The night passes when %s." % [asleep, n,
"everyone's in bed" if _beds_needed(n) >= n else "%d are" % _beds_needed(n)])
for q in _alive():
if q != p:
tell(q.peer, "%s is in bed (%d of %d asleep)" % [p.display, asleep, n])
# (Counting everyone, in every land: on another land's server, the
# hub's last count, with this bed in it.)
var counts := sleep_counts()
if not Lands.land.is_empty():
counts = [_alive().size() + _hub_beds[0], _alive().filter(func(q): return q.in_bed).size() + _hub_beds[1]]
var n: int = counts[0]
var asleep: int = counts[1]
var need: int = _beds_needed(n)
tell(p.peer, waiting_text(asleep, n, need))
# Everyone else, in every land, hears it in chat (so whoever's still
# up knows they're being waited for).
Chat._all("%s is in bed (%d of %d asleep%s)" % [p.display, asleep, n,
"" if need >= n else ", %d needed" % need], "server")
## What a player who's just got into bed is told while the others are up.
static func waiting_text(asleep: int, n: int, need: int) -> String:
var left := need - asleep
var who := "everyone else" if need >= n else ("%d more" % left)
return "You're in bed: waiting for %s to go to bed (%d of %d asleep)" % [who, asleep, n]
func _alive() -> Array:
return proxies.values().filter(func(q): return not q.survival.dead)
## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires,
## regrowth and weather move on; sleepers wake rested.
## One clock for every land (J3b): the night passes when enough of everyone,
## in every land, is in bed. Another land's server reports its beds to the
## hub, which decides, and tells every land to pass the night too.
func _maybe_pass_night() -> bool:
var w: Node3D = WorldNet.world
var live := _alive()
if w == null or live.is_empty():
if w == null:
return false
var asleep := live.filter(func(q): return q.in_bed)
if asleep.size() < _beds_needed(live.size()):
if not Lands.land.is_empty():
_report_beds()
return false
Lands._push_bed_totals()
var counts := sleep_counts()
if counts[0] == 0 or counts[1] < _beds_needed(counts[0]):
return false
var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length
Lands.tell_lands({"t": "night", "secs": secs})
pass_night_here(secs)
return true
## [alive, asleep] on this server and (the hub) every other land.
func sleep_counts() -> Array:
var live := _alive()
var counts := [live.size(), live.filter(func(q): return q.in_bed).size()]
if Lands.hub:
var e: Array = Lands.beds_elsewhere()
counts = [counts[0] + int(e[0]), counts[1] + int(e[1])]
return counts
## The night passes here: until 06:00 for everyone on this server.
func pass_night_here(secs: float) -> void:
var w: Node3D = WorldNet.world
if w == null:
return
var live := _alive()
var asleep := live.filter(func(q): return q.in_bed)
w.skip_time(secs)
for q in live:
if q.in_bed:
@@ -545,7 +677,20 @@ func _maybe_pass_night() -> bool:
_s2c_slept.rpc_id(q.peer, asleep.has(q))
Net.push_clock()
WorldNet.save()
return true
if not Lands.land.is_empty():
_report_beds()
## Someone got into or out of bed, or came or went: (a land server) the hub
## hears the new count; (the hub) the night may pass.
func beds_changed() -> void:
if not Lands.land.is_empty():
_report_beds()
elif Net.is_server():
_maybe_pass_night()
func _report_beds() -> void:
var live := _alive()
Lands.send_hub({"t": "beds", "alive": live.size(), "asleep": live.filter(func(q): return q.in_bed).size()})
## The chest or grave `nid`, if this player may use it right now.
func _container(p: Node3D, nid: int) -> Node3D:
@@ -556,6 +701,10 @@ func _container(p: Node3D, nid: int) -> Node3D:
const Rules = preload("res://scripts/net/rules.gd")
## (Land server) the hub's latest [alive, asleep] in every OTHER land, for
## the "N of M asleep" message and the all-abed check here.
var _hub_beds := [0, 0]
## Tells the player why not, if there's a reason.
func _allowed(why: String) -> bool:
if why.is_empty():

View File

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

1
scripts/net/rules.gd.uid Normal file
View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ func open_server_world(config: RefCounted) -> Node3D:
var id := String(config.v("world")).validate_filename()
if id.is_empty():
id = "server-world"
Saves.current_id = Saves.open_or_create(id, int(config.v("seed")))
Saves.current_id = Saves.open_or_create(id, int(config.v("seed")), String(config.v("worldlet")))
backup("start-up") # (the state it's about to load, before anyone changes it)
_backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0
var w: Node3D = load("res://scenes/world.tscn").instantiate()
@@ -369,6 +369,20 @@ func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void:
func stones_state(d: Dictionary) -> void:
_all("_s2c_stones", [d])
## The cliff tunnel changed (a blow on its face, a stretch dug, a prop set):
## everyone here sees it, and the other lands hear how far it's dug.
func tunnel_state(d: Dictionary) -> void:
_all("_s2c_tunnel", [d])
mark_changed()
if Lands.hub:
Lands.push_tunnel(int(d.get("dug", 0)))
@rpc("authority", "call_remote", "reliable")
func _s2c_tunnel(d: Dictionary) -> void:
if not is_client() or world.tunnel == null:
return
world.tunnel.load_data(d)
@rpc("authority", "call_remote", "reliable")
func _s2c_stones(d: Dictionary) -> void:
if not is_client() or world.stones == null:

View File

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

View File

@@ -103,8 +103,9 @@ func step(dt: float) -> void:
_shelter_left -= dt
if _shelter_left <= 0.0:
_shelter_left = 0.25
roofed = world.build.roofed_at(p)
sheltered = roofed and world.build.enclosure_at(p) >= 0.74
var cave: bool = world.has_method("underground_at") and world.underground_at(p) # (the tunnel: rock all round)
roofed = cave or world.build.roofed_at(p)
sheltered = roofed and (cave or world.build.enclosure_at(p) >= 0.74)
stone_room = sheltered and world.build.stone_walls_at(p) >= 0.5
cozy = sheltered and _rug_near(p)
# Rain soaks you unless there's a roof over your head.

53
scripts/ui/compass.gd Normal file
View File

@@ -0,0 +1,53 @@
extends Control
## The compass (Gitea issue #1): a strip across the top of the screen that
## shows which way you're facing, N/E/S/W and the points between, with a tick
## at the centre. North is the map's top (-Z).
const UITheme = preload("res://scripts/ui/theme.gd")
const WIDTH := 420.0
const SPAN := 180.0 ## degrees shown across the strip
const POINTS := [[0, "N"], [45, "NE"], [90, "E"], [135, "SE"], [180, "S"], [225, "SW"], [270, "W"], [315, "NW"]]
var player: Node3D
func setup(p: Node3D) -> void:
player = p
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_preset(Control.PRESET_CENTER_TOP)
offset_left = -WIDTH * 0.5
offset_right = WIDTH * 0.5
offset_top = 44.0
offset_bottom = 74.0
## Which way `p` faces, in degrees clockwise from north (0 = N, 90 = E).
static func heading(p: Node3D) -> float:
var f: Vector3 = -p.global_basis.z
return fposmod(rad_to_deg(atan2(f.x, -f.z)), 360.0)
func _process(_d: float) -> void:
visible = player != null and is_instance_valid(player) and not bool(player.get("ui_open"))
queue_redraw()
func _draw() -> void:
if player == null or not is_instance_valid(player):
return
var h := heading(player)
var font := get_theme_default_font()
draw_rect(Rect2(Vector2.ZERO, size), Color(0, 0, 0, 0.35))
for deg in range(0, 360, 15):
var off := wrapf(deg - h, -180.0, 180.0)
if absf(off) > SPAN * 0.5:
continue
var x := WIDTH * 0.5 + off / SPAN * WIDTH
var big := deg % 45 == 0
draw_line(Vector2(x, 0), Vector2(x, 6 if not big else 9), Color(1, 1, 1, 0.5 if not big else 0.8), 1.0)
for pt in POINTS:
var off := wrapf(float(pt[0]) - h, -180.0, 180.0)
if absf(off) > SPAN * 0.5 - 6.0:
continue
var x := WIDTH * 0.5 + off / SPAN * WIDTH
var s := 17 if String(pt[1]).length() == 1 else 13
var col := Color(1.0, 0.55, 0.35) if pt[1] == "N" else Color(1, 1, 1, 0.9)
var w := font.get_string_size(pt[1], HORIZONTAL_ALIGNMENT_CENTER, -1, s).x
draw_string(font, Vector2(x - w * 0.5, 25), pt[1], HORIZONTAL_ALIGNMENT_LEFT, -1, s, col)
draw_line(Vector2(WIDTH * 0.5, 0), Vector2(WIDTH * 0.5, 30), Color(1.0, 0.85, 0.4, 0.9), 2.0)

View File

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

1
scripts/ui/guide.gd.uid Normal file
View File

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

View File

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

View File

@@ -21,6 +21,8 @@ var _diet_bars: Dictionary = {}
var _status: Label
var _clock: Label
var _hurt: ColorRect
var compass: Control
var map: Control
## A red flash at the screen edge when something hits you.
func _on_hurt(amount: float, _from: Vector3) -> void:
@@ -196,6 +198,15 @@ func _build_stats(root: Control) -> void:
tips.name = "HintCard"
root.add_child(tips)
tips.setup()
# The compass along the top, and the map on N (Gitea issue #1).
compass = preload("res://scripts/ui/compass.gd").new()
compass.name = "Compass"
root.add_child(compass)
compass.setup(player)
map = preload("res://scripts/ui/map_screen.gd").new()
map.name = "Map"
root.add_child(map)
map.setup(player.get_parent(), player)
func _process(_d: float) -> void:
_fade_chat()
@@ -387,7 +398,9 @@ func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
if is_dead_screen():
return # (respawn first)
if _screen.is_open():
if map != null and map.is_open():
map.close()
elif _screen.is_open():
_screen.close()
elif _menu.is_open():
_menu.back()
@@ -398,6 +411,13 @@ func _input(event: InputEvent) -> void:
func _unhandled_input(event: InputEvent) -> void:
if _menu.is_open():
return
if event.is_action_pressed("map") and not _screen.is_open() and not is_dead_screen() \
and (map.is_open() or not player.ui_open):
map.toggle()
get_viewport().set_input_as_handled()
return
if map.is_open():
return
if event.is_action_pressed("inventory") and not _screen.is_open():
_screen.open(null)
get_viewport().set_input_as_handled()

View File

@@ -0,0 +1 @@
uid://87b73wsdjp7t

209
scripts/ui/map_screen.gd Normal file
View File

@@ -0,0 +1,209 @@
extends Control
## The map (Gitea issue #1), on N. The worldlet you're in, drawn from its
## terrain (water, sand, grass, rock, snow, shaded by slope), but only where
## you've been: the rest is blank until you explore it (world.explored).
## Marked where you've seen them: the den, the barrow, the standing stones,
## the tunnel's seam, the pier and sea cave, your beds and buildings, your
## camp (fires, chests, benches) and your grave. You're a dot; which way
## you're facing is on the compass (and under the map). North is up.
const UITheme = preload("res://scripts/ui/theme.gd")
const Compass = preload("res://scripts/ui/compass.gd")
const RES := 200 ## map image pixels per side
const SIDE := 500.0 ## map size on screen
const MARKS := {
"den": [Color(0.85, 0.30, 0.25), "Wolf den"],
"barrow": [Color(0.75, 0.70, 0.55), "Barrow"],
"stones": [Color(0.80, 0.80, 0.85), "Standing stones"],
"seam": [Color(0.95, 0.60, 0.25), "Cliff tunnel"],
"pier": [Color(0.70, 0.50, 0.30), "Pier"],
"cave": [Color(0.95, 0.60, 0.25), "Sea cave"],
"bed": [Color(0.40, 0.75, 1.00), "Bed"],
"build": [Color(0.62, 0.45, 0.28), "Your buildings"],
"camp": [Color(1.00, 0.80, 0.35), "Camp"],
"grave": [Color(0.85, 0.85, 0.85), "Your things"],
}
var world: Node3D
var player: Node3D
var _tex: TextureRect
var _marks: Control
var _title: Label
var _facing: Label
var _others_key: Label
func setup(w: Node3D, p: Node3D) -> void:
world = w
player = p
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
visible = false
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.6)
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
centre.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(centre)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 6)
centre.add_child(box)
_title = UITheme.label("", 22)
_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_title)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 14)
box.add_child(row)
var frame := Control.new()
frame.custom_minimum_size = Vector2(SIDE, SIDE)
row.add_child(frame)
_tex = TextureRect.new()
_tex.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_tex.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_tex.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
frame.add_child(_tex)
_marks = Control.new()
_marks.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_marks.mouse_filter = Control.MOUSE_FILTER_IGNORE
_marks.draw.connect(_draw_marks)
frame.add_child(_marks)
var legend := VBoxContainer.new()
legend.add_theme_constant_override("separation", 4)
row.add_child(legend)
legend.add_child(UITheme.label("Key", 16, UITheme.TEXT_DIM))
var you := UITheme.label("● You", 14)
you.add_theme_color_override("font_color", Color(1, 1, 1))
legend.add_child(you)
_others_key = UITheme.label("● Other players (co-op)", 14)
_others_key.add_theme_color_override("font_color", Color(0.6, 1.0, 0.65))
legend.add_child(_others_key)
for k in MARKS:
var l := UITheme.label("■ %s" % MARKS[k][1], 14)
l.add_theme_color_override("font_color", MARKS[k][0])
legend.add_child(l)
_facing = UITheme.label("", 15, UITheme.TEXT_DIM)
_facing.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_facing)
var help := UITheme.label("N or Esc to close · north is up · the map fills in as you explore", 13, UITheme.TEXT_DIM)
help.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(help)
func is_open() -> bool:
return visible
func open() -> void:
_tex.texture = ImageTexture.create_from_image(render(world))
_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))
visible = true
player.ui_open = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func close() -> void:
visible = false
player.ui_open = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func toggle() -> void:
if visible:
close()
else:
open()
func _process(_d: float) -> void:
if not visible:
return
var h: float = Compass.heading(player)
var names := ["north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west"]
_facing.text = "Facing %s" % names[int(round(h / 45.0)) % 8]
if world.underground_at(player.global_position):
_facing.text += " · in the tunnel, under the cliff"
_marks.queue_redraw()
## Where (x, z) in the world falls on the map, in its frame's pixels.
func _to_map(p: Vector3) -> Vector2:
var s: float = world.terrain.size
return Vector2((p.x + s * 0.5) / s, (p.z + s * 0.5) / s) * SIDE
func _draw_marks() -> void:
var font := get_theme_default_font()
for m in world.map_marks():
var at := _to_map(m.pos)
var col: Color = MARKS.get(m.kind, [Color.WHITE])[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), Color(0, 0, 0, 0.7), false, 1.0)
if m.kind in ["den", "barrow", "stones", "seam", "pier", "cave"]:
_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
# map never gives anyone away). Green dots with their names.
for o in others(world):
var at2 := _to_map(o.pos)
_marks.draw_circle(at2, 5.5, Color(0, 0, 0, 0.8))
_marks.draw_circle(at2, 4.0, Color(0.45, 0.95, 0.5))
_marks.draw_string(font, at2 + Vector2(8, 5), String(o.name), HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.6, 1.0, 0.65))
# You: a dot (the way you're facing is on the compass).
var p := player.global_position
if world.underground_at(p) and world.tunnel != null:
p = world.tunnel.seam_at # (down the tunnel: shown at its top)
var me := _to_map(p)
_marks.draw_circle(me, 6.5, Color(0, 0, 0, 0.8))
_marks.draw_circle(me, 5.0, Color(1, 1, 1))
# North.
_marks.draw_string(font, Vector2(SIDE - 26, 24), "N", HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(1.0, 0.55, 0.35))
_marks.draw_line(Vector2(SIDE - 20, 30), Vector2(SIDE - 20, 48), Color(1.0, 0.55, 0.35), 2.0)
## The other players to show: [{name, pos}], everyone in this land on a
## server with PvP off; nobody single-player or with PvP on (rivals and
## adversarial), so the map can't be used to hunt anyone down.
static func others(w: Node3D) -> Array:
var out: Array = []
if not w.is_online() or bool(Net.server_info.get("pvp", true)) or w.net_sync == null:
return out
for peer in w.net_sync.avatars:
var a: Node3D = w.net_sync.avatars[peer]
if is_instance_valid(a) and a.visible:
out.append({"name": String(a.info.get("name", "?")), "pos": a.global_position})
return out
## The map image: the terrain, coloured and shaded, blank where unexplored.
static func render(w: Node3D) -> Image:
var t: Node3D = w.terrain
var img := Image.create(RES, RES, false, Image.FORMAT_RGB8)
var s: float = t.size
var step := s / RES
var blank := Color(0.16, 0.14, 0.12)
for iy in range(RES):
for ix in range(RES):
var x := -s * 0.5 + (ix + 0.5) * step
var z := -s * 0.5 + (iy + 0.5) * step
if not w.seen(x, z):
# (a faint paper grain, so blank reads as "not yet", not "nothing")
img.set_pixel(ix, iy, blank.lightened(0.04 * float((ix * 7 + iy * 13) % 3) / 2.0))
continue
img.set_pixel(ix, iy, ground_colour(t, x, z))
return img
static func ground_colour(t: Node3D, x: float, z: float) -> Color:
var h: float = t.sample_height(x, z)
if t.near_stream(x, z, 0.3):
return Color(0.32, 0.56, 0.66) # (the stream, a little wider than it is so it shows)
var surface: float = t.water_level if t.inside_boundary(x, z, -2.0) else t.sea_level
if h < surface:
var deep := clampf((surface - h) / 6.0, 0.0, 1.0)
return Color(0.30, 0.52, 0.62).lerp(Color(0.10, 0.22, 0.36), deep)
var n: Vector3 = t.sample_normal(x, z)
var c: Color
if h > t.snow_height:
c = Color(0.92, 0.93, 0.95)
elif n.y < 0.75:
c = Color(0.48, 0.46, 0.43) # rock
elif h < surface + 0.9:
c = Color(0.80, 0.74, 0.55) # shore
else:
c = Color(0.36, 0.52, 0.27).lerp(Color(0.46, 0.50, 0.32), clampf(h / 20.0, 0.0, 1.0))
# Shading from the north-west, so hills and cliffs stand out.
var light := clampf(0.75 + (-n.x - n.z) * 0.9, 0.45, 1.25)
return Color(c.r * light, c.g * light, c.b * light)

View File

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

View File

@@ -15,6 +15,7 @@ const CONTROLS := [
["E on a bed", "Set spawn point / sleep (night, under a roof)"],
["1 - 8, wheel", "Hotbar (the bag row you're on)"], ["K / M", "Hotbar shows the bag row above / below"],
["Q (shift+Q)", "Drop what you're holding (just one)"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"],
["N", "Map (fills in as you explore; the compass is along the top)"],
["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"],
["Esc", "This menu (game autosaves every 2 min)"],
]
@@ -193,9 +194,18 @@ func open() -> void:
get_tree().paused = not _online # a shared world can't be paused by one player
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
if _online:
# Everyone on the server, in whichever land (J3b), with where they are
# once anyone's somewhere else.
var all: Array = Net.everyone()
var lands := {}
for p in all:
lands[String(p.get("land", ""))] = true
var names: Array[String] = []
for p in Net.players.values():
names.append(String(p.name) + (" (admin)" if p.get("admin", false) else ""))
for p in all:
var where := ""
if lands.size() > 1 and not String(p.get("land", "")).is_empty():
where = " · %s" % preload("res://scripts/world/terrain.gd").worldlet_name(String(p.land))
names.append(String(p.name) + (" (admin)" if p.get("admin", false) else "") + where)
names.sort()
_players_label.text = "%s · %d online\n%s" % [Net.server_info.get("server_name", "Server"), names.size(), ", ".join(names)]

View File

@@ -370,6 +370,7 @@ func _host_page() -> Control:
_host_preset.add_item(n)
_host_preset.custom_minimum_size = Vector2(420, 36)
v.add_child(_host_preset)
_host_worldlet = _worldlet_picker(v)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var c1 := VBoxContainer.new()
@@ -424,7 +425,10 @@ func _fill_host() -> void:
_host_seed.editable = not existing
_host_seed_label.text = "Seed" if not existing else "Seed (kept)"
_host_seed.text = "" if not existing else str(int(c.data.seed))
_host_msg.text = "Your hosted island carries on where you left it." if existing else "A new island will be made."
_show_host_worldlet(existing, meta_path)
_host_msg.text = ("Your hosted island carries on where you left it." if existing else "A new island will be made.") + \
"\nFriends on your network join at your address. Over the internet, forward %s on your router." % \
Lands.ports_text(picked_worldlet(_host_worldlet), int(c.data.port))
_host_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
_host_btn.disabled = false
@@ -439,7 +443,8 @@ func _start_host() -> void:
if name.is_empty():
name = "%s's island" % Net.get_identity().name
_host_btn.disabled = true
Net.host(name, _host_pw.text, int(port_t), PRESET_IDS[maxi(_host_preset.selected, 0)], seed)
Net.host(name, _host_pw.text, int(port_t), PRESET_IDS[maxi(_host_preset.selected, 0)], seed,
picked_worldlet(_host_worldlet))
func _on_host_status(text: String, failed: bool) -> void:
if not _pages["host"].visible:
@@ -591,6 +596,10 @@ func _new_page() -> Control:
_rot_pick.add_item(t[1])
_rot_pick.custom_minimum_size = Vector2(360, 36)
v.add_child(_rot_pick)
# The journey always begins in the Valley, so a new world has no "Where"
# (only with --dev-worldlets, to try the others).
if dev_worldlets():
_worldlet_pick = _worldlet_picker(v)
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10)
h.add_child(_button("Create & Play", _create, 170))
@@ -633,7 +642,8 @@ func _fill_list() -> void:
info.add_child(UITheme.label(w.name, 18))
var played := int(w.get("play_time", 0.0))
var when := Time.get_datetime_string_from_unix_time(int(w.get("last_played", 0)), true)
info.add_child(UITheme.label("seed %d · played %dh %02dm · %s" % [int(w.seed), played / 3600, (played / 60) % 60, when], 13, UITheme.TEXT_DIM))
info.add_child(UITheme.label("%s · seed %d · played %dh %02dm · %s" % [Terrain.worldlet_name(String(w.get("worldlet", "valley"))),
int(w.seed), played / 3600, (played / 60) % 60, when], 13, UITheme.TEXT_DIM))
h.add_child(info)
h.add_child(_button("Play", _play.bind(w.id), 80))
var del := Button.new()
@@ -681,7 +691,85 @@ func _create() -> void:
var seed_text := _seed_edit.text.strip_edges()
var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi())
var rot: String = ROT_CHOICES[maxi(_rot_pick.selected, 0)][0]
_play(Saves.create_world(world_name, seed, {"rot": rot}))
_play(Saves.create_world(world_name, seed, {"rot": rot, "worldlet": picked_worldlet(_worldlet_pick)}))
# --- worldlets (docs/WORLDLETS_DESIGN.md, W1) -----------------------------------------
const Terrain = preload("res://scripts/world/terrain.gd")
var _worldlet_pick: OptionButton
var _host_worldlet: OptionButton
## "Where": the worldlets a new world can start in, with the chosen one's
## description under it. (Only the Valley today: the others are reached on
## the journey, so the list is short and the picker sits still.)
func _worldlet_picker(v: VBoxContainer) -> OptionButton:
var starts: Array = offered_worldlets(dev_worldlets())
v.add_child(UITheme.label("Where", 15, UITheme.TEXT_DIM))
var pick := OptionButton.new()
pick.custom_minimum_size = Vector2(360, 36)
pick.focus_mode = Control.FOCUS_NONE
for w in starts:
pick.add_item(String(w.name))
pick.set_item_metadata(pick.item_count - 1, String(w.id))
pick.disabled = starts.size() < 2
v.add_child(pick)
var about := UITheme.label(String(starts[0].description) if not starts.is_empty() else "", 13, UITheme.TEXT_DIM)
about.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
about.custom_minimum_size = Vector2(360, 0)
v.add_child(about)
pick.item_selected.connect(func(i: int):
about.text = String(Terrain.load_worldlet(String(pick.get_item_metadata(i))).get("description", "")))
return pick
## Started with `-- --dev-worldlets`: every worldlet can be picked, for
## trying one before the journey reaches it.
static func dev_worldlets() -> bool:
return "--dev-worldlets" in OS.get_cmdline_user_args()
## The worldlets offered for a new world or island: the starts, and (`dev`)
## the rest too, marked for testing.
static func offered_worldlets(dev: bool) -> Array:
var out: Array = Terrain.starting_worldlets()
if dev:
for w in Terrain.worldlets():
if not w.start:
var t: Dictionary = w.duplicate()
t.name = "%s (testing)" % w.name
out.append(t)
return out
## The Host page's "Where": a hosted island keeps its worldlet, like its
## seed, so once it exists the picker shows that one and stays put.
func _show_host_worldlet(existing: bool, meta_path: String) -> void:
var id := "valley"
if existing:
var meta: Variant = JSON.parse_string(FileAccess.get_file_as_string(meta_path))
if meta is Dictionary:
id = String(meta.get("worldlet", "valley"))
var i := select_worldlet(_host_worldlet, id)
if i >= 0:
_host_worldlet.item_selected.emit(i)
_host_worldlet.disabled = existing or _host_worldlet.item_count < 2
## Selects worldlet `id` in `pick`, adding it if it isn't a starting one
## (an island made somewhere else). Returns its index, or -1.
static func select_worldlet(pick: OptionButton, id: String) -> int:
for i in pick.item_count:
if String(pick.get_item_metadata(i)) == id:
pick.select(i)
return i
if Terrain.load_worldlet(id).is_empty():
return -1
pick.add_item(Terrain.worldlet_name(id))
pick.set_item_metadata(pick.item_count - 1, id)
pick.select(pick.item_count - 1)
return pick.item_count - 1
## The id of the worldlet chosen in `pick` (the Valley if nothing is).
static func picked_worldlet(pick: OptionButton) -> String:
if pick == null or pick.item_count == 0 or pick.selected < 0:
return "valley"
return String(pick.get_item_metadata(pick.selected))
## The tutorial (docs/TUTORIAL_DESIGN.md): its own world on the original
## island (seed 1337), made the first time and carried on after that.

View File

@@ -102,7 +102,7 @@ func _search(max_spread: float, from_spawn: float, from_den: float) -> Vector3:
hs.append(terrain.sample_height(c.x + cos(b) * rad, c.y + sin(b) * rad))
var h0: float = terrain.sample_height(c.x, c.y)
hs.append(h0)
if hs.min() < terrain.water_level + 1.0:
if hs.min() < terrain.water_level + 1.0 or terrain.near_stream(c.x, c.y, MOUND_R + 4.0):
continue
var spread: float = hs.max() - hs.min()
if spread > max_spread:

View File

@@ -0,0 +1 @@
uid://5vs0iggnxpuc

View File

@@ -15,6 +15,8 @@ const DAY_BIAS := 0.2
var day := 1
var time := 8.0 / 24.0
var frozen := false ## title-screen backdrop: hold a fixed time
var underground := 0.0 ## 0..1: how far into the tunnel you are (no daylight; world.gd sets it)
const AERIAL := 0.55 ## the fog's sky tint with distance (scenes/default_env.tres)
var sun: DirectionalLight3D
var moon: DirectionalLight3D
@@ -123,6 +125,26 @@ func apply(force: bool) -> void:
env.ambient_light_energy = lerpf(0.3, 1.0, daylight) * lerpf(1.0, 0.85, overcast) + w_flash * 3.0
env.fog_light_energy = lerpf(0.25, 1.0, daylight) * lerpf(1.0, 0.7, overcast) + w_flash * 2.0
env.volumetric_fog_density = lerpf(0.0025, 0.0045, daylight) * lerpf(1.0, 2.0, overcast)
if underground > 0.0:
# In the tunnel (J1): no sun, moon or sky, only the lights you bring.
var u := underground
sun.light_energy *= 1.0 - u
sun.visible = sun.light_energy > 0.01
sun.shadow_enabled = sun.visible
moon.light_energy *= 1.0 - u
moon.visible = moon.light_energy > 0.01
env.ambient_light_energy = lerpf(env.ambient_light_energy, 0.015, u)
env.ambient_light_sky_contribution = 1.0 - u
env.fog_light_energy = lerpf(env.fog_light_energy, 0.0, u)
env.volumetric_fog_density *= 1.0 - u
# (Sky reflections and the fog's sky-coloured distance would light the
# rock blue-grey all the way down: off.)
env.fog_aerial_perspective = lerpf(AERIAL, 0.0, u)
env.reflected_light_source = Environment.REFLECTION_SOURCE_DISABLED if u > 0.5 else Environment.REFLECTION_SOURCE_SKY
else:
env.ambient_light_sky_contribution = 1.0
env.fog_aerial_perspective = AERIAL
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
env.fog_density = BASE_FOG * w_fog
_sky_timer -= get_process_delta_time()

View File

@@ -163,7 +163,7 @@ func _on_new_day(day: int) -> void:
if Weather.hard_winter(day + 1) and not Weather.hard_winter(day):
Actions.tell_all("The air has a bite to it. A hard winter is coming tomorrow: three bitter days. Stock up on food and firewood.")
elif Weather.hard_winter(day) and not Weather.hard_winter(day - 1):
Actions.tell_all("A hard winter has set in. The lake's edge has frozen, and the animals are holed up.")
Actions.tell_all("A hard winter has set in. The %s's edge has frozen, and the animals are holed up." % ("lake" if world.worldlet_id() == "valley" else "pond"))
elif Weather.hard_winter(day - 1) and not Weather.hard_winter(day):
Actions.tell_all("The hard winter is over.")

View File

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

114
scripts/world/pier.gd Normal file
View File

@@ -0,0 +1,114 @@
extends Node3D
## The Cove's pier (W3, docs/WORLDLETS_DESIGN.md §5): a wooden walkway from
## the sand straight out over the bay, so a cast from its end reaches deep
## sea water (mackerel, and cod further out). Part of the world, like the
## barrow: worked out from the terrain alone, so every player and the server
## get the same one, and it's never saved, built or dismantled.
##
## It starts where the beach has risen to deck height (so you walk straight
## on) and runs `LENGTH_OUT` metres past the waterline.
const WIDTH := 2.4
const DECK_ABOVE := 1.0 ## deck height over the water
const LENGTH_OUT := 24.0 ## how far past the waterline it reaches
const POST_EVERY := 3.0
const WOOD := Color(0.42, 0.30, 0.19)
const POST := Color(0.30, 0.22, 0.15)
var start := Vector3.ZERO ## the landward end, on the sand
var end := Vector3.ZERO ## the seaward end (fish from here)
var dir := Vector3.ZERO ## out to sea along the deck
## Lays the pier off the terrain's beach. Returns false (and adds nothing)
## when the worldlet has no beach.
func place(terrain: Node3D) -> bool:
if not terrain._has_beach or not terrain.worldlet.get("places", []).any(func(p): return p is Dictionary and p.get("type", "") == "pier"):
return false
var deck_y: float = terrain.water_level + DECK_ABOVE
var out2: Vector2 = terrain.shore_point(LENGTH_OUT)
var root2: Vector2 = terrain.shore_point(-2.0)
var d2 := (out2 - root2).normalized()
# Walk inland from the waterline to where the sand is at deck height.
var s2 := root2
for _i in range(40):
if terrain.sample_height(s2.x, s2.y) >= deck_y - 0.15:
break
s2 -= d2 * 0.5
start = Vector3(s2.x, deck_y, s2.y)
end = Vector3(out2.x, deck_y, out2.y)
dir = (end - start).normalized()
_build(terrain)
return true
## Whether (x, z) is under or right beside the pier (scenery keeps clear).
func covers(x: float, z: float, margin: float = 1.0) -> bool:
var a := Vector2(start.x, start.z)
var b := Vector2(end.x, end.z)
var p := Vector2(x, z)
var t := clampf((p - a).dot(b - a) / maxf((b - a).length_squared(), 0.001), 0.0, 1.0)
return p.distance_to(a.lerp(b, t)) < WIDTH * 0.5 + margin
func _build(terrain: Node3D) -> void:
var length := start.distance_to(end)
var yaw := atan2(dir.x, dir.z)
var basis := Basis(Vector3.UP, yaw)
var mid := (start + end) * 0.5
# The deck: planks across it, with a little gap and wobble between them.
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var n := int(length / 0.32)
var rng := RandomNumberGenerator.new()
rng.seed = 7171
for i in range(n):
var z := -length * 0.5 + (i + 0.5) * length / n
var h := 0.08 + rng.randf_range(-0.01, 0.01)
_box(st, Vector3(rng.randf_range(-0.04, 0.04), -h * 0.5, z), Vector3(WIDTH, h, length / n - 0.03),
WOOD.darkened(rng.randf_range(0.0, 0.18)))
# Posts down to the bed, in pairs, and a rail beam along each side.
var k := 0.0
while k <= length + 0.01:
var along := -length * 0.5 + k
for side in [-1.0, 1.0]:
var local := Vector3(side * (WIDTH * 0.5 - 0.1), 0.0, along)
var world_p: Vector3 = mid + basis * local
var bed: float = terrain.sample_height(world_p.x, world_p.z)
var tall := start.y - bed + 0.3
if tall > 0.2:
_box(st, local + Vector3(0, -tall * 0.5 - 0.05, 0), Vector3(0.22, tall, 0.22), POST)
k += POST_EVERY
for side in [-1.0, 1.0]:
_box(st, Vector3(side * (WIDTH * 0.5 - 0.06), -0.16, 0.0), Vector3(0.12, 0.14, length), POST)
st.generate_normals()
var mi := MeshInstance3D.new()
mi.name = "Deck"
mi.mesh = st.commit()
var mat := StandardMaterial3D.new()
mat.vertex_color_use_as_albedo = true
mat.roughness = 0.9
mi.material_override = mat
add_child(mi)
# Walkable deck (on the ground layer, like the terrain).
var body := StaticBody3D.new()
body.name = "DeckBody"
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(WIDTH, 0.12, length)
cs.shape = box
cs.position = Vector3(0, -0.06, 0)
body.add_child(cs)
add_child(body)
transform = Transform3D(basis, mid)
add_to_group("pier")
static func _box(st: SurfaceTool, c: Vector3, s: Vector3, col: Color) -> void:
var h := s * 0.5
var v := [
c + Vector3(-h.x, -h.y, -h.z), c + Vector3(h.x, -h.y, -h.z), c + Vector3(h.x, h.y, -h.z), c + Vector3(-h.x, h.y, -h.z),
c + Vector3(-h.x, -h.y, h.z), c + Vector3(h.x, -h.y, h.z), c + Vector3(h.x, h.y, h.z), c + Vector3(-h.x, h.y, h.z),
]
var faces := [[0, 3, 2, 1], [4, 5, 6, 7], [0, 4, 7, 3], [1, 2, 6, 5], [3, 7, 6, 2], [0, 1, 5, 4]]
st.set_color(col)
for f in faces:
# (listed anticlockwise from outside; Godot's front faces are clockwise)
st.add_vertex(v[f[0]]); st.add_vertex(v[f[2]]); st.add_vertex(v[f[1]])
st.add_vertex(v[f[0]]); st.add_vertex(v[f[3]]); st.add_vertex(v[f[2]])

View File

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

View File

@@ -31,6 +31,8 @@ const FLOWER_COLORS := [
@export var grass_tiles_per_update: int = 6
var terrain: Node3D
var pier: Node3D ## the Cove's pier, kept clear of scenery (set by the world)
var keep_clear: Array = [] ## [[Vector3, radius]]: the tunnel's seam, the sea cave
var _player: Node3D
var _spawn := Vector3.ZERO
var _forest_noise := FastNoiseLite.new()
@@ -417,7 +419,15 @@ func _scatter_trees() -> void:
## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so
## the view out to sea is clear and you can walk right up to the edge.
## (Also kept clear: the Cove's pier and the sand by its foot.)
func _on_clifftop(x: float, z: float) -> bool:
if pier != null and pier.covers(x, z, 2.0):
return true
if terrain.near_stream(x, z, 1.2):
return true # (the stream's bed and banks)
for c in keep_clear:
if Vector2(x - c[0].x, z - c[0].z).length() < float(c[1]):
return true
return terrain.sea_weight(x, z) > 0.5 and not terrain.inside_boundary(x, z, 10.0) \
and terrain.inside_boundary(x, z, -8.0)
@@ -486,7 +496,7 @@ func _scatter_rocks() -> void:
# Sea stacks: tall pillars standing off the cliff (no collision, unreachable).
var placed := 0
var tries := 0
while placed < 9 and tries < 200:
while placed < 9 and tries < 200 and terrain._has_sea: # (only off a sea cliff)
tries += 1
var ang: float = terrain.sea_angle_deg + rng.randf_range(-1.0, 1.0) * (terrain.sea_half_width_deg + 8.0)
var p: Vector2 = terrain.coast_point(ang, rng.randf_range(10.0, 48.0))
@@ -607,7 +617,7 @@ func _place_logs() -> void:
continue
var h: float = terrain.sample_height(x, z)
var n: Vector3 = terrain.sample_normal(x, z)
if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0:
if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0 or terrain.near_stream(x, z, 1.5):
continue
var mi := MeshInstance3D.new()
var shape: Shape3D
@@ -672,7 +682,7 @@ func _scatter_pickups() -> void:
continue
var h: float = terrain.sample_height(x, z)
var n: Vector3 = terrain.sample_normal(x, z)
if h < terrain.water_level + 0.3 or n.y < 0.8:
if h < terrain.water_level + 0.3 or n.y < 0.8 or (pier != null and pier.covers(x, z, 1.0)) or terrain.near_stream(x, z, 0.3):
continue
var chance := 1.0
match id:
@@ -734,7 +744,7 @@ func _scatter_berry_bushes(root: Node3D) -> void:
continue
var h: float = terrain.sample_height(x, z)
var n: Vector3 = terrain.sample_normal(x, z)
if h < terrain.water_level + 0.6 or n.y < 0.85:
if h < terrain.water_level + 0.6 or n.y < 0.85 or terrain.near_stream(x, z, 1.0):
continue
var f := _forest(x, z)
var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5
@@ -816,7 +826,7 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
if not terrain.in_bounds(x, z):
continue
var h: float = terrain.sample_height(x, z)
if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0:
if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0 or terrain.near_stream(x, z, -0.2):
continue
var n: Vector3 = terrain.sample_normal(x, z)
if n.y < 0.72:

View File

@@ -0,0 +1,39 @@
extends RefCounted
## Boulders for hand-placed rockwork (the cliff tunnel's seam and its faces,
## the Cove's sea cave): the same low-poly rocks as the scenery and the wolf
## den (ProcMesh.rock, rock.gdshader: moss on top, snow up high), each with a
## convex collider that matches it.
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
## The scenery's rock material, for a world's terrain.
static func material(terrain: Node3D) -> ShaderMaterial:
var m := ShaderMaterial.new()
m.shader = preload("res://shaders/rock.gdshader")
m.set_shader_parameter("noise_tex", terrain.noise_tex)
m.set_shader_parameter("snow_height", terrain.snow_height)
return m
## One boulder under `parent` at local `at`, `radii` across (a rock mesh is
## about 1 m round before scaling), turned `turn` radians. Its collider goes
## on `body` (which must share `parent`'s transform), unless `body` is null.
static func add(parent: Node3D, body: CollisionObject3D, seed: int, at: Vector3, radii: Vector3, turn: float,
mat: Material, tall: bool = false) -> MeshInstance3D:
var mesh := ProcMesh.rock(seed, tall)
var b := Basis(Vector3.UP, turn).scaled(radii)
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.material_override = mat
mi.transform = Transform3D(b, at)
parent.add_child(mi)
if body != null:
var hull := mesh.create_convex_shape(true, true) as ConvexPolygonShape3D
var pts := PackedVector3Array()
for p: Vector3 in hull.points:
pts.append(b * p)
hull.points = pts
var cs := CollisionShape3D.new()
cs.shape = hull
cs.position = at
body.add_child(cs)
return mi

View File

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

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