Compare commits
25 Commits
f4df2bc99d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b611a6d34 | ||
|
|
27040d6805 | ||
|
|
2004fe193c | ||
|
|
9709d2c424 | ||
|
|
5bcfa23710 | ||
|
|
74bd8f5898 | ||
|
|
419c71e638 | ||
|
|
ec8caa9ef4 | ||
|
|
1bf33935e0 | ||
|
|
62b3d6c47f | ||
|
|
f9b138b304 | ||
|
|
86ebe07899 | ||
|
|
85bbed249b | ||
|
|
6544862885 | ||
|
|
109fff22d0 | ||
|
|
b938e045cb | ||
|
|
ad47cd53da | ||
|
|
0209c6149a | ||
|
|
b86fb879d2 | ||
|
|
2b7e52cd82 | ||
|
|
980ee53579 | ||
|
|
793b214786 | ||
|
|
9b88a10609 | ||
|
|
68e6d814d9 | ||
|
|
db4a659236 |
19
README.md
19
README.md
@@ -16,7 +16,7 @@ addon SDK with guard rails.
|
|||||||
## Status
|
## Status
|
||||||
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
|
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
|
||||||
|
|
||||||
**Emberwild 0.3.0** is released (download page and player guide:
|
**Emberwild 0.6.0** is released (download page and player guide:
|
||||||
https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
|
https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
|
||||||
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
|
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
|
||||||
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
|
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
|
||||||
@@ -26,8 +26,21 @@ https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
|
|||||||
- T1, T2, T4 (`docs/TUTORIAL_DESIGN.md`): the tutorial with Old Tam (spoken),
|
- T1, T2, T4 (`docs/TUTORIAL_DESIGN.md`): the tutorial with Old Tam (spoken),
|
||||||
and his tips in normal worlds.
|
and his tips in normal worlds.
|
||||||
- M4 so far: packaged builds, the update check, music, ambience, sound effects
|
- M4 so far: packaged builds, the update check, music, ambience, sound effects
|
||||||
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages.
|
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages,
|
||||||
Next: the worldlets and the journey between them (`docs/WORLDLETS_DESIGN.md`).
|
the map (N) and compass.
|
||||||
|
- W0-W3, J1, J3 (`docs/WORLDLETS_DESIGN.md`): worldlets as data, the Cove
|
||||||
|
(beach, pier, sea fishing, a stream), and the cliff tunnel between them,
|
||||||
|
with a save file per worldlet; on a server, a server per land (the Valley's
|
||||||
|
starts the Cove's on the next UDP port), with chat, the player list,
|
||||||
|
admin commands and sleep across lands, and one clock everywhere.
|
||||||
|
- 0.6.0, the Cove's own (`docs/WORLDLETS_DESIGN.md` §5): the wreck, a
|
||||||
|
treasure map and a dig for each player, the drowned crew, crabs and gulls,
|
||||||
|
and a rowing boat to build and moor at the pier (sailing comes with the
|
||||||
|
Highlands). Report a bug in the pause menu; `docs/PLAYTEST.md` for testers.
|
||||||
|
Next: after the playtest, J2 (each tunnel's own gate and supplies), then the
|
||||||
|
Highlands (by sail).
|
||||||
|
Running from the source, `godot --path . -- --dev-worldlets` lets New World
|
||||||
|
start in any worldlet, to try one directly.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- **Godot 4.3+** (standard build; no C# needed — this project uses GDScript).
|
- **Godot 4.3+** (standard build; no C# needed — this project uses GDScript).
|
||||||
|
|||||||
BIN
assets/audio/sfx/creatures/crab_click_0.ogg
Normal file
BIN
assets/audio/sfx/creatures/crab_click_0.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/creatures/crab_click_1.ogg
Normal file
BIN
assets/audio/sfx/creatures/crab_click_1.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/creatures/gull_cry_0.ogg
Normal file
BIN
assets/audio/sfx/creatures/gull_cry_0.ogg
Normal file
Binary file not shown.
BIN
assets/audio/sfx/creatures/gull_cry_1.ogg
Normal file
BIN
assets/audio/sfx/creatures/gull_cry_1.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_cove.ogg
Normal file
BIN
assets/audio/voice/hint_cove.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_crab.ogg
Normal file
BIN
assets/audio/voice/hint_crab.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_seam.ogg
Normal file
BIN
assets/audio/voice/hint_seam.ogg
Normal file
Binary file not shown.
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"hint_cold": "91c5088e453fb0e6",
|
"hint_cold": "91c5088e453fb0e6",
|
||||||
|
"hint_cove": "f7e5c0283b31e1cc",
|
||||||
|
"hint_crab": "283542496ecd180c",
|
||||||
"hint_died": "e865f8a63622b3c3",
|
"hint_died": "e865f8a63622b3c3",
|
||||||
"hint_diet": "3178b319c44e9fa0",
|
"hint_diet": "3178b319c44e9fa0",
|
||||||
"hint_freezing": "48b1e505119a0fc9",
|
"hint_freezing": "48b1e505119a0fc9",
|
||||||
@@ -8,6 +10,7 @@
|
|||||||
"hint_hungry": "ad76f22cb8e034a0",
|
"hint_hungry": "ad76f22cb8e034a0",
|
||||||
"hint_night": "2a07d9c0bfae4bf9",
|
"hint_night": "2a07d9c0bfae4bf9",
|
||||||
"hint_rotten": "98b69de03dbee089",
|
"hint_rotten": "98b69de03dbee089",
|
||||||
|
"hint_seam": "b353f46816d329b7",
|
||||||
"hint_sheltered": "ba6d6ff8bc4a1c80",
|
"hint_sheltered": "ba6d6ff8bc4a1c80",
|
||||||
"hint_sick": "ec07963c46638ffc",
|
"hint_sick": "ec07963c46638ffc",
|
||||||
"hint_snow": "8f0ac7d093f73840",
|
"hint_snow": "8f0ac7d093f73840",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Fishing by water type. world.water_at() says which type a spot is; a type with no entry has no fish. Weights are relative. 'dawn_dusk' replaces the weight at dawn (05:00-08:30) and dusk (17:00-20:30). Bite waits are seconds, scaled by the modifiers.",
|
"_comment": "Fishing by water type. world.water_at() says which type a spot is; a type with no entry has no fish. Weights are relative. 'dawn_dusk' replaces the weight at dawn (05:00-08:30) and dusk (17:00-20:30). Bite waits are seconds, scaled by the modifiers. A fish with its own 'min_depth' only bites in water at least that deep (cod: cast well out from the pier).",
|
||||||
"modifiers": {
|
"modifiers": {
|
||||||
"dawn_dusk_wait": 0.6,
|
"dawn_dusk_wait": 0.6,
|
||||||
"rain_wait": 0.8,
|
"rain_wait": 0.8,
|
||||||
@@ -24,6 +24,40 @@
|
|||||||
"dawn_dusk": 40
|
"dawn_dusk": 40
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"sea": {
|
||||||
|
"min_depth": 1.0,
|
||||||
|
"bite_wait": [
|
||||||
|
6.0,
|
||||||
|
16.0
|
||||||
|
],
|
||||||
|
"fish": [
|
||||||
|
{
|
||||||
|
"id": "mackerel",
|
||||||
|
"weight": 75,
|
||||||
|
"dawn_dusk": 65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cod",
|
||||||
|
"weight": 25,
|
||||||
|
"dawn_dusk": 35,
|
||||||
|
"min_depth": 2.2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"min_depth": 0.3,
|
||||||
|
"bite_wait": [
|
||||||
|
8.0,
|
||||||
|
18.0
|
||||||
|
],
|
||||||
|
"fish": [
|
||||||
|
{
|
||||||
|
"id": "trout",
|
||||||
|
"weight": 100,
|
||||||
|
"dawn_dusk": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"guide": "Old Tam",
|
"guide": "Old Tam",
|
||||||
"_doc": "T4 (docs/TUTORIAL_DESIGN.md §5): one-off tips in normal worlds, each shown once per player (user://hints.json), in list order when several are due. 'when' is kind:value, with | for alternatives: status (a survival status, by prefix), weather (a state), has (an item in your bag), event (howl, died), or a plain check (night, stamina_low, wolf_moon, hard_winter). 'hold' is how long it must stay true first (seconds, default 1).",
|
"_doc": "T4 (docs/TUTORIAL_DESIGN.md §5): one-off tips in normal worlds, each shown once per player (user://hints.json), in list order when several are due. 'when' is kind:value, with | for alternatives: status (a survival status, by prefix), weather (a state), has (an item in your bag), event (howl, died), worldlet (where you are on the journey), or a plain check (night, stamina_low, wolf_moon, hard_winter, seam: holding the antler pickaxe with the cliff tunnel's seam still to dig). 'hold' is how long it must stay true first (seconds, default 1).",
|
||||||
"hints": [
|
"hints": [
|
||||||
{"id": "died", "when": "event:died", "title": "Back on your feet",
|
{"id": "died", "when": "event:died", "title": "Back on your feet",
|
||||||
"text": "Dying's not the end out here. Whatever you carried is waiting where you fell, so go back for it before anything else. Sleep in a bed once and that's where you'll wake."},
|
"text": "Dying's not the end out here. Whatever you carried is waiting where you fell, so go back for it before anything else. Sleep in a bed once and that's where you'll wake."},
|
||||||
@@ -33,6 +33,12 @@
|
|||||||
{"id": "stamina", "when": "stamina_low", "title": "Out of breath",
|
{"id": "stamina", "when": "stamina_low", "title": "Out of breath",
|
||||||
"text": "You're spent. Stop swinging and running a moment and your stamina comes back. Rested by a fire, it comes back faster."},
|
"text": "You're spent. Stop swinging and running a moment and your stamina comes back. Rested by a fire, it comes back faster."},
|
||||||
{"id": "sheltered", "when": "status:Sheltered", "hold": 3, "title": "A proper shelter",
|
{"id": "sheltered", "when": "status:Sheltered", "hold": 3, "title": "A proper shelter",
|
||||||
"text": "Roof and walls: the weather can't touch you here. Sit by a fire in here a little while and you'll be Rested."}
|
"text": "Roof and walls: the weather can't touch you here. Sit by a fire in here a little while and you'll be Rested."},
|
||||||
|
{"id": "seam", "when": "seam", "hold": 3, "title": "The cracked seam",
|
||||||
|
"text": "That antler pick bites rock iron only scratches. Out on the clifftop, near the edge, there's a seam of cracked rock, and a way down to the sea behind it if you dig. Take wood to prop it as you go, and lights: it's black as pitch in there."},
|
||||||
|
{"id": "cove", "when": "worldlet:cove", "hold": 3, "title": "The Cove",
|
||||||
|
"text": "You made it down. Sand, salt and sea fish. Fish off the end of the pier: mackerel bite close in, cod further out where it's deep. The sea cave takes you back up the tunnel, and home's waiting at the top. Mind the gulls: they'll have any food you leave on the sand, and fish off a smoking rack. And at dusk the crabs come up the beach: give them room, or they pinch."},
|
||||||
|
{"id": "crab", "when": "has:crab_meat", "hold": 2, "title": "Crab",
|
||||||
|
"text": "Two lots of crab meat cook into a good crab supper at a campfire. Raw, it's as risky as raw fish."}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
212
data/items.json
212
data/items.json
@@ -543,6 +543,105 @@
|
|||||||
},
|
},
|
||||||
"keeps": 4
|
"keeps": 4
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "mackerel",
|
||||||
|
"name": "Mackerel",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "A quick, striped sea fish, caught off the pier. Grill it at a campfire. (Fish)",
|
||||||
|
"mesh": "mackerel",
|
||||||
|
"food": {
|
||||||
|
"hunger": 5,
|
||||||
|
"health": -2,
|
||||||
|
"diet": {
|
||||||
|
"fish": 10
|
||||||
|
},
|
||||||
|
"risk": 0.2
|
||||||
|
},
|
||||||
|
"keeps": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cod",
|
||||||
|
"name": "Cod",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "A big sea fish from deeper water: cast well out. (Fish)",
|
||||||
|
"mesh": "cod",
|
||||||
|
"food": {
|
||||||
|
"hunger": 8,
|
||||||
|
"health": -2,
|
||||||
|
"diet": {
|
||||||
|
"fish": 16
|
||||||
|
},
|
||||||
|
"risk": 0.2
|
||||||
|
},
|
||||||
|
"keeps": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grilled_mackerel",
|
||||||
|
"name": "Grilled Mackerel",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Oily and good. (Fish)",
|
||||||
|
"mesh": "grilled_mackerel",
|
||||||
|
"food": {
|
||||||
|
"hunger": 22,
|
||||||
|
"health": 8,
|
||||||
|
"diet": {
|
||||||
|
"fish": 36
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"keeps": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "crab_meat",
|
||||||
|
"name": "Crab Meat",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Sweet white meat from a crab off the Cove's beach. Cook it at a campfire. (Fish)",
|
||||||
|
"mesh": "crab_meat",
|
||||||
|
"food": {
|
||||||
|
"hunger": 4,
|
||||||
|
"health": -2,
|
||||||
|
"diet": {
|
||||||
|
"fish": 8
|
||||||
|
},
|
||||||
|
"risk": 0.2
|
||||||
|
},
|
||||||
|
"keeps": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cooked_crab",
|
||||||
|
"name": "Cooked Crab",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Boiled pink in its shell. Worth the pinches. (Fish)",
|
||||||
|
"mesh": "cooked_crab",
|
||||||
|
"food": {
|
||||||
|
"hunger": 20,
|
||||||
|
"health": 10,
|
||||||
|
"diet": {
|
||||||
|
"fish": 32
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"keeps": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grilled_cod",
|
||||||
|
"name": "Grilled Cod",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Thick white flakes: a proper meal. (Fish)",
|
||||||
|
"mesh": "grilled_cod",
|
||||||
|
"food": {
|
||||||
|
"hunger": 32,
|
||||||
|
"health": 12,
|
||||||
|
"diet": {
|
||||||
|
"fish": 52
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"keeps": 4
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "iron_nugget",
|
"id": "iron_nugget",
|
||||||
"name": "Iron Nugget",
|
"name": "Iron Nugget",
|
||||||
@@ -1007,6 +1106,119 @@
|
|||||||
"description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.",
|
"description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.",
|
||||||
"mesh": "salt"
|
"mesh": "salt"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "driftwood",
|
||||||
|
"name": "Driftwood",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Sea-bleached wood washed up on the Cove's beach. It burns well, and it's light and tough: what a rowing boat is built from.",
|
||||||
|
"mesh": "driftwood",
|
||||||
|
"fuel": 48.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seagrass",
|
||||||
|
"name": "Seagrass",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "Long, tough ribbons of seagrass washed up in the Cove's shallows. Twisted together it makes a rope that won't rot in salt water.",
|
||||||
|
"mesh": "seagrass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rope",
|
||||||
|
"name": "Seagrass Rope",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Twisted from seagrass: for rigging and lashing a boat.",
|
||||||
|
"mesh": "rope"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pine_resin",
|
||||||
|
"name": "Pine Resin",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "Sticky amber resin from a felled pine. Boiled with salt it makes pitch.",
|
||||||
|
"mesh": "pine_resin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pitch",
|
||||||
|
"name": "Pitch",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Black, tarry pitch from pine resin and salt, boiled over a fire. It keeps the sea out of a boat's seams.",
|
||||||
|
"mesh": "pitch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rowing_boat",
|
||||||
|
"name": "Rowing Boat",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A small driftwood boat, seams pitched tight. Carry it to the mooring post at the end of the Cove's pier to tie it up. Where it'll take you, a ship's compass will tell.",
|
||||||
|
"mesh": "rowing_boat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "treasure_map",
|
||||||
|
"name": "Treasure Map",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A torn chart from a wrecked ship's sea chest, with an X inked in the hills above the Cove. Right-click to read it: the X goes on your map (N).",
|
||||||
|
"mesh": "treasure_map",
|
||||||
|
"read": {
|
||||||
|
"marks": "treasure"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ship_compass",
|
||||||
|
"name": "Ship's Compass",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A brass sea compass from the pirate chest, its needle still true. No boat leaves for the Highlands without one.",
|
||||||
|
"mesh": "ship_compass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spyglass",
|
||||||
|
"name": "Spyglass",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A captain's brass telescope. Hold right-click to look through it.",
|
||||||
|
"mesh": "spyglass",
|
||||||
|
"zoom": 4.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pirate_gold",
|
||||||
|
"name": "Pirate Gold",
|
||||||
|
"category": "placeable",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Old gold coins from the pirate chest. Nothing on the island takes coin (yet), but a heap of it looks fine in a hut.",
|
||||||
|
"mesh": "pirate_gold",
|
||||||
|
"place": {
|
||||||
|
"piece": "trophy",
|
||||||
|
"footprint": [
|
||||||
|
0.6,
|
||||||
|
0.6
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cutlass",
|
||||||
|
"name": "Cutlass",
|
||||||
|
"category": "weapon",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A pirate's curved blade: quick, and it hits harder than the iron spear.",
|
||||||
|
"mesh": "cutlass",
|
||||||
|
"weapon": {
|
||||||
|
"damage": 30,
|
||||||
|
"reach": 2.3,
|
||||||
|
"stamina": 8,
|
||||||
|
"cooldown": 0.45
|
||||||
|
},
|
||||||
|
"durability": 220,
|
||||||
|
"repair": {
|
||||||
|
"inputs": {
|
||||||
|
"iron_bar": 1
|
||||||
|
},
|
||||||
|
"station": "forge"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "smoking_rack",
|
"id": "smoking_rack",
|
||||||
"name": "Smoking Rack",
|
"name": "Smoking Rack",
|
||||||
|
|||||||
@@ -249,6 +249,36 @@
|
|||||||
"trout": 1
|
"trout": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "grilled_mackerel",
|
||||||
|
"output": "grilled_mackerel",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"mackerel": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cooked_crab",
|
||||||
|
"output": "cooked_crab",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"crab_meat": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grilled_cod",
|
||||||
|
"output": "grilled_cod",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"cod": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "charcoal_kiln",
|
"id": "charcoal_kiln",
|
||||||
"output": "charcoal_kiln",
|
"output": "charcoal_kiln",
|
||||||
@@ -578,6 +608,28 @@
|
|||||||
"salt": 1
|
"salt": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "salted_mackerel",
|
||||||
|
"output": "salted_fish",
|
||||||
|
"count": 2,
|
||||||
|
"station": "",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"mackerel": 2,
|
||||||
|
"salt": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "salted_cod",
|
||||||
|
"output": "salted_fish",
|
||||||
|
"count": 2,
|
||||||
|
"station": "",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"cod": 1,
|
||||||
|
"salt": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "antler_pickaxe",
|
"id": "antler_pickaxe",
|
||||||
"output": "antler_pickaxe",
|
"output": "antler_pickaxe",
|
||||||
@@ -681,6 +733,40 @@
|
|||||||
"stick": 2,
|
"stick": 2,
|
||||||
"fiber": 2
|
"fiber": 2
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rope",
|
||||||
|
"output": "rope",
|
||||||
|
"count": 1,
|
||||||
|
"station": "",
|
||||||
|
"category": "tools",
|
||||||
|
"inputs": {
|
||||||
|
"seagrass": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pitch",
|
||||||
|
"output": "pitch",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"category": "building",
|
||||||
|
"inputs": {
|
||||||
|
"pine_resin": 2,
|
||||||
|
"salt": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rowing_boat",
|
||||||
|
"output": "rowing_boat",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"category": "building",
|
||||||
|
"inputs": {
|
||||||
|
"driftwood": 12,
|
||||||
|
"rope": 4,
|
||||||
|
"pitch": 3,
|
||||||
|
"nails": 6
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
24
data/worldlets/cove.json
Normal file
24
data/worldlets/cove.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"_doc": "The Cove (docs/WORLDLETS_DESIGN.md §5, W3): the second worldlet, reached from the Valley down the cliff tunnel (J1). Low hills run down to a wide sandy bay with a pier (scripts/world/pier.gd) for sea fishing; a small pond inland; a stream from a spring under the mountains down to the bay (a 'stream' block: its points, spring first, in world metres); mountains behind. Not a start: you get here on the journey.",
|
||||||
|
"id": "cove",
|
||||||
|
"name": "Cove",
|
||||||
|
"description": "Low hills running down to a sheltered sandy bay, with a pier out over the water for sea fishing.",
|
||||||
|
"start": false,
|
||||||
|
"size": 320,
|
||||||
|
"resolution": 256,
|
||||||
|
"terrain": [
|
||||||
|
{"type": "hills", "height_scale": 5.5, "detail": 1.2},
|
||||||
|
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 52.0, "rim_warp": 14.0, "wall_frac": 0.45},
|
||||||
|
{"type": "beach", "angle_deg": 90.0, "half_width_deg": 55.0, "fade_deg": 22.0, "shore_offset": -16.0, "slope": 0.08, "current_out": 22.0, "wall_out": 36.0},
|
||||||
|
{"type": "lake", "center": [-30.0, -40.0], "radius": 11.0, "water_level": -2.6},
|
||||||
|
{"type": "stream", "points": [[46.0, -80.0], [34.0, -48.0], [40.0, -14.0], [30.0, 22.0], [36.0, 52.0], [33.0, 78.0], [33.0, 104.0]], "width": 2.4, "depth": 0.6},
|
||||||
|
{"type": "ocean_edge"}
|
||||||
|
],
|
||||||
|
"climate": {"snowline": 42.0},
|
||||||
|
"places": [{"type": "den"}, {"type": "pier", "at": "beach"}, {"type": "wreck"}],
|
||||||
|
"wildlife": ["crabs", "gulls"],
|
||||||
|
"next": [
|
||||||
|
{"to": "valley", "by": "tunnel", "where": "sea_cave"},
|
||||||
|
{"to": "highlands", "by": "sail", "where": "pier"}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
data/worldlets/valley.json
Normal file
19
data/worldlets/valley.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"_doc": "A worldlet (docs/WORLDLETS_DESIGN.md): its name and description (the New World page), whether a new world can start here (\"start\"; the others are reached on the journey), its size, and its terrain as building blocks applied in order (scripts/world/terrain.gd: hills, rim, sea_cliff, lake, ocean_edge). The numbers here are the Valley exactly as it was hand-coded: tests/test_worldlets.gd checks it still makes the same height grid.",
|
||||||
|
"id": "valley",
|
||||||
|
"name": "Valley",
|
||||||
|
"description": "A sheltered valley ringed by mountains, with a lake in the middle and sea cliffs on one side.",
|
||||||
|
"start": true,
|
||||||
|
"size": 320,
|
||||||
|
"resolution": 256,
|
||||||
|
"terrain": [
|
||||||
|
{"type": "hills", "height_scale": 9.0, "detail": 1.4},
|
||||||
|
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 58.0, "rim_warp": 16.0, "wall_frac": 0.45},
|
||||||
|
{"type": "sea_cliff", "angle_deg": -75.0, "half_width_deg": 38.0, "fade_deg": 22.0, "sea_level": -24.0, "cliff_top": 0.5, "cliff_offset": 4.0, "drop_width": 6.0},
|
||||||
|
{"type": "lake", "center": [14.0, -46.0], "radius": 24.0, "water_level": -2.6},
|
||||||
|
{"type": "ocean_edge"}
|
||||||
|
],
|
||||||
|
"climate": {"snowline": 40.0},
|
||||||
|
"places": [{"type": "den"}, {"type": "barrow"}, {"type": "stones"}, {"type": "bear"}],
|
||||||
|
"next": [{"to": "cove", "by": "tunnel", "where": "sea_cliff"}]
|
||||||
|
}
|
||||||
@@ -113,15 +113,22 @@ To check it works: `tailscale ping <server name>` from the friend's
|
|||||||
computer.
|
computer.
|
||||||
|
|
||||||
### Port forwarding
|
### Port forwarding
|
||||||
On your router, forward **UDP 24242** to the server computer's local
|
On your router, forward **UDP 24242 and 24243** to the server computer's
|
||||||
address (give that computer a fixed local address first). Friends join
|
local address (give that computer a fixed local address first). Friends
|
||||||
with your public IP address. It needs no extra software, but your home IP
|
join with your public IP address. It needs no extra software, but your
|
||||||
and that port are public, so set a `password`.
|
home IP and those ports are public, so set a `password`.
|
||||||
|
|
||||||
|
Why two: each land of the journey runs as its own server on the next port
|
||||||
|
up (24243 is the Cove's, when the server's on 24242). The server players
|
||||||
|
join (the Valley's) starts it when someone first goes there and stops it
|
||||||
|
when it's been empty a couple of minutes; everyone still joins on 24242.
|
||||||
|
Host Game and the server's log both say which ports to forward. With
|
||||||
|
Tailscale, or on the same Wi-Fi, there's nothing to do.
|
||||||
|
|
||||||
### A cloud server
|
### A cloud server
|
||||||
Any small Linux server works (it needs little: a couple of GB of memory).
|
Any small Linux server works (it needs little: a couple of GB of memory).
|
||||||
Install Godot, clone the game, use the systemd file, and open UDP 24242 in
|
Install Godot, clone the game, use the systemd file, and open UDP 24242
|
||||||
the provider's firewall.
|
and 24243 in the provider's firewall.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
- **"Different game version":** the server and the player are on different
|
- **"Different game version":** the server and the player are on different
|
||||||
@@ -132,6 +139,11 @@ the provider's firewall.
|
|||||||
- **"Port 24242 is already in use":** another server is running on this
|
- **"Port 24242 is already in use":** another server is running on this
|
||||||
computer (a dedicated one, or a Host Game that didn't stop). Stop it, or
|
computer (a dedicated one, or a Host Game that didn't stop). Stop it, or
|
||||||
host on another port.
|
host on another port.
|
||||||
|
- **"Lost the connection to the Cove":** that land's server stopped (it
|
||||||
|
logs to `land-cove.log` beside `server.json`). Join again: the server
|
||||||
|
starts it afresh and sends you back there.
|
||||||
|
- **Stuck going on to the Cove from the internet:** UDP 24243 isn't
|
||||||
|
forwarded (see above).
|
||||||
- **The server says the game's scripts didn't load:** it's a fresh copy
|
- **The server says the game's scripts didn't load:** it's a fresh copy
|
||||||
that hasn't been imported, or Godot is older than 4.7. `run_server.sh`
|
that hasn't been imported, or Godot is older than 4.7. `run_server.sh`
|
||||||
handles both; see `docs/SETUP_ON_NEW_MACHINE.md`.
|
handles both; see `docs/SETUP_ON_NEW_MACHINE.md`.
|
||||||
|
|||||||
80
docs/PLAYTEST.md
Normal file
80
docs/PLAYTEST.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Emberwild 0.6.0 playtest
|
||||||
|
|
||||||
|
Thanks for playing. This build is the **Valley** and the **Cove**, and
|
||||||
|
we'd like to know what breaks, what's confusing, and what's no fun. The
|
||||||
|
game's player guide is on the download page: https://emberwild-95k.pages.dev
|
||||||
|
|
||||||
|
## Reporting a bug
|
||||||
|
|
||||||
|
Press **Esc** in the game, then **Report a bug**:
|
||||||
|
|
||||||
|
- **Open the bug report page** opens a new issue on the Emberwild Gitea page
|
||||||
|
in your browser, already filled in with your version, computer, mode,
|
||||||
|
land, the in-game time and where you are. Say what happened, what you
|
||||||
|
expected, and how to make it happen again, then press **Create Issue**.
|
||||||
|
Posting needs a (free) account on the Gitea server, and you need to be
|
||||||
|
signed in. Nothing is sent from the game itself.
|
||||||
|
- **Copy the details** puts the same details on your clipboard, if you'd
|
||||||
|
rather send them by message.
|
||||||
|
- **Show the log file** opens the folder with `godot.log`. For a crash or an
|
||||||
|
error message, attach it to the issue (drag it into the comment box).
|
||||||
|
`godot.log` is the latest run; the last few runs before it are kept
|
||||||
|
beside it, named with the date and time.
|
||||||
|
|
||||||
|
Where the log is:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Mac | `~/Library/Application Support/Godot/app_userdata/Emberwild/logs/godot.log` |
|
||||||
|
| Windows | `%APPDATA%\Godot\app_userdata\Emberwild\logs\godot.log` |
|
||||||
|
| Linux | `~/.local/share/godot/app_userdata/Emberwild/logs/godot.log` |
|
||||||
|
|
||||||
|
A screenshot helps for anything that looks wrong (Cmd+Shift+4 on a Mac,
|
||||||
|
Win+Shift+S on Windows).
|
||||||
|
|
||||||
|
One problem per issue, please; a second issue is better than a long one.
|
||||||
|
|
||||||
|
## What to try
|
||||||
|
|
||||||
|
Play however you like; these are the parts that are new or that we'd most
|
||||||
|
like tried.
|
||||||
|
|
||||||
|
**The Valley**
|
||||||
|
- The tutorial with Old Tam (New World, Tutorial), or start a normal world
|
||||||
|
and listen for his tips.
|
||||||
|
- Make a camp, survive a few nights, a winter, a wolf moon.
|
||||||
|
- Iron, stone building, the barrow, the standing stones and the Old Stag.
|
||||||
|
- Out on the clifftop, the cracked seam: dig the cliff tunnel down to the
|
||||||
|
Cove (an antler pickaxe, wood to prop it, lights).
|
||||||
|
|
||||||
|
**The Cove (new in 0.6.0)**
|
||||||
|
- The wreck at the far end of the beach: the captain's sea chest has a
|
||||||
|
treasure map for each player. Right-click the map, find your X (N for the
|
||||||
|
map), and dig it up with a pickaxe. Don't dig at night, or do.
|
||||||
|
- The pier: mackerel close in, cod from the far end.
|
||||||
|
- Gulls, after food on the sand and fish on a smoking rack.
|
||||||
|
- Crabs on the beach from dusk till dawn.
|
||||||
|
- Build a rowing boat (seagrass rope, pitch from pine resin and salt,
|
||||||
|
driftwood and nails, at a workbench) and tie it up at the end of the pier.
|
||||||
|
|
||||||
|
**Online**
|
||||||
|
- Host Game from the title screen, and have a friend join. Over the
|
||||||
|
internet, forward UDP ports **24242** and **24243** (the Cove has its own
|
||||||
|
server on the next port).
|
||||||
|
- Go between the lands together and apart, chat, sleep (everyone in every
|
||||||
|
land has to be in bed), and leave and come back.
|
||||||
|
|
||||||
|
## Not in this build (these aren't bugs)
|
||||||
|
|
||||||
|
- **Setting sail.** The boat ties up but can't leave yet: the Highlands are
|
||||||
|
the next part of the journey.
|
||||||
|
- **Pirate gold** has no use yet beyond being a trophy: there's no trader.
|
||||||
|
- **The ship's compass** does nothing yet; it's what the boat will need.
|
||||||
|
|
||||||
|
## Things we'd especially like to hear
|
||||||
|
|
||||||
|
- Anywhere you got stuck, or didn't know what to do next.
|
||||||
|
- Anything that felt too hard, too easy, or too slow (night, hunger, cold,
|
||||||
|
the drowned crew, the crabs).
|
||||||
|
- Frame-rate drops, and on what computer.
|
||||||
|
- Anything online that went out of step between players.
|
||||||
@@ -87,7 +87,16 @@ phases M2.0-M2.7).
|
|||||||
- PvP / raiding rules, building damage
|
- PvP / raiding rules, building damage
|
||||||
- Better UI, settings, controls. *Done so far:* volume sliders, tips and
|
- 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
|
update settings in the pause menu; `Q` drops items; `K`/`M` put any bag row
|
||||||
on the 1-8 keys (0.3.0).
|
on the 1-8 keys (0.3.0); the map on `N`, filling in as you explore, and a
|
||||||
|
compass along the top (0.4.0, Gitea issue #1).
|
||||||
|
- More places: the worldlets and the journey (`docs/WORLDLETS_DESIGN.md`).
|
||||||
|
*Done (0.4.0):* W0-W3 and J1, the Cove reached down the cliff tunnel
|
||||||
|
(single-player). *(0.5.0):* J3, the journey on a server: a server per
|
||||||
|
land, players in different lands at once, chat, the player list, admin
|
||||||
|
and sleep across lands. *(0.6.0, a playtest):* the Cove's own: the wreck,
|
||||||
|
a treasure dig for each player and its drowned crew, crabs, gulls, and a
|
||||||
|
rowing boat moored at the pier; Report a bug in the pause menu. Next J2,
|
||||||
|
then J4 (the Highlands by sail).
|
||||||
- Audio, music, weather. *Done (0.2.0-0.2.1):* mood music (calm, night,
|
- Audio, music, weather. *Done (0.2.0-0.2.1):* mood music (calm, night,
|
||||||
storm, danger, boss) that crossfades with what's happening, ambience (CC0
|
storm, danger, boss) that crossfades with what's happening, ambience (CC0
|
||||||
birdsong, crickets, owls, fire), sound effects for every action (Kenney
|
birdsong, crickets, owls, fire), sound effects for every action (Kenney
|
||||||
|
|||||||
@@ -1,7 +1,168 @@
|
|||||||
# Worldlets design
|
# Worldlets design
|
||||||
|
|
||||||
Status: **proposal**. Nothing here is built yet, apart from the first piece
|
Status: **W0-W3, J1 and J3a built**: the Cove (with its stream), the cliff
|
||||||
of groundwork (`world.water_at()` and `data/fish.json`, see §6).
|
tunnel, and the journey on a server (a server per land; chat and the
|
||||||
|
player list across lands are J3b). The rest is planned.
|
||||||
|
|
||||||
|
*As built in J3a (a server per land):*
|
||||||
|
- `scripts/net/lands.gd` (autoload `Lands`): the server players join is the
|
||||||
|
hub. `Lands.ensure(land)` starts a land server (`--server --land cove
|
||||||
|
--port <hub+1..> --hub-port <hub>`, same config and world folder, output
|
||||||
|
to `land-<land>.log` beside server.json) and waits for it to say it's
|
||||||
|
listening. Hub and land servers talk over TCP on 127.0.0.1, port hub +
|
||||||
|
100, one JSON object per line: hello/ready, the player count, the clock
|
||||||
|
(the hub's, every 10 s: one clock everywhere), how far the tunnel's dug,
|
||||||
|
one-time passes, "stop", "host_left". A land server stops after 2 minutes
|
||||||
|
empty, when the hub stops, or if it loses the hub.
|
||||||
|
- A land server's saves: `Saves.land` makes it load and save its own
|
||||||
|
worldlet's file (`worldlets/cove.json`) in the hub's world folder.
|
||||||
|
Characters (`players/<id>.json`) are shared, and say which land they're
|
||||||
|
in (`land`) and, mid-crossing, where they'll come out (`arrive`).
|
||||||
|
- Crossing (`PlayerNet.cross`): the server saves the character as heading
|
||||||
|
there, makes sure the other server's up, and sends the game
|
||||||
|
`_s2c_travel {land, port, pass}`; the game drops the connection quietly
|
||||||
|
and joins that port with the pass (`Net.join(address, "", pass)`), checked
|
||||||
|
after the identity proof: a land server lets nobody in without one. The
|
||||||
|
world reloads on `Net.arrived`. Joining the hub when your character's in
|
||||||
|
another land sends you on there; a crossing that fails goes back to the
|
||||||
|
title. A hosted game: the host crossing doesn't stop the hub; the host
|
||||||
|
leaving the game from another land stops everything.
|
||||||
|
- The tunnel online: built on the server and in every game. The server
|
||||||
|
decides blows on its faces (`"hit"` finds them by uid), props
|
||||||
|
(`"tunnel_prop"`), going in and out (`"tunnel_go"`: a server teleport;
|
||||||
|
the move check allows the tunnel only from inside it) and the crossings
|
||||||
|
(`"cross"`), and tells everyone how it stands (`_s2c_tunnel`; it's in the
|
||||||
|
shared state a joining player gets, and saved with the Valley).
|
||||||
|
- `tests/test_net_lands.gd`: Alice digs and props online, lights it,
|
||||||
|
crosses to the Cove (bag and day kept) while Bob stays in the Valley,
|
||||||
|
comes back up, then leaves from the Cove and rejoining sends her there.
|
||||||
|
|
||||||
|
*As built in J3b (everyone, everywhere):*
|
||||||
|
- Chat and server messages go to every land (`Chat._all` relays through
|
||||||
|
the hub, `Lands.relay_line`).
|
||||||
|
- The hub keeps the **roster** (everyone on every land, with their land);
|
||||||
|
every land and every player's game gets it (`Net.roster`,
|
||||||
|
`Net.everyone()`): the pause menu's player list and `/players` show
|
||||||
|
where each player is.
|
||||||
|
- Commands typed in another land run **on the hub** (it alone keeps
|
||||||
|
server.json) and answer back there; after one that changes server.json
|
||||||
|
(`/set`, `/ban`, `/admin`, `/friend`...) every land reloads it.
|
||||||
|
`/kick` and `/ban` reach the land the player's in; `/weather`, `/save`
|
||||||
|
and `/backup` reach every land.
|
||||||
|
- **Sleep needs everyone in every land:** land servers report their beds,
|
||||||
|
the hub decides, and every land passes the night together. Getting into
|
||||||
|
bed while others are up: "You're in bed: waiting for everyone else to go
|
||||||
|
to bed (1 of 2 asleep)", and every land hears "Bob is in bed (1 of 2
|
||||||
|
asleep)" in chat.
|
||||||
|
- `tests/test_net_lands.gd` covers it all, Alice in the Cove and Bob in the
|
||||||
|
Valley.
|
||||||
|
|
||||||
|
*As built in J3c:*
|
||||||
|
- A land server that stops (crashed, or killed) tells its players "Lost the
|
||||||
|
connection to the Cove. Join the server again to carry on there."; the
|
||||||
|
hub notices it's gone (even mid-join) and joining again starts it afresh
|
||||||
|
and sends them back (their characters were saved by its autosave).
|
||||||
|
- The extra ports: `Lands.extra_ports()` / `ports_text()` ("UDP 24242 and
|
||||||
|
24243 (the Cove)"), on the Host page, in the "You're hosting" message, in
|
||||||
|
a hub's start-up log, and in docs/HOSTING.md.
|
||||||
|
- Tested in `test_net_lands.gd` (the Cove's server killed, Alice rejoins)
|
||||||
|
and `test_worldlet_pick.gd` (the Host page).
|
||||||
|
|
||||||
|
*As built in J1:*
|
||||||
|
- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip,
|
||||||
|
7 m in from the edge, its angle from the seed) and the tunnel space, out
|
||||||
|
of sight 260 m under the island: a CSG rock block with the landings and
|
||||||
|
six sloping stretches cut out (6 m on, 4 m down each, switching back on
|
||||||
|
two lanes: 24 m down), then a sea cave. Faces (`tunnel_face.gd`) answer
|
||||||
|
like boulders, so the pick works on them: tier 3 only ("Too hard: you
|
||||||
|
need a better pick"), 10 blows for the seam and 15 per stretch. Each
|
||||||
|
stretch must be propped (2 wood, [E] at its mouth) before the next face
|
||||||
|
takes a blow ("It's loose..."): 10 wood all the way down.
|
||||||
|
- Underground: no sun, moon, sky reflections or sky-tinted fog
|
||||||
|
(`day_night.underground`), no weather, no lake water, sheltered. Candles
|
||||||
|
and torch stands place anywhere on its floor and save as ordinary pieces.
|
||||||
|
- Walking out of the sea cave's mouth travels (`world.travel()`): the
|
||||||
|
worldlet you leave is saved, your character and the clock go with you,
|
||||||
|
and the other loads, arriving at its sea cave (`sea_cave.gd`, on the
|
||||||
|
Cove's beach where the headland starts). [E] at the cave goes back up the
|
||||||
|
same tunnel to its bottom, once it's dug through.
|
||||||
|
- Saves: `world.json` is the home worldlet's state, as before;
|
||||||
|
others are `worldlets/<id>.json`; meta.json's `worldlet` is where you
|
||||||
|
are, `home` where you began, `arrive` where the next load puts you.
|
||||||
|
- `tests/test_tunnel.gd`: dig through, walk every stretch down and back
|
||||||
|
up, light it, reload, out to the Cove, back up, out at the top, and the
|
||||||
|
chest at home is still there.
|
||||||
|
- Not yet: Old Tam's tip about the seam, the Guide page, and travel on a
|
||||||
|
server (J3: the tunnel isn't built online). The journey between
|
||||||
|
worldlets is §10, and the two ways to play (the journey, or one land) are in
|
||||||
|
its decisions.
|
||||||
|
|
||||||
|
*As built in W3:*
|
||||||
|
- `data/worldlets/cove.json`: low hills, mountains behind, a wide sandy
|
||||||
|
bay (a `beach` block) and a small pond. Not a start; its `next` is back
|
||||||
|
to the Valley (tunnel, J1) and on to the Highlands (sail).
|
||||||
|
- The pier (`scripts/world/pier.gd`), a world "place" (`"places":
|
||||||
|
[{"type": "pier"}]`): planks on posts from where the sand is at deck
|
||||||
|
height to 24 m past the waterline. Worked out from the terrain, so it's
|
||||||
|
the same for everyone and never saved or dismantled; scenery keeps clear
|
||||||
|
of it. ~1.9 m of sea at its end, ~2.7 m a cast beyond.
|
||||||
|
- Sea fish in `data/fish.json`: mackerel (common) and cod, which only bites
|
||||||
|
in water 2.2 m or deeper (a per-fish `min_depth`). Grilled at a campfire,
|
||||||
|
salted, or smoked, like the lake's.
|
||||||
|
- New World has no "Where": the journey always begins in the Valley (the
|
||||||
|
Host page keeps it, for One land mode). Started with
|
||||||
|
`godot --path . -- --dev-worldlets`, New World and Host offer every
|
||||||
|
worldlet, the others marked "(testing)", to try one directly.
|
||||||
|
- No sea stacks without a sea cliff.
|
||||||
|
- Tests: `test_cove` (walk onto the pier, catch a mackerel off its end, cod
|
||||||
|
only in deep water), and the Cove in `test_terrain_edges` and
|
||||||
|
`test_boundary`.
|
||||||
|
- The stream (after 0.4.0): a `stream` block, a line of points from a
|
||||||
|
spring under the mountains to the bay. Its water surface is the running
|
||||||
|
minimum of the ground along it (so it only ever runs downhill, down to
|
||||||
|
sea level), the bed is cut 0.6 m under that and the banks rise a little
|
||||||
|
above it; its water is its own ribbon mesh (`terrain._stream_mesh()`),
|
||||||
|
murkier than the lake. `water_at()` answers "stream" (trout bite there,
|
||||||
|
`data/fish.json`), the map shows it, and scenery, the den, the barrow and
|
||||||
|
the stones keep clear of it (`terrain.near_stream()`).
|
||||||
|
|
||||||
|
*As built in W2:*
|
||||||
|
- `lake` blocks can repeat: every lake and pond is its own body
|
||||||
|
(`terrain.lakes`, `world.water_bodies()`), all at one surface
|
||||||
|
(`water_level`, set by the first). The first is still "the lake" to the
|
||||||
|
older code (ice, its fish, the tutorial).
|
||||||
|
- A `beach` block (angle, width, `shore_offset`, `slope`, `current_out`,
|
||||||
|
`wall_out`): the land eases to a waterline and shelves gently into the
|
||||||
|
sea, which is then the same surface as the ponds (one ocean sheet, no
|
||||||
|
inland disc). The invisible wall stands well out in the water, and before
|
||||||
|
it a current (`terrain.current_at()`, `world._current()`) carries
|
||||||
|
swimmers back faster than they can swim, with a toast.
|
||||||
|
- `water_at()` tells the sea off a beach from a pond.
|
||||||
|
- Test-only worldlets live in `tests/worldlets/` (never listed or offered).
|
||||||
|
`testbay.json` (two ponds and a beach) runs through
|
||||||
|
`test_water_bodies`, `test_terrain_edges` (8 seeds) and `test_boundary`
|
||||||
|
(swimming out across the beach).
|
||||||
|
|
||||||
|
*As built in W1:* a "Where" picker on the New World and Host Game pages
|
||||||
|
(worldlets with `"start": true`; only the Valley, so it's greyed out), the
|
||||||
|
worldlet in the Load list, and a hosted island keeps its worldlet like its
|
||||||
|
seed.
|
||||||
|
|
||||||
|
*As built in W0:*
|
||||||
|
- `data/worldlets/valley.json`: the Valley as data, its terrain as five
|
||||||
|
building blocks in order (`hills`, `rim`, `sea_cliff`, `lake`,
|
||||||
|
`ocean_edge`) with every number that used to be hard-coded.
|
||||||
|
- `terrain.gd` runs a worldlet's blocks (`use_worldlet()`); each block is
|
||||||
|
the old code's arithmetic, unchanged. `tests/test_worldlets.gd` checks
|
||||||
|
four seeds give exactly the same height grid as before (SHA-256 of the
|
||||||
|
grid, taken from the old code).
|
||||||
|
- The worldlet is chosen like the seed: a server's (it sends its real one),
|
||||||
|
else the save's `meta.json` "worldlet", else the Valley (worlds from
|
||||||
|
before W0). New worlds and servers record it.
|
||||||
|
- The WorldInfo API on the world: `worldlet_id()`, `water_bodies()`,
|
||||||
|
`climate()`, `crossings()` (the planned ways on), beside `water_at()`.
|
||||||
|
Systems still read the terrain's numbers directly; they move over to
|
||||||
|
these as each new worldlet needs them to differ (W1-W3).
|
||||||
|
|
||||||
## 1. What a worldlet is
|
## 1. What a worldlet is
|
||||||
A **worldlet** is a small, complete world type: a ~320 m playable area with
|
A **worldlet** is a small, complete world type: a ~320 m playable area with
|
||||||
@@ -122,6 +283,68 @@ The steps:
|
|||||||
- Later: tides (sea level shifting slowly), a raft or rowing boat, and
|
- Later: tides (sea level shifting slowly), a raft or rowing boat, and
|
||||||
driftwood on the beach.
|
driftwood on the beach.
|
||||||
|
|
||||||
|
### The Cove's own dangers and the pirate treasure (Paul, Nov 2026, for 0.6.0)
|
||||||
|
Paused at the Cove for a playtest before the Highlands. The Cove gets its
|
||||||
|
own set pieces instead of copies of the Valley's:
|
||||||
|
- **Set pieces per land**, listed in each worldlet's data (`"places"`). The
|
||||||
|
Valley keeps the wolf den, the barrow, the standing stones (the Old Stag)
|
||||||
|
and the bear; the Cove keeps the wolves only, and gets the wreck and the
|
||||||
|
treasure.
|
||||||
|
- **The wreck:** a beached ship on the rocks at the far end of the bay, its
|
||||||
|
timbers along the sand (driftwood). Its captain's sea chest: some nails,
|
||||||
|
rope, coins and a **torn treasure map**.
|
||||||
|
- **The map:** reading it puts an X on your N map: a mound of loose earth
|
||||||
|
by a marker stone in the Cove's hills (your own spot: see below).
|
||||||
|
- **The dig:** a pickaxe, many blows, turns up the **pirate chest**: a
|
||||||
|
**ship's compass** (what the boat will need to sail to the Highlands: the
|
||||||
|
next crossing's gate, J2), a **spyglass** (zooms your view while held),
|
||||||
|
**pirate gold**, and a **cutlass** (quick, harder-hitting than the iron
|
||||||
|
spear).
|
||||||
|
- **One treasure each, each in its own place** (online too): the wreck's
|
||||||
|
chest gives each player their own map, once, and each map's X is
|
||||||
|
somewhere different: picked from the world's seed and the player's id
|
||||||
|
(so it's fixed for them), among dry, gentle spots in the Cove's hills
|
||||||
|
away from the other places. You only see your own mound; the server knows
|
||||||
|
every player's spot and what each has found, and checks the right one
|
||||||
|
when you dig.
|
||||||
|
- **Gold** is a **trophy you can place** in your hut (a heap of coins) for
|
||||||
|
now; what else it's for waits until there's a trader.
|
||||||
|
- **The drowned crew:** the treasure's guards (the Cove's barrow guards).
|
||||||
|
Two or three rise from the shallows round the dig at night, or as the
|
||||||
|
chest comes up, for whoever's digging. Slow, hit hard, unafraid of fire,
|
||||||
|
they drag at you in the water.
|
||||||
|
- **Crabs** on the beach at dusk (pinch if trodden on; food), and **gulls**,
|
||||||
|
thieves like the crows (fish off drying racks, things dropped on sand).
|
||||||
|
- **The boat** (J2's first part): driftwood, seagrass rope and pitch (pine
|
||||||
|
resin and salt), at a workbench, placed at the pier. One each, moored,
|
||||||
|
yours and your friends'. Setting sail says the Highlands are uncharted
|
||||||
|
for now.
|
||||||
|
- **Report a bug** in the pause menu, and a short playtest guide.
|
||||||
|
- Order: set pieces per land, the wreck and map, the dig and the drowned
|
||||||
|
crew, crabs and gulls, the boat, report a bug; then 0.6.0 (protocol 18).
|
||||||
|
|
||||||
|
**Built (Nov 2026), and where it differs from the plan above:**
|
||||||
|
- Places: `"places"` in `data/worldlets/*.json` (`world.has_place()`); the
|
||||||
|
Cove's extra wildlife in `"wildlife"` (`world.has_wildlife()`).
|
||||||
|
- The wreck (`scripts/world/wreck.gd`): the sea chest gives a map, 4 nails
|
||||||
|
and 2 salt (no rope or coins: gold comes from the dig). Each X is one of
|
||||||
|
12 hiding spots picked from the seed (`world.treasure_spots()`), chosen
|
||||||
|
per player by their id.
|
||||||
|
- The dig (`treasure.gd`, `treasure_mound.gd`, `crew_site.gd`): 18 HP, any
|
||||||
|
pickaxe; three drowned rise round the mound itself (not from the
|
||||||
|
shallows), at the first blow by night or as the chest comes up by day.
|
||||||
|
- Crabs (`crab.gd`, `shore.gd`): up to 5 per player on the sand from 17:30
|
||||||
|
to 06:30, pinch for 5 within about 2 m, let you go past 6 m; crab meat.
|
||||||
|
Gulls (`gull.gd`, `thieves.gd`): food on the sand, and fish on a smoking
|
||||||
|
rack; they take one and fly off (only food, not tools left on the sand).
|
||||||
|
- The boat (`moorings.gd`, `boat.gd`, `mooring_post.gd`): a rowing boat
|
||||||
|
item (12 driftwood, 4 rope, 3 pitch, 6 nails), tied up at the post at the
|
||||||
|
end of the pier rather than placed freely: 6 berths, one boat each, saved
|
||||||
|
with the Cove. Pine resin drops from felled pines in every land.
|
||||||
|
- Report a bug (`scripts/core/bug_report.gd`): opens the repo's new-issue
|
||||||
|
page filled in (the tester posts it, signed in); no token in the game.
|
||||||
|
Testers' notes: `docs/PLAYTEST.md`.
|
||||||
|
|
||||||
## 6. Done already (groundwork)
|
## 6. Done already (groundwork)
|
||||||
- `world.water_at(x, z)` returns `{type, surface, depth}` for the lake and
|
- `world.water_at(x, z)` returns `{type, surface, depth}` for the lake and
|
||||||
the sea, and fishing uses it instead of `terrain.water_level`.
|
the sea, and fishing uses it instead of `terrain.water_level`.
|
||||||
@@ -224,6 +447,41 @@ you've learned and gathered there:
|
|||||||
- **Through:** the far end opens onto the next worldlet. Walking out loads
|
- **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.
|
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
|
### Why go back
|
||||||
- **Each worldlet has things the others lack.** Clay and salt only in the
|
- **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 and Cove; little wood in the Highlands; no bears outside the
|
||||||
@@ -232,8 +490,10 @@ you've learned and gathered there:
|
|||||||
meat, clay jars) becomes what you carry between them.
|
meat, clay jars) becomes what you carry between them.
|
||||||
- **Your bases stay** where you built them: each worldlet keeps its own
|
- **Your bases stay** where you built them: each worldlet keeps its own
|
||||||
save file, so your Valley hut, chests and jars are waiting.
|
save file, so your Valley hut, chests and jars are waiting.
|
||||||
- **Time:** a worldlet you're not in stands still (as a server does with
|
- **Time:** there's one clock for every land (see the decisions below). A
|
||||||
nobody on), so a base you left doesn't starve or get raided.
|
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)
|
### Building it (phases)
|
||||||
| Phase | Delivers | Done when |
|
| Phase | Delivers | Done when |
|
||||||
@@ -264,17 +524,45 @@ A server today runs one island. Options:
|
|||||||
bites can make you sick, and bog, mist and the like. Others in the same
|
bites can make you sick, and bog, mist and the like. Others in the same
|
||||||
spirit per biome (to design with each worldlet).
|
spirit per biome (to design with each worldlet).
|
||||||
- **Multiplayer: spread out.** Each player goes where they like; the server
|
- **Multiplayer: spread out.** Each player goes where they like; the server
|
||||||
runs every worldlet someone is in, and the rest stand still. Built in J3,
|
runs every worldlet someone is in. Built in J3, after single-player travel
|
||||||
after single-player travel works. What that means for the code:
|
works.
|
||||||
- one server process holds several worldlet scenes, each with its own
|
- **A server per land (Paul, Nov 2026)**, rather than one process holding
|
||||||
clock, weather, creatures and save file;
|
several worldlets (which would mean making every network module, the
|
||||||
- every player belongs to one worldlet at a time, and the server only
|
saves and the actions handle several worlds at once):
|
||||||
sends them that worldlet's world, creatures, sounds and chat-free events
|
- the Valley's server is the **hub**: the address everyone joins. It
|
||||||
(chat and the player list stay server-wide);
|
starts a server for another land (the next UDP port: 24243 the Cove,
|
||||||
- a crossing moves a player from one worldlet to another (save their
|
and so on) when someone first goes there, and stops it a couple of
|
||||||
place, load or wake the other worldlet, send them its world);
|
minutes after its last player leaves;
|
||||||
- sleep: "everyone in bed" counts the players in that worldlet only;
|
- each land server is today's server, with its own worldlet, save file,
|
||||||
- the Old Stag and other bosses scale with the players in their worldlet.
|
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
|
- **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
|
(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
|
rule, can sail it, and nobody else can dismantle it. It stays moored where
|
||||||
@@ -285,6 +573,22 @@ A server today runs one island. Options:
|
|||||||
- **The other crossings are shared:** a dug tunnel, a fixed climbing rope
|
- **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
|
and a dug burrow are changes to the world, there for everyone once one
|
||||||
player has made them (like a door or a bridge).
|
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)
|
### Server cost, measured (0.3.1)
|
||||||
One worldlet, a dedicated server on the M5 Pro, never pausing: about
|
One worldlet, a dedicated server on the M5 Pro, never pausing: about
|
||||||
@@ -292,7 +596,7 @@ One worldlet, a dedicated server on the M5 Pro, never pausing: about
|
|||||||
creatures, weather and pickups around them. So with *spread out*, the worst
|
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
|
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,
|
one process: roughly 1.4 GB and a little over half a core before players,
|
||||||
and worldlets with nobody in them stand still and cost next to nothing.
|
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.
|
Comfortable on the home Mac; a small cloud server (2 GB) would be tight.
|
||||||
|
|
||||||
### (Answered) questions
|
### (Answered) questions
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ runnable=true
|
|||||||
dedicated_server=false
|
dedicated_server=false
|
||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter="data/*.json"
|
include_filter="data/*.json, data/worldlets/*.json"
|
||||||
exclude_filter="tests/*, docs/*, tools/*, server/*"
|
exclude_filter="tests/*, docs/*, tools/*, server/*"
|
||||||
export_path="build/macos/Emberwild.zip"
|
export_path="build/macos/Emberwild.zip"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
@@ -29,8 +29,8 @@ application/icon_interpolation=4
|
|||||||
application/bundle_identifier="com.pbailey.emberwild"
|
application/bundle_identifier="com.pbailey.emberwild"
|
||||||
application/signature=""
|
application/signature=""
|
||||||
application/app_category="Games"
|
application/app_category="Games"
|
||||||
application/short_version="0.3.1"
|
application/short_version="0.6.0"
|
||||||
application/version="0.3.1"
|
application/version="0.6.0"
|
||||||
application/copyright=""
|
application/copyright=""
|
||||||
display/high_res=true
|
display/high_res=true
|
||||||
codesign/codesign=1
|
codesign/codesign=1
|
||||||
@@ -64,7 +64,7 @@ runnable=true
|
|||||||
dedicated_server=false
|
dedicated_server=false
|
||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter="data/*.json"
|
include_filter="data/*.json, data/worldlets/*.json"
|
||||||
exclude_filter="tests/*, docs/*, tools/*, server/*"
|
exclude_filter="tests/*, docs/*, tools/*, server/*"
|
||||||
export_path="build/windows/Emberwild/Emberwild.exe"
|
export_path="build/windows/Emberwild/Emberwild.exe"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
@@ -89,8 +89,8 @@ application/modify_resources=false
|
|||||||
application/icon=""
|
application/icon=""
|
||||||
application/console_wrapper_icon=""
|
application/console_wrapper_icon=""
|
||||||
application/icon_interpolation=4
|
application/icon_interpolation=4
|
||||||
application/file_version="0.3.1"
|
application/file_version="0.6.0"
|
||||||
application/product_version="0.3.1"
|
application/product_version="0.6.0"
|
||||||
application/company_name=""
|
application/company_name=""
|
||||||
application/product_name="Emberwild"
|
application/product_name="Emberwild"
|
||||||
application/file_description="Emberwild"
|
application/file_description="Emberwild"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ config/description="Low-poly survival game — Valheim look, Rust depth, self-ho
|
|||||||
run/main_scene="res://scenes/title.tscn"
|
run/main_scene="res://scenes/title.tscn"
|
||||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
config/version="0.3.1"
|
config/version="0.6.0"
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ Hints="*res://scripts/core/hints.gd"
|
|||||||
Updates="*res://scripts/core/updates.gd"
|
Updates="*res://scripts/core/updates.gd"
|
||||||
Saves="*res://scripts/core/saves.gd"
|
Saves="*res://scripts/core/saves.gd"
|
||||||
Net="*res://scripts/net/net.gd"
|
Net="*res://scripts/net/net.gd"
|
||||||
|
Lands="*res://scripts/net/lands.gd"
|
||||||
WorldNet="*res://scripts/net/world_net.gd"
|
WorldNet="*res://scripts/net/world_net.gd"
|
||||||
PlayerNet="*res://scripts/net/player_net.gd"
|
PlayerNet="*res://scripts/net/player_net.gd"
|
||||||
CreatureNet="*res://scripts/net/creature_net.gd"
|
CreatureNet="*res://scripts/net/creature_net.gd"
|
||||||
|
|||||||
@@ -43,5 +43,5 @@ offset_left = 16.0
|
|||||||
offset_top = 12.0
|
offset_top = 12.0
|
||||||
offset_right = 600.0
|
offset_right = 600.0
|
||||||
offset_bottom = 60.0
|
offset_bottom = 60.0
|
||||||
text = "E interact · LMB use/place · RMB eat · R rotate · X dismantle · 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")
|
label_settings = SubResource("Label_1")
|
||||||
|
|||||||
1
scripts/building/bed.gd.uid
Normal file
1
scripts/building/bed.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://nx08dmty60ny
|
||||||
1
scripts/building/build_piece.gd.uid
Normal file
1
scripts/building/build_piece.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dsbvhwas6wsxf
|
||||||
1
scripts/building/build_system.gd.uid
Normal file
1
scripts/building/build_system.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://gj02g6p3xfip
|
||||||
1
scripts/building/door.gd.uid
Normal file
1
scripts/building/door.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dhsyx4mrm28oe
|
||||||
1
scripts/building/forge.gd.uid
Normal file
1
scripts/building/forge.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cl5ip4achfdwo
|
||||||
1
scripts/building/hearth.gd.uid
Normal file
1
scripts/building/hearth.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://mjs6qb5p3q1a
|
||||||
1
scripts/building/jar.gd.uid
Normal file
1
scripts/building/jar.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ciqsy8msggcke
|
||||||
1
scripts/building/lamp.gd.uid
Normal file
1
scripts/building/lamp.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cwvlv0fbxhlgj
|
||||||
@@ -23,6 +23,8 @@ const RECIPES := {
|
|||||||
{"inputs": {"raw_meat": 2, "wood": 1}, "output": "smoked_meat", "count": 2, "seconds": 60.0},
|
{"inputs": {"raw_meat": 2, "wood": 1}, "output": "smoked_meat", "count": 2, "seconds": 60.0},
|
||||||
{"inputs": {"perch": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
|
{"inputs": {"perch": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
|
||||||
{"inputs": {"trout": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
|
{"inputs": {"trout": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
|
||||||
|
{"inputs": {"mackerel": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
|
||||||
|
{"inputs": {"cod": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
## What the prompt says it makes, and what it needs when idle.
|
## What the prompt says it makes, and what it needs when idle.
|
||||||
|
|||||||
1
scripts/building/processor.gd.uid
Normal file
1
scripts/building/processor.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://jvcfwahbmdsm
|
||||||
1
scripts/building/rug.gd.uid
Normal file
1
scripts/building/rug.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d21if6sepl888
|
||||||
1
scripts/building/stonecutter.gd.uid
Normal file
1
scripts/building/stonecutter.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dk1ge2xxgiev1
|
||||||
@@ -34,6 +34,7 @@ func _ready() -> void:
|
|||||||
_bind("drop", [KEY_Q]) # (shift+Q drops just one)
|
_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_up", [KEY_K]) # the 1-8 keys pick from the bag row above...
|
||||||
_bind("row_down", [KEY_M]) # ...or below
|
_bind("row_down", [KEY_M]) # ...or below
|
||||||
|
_bind("map", [KEY_N]) # the map (Gitea issue #1)
|
||||||
for i in range(8):
|
for i in range(8):
|
||||||
_bind("hotbar_%d" % (i + 1), [KEY_1 + i])
|
_bind("hotbar_%d" % (i + 1), [KEY_1 + i])
|
||||||
_bind_mouse("attack", MOUSE_BUTTON_LEFT)
|
_bind_mouse("attack", MOUSE_BUTTON_LEFT)
|
||||||
@@ -257,6 +258,14 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
|
|||||||
return false
|
return false
|
||||||
var held: Dictionary = ItemDB.get_item(player.held_item_id())
|
var held: Dictionary = ItemDB.get_item(player.held_item_id())
|
||||||
var tool: Dictionary = held.get("tool", {})
|
var tool: Dictionary = held.get("tool", {})
|
||||||
|
if node.has_method("refusal"): # (the cliff tunnel's faces: their own rules first)
|
||||||
|
var why: String = node.refusal()
|
||||||
|
if not why.is_empty():
|
||||||
|
notify.emit(why)
|
||||||
|
return false
|
||||||
|
if tool.get("kind", "") == node.tool_kind and int(tool.get("tier", 0)) < node.min_tier:
|
||||||
|
notify.emit("Too hard: you need a better pick")
|
||||||
|
return false
|
||||||
if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier:
|
if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier:
|
||||||
notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
|
notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
|
||||||
return false
|
return false
|
||||||
|
|||||||
1
scripts/core/boot.gd.uid
Normal file
1
scripts/core/boot.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://frm15unnr4w5
|
||||||
88
scripts/core/bug_report.gd
Normal file
88
scripts/core/bug_report.gd
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Report a bug (0.6.0, the playtest; pause menu): opens a new issue on the
|
||||||
|
## Emberwild repo in your browser, already filled in with a title to edit,
|
||||||
|
## a few questions to answer, and the details we'd ask for anyway (version,
|
||||||
|
## computer, single-player or online, which land, the day and time, where you
|
||||||
|
## are). You read it, add what happened, and press Create Issue: nothing is
|
||||||
|
## sent from the game itself. Posting needs an account on the Gitea server.
|
||||||
|
##
|
||||||
|
## The game's log (Godot writes it to user://logs/godot.log) helps with a
|
||||||
|
## crash or an error: the pause menu shows where it is and opens its folder,
|
||||||
|
## to attach the file to the issue.
|
||||||
|
|
||||||
|
const REPO := "https://git.psbailey.uk/Admin/Emberwild"
|
||||||
|
|
||||||
|
## An autoload (Net, Updates), looked up at run time: this script is also
|
||||||
|
## loaded by tests that start before the autoloads are compiled in.
|
||||||
|
static func _al(n: String) -> Node:
|
||||||
|
return (Engine.get_main_loop() as SceneTree).root.get_node_or_null(n)
|
||||||
|
|
||||||
|
## Where this game's log is, on disk.
|
||||||
|
static func log_path() -> String:
|
||||||
|
return ProjectSettings.globalize_path("user://logs/godot.log")
|
||||||
|
|
||||||
|
## The same, with your home folder as "~" (so your user name isn't in an
|
||||||
|
## issue anyone can read).
|
||||||
|
static func log_path_short() -> String:
|
||||||
|
var home := OS.get_environment("HOME") if OS.get_name() != "Windows" else OS.get_environment("USERPROFILE")
|
||||||
|
var p := log_path()
|
||||||
|
if not home.is_empty() and p.begins_with(home):
|
||||||
|
p = "~" + p.substr(home.length())
|
||||||
|
return p
|
||||||
|
|
||||||
|
static func log_dir() -> String:
|
||||||
|
return ProjectSettings.globalize_path("user://logs")
|
||||||
|
|
||||||
|
## How you're playing: "single-player", or online with the server's rules.
|
||||||
|
static func mode(world: Node) -> String:
|
||||||
|
if world == null or not world.has_method("is_online") or not world.is_online():
|
||||||
|
return "single-player"
|
||||||
|
var net := _al("Net")
|
||||||
|
var rules := "PvP" if bool(net.server_info.get("pvp", false)) else "co-op"
|
||||||
|
if bool(net.server_info.get("raiding", false)):
|
||||||
|
rules += ", raiding"
|
||||||
|
return "online (%s%s)" % [rules, ", hosting" if net.hosting else ""]
|
||||||
|
|
||||||
|
## The details, one per line (what's in the issue, and what "Copy" copies).
|
||||||
|
static func details(world: Node) -> Dictionary:
|
||||||
|
var d := {
|
||||||
|
"Version": "Emberwild %s (protocol %d)" % [String(ProjectSettings.get_setting("application/config/version", "?")), int(_al("Net").PROTOCOL)],
|
||||||
|
"Computer": "%s %s" % [OS.get_name(), OS.get_version()],
|
||||||
|
"Graphics": "%s (%s)" % [RenderingServer.get_video_adapter_name(), RenderingServer.get_current_rendering_method()],
|
||||||
|
"Mode": mode(world),
|
||||||
|
}
|
||||||
|
if world != null and is_instance_valid(world) and world.has_method("worldlet_id"):
|
||||||
|
d["Land"] = preload("res://scripts/world/terrain.gd").worldlet_name(String(world.worldlet_id()))
|
||||||
|
if world.get("day_night") != null:
|
||||||
|
var h: float = world.day_night.hours()
|
||||||
|
d["Time"] = "day %d, %02d:%02d" % [int(world.day_night.day), int(h), int(fmod(h, 1.0) * 60.0)]
|
||||||
|
if world.get("player") != null and is_instance_valid(world.player):
|
||||||
|
var p: Vector3 = world.player.global_position
|
||||||
|
d["Where"] = "%.0f, %.0f, %.0f" % [p.x, p.y, p.z]
|
||||||
|
if world.get("terrain") != null:
|
||||||
|
d["Seed"] = str(world.terrain.noise_seed)
|
||||||
|
return d
|
||||||
|
|
||||||
|
static func details_text(world: Node) -> String:
|
||||||
|
var d := details(world)
|
||||||
|
var lines: Array[String] = []
|
||||||
|
for k in d:
|
||||||
|
lines.append("- %s: %s" % [k, d[k]])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
static func body(world: Node) -> String:
|
||||||
|
return "\n".join([
|
||||||
|
"**What happened?**", "", "",
|
||||||
|
"**What did you expect to happen?**", "", "",
|
||||||
|
"**How can we make it happen again?** (what you did just before)", "1. ", "",
|
||||||
|
"**Screenshot or log** (optional: drag a screenshot here, or the log file from %s)" % log_path_short(), "",
|
||||||
|
"---", details_text(world)])
|
||||||
|
|
||||||
|
## The new-issue page, filled in.
|
||||||
|
static func issue_url(world: Node, title: String = "") -> String:
|
||||||
|
var t := title if not title.is_empty() else "Bug: "
|
||||||
|
return "%s/issues/new?title=%s&body=%s" % [REPO, t.uri_encode(), body(world).uri_encode()]
|
||||||
|
|
||||||
|
## Opens it in the browser. Returns false when the browser couldn't be opened.
|
||||||
|
static func open(world: Node) -> bool:
|
||||||
|
return OS.shell_open(issue_url(world)) == OK
|
||||||
1
scripts/core/bug_report.gd.uid
Normal file
1
scripts/core/bug_report.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://tnlssmningwq
|
||||||
1
scripts/core/guide.gd.uid
Normal file
1
scripts/core/guide.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://nva2x7o0ggw1
|
||||||
@@ -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()
|
return not dead and world.day_night.is_night() and world.day_night.wolf_moon_now()
|
||||||
"hard_winter":
|
"hard_winter":
|
||||||
return not dead and world.weather != null and world.weather.in_hard_winter()
|
return not dead and world.weather != null and world.weather.in_hard_winter()
|
||||||
|
"worldlet": # (where you are on the journey: "worldlet:cove")
|
||||||
|
return not dead and world.has_method("worldlet_id") and String(world.worldlet_id()) in vals
|
||||||
|
"seam": # (an antler pickaxe in your bag, and the cliff tunnel's seam still to dig)
|
||||||
|
return not dead and world.get("tunnel") != null and not world.tunnel.opened \
|
||||||
|
and player.inventory.count("antler_pickaxe") > 0
|
||||||
return false
|
return false
|
||||||
|
|||||||
1
scripts/core/hints.gd.uid
Normal file
1
scripts/core/hints.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cd4pyfn6igpx0
|
||||||
@@ -92,7 +92,8 @@ func define_water(water: String, d: Dictionary) -> void:
|
|||||||
if not items.has(f.get("id", "")):
|
if not items.has(f.get("id", "")):
|
||||||
push_error("ItemDB: fish '%s' in water '%s' is not an item" % [f.get("id", "?"), water])
|
push_error("ItemDB: fish '%s' in water '%s' is not an item" % [f.get("id", "?"), water])
|
||||||
continue
|
continue
|
||||||
fish.append({"id": f.id, "weight": float(f.get("weight", 1)), "dawn_dusk": float(f.get("dawn_dusk", f.get("weight", 1)))})
|
fish.append({"id": f.id, "weight": float(f.get("weight", 1)), "dawn_dusk": float(f.get("dawn_dusk", f.get("weight", 1))),
|
||||||
|
"min_depth": float(f.get("min_depth", 0.0))}) # (a fish of deeper water: cod)
|
||||||
var w: Array = d.get("bite_wait", [5.0, 14.0])
|
var w: Array = d.get("bite_wait", [5.0, 14.0])
|
||||||
fishing.waters[water] = {"min_depth": float(d.get("min_depth", 0.5)), "bite_wait": [float(w[0]), float(w[1])], "fish": fish}
|
fishing.waters[water] = {"min_depth": float(d.get("min_depth", 0.5)), "bite_wait": [float(w[0]), float(w[1])], "fish": fish}
|
||||||
|
|
||||||
|
|||||||
1
scripts/core/music.gd.uid
Normal file
1
scripts/core/music.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://jojnurcejyff
|
||||||
1
scripts/core/narrator.gd.uid
Normal file
1
scripts/core/narrator.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ctvohlyg6yr5m
|
||||||
@@ -56,16 +56,17 @@ func create_world(world_name: String, seed: int, options: Dictionary = {}) -> St
|
|||||||
var now := Time.get_unix_time_from_system()
|
var now := Time.get_unix_time_from_system()
|
||||||
var meta := {
|
var meta := {
|
||||||
"name": world_name, "seed": seed, "created": now, "last_played": now,
|
"name": world_name, "seed": seed, "created": now, "last_played": now,
|
||||||
"play_time": 0.0, "version": VERSION,
|
"play_time": 0.0, "version": VERSION, "worldlet": "valley",
|
||||||
}
|
}
|
||||||
for k in options:
|
for k in options:
|
||||||
meta[k] = options[k]
|
meta[k] = options[k]
|
||||||
|
meta["home"] = String(meta.worldlet)
|
||||||
_write_json(_dir(id).path_join("meta.json"), meta)
|
_write_json(_dir(id).path_join("meta.json"), meta)
|
||||||
return id
|
return id
|
||||||
|
|
||||||
## The world slot called exactly `id` (a server's world), made with `seed` if
|
## The world slot called exactly `id` (a server's world), made with `seed` if
|
||||||
## it doesn't exist yet. An existing world keeps its own seed.
|
## it doesn't exist yet. An existing world keeps its own seed.
|
||||||
func open_or_create(id: String, seed: int) -> String:
|
func open_or_create(id: String, seed: int, worldlet: String = "valley") -> String:
|
||||||
if not read_meta(id).is_empty():
|
if not read_meta(id).is_empty():
|
||||||
var had := int(read_meta(id).get("seed", seed))
|
var had := int(read_meta(id).get("seed", seed))
|
||||||
if had != seed:
|
if had != seed:
|
||||||
@@ -75,7 +76,7 @@ func open_or_create(id: String, seed: int) -> String:
|
|||||||
var now := Time.get_unix_time_from_system()
|
var now := Time.get_unix_time_from_system()
|
||||||
_write_json(_dir(id).path_join("meta.json"), {
|
_write_json(_dir(id).path_join("meta.json"), {
|
||||||
"name": id, "seed": seed, "created": now, "last_played": now,
|
"name": id, "seed": seed, "created": now, "last_played": now,
|
||||||
"play_time": 0.0, "version": VERSION,
|
"play_time": 0.0, "version": VERSION, "worldlet": worldlet,
|
||||||
})
|
})
|
||||||
return id
|
return id
|
||||||
|
|
||||||
@@ -84,6 +85,8 @@ func delete_world(id: String) -> void:
|
|||||||
var d := DirAccess.open(dir)
|
var d := DirAccess.open(dir)
|
||||||
if d == null:
|
if d == null:
|
||||||
return
|
return
|
||||||
|
for sub in d.get_directories(): # (worldlets/, and a server's backups/)
|
||||||
|
_remove_tree(dir.path_join(sub))
|
||||||
for f in d.get_files():
|
for f in d.get_files():
|
||||||
d.remove(f)
|
d.remove(f)
|
||||||
DirAccess.remove_absolute(dir)
|
DirAccess.remove_absolute(dir)
|
||||||
@@ -93,16 +96,70 @@ func delete_world(id: String) -> void:
|
|||||||
func read_meta(id: String) -> Dictionary:
|
func read_meta(id: String) -> Dictionary:
|
||||||
return _read_json(_dir(id).path_join("meta.json"))
|
return _read_json(_dir(id).path_join("meta.json"))
|
||||||
|
|
||||||
func load_state(id: String) -> Dictionary:
|
# --- worldlets in one world (J1, docs/WORLDLETS_DESIGN.md §10) --------------------
|
||||||
var s := _read_json(_dir(id).path_join("world.json"))
|
# A world can span several worldlets (the journey). Each keeps its own state
|
||||||
if s.is_empty() and FileAccess.file_exists(_dir(id).path_join("world.json.bak")):
|
# file: the one it began in (its "home") is world.json, as before, so older
|
||||||
push_warning("Saves: world.json unreadable, using backup")
|
# saves are unchanged; the others are worldlets/<id>.json. meta.json's
|
||||||
s = _read_json(_dir(id).path_join("world.json.bak"))
|
# "worldlet" is where you are now, so that's the one loaded.
|
||||||
|
|
||||||
|
## A land server (J3, `--land cove`) runs one worldlet of a server's world:
|
||||||
|
## this is that worldlet ("" = whatever the world's meta says).
|
||||||
|
var land := ""
|
||||||
|
|
||||||
|
## The worldlet world `id` is being played in now.
|
||||||
|
func worldlet_of(id: String) -> String:
|
||||||
|
if not land.is_empty():
|
||||||
|
return land
|
||||||
|
return String(read_meta(id).get("worldlet", "valley"))
|
||||||
|
|
||||||
|
## The worldlet world `id` began in (its world.json).
|
||||||
|
func home_of(id: String) -> String:
|
||||||
|
var m := read_meta(id)
|
||||||
|
return String(m.get("home", m.get("worldlet", "valley")))
|
||||||
|
|
||||||
|
## The state file for worldlet `w` of world `id`.
|
||||||
|
func state_path(id: String, w: String) -> String:
|
||||||
|
if w == home_of(id):
|
||||||
|
return _dir(id).path_join("world.json")
|
||||||
|
return _dir(id).path_join("worldlets/%s.json" % w)
|
||||||
|
|
||||||
|
## Whether world `id` has been to worldlet `w` (has a state for it).
|
||||||
|
func has_state(id: String, w: String) -> bool:
|
||||||
|
return FileAccess.file_exists(state_path(id, w))
|
||||||
|
|
||||||
|
## Moves world `id` to worldlet `to` (the next load is there), arriving at
|
||||||
|
## `arrive` (a place the arriving world puts you: "sea_cave", "tunnel_bottom").
|
||||||
|
func set_worldlet(id: String, to: String, arrive: String) -> void:
|
||||||
|
var m := read_meta(id)
|
||||||
|
if not m.has("home"):
|
||||||
|
m["home"] = String(m.get("worldlet", "valley")) # (pin it before "worldlet" moves)
|
||||||
|
m["worldlet"] = to
|
||||||
|
m["arrive"] = arrive
|
||||||
|
_write_json(_dir(id).path_join("meta.json"), m)
|
||||||
|
|
||||||
|
## Where the next load should put you, once (then forgotten).
|
||||||
|
func take_arrival(id: String) -> String:
|
||||||
|
var m := read_meta(id)
|
||||||
|
var a := String(m.get("arrive", ""))
|
||||||
|
if not a.is_empty():
|
||||||
|
m.erase("arrive")
|
||||||
|
_write_json(_dir(id).path_join("meta.json"), m)
|
||||||
|
return a
|
||||||
|
|
||||||
|
## The state of the worldlet being played (or of worldlet `w`).
|
||||||
|
func load_state(id: String, w: String = "") -> Dictionary:
|
||||||
|
var path := state_path(id, worldlet_of(id) if w.is_empty() else w)
|
||||||
|
var s := _read_json(path)
|
||||||
|
if s.is_empty() and FileAccess.file_exists(path + ".bak"):
|
||||||
|
push_warning("Saves: %s unreadable, using backup" % path.get_file())
|
||||||
|
s = _read_json(path + ".bak")
|
||||||
return s
|
return s
|
||||||
|
|
||||||
## Writes the state for `id` and updates its meta. Returns true on success.
|
## Writes the state for `id` (the worldlet being played, or `w`) and
|
||||||
func save_state(id: String, state: Dictionary, session_seconds: float) -> bool:
|
## updates its meta. Returns true on success.
|
||||||
var path := _dir(id).path_join("world.json")
|
func save_state(id: String, state: Dictionary, session_seconds: float, w: String = "") -> bool:
|
||||||
|
var path := state_path(id, worldlet_of(id) if w.is_empty() else w)
|
||||||
|
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||||
if FileAccess.file_exists(path):
|
if FileAccess.file_exists(path):
|
||||||
DirAccess.copy_absolute(path, path + ".bak")
|
DirAccess.copy_absolute(path, path + ".bak")
|
||||||
state["version"] = VERSION
|
state["version"] = VERSION
|
||||||
@@ -129,3 +186,10 @@ func _read_json(path: String) -> Dictionary:
|
|||||||
return {}
|
return {}
|
||||||
var data = JSON.parse_string(FileAccess.get_file_as_string(path))
|
var data = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||||
return data if typeof(data) == TYPE_DICTIONARY else {}
|
return data if typeof(data) == TYPE_DICTIONARY else {}
|
||||||
|
|
||||||
|
static func _remove_tree(d: String) -> void:
|
||||||
|
for sub in DirAccess.get_directories_at(d):
|
||||||
|
_remove_tree(d.path_join(sub))
|
||||||
|
for f in DirAccess.get_files_at(d):
|
||||||
|
DirAccess.remove_absolute(d.path_join(f))
|
||||||
|
DirAccess.remove_absolute(d)
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ const SOUNDS := {
|
|||||||
"guard_rattle": {"set": [C + "guard_rattle_%d.ogg", 2], "db": -2.0, "pitch": [0.85, 1.1], "reach": 25.0},
|
"guard_rattle": {"set": [C + "guard_rattle_%d.ogg", 2], "db": -2.0, "pitch": [0.85, 1.1], "reach": 25.0},
|
||||||
"crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0},
|
"crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0},
|
||||||
"fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0},
|
"fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0},
|
||||||
|
"gull_cry": {"set": [C + "gull_cry_%d.ogg", 2], "db": -5.0, "pitch": [0.9, 1.12], "reach": 50.0},
|
||||||
|
"crab_click": {"set": [C + "crab_click_%d.ogg", 2], "db": -6.0, "pitch": [0.85, 1.15], "reach": 14.0},
|
||||||
}
|
}
|
||||||
const BUSES := ["Music", "SFX", "Ambience", "Voice"]
|
const BUSES := ["Music", "SFX", "Ambience", "Voice"]
|
||||||
const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7, "Voice": 1.0}
|
const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7, "Voice": 1.0}
|
||||||
|
|||||||
1
scripts/core/sfx.gd.uid
Normal file
1
scripts/core/sfx.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://br583euuk842q
|
||||||
1
scripts/core/updates.gd.uid
Normal file
1
scripts/core/updates.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://3lflq11qfg00
|
||||||
1
scripts/creatures/bear.gd.uid
Normal file
1
scripts/creatures/bear.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://co3yk3efstys2
|
||||||
1
scripts/creatures/bear_range.gd.uid
Normal file
1
scripts/creatures/bear_range.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ce34amx3oagyx
|
||||||
113
scripts/creatures/crab.gd
Normal file
113
scripts/creatures/crab.gd
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
extends "res://scripts/creatures/creature.gd"
|
||||||
|
## A crab (0.6.0, the Cove only): shore.gd brings them up out of the sea
|
||||||
|
## onto the beach at dusk. They scuttle about the sand sideways and keep to
|
||||||
|
## it. Walk within NIP of one and it comes at you, claws up, and pinches
|
||||||
|
## (not much, but it adds up with three of them about). Leave it NIP_GIVE_UP
|
||||||
|
## behind and it forgets you. Hit one hard enough and it runs for it. At
|
||||||
|
## dawn they all go back into the sea. A dead one gives crab meat.
|
||||||
|
|
||||||
|
const NIP := 2.2 ## this close and it comes for you
|
||||||
|
const NIP_GIVE_UP := 6.0 ## this far and it lets you go
|
||||||
|
const HOME_FOR := 40.0 ## at dawn: this long to reach the water before it simply goes
|
||||||
|
|
||||||
|
func interact_prompt(p: Node) -> String:
|
||||||
|
if is_dead():
|
||||||
|
return "Crab [E] Crack it open"
|
||||||
|
match state:
|
||||||
|
"charge": return "Crab (claws up!)"
|
||||||
|
"home": return "Crab (heading back to the sea)"
|
||||||
|
return "Crab (keep your distance, or it'll pinch)"
|
||||||
|
|
||||||
|
## Daytime: back to the sea.
|
||||||
|
func _daytime() -> bool:
|
||||||
|
var h: float = world.day_night.hours()
|
||||||
|
return h >= 6.5 and h < 17.5
|
||||||
|
|
||||||
|
## Which way the sea is: straight out from the middle of the bay.
|
||||||
|
func _seaward() -> Vector3:
|
||||||
|
var a := deg_to_rad(float(terrain.beach_angle_deg))
|
||||||
|
return Vector3(cos(a), 0.0, sin(a))
|
||||||
|
|
||||||
|
func _think(dt: float) -> void:
|
||||||
|
if state != "home" and state != "flee" and _daytime():
|
||||||
|
state = "home"
|
||||||
|
_state_t = HOME_FOR
|
||||||
|
var pp: Vector3 = player.global_position
|
||||||
|
var d := global_position.distance_to(pp)
|
||||||
|
match state:
|
||||||
|
"home":
|
||||||
|
var to := _seaward()
|
||||||
|
steer(atan2(-to.x, -to.z))
|
||||||
|
_state_t -= dt
|
||||||
|
if _state_t <= 0.0 or global_position.y < terrain.water_level - 0.25:
|
||||||
|
queue_free() # (into the waves)
|
||||||
|
return
|
||||||
|
"idle", "wander":
|
||||||
|
if d < NIP:
|
||||||
|
state = "charge"
|
||||||
|
_aggro = def.aggro
|
||||||
|
_attack_cd = 0.5 # (a moment to see it coming)
|
||||||
|
return
|
||||||
|
"charge":
|
||||||
|
if d < NIP_GIVE_UP:
|
||||||
|
_aggro = def.aggro # (it keeps on at you while you're close)
|
||||||
|
else:
|
||||||
|
_aggro = 0.0
|
||||||
|
super._think(dt)
|
||||||
|
|
||||||
|
func _desired_speed() -> float:
|
||||||
|
if state == "home":
|
||||||
|
return def.walk * 1.6
|
||||||
|
return super._desired_speed()
|
||||||
|
|
||||||
|
## Keeps to the sand (and out of the water) until it's going home.
|
||||||
|
func _blocked(dir: Vector3) -> bool:
|
||||||
|
var probe := global_position + dir * (0.8 + speed * 0.15)
|
||||||
|
if not terrain.inside_boundary(probe.x, probe.z, 4.0):
|
||||||
|
return true
|
||||||
|
if state == "home":
|
||||||
|
return false # (straight down the beach and into the sea)
|
||||||
|
if not terrain.on_sand(probe.x, probe.z):
|
||||||
|
return true
|
||||||
|
return super._blocked(dir)
|
||||||
|
|
||||||
|
## Hit: it fights back while it can, then runs.
|
||||||
|
func _react(from: Vector3) -> void:
|
||||||
|
if hp > def.hp * 0.35:
|
||||||
|
state = "charge"
|
||||||
|
_aggro = def.aggro
|
||||||
|
_attack_cd = 0.3
|
||||||
|
else:
|
||||||
|
_start_flee(from)
|
||||||
|
_state_t = 5.0
|
||||||
|
|
||||||
|
## Sideways, legs a-flicker; the claws come up when it's after you.
|
||||||
|
func _animate(dt: float) -> void:
|
||||||
|
_visual.rotation.y = PI * 0.5 # (it walks sideways)
|
||||||
|
if is_dead():
|
||||||
|
return
|
||||||
|
var k := clampf(speed / maxf(def.run, 0.01), 0.0, 1.0)
|
||||||
|
_phase += dt * (6.0 + speed * 14.0)
|
||||||
|
for i in range(_legs.size()):
|
||||||
|
var l: Node3D = _legs[i]
|
||||||
|
var s := -1.0 if l.position.x < 0.0 else 1.0
|
||||||
|
l.rotation.z = s * (1.0 + sin(_phase + i * 1.9) * (0.08 + 0.3 * k))
|
||||||
|
l.rotation.x = cos(_phase + i * 1.9) * 0.25 * k
|
||||||
|
var up := 0.12 if state == "charge" or (puppet and speed > 1.5) else 0.0
|
||||||
|
_visual.rotation.x = lerpf(_visual.rotation.x, -up, minf(dt * 6.0, 1.0))
|
||||||
|
_visual.position.y = absf(sin(_phase * 0.5)) * 0.01 * k
|
||||||
|
|
||||||
|
## A crab doesn't topple onto its side: it ends up on its back.
|
||||||
|
func _die(_from: Vector3) -> void:
|
||||||
|
state = "dead"
|
||||||
|
speed = 0.0
|
||||||
|
hp = 0.0
|
||||||
|
_flash = 0.0
|
||||||
|
_set_material(_mat)
|
||||||
|
remove_from_group("creature")
|
||||||
|
add_to_group("carcass")
|
||||||
|
collision_layer = 2
|
||||||
|
_visual.rotation = Vector3(0.0, PI * 0.5, PI)
|
||||||
|
_visual.position.y = 0.1
|
||||||
|
if not puppet:
|
||||||
|
died.emit(self)
|
||||||
1
scripts/creatures/crab.gd.uid
Normal file
1
scripts/creatures/crab.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://kqumi2x6lfai
|
||||||
@@ -54,12 +54,28 @@ const KINDS := {
|
|||||||
"fox": {"name": "Fox", "hp": 25.0, "walk": 1.5, "run": 7.6, "turn": 7.0,
|
"fox": {"name": "Fox", "hp": 25.0, "walk": 1.5, "run": 7.6, "turn": 7.0,
|
||||||
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
||||||
"drops": {"raw_meat": [1, 1], "hide": [1, 1]}},
|
"drops": {"raw_meat": [1, 1], "hide": [1, 1]}},
|
||||||
|
# 0.6.0, the Cove only: crabs come up the beach at dusk and pinch anyone
|
||||||
|
# who walks too close (crab.gd, shore.gd); gulls are the Cove's crows,
|
||||||
|
# after food left on the sand and fish on a smoking rack (gull.gd).
|
||||||
|
"crab": {"name": "Crab", "hp": 14.0, "walk": 0.9, "run": 2.6, "turn": 7.0,
|
||||||
|
"notice": 0.0, "notice_loud": 0.0, "calm": 20.0,
|
||||||
|
"damage": 5.0, "attack_range": 1.7, "attack_cd": 1.3, "aggro": 5.0,
|
||||||
|
"drops": {"crab_meat": [1, 2]}},
|
||||||
|
"gull": {"name": "Gull", "hp": 8.0, "walk": 1.0, "run": 11.0, "turn": 8.0,
|
||||||
|
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
||||||
|
"drops": {"feather": [1, 3]}},
|
||||||
# The barrow's skeletal guards (guard.gd, barrow.gd). Slow (you can walk
|
# The barrow's skeletal guards (guard.gd, barrow.gd). Slow (you can walk
|
||||||
# away from one) but they hit hard; bone, so blunt weapons do +50%.
|
# away from one) but they hit hard; bone, so blunt weapons do +50%.
|
||||||
"guard": {"name": "Barrow Guard", "hp": 80.0, "walk": 1.4, "run": 3.4, "turn": 4.0,
|
"guard": {"name": "Barrow Guard", "hp": 80.0, "walk": 1.4, "run": 3.4, "turn": 4.0,
|
||||||
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
||||||
"damage": 20.0, "attack_range": 2.0, "attack_cd": 2.0, "bone": true,
|
"damage": 20.0, "attack_range": 2.0, "attack_cd": 2.0, "bone": true,
|
||||||
"drops": {"iron_nugget": [1, 2]}},
|
"drops": {"iron_nugget": [1, 2]}},
|
||||||
|
# 0.6.0: the drowned crew (guard.gd, at a treasure dig: treasure.gd). The
|
||||||
|
# Cove's barrow guards: slow, hard-hitting, unafraid of fire.
|
||||||
|
"drowned": {"name": "Drowned Sailor", "hp": 70.0, "walk": 1.3, "run": 3.1, "turn": 4.0,
|
||||||
|
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
||||||
|
"damage": 16.0, "attack_range": 2.0, "attack_cd": 1.8, "bone": true,
|
||||||
|
"drops": {"salt": [0, 1]}},
|
||||||
# P5: the Old Stag (old_stag.gd), called by the standing stones, and the
|
# P5: the Old Stag (old_stag.gd), called by the standing stones, and the
|
||||||
# spirit wolves it summons (they just run at you: the base "charge").
|
# spirit wolves it summons (they just run at you: the base "charge").
|
||||||
"old_stag": {"name": "Old Stag", "hp": 900.0, "walk": 2.0, "run": 13.0, "turn": 3.0,
|
"old_stag": {"name": "Old Stag", "hp": 900.0, "walk": 2.0, "run": 13.0, "turn": 3.0,
|
||||||
@@ -140,7 +156,7 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
|
|||||||
var eyes := MeshInstance3D.new()
|
var eyes := MeshInstance3D.new()
|
||||||
eyes.mesh = parts.eyes
|
eyes.mesh = parts.eyes
|
||||||
var em := StandardMaterial3D.new()
|
var em := StandardMaterial3D.new()
|
||||||
var eye: Color = {"alpha": Color(1.0, 0.18, 0.1), "guard": Color(0.45, 0.8, 1.0), "old_stag": Color(0.55, 0.95, 1.0),
|
var eye: Color = {"alpha": Color(1.0, 0.18, 0.1), "guard": Color(0.45, 0.8, 1.0), "drowned": Color(0.5, 1.0, 0.6), "old_stag": Color(0.55, 0.95, 1.0),
|
||||||
"spirit_wolf": Color(0.55, 0.95, 1.0)}.get(k, Color(1.0, 0.75, 0.3))
|
"spirit_wolf": Color(0.55, 0.95, 1.0)}.get(k, Color(1.0, 0.75, 0.3))
|
||||||
em.albedo_color = eye.lerp(Color.WHITE, 0.2)
|
em.albedo_color = eye.lerp(Color.WHITE, 0.2)
|
||||||
em.emission_enabled = true
|
em.emission_enabled = true
|
||||||
@@ -164,9 +180,9 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
|
|||||||
cap.height = maxf(parts.height, parts.radius * 2.0 + 0.01)
|
cap.height = maxf(parts.height, parts.radius * 2.0 + 0.01)
|
||||||
cs.shape = cap
|
cs.shape = cap
|
||||||
cs.position = Vector3(0, parts.height * 0.5, 0)
|
cs.position = Vector3(0, parts.height * 0.5, 0)
|
||||||
if k != "rabbit" and k != "guard": # (rabbits are round, guards stand upright)
|
if not k in ["rabbit", "guard", "drowned"]: # (rabbits are round, guards stand upright)
|
||||||
# Long bodies: lay the capsule along the spine.
|
# Long bodies: lay the capsule along the spine.
|
||||||
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0, "fox": 0.8, "crow": 0.3, "old_stag": 3.3, "spirit_wolf": 1.25}.get(k, 1.4)
|
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0, "fox": 0.8, "crow": 0.3, "gull": 0.4, "crab": 0.45, "old_stag": 3.3, "spirit_wolf": 1.25}.get(k, 1.4)
|
||||||
cs.rotation.x = PI * 0.5
|
cs.rotation.x = PI * 0.5
|
||||||
cs.position = Vector3(0, parts.height * 0.55, 0)
|
cs.position = Vector3(0, parts.height * 0.55, 0)
|
||||||
add_child(cs)
|
add_child(cs)
|
||||||
@@ -516,8 +532,11 @@ const VOICES := {
|
|||||||
"boar": {"alert": "boar_grunt", "angry": "boar_grunt", "attack": "boar_grunt", "hurt": "boar_squeal", "idle": "boar_grunt"},
|
"boar": {"alert": "boar_grunt", "angry": "boar_grunt", "attack": "boar_grunt", "hurt": "boar_squeal", "idle": "boar_grunt"},
|
||||||
"old_stag": {"alert": "stag_bellow", "angry": "stag_bellow", "attack": "stag_snort", "hurt": "stag_snort"},
|
"old_stag": {"alert": "stag_bellow", "angry": "stag_bellow", "attack": "stag_snort", "hurt": "stag_snort"},
|
||||||
"guard": {"alert": "guard_rattle", "angry": "guard_rattle", "attack": "guard_rattle"},
|
"guard": {"alert": "guard_rattle", "angry": "guard_rattle", "attack": "guard_rattle"},
|
||||||
|
"drowned": {"alert": "guard_rattle", "angry": "guard_rattle", "attack": "guard_rattle"},
|
||||||
"crow": {"alert": "crow_caw", "hurt": "crow_caw", "idle": "crow_caw"},
|
"crow": {"alert": "crow_caw", "hurt": "crow_caw", "idle": "crow_caw"},
|
||||||
"fox": {"alert": "fox_yip", "hurt": "fox_yip"},
|
"fox": {"alert": "fox_yip", "hurt": "fox_yip"},
|
||||||
|
"gull": {"alert": "gull_cry", "hurt": "gull_cry", "idle": "gull_cry"},
|
||||||
|
"crab": {"alert": "crab_click", "attack": "crab_click"},
|
||||||
}
|
}
|
||||||
## States a creature is in when it's after someone (music.gd's list too).
|
## States a creature is in when it's after someone (music.gd's list too).
|
||||||
const ANGRY := ["charge", "stalk", "lunge", "bash", "stomp", "telegraph", "warn", "raid", "summon"]
|
const ANGRY := ["charge", "stalk", "lunge", "bash", "stomp", "telegraph", "warn", "raid", "summon"]
|
||||||
|
|||||||
1
scripts/creatures/creature.gd.uid
Normal file
1
scripts/creatures/creature.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cdxggdwnq1ctn
|
||||||
@@ -22,7 +22,10 @@ static func build(kind: String) -> Dictionary:
|
|||||||
"bear": d = _bear()
|
"bear": d = _bear()
|
||||||
"fox": d = _scaled(_wolf_parts(Color(0.78, 0.4, 0.15), Color(0.92, 0.88, 0.8), 281), 0.6)
|
"fox": d = _scaled(_wolf_parts(Color(0.78, 0.4, 0.15), Color(0.92, 0.88, 0.8), 281), 0.6)
|
||||||
"crow": d = _crow()
|
"crow": d = _crow()
|
||||||
|
"gull": d = _gull()
|
||||||
|
"crab": d = _crab()
|
||||||
"guard": d = _guard()
|
"guard": d = _guard()
|
||||||
|
"drowned": d = _drowned()
|
||||||
"old_stag": d = _scaled(_stag(), 2.1)
|
"old_stag": d = _scaled(_stag(), 2.1)
|
||||||
"spirit_wolf": d = _wolf_parts(Color(0.62, 0.72, 0.78), Color(0.85, 0.92, 0.95), 351)
|
"spirit_wolf": d = _wolf_parts(Color(0.62, 0.72, 0.78), Color(0.85, 0.92, 0.95), 351)
|
||||||
_: d = _boar()
|
_: d = _boar()
|
||||||
@@ -207,6 +210,61 @@ static func _crow() -> Dictionary:
|
|||||||
"hips": [Vector3(-0.06, 0.19, -0.01), Vector3(0.06, 0.19, -0.01)],
|
"hips": [Vector3(-0.06, 0.19, -0.01), Vector3(0.06, 0.19, -0.01)],
|
||||||
"radius": 0.12, "height": 0.3}
|
"radius": 0.12, "height": 0.3}
|
||||||
|
|
||||||
|
## Gull: a crow's build, bigger, white with grey wings and a yellow beak.
|
||||||
|
static func _gull() -> Dictionary:
|
||||||
|
var white := Color(0.93, 0.93, 0.9)
|
||||||
|
var grey := Color(0.6, 0.63, 0.67)
|
||||||
|
var tip := Color(0.12, 0.12, 0.13)
|
||||||
|
var beak := Color(0.95, 0.75, 0.2)
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(295)
|
||||||
|
var n := _noise(295, 2.0)
|
||||||
|
P._blob(st, Vector3(0, 0.2, 0), Vector3(0.09, 0.085, 0.18), n, 0.08, white, r, 1)
|
||||||
|
P._blob(st, Vector3(0, 0.25, 0.04), Vector3(0.075, 0.05, 0.14), n, 0.06, grey, r, 1)
|
||||||
|
P._blob(st, Vector3(0, 0.28, -0.16), Vector3(0.06, 0.06, 0.065), n, 0.08, white, r, 1)
|
||||||
|
P._cylinder(st, Vector3(0, 0.275, -0.21), Vector3(0, 0.255, -0.3), 0.017, 0.004, 4, beak, r)
|
||||||
|
P._cylinder(st, Vector3(0, 0.22, 0.15), Vector3(0, 0.2, 0.3), 0.035, 0.05, 4, tip, r)
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
P._cylinder(st, Vector3(s * 0.03, 0.13, 0.0), Vector3(s * 0.035, 0.0, 0.02), 0.008, 0.007, 3, beak, r)
|
||||||
|
P._blob(st, Vector3(s * 0.04, 0.295, -0.19), Vector3.ONE * 0.009, n, 0.0, Color(0.1, 0.1, 0.1), r, 0)
|
||||||
|
var wst := P._begin()
|
||||||
|
P._blob(wst, Vector3(0, -0.2, 0.03), Vector3(0.014, 0.21, 0.09), n, 0.04, grey, r, 0)
|
||||||
|
P._blob(wst, Vector3(0, -0.38, 0.05), Vector3(0.012, 0.05, 0.06), n, 0.0, tip, r, 0)
|
||||||
|
return {"body": P._commit(st), "leg": P._commit(wst),
|
||||||
|
"hips": [Vector3(-0.08, 0.25, -0.01), Vector3(0.08, 0.25, -0.01)],
|
||||||
|
"radius": 0.15, "height": 0.38}
|
||||||
|
|
||||||
|
## Crab: a wide, flat red-brown shell facing -Z, two big claws in front and
|
||||||
|
## eyes on stalks; six thin legs splayed out to the sides (crab.gd turns the
|
||||||
|
## whole crab side-on to walk, and flickers the legs).
|
||||||
|
static func _crab() -> Dictionary:
|
||||||
|
var shell := Color(0.66, 0.22, 0.12)
|
||||||
|
var dark := Color(0.42, 0.13, 0.08)
|
||||||
|
var pale := Color(0.9, 0.72, 0.55)
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(297)
|
||||||
|
var n := _noise(297, 2.4)
|
||||||
|
P._blob(st, Vector3(0, 0.11, 0), Vector3(0.2, 0.06, 0.14), n, 0.1, shell, r, 1)
|
||||||
|
P._blob(st, Vector3(0, 0.08, 0.01), Vector3(0.17, 0.035, 0.12), n, 0.05, pale, r, 1)
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
# Arm, then the claw: a fat pincer and a thin finger.
|
||||||
|
P._cylinder(st, Vector3(s * 0.12, 0.1, -0.09), Vector3(s * 0.17, 0.13, -0.2), 0.025, 0.02, 4, shell, r)
|
||||||
|
P._blob(st, Vector3(s * 0.17, 0.14, -0.26), Vector3(0.05, 0.04, 0.07), n, 0.1, shell, r, 1)
|
||||||
|
P._cylinder(st, Vector3(s * 0.17, 0.15, -0.31), Vector3(s * 0.15, 0.16, -0.37), 0.02, 0.006, 4, dark, r)
|
||||||
|
P._cylinder(st, Vector3(s * 0.19, 0.12, -0.31), Vector3(s * 0.18, 0.12, -0.36), 0.014, 0.004, 4, dark, r)
|
||||||
|
# Eyes on stalks.
|
||||||
|
P._cylinder(st, Vector3(s * 0.04, 0.15, -0.11), Vector3(s * 0.05, 0.21, -0.13), 0.008, 0.006, 3, shell, r)
|
||||||
|
P._blob(st, Vector3(s * 0.05, 0.22, -0.13), Vector3.ONE * 0.014, n, 0.0, Color(0.05, 0.05, 0.05), r, 0)
|
||||||
|
var lst := P._begin()
|
||||||
|
P._cylinder(lst, Vector3(0, 0, 0), Vector3(0, -0.11, 0.0), 0.014, 0.01, 3, shell, r)
|
||||||
|
P._cylinder(lst, Vector3(0, -0.11, 0.0), Vector3(0, -0.2, 0.0), 0.01, 0.003, 3, dark, r)
|
||||||
|
var hips: Array = []
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
for z in [-0.05, 0.03, 0.1]:
|
||||||
|
hips.append(Vector3(s * 0.17, 0.1, z))
|
||||||
|
return {"body": P._commit(st), "leg": P._commit(lst), "hips": hips,
|
||||||
|
"radius": 0.2, "height": 0.25}
|
||||||
|
|
||||||
## Barrow guard: an upright skeleton facing -Z, in a rusted helm, an old
|
## Barrow guard: an upright skeleton facing -Z, in a rusted helm, an old
|
||||||
## sword in its right hand and a round shield on its left arm. Two legs
|
## sword in its right hand and a round shield on its left arm. Two legs
|
||||||
## (creature.gd walks them like a pair of a quadruped's).
|
## (creature.gd walks them like a pair of a quadruped's).
|
||||||
@@ -253,6 +311,53 @@ static func _guard() -> Dictionary:
|
|||||||
"hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)],
|
"hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)],
|
||||||
"radius": 0.3, "height": 1.85}
|
"radius": 0.3, "height": 1.85}
|
||||||
|
|
||||||
|
## A drowned sailor (0.6.0): the barrow guard's bones, sea-greyed and hung
|
||||||
|
## with weed, a rag of a shirt, a bandana, and a cutlass; no shield or helm.
|
||||||
|
static func _drowned() -> Dictionary:
|
||||||
|
var bone := Color(0.62, 0.66, 0.58)
|
||||||
|
var old := Color(0.44, 0.5, 0.42)
|
||||||
|
var weed := Color(0.16, 0.32, 0.18)
|
||||||
|
var rag := Color(0.36, 0.33, 0.27)
|
||||||
|
var red := Color(0.45, 0.12, 0.1)
|
||||||
|
var steel := Color(0.5, 0.52, 0.55)
|
||||||
|
var dark := Color(0.05, 0.07, 0.06)
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(331)
|
||||||
|
var n := _noise(331, 2.2)
|
||||||
|
P._blob(st, Vector3(0, 0.95, 0), Vector3(0.17, 0.07, 0.1), n, 0.2, old, r, 0) # pelvis
|
||||||
|
P._cylinder(st, Vector3(0, 0.97, 0.02), Vector3(0, 1.45, 0.0), 0.035, 0.03, 5, bone, r) # spine
|
||||||
|
for i in range(4): # ribs
|
||||||
|
var y := 1.18 + 0.075 * i
|
||||||
|
P._cylinder(st, Vector3(-0.15 + 0.01 * i, y, -0.02), Vector3(0.15 - 0.01 * i, y, -0.02), 0.018, 0.018, 4, bone, r)
|
||||||
|
P._blob(st, Vector3(0.02, 1.3, 0.0), Vector3(0.19, 0.17, 0.12), n, 0.45, rag, r, 0) # shirt, in tatters
|
||||||
|
P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(0.22, 1.47, 0), 0.025, 0.025, 4, bone, r) # shoulders
|
||||||
|
P._blob(st, Vector3(0, 1.66, -0.02), Vector3(0.11, 0.13, 0.12), n, 0.12, bone, r, 1) # skull
|
||||||
|
P._blob(st, Vector3(0, 1.56, -0.08), Vector3(0.07, 0.04, 0.06), n, 0.1, old, r, 0) # jaw
|
||||||
|
P._blob(st, Vector3(0, 1.75, 0.0), Vector3(0.12, 0.05, 0.13), n, 0.15, red, r, 0) # bandana
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
P._blob(st, Vector3(s * 0.045, 1.67, -0.115), Vector3(0.03, 0.025, 0.012), n, 0.0, dark, r, 0) # sockets
|
||||||
|
for i in range(5): # weed hanging off
|
||||||
|
var x := -0.16 + i * 0.08
|
||||||
|
P._cylinder(st, Vector3(x, 1.45, -0.04), Vector3(x + 0.02, 1.05 - 0.05 * (i % 2), -0.06), 0.012, 0.004, 3, weed, r)
|
||||||
|
# Right arm forward with the cutlass, left hanging.
|
||||||
|
P._cylinder(st, Vector3(0.22, 1.47, 0), Vector3(0.28, 1.2, -0.12), 0.025, 0.022, 4, bone, r)
|
||||||
|
P._cylinder(st, Vector3(0.28, 1.2, -0.12), Vector3(0.27, 1.08, -0.38), 0.022, 0.02, 4, bone, r)
|
||||||
|
P._cylinder(st, Vector3(0.27, 1.08, -0.4), Vector3(0.33, 1.42, -0.85), 0.035, 0.008, 4, steel, r) # curved blade
|
||||||
|
P._cylinder(st, Vector3(0.21, 1.06, -0.38), Vector3(0.33, 1.1, -0.4), 0.02, 0.02, 4, steel, r) # guard
|
||||||
|
P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(-0.27, 1.15, 0.02), 0.025, 0.022, 4, bone, r)
|
||||||
|
P._cylinder(st, Vector3(-0.27, 1.15, 0.02), Vector3(-0.26, 0.9, -0.02), 0.022, 0.02, 4, bone, r)
|
||||||
|
var est := P._begin()
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
P._blob(est, Vector3(s * 0.045, 1.67, -0.125), Vector3.ONE * 0.012, n, 0.0, Color(0.5, 1.0, 0.6), r, 0)
|
||||||
|
var lst := P._begin()
|
||||||
|
P._cylinder(lst, Vector3(0, 0, 0), Vector3(0, -0.45, 0.02), 0.03, 0.025, 4, bone, r)
|
||||||
|
P._cylinder(lst, Vector3(0, -0.45, 0.02), Vector3(0, -0.9, 0), 0.025, 0.022, 4, bone, r)
|
||||||
|
P._blob(lst, Vector3(0, -0.2, 0), Vector3(0.07, 0.18, 0.07), n, 0.4, rag, r, 0) # breeches
|
||||||
|
P._blob(lst, Vector3(0, -0.9, -0.05), Vector3(0.04, 0.025, 0.08), n, 0.1, old, r, 0)
|
||||||
|
return {"body": P._commit(st), "eyes": P._commit(est), "leg": P._commit(lst),
|
||||||
|
"hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)],
|
||||||
|
"radius": 0.3, "height": 1.85}
|
||||||
|
|
||||||
## The Old Stag (before scaling up): a pale, moss-hung spirit deer with a
|
## The Old Stag (before scaling up): a pale, moss-hung spirit deer with a
|
||||||
## huge crown of antlers.
|
## huge crown of antlers.
|
||||||
static func _stag() -> Dictionary:
|
static func _stag() -> Dictionary:
|
||||||
|
|||||||
1
scripts/creatures/creature_meshes.gd.uid
Normal file
1
scripts/creatures/creature_meshes.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dy6utmjegblry
|
||||||
1
scripts/creatures/crow.gd.uid
Normal file
1
scripts/creatures/crow.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bs230fcuvorft
|
||||||
@@ -90,7 +90,7 @@ func _check_site(world: Node3D, p: Vector2) -> Vector3:
|
|||||||
var hs: Array[float] = []
|
var hs: Array[float] = []
|
||||||
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
|
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))
|
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
|
return Vector3.INF
|
||||||
# Trees and boulders in the way are cleared (_clear_area); just avoid
|
# Trees and boulders in the way are cleared (_clear_area); just avoid
|
||||||
# the thickest woods. Counted by position, because their colliders
|
# the thickest woods. Counted by position, because their colliders
|
||||||
|
|||||||
1
scripts/creatures/den.gd.uid
Normal file
1
scripts/creatures/den.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://beng43qyj6rke
|
||||||
1
scripts/creatures/fox.gd.uid
Normal file
1
scripts/creatures/fox.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cfailgwvqv2fc
|
||||||
@@ -18,11 +18,12 @@ func display_name() -> String:
|
|||||||
func interact_prompt(p: Node) -> String:
|
func interact_prompt(p: Node) -> String:
|
||||||
if is_dead():
|
if is_dead():
|
||||||
return "Bones [E] Search"
|
return "Bones [E] Search"
|
||||||
var s := "Barrow guard"
|
# (A barrow guard, or a drowned sailor at a treasure dig: treasure.gd.)
|
||||||
|
var s: String = def.name
|
||||||
match state:
|
match state:
|
||||||
"rest": s = "Barrow guard (still... for now)"
|
"rest": s += " (still... for now)"
|
||||||
"charge": s = "Barrow guard (coming for you!)"
|
"charge": s += " (coming for you!)"
|
||||||
"home": s = "Barrow guard (going back to its post)"
|
"home": s += " (going back to its post)"
|
||||||
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
|
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
|
||||||
s += " (you need a weapon: a club cracks bone)"
|
s += " (you need a weapon: a club cracks bone)"
|
||||||
return s
|
return s
|
||||||
@@ -46,7 +47,7 @@ func _think(dt: float) -> void:
|
|||||||
_goal_d = d
|
_goal_d = d
|
||||||
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player):
|
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player):
|
||||||
_attack_cd = def.attack_cd
|
_attack_cd = def.attack_cd
|
||||||
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a barrow guard")
|
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a %s" % String(def.name).to_lower())
|
||||||
"home":
|
"home":
|
||||||
if barrow.wakes_for(player) and barrow.center().distance_to(pp) < barrow.LEASH:
|
if barrow.wakes_for(player) and barrow.center().distance_to(pp) < barrow.LEASH:
|
||||||
state = "charge"
|
state = "charge"
|
||||||
|
|||||||
1
scripts/creatures/guard.gd.uid
Normal file
1
scripts/creatures/guard.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://x0xva7tw6ind
|
||||||
56
scripts/creatures/gull.gd
Normal file
56
scripts/creatures/gull.gd
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
extends "res://scripts/creatures/crow.gd"
|
||||||
|
## A gull (0.6.0, the Cove only): the Cove's crow, sent by thieves.gd by
|
||||||
|
## day. It comes for food left on the sand and for fish on a smoking rack
|
||||||
|
## that nobody's watching. Bolder and quicker than a crow: it lands, snatches
|
||||||
|
## one and is off with it, and another soon follows. Shoot it down while
|
||||||
|
## it's carrying and it drops what it took. Feathers.
|
||||||
|
|
||||||
|
const SNATCH_AFTER := 1.2 ## seconds on the ground before it grabs one
|
||||||
|
const GULL_SCARE := 6.0 ## (bolder than a crow)
|
||||||
|
|
||||||
|
var carry: Dictionary = {} ## {id, count, extra} once it has something
|
||||||
|
|
||||||
|
func display_name() -> String:
|
||||||
|
return "Gull"
|
||||||
|
|
||||||
|
func interact_prompt(_p: Node) -> String:
|
||||||
|
if is_dead():
|
||||||
|
return "Gull [E] Pluck"
|
||||||
|
if not carry.is_empty():
|
||||||
|
return "Gull (off with your %s!)" % ItemDB.item_name(String(carry.id)).to_lower()
|
||||||
|
return {"peck": "Gull (after your food!)", "fly": "Gull (circling your food)"}.get(state, "Gull")
|
||||||
|
|
||||||
|
func _think(dt: float) -> void:
|
||||||
|
match state:
|
||||||
|
"fly":
|
||||||
|
if not thieves.gull_still_there(food) or thieves.watched(thieves.spot(food), GULL_SCARE):
|
||||||
|
_leave()
|
||||||
|
return
|
||||||
|
_goal = thieves.spot(food)
|
||||||
|
var ground: float = terrain.sample_height(global_position.x, global_position.z)
|
||||||
|
if Vector2(_goal.x - global_position.x, _goal.z - global_position.z).length() < 0.35 and global_position.y - ground < 0.15:
|
||||||
|
state = "peck"
|
||||||
|
_state_t = SNATCH_AFTER
|
||||||
|
thieves.gull_landed(self, food)
|
||||||
|
"peck":
|
||||||
|
if not thieves.gull_still_there(food) or thieves.watched(global_position, GULL_SCARE):
|
||||||
|
_leave()
|
||||||
|
return
|
||||||
|
_state_t -= dt
|
||||||
|
if _state_t <= 0.0:
|
||||||
|
_peck_anim = 0.4
|
||||||
|
carry = thieves.snatch(self, food)
|
||||||
|
_leave()
|
||||||
|
"fly_off":
|
||||||
|
if thieves.out_of_sight(global_position):
|
||||||
|
thieves.gone(self)
|
||||||
|
_:
|
||||||
|
_leave()
|
||||||
|
|
||||||
|
## Shot down with something in its beak: it drops it where it falls.
|
||||||
|
func _die(from: Vector3) -> void:
|
||||||
|
super._die(from)
|
||||||
|
if not carry.is_empty() and not puppet:
|
||||||
|
var at := global_position + Vector3(0, 0.3, 0)
|
||||||
|
world.drop_item(String(carry.id), int(carry.count), Transform3D(Basis(), at), carry.get("extra", {}))
|
||||||
|
carry = {}
|
||||||
1
scripts/creatures/gull.gd.uid
Normal file
1
scripts/creatures/gull.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://draw1wvqi3cif
|
||||||
1
scripts/creatures/old_stag.gd.uid
Normal file
1
scripts/creatures/old_stag.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://f88v5i7s5e5c
|
||||||
1
scripts/creatures/projectile.gd.uid
Normal file
1
scripts/creatures/projectile.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ccpmq0ddkoc8g
|
||||||
106
scripts/creatures/shore.gd
Normal file
106
scripts/creatures/shore.gd
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
extends Node3D
|
||||||
|
## The shore's own wildlife (0.6.0, a land whose worldlet lists "crabs" in
|
||||||
|
## its "wildlife": the Cove). From dusk till dawn crabs come up out of the
|
||||||
|
## sea onto the beach, a few around each player who's down on the sand, at
|
||||||
|
## the waterline and out of the way. At dawn they go back in (crab.gd).
|
||||||
|
## Not saved: like the other wildlife they simply carry on around you.
|
||||||
|
##
|
||||||
|
## On a server this runs for everyone, and players see the crabs through
|
||||||
|
## creature_net.gd.
|
||||||
|
|
||||||
|
const CrabScript = preload("res://scripts/creatures/crab.gd")
|
||||||
|
|
||||||
|
const BUDGET := 5 ## per player on the beach, up to twice it
|
||||||
|
const CHECK_EVERY := 2.0
|
||||||
|
const RING_MIN := 14.0
|
||||||
|
const RING_MAX := 45.0
|
||||||
|
const NOT_WITHIN := 10.0 ## never comes up right beside anyone
|
||||||
|
const DESPAWN := 80.0
|
||||||
|
const BEACH_NEAR := 40.0 ## a player this close to the sand counts as on the beach
|
||||||
|
|
||||||
|
var world: Node3D
|
||||||
|
var terrain: Node3D
|
||||||
|
var enabled := true
|
||||||
|
var _rng := RandomNumberGenerator.new()
|
||||||
|
var _acc := 0.0
|
||||||
|
var _serial := 0
|
||||||
|
|
||||||
|
func setup(w: Node3D) -> void:
|
||||||
|
world = w
|
||||||
|
terrain = w.terrain
|
||||||
|
_rng.seed = terrain.noise_seed + 1201
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
step(delta)
|
||||||
|
|
||||||
|
## Dusk till dawn.
|
||||||
|
func night() -> bool:
|
||||||
|
var h: float = world.day_night.hours()
|
||||||
|
return h >= 17.5 or h < 6.5
|
||||||
|
|
||||||
|
## Living crabs.
|
||||||
|
func alive() -> Array:
|
||||||
|
return get_children().filter(func(c): return is_instance_valid(c) and not c.is_queued_for_deletion() and not c.is_dead())
|
||||||
|
|
||||||
|
func step(dt: float) -> void:
|
||||||
|
if not enabled or world.creatures == null or not world.creatures.enabled:
|
||||||
|
return
|
||||||
|
_acc += dt
|
||||||
|
if _acc < CHECK_EVERY:
|
||||||
|
return
|
||||||
|
_acc = 0.0
|
||||||
|
var everyone: Array = world.players()
|
||||||
|
for c in get_children():
|
||||||
|
if not everyone.any(func(p): return p.global_position.distance_to(c.global_position) <= DESPAWN):
|
||||||
|
c.queue_free()
|
||||||
|
if not night() or everyone.is_empty():
|
||||||
|
return
|
||||||
|
var on_beach := everyone.filter(func(p): return _near_sand(p.global_position))
|
||||||
|
if on_beach.is_empty():
|
||||||
|
return
|
||||||
|
if alive().size() < BUDGET * clampi(on_beach.size(), 1, 2):
|
||||||
|
var who: Node3D = on_beach[_rng.randi() % on_beach.size()]
|
||||||
|
spawn_near(who.global_position) # (one at a time: they trickle up out of the sea)
|
||||||
|
|
||||||
|
func _near_sand(p: Vector3) -> bool:
|
||||||
|
for a in [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]:
|
||||||
|
var q := p + Vector3(cos(a), 0.0, sin(a)) * BEACH_NEAR * 0.5
|
||||||
|
if terrain.on_sand(p.x, p.z) or terrain.on_sand(q.x, q.z):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
## A spot on the sand at the waterline, RING_MIN..RING_MAX from `around`
|
||||||
|
## and not right beside anyone, or Vector3.INF.
|
||||||
|
func find_spot(around: Vector3) -> Vector3:
|
||||||
|
for _try in range(40):
|
||||||
|
var a := _rng.randf() * TAU
|
||||||
|
var r := _rng.randf_range(RING_MIN, RING_MAX)
|
||||||
|
var x := around.x + cos(a) * r
|
||||||
|
var z := around.z + sin(a) * r
|
||||||
|
if not terrain.inside_boundary(x, z, 6.0) or terrain.beach_weight(x, z) < 0.75:
|
||||||
|
continue
|
||||||
|
var h: float = terrain.sample_height(x, z)
|
||||||
|
if h < terrain.water_level + 0.25 or h > terrain.water_level + 0.9:
|
||||||
|
continue
|
||||||
|
var p := Vector3(x, h, z)
|
||||||
|
if world.players().any(func(pl): return pl.global_position.distance_to(p) < NOT_WITHIN):
|
||||||
|
continue
|
||||||
|
return p
|
||||||
|
return Vector3.INF
|
||||||
|
|
||||||
|
func spawn_near(around: Vector3) -> Node3D:
|
||||||
|
var p := find_spot(around)
|
||||||
|
if p == Vector3.INF:
|
||||||
|
return null
|
||||||
|
return spawn(p)
|
||||||
|
|
||||||
|
func spawn(pos: Vector3) -> Node3D:
|
||||||
|
var c: AnimatableBody3D = CrabScript.new()
|
||||||
|
_serial += 1
|
||||||
|
c.setup("crab", world, pos, _rng.randi() + _serial)
|
||||||
|
add_child(c)
|
||||||
|
c.global_position = Vector3(pos.x, terrain.sample_height(pos.x, pos.z), pos.z)
|
||||||
|
c.state = "wander"
|
||||||
|
c.yaw = _rng.randf() * TAU
|
||||||
|
c.target_yaw = c.yaw
|
||||||
|
return c
|
||||||
1
scripts/creatures/shore.gd.uid
Normal file
1
scripts/creatures/shore.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b6eeco4cs028q
|
||||||
1
scripts/creatures/spawner.gd.uid
Normal file
1
scripts/creatures/spawner.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dukt8gme03lsb
|
||||||
@@ -9,10 +9,16 @@ extends Node3D
|
|||||||
## fox.gd). Food in a chest is never touched (that's what chests are for);
|
## fox.gd). Food in a chest is never touched (that's what chests are for);
|
||||||
## the bear (bear_range.gd) is another matter.
|
## the bear (bear_range.gd) is another matter.
|
||||||
##
|
##
|
||||||
|
## In a land with gulls (the Cove, 0.6.0: its worldlet's "wildlife"), food
|
||||||
|
## left on the sand draws a gull by day instead of a crow (gull.gd), and so
|
||||||
|
## does fish on a smoking rack that nobody's watching: a gull snatches one
|
||||||
|
## and flies off with it.
|
||||||
|
##
|
||||||
## On a server this runs for everyone (thieves are wildlife: creature_net.gd).
|
## On a server this runs for everyone (thieves are wildlife: creature_net.gd).
|
||||||
|
|
||||||
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
||||||
const FoxScript = preload("res://scripts/creatures/fox.gd")
|
const FoxScript = preload("res://scripts/creatures/fox.gd")
|
||||||
|
const GullScript = preload("res://scripts/creatures/gull.gd")
|
||||||
|
|
||||||
const CHECK_EVERY := 5.0
|
const CHECK_EVERY := 5.0
|
||||||
const UNWATCHED := 10.0
|
const UNWATCHED := 10.0
|
||||||
@@ -25,6 +31,7 @@ signal stolen(thief: Node, id: String, count: int)
|
|||||||
|
|
||||||
var world: Node3D
|
var world: Node3D
|
||||||
var enabled := true
|
var enabled := true
|
||||||
|
var gulls := false ## this land has gulls (the Cove)
|
||||||
var _acc := 0.0
|
var _acc := 0.0
|
||||||
var _unwatched: Dictionary = {} ## pickup instance id -> seconds left alone
|
var _unwatched: Dictionary = {} ## pickup instance id -> seconds left alone
|
||||||
var _rng := RandomNumberGenerator.new()
|
var _rng := RandomNumberGenerator.new()
|
||||||
@@ -32,6 +39,7 @@ var _rng := RandomNumberGenerator.new()
|
|||||||
func setup(w: Node3D) -> void:
|
func setup(w: Node3D) -> void:
|
||||||
world = w
|
world = w
|
||||||
_rng.seed = w.terrain.noise_seed + 997
|
_rng.seed = w.terrain.noise_seed + 997
|
||||||
|
gulls = w.has_wildlife("gulls")
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
if enabled:
|
if enabled:
|
||||||
@@ -85,14 +93,28 @@ func step(dt: float) -> void:
|
|||||||
_unwatched[k] = float(_unwatched.get(k, 0.0)) + since
|
_unwatched[k] = float(_unwatched.get(k, 0.0)) + since
|
||||||
if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(d) and alive().size() < MAX_THIEVES:
|
if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(d) and alive().size() < MAX_THIEVES:
|
||||||
send(d)
|
send(d)
|
||||||
|
if gulls and not night():
|
||||||
|
for r in racks():
|
||||||
|
if not gull_still_there(r):
|
||||||
|
continue
|
||||||
|
var k: int = r.get_instance_id()
|
||||||
|
seen[k] = true
|
||||||
|
if watched(r.global_position, UNWATCHED):
|
||||||
|
_unwatched.erase(k)
|
||||||
|
continue
|
||||||
|
_unwatched[k] = float(_unwatched.get(k, 0.0)) + since
|
||||||
|
if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(r) and alive().size() < MAX_THIEVES:
|
||||||
|
send(r)
|
||||||
for k in _unwatched.keys():
|
for k in _unwatched.keys():
|
||||||
if not seen.has(k):
|
if not seen.has(k):
|
||||||
_unwatched.erase(k)
|
_unwatched.erase(k)
|
||||||
|
|
||||||
## Sends a thief after `food`: a fox at dusk and night, otherwise a crow.
|
## Sends a thief after `food`: a fox at dusk and night, otherwise a crow (a
|
||||||
|
## gull on the sand, or at a smoking rack, where there are gulls).
|
||||||
func send(food: Node3D) -> Node3D:
|
func send(food: Node3D) -> Node3D:
|
||||||
var fox := night()
|
var fox := night()
|
||||||
var fp: Vector3 = food.global_position
|
var gull := not fox and gulls and (food.is_in_group("processor") or on_sand(food.global_position))
|
||||||
|
var fp: Vector3 = spot(food)
|
||||||
var from := Vector3.INF
|
var from := Vector3.INF
|
||||||
for _i in range(12):
|
for _i in range(12):
|
||||||
var a := _rng.randf() * TAU
|
var a := _rng.randf() * TAU
|
||||||
@@ -104,8 +126,8 @@ func send(food: Node3D) -> Node3D:
|
|||||||
break
|
break
|
||||||
if from == Vector3.INF:
|
if from == Vector3.INF:
|
||||||
return null
|
return null
|
||||||
var t: AnimatableBody3D = (FoxScript if fox else CrowScript).new()
|
var t: AnimatableBody3D = (FoxScript if fox else (GullScript if gull else CrowScript)).new()
|
||||||
t.setup("fox" if fox else "crow", world, from, _rng.randi())
|
t.setup("fox" if fox else ("gull" if gull else "crow"), world, from, _rng.randi())
|
||||||
t.thieves = self
|
t.thieves = self
|
||||||
t.food = food
|
t.food = food
|
||||||
t.state = "sneak" if fox else "fly"
|
t.state = "sneak" if fox else "fly"
|
||||||
@@ -148,3 +170,63 @@ func grab(fox: Node, food: Node3D) -> Dictionary:
|
|||||||
## A thief left with what it took (or empty-handed).
|
## A thief left with what it took (or empty-handed).
|
||||||
func gone(t: Node) -> void:
|
func gone(t: Node) -> void:
|
||||||
t.queue_free()
|
t.queue_free()
|
||||||
|
|
||||||
|
# --- gulls (the Cove) ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func on_sand(pos: Vector3) -> bool:
|
||||||
|
return world.terrain.on_sand(pos.x, pos.z)
|
||||||
|
|
||||||
|
static func is_fish(id: String) -> bool:
|
||||||
|
return Dictionary(ItemDB.get_item(id).get("food", {})).get("diet", {}).has("fish")
|
||||||
|
|
||||||
|
## This land's smoking racks.
|
||||||
|
func racks() -> Array:
|
||||||
|
return get_tree().get_nodes_in_group("processor").filter(func(r): return is_instance_valid(r) and not r.is_queued_for_deletion() \
|
||||||
|
and String(r.item_id) == "smoking_rack" and world.is_ancestor_of(r))
|
||||||
|
|
||||||
|
## Fish on a rack: the first id there is, or "".
|
||||||
|
func _rack_fish(r: Node3D) -> String:
|
||||||
|
for st in r.inventory.slots:
|
||||||
|
if st != null and is_fish(String(st.id)):
|
||||||
|
return String(st.id)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
## Whether a gull's target is still worth it: food on the ground, or a rack
|
||||||
|
## with fish on it.
|
||||||
|
func gull_still_there(t: Node3D) -> bool:
|
||||||
|
if t == null or not is_instance_valid(t) or t.is_queued_for_deletion():
|
||||||
|
return false
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
return not _rack_fish(t).is_empty()
|
||||||
|
return still_there(t)
|
||||||
|
|
||||||
|
## Where a thief lands for `t`: on food, or on the ground beside a rack.
|
||||||
|
func spot(t: Node3D) -> Vector3:
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
var p: Vector3 = t.global_position + t.global_basis.z * 0.8
|
||||||
|
return Vector3(p.x, world.terrain.sample_height(p.x, p.z), p.z)
|
||||||
|
return t.global_position
|
||||||
|
|
||||||
|
func gull_landed(_gull: Node, t: Node3D) -> void:
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
_tell(t.global_position, "A gull's at the fish on your smoking rack!")
|
||||||
|
else:
|
||||||
|
_tell(t.global_position, "A gull's landed by your %s!" % ItemDB.item_name(t.item_id).to_lower())
|
||||||
|
|
||||||
|
## A gull takes one and flies off with it. Returns what it's carrying.
|
||||||
|
func snatch(gull: Node, t: Node3D) -> Dictionary:
|
||||||
|
if not gull_still_there(t):
|
||||||
|
return {}
|
||||||
|
var c := {}
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
var id := _rack_fish(t)
|
||||||
|
t.inventory.remove(id, 1)
|
||||||
|
c = {"id": id, "count": 1, "extra": {}}
|
||||||
|
else:
|
||||||
|
c = {"id": String(t.item_id), "count": 1, "extra": t.extra.duplicate()}
|
||||||
|
t.amount = int(t.amount) - 1
|
||||||
|
if int(t.amount) <= 0:
|
||||||
|
world.take_dropped(t)
|
||||||
|
_tell(t.global_position, "A gull has snatched your %s!" % ItemDB.item_name(String(c.id)).to_lower())
|
||||||
|
stolen.emit(gull, String(c.id), 1)
|
||||||
|
return c
|
||||||
|
|||||||
1
scripts/creatures/thieves.gd.uid
Normal file
1
scripts/creatures/thieves.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cpwa4sffdi3nt
|
||||||
1
scripts/creatures/tutorial_wolf.gd.uid
Normal file
1
scripts/creatures/tutorial_wolf.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b8wfmfnhg2edq
|
||||||
1
scripts/creatures/wolf.gd.uid
Normal file
1
scripts/creatures/wolf.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://024u453fdbml
|
||||||
1
scripts/creatures/wolf_pack.gd.uid
Normal file
1
scripts/creatures/wolf_pack.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cbb3g7cjprhy8
|
||||||
@@ -15,6 +15,7 @@ const BITE_WINDOW := 1.5
|
|||||||
const LINE_LENGTH := 26.0
|
const LINE_LENGTH := 26.0
|
||||||
|
|
||||||
var water_type := "" ## "lake", "sea"... (world.water_at)
|
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 table: Dictionary = {} ## that water's fishing table (ItemDB.water_fishing)
|
||||||
|
|
||||||
var world: Node3D
|
var world: Node3D
|
||||||
@@ -91,8 +92,9 @@ func _next_wait() -> float:
|
|||||||
return rng.randf_range(w[0], w[1]) * k
|
return rng.randf_range(w[0], w[1]) * k
|
||||||
|
|
||||||
## A weighted pick from this water's fish (dawn/dusk weights when they apply).
|
## 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:
|
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 key := "dawn_dusk" if _dawn_dusk() else "weight"
|
||||||
var total := 0.0
|
var total := 0.0
|
||||||
for f in fish:
|
for f in fish:
|
||||||
@@ -120,6 +122,7 @@ func step(dt: float) -> void:
|
|||||||
if not water.is_empty() and p.y <= float(water.surface):
|
if not water.is_empty() and p.y <= float(water.surface):
|
||||||
p.y = water.surface
|
p.y = water.surface
|
||||||
water_type = water.type
|
water_type = water.type
|
||||||
|
water_depth = float(water.depth)
|
||||||
table = ItemDB.water_fishing(water_type)
|
table = ItemDB.water_fishing(water_type)
|
||||||
if table.is_empty() or table.fish.is_empty():
|
if table.is_empty() or table.fish.is_empty():
|
||||||
state = "landed"
|
state = "landed"
|
||||||
|
|||||||
1
scripts/fishing/bobber.gd.uid
Normal file
1
scripts/fishing/bobber.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://br7isyu6hxnsd
|
||||||
1
scripts/fishing/lake_fish.gd.uid
Normal file
1
scripts/fishing/lake_fish.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cs6ucd2nldqmj
|
||||||
@@ -36,6 +36,17 @@ static func build(key: String) -> ArrayMesh:
|
|||||||
var m: ArrayMesh
|
var m: ArrayMesh
|
||||||
match key:
|
match key:
|
||||||
"stick": m = _stick()
|
"stick": m = _stick()
|
||||||
|
"driftwood": m = _driftwood()
|
||||||
|
"seagrass": m = _seagrass()
|
||||||
|
"rope": m = _rope()
|
||||||
|
"pine_resin": m = _pine_resin()
|
||||||
|
"pitch": m = _pitch()
|
||||||
|
"rowing_boat": m = _rowing_boat()
|
||||||
|
"treasure_map": m = _treasure_map()
|
||||||
|
"ship_compass": m = _ship_compass()
|
||||||
|
"spyglass": m = _spyglass()
|
||||||
|
"pirate_gold": m = _pirate_gold()
|
||||||
|
"cutlass": m = _cutlass()
|
||||||
"stone": m = _pebble(11, STONE, Vector3(0.14, 0.10, 0.12))
|
"stone": m = _pebble(11, STONE, Vector3(0.14, 0.10, 0.12))
|
||||||
"flint": m = _pebble(23, FLINT, Vector3(0.12, 0.07, 0.15), 0.5)
|
"flint": m = _pebble(23, FLINT, Vector3(0.12, 0.07, 0.15), 0.5)
|
||||||
"wood": m = _wood()
|
"wood": m = _wood()
|
||||||
@@ -75,6 +86,12 @@ static func build(key: String) -> ArrayMesh:
|
|||||||
"trout": m = _fish(TROUT, 0.24, false)
|
"trout": m = _fish(TROUT, 0.24, false)
|
||||||
"grilled_perch": m = _fish(PERCH, 0.16, true)
|
"grilled_perch": m = _fish(PERCH, 0.16, true)
|
||||||
"grilled_trout": m = _fish(TROUT, 0.24, 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)
|
||||||
|
"crab_meat": m = _crab_meat()
|
||||||
|
"cooked_crab": m = _cooked_crab()
|
||||||
|
"grilled_cod": m = _fish(COD, 0.3, true)
|
||||||
"fiber_plant": m = _fiber_plant()
|
"fiber_plant": m = _fiber_plant()
|
||||||
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
|
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
|
||||||
"iron_ore": m = _ore()
|
"iron_ore": m = _ore()
|
||||||
@@ -180,6 +197,147 @@ static func _stick() -> ArrayMesh:
|
|||||||
P._cylinder(st, Vector3(0.05, 0.03, 0.0), Vector3(0.2, 0.05, 0.1), 0.012, 0.006, 4, HANDLE, r)
|
P._cylinder(st, Vector3(0.05, 0.03, 0.0), Vector3(0.2, 0.05, 0.1), 0.012, 0.006, 4, HANDLE, r)
|
||||||
return P._commit(st)
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A sea-bleached branch, twisted, with a stub of a side branch.
|
||||||
|
static func _driftwood() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(41)
|
||||||
|
const BLEACHED := Color(0.72, 0.68, 0.6)
|
||||||
|
const GREY := Color(0.58, 0.55, 0.5)
|
||||||
|
P._cylinder(st, Vector3(-0.5, 0.06, -0.04), Vector3(0.05, 0.08, 0.05), 0.07, 0.06, 6, BLEACHED, r, -1.0, GREY)
|
||||||
|
P._cylinder(st, Vector3(0.05, 0.08, 0.05), Vector3(0.48, 0.05, -0.02), 0.06, 0.035, 6, BLEACHED, r, -1.0, GREY)
|
||||||
|
P._cylinder(st, Vector3(0.1, 0.09, 0.05), Vector3(0.22, 0.12, 0.24), 0.03, 0.015, 5, GREY, r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A tangle of long, flat ribbons of seagrass.
|
||||||
|
static func _seagrass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(45)
|
||||||
|
for i in range(6):
|
||||||
|
var a := r.randf() * TAU
|
||||||
|
var col := Color(0.28, 0.42, 0.2).lerp(Color(0.5, 0.48, 0.25), r.randf())
|
||||||
|
_box(st, Vector3(r.randf_range(-0.03, 0.03), 0.01 + i * 0.006, r.randf_range(-0.03, 0.03)), Vector3(0.03, 0.006, 0.38),
|
||||||
|
col, Basis(Vector3.UP, a))
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A coil of seagrass rope.
|
||||||
|
static func _rope() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(47)
|
||||||
|
const ROPE := Color(0.6, 0.55, 0.36)
|
||||||
|
var n := 12
|
||||||
|
for k in range(2):
|
||||||
|
for i in range(n):
|
||||||
|
var a0 := TAU * i / n
|
||||||
|
var a1 := TAU * (i + 1) / n
|
||||||
|
var rr := 0.1 - k * 0.035
|
||||||
|
P._cylinder(st, Vector3(cos(a0) * rr, 0.025 + k * 0.03, sin(a0) * rr), Vector3(cos(a1) * rr, 0.025 + k * 0.03, sin(a1) * rr),
|
||||||
|
0.02, 0.02, 5, ROPE.darkened(r.randf() * 0.12), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A few lumps of sticky amber resin.
|
||||||
|
static func _pine_resin() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(49)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 49
|
||||||
|
n.frequency = 3.0
|
||||||
|
for i in range(4):
|
||||||
|
var c := Vector3(r.randf_range(-0.05, 0.05), 0.03, r.randf_range(-0.05, 0.05))
|
||||||
|
P._blob(st, c, Vector3.ONE * r.randf_range(0.025, 0.045), n, 0.25, Color(0.8, 0.5, 0.12).darkened(r.randf() * 0.25), r, 1)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A small clay pot of black pitch, a stick stood in it.
|
||||||
|
static func _pitch() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(51)
|
||||||
|
P._cylinder(st, Vector3(0, 0, 0), Vector3(0, 0.12, 0), 0.07, 0.085, 8, Color(0.55, 0.36, 0.24), r, 0.0)
|
||||||
|
P._cylinder(st, Vector3(0, 0.115, 0), Vector3(0, 0.125, 0), 0.078, 0.078, 8, Color(0.06, 0.05, 0.05), r, -1.0, Color(0.06, 0.05, 0.05))
|
||||||
|
P._cylinder(st, Vector3(0.01, 0.06, 0.0), Vector3(0.05, 0.24, 0.02), 0.008, 0.007, 4, HANDLE, r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A rowing boat, bow to -Z: a model 0.55 m long (moorings.gd shows it eight
|
||||||
|
## times over at the pier). Driftwood planks, pitch-black seams, two thwarts
|
||||||
|
## and a pair of oars.
|
||||||
|
static func _rowing_boat() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(53)
|
||||||
|
const WOOD := Color(0.66, 0.6, 0.5)
|
||||||
|
const SEAM := Color(0.1, 0.08, 0.07)
|
||||||
|
const OAR := Color(0.55, 0.42, 0.28)
|
||||||
|
var hw := 0.085 # half the beam amidships
|
||||||
|
# Bottom: a wide plank aft, narrowing to the bow.
|
||||||
|
_box(st, Vector3(0, 0.01, 0.07), Vector3(hw * 1.7, 0.012, 0.36), WOOD)
|
||||||
|
_box(st, Vector3(0, 0.014, -0.16), Vector3(hw * 1.1, 0.012, 0.12), WOOD)
|
||||||
|
for side in [-1.0, 1.0]:
|
||||||
|
# Sides amidships and aft, two strakes with a dark seam between.
|
||||||
|
_box(st, Vector3(side * hw, 0.03, 0.06), Vector3(0.008, 0.03, 0.38), WOOD.darkened(0.05))
|
||||||
|
_box(st, Vector3(side * hw, 0.047, 0.06), Vector3(0.0085, 0.004, 0.38), SEAM)
|
||||||
|
_box(st, Vector3(side * hw, 0.062, 0.06), Vector3(0.008, 0.028, 0.38), WOOD)
|
||||||
|
# Bow: the sides swept in to meet at the stem.
|
||||||
|
var bow := Basis(Vector3.UP, side * 0.53)
|
||||||
|
_box(st, Vector3(side * hw * 0.5, 0.048, -0.193), Vector3(0.008, 0.07, 0.17), WOOD, bow)
|
||||||
|
_box(st, Vector3(0, 0.055, -0.265), Vector3(0.014, 0.085, 0.014), WOOD.darkened(0.15)) # stem
|
||||||
|
_box(st, Vector3(0, 0.045, 0.25), Vector3(hw * 2.0, 0.06, 0.008), WOOD.darkened(0.08)) # transom
|
||||||
|
for z in [-0.02, 0.15]:
|
||||||
|
_box(st, Vector3(0, 0.055, z), Vector3(hw * 2.0, 0.008, 0.035), WOOD.darkened(0.12)) # thwarts
|
||||||
|
for side in [-1.0, 1.0]:
|
||||||
|
P._cylinder(st, Vector3(side * 0.04, 0.07, 0.17), Vector3(side * 0.05, 0.075, -0.13), 0.004, 0.004, 4, OAR, r)
|
||||||
|
_box(st, Vector3(side * 0.05, 0.075, -0.15), Vector3(0.022, 0.003, 0.05), OAR)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A rolled-up, torn sheet of old parchment tied with string.
|
||||||
|
static func _treasure_map() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(43)
|
||||||
|
const PARCH := Color(0.82, 0.74, 0.55)
|
||||||
|
P._cylinder(st, Vector3(-0.16, 0.04, 0), Vector3(0.16, 0.04, 0), 0.04, 0.04, 8, PARCH, r, -1.0, PARCH.darkened(0.15))
|
||||||
|
P._cylinder(st, Vector3(-0.01, 0.04, 0), Vector3(0.01, 0.04, 0), 0.043, 0.043, 8, Color(0.45, 0.12, 0.08), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
const BRASS := Color(0.78, 0.6, 0.25)
|
||||||
|
const GOLD := Color(0.95, 0.75, 0.2)
|
||||||
|
|
||||||
|
## A brass sea compass: a flat round case, a glass face, a red needle.
|
||||||
|
static func _ship_compass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(47)
|
||||||
|
P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 0.05, 0), 0.11, 0.11, 12, BRASS, r, -1.0, BRASS)
|
||||||
|
P._cylinder(st, Vector3(0, 0.05, 0), Vector3(0, 0.056, 0), 0.095, 0.095, 12, Color(0.9, 0.88, 0.8), r, -1.0, Color(0.9, 0.88, 0.8))
|
||||||
|
P._cylinder(st, Vector3(0, 0.06, 0.07), Vector3(0, 0.06, -0.07), 0.012, 0.004, 4, Color(0.75, 0.1, 0.08), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A brass telescope, drawn out: three tubes, the big end forward.
|
||||||
|
static func _spyglass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(49)
|
||||||
|
P._cylinder(st, Vector3(0, 0.04, 0.22), Vector3(0, 0.04, 0.06), 0.03, 0.03, 8, BRASS, r, -1.0, BRASS)
|
||||||
|
P._cylinder(st, Vector3(0, 0.04, 0.06), Vector3(0, 0.04, -0.1), 0.036, 0.036, 8, Color(0.3, 0.2, 0.12), r, -1.0, BRASS)
|
||||||
|
P._cylinder(st, Vector3(0, 0.04, -0.1), Vector3(0, 0.04, -0.26), 0.044, 0.048, 8, BRASS, r, -1.0, Color(0.6, 0.7, 0.8))
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A heap of old gold coins, and a few spilled.
|
||||||
|
static func _pirate_gold() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(53)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 53
|
||||||
|
P._blob(st, Vector3(0, 0.06, 0), Vector3(0.2, 0.09, 0.18), n, 0.25, GOLD, r, 1)
|
||||||
|
for i in range(7):
|
||||||
|
var a := i * 0.9
|
||||||
|
var c := Vector3(cos(a) * (0.16 + 0.05 * (i % 3)), 0.01, sin(a) * (0.16 + 0.05 * (i % 3)))
|
||||||
|
P._cylinder(st, c, c + Vector3(0, 0.012, 0), 0.03, 0.03, 8, GOLD.darkened(0.1 * (i % 3)), r, -1.0, GOLD)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A cutlass: a curved, broad blade, a brass basket guard, a dark grip.
|
||||||
|
static func _cutlass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(59)
|
||||||
|
_handle(st, 0.14, r)
|
||||||
|
P._cylinder(st, Vector3(-0.06, 0.15, 0), Vector3(0.06, 0.15, 0), 0.025, 0.025, 5, BRASS, r)
|
||||||
|
P._cylinder(st, Vector3(0, 0.16, 0), Vector3(0.02, 0.45, 0), 0.03, 0.028, 4, Color(0.72, 0.74, 0.78), r)
|
||||||
|
P._cylinder(st, Vector3(0.02, 0.45, 0), Vector3(0.08, 0.72, 0), 0.028, 0.006, 4, Color(0.72, 0.74, 0.78), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
static func _wood() -> ArrayMesh:
|
static func _wood() -> ArrayMesh:
|
||||||
var st := P._begin()
|
var st := P._begin()
|
||||||
var r := _rng(9)
|
var r := _rng(9)
|
||||||
@@ -694,6 +852,8 @@ static func _cloak(col: Color = HIDE) -> ArrayMesh:
|
|||||||
|
|
||||||
const PERCH := Color(0.45, 0.52, 0.30)
|
const PERCH := Color(0.45, 0.52, 0.30)
|
||||||
const TROUT := Color(0.50, 0.55, 0.58)
|
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 GRILLED := Color(0.55, 0.36, 0.18)
|
||||||
const FLOAT_RED := Color(0.85, 0.15, 0.12)
|
const FLOAT_RED := Color(0.85, 0.15, 0.12)
|
||||||
|
|
||||||
@@ -1103,6 +1263,34 @@ static func _barrow_key() -> ArrayMesh:
|
|||||||
# --- P4 food keeping ---------------------------------------------------------------------
|
# --- P4 food keeping ---------------------------------------------------------------------
|
||||||
|
|
||||||
## A crust of white salt crystals.
|
## A crust of white salt crystals.
|
||||||
|
## A few chunks of white crab meat in a cracked bit of shell.
|
||||||
|
static func _crab_meat() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(341)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 341
|
||||||
|
n.frequency = 2.6
|
||||||
|
P._blob(st, Vector3(0, 0.03, 0), Vector3(0.11, 0.03, 0.08), n, 0.2, Color(0.62, 0.22, 0.12), r, 1)
|
||||||
|
for i in range(4):
|
||||||
|
var c := Vector3(r.randf_range(-0.05, 0.05), 0.06, r.randf_range(-0.035, 0.035))
|
||||||
|
P._blob(st, c, Vector3(0.035, 0.022, 0.025), n, 0.25, Color(0.95, 0.9, 0.84).lerp(Color(0.95, 0.7, 0.62), r.randf() * 0.4), r, 0)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A whole crab, boiled pink-red, legs tucked.
|
||||||
|
static func _cooked_crab() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(343)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 343
|
||||||
|
n.frequency = 2.4
|
||||||
|
var shell := Color(0.9, 0.35, 0.22)
|
||||||
|
P._blob(st, Vector3(0, 0.05, 0), Vector3(0.12, 0.04, 0.09), n, 0.1, shell, r, 1)
|
||||||
|
for sx in [-1.0, 1.0]:
|
||||||
|
P._blob(st, Vector3(sx * 0.1, 0.04, -0.1), Vector3(0.03, 0.025, 0.045), n, 0.1, shell, r, 0)
|
||||||
|
for z in [-0.03, 0.01, 0.05]:
|
||||||
|
P._cylinder(st, Vector3(sx * 0.1, 0.03, z), Vector3(sx * 0.15, 0.01, z + 0.02), 0.008, 0.004, 3, P._mul(shell, 0.85), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
static func _salt() -> ArrayMesh:
|
static func _salt() -> ArrayMesh:
|
||||||
var st := P._begin()
|
var st := P._begin()
|
||||||
var r := _rng(331)
|
var r := _rng(331)
|
||||||
|
|||||||
1
scripts/net/avatar.gd.uid
Normal file
1
scripts/net/avatar.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bccgr18dkfhad
|
||||||
1
scripts/net/character_mesh.gd.uid
Normal file
1
scripts/net/character_mesh.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://w2qc4a4ai4vt
|
||||||
@@ -57,6 +57,11 @@ func _c2s_say(text: String) -> void:
|
|||||||
var who: String = Net.players[peer].name
|
var who: String = Net.players[peer].name
|
||||||
if text.begins_with("/"):
|
if text.begins_with("/"):
|
||||||
print("[server] %s: %s" % [who, text])
|
print("[server] %s: %s" % [who, text])
|
||||||
|
if not Lands.land.is_empty():
|
||||||
|
# (Another land's server: commands run on the hub, which keeps
|
||||||
|
# server.json, and answers back here.)
|
||||||
|
Lands.send_hub({"t": "cmd", "line": text.substr(1), "id": String(Net.players[peer].id), "name": who, "peer": peer})
|
||||||
|
return
|
||||||
run(text.substr(1), peer)
|
run(text.substr(1), peer)
|
||||||
return
|
return
|
||||||
# Plain chat only: a line every GAP seconds, so nobody can flood it.
|
# Plain chat only: a line every GAP seconds, so nobody can flood it.
|
||||||
@@ -68,22 +73,44 @@ func _c2s_say(text: String) -> void:
|
|||||||
print("[chat] %s: %s" % [who, text])
|
print("[chat] %s: %s" % [who, text])
|
||||||
_all("%s: %s" % [who, text], "chat")
|
_all("%s: %s" % [who, text], "chat")
|
||||||
|
|
||||||
func _all(text: String, kind: String) -> void:
|
## A line for everyone here, and (`relay`) in every other land too (J3b).
|
||||||
|
func _all(text: String, kind: String, relay: bool = true) -> void:
|
||||||
if kind == "server":
|
if kind == "server":
|
||||||
print("[server] ", text)
|
print("[server] ", text)
|
||||||
for peer in Net.players:
|
for peer in Net.players:
|
||||||
_s2c_line.rpc_id(peer, text, kind)
|
_s2c_line.rpc_id(peer, text, kind)
|
||||||
|
if relay:
|
||||||
|
Lands.relay_line(text, kind)
|
||||||
|
|
||||||
|
## A command typed by someone in another land (J3b): who they are, and where
|
||||||
|
## to send the answer. `run()` sees them as REMOTE.
|
||||||
|
const Terrain = preload("res://scripts/world/terrain.gd")
|
||||||
|
const REMOTE := -1
|
||||||
|
var _caller: Dictionary = {}
|
||||||
|
|
||||||
|
func run_remote(line: String, caller: Dictionary) -> void:
|
||||||
|
print("[server] %s (in the %s): /%s" % [caller.name, caller.land, line])
|
||||||
|
_caller = caller
|
||||||
|
run(line, REMOTE)
|
||||||
|
_caller = {}
|
||||||
|
|
||||||
## An answer to whoever gave the command (peer 0 = the console).
|
## An answer to whoever gave the command (peer 0 = the console).
|
||||||
func _reply(peer: int, text: String) -> void:
|
func _reply(peer: int, text: String) -> void:
|
||||||
if peer == 0:
|
if peer == 0:
|
||||||
print(text)
|
print(text)
|
||||||
|
elif peer == REMOTE:
|
||||||
|
Lands.tell_land(String(_caller.land), {"t": "reply", "peer": int(_caller.peer), "text": text})
|
||||||
else:
|
else:
|
||||||
_s2c_line.rpc_id(peer, text, "reply")
|
_s2c_line.rpc_id(peer, text, "reply")
|
||||||
|
|
||||||
func _is_admin(peer: int) -> bool:
|
func _is_admin(peer: int) -> bool:
|
||||||
|
if peer == REMOTE:
|
||||||
|
return Net.config.is_admin(String(_caller.id))
|
||||||
return peer == 0 or (Net.players.has(peer) and Net.config.is_admin(String(Net.players[peer].id)))
|
return peer == 0 or (Net.players.has(peer) and Net.config.is_admin(String(Net.players[peer].id)))
|
||||||
|
|
||||||
|
## Another land's server should know: this command changed server.json.
|
||||||
|
const CHANGES_CONFIG := ["friend", "unfriend", "ban", "unban", "admin", "set", "release-name"]
|
||||||
|
|
||||||
## The player id a name belongs to (online or reserved), or "".
|
## The player id a name belongs to (online or reserved), or "".
|
||||||
func _id_of(n: String) -> String:
|
func _id_of(n: String) -> String:
|
||||||
var peer := Net.peer_by_name(n)
|
var peer := Net.peer_by_name(n)
|
||||||
@@ -102,16 +129,24 @@ func run(line: String, peer: int) -> void:
|
|||||||
if cmd in ADMIN_ONLY and not _is_admin(peer):
|
if cmd in ADMIN_ONLY and not _is_admin(peer):
|
||||||
_reply(peer, "Only admins can use /%s" % cmd)
|
_reply(peer, "Only admins can use /%s" % cmd)
|
||||||
return
|
return
|
||||||
var me: String = String(Net.players[peer].id) if Net.players.has(peer) else ""
|
var me: String = String(Net.players[peer].id) if Net.players.has(peer) else String(_caller.get("id", ""))
|
||||||
var by: String = String(Net.players[peer].name) if Net.players.has(peer) else "The server"
|
var by: String = String(Net.players[peer].name) if Net.players.has(peer) else String(_caller.get("name", "The server"))
|
||||||
|
_run_cmd(cmd, parts, rest, arg, peer, me, by)
|
||||||
|
if cmd in CHANGES_CONFIG:
|
||||||
|
Lands.tell_lands({"t": "config"})
|
||||||
|
|
||||||
|
func _run_cmd(cmd: String, parts: PackedStringArray, rest: String, arg: String, peer: int, me: String, by: String) -> void:
|
||||||
match cmd:
|
match cmd:
|
||||||
"help":
|
"help":
|
||||||
_reply(peer, "Commands: /players /rules /friend <name> /unfriend <name> /friends" +
|
_reply(peer, "Commands: /players /rules /friend <name> /unfriend <name> /friends" +
|
||||||
(" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else ""))
|
(" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else ""))
|
||||||
"players":
|
"players":
|
||||||
|
# Everyone, in every land (J3b: the hub's roster), with where they are.
|
||||||
var names: Array[String] = []
|
var names: Array[String] = []
|
||||||
for p in Net.players.values():
|
var many := Lands.roster.any(func(e): return String(e.land) != Lands.here())
|
||||||
names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else ""))
|
for p in (Lands.roster if not Lands.roster.is_empty() else Lands._here_list()):
|
||||||
|
names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else "")
|
||||||
|
+ (" in the %s" % Terrain.worldlet_name(String(p.land)) if many else ""))
|
||||||
_reply(peer, "%d online: %s" % [names.size(), ", ".join(names)])
|
_reply(peer, "%d online: %s" % [names.size(), ", ".join(names)])
|
||||||
"rules":
|
"rules":
|
||||||
var c: RefCounted = Net.config
|
var c: RefCounted = Net.config
|
||||||
@@ -142,10 +177,14 @@ func run(line: String, peer: int) -> void:
|
|||||||
_reply(peer, "Your friends: %s" % (", ".join(names) if not names.is_empty() else "nobody yet (/friend <name>)"))
|
_reply(peer, "Your friends: %s" % (", ".join(names) if not names.is_empty() else "nobody yet (/friend <name>)"))
|
||||||
"kick":
|
"kick":
|
||||||
var kp := Net.peer_by_name(arg)
|
var kp := Net.peer_by_name(arg)
|
||||||
if kp == 0:
|
var away := Lands.land_of_name(arg) if kp == 0 else ""
|
||||||
|
if kp == 0 and (away.is_empty() or away == Lands.here()):
|
||||||
_reply(peer, "Nobody called '%s' is online" % arg)
|
_reply(peer, "Nobody called '%s' is online" % arg)
|
||||||
return
|
return
|
||||||
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")
|
_all("%s was kicked" % arg, "server")
|
||||||
"ban":
|
"ban":
|
||||||
var bid := _id_of(arg)
|
var bid := _id_of(arg)
|
||||||
@@ -156,6 +195,8 @@ func run(line: String, peer: int) -> void:
|
|||||||
var bp := Net.peer_by_name(arg)
|
var bp := Net.peer_by_name(arg)
|
||||||
if bp != 0:
|
if bp != 0:
|
||||||
Net.kick(bp, "You were banned by %s" % by)
|
Net.kick(bp, "You were banned by %s" % by)
|
||||||
|
elif not Lands.land_of_name(arg).is_empty():
|
||||||
|
Lands.tell_land(Lands.land_of_name(arg), {"t": "kick", "name": arg, "reason": "You were banned by %s" % by})
|
||||||
_all("%s was banned" % arg, "server")
|
_all("%s was banned" % arg, "server")
|
||||||
"unban":
|
"unban":
|
||||||
var uid: String = Net.config.name_owner(arg)
|
var uid: String = Net.config.name_owner(arg)
|
||||||
@@ -173,9 +214,11 @@ func run(line: String, peer: int) -> void:
|
|||||||
_reply(ap, "%s made you an admin (/help for the commands)" % by)
|
_reply(ap, "%s made you an admin (/help for the commands)" % by)
|
||||||
"save":
|
"save":
|
||||||
WorldNet.save()
|
WorldNet.save()
|
||||||
|
Lands.tell_lands({"t": "save"})
|
||||||
_reply(peer, "Saved")
|
_reply(peer, "Saved")
|
||||||
"backup":
|
"backup":
|
||||||
WorldNet.save()
|
WorldNet.save()
|
||||||
|
Lands.tell_lands({"t": "save"})
|
||||||
var b: String = WorldNet.backup("by %s" % by)
|
var b: String = WorldNet.backup("by %s" % by)
|
||||||
_reply(peer, "Backed up as %s (%d kept)" % [b, WorldNet.list_backups().size()] if not b.is_empty() else "Nothing to back up yet")
|
_reply(peer, "Backed up as %s (%d kept)" % [b, WorldNet.list_backups().size()] if not b.is_empty() else "Nothing to back up yet")
|
||||||
"backups":
|
"backups":
|
||||||
@@ -198,6 +241,7 @@ func run(line: String, peer: int) -> void:
|
|||||||
_reply(peer, "Use /weather %s or auto" % "|".join(PackedStringArray(states)))
|
_reply(peer, "Use /weather %s or auto" % "|".join(PackedStringArray(states)))
|
||||||
return
|
return
|
||||||
Net.set_weather("" if arg == "auto" else arg)
|
Net.set_weather("" if arg == "auto" else arg)
|
||||||
|
Lands.tell_lands({"t": "weather", "state": "" if arg == "auto" else arg})
|
||||||
_all("%s set the weather to %s" % [by, arg], "server")
|
_all("%s set the weather to %s" % [by, arg], "server")
|
||||||
"set":
|
"set":
|
||||||
if parts.size() < 3:
|
if parts.size() < 3:
|
||||||
|
|||||||
1
scripts/net/chat.gd.uid
Normal file
1
scripts/net/chat.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cj8ew4d4huen5
|
||||||
@@ -21,6 +21,8 @@ const Creature = preload("res://scripts/creatures/creature.gd")
|
|||||||
const BearScript = preload("res://scripts/creatures/bear.gd")
|
const BearScript = preload("res://scripts/creatures/bear.gd")
|
||||||
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
||||||
const GuardScript = preload("res://scripts/creatures/guard.gd")
|
const GuardScript = preload("res://scripts/creatures/guard.gd")
|
||||||
|
const GullScript = preload("res://scripts/creatures/gull.gd")
|
||||||
|
const CrabScript = preload("res://scripts/creatures/crab.gd")
|
||||||
const StagScript = preload("res://scripts/creatures/old_stag.gd") # (rears and lowers its antlers) # (falls apart in its own way)
|
const StagScript = preload("res://scripts/creatures/old_stag.gd") # (rears and lowers its antlers) # (falls apart in its own way)
|
||||||
const Bobber = preload("res://scripts/fishing/bobber.gd")
|
const Bobber = preload("res://scripts/fishing/bobber.gd")
|
||||||
const Projectile = preload("res://scripts/creatures/projectile.gd")
|
const Projectile = preload("res://scripts/creatures/projectile.gd")
|
||||||
@@ -99,7 +101,7 @@ func _server_tick(w: Node3D, delta: float) -> void:
|
|||||||
|
|
||||||
const PER_PACKET := 40
|
const PER_PACKET := 40
|
||||||
const ENTRY := 23
|
const ENTRY := 23
|
||||||
const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf"]
|
const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf", "drowned", "crab", "gull"]
|
||||||
const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash",
|
const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash",
|
||||||
"warn", "raid", "eat", "fly", "fly_off", "peck", "sneak", "carry", "circle", "telegraph", "stunned", "summon", "stomp"]
|
"warn", "raid", "eat", "fly", "fly_off", "peck", "sneak", "carry", "circle", "telegraph", "stunned", "summon", "stomp"]
|
||||||
|
|
||||||
@@ -198,7 +200,8 @@ func _s2c_life(packed: PackedByteArray, fl: Array) -> void:
|
|||||||
var c: Node3D = got if is_instance_valid(got) else null
|
var c: Node3D = got if is_instance_valid(got) else null
|
||||||
if c == null:
|
if c == null:
|
||||||
# (Kinds with their own look: the bear rears up, a crow flaps.)
|
# (Kinds with their own look: the bear rears up, a crow flaps.)
|
||||||
var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript, "old_stag": StagScript}.get(kind, Creature)
|
var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript, "drowned": GuardScript, "old_stag": StagScript,
|
||||||
|
"gull": GullScript, "crab": CrabScript}.get(kind, Creature)
|
||||||
c = script.new()
|
c = script.new()
|
||||||
c.puppet = true
|
c.puppet = true
|
||||||
c.setup(kind, world, e[2], cid)
|
c.setup(kind, world, e[2], cid)
|
||||||
|
|||||||
1
scripts/net/creature_net.gd.uid
Normal file
1
scripts/net/creature_net.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://c3amf3mwhetmf
|
||||||
1
scripts/net/identity.gd.uid
Normal file
1
scripts/net/identity.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://x0plvp658wne
|
||||||
534
scripts/net/lands.gd
Normal file
534
scripts/net/lands.gd
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
extends Node
|
||||||
|
## Autoload "Lands" (J3, docs/WORLDLETS_DESIGN.md: a server per land).
|
||||||
|
##
|
||||||
|
## A server's world can span several lands (worldlets). The server players
|
||||||
|
## join, on the usual port, runs the world's home land (the Valley) and is the
|
||||||
|
## **hub**. When someone first heads for another land, the hub starts a
|
||||||
|
## **land server** for it: the same game, headless, on the next port (24243
|
||||||
|
## for the Cove, and so on), with `--land cove`. It shares the hub's config
|
||||||
|
## and world folder (worlds/<world>/: that land's state is
|
||||||
|
## worldlets/<land>.json, and characters are in players/, shared by all), so
|
||||||
|
## a character saved by one is loaded by the next. A land server stops a
|
||||||
|
## couple of minutes after its last player leaves, and whenever the hub
|
||||||
|
## stops.
|
||||||
|
##
|
||||||
|
## Hub and land servers talk over a private TCP link on 127.0.0.1 (one JSON
|
||||||
|
## object per line), never seen from outside. J3a carries: the land's
|
||||||
|
## "ready" (listening), its player count, the hub's clock, one-time passes
|
||||||
|
## for players crossing over, and asks about the tunnel.
|
||||||
|
##
|
||||||
|
## A crossing (PlayerNet, server side): the player's character is saved with
|
||||||
|
## where they're heading; the hub (or the land server, back the other way)
|
||||||
|
## makes a one-time pass, makes sure the other server is up, and sends the
|
||||||
|
## player's game `{port, pass, land}`. The game reconnects there with the
|
||||||
|
## pass (Net.join(..., pass)), and that server lets them straight in.
|
||||||
|
|
||||||
|
signal land_ready(land: String) ## hub: a land server is listening
|
||||||
|
signal hub_message(msg: Dictionary) ## land server: something from the hub
|
||||||
|
|
||||||
|
const LINK_PORT_OFFSET := 100 ## the hub's private link: its game port + 100 (24342)
|
||||||
|
const IDLE_STOP_SECONDS := 120.0 ## a land server with nobody on stops after this
|
||||||
|
const PASS_SECONDS := 60.0 ## a pass not used by then is no good
|
||||||
|
|
||||||
|
var hub := false ## this server is the hub
|
||||||
|
var land := "" ## this server is a land server, running this land
|
||||||
|
var hub_port := 0 ## (land server) the hub's game port
|
||||||
|
|
||||||
|
## Hub: the land servers it's running. land -> {"pid", "port", "io", "conn", "players", "ready", "idle"}
|
||||||
|
var servers: Dictionary = {}
|
||||||
|
var _link: TCPServer
|
||||||
|
var _unclaimed: Array = [] ## links not yet saying which land they are
|
||||||
|
## One-time passes, wherever a player is arriving: pass -> {"id", "t", "land"}
|
||||||
|
var passes: Dictionary = {}
|
||||||
|
|
||||||
|
## Land server: the link to the hub.
|
||||||
|
var _hub: StreamPeerTCP
|
||||||
|
var _hub_link: Dictionary = {} ## {"conn": _hub, "buf": the partial line}
|
||||||
|
|
||||||
|
# --- setup (called by Net.start_server) ------------------------------------------------
|
||||||
|
|
||||||
|
## Called once a server is listening. `args` are the command-line flags.
|
||||||
|
func server_started(args: Dictionary) -> void:
|
||||||
|
land = String(args.get("land", ""))
|
||||||
|
roster = []
|
||||||
|
if not Net.players_changed.is_connected(rebuild_roster):
|
||||||
|
Net.players_changed.connect(rebuild_roster)
|
||||||
|
if not land.is_empty():
|
||||||
|
hub_port = int(args.get("hub-port", Net.DEFAULT_PORT))
|
||||||
|
_hub = StreamPeerTCP.new()
|
||||||
|
_hub.connect_to_host("127.0.0.1", hub_port + LINK_PORT_OFFSET)
|
||||||
|
_hub_link = {"conn": _hub, "buf": ""}
|
||||||
|
return
|
||||||
|
hub = true
|
||||||
|
_link = TCPServer.new()
|
||||||
|
var err := _link.listen(game_port() + LINK_PORT_OFFSET, "127.0.0.1")
|
||||||
|
if err != OK:
|
||||||
|
push_warning("[lands] couldn't open the private link on %d (error %d): no other lands this run" % [game_port() + LINK_PORT_OFFSET, err])
|
||||||
|
_link = null
|
||||||
|
elif not _order().is_empty():
|
||||||
|
print("[lands] other lands start on demand. Players over the internet need %s forwarded" % ports_text(home(), game_port()))
|
||||||
|
|
||||||
|
## Called as a server stops: stops every land server it started.
|
||||||
|
func server_stopped() -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "stop"})
|
||||||
|
var pids: Array = servers.values().map(func(s): return int(s.pid))
|
||||||
|
if not pids.is_empty():
|
||||||
|
get_tree().create_timer(6.0).timeout.connect(func():
|
||||||
|
for pid in pids:
|
||||||
|
if pid != 0 and OS.is_process_running(pid):
|
||||||
|
OS.kill(pid))
|
||||||
|
servers.clear()
|
||||||
|
_unclaimed.clear()
|
||||||
|
if _link != null:
|
||||||
|
_link.stop()
|
||||||
|
_link = null
|
||||||
|
if _hub != null:
|
||||||
|
_hub.disconnect_from_host()
|
||||||
|
_hub = null
|
||||||
|
hub = false
|
||||||
|
land = ""
|
||||||
|
passes.clear()
|
||||||
|
|
||||||
|
func game_port() -> int:
|
||||||
|
return int(Net.config.v("port")) if Net.config != null else Net.DEFAULT_PORT
|
||||||
|
|
||||||
|
## The worldlet this server runs.
|
||||||
|
func here() -> String:
|
||||||
|
return land if not land.is_empty() else String(Net.config.v("worldlet")) if Net.config != null else "valley"
|
||||||
|
|
||||||
|
## The port land `l` runs on: the hub's, then one up for each land after it
|
||||||
|
## in the journey's order (data/worldlets: next).
|
||||||
|
func port_for(l: String) -> int:
|
||||||
|
if l == home():
|
||||||
|
return hub_port if not land.is_empty() else game_port()
|
||||||
|
var base := hub_port if not land.is_empty() else game_port()
|
||||||
|
return base + 1 + maxi(_order().find(l), 0)
|
||||||
|
|
||||||
|
## The world's home land (the hub's).
|
||||||
|
func home() -> String:
|
||||||
|
return String(Net.config.v("worldlet")) if Net.config != null else "valley"
|
||||||
|
|
||||||
|
## Every land after home, in the order the journey reaches them.
|
||||||
|
func _order() -> Array:
|
||||||
|
return journey_from(home())
|
||||||
|
|
||||||
|
## The lands a server whose home is `home` and whose port is `base` may run,
|
||||||
|
## after home: [[land, port]] (24243 for the Cove when the server's 24242).
|
||||||
|
## Friends playing over the internet need these forwarded too.
|
||||||
|
static func extra_ports(home_land: String, base: int) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
var i := 1
|
||||||
|
for l in journey_from(home_land):
|
||||||
|
out.append([l, base + i])
|
||||||
|
i += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
## "UDP 24242 and 24243 (the Cove)": what to forward for a server on `base`.
|
||||||
|
static func ports_text(home_land: String, base: int) -> String:
|
||||||
|
var extra := extra_ports(home_land, base)
|
||||||
|
if extra.is_empty():
|
||||||
|
return "UDP %d" % base
|
||||||
|
var T: Script = load("res://scripts/world/terrain.gd")
|
||||||
|
var parts: Array[String] = []
|
||||||
|
for e in extra:
|
||||||
|
parts.append("%d (the %s)" % [int(e[1]), T.worldlet_name(String(e[0]))])
|
||||||
|
return "UDP %d and %s" % [base, ", ".join(parts)]
|
||||||
|
|
||||||
|
static func journey_from(home_land: String) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
var at := home_land
|
||||||
|
for _i in range(16):
|
||||||
|
var d: Dictionary = load("res://scripts/world/terrain.gd").load_worldlet(at)
|
||||||
|
var nxt := ""
|
||||||
|
for c in d.get("next", []):
|
||||||
|
if c is Dictionary and not String(c.get("to", "")) in out and String(c.get("to", "")) != home_land \
|
||||||
|
and not load("res://scripts/world/terrain.gd").load_worldlet(String(c.get("to", ""))).is_empty():
|
||||||
|
nxt = String(c.to)
|
||||||
|
break
|
||||||
|
if nxt.is_empty():
|
||||||
|
break
|
||||||
|
out.append(nxt)
|
||||||
|
at = nxt
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- passes -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## A one-time pass for player `id` to come into land `l`.
|
||||||
|
func make_pass(id: String, l: String) -> String:
|
||||||
|
var p := Crypto.new().generate_random_bytes(16).hex_encode()
|
||||||
|
passes[p] = {"id": id, "t": 0.0, "land": l}
|
||||||
|
return p
|
||||||
|
|
||||||
|
## Whether `p` lets player `id` in here (it's used up either way).
|
||||||
|
func take_pass(p: String, id: String) -> bool:
|
||||||
|
if not passes.has(p):
|
||||||
|
return false
|
||||||
|
var e: Dictionary = passes[p]
|
||||||
|
passes.erase(p)
|
||||||
|
return String(e.id) == id and float(e.t) < PASS_SECONDS
|
||||||
|
|
||||||
|
# --- the hub ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Makes sure land `l` has a server (starting one if not). Calls `then`
|
||||||
|
## (with "" or what went wrong) once it's listening.
|
||||||
|
func ensure(l: String, then: Callable) -> void:
|
||||||
|
if not hub:
|
||||||
|
then.call("this isn't the hub")
|
||||||
|
return
|
||||||
|
if servers.has(l) and int(servers[l].pid) != 0 and not OS.is_process_running(int(servers[l].pid)):
|
||||||
|
print("[lands] the %s's server has gone: starting it again" % l)
|
||||||
|
servers.erase(l) # (crashed, and not noticed yet: start afresh)
|
||||||
|
if servers.has(l) and bool(servers[l].ready):
|
||||||
|
servers[l].idle = 0.0
|
||||||
|
then.call("")
|
||||||
|
return
|
||||||
|
if not servers.has(l):
|
||||||
|
var why := _start(l)
|
||||||
|
if not why.is_empty():
|
||||||
|
then.call(why)
|
||||||
|
return
|
||||||
|
var waiting: Callable
|
||||||
|
waiting = func(which: String):
|
||||||
|
if which == l:
|
||||||
|
land_ready.disconnect(waiting)
|
||||||
|
then.call("")
|
||||||
|
land_ready.connect(waiting)
|
||||||
|
|
||||||
|
func _start(l: String) -> String:
|
||||||
|
var args := PackedStringArray(["--headless"])
|
||||||
|
if not OS.has_feature("template"):
|
||||||
|
args.append_array(["--path", ProjectSettings.globalize_path("res://")])
|
||||||
|
args.append_array(["--", "--server", "--land", l, "--port", str(port_for(l)), "--hub-port", str(game_port()),
|
||||||
|
"--config", ProjectSettings.globalize_path(Net.config.path), "--exit-with", str(OS.get_process_id())])
|
||||||
|
for k in ["no-creatures", "debug-cheats"]:
|
||||||
|
if Net._args.has(k):
|
||||||
|
args.append("--" + k)
|
||||||
|
if Net._args.has("host-id"):
|
||||||
|
args.append_array(["--host-id", String(Net._args["host-id"])]) # (a hosted game: the host leaving stops it all)
|
||||||
|
var p: Dictionary = OS.execute_with_pipe(OS.get_executable_path(), args, false)
|
||||||
|
if p.is_empty():
|
||||||
|
return "couldn't start the %s's server" % l
|
||||||
|
var log := FileAccess.open(Net.config.path.get_base_dir().path_join("land-%s.log" % l), FileAccess.WRITE)
|
||||||
|
servers[l] = {"pid": int(p.pid), "port": port_for(l), "io": p.stdio, "log": log, "conn": null,
|
||||||
|
"players": 0, "ready": false, "idle": 0.0}
|
||||||
|
print("[lands] starting the %s's server on UDP %d" % [l, port_for(l)])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
for p in passes.keys():
|
||||||
|
passes[p].t += delta
|
||||||
|
if float(passes[p].t) > PASS_SECONDS:
|
||||||
|
passes.erase(p)
|
||||||
|
if hub:
|
||||||
|
_hub_tick(delta)
|
||||||
|
elif not land.is_empty():
|
||||||
|
_land_tick()
|
||||||
|
|
||||||
|
func _hub_tick(delta: float) -> void:
|
||||||
|
if _link != null:
|
||||||
|
while _link.is_connection_available():
|
||||||
|
var c := _link.take_connection()
|
||||||
|
_unclaimed.append({"conn": c, "buf": ""})
|
||||||
|
for u in _unclaimed.duplicate():
|
||||||
|
var claimed := ""
|
||||||
|
for msg in _read(u):
|
||||||
|
if claimed.is_empty():
|
||||||
|
if String(msg.get("t", "")) == "hello" and servers.has(String(msg.land)):
|
||||||
|
claimed = String(msg.land)
|
||||||
|
servers[claimed].conn = u.conn
|
||||||
|
_unclaimed.erase(u)
|
||||||
|
else:
|
||||||
|
_from_land(claimed, msg) # (what came in the same read: "ready", most likely)
|
||||||
|
if not claimed.is_empty():
|
||||||
|
servers[claimed].buf = u.buf
|
||||||
|
for l in servers.keys():
|
||||||
|
var s: Dictionary = servers[l]
|
||||||
|
_drain_output(s)
|
||||||
|
if int(s.pid) != 0 and not OS.is_process_running(int(s.pid)):
|
||||||
|
print("[lands] the %s's server stopped" % l)
|
||||||
|
_drain_output(s)
|
||||||
|
servers.erase(l)
|
||||||
|
rebuild_roster()
|
||||||
|
PlayerNet.beds_changed()
|
||||||
|
continue
|
||||||
|
if s.conn != null:
|
||||||
|
for msg in _read(s):
|
||||||
|
_from_land(l, msg)
|
||||||
|
s.idle = float(s.idle) + delta if int(s.players) == 0 and bool(s.ready) else 0.0
|
||||||
|
if float(s.idle) > IDLE_STOP_SECONDS:
|
||||||
|
print("[lands] nobody in the %s for %d s: stopping its server" % [l, int(IDLE_STOP_SECONDS)])
|
||||||
|
_send_land(l, {"t": "stop"})
|
||||||
|
s.idle = -INF # (asked once)
|
||||||
|
|
||||||
|
## A land server's output goes to land-<land>.log beside server.json.
|
||||||
|
func _drain_output(s: Dictionary) -> void:
|
||||||
|
if s.io == null:
|
||||||
|
return
|
||||||
|
for _i in range(16):
|
||||||
|
var chunk: PackedByteArray = s.io.get_buffer(8192)
|
||||||
|
if chunk.is_empty():
|
||||||
|
break
|
||||||
|
if s.log != null:
|
||||||
|
s.log.store_string(chunk.get_string_from_utf8())
|
||||||
|
s.log.flush()
|
||||||
|
|
||||||
|
func _from_land(l: String, msg: Dictionary) -> void:
|
||||||
|
match String(msg.get("t", "")):
|
||||||
|
"ready":
|
||||||
|
servers[l].ready = true
|
||||||
|
_send_land(l, {"t": "clock", "day": Net._world.day_night.day, "time": Net._world.day_night.time})
|
||||||
|
_send_land(l, {"t": "tunnel", "dug": _tunnel_dug_here()})
|
||||||
|
land_ready.emit(l)
|
||||||
|
"players":
|
||||||
|
servers[l].players = int(msg.n)
|
||||||
|
"pass":
|
||||||
|
# A player there is coming back here: let them in with this pass.
|
||||||
|
passes[String(msg.pass)] = {"id": String(msg.id), "t": 0.0, "land": home()}
|
||||||
|
"tunnel?":
|
||||||
|
_send_land(l, {"t": "tunnel", "dug": _tunnel_dug_here()})
|
||||||
|
"host_left":
|
||||||
|
Net._host_left.call_deferred()
|
||||||
|
"roster":
|
||||||
|
servers[l].list = msg.get("list", [])
|
||||||
|
rebuild_roster()
|
||||||
|
"line":
|
||||||
|
# Chat from that land: everyone here, and every other land.
|
||||||
|
Chat._all(String(msg.text), String(msg.kind), false)
|
||||||
|
for other in servers:
|
||||||
|
if other != l:
|
||||||
|
_send_land(other, msg)
|
||||||
|
"cmd":
|
||||||
|
Chat.run_remote(String(msg.line), {"id": String(msg.id), "name": String(msg.name), "land": l, "peer": int(msg.peer)})
|
||||||
|
"beds":
|
||||||
|
servers[l].beds = [int(msg.alive), int(msg.asleep)]
|
||||||
|
# Tell the lands the running total (minus their own), so each can
|
||||||
|
# say "N of M asleep", then see if the night passes.
|
||||||
|
_push_bed_totals()
|
||||||
|
PlayerNet.beds_changed()
|
||||||
|
|
||||||
|
## Sends `msg` to land `l`'s server.
|
||||||
|
func _send_land(l: String, msg: Dictionary) -> void:
|
||||||
|
if not servers.has(l) or servers[l].conn == null:
|
||||||
|
return
|
||||||
|
var c: StreamPeerTCP = servers[l].conn
|
||||||
|
c.poll()
|
||||||
|
if c.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
c.put_data((JSON.stringify(msg) + "\n").to_utf8_buffer())
|
||||||
|
|
||||||
|
## Hub: tells every land how far the cliff tunnel's dug (the Cove's sea
|
||||||
|
## cave leads up it once it's through).
|
||||||
|
func push_tunnel(dug: int) -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "tunnel", "dug": dug})
|
||||||
|
|
||||||
|
func _tunnel_dug_here() -> int:
|
||||||
|
var t: Node3D = Net._world.tunnel if Net._world != null else null
|
||||||
|
return int(t.dug) if t != null else 0
|
||||||
|
|
||||||
|
## Hub: tells every land the clock (so there's one clock everywhere).
|
||||||
|
func push_clock(day: int, time: float) -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "clock", "day": day, "time": time})
|
||||||
|
|
||||||
|
## Hub: a pass for player `id` to go to land `l` (the land server learns it).
|
||||||
|
func pass_to(l: String, id: String) -> String:
|
||||||
|
var p := make_pass(id, l)
|
||||||
|
_send_land(l, {"t": "pass", "pass": p, "id": id})
|
||||||
|
passes.erase(p) # (it's the land server's to take, not ours)
|
||||||
|
return p
|
||||||
|
|
||||||
|
# --- a land server --------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _said_hello := false
|
||||||
|
var _last_players := -1
|
||||||
|
var tunnel_dug := -1 ## (land server) how far the hub's tunnel is dug, last asked
|
||||||
|
|
||||||
|
func _land_tick() -> void:
|
||||||
|
if _hub == null:
|
||||||
|
return
|
||||||
|
_hub.poll()
|
||||||
|
var st := _hub.get_status()
|
||||||
|
if st == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
if not _said_hello:
|
||||||
|
_said_hello = true
|
||||||
|
send_hub({"t": "hello", "land": land})
|
||||||
|
send_hub({"t": "ready"})
|
||||||
|
var n := Net.players.size()
|
||||||
|
if n != _last_players or _roster_dirty:
|
||||||
|
_last_players = n
|
||||||
|
_roster_dirty = false
|
||||||
|
send_hub({"t": "players", "n": n})
|
||||||
|
send_hub({"t": "roster", "list": _here_list()})
|
||||||
|
for msg in _read(_hub_link):
|
||||||
|
_from_hub(msg)
|
||||||
|
elif st == StreamPeerTCP.STATUS_ERROR or (st == StreamPeerTCP.STATUS_NONE and _said_hello):
|
||||||
|
print("[lands] lost the hub: saving and stopping")
|
||||||
|
_hub = null
|
||||||
|
Net.stop()
|
||||||
|
get_tree().quit()
|
||||||
|
|
||||||
|
func _from_hub(msg: Dictionary) -> void:
|
||||||
|
match String(msg.get("t", "")):
|
||||||
|
"stop":
|
||||||
|
print("[lands] the hub asked us to stop: saving")
|
||||||
|
_hub = null
|
||||||
|
Net.stop()
|
||||||
|
get_tree().quit()
|
||||||
|
"pass":
|
||||||
|
passes[String(msg.pass)] = {"id": String(msg.id), "t": 0.0, "land": land}
|
||||||
|
"clock":
|
||||||
|
if Net._world != null:
|
||||||
|
var dn: Node = Net._world.day_night
|
||||||
|
var was_day: int = dn.day
|
||||||
|
var was_t: float = dn.time
|
||||||
|
var secs: float = ((int(msg.day) - was_day) + (float(msg.time) - was_t)) * dn.day_length
|
||||||
|
if secs > 1.0:
|
||||||
|
Net._world.skip_time(secs) # (catching up: a slept night, or time nobody was here)
|
||||||
|
dn.day = int(msg.day)
|
||||||
|
dn.time = float(msg.time)
|
||||||
|
dn.apply(true)
|
||||||
|
Net.push_clock()
|
||||||
|
"tunnel":
|
||||||
|
tunnel_dug = int(msg.dug)
|
||||||
|
"roster":
|
||||||
|
roster = msg.get("list", [])
|
||||||
|
Net.push_roster()
|
||||||
|
"line":
|
||||||
|
Chat._all(String(msg.text), String(msg.kind), false)
|
||||||
|
"reply":
|
||||||
|
if Net.players.has(int(msg.peer)):
|
||||||
|
Chat._s2c_line.rpc_id(int(msg.peer), String(msg.text), "reply")
|
||||||
|
"kick":
|
||||||
|
var kp: int = Net.peer_by_name(String(msg.name))
|
||||||
|
if kp != 0:
|
||||||
|
Net.kick(kp, String(msg.reason))
|
||||||
|
"config":
|
||||||
|
Net.reload_config()
|
||||||
|
Net.broadcast_rules()
|
||||||
|
"weather":
|
||||||
|
Net.set_weather(String(msg.state))
|
||||||
|
"save":
|
||||||
|
WorldNet.save()
|
||||||
|
"night":
|
||||||
|
PlayerNet.pass_night_here(float(msg.secs))
|
||||||
|
"beds_total":
|
||||||
|
PlayerNet._hub_beds = [int(msg.alive), int(msg.asleep)]
|
||||||
|
hub_message.emit(msg)
|
||||||
|
|
||||||
|
func send_hub(msg: Dictionary) -> void:
|
||||||
|
if _hub != null and _hub.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
_hub.put_data((JSON.stringify(msg) + "\n").to_utf8_buffer())
|
||||||
|
|
||||||
|
# --- everyone, everywhere (J3b) ------------------------------------------------------------
|
||||||
|
|
||||||
|
## Everyone on every land: [{"name", "id", "land", "admin"}]. The hub builds
|
||||||
|
## it from its own players and what each land reports; every land and every
|
||||||
|
## player's game gets a copy (Net.roster), for the player list and /players.
|
||||||
|
var roster: Array = []
|
||||||
|
var _roster_dirty := false
|
||||||
|
|
||||||
|
## The players on this server, as the roster lists them.
|
||||||
|
func _here_list() -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
for p in Net.players.values():
|
||||||
|
out.append({"name": String(p.name), "id": String(p.id), "land": here(), "admin": bool(p.get("admin", false))})
|
||||||
|
return out
|
||||||
|
|
||||||
|
## Hub: the roster again (its players changed, or a land's did), sent out.
|
||||||
|
func rebuild_roster() -> void:
|
||||||
|
if not hub:
|
||||||
|
if not land.is_empty():
|
||||||
|
_roster_dirty = true # (a land server: tell the hub, next tick)
|
||||||
|
return
|
||||||
|
var out: Array = _here_list()
|
||||||
|
for l in servers:
|
||||||
|
for e in servers[l].get("list", []):
|
||||||
|
var e2: Dictionary = (e as Dictionary).duplicate()
|
||||||
|
e2.land = l
|
||||||
|
out.append(e2)
|
||||||
|
roster = out
|
||||||
|
Net.push_roster()
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "roster", "list": out})
|
||||||
|
|
||||||
|
## The land a player called `n` is in ("" = not on anywhere).
|
||||||
|
func land_of_name(n: String) -> String:
|
||||||
|
for e in roster:
|
||||||
|
if String(e.name).to_lower() == n.to_lower():
|
||||||
|
return String(e.land)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
## Chat or a server message, from here to every other land.
|
||||||
|
func relay_line(text: String, kind: String) -> void:
|
||||||
|
if hub:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "line", "text": text, "kind": kind})
|
||||||
|
elif not land.is_empty():
|
||||||
|
send_hub({"t": "line", "text": text, "kind": kind})
|
||||||
|
|
||||||
|
## Hub: sends `msg` to every land.
|
||||||
|
func tell_lands(msg: Dictionary) -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, msg)
|
||||||
|
|
||||||
|
## Hub: sends `msg` to land `l`.
|
||||||
|
func tell_land(l: String, msg: Dictionary) -> void:
|
||||||
|
_send_land(l, msg)
|
||||||
|
|
||||||
|
## Hub: tells each land the alive/asleep everywhere BUT that land, so a land
|
||||||
|
## server can show the right "N of M" and know when all are abed.
|
||||||
|
func _push_bed_totals() -> void:
|
||||||
|
var here_alive: int = PlayerNet._alive().size()
|
||||||
|
var here_asleep: int = PlayerNet._alive().filter(func(q): return q.in_bed).size()
|
||||||
|
for l in servers:
|
||||||
|
var a := here_alive
|
||||||
|
var s := here_asleep
|
||||||
|
for other in servers:
|
||||||
|
if other != l:
|
||||||
|
var b: Array = servers[other].get("beds", [0, 0])
|
||||||
|
a += int(b[0])
|
||||||
|
s += int(b[1])
|
||||||
|
_send_land(l, {"t": "beds_total", "alive": a, "asleep": s})
|
||||||
|
|
||||||
|
## Hub: how many are alive, and how many asleep, in every other land.
|
||||||
|
func beds_elsewhere() -> Array:
|
||||||
|
var alive := 0
|
||||||
|
var asleep := 0
|
||||||
|
for l in servers:
|
||||||
|
var b: Array = servers[l].get("beds", [0, 0])
|
||||||
|
alive += int(b[0])
|
||||||
|
asleep += int(b[1])
|
||||||
|
return [alive, asleep]
|
||||||
|
|
||||||
|
## Land server: a pass for player `id` to go back to the hub.
|
||||||
|
func pass_home(id: String) -> String:
|
||||||
|
var p := Crypto.new().generate_random_bytes(16).hex_encode()
|
||||||
|
send_hub({"t": "pass", "pass": p, "id": id})
|
||||||
|
return p
|
||||||
|
|
||||||
|
# --- the line protocol ------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Whole JSON lines waiting on `link` ({"conn", "buf"}); keeps the rest.
|
||||||
|
func _read(link: Dictionary) -> Array:
|
||||||
|
var c: StreamPeerTCP = link.conn
|
||||||
|
if c == null:
|
||||||
|
return []
|
||||||
|
c.poll()
|
||||||
|
if c.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
return []
|
||||||
|
var n := c.get_available_bytes()
|
||||||
|
if n > 0:
|
||||||
|
var got: Array = c.get_data(n)
|
||||||
|
if int(got[0]) == OK:
|
||||||
|
link.buf = String(link.get("buf", "")) + (got[1] as PackedByteArray).get_string_from_utf8()
|
||||||
|
var out: Array = []
|
||||||
|
var buf := String(link.get("buf", ""))
|
||||||
|
while buf.contains("\n"):
|
||||||
|
var line := buf.get_slice("\n", 0)
|
||||||
|
buf = buf.substr(line.length() + 1)
|
||||||
|
var m: Variant = JSON.parse_string(line)
|
||||||
|
if m is Dictionary:
|
||||||
|
out.append(m)
|
||||||
|
link.buf = buf
|
||||||
|
return out
|
||||||
1
scripts/net/lands.gd.uid
Normal file
1
scripts/net/lands.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cl2iw0a328ja8
|
||||||
@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
|
|||||||
const Identity = preload("res://scripts/net/identity.gd")
|
const Identity = preload("res://scripts/net/identity.gd")
|
||||||
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
||||||
|
|
||||||
const PROTOCOL := 15 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand)
|
const PROTOCOL := 18 ## 18: the Cove's own (the wreck and treasure, the drowned crew, crabs and gulls, boats at the pier); 17: lands on a server (crossings, the tunnel online, the roster); 16: worldlets (the Cove), sea fish (mackerel, cod); 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand)
|
||||||
const DEFAULT_PORT := 24242
|
const DEFAULT_PORT := 24242
|
||||||
const HANDSHAKE_TIMEOUT := 10.0
|
const HANDSHAKE_TIMEOUT := 10.0
|
||||||
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
|
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
|
||||||
@@ -191,6 +191,10 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
|
|||||||
if overrides.has(flag):
|
if overrides.has(flag):
|
||||||
var v: Variant = overrides[flag]
|
var v: Variant = overrides[flag]
|
||||||
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v)
|
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v)
|
||||||
|
# A land server (J3, lands.gd) runs another land of the hub's world.
|
||||||
|
Saves.land = String(overrides.get("land", ""))
|
||||||
|
if not Saves.land.is_empty():
|
||||||
|
config.overrides["port"] = int(overrides.get("port", config.v("port")))
|
||||||
# The shared island (world_net.gd): the same terrain and scenery every
|
# The shared island (world_net.gd): the same terrain and scenery every
|
||||||
# client generates from the seed, plus everything players changed.
|
# client generates from the seed, plus everything players changed.
|
||||||
_world = WorldNet.open_server_world(config)
|
_world = WorldNet.open_server_world(config)
|
||||||
@@ -211,8 +215,10 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
|
|||||||
players.clear()
|
players.clear()
|
||||||
_pending.clear()
|
_pending.clear()
|
||||||
_set_state("server")
|
_set_state("server")
|
||||||
print("[server] '%s' listening on UDP %d (max %d players%s). Config: %s" % [config.v("server_name"), port,
|
Lands.server_started(overrides)
|
||||||
|
print("[server] '%s' listening on UDP %d (max %d players%s%s). Config: %s" % [config.v("server_name"), port,
|
||||||
int(config.v("max_players")), ", password set" if not String(config.v("password")).is_empty() else "",
|
int(config.v("max_players")), ", password set" if not String(config.v("password")).is_empty() else "",
|
||||||
|
", the %s (a land server)" % Saves.land if not Saves.land.is_empty() else "",
|
||||||
ProjectSettings.globalize_path(config.path)])
|
ProjectSettings.globalize_path(config.path)])
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -226,11 +232,14 @@ func stop() -> void:
|
|||||||
_states.clear()
|
_states.clear()
|
||||||
server_info.clear()
|
server_info.clear()
|
||||||
world_info.clear()
|
world_info.clear()
|
||||||
|
roster.clear()
|
||||||
_out_q.clear()
|
_out_q.clear()
|
||||||
_in_q.clear()
|
_in_q.clear()
|
||||||
local_peer = 0
|
local_peer = 0
|
||||||
if _world != null:
|
if _world != null:
|
||||||
WorldNet.close_server_world() # saves it first
|
WorldNet.close_server_world() # saves it first
|
||||||
|
Lands.server_stopped() # (and the hub's land servers with it)
|
||||||
|
Saves.land = ""
|
||||||
_world = null
|
_world = null
|
||||||
_terrain = null
|
_terrain = null
|
||||||
if state != "offline":
|
if state != "offline":
|
||||||
@@ -252,7 +261,8 @@ func _on_peer_disconnected(id: int) -> void:
|
|||||||
_s2c_player_left.rpc(id)
|
_s2c_player_left.rpc(id)
|
||||||
PlayerNet.server_leave(id) # saves their character
|
PlayerNet.server_leave(id) # saves their character
|
||||||
WorldNet.peer_left(id)
|
WorldNet.peer_left(id)
|
||||||
if _hosted_by(String(p.id)):
|
# (The host crossing to another land hasn't left: only leaving the game does.)
|
||||||
|
if _hosted_by(String(p.id)) and PlayerNet.land_of(String(p.id)) in ["", Lands.here()]:
|
||||||
_host_left.call_deferred()
|
_host_left.call_deferred()
|
||||||
players_changed.emit()
|
players_changed.emit()
|
||||||
|
|
||||||
@@ -282,10 +292,12 @@ func _process(delta: float) -> void:
|
|||||||
## it every few seconds.
|
## it every few seconds.
|
||||||
func _tick_clock(delta: float) -> void:
|
func _tick_clock(delta: float) -> void:
|
||||||
_clock_left -= delta
|
_clock_left -= delta
|
||||||
if _clock_left <= 0.0 and not players.is_empty():
|
if _clock_left <= 0.0 and (not players.is_empty() or not Lands.servers.is_empty()):
|
||||||
_clock_left = CLOCK_INTERVAL
|
_clock_left = CLOCK_INTERVAL
|
||||||
for peer in players:
|
for peer in players:
|
||||||
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
||||||
|
if Lands.hub:
|
||||||
|
Lands.push_clock(_world.day_night.day, _world.day_night.time) # (one clock for every land)
|
||||||
|
|
||||||
# --- hosting from the title screen (M2.7) ------------------------------------------
|
# --- hosting from the title screen (M2.7) ------------------------------------------
|
||||||
# "Host Game" runs a dedicated server as a separate, windowless copy of the
|
# "Host Game" runs a dedicated server as a separate, windowless copy of the
|
||||||
@@ -304,14 +316,14 @@ var _host_out := ""
|
|||||||
var _host_port := DEFAULT_PORT
|
var _host_port := DEFAULT_PORT
|
||||||
|
|
||||||
## Settings for a hosted game: name, password, port, a rules preset
|
## 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 := {
|
const PRESETS := {
|
||||||
"co-op": {"pvp": "false", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
|
"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"},
|
"rivals": {"pvp": "true", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
|
||||||
"adversarial": {"pvp": "true", "raiding": "true", "locks": "owner", "death_penalty": "drop_all"},
|
"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":
|
if hosting or state != "offline":
|
||||||
return
|
return
|
||||||
if _host_pid != 0 and OS.is_process_running(_host_pid):
|
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.password = password
|
||||||
c.data.port = port
|
c.data.port = port
|
||||||
var fresh := not FileAccess.file_exists(host_config.get_base_dir().path_join("worlds/server-world/meta.json"))
|
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.seed = seed
|
||||||
|
c.data.worldlet = worldlet
|
||||||
c.save()
|
c.save()
|
||||||
_host_port = port
|
_host_port = port
|
||||||
var args := PackedStringArray(["--headless"])
|
var args := PackedStringArray(["--headless"])
|
||||||
@@ -413,7 +426,9 @@ func _hosted_by(id: String) -> bool:
|
|||||||
|
|
||||||
func _host_left() -> void:
|
func _host_left() -> void:
|
||||||
print("[server] the host left: saving and stopping")
|
print("[server] the host left: saving and stopping")
|
||||||
stop() # (saves the world and everyone's characters)
|
if not Lands.land.is_empty():
|
||||||
|
Lands.send_hub({"t": "host_left"}) # (they left from another land: the hub stops too)
|
||||||
|
stop() # (saves the world and everyone's characters, and stops the other lands)
|
||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
|
|
||||||
# --- admin (chat.gd) ---------------------------------------------------------------
|
# --- admin (chat.gd) ---------------------------------------------------------------
|
||||||
@@ -455,11 +470,94 @@ func _s2c_weather(state: String) -> void:
|
|||||||
|
|
||||||
signal weather_changed(state: String)
|
signal weather_changed(state: String)
|
||||||
|
|
||||||
|
# --- crossing to another land (J3, lands.gd) ------------------------------------------
|
||||||
|
|
||||||
|
signal travelling(land: String) ## client: on our way to another land's server
|
||||||
|
signal arrived(land: String) ## client: in, at the other land (the world reloads)
|
||||||
|
|
||||||
|
var _travel: Dictionary = {} ## client: {"land", "address", "pass"} while crossing
|
||||||
|
var _hub_address := "" ## client: the address we joined (the hub's), for the host part
|
||||||
|
|
||||||
|
## Server: sends player `peer`'s game on to land `l` (its server's port, and
|
||||||
|
## the pass that lets them in), then lets them go.
|
||||||
|
func send_travel(peer: int, l: String, pass_: String) -> void:
|
||||||
|
_s2c_travel.rpc_id(peer, {"land": l, "port": Lands.port_for(l), "pass": pass_})
|
||||||
|
# (Their game goes on its own; this only tidies up one that doesn't.)
|
||||||
|
get_tree().create_timer(3.0).timeout.connect(func():
|
||||||
|
if _peer != null and is_server() and (players.has(peer) or _pending.has(peer)):
|
||||||
|
_peer.disconnect_peer(peer))
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_travel(d: Dictionary) -> void:
|
||||||
|
var host := String(parse_address(_hub_address if not _hub_address.is_empty() else get_identity().last_server)[0])
|
||||||
|
if host.is_empty():
|
||||||
|
host = "127.0.0.1"
|
||||||
|
_travel = {"land": String(d.land), "address": "%s:%d" % [host, int(d.port)], "pass": String(d.pass)}
|
||||||
|
print("[net] on to the %s (%s)" % [d.land, _travel.address])
|
||||||
|
travelling.emit(String(d.land))
|
||||||
|
var pw := _pending_password
|
||||||
|
_quiet_stop()
|
||||||
|
join(String(_travel.address), pw, String(_travel.pass))
|
||||||
|
|
||||||
|
## Closes the connection without telling anyone we left (a crossing).
|
||||||
|
func _quiet_stop() -> void:
|
||||||
|
if _peer != null:
|
||||||
|
_peer.close()
|
||||||
|
_peer = null
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
players.clear()
|
||||||
|
_states.clear()
|
||||||
|
_out_q.clear()
|
||||||
|
_in_q.clear()
|
||||||
|
local_peer = 0
|
||||||
|
_set_state("offline")
|
||||||
|
|
||||||
|
func is_travelling() -> bool:
|
||||||
|
return not _travel.is_empty()
|
||||||
|
|
||||||
|
# --- everyone on every land (J3b, lands.gd) -------------------------------------------
|
||||||
|
|
||||||
|
## Client: everyone on the server, whichever land they're in: [{"name",
|
||||||
|
## "id", "land", "admin"}] (the hub's roster). Empty on a server with only
|
||||||
|
## one land running: then `players` is everyone.
|
||||||
|
var roster: Array = []
|
||||||
|
|
||||||
|
## Server: everyone here gets the roster.
|
||||||
|
func push_roster() -> void:
|
||||||
|
for peer in players:
|
||||||
|
_s2c_roster.rpc_id(peer, Lands.roster)
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_roster(list: Array) -> void:
|
||||||
|
roster = list
|
||||||
|
players_changed.emit()
|
||||||
|
|
||||||
|
## Everyone on the server to show in a list: the roster, or (one land) just
|
||||||
|
## those here. [{"name", "admin", "land"}]
|
||||||
|
func everyone() -> Array:
|
||||||
|
if not roster.is_empty():
|
||||||
|
return roster
|
||||||
|
var out: Array = []
|
||||||
|
for p in players.values():
|
||||||
|
out.append({"name": String(p.name), "admin": bool(p.get("admin", false)), "land": String(world_info.get("worldlet", ""))})
|
||||||
|
return out
|
||||||
|
|
||||||
|
## Server: server.json read again (the hub changed it: a /set, a ban, a
|
||||||
|
## new admin, a friend), keeping this run's command-line overrides.
|
||||||
|
func reload_config() -> void:
|
||||||
|
if config == null:
|
||||||
|
return
|
||||||
|
var ov: Dictionary = config.overrides
|
||||||
|
config = ServerConfig.open(config.path)
|
||||||
|
config.overrides = ov
|
||||||
|
|
||||||
## Everyone gets the clock now (after a night's sleep, an admin's /time).
|
## Everyone gets the clock now (after a night's sleep, an admin's /time).
|
||||||
func push_clock() -> void:
|
func push_clock() -> void:
|
||||||
_clock_left = CLOCK_INTERVAL
|
_clock_left = CLOCK_INTERVAL
|
||||||
for peer in players:
|
for peer in players:
|
||||||
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
||||||
|
if Lands.hub:
|
||||||
|
Lands.push_clock(_world.day_night.day, _world.day_night.time)
|
||||||
|
|
||||||
## Where a player is, as far as the server knows.
|
## Where a player is, as far as the server knows.
|
||||||
func player_pos(peer: int) -> Vector3:
|
func player_pos(peer: int) -> Vector3:
|
||||||
@@ -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]
|
return "too fast (%.0f m in %.2f s)" % [flat, dt]
|
||||||
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
|
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
|
||||||
return "rising too fast"
|
return "rising too fast"
|
||||||
|
# (Down the cliff tunnel, J1: its own space under the island, where its
|
||||||
|
# rock is the ground. The way in and out is a server teleport.)
|
||||||
|
if _world != null and _world.underground_at(to):
|
||||||
|
return "" if teleported or _world.underground_at(from) else "into the tunnel without going in"
|
||||||
|
if _world != null and _world.underground_at(from):
|
||||||
|
return "out of the tunnel without climbing out"
|
||||||
if not _terrain.inside_boundary(to.x, to.z, -3.0):
|
if not _terrain.inside_boundary(to.x, to.z, -3.0):
|
||||||
return "outside the island"
|
return "outside the island"
|
||||||
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
|
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
|
||||||
@@ -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" % [
|
_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."])
|
Updates.version, PROTOCOL, theirs, "Update the game to join." if theirs < PROTOCOL else "The server needs updating."])
|
||||||
return
|
return
|
||||||
|
# (A crossing to another land comes with a one-time pass instead, checked
|
||||||
|
# once they've proved who they are: _c2s_proof.)
|
||||||
var pw := String(config.v("password"))
|
var pw := String(config.v("password"))
|
||||||
if not pw.is_empty() and String(d.get("password", "")) != pw:
|
if not pw.is_empty() and String(d.get("password", "")) != pw and String(d.get("pass", "")).is_empty():
|
||||||
_reject(peer, "Wrong password")
|
_reject(peer, "Wrong password")
|
||||||
return
|
return
|
||||||
var nonce := Crypto.new().generate_random_bytes(32)
|
var nonce := Crypto.new().generate_random_bytes(32)
|
||||||
@@ -618,6 +724,23 @@ func _c2s_proof(sig: PackedByteArray) -> void:
|
|||||||
if not why.is_empty():
|
if not why.is_empty():
|
||||||
_reject(peer, why)
|
_reject(peer, why)
|
||||||
return
|
return
|
||||||
|
# Lands (J3): a land server only lets in someone crossing with a pass the
|
||||||
|
# hub gave them; a pass anywhere must be theirs and fresh. And on the
|
||||||
|
# hub, a character who's in another land is sent straight on there.
|
||||||
|
var pass_ := String(hello.get("pass", ""))
|
||||||
|
if not pass_.is_empty():
|
||||||
|
if not Lands.take_pass(pass_, id):
|
||||||
|
_reject(peer, "That way in has expired: join the server again")
|
||||||
|
return
|
||||||
|
elif not Lands.land.is_empty():
|
||||||
|
_reject(peer, "Join this server's main address (this is one of its other lands)")
|
||||||
|
return
|
||||||
|
elif Lands.hub:
|
||||||
|
var away: String = PlayerNet.land_of(id)
|
||||||
|
if not away.is_empty() and away != Lands.home():
|
||||||
|
_pending.erase(peer)
|
||||||
|
PlayerNet.send_on(peer, id, away)
|
||||||
|
return
|
||||||
_pending.erase(peer)
|
_pending.erase(peer)
|
||||||
config.reserve_name(name, id)
|
config.reserve_name(name, id)
|
||||||
if config.data.admins.is_empty():
|
if config.data.admins.is_empty():
|
||||||
@@ -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 "",
|
print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
|
||||||
players.size(), int(config.v("max_players"))])
|
players.size(), int(config.v("max_players"))])
|
||||||
# (The world's own seed: an existing world keeps the seed it was made with.)
|
# (The world's own seed: an existing world keeps the seed it was made with.)
|
||||||
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(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,
|
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
|
||||||
"weather": _world.weather.override}
|
"weather": _world.weather.override,
|
||||||
|
"tunnel_dug": Lands.tunnel_dug if not Lands.land.is_empty() else Lands._tunnel_dug_here()}
|
||||||
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
||||||
for other in players:
|
for other in players:
|
||||||
if other != peer:
|
if other != peer:
|
||||||
@@ -682,7 +806,12 @@ static func parse_address(a: String) -> Array:
|
|||||||
return [a, DEFAULT_PORT]
|
return [a, DEFAULT_PORT]
|
||||||
|
|
||||||
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
|
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
|
||||||
func join(address: String, password: String = "") -> void:
|
## `pass_` is a one-time pass from a crossing to another land (J3).
|
||||||
|
func join(address: String, password: String = "", pass_: String = "") -> void:
|
||||||
|
_pending_pass = pass_
|
||||||
|
if pass_.is_empty():
|
||||||
|
_hub_address = address.strip_edges()
|
||||||
|
_travel = {}
|
||||||
if state != "offline":
|
if state != "offline":
|
||||||
stop()
|
stop()
|
||||||
var me := get_identity()
|
var me := get_identity()
|
||||||
@@ -704,11 +833,13 @@ func join(address: String, password: String = "") -> void:
|
|||||||
_reject_reason = ""
|
_reject_reason = ""
|
||||||
_pending_password = password
|
_pending_password = password
|
||||||
_join_left = JOIN_TIMEOUT
|
_join_left = JOIN_TIMEOUT
|
||||||
me.last_server = address.strip_edges()
|
if pass_.is_empty():
|
||||||
me.save_profile()
|
me.last_server = address.strip_edges() # (always the hub's: rejoining sends you on)
|
||||||
|
me.save_profile()
|
||||||
_set_state("connecting")
|
_set_state("connecting")
|
||||||
|
|
||||||
var _pending_password := ""
|
var _pending_password := ""
|
||||||
|
var _pending_pass := ""
|
||||||
|
|
||||||
func leave() -> void:
|
func leave() -> void:
|
||||||
var was := state
|
var was := state
|
||||||
@@ -721,7 +852,7 @@ func leave() -> void:
|
|||||||
func _on_connected() -> void:
|
func _on_connected() -> void:
|
||||||
var me := get_identity()
|
var me := get_identity()
|
||||||
_c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name,
|
_c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name,
|
||||||
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password})
|
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password, "pass": _pending_pass})
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_challenge(nonce: PackedByteArray) -> void:
|
func _s2c_challenge(nonce: PackedByteArray) -> void:
|
||||||
@@ -744,6 +875,10 @@ func _s2c_welcome(d: Dictionary) -> void:
|
|||||||
_set_state("connected")
|
_set_state("connected")
|
||||||
joined.emit()
|
joined.emit()
|
||||||
players_changed.emit()
|
players_changed.emit()
|
||||||
|
if not _travel.is_empty():
|
||||||
|
var l := String(_travel.land)
|
||||||
|
_travel = {}
|
||||||
|
arrived.emit(l)
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_rejected(reason: String) -> void:
|
func _s2c_rejected(reason: String) -> void:
|
||||||
@@ -806,10 +941,17 @@ func _run_queue(q: Array) -> void:
|
|||||||
f.call()
|
f.call()
|
||||||
|
|
||||||
func _fail(reason: String) -> void:
|
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()
|
stop()
|
||||||
last_reason = reason
|
last_reason = reason
|
||||||
join_failed.emit(reason)
|
join_failed.emit(reason)
|
||||||
|
if crossing:
|
||||||
|
# (The world we were in is still on screen: it goes back to the title.)
|
||||||
|
last_reason = "Couldn't get through to the next land (%s). Join again to carry on." % reason
|
||||||
|
left.emit(last_reason)
|
||||||
|
|
||||||
func _on_connection_failed() -> void:
|
func _on_connection_failed() -> void:
|
||||||
_fail("Couldn't reach the server")
|
_fail("Couldn't reach the server")
|
||||||
@@ -817,6 +959,11 @@ func _on_connection_failed() -> void:
|
|||||||
func _on_server_disconnected() -> void:
|
func _on_server_disconnected() -> void:
|
||||||
var was := state
|
var was := state
|
||||||
var reason := _reject_reason
|
var reason := _reject_reason
|
||||||
|
if not _travel.is_empty():
|
||||||
|
if was == "connecting":
|
||||||
|
_fail(reason if not reason.is_empty() else "the server hung up") # (the next land wouldn't have us)
|
||||||
|
return # (the land we're leaving letting us go)
|
||||||
|
var land_name := on_land()
|
||||||
stop_hosting()
|
stop_hosting()
|
||||||
stop()
|
stop()
|
||||||
if was == "connecting":
|
if was == "connecting":
|
||||||
@@ -824,8 +971,19 @@ func _on_server_disconnected() -> void:
|
|||||||
join_failed.emit(last_reason)
|
join_failed.emit(last_reason)
|
||||||
else:
|
else:
|
||||||
last_reason = reason if not reason.is_empty() else "The server closed the connection"
|
last_reason = reason if not reason.is_empty() else "The server closed the connection"
|
||||||
|
if reason.is_empty() and not land_name.is_empty():
|
||||||
|
# (Another land's server went: the rest of the server's still up,
|
||||||
|
# and joining again starts that land's server afresh.)
|
||||||
|
last_reason = "Lost the connection to the %s. Join the server again to carry on there." % \
|
||||||
|
preload("res://scripts/world/terrain.gd").worldlet_name(land_name)
|
||||||
left.emit(last_reason)
|
left.emit(last_reason)
|
||||||
|
|
||||||
|
## Client: the land we're in when it's not the server's home land (we're on
|
||||||
|
## one of its other lands' servers), else "".
|
||||||
|
func on_land() -> String:
|
||||||
|
var here := String(world_info.get("worldlet", ""))
|
||||||
|
return here if not here.is_empty() and here != String(server_info.get("worldlet", here)) else ""
|
||||||
|
|
||||||
func _notification(what: int) -> void:
|
func _notification(what: int) -> void:
|
||||||
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
|
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
|
||||||
if _peer != null:
|
if _peer != null:
|
||||||
|
|||||||
1
scripts/net/net.gd.uid
Normal file
1
scripts/net/net.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d1153ihwyw01t
|
||||||
1
scripts/net/net_sync.gd.uid
Normal file
1
scripts/net/net_sync.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bgyhqtmwppt4g
|
||||||
@@ -79,11 +79,20 @@ func server_join(peer: int, id: String, display: String) -> Vector3:
|
|||||||
p.load_data(d)
|
p.load_data(d)
|
||||||
var t: Node3D = WorldNet.world.terrain
|
var t: Node3D = WorldNet.world.terrain
|
||||||
var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF
|
var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF
|
||||||
if pos.is_finite() and t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0:
|
var here_ := String(d.get("land", Lands.here())) == Lands.here()
|
||||||
|
if here_ and d.has("arrive"):
|
||||||
|
# Just crossed over: where the way in comes out (J3).
|
||||||
|
pos = WorldNet.world.arrival_point(String(d.arrive))
|
||||||
|
elif not here_:
|
||||||
|
pos = Vector3.INF # (saved somewhere else: start at this land's start)
|
||||||
|
var ok_spot: bool = pos.is_finite() and (WorldNet.world.underground_at(pos) \
|
||||||
|
or (t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0))
|
||||||
|
if ok_spot:
|
||||||
at = pos
|
at = pos
|
||||||
print("[server] %s's character loaded" % display)
|
print("[server] %s's character loaded" % display)
|
||||||
if at != Vector3.INF:
|
if at != Vector3.INF:
|
||||||
p.global_position = at
|
p.global_position = at
|
||||||
|
beds_changed.call_deferred() # (one more awake: everyone's count changes)
|
||||||
return at
|
return at
|
||||||
|
|
||||||
func place_at(peer: int, pos: Vector3) -> void:
|
func place_at(peer: int, pos: Vector3) -> void:
|
||||||
@@ -127,10 +136,54 @@ func _save(p: Node3D) -> void:
|
|||||||
if f == null:
|
if f == null:
|
||||||
printerr("[server] can't save %s's character" % p.display)
|
printerr("[server] can't save %s's character" % p.display)
|
||||||
return
|
return
|
||||||
f.store_string(JSON.stringify(p.to_data(), "\t"))
|
var d: Dictionary = p.to_data()
|
||||||
|
# Which land the character's in (J3: characters are shared by every land
|
||||||
|
# of the world); heading somewhere, it's there they'll turn up next.
|
||||||
|
d["land"] = String(p.get_meta("heading", Lands.here()))
|
||||||
|
if p.has_meta("arrive"):
|
||||||
|
d["arrive"] = String(p.get_meta("arrive"))
|
||||||
|
f.store_string(JSON.stringify(d, "\t"))
|
||||||
f.close()
|
f.close()
|
||||||
DirAccess.rename_absolute(tmp, path)
|
DirAccess.rename_absolute(tmp, path)
|
||||||
|
|
||||||
|
## Which land player `id`'s character is in ("" = not saved yet, or here).
|
||||||
|
func land_of(id: String) -> String:
|
||||||
|
return String(_read(_file(id)).get("land", ""))
|
||||||
|
|
||||||
|
## The hub: player `id` (at `peer`, not let in yet) left their character in
|
||||||
|
## land `l`: start its server if need be and send them on there.
|
||||||
|
func send_on(peer: int, id: String, l: String) -> void:
|
||||||
|
print("[server] %s's character is in the %s: sending them on" % [id.substr(0, 8), l])
|
||||||
|
Lands.ensure(l, func(why: String):
|
||||||
|
if not why.is_empty():
|
||||||
|
Net._reject(peer, "The %s isn't open right now (%s)" % [l, why])
|
||||||
|
return
|
||||||
|
Net.send_travel(peer, l, Lands.pass_to(l, id)))
|
||||||
|
|
||||||
|
## A crossing, on the server (the tunnel's sea cave, the cave back up): the
|
||||||
|
## character is saved as heading for `to`, arriving at `arrive`, and their
|
||||||
|
## game is sent on (to a land server, or back to the hub).
|
||||||
|
func cross(peer: int, to: String, arrive: String) -> void:
|
||||||
|
var p: Node3D = proxies.get(peer)
|
||||||
|
if p == null or p.has_meta("heading"):
|
||||||
|
return
|
||||||
|
p.set_meta("heading", to)
|
||||||
|
p.set_meta("arrive", arrive)
|
||||||
|
_save(p)
|
||||||
|
var id: String = p.player_id
|
||||||
|
print("[server] %s crosses to the %s" % [p.display, to])
|
||||||
|
if to == Lands.home() and not Lands.land.is_empty():
|
||||||
|
Net.send_travel(peer, to, Lands.pass_home(id))
|
||||||
|
return
|
||||||
|
Lands.ensure(to, func(why: String):
|
||||||
|
if not why.is_empty():
|
||||||
|
p.remove_meta("heading")
|
||||||
|
p.remove_meta("arrive")
|
||||||
|
_save(p)
|
||||||
|
tell(peer, "The way's blocked for now (%s)" % why)
|
||||||
|
return
|
||||||
|
Net.send_travel(peer, to, Lands.pass_to(to, id)))
|
||||||
|
|
||||||
static func _read(path: String) -> Dictionary:
|
static func _read(path: String) -> Dictionary:
|
||||||
if not FileAccess.file_exists(path):
|
if not FileAccess.file_exists(path):
|
||||||
return {}
|
return {}
|
||||||
@@ -160,6 +213,7 @@ func server_moved(peer: int, from: Vector3, to: Vector3, yaw: float, anim: int,
|
|||||||
if p.in_bed and to.distance_to(p.bed_at) > 1.5:
|
if p.in_bed and to.distance_to(p.bed_at) > 1.5:
|
||||||
p.in_bed = false
|
p.in_bed = false
|
||||||
tell(p.peer, "You got out of bed")
|
tell(p.peer, "You got out of bed")
|
||||||
|
beds_changed()
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if not Net.is_server() or proxies.is_empty():
|
if not Net.is_server() or proxies.is_empty():
|
||||||
@@ -300,8 +354,56 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
"hit":
|
"hit":
|
||||||
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
|
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
|
||||||
var at: Vector3 = _v(a, 1)
|
var at: Vector3 = _v(a, 1)
|
||||||
if n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
|
if n == null and _s(a, 0) == "treasure:" + String(p.player_id) and w.treasure != null:
|
||||||
|
var m: Node3D = w.treasure.mound_for(String(p.player_id)) # (your own X: treasure.gd)
|
||||||
|
if m != null and at.is_finite() and at.distance_to(m.global_position) < 2.5:
|
||||||
|
Actions.harvest_hit(p, m, at)
|
||||||
|
elif n == null and w.tunnel != null:
|
||||||
|
var f: Node3D = w.tunnel.face_by_uid(_s(a, 0)) # (the cliff tunnel's seam or face)
|
||||||
|
if f != null and at.is_finite():
|
||||||
|
Actions.harvest_hit(p, f, at) # (the tunnel tells everyone: tunnel.gd)
|
||||||
|
elif n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
|
||||||
WorldNet.node_hit(n, at, p.global_position)
|
WorldNet.node_hit(n, at, p.global_position)
|
||||||
|
"tunnel_prop":
|
||||||
|
if w.tunnel != null:
|
||||||
|
w.tunnel.set_prop(p, _i(a, 0))
|
||||||
|
"wreck_chest":
|
||||||
|
# The wreck's sea chest: each player's share, once.
|
||||||
|
if w.wreck != null and p.global_position.distance_to(w.wreck.chest.global_position) < 4.0:
|
||||||
|
if w.wreck.open_for(p, String(p.player_id)):
|
||||||
|
WorldNet.wreck_state(w.wreck.to_data())
|
||||||
|
"moor":
|
||||||
|
# The mooring post at the end of the pier: tie up your boat.
|
||||||
|
if w.moorings != null and p.global_position.distance_to(w.moorings.post.global_position) < 4.0:
|
||||||
|
if w.moorings.moor(p, String(p.player_id)):
|
||||||
|
WorldNet.boats_state(w.moorings.to_data())
|
||||||
|
"tunnel_go":
|
||||||
|
# Down into the cliff tunnel, or back out at the top: a teleport
|
||||||
|
# the server makes (the tunnel's under the island), from nearby.
|
||||||
|
var t: Node3D = w.tunnel
|
||||||
|
if t == null or not t.opened:
|
||||||
|
return
|
||||||
|
var go_in := _s(a, 0) == "in"
|
||||||
|
var from: Vector3 = t.seam_out if go_in else t.top
|
||||||
|
if p.global_position.distance_to(from) > 4.5:
|
||||||
|
return
|
||||||
|
if go_in:
|
||||||
|
t.go_in(p)
|
||||||
|
else:
|
||||||
|
t.go_out(p)
|
||||||
|
Net.teleport(p.peer, p.global_position)
|
||||||
|
_s2c_respawn.rpc_id(p.peer, p.global_position, p.rotation.y, "")
|
||||||
|
"cross":
|
||||||
|
# The sea cave's mouth (the Valley), or the sea cave (the Cove).
|
||||||
|
var where := _s(a, 0)
|
||||||
|
if where == "tunnel" and w.tunnel != null and w.tunnel.dug >= w.tunnel.STRETCHES \
|
||||||
|
and p.global_position.distance_to(w.tunnel.cave_mouth) < 4.0:
|
||||||
|
cross(p.peer, w.tunnel.to, "sea_cave")
|
||||||
|
elif where == "sea_cave" and w.sea_cave != null and w.sea_cave.open() \
|
||||||
|
and p.global_position.distance_to(w.sea_cave.front) < 6.0:
|
||||||
|
cross(p.peer, w.sea_cave.to, "tunnel_bottom")
|
||||||
|
elif where == "sea_cave" and w.sea_cave != null:
|
||||||
|
tell(p.peer, w.sea_cave.interact_prompt(p))
|
||||||
"pickup":
|
"pickup":
|
||||||
var key := _s(a, 0)
|
var key := _s(a, 0)
|
||||||
var pk: Node3D = WorldNet.find_pickup(key)
|
var pk: Node3D = WorldNet.find_pickup(key)
|
||||||
@@ -436,7 +538,7 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
for m in w.get_tree().get_nodes_in_group("processor"):
|
for m in w.get_tree().get_nodes_in_group("processor"):
|
||||||
m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0)
|
m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0)
|
||||||
"debug_spawn":
|
"debug_spawn":
|
||||||
if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf"]) and _v(a, 1).is_finite():
|
if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf", "drowned", "crab", "gull"]) and _v(a, 1).is_finite():
|
||||||
w.creatures.spawn(_s(a, 0), _v(a, 1))
|
w.creatures.spawn(_s(a, 0), _v(a, 1))
|
||||||
"debug_boss":
|
"debug_boss":
|
||||||
# Tests: ["hold", secs] stands the Old Stag still (stunned);
|
# Tests: ["hold", secs] stands the Old Stag still (stunned);
|
||||||
@@ -456,6 +558,12 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
w.day_night.apply(true)
|
w.day_night.apply(true)
|
||||||
w.weather.snap()
|
w.weather.snap()
|
||||||
Net.push_clock()
|
Net.push_clock()
|
||||||
|
"debug_tunnel":
|
||||||
|
# (tests) dig the cliff tunnel to `n` stretches, all propped.
|
||||||
|
if cheats and w.tunnel != null:
|
||||||
|
var n := clampi(_i(a, 0), 0, w.tunnel.STRETCHES)
|
||||||
|
w.tunnel.load_data({"opened": true, "dug": n, "props": range(1, n + 1)})
|
||||||
|
w.tunnel._tell_everyone()
|
||||||
"debug_tp":
|
"debug_tp":
|
||||||
var to := _v(a, 0)
|
var to := _v(a, 0)
|
||||||
if cheats and to.is_finite():
|
if cheats and to.is_finite():
|
||||||
@@ -515,28 +623,66 @@ func go_to_bed(p: Node3D) -> void:
|
|||||||
p.in_bed = true
|
p.in_bed = true
|
||||||
p.bed_at = p.global_position
|
p.bed_at = p.global_position
|
||||||
if not _maybe_pass_night():
|
if not _maybe_pass_night():
|
||||||
var n: int = _alive().size()
|
# (Counting everyone, in every land: on another land's server, the
|
||||||
var asleep: int = _alive().filter(func(q): return q.in_bed).size()
|
# hub's last count, with this bed in it.)
|
||||||
tell(p.peer, "You're in bed (%d of %d asleep). The night passes when %s." % [asleep, n,
|
var counts := sleep_counts()
|
||||||
"everyone's in bed" if _beds_needed(n) >= n else "%d are" % _beds_needed(n)])
|
if not Lands.land.is_empty():
|
||||||
for q in _alive():
|
counts = [_alive().size() + _hub_beds[0], _alive().filter(func(q): return q.in_bed).size() + _hub_beds[1]]
|
||||||
if q != p:
|
var n: int = counts[0]
|
||||||
tell(q.peer, "%s is in bed (%d of %d asleep)" % [p.display, asleep, n])
|
var asleep: int = counts[1]
|
||||||
|
var need: int = _beds_needed(n)
|
||||||
|
tell(p.peer, waiting_text(asleep, n, need))
|
||||||
|
# Everyone else, in every land, hears it in chat (so whoever's still
|
||||||
|
# up knows they're being waited for).
|
||||||
|
Chat._all("%s is in bed (%d of %d asleep%s)" % [p.display, asleep, n,
|
||||||
|
"" if need >= n else ", %d needed" % need], "server")
|
||||||
|
|
||||||
|
## What a player who's just got into bed is told while the others are up.
|
||||||
|
static func waiting_text(asleep: int, n: int, need: int) -> String:
|
||||||
|
var left := need - asleep
|
||||||
|
var who := "everyone else" if need >= n else ("%d more" % left)
|
||||||
|
return "You're in bed: waiting for %s to go to bed (%d of %d asleep)" % [who, asleep, n]
|
||||||
|
|
||||||
func _alive() -> Array:
|
func _alive() -> Array:
|
||||||
return proxies.values().filter(func(q): return not q.survival.dead)
|
return proxies.values().filter(func(q): return not q.survival.dead)
|
||||||
|
|
||||||
## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires,
|
## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires,
|
||||||
## regrowth and weather move on; sleepers wake rested.
|
## regrowth and weather move on; sleepers wake rested.
|
||||||
|
## One clock for every land (J3b): the night passes when enough of everyone,
|
||||||
|
## in every land, is in bed. Another land's server reports its beds to the
|
||||||
|
## hub, which decides, and tells every land to pass the night too.
|
||||||
func _maybe_pass_night() -> bool:
|
func _maybe_pass_night() -> bool:
|
||||||
var w: Node3D = WorldNet.world
|
var w: Node3D = WorldNet.world
|
||||||
var live := _alive()
|
if w == null:
|
||||||
if w == null or live.is_empty():
|
|
||||||
return false
|
return false
|
||||||
var asleep := live.filter(func(q): return q.in_bed)
|
if not Lands.land.is_empty():
|
||||||
if asleep.size() < _beds_needed(live.size()):
|
_report_beds()
|
||||||
|
return false
|
||||||
|
Lands._push_bed_totals()
|
||||||
|
var counts := sleep_counts()
|
||||||
|
if counts[0] == 0 or counts[1] < _beds_needed(counts[0]):
|
||||||
return false
|
return false
|
||||||
var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length
|
var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length
|
||||||
|
Lands.tell_lands({"t": "night", "secs": secs})
|
||||||
|
pass_night_here(secs)
|
||||||
|
return true
|
||||||
|
|
||||||
|
## [alive, asleep] on this server and (the hub) every other land.
|
||||||
|
func sleep_counts() -> Array:
|
||||||
|
var live := _alive()
|
||||||
|
var counts := [live.size(), live.filter(func(q): return q.in_bed).size()]
|
||||||
|
if Lands.hub:
|
||||||
|
var e: Array = Lands.beds_elsewhere()
|
||||||
|
counts = [counts[0] + int(e[0]), counts[1] + int(e[1])]
|
||||||
|
return counts
|
||||||
|
|
||||||
|
## The night passes here: until 06:00 for everyone on this server.
|
||||||
|
func pass_night_here(secs: float) -> void:
|
||||||
|
var w: Node3D = WorldNet.world
|
||||||
|
if w == null:
|
||||||
|
return
|
||||||
|
var live := _alive()
|
||||||
|
var asleep := live.filter(func(q): return q.in_bed)
|
||||||
w.skip_time(secs)
|
w.skip_time(secs)
|
||||||
for q in live:
|
for q in live:
|
||||||
if q.in_bed:
|
if q.in_bed:
|
||||||
@@ -545,7 +691,20 @@ func _maybe_pass_night() -> bool:
|
|||||||
_s2c_slept.rpc_id(q.peer, asleep.has(q))
|
_s2c_slept.rpc_id(q.peer, asleep.has(q))
|
||||||
Net.push_clock()
|
Net.push_clock()
|
||||||
WorldNet.save()
|
WorldNet.save()
|
||||||
return true
|
if not Lands.land.is_empty():
|
||||||
|
_report_beds()
|
||||||
|
|
||||||
|
## Someone got into or out of bed, or came or went: (a land server) the hub
|
||||||
|
## hears the new count; (the hub) the night may pass.
|
||||||
|
func beds_changed() -> void:
|
||||||
|
if not Lands.land.is_empty():
|
||||||
|
_report_beds()
|
||||||
|
elif Net.is_server():
|
||||||
|
_maybe_pass_night()
|
||||||
|
|
||||||
|
func _report_beds() -> void:
|
||||||
|
var live := _alive()
|
||||||
|
Lands.send_hub({"t": "beds", "alive": live.size(), "asleep": live.filter(func(q): return q.in_bed).size()})
|
||||||
|
|
||||||
## The chest or grave `nid`, if this player may use it right now.
|
## The chest or grave `nid`, if this player may use it right now.
|
||||||
func _container(p: Node3D, nid: int) -> Node3D:
|
func _container(p: Node3D, nid: int) -> Node3D:
|
||||||
@@ -556,6 +715,10 @@ func _container(p: Node3D, nid: int) -> Node3D:
|
|||||||
|
|
||||||
const Rules = preload("res://scripts/net/rules.gd")
|
const Rules = preload("res://scripts/net/rules.gd")
|
||||||
|
|
||||||
|
## (Land server) the hub's latest [alive, asleep] in every OTHER land, for
|
||||||
|
## the "N of M asleep" message and the all-abed check here.
|
||||||
|
var _hub_beds := [0, 0]
|
||||||
|
|
||||||
## Tells the player why not, if there's a reason.
|
## Tells the player why not, if there's a reason.
|
||||||
func _allowed(why: String) -> bool:
|
func _allowed(why: String) -> bool:
|
||||||
if why.is_empty():
|
if why.is_empty():
|
||||||
|
|||||||
1
scripts/net/player_net.gd.uid
Normal file
1
scripts/net/player_net.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dim4cfgbtabvf
|
||||||
1
scripts/net/rules.gd.uid
Normal file
1
scripts/net/rules.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d2fotty6gceeh
|
||||||
1
scripts/net/server_config.gd.uid
Normal file
1
scripts/net/server_config.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cykv4lse4g62a
|
||||||
1
scripts/net/server_player.gd.uid
Normal file
1
scripts/net/server_player.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dkdnvh8f18o2x
|
||||||
@@ -49,7 +49,7 @@ func open_server_world(config: RefCounted) -> Node3D:
|
|||||||
var id := String(config.v("world")).validate_filename()
|
var id := String(config.v("world")).validate_filename()
|
||||||
if id.is_empty():
|
if id.is_empty():
|
||||||
id = "server-world"
|
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("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
|
_backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0
|
||||||
var w: Node3D = load("res://scenes/world.tscn").instantiate()
|
var w: Node3D = load("res://scenes/world.tscn").instantiate()
|
||||||
@@ -369,6 +369,55 @@ func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void:
|
|||||||
func stones_state(d: Dictionary) -> void:
|
func stones_state(d: Dictionary) -> void:
|
||||||
_all("_s2c_stones", [d])
|
_all("_s2c_stones", [d])
|
||||||
|
|
||||||
|
## The cliff tunnel changed (a blow on its face, a stretch dug, a prop set):
|
||||||
|
## everyone here sees it, and the other lands hear how far it's dug.
|
||||||
|
func tunnel_state(d: Dictionary) -> void:
|
||||||
|
_all("_s2c_tunnel", [d])
|
||||||
|
mark_changed()
|
||||||
|
if Lands.hub:
|
||||||
|
Lands.push_tunnel(int(d.get("dug", 0)))
|
||||||
|
|
||||||
|
## Whose treasure's been dug (and how far): everyone here sees their own
|
||||||
|
## mound go down, or go.
|
||||||
|
func treasure_state(d: Dictionary) -> void:
|
||||||
|
_all("_s2c_treasure", [d])
|
||||||
|
mark_changed()
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_treasure(d: Dictionary) -> void:
|
||||||
|
if not is_client() or world.treasure == null:
|
||||||
|
return
|
||||||
|
world.treasure.load_data(d)
|
||||||
|
|
||||||
|
## Someone tied up a boat at the pier: everyone here sees it.
|
||||||
|
func boats_state(d: Dictionary) -> void:
|
||||||
|
_all("_s2c_boats", [d])
|
||||||
|
mark_changed()
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_boats(d: Dictionary) -> void:
|
||||||
|
if not is_client() or world.moorings == null:
|
||||||
|
return
|
||||||
|
world.moorings.load_data(d)
|
||||||
|
|
||||||
|
## Who's had their share of the wreck's sea chest changed: everyone here
|
||||||
|
## sees whether it's empty for them.
|
||||||
|
func wreck_state(d: Dictionary) -> void:
|
||||||
|
_all("_s2c_wreck", [d])
|
||||||
|
mark_changed()
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_wreck(d: Dictionary) -> void:
|
||||||
|
if not is_client() or world.wreck == null:
|
||||||
|
return
|
||||||
|
world.wreck.load_data(d)
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_tunnel(d: Dictionary) -> void:
|
||||||
|
if not is_client() or world.tunnel == null:
|
||||||
|
return
|
||||||
|
world.tunnel.load_data(d)
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_stones(d: Dictionary) -> void:
|
func _s2c_stones(d: Dictionary) -> void:
|
||||||
if not is_client() or world.stones == null:
|
if not is_client() or world.stones == null:
|
||||||
|
|||||||
1
scripts/net/world_net.gd.uid
Normal file
1
scripts/net/world_net.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cmk7ho2ij6esq
|
||||||
@@ -145,6 +145,7 @@ func _target() -> Object:
|
|||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
_swing_cd = maxf(_swing_cd - delta, 0.0)
|
_swing_cd = maxf(_swing_cd - delta, 0.0)
|
||||||
|
_zoom(delta)
|
||||||
if player.ui_open:
|
if player.ui_open:
|
||||||
_ghost.visible = false
|
_ghost.visible = false
|
||||||
_set_prompt("")
|
_set_prompt("")
|
||||||
@@ -174,6 +175,10 @@ func _physics_process(delta: float) -> void:
|
|||||||
_set_prompt("[RMB] Eat %s" % def.name)
|
_set_prompt("[RMB] Eat %s" % def.name)
|
||||||
elif def.has("wear"):
|
elif def.has("wear"):
|
||||||
_set_prompt("[RMB] Wear %s" % def.name)
|
_set_prompt("[RMB] Wear %s" % def.name)
|
||||||
|
elif def.has("read"):
|
||||||
|
_set_prompt("[RMB] Read the %s" % String(def.name).to_lower())
|
||||||
|
elif def.has("zoom"):
|
||||||
|
_set_prompt("[hold RMB] Look through the %s" % String(def.name).to_lower())
|
||||||
elif def.has("bow"):
|
elif def.has("bow"):
|
||||||
var n: int = Actions.ammo_count(player, def.bow)
|
var n: int = Actions.ammo_count(player, def.bow)
|
||||||
_set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n)
|
_set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n)
|
||||||
@@ -232,6 +237,21 @@ func _update_ghost(def: Dictionary) -> void:
|
|||||||
_ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat
|
_ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat
|
||||||
_ghost.visible = true
|
_ghost.visible = true
|
||||||
|
|
||||||
|
## The spyglass (0.6.0): holding right-click with it narrows the view by its
|
||||||
|
## "zoom", eased in and out.
|
||||||
|
var _base_fov := 0.0
|
||||||
|
func _zoom(delta: float) -> void:
|
||||||
|
var cam := get_parent() as Camera3D
|
||||||
|
if cam == null:
|
||||||
|
return
|
||||||
|
if _base_fov <= 0.0:
|
||||||
|
_base_fov = cam.fov
|
||||||
|
var z := float(_held_def().get("zoom", 0.0))
|
||||||
|
var looking: bool = z > 1.0 and not player.ui_open and Input.is_action_pressed("use_item") \
|
||||||
|
and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED
|
||||||
|
var want: float = _base_fov / z if looking else _base_fov
|
||||||
|
cam.fov = move_toward(cam.fov, want, delta * 160.0)
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if player.ui_open:
|
if player.ui_open:
|
||||||
return
|
return
|
||||||
@@ -262,6 +282,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
Actions.eat(player, player.selected)
|
Actions.eat(player, player.selected)
|
||||||
elif d.has("wear"):
|
elif d.has("wear"):
|
||||||
Actions.wear(player, player.selected)
|
Actions.wear(player, player.selected)
|
||||||
|
elif d.has("read"):
|
||||||
|
player.get_parent().read_map(player) # (the treasure map: your X, on your map)
|
||||||
elif d.get("weapon", {}).has("throw"):
|
elif d.get("weapon", {}).has("throw"):
|
||||||
_throw()
|
_throw()
|
||||||
elif event.is_action_pressed("rotate_piece"):
|
elif event.is_action_pressed("rotate_piece"):
|
||||||
|
|||||||
@@ -103,8 +103,9 @@ func step(dt: float) -> void:
|
|||||||
_shelter_left -= dt
|
_shelter_left -= dt
|
||||||
if _shelter_left <= 0.0:
|
if _shelter_left <= 0.0:
|
||||||
_shelter_left = 0.25
|
_shelter_left = 0.25
|
||||||
roofed = world.build.roofed_at(p)
|
var cave: bool = world.has_method("underground_at") and world.underground_at(p) # (the tunnel: rock all round)
|
||||||
sheltered = roofed and world.build.enclosure_at(p) >= 0.74
|
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
|
stone_room = sheltered and world.build.stone_walls_at(p) >= 0.5
|
||||||
cozy = sheltered and _rug_near(p)
|
cozy = sheltered and _rug_near(p)
|
||||||
# Rain soaks you unless there's a roof over your head.
|
# Rain soaks you unless there's a roof over your head.
|
||||||
|
|||||||
53
scripts/ui/compass.gd
Normal file
53
scripts/ui/compass.gd
Normal 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)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user