Compare commits
19 Commits
b86fb879d2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b9d17826 | ||
|
|
3b611a6d34 | ||
|
|
27040d6805 | ||
|
|
2004fe193c | ||
|
|
9709d2c424 | ||
|
|
5bcfa23710 | ||
|
|
74bd8f5898 | ||
|
|
419c71e638 | ||
|
|
ec8caa9ef4 | ||
|
|
1bf33935e0 | ||
|
|
62b3d6c47f | ||
|
|
f9b138b304 | ||
|
|
86ebe07899 | ||
|
|
85bbed249b | ||
|
|
6544862885 | ||
|
|
109fff22d0 | ||
|
|
b938e045cb | ||
|
|
ad47cd53da | ||
|
|
0209c6149a |
20
README.md
20
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.4.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).
|
||||||
@@ -28,10 +28,17 @@ https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
|
|||||||
- M4 so far: packaged builds, the update check, music, ambience, sound effects
|
- M4 so far: packaged builds, the update check, music, ambience, sound effects
|
||||||
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages,
|
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages,
|
||||||
the map (N) and compass.
|
the map (N) and compass.
|
||||||
- W0-W3, J1 (`docs/WORLDLETS_DESIGN.md`): worldlets as data, the Cove (beach,
|
- W0-W3, J1, J3 (`docs/WORLDLETS_DESIGN.md`): worldlets as data, the Cove
|
||||||
pier, sea fishing), and the cliff tunnel between them, with a save file per
|
(beach, pier, sea fishing, a stream), and the cliff tunnel between them,
|
||||||
worldlet (single-player).
|
with a save file per worldlet; on a server, a server per land (the Valley's
|
||||||
Next: J3, the journey on a server ("spread out"), then the Highlands.
|
starts the Cove's on the next UDP port), with chat, the player list,
|
||||||
|
admin commands and sleep across lands, and one clock everywhere.
|
||||||
|
- 0.6.0, the Cove's own (`docs/WORLDLETS_DESIGN.md` §5): the wreck, a
|
||||||
|
treasure map and a dig for each player, the drowned crew, crabs and gulls,
|
||||||
|
and a rowing boat to build and moor at the pier (sailing comes with the
|
||||||
|
Highlands). Report a bug in the pause menu; `docs/PLAYTEST.md` for testers.
|
||||||
|
Next: after the playtest, J2 (each tunnel's own gate and supplies), then the
|
||||||
|
Highlands (by sail).
|
||||||
Running from the source, `godot --path . -- --dev-worldlets` lets New World
|
Running from the source, `godot --path . -- --dev-worldlets` lets New World
|
||||||
start in any worldlet, to try one directly.
|
start in any worldlet, to try one directly.
|
||||||
|
|
||||||
@@ -73,7 +80,8 @@ Export Templates). The version number is `application/short_version` in
|
|||||||
## Trailer and download page
|
## Trailer and download page
|
||||||
```bash
|
```bash
|
||||||
tools/trailer/make_trailer.sh # build/Emberwild-trailer.mp4: recorded in-game (Movie Maker), needs ffmpeg
|
tools/trailer/make_trailer.sh # build/Emberwild-trailer.mp4: recorded in-game (Movie Maker), needs ffmpeg
|
||||||
tools/site/make_site.sh # build/site/: index.html + poster.jpg + trailer.mp4 for the current release
|
tools/site/make_shots.sh # build/shots/*.jpg: the page's screenshots, rendered in a window
|
||||||
|
tools/site/make_site.sh # build/site/: index.html, guide.html, the shots, poster and trailer for the current release
|
||||||
```
|
```
|
||||||
The page's download links point at the Gitea release named in
|
The page's download links point at the Gitea release named in
|
||||||
`build/version.json`, so rebuild it after each `tools/export.sh` release.
|
`build/version.json`, so rebuild it after each `tools/export.sh` release.
|
||||||
|
|||||||
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.
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.
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"hint_cold": "91c5088e453fb0e6",
|
"hint_cold": "91c5088e453fb0e6",
|
||||||
"hint_cove": "126f0b5e977fbef7",
|
"hint_cove": "f7e5c0283b31e1cc",
|
||||||
|
"hint_crab": "283542496ecd180c",
|
||||||
"hint_died": "e865f8a63622b3c3",
|
"hint_died": "e865f8a63622b3c3",
|
||||||
"hint_diet": "3178b319c44e9fa0",
|
"hint_diet": "3178b319c44e9fa0",
|
||||||
"hint_freezing": "48b1e505119a0fc9",
|
"hint_freezing": "48b1e505119a0fc9",
|
||||||
|
|||||||
@@ -44,6 +44,20 @@
|
|||||||
"min_depth": 2.2
|
"min_depth": 2.2
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"min_depth": 0.3,
|
||||||
|
"bite_wait": [
|
||||||
|
8.0,
|
||||||
|
18.0
|
||||||
|
],
|
||||||
|
"fish": [
|
||||||
|
{
|
||||||
|
"id": "trout",
|
||||||
|
"weight": 100,
|
||||||
|
"dawn_dusk": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
{"id": "seam", "when": "seam", "hold": 3, "title": "The cracked seam",
|
{"id": "seam", "when": "seam", "hold": 3, "title": "The cracked seam",
|
||||||
"text": "That antler pick bites rock iron only scratches. Out on the clifftop, near the edge, there's a seam of cracked rock, and a way down to the sea behind it if you dig. Take wood to prop it as you go, and lights: it's black as pitch in there."},
|
"text": "That antler pick bites rock iron only scratches. Out on the clifftop, near the edge, there's a seam of cracked rock, and a way down to the sea behind it if you dig. Take wood to prop it as you go, and lights: it's black as pitch in there."},
|
||||||
{"id": "cove", "when": "worldlet:cove", "hold": 3, "title": "The Cove",
|
{"id": "cove", "when": "worldlet:cove", "hold": 3, "title": "The Cove",
|
||||||
"text": "You made it down. Sand, salt and sea fish. Fish off the end of the pier: mackerel bite close in, cod further out where it's deep. The sea cave takes you back up the tunnel, and home's waiting at the top."}
|
"text": "You made it down. Sand, salt and sea fish. Fish off the end of the pier: mackerel bite close in, cod further out where it's deep. The sea cave takes you back up the tunnel, and home's waiting at the top. Mind the gulls: they'll have any food you leave on the sand, and fish off a smoking rack. And at dusk the crabs come up the beach: give them room, or they pinch."},
|
||||||
|
{"id": "crab", "when": "has:crab_meat", "hold": 2, "title": "Crab",
|
||||||
|
"text": "Two lots of crab meat cook into a good crab supper at a campfire. Raw, it's as risky as raw fish."}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
146
data/items.json
146
data/items.json
@@ -593,6 +593,39 @@
|
|||||||
},
|
},
|
||||||
"keeps": 4
|
"keeps": 4
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "crab_meat",
|
||||||
|
"name": "Crab Meat",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Sweet white meat from a crab off the Cove's beach. Cook it at a campfire. (Fish)",
|
||||||
|
"mesh": "crab_meat",
|
||||||
|
"food": {
|
||||||
|
"hunger": 4,
|
||||||
|
"health": -2,
|
||||||
|
"diet": {
|
||||||
|
"fish": 8
|
||||||
|
},
|
||||||
|
"risk": 0.2
|
||||||
|
},
|
||||||
|
"keeps": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cooked_crab",
|
||||||
|
"name": "Cooked Crab",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Boiled pink in its shell. Worth the pinches. (Fish)",
|
||||||
|
"mesh": "cooked_crab",
|
||||||
|
"food": {
|
||||||
|
"hunger": 20,
|
||||||
|
"health": 10,
|
||||||
|
"diet": {
|
||||||
|
"fish": 32
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"keeps": 3
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "grilled_cod",
|
"id": "grilled_cod",
|
||||||
"name": "Grilled Cod",
|
"name": "Grilled Cod",
|
||||||
@@ -1073,6 +1106,119 @@
|
|||||||
"description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.",
|
"description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.",
|
||||||
"mesh": "salt"
|
"mesh": "salt"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "driftwood",
|
||||||
|
"name": "Driftwood",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Sea-bleached wood washed up on the Cove's beach. It burns well, and it's light and tough: what a rowing boat is built from.",
|
||||||
|
"mesh": "driftwood",
|
||||||
|
"fuel": 48.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seagrass",
|
||||||
|
"name": "Seagrass",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "Long, tough ribbons of seagrass washed up in the Cove's shallows. Twisted together it makes a rope that won't rot in salt water.",
|
||||||
|
"mesh": "seagrass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rope",
|
||||||
|
"name": "Seagrass Rope",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Twisted from seagrass: for rigging and lashing a boat.",
|
||||||
|
"mesh": "rope"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pine_resin",
|
||||||
|
"name": "Pine Resin",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "Sticky amber resin from a felled pine. Boiled with salt it makes pitch.",
|
||||||
|
"mesh": "pine_resin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pitch",
|
||||||
|
"name": "Pitch",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Black, tarry pitch from pine resin and salt, boiled over a fire. It keeps the sea out of a boat's seams.",
|
||||||
|
"mesh": "pitch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rowing_boat",
|
||||||
|
"name": "Rowing Boat",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A small driftwood boat, seams pitched tight. Carry it to the mooring post at the end of the Cove's pier to tie it up. Where it'll take you, a ship's compass will tell.",
|
||||||
|
"mesh": "rowing_boat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "treasure_map",
|
||||||
|
"name": "Treasure Map",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A torn chart from a wrecked ship's sea chest, with an X inked in the hills above the Cove. Right-click to read it: the X goes on your map (N).",
|
||||||
|
"mesh": "treasure_map",
|
||||||
|
"read": {
|
||||||
|
"marks": "treasure"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ship_compass",
|
||||||
|
"name": "Ship's Compass",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A brass sea compass from the pirate chest, its needle still true. No boat leaves for the Highlands without one.",
|
||||||
|
"mesh": "ship_compass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spyglass",
|
||||||
|
"name": "Spyglass",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A captain's brass telescope. Hold right-click to look through it.",
|
||||||
|
"mesh": "spyglass",
|
||||||
|
"zoom": 4.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pirate_gold",
|
||||||
|
"name": "Pirate Gold",
|
||||||
|
"category": "placeable",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Old gold coins from the pirate chest. Nothing on the island takes coin (yet), but a heap of it looks fine in a hut.",
|
||||||
|
"mesh": "pirate_gold",
|
||||||
|
"place": {
|
||||||
|
"piece": "trophy",
|
||||||
|
"footprint": [
|
||||||
|
0.6,
|
||||||
|
0.6
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cutlass",
|
||||||
|
"name": "Cutlass",
|
||||||
|
"category": "weapon",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "A pirate's curved blade: quick, and it hits harder than the iron spear.",
|
||||||
|
"mesh": "cutlass",
|
||||||
|
"weapon": {
|
||||||
|
"damage": 30,
|
||||||
|
"reach": 2.3,
|
||||||
|
"stamina": 8,
|
||||||
|
"cooldown": 0.45
|
||||||
|
},
|
||||||
|
"durability": 220,
|
||||||
|
"repair": {
|
||||||
|
"inputs": {
|
||||||
|
"iron_bar": 1
|
||||||
|
},
|
||||||
|
"station": "forge"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "smoking_rack",
|
"id": "smoking_rack",
|
||||||
"name": "Smoking Rack",
|
"name": "Smoking Rack",
|
||||||
|
|||||||
@@ -259,6 +259,16 @@
|
|||||||
"mackerel": 1
|
"mackerel": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "cooked_crab",
|
||||||
|
"output": "cooked_crab",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"crab_meat": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "grilled_cod",
|
"id": "grilled_cod",
|
||||||
"output": "grilled_cod",
|
"output": "grilled_cod",
|
||||||
@@ -723,6 +733,40 @@
|
|||||||
"stick": 2,
|
"stick": 2,
|
||||||
"fiber": 2
|
"fiber": 2
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rope",
|
||||||
|
"output": "rope",
|
||||||
|
"count": 1,
|
||||||
|
"station": "",
|
||||||
|
"category": "tools",
|
||||||
|
"inputs": {
|
||||||
|
"seagrass": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pitch",
|
||||||
|
"output": "pitch",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"category": "building",
|
||||||
|
"inputs": {
|
||||||
|
"pine_resin": 2,
|
||||||
|
"salt": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rowing_boat",
|
||||||
|
"output": "rowing_boat",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"category": "building",
|
||||||
|
"inputs": {
|
||||||
|
"driftwood": 12,
|
||||||
|
"rope": 4,
|
||||||
|
"pitch": 3,
|
||||||
|
"nails": 6
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_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; mountains behind. Not a start: you get here on the journey.",
|
"_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",
|
"id": "cove",
|
||||||
"name": "Cove",
|
"name": "Cove",
|
||||||
"description": "Low hills running down to a sheltered sandy bay, with a pier out over the water for sea fishing.",
|
"description": "Low hills running down to a sheltered sandy bay, with a pier out over the water for sea fishing.",
|
||||||
@@ -11,10 +11,12 @@
|
|||||||
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 52.0, "rim_warp": 14.0, "wall_frac": 0.45},
|
{"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": "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": "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"}
|
{"type": "ocean_edge"}
|
||||||
],
|
],
|
||||||
"climate": {"snowline": 42.0},
|
"climate": {"snowline": 42.0},
|
||||||
"places": [{"type": "pier", "at": "beach"}],
|
"places": [{"type": "den"}, {"type": "pier", "at": "beach"}, {"type": "wreck"}],
|
||||||
|
"wildlife": ["crabs", "gulls"],
|
||||||
"next": [
|
"next": [
|
||||||
{"to": "valley", "by": "tunnel", "where": "sea_cave"},
|
{"to": "valley", "by": "tunnel", "where": "sea_cave"},
|
||||||
{"to": "highlands", "by": "sail", "where": "pier"}
|
{"to": "highlands", "by": "sail", "where": "pier"}
|
||||||
|
|||||||
@@ -14,5 +14,6 @@
|
|||||||
{"type": "ocean_edge"}
|
{"type": "ocean_edge"}
|
||||||
],
|
],
|
||||||
"climate": {"snowline": 40.0},
|
"climate": {"snowline": 40.0},
|
||||||
|
"places": [{"type": "den"}, {"type": "barrow"}, {"type": "stones"}, {"type": "bear"}],
|
||||||
"next": [{"to": "cove", "by": "tunnel", "where": "sea_cliff"}]
|
"next": [{"to": "cove", "by": "tunnel", "where": "sea_cliff"}]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -91,7 +91,12 @@ phases M2.0-M2.7).
|
|||||||
compass along the top (0.4.0, Gitea issue #1).
|
compass along the top (0.4.0, Gitea issue #1).
|
||||||
- More places: the worldlets and the journey (`docs/WORLDLETS_DESIGN.md`).
|
- More places: the worldlets and the journey (`docs/WORLDLETS_DESIGN.md`).
|
||||||
*Done (0.4.0):* W0-W3 and J1, the Cove reached down the cliff tunnel
|
*Done (0.4.0):* W0-W3 and J1, the Cove reached down the cliff tunnel
|
||||||
(single-player). Next J3 (multiplayer, "spread out"), then J2/J4.
|
(single-player). *(0.5.0):* J3, the journey on a server: a server per
|
||||||
|
land, players in different lands at once, chat, the player list, admin
|
||||||
|
and sleep across lands. *(0.6.0, a playtest):* the Cove's own: the wreck,
|
||||||
|
a treasure dig for each player and its drowned crew, crabs, gulls, and a
|
||||||
|
rowing boat moored at the pier; Report a bug in the pause menu. 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,72 @@
|
|||||||
# Worldlets design
|
# Worldlets design
|
||||||
|
|
||||||
Status: **W0-W3 and J1 built** (the Cove without its stream yet; the
|
Status: **W0-W3, J1 and J3a built**: the Cove (with its stream), the cliff
|
||||||
tunnel single-player only); the rest is planned.
|
tunnel, and the journey on a server (a server per land; chat and the
|
||||||
|
player list across lands are J3b). The rest is planned.
|
||||||
|
|
||||||
|
*As built in J3a (a server per land):*
|
||||||
|
- `scripts/net/lands.gd` (autoload `Lands`): the server players join is the
|
||||||
|
hub. `Lands.ensure(land)` starts a land server (`--server --land cove
|
||||||
|
--port <hub+1..> --hub-port <hub>`, same config and world folder, output
|
||||||
|
to `land-<land>.log` beside server.json) and waits for it to say it's
|
||||||
|
listening. Hub and land servers talk over TCP on 127.0.0.1, port hub +
|
||||||
|
100, one JSON object per line: hello/ready, the player count, the clock
|
||||||
|
(the hub's, every 10 s: one clock everywhere), how far the tunnel's dug,
|
||||||
|
one-time passes, "stop", "host_left". A land server stops after 2 minutes
|
||||||
|
empty, when the hub stops, or if it loses the hub.
|
||||||
|
- A land server's saves: `Saves.land` makes it load and save its own
|
||||||
|
worldlet's file (`worldlets/cove.json`) in the hub's world folder.
|
||||||
|
Characters (`players/<id>.json`) are shared, and say which land they're
|
||||||
|
in (`land`) and, mid-crossing, where they'll come out (`arrive`).
|
||||||
|
- Crossing (`PlayerNet.cross`): the server saves the character as heading
|
||||||
|
there, makes sure the other server's up, and sends the game
|
||||||
|
`_s2c_travel {land, port, pass}`; the game drops the connection quietly
|
||||||
|
and joins that port with the pass (`Net.join(address, "", pass)`), checked
|
||||||
|
after the identity proof: a land server lets nobody in without one. The
|
||||||
|
world reloads on `Net.arrived`. Joining the hub when your character's in
|
||||||
|
another land sends you on there; a crossing that fails goes back to the
|
||||||
|
title. A hosted game: the host crossing doesn't stop the hub; the host
|
||||||
|
leaving the game from another land stops everything.
|
||||||
|
- The tunnel online: built on the server and in every game. The server
|
||||||
|
decides blows on its faces (`"hit"` finds them by uid), props
|
||||||
|
(`"tunnel_prop"`), going in and out (`"tunnel_go"`: a server teleport;
|
||||||
|
the move check allows the tunnel only from inside it) and the crossings
|
||||||
|
(`"cross"`), and tells everyone how it stands (`_s2c_tunnel`; it's in the
|
||||||
|
shared state a joining player gets, and saved with the Valley).
|
||||||
|
- `tests/test_net_lands.gd`: Alice digs and props online, lights it,
|
||||||
|
crosses to the Cove (bag and day kept) while Bob stays in the Valley,
|
||||||
|
comes back up, then leaves from the Cove and rejoining sends her there.
|
||||||
|
|
||||||
|
*As built in J3b (everyone, everywhere):*
|
||||||
|
- Chat and server messages go to every land (`Chat._all` relays through
|
||||||
|
the hub, `Lands.relay_line`).
|
||||||
|
- The hub keeps the **roster** (everyone on every land, with their land);
|
||||||
|
every land and every player's game gets it (`Net.roster`,
|
||||||
|
`Net.everyone()`): the pause menu's player list and `/players` show
|
||||||
|
where each player is.
|
||||||
|
- Commands typed in another land run **on the hub** (it alone keeps
|
||||||
|
server.json) and answer back there; after one that changes server.json
|
||||||
|
(`/set`, `/ban`, `/admin`, `/friend`...) every land reloads it.
|
||||||
|
`/kick` and `/ban` reach the land the player's in; `/weather`, `/save`
|
||||||
|
and `/backup` reach every land.
|
||||||
|
- **Sleep needs everyone in every land:** land servers report their beds,
|
||||||
|
the hub decides, and every land passes the night together. Getting into
|
||||||
|
bed while others are up: "You're in bed: waiting for everyone else to go
|
||||||
|
to bed (1 of 2 asleep)", and every land hears "Bob is in bed (1 of 2
|
||||||
|
asleep)" in chat.
|
||||||
|
- `tests/test_net_lands.gd` covers it all, Alice in the Cove and Bob in the
|
||||||
|
Valley.
|
||||||
|
|
||||||
|
*As built in J3c:*
|
||||||
|
- A land server that stops (crashed, or killed) tells its players "Lost the
|
||||||
|
connection to the Cove. Join the server again to carry on there."; the
|
||||||
|
hub notices it's gone (even mid-join) and joining again starts it afresh
|
||||||
|
and sends them back (their characters were saved by its autosave).
|
||||||
|
- The extra ports: `Lands.extra_ports()` / `ports_text()` ("UDP 24242 and
|
||||||
|
24243 (the Cove)"), on the Host page, in the "You're hosting" message, in
|
||||||
|
a hub's start-up log, and in docs/HOSTING.md.
|
||||||
|
- Tested in `test_net_lands.gd` (the Cove's server killed, Alice rejoins)
|
||||||
|
and `test_worldlet_pick.gd` (the Host page).
|
||||||
|
|
||||||
*As built in J1:*
|
*As built in J1:*
|
||||||
- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip,
|
- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip,
|
||||||
@@ -52,9 +117,14 @@ its decisions.
|
|||||||
- Tests: `test_cove` (walk onto the pier, catch a mackerel off its end, cod
|
- 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
|
only in deep water), and the Cove in `test_terrain_edges` and
|
||||||
`test_boundary`.
|
`test_boundary`.
|
||||||
- Still to do: the stream (it needs water that runs downhill, which the
|
- The stream (after 0.4.0): a `stream` block, a line of points from a
|
||||||
one flat water surface can't do: its own ribbon of water, planned with
|
spring under the mountains to the bay. Its water surface is the running
|
||||||
the Cove's sea cave when the tunnel arrives).
|
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:*
|
*As built in W2:*
|
||||||
- `lake` blocks can repeat: every lake and pond is its own body
|
- `lake` blocks can repeat: every lake and pond is its own body
|
||||||
@@ -213,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`.
|
||||||
@@ -358,8 +490,10 @@ tunnel goes **down**, inside the cliff, from the clifftop to the shore.
|
|||||||
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 |
|
||||||
@@ -390,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
|
||||||
@@ -434,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
|
||||||
|
|||||||
@@ -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.4.0"
|
application/short_version="0.6.0"
|
||||||
application/version="0.4.0"
|
application/version="0.6.0"
|
||||||
application/copyright=""
|
application/copyright=""
|
||||||
display/high_res=true
|
display/high_res=true
|
||||||
codesign/codesign=1
|
codesign/codesign=1
|
||||||
@@ -89,8 +89,8 @@ application/modify_resources=false
|
|||||||
application/icon=""
|
application/icon=""
|
||||||
application/console_wrapper_icon=""
|
application/console_wrapper_icon=""
|
||||||
application/icon_interpolation=4
|
application/icon_interpolation=4
|
||||||
application/file_version="0.4.0"
|
application/file_version="0.6.0"
|
||||||
application/product_version="0.4.0"
|
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.4.0"
|
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"
|
||||||
|
|||||||
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
|
||||||
@@ -102,8 +102,14 @@ func read_meta(id: String) -> Dictionary:
|
|||||||
# saves are unchanged; the others are worldlets/<id>.json. meta.json's
|
# saves are unchanged; the others are worldlets/<id>.json. meta.json's
|
||||||
# "worldlet" is where you are now, so that's the one loaded.
|
# "worldlet" is where you are now, so that's the one loaded.
|
||||||
|
|
||||||
|
## A land server (J3, `--land cove`) runs one worldlet of a server's world:
|
||||||
|
## this is that worldlet ("" = whatever the world's meta says).
|
||||||
|
var land := ""
|
||||||
|
|
||||||
## The worldlet world `id` is being played in now.
|
## The worldlet world `id` is being played in now.
|
||||||
func worldlet_of(id: String) -> String:
|
func worldlet_of(id: String) -> String:
|
||||||
|
if not land.is_empty():
|
||||||
|
return land
|
||||||
return String(read_meta(id).get("worldlet", "valley"))
|
return String(read_meta(id).get("worldlet", "valley"))
|
||||||
|
|
||||||
## The worldlet world `id` began in (its world.json).
|
## The worldlet world `id` began in (its world.json).
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
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"]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ func display_name() -> String:
|
|||||||
func interact_prompt(p: Node) -> String:
|
func interact_prompt(p: Node) -> String:
|
||||||
if is_dead():
|
if is_dead():
|
||||||
return "Bones [E] Search"
|
return "Bones [E] Search"
|
||||||
var s := "Barrow guard"
|
# (A barrow guard, or a drowned sailor at a treasure dig: treasure.gd.)
|
||||||
|
var s: String = def.name
|
||||||
match state:
|
match state:
|
||||||
"rest": s = "Barrow guard (still... for now)"
|
"rest": s += " (still... for now)"
|
||||||
"charge": s = "Barrow guard (coming for you!)"
|
"charge": s += " (coming for you!)"
|
||||||
"home": s = "Barrow guard (going back to its post)"
|
"home": s += " (going back to its post)"
|
||||||
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
|
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
|
||||||
s += " (you need a weapon: a club cracks bone)"
|
s += " (you need a weapon: a club cracks bone)"
|
||||||
return s
|
return s
|
||||||
@@ -46,7 +47,7 @@ func _think(dt: float) -> void:
|
|||||||
_goal_d = d
|
_goal_d = d
|
||||||
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player):
|
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player):
|
||||||
_attack_cd = def.attack_cd
|
_attack_cd = def.attack_cd
|
||||||
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a barrow guard")
|
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a %s" % String(def.name).to_lower())
|
||||||
"home":
|
"home":
|
||||||
if barrow.wakes_for(player) and barrow.center().distance_to(pp) < barrow.LEASH:
|
if barrow.wakes_for(player) and barrow.center().distance_to(pp) < barrow.LEASH:
|
||||||
state = "charge"
|
state = "charge"
|
||||||
|
|||||||
56
scripts/creatures/gull.gd
Normal file
56
scripts/creatures/gull.gd
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
extends "res://scripts/creatures/crow.gd"
|
||||||
|
## A gull (0.6.0, the Cove only): the Cove's crow, sent by thieves.gd by
|
||||||
|
## day. It comes for food left on the sand and for fish on a smoking rack
|
||||||
|
## that nobody's watching. Bolder and quicker than a crow: it lands, snatches
|
||||||
|
## one and is off with it, and another soon follows. Shoot it down while
|
||||||
|
## it's carrying and it drops what it took. Feathers.
|
||||||
|
|
||||||
|
const SNATCH_AFTER := 1.2 ## seconds on the ground before it grabs one
|
||||||
|
const GULL_SCARE := 6.0 ## (bolder than a crow)
|
||||||
|
|
||||||
|
var carry: Dictionary = {} ## {id, count, extra} once it has something
|
||||||
|
|
||||||
|
func display_name() -> String:
|
||||||
|
return "Gull"
|
||||||
|
|
||||||
|
func interact_prompt(_p: Node) -> String:
|
||||||
|
if is_dead():
|
||||||
|
return "Gull [E] Pluck"
|
||||||
|
if not carry.is_empty():
|
||||||
|
return "Gull (off with your %s!)" % ItemDB.item_name(String(carry.id)).to_lower()
|
||||||
|
return {"peck": "Gull (after your food!)", "fly": "Gull (circling your food)"}.get(state, "Gull")
|
||||||
|
|
||||||
|
func _think(dt: float) -> void:
|
||||||
|
match state:
|
||||||
|
"fly":
|
||||||
|
if not thieves.gull_still_there(food) or thieves.watched(thieves.spot(food), GULL_SCARE):
|
||||||
|
_leave()
|
||||||
|
return
|
||||||
|
_goal = thieves.spot(food)
|
||||||
|
var ground: float = terrain.sample_height(global_position.x, global_position.z)
|
||||||
|
if Vector2(_goal.x - global_position.x, _goal.z - global_position.z).length() < 0.35 and global_position.y - ground < 0.15:
|
||||||
|
state = "peck"
|
||||||
|
_state_t = SNATCH_AFTER
|
||||||
|
thieves.gull_landed(self, food)
|
||||||
|
"peck":
|
||||||
|
if not thieves.gull_still_there(food) or thieves.watched(global_position, GULL_SCARE):
|
||||||
|
_leave()
|
||||||
|
return
|
||||||
|
_state_t -= dt
|
||||||
|
if _state_t <= 0.0:
|
||||||
|
_peck_anim = 0.4
|
||||||
|
carry = thieves.snatch(self, food)
|
||||||
|
_leave()
|
||||||
|
"fly_off":
|
||||||
|
if thieves.out_of_sight(global_position):
|
||||||
|
thieves.gone(self)
|
||||||
|
_:
|
||||||
|
_leave()
|
||||||
|
|
||||||
|
## Shot down with something in its beak: it drops it where it falls.
|
||||||
|
func _die(from: Vector3) -> void:
|
||||||
|
super._die(from)
|
||||||
|
if not carry.is_empty() and not puppet:
|
||||||
|
var at := global_position + Vector3(0, 0.3, 0)
|
||||||
|
world.drop_item(String(carry.id), int(carry.count), Transform3D(Basis(), at), carry.get("extra", {}))
|
||||||
|
carry = {}
|
||||||
1
scripts/creatures/gull.gd.uid
Normal file
1
scripts/creatures/gull.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://draw1wvqi3cif
|
||||||
106
scripts/creatures/shore.gd
Normal file
106
scripts/creatures/shore.gd
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
extends Node3D
|
||||||
|
## The shore's own wildlife (0.6.0, a land whose worldlet lists "crabs" in
|
||||||
|
## its "wildlife": the Cove). From dusk till dawn crabs come up out of the
|
||||||
|
## sea onto the beach, a few around each player who's down on the sand, at
|
||||||
|
## the waterline and out of the way. At dawn they go back in (crab.gd).
|
||||||
|
## Not saved: like the other wildlife they simply carry on around you.
|
||||||
|
##
|
||||||
|
## On a server this runs for everyone, and players see the crabs through
|
||||||
|
## creature_net.gd.
|
||||||
|
|
||||||
|
const CrabScript = preload("res://scripts/creatures/crab.gd")
|
||||||
|
|
||||||
|
const BUDGET := 5 ## per player on the beach, up to twice it
|
||||||
|
const CHECK_EVERY := 2.0
|
||||||
|
const RING_MIN := 14.0
|
||||||
|
const RING_MAX := 45.0
|
||||||
|
const NOT_WITHIN := 10.0 ## never comes up right beside anyone
|
||||||
|
const DESPAWN := 80.0
|
||||||
|
const BEACH_NEAR := 40.0 ## a player this close to the sand counts as on the beach
|
||||||
|
|
||||||
|
var world: Node3D
|
||||||
|
var terrain: Node3D
|
||||||
|
var enabled := true
|
||||||
|
var _rng := RandomNumberGenerator.new()
|
||||||
|
var _acc := 0.0
|
||||||
|
var _serial := 0
|
||||||
|
|
||||||
|
func setup(w: Node3D) -> void:
|
||||||
|
world = w
|
||||||
|
terrain = w.terrain
|
||||||
|
_rng.seed = terrain.noise_seed + 1201
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
step(delta)
|
||||||
|
|
||||||
|
## Dusk till dawn.
|
||||||
|
func night() -> bool:
|
||||||
|
var h: float = world.day_night.hours()
|
||||||
|
return h >= 17.5 or h < 6.5
|
||||||
|
|
||||||
|
## Living crabs.
|
||||||
|
func alive() -> Array:
|
||||||
|
return get_children().filter(func(c): return is_instance_valid(c) and not c.is_queued_for_deletion() and not c.is_dead())
|
||||||
|
|
||||||
|
func step(dt: float) -> void:
|
||||||
|
if not enabled or world.creatures == null or not world.creatures.enabled:
|
||||||
|
return
|
||||||
|
_acc += dt
|
||||||
|
if _acc < CHECK_EVERY:
|
||||||
|
return
|
||||||
|
_acc = 0.0
|
||||||
|
var everyone: Array = world.players()
|
||||||
|
for c in get_children():
|
||||||
|
if not everyone.any(func(p): return p.global_position.distance_to(c.global_position) <= DESPAWN):
|
||||||
|
c.queue_free()
|
||||||
|
if not night() or everyone.is_empty():
|
||||||
|
return
|
||||||
|
var on_beach := everyone.filter(func(p): return _near_sand(p.global_position))
|
||||||
|
if on_beach.is_empty():
|
||||||
|
return
|
||||||
|
if alive().size() < BUDGET * clampi(on_beach.size(), 1, 2):
|
||||||
|
var who: Node3D = on_beach[_rng.randi() % on_beach.size()]
|
||||||
|
spawn_near(who.global_position) # (one at a time: they trickle up out of the sea)
|
||||||
|
|
||||||
|
func _near_sand(p: Vector3) -> bool:
|
||||||
|
for a in [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]:
|
||||||
|
var q := p + Vector3(cos(a), 0.0, sin(a)) * BEACH_NEAR * 0.5
|
||||||
|
if terrain.on_sand(p.x, p.z) or terrain.on_sand(q.x, q.z):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
## A spot on the sand at the waterline, RING_MIN..RING_MAX from `around`
|
||||||
|
## and not right beside anyone, or Vector3.INF.
|
||||||
|
func find_spot(around: Vector3) -> Vector3:
|
||||||
|
for _try in range(40):
|
||||||
|
var a := _rng.randf() * TAU
|
||||||
|
var r := _rng.randf_range(RING_MIN, RING_MAX)
|
||||||
|
var x := around.x + cos(a) * r
|
||||||
|
var z := around.z + sin(a) * r
|
||||||
|
if not terrain.inside_boundary(x, z, 6.0) or terrain.beach_weight(x, z) < 0.75:
|
||||||
|
continue
|
||||||
|
var h: float = terrain.sample_height(x, z)
|
||||||
|
if h < terrain.water_level + 0.25 or h > terrain.water_level + 0.9:
|
||||||
|
continue
|
||||||
|
var p := Vector3(x, h, z)
|
||||||
|
if world.players().any(func(pl): return pl.global_position.distance_to(p) < NOT_WITHIN):
|
||||||
|
continue
|
||||||
|
return p
|
||||||
|
return Vector3.INF
|
||||||
|
|
||||||
|
func spawn_near(around: Vector3) -> Node3D:
|
||||||
|
var p := find_spot(around)
|
||||||
|
if p == Vector3.INF:
|
||||||
|
return null
|
||||||
|
return spawn(p)
|
||||||
|
|
||||||
|
func spawn(pos: Vector3) -> Node3D:
|
||||||
|
var c: AnimatableBody3D = CrabScript.new()
|
||||||
|
_serial += 1
|
||||||
|
c.setup("crab", world, pos, _rng.randi() + _serial)
|
||||||
|
add_child(c)
|
||||||
|
c.global_position = Vector3(pos.x, terrain.sample_height(pos.x, pos.z), pos.z)
|
||||||
|
c.state = "wander"
|
||||||
|
c.yaw = _rng.randf() * TAU
|
||||||
|
c.target_yaw = c.yaw
|
||||||
|
return c
|
||||||
1
scripts/creatures/shore.gd.uid
Normal file
1
scripts/creatures/shore.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b6eeco4cs028q
|
||||||
@@ -9,10 +9,16 @@ extends Node3D
|
|||||||
## fox.gd). Food in a chest is never touched (that's what chests are for);
|
## fox.gd). Food in a chest is never touched (that's what chests are for);
|
||||||
## the bear (bear_range.gd) is another matter.
|
## the bear (bear_range.gd) is another matter.
|
||||||
##
|
##
|
||||||
|
## In a land with gulls (the Cove, 0.6.0: its worldlet's "wildlife"), food
|
||||||
|
## left on the sand draws a gull by day instead of a crow (gull.gd), and so
|
||||||
|
## does fish on a smoking rack that nobody's watching: a gull snatches one
|
||||||
|
## and flies off with it.
|
||||||
|
##
|
||||||
## On a server this runs for everyone (thieves are wildlife: creature_net.gd).
|
## On a server this runs for everyone (thieves are wildlife: creature_net.gd).
|
||||||
|
|
||||||
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
||||||
const FoxScript = preload("res://scripts/creatures/fox.gd")
|
const FoxScript = preload("res://scripts/creatures/fox.gd")
|
||||||
|
const GullScript = preload("res://scripts/creatures/gull.gd")
|
||||||
|
|
||||||
const CHECK_EVERY := 5.0
|
const CHECK_EVERY := 5.0
|
||||||
const UNWATCHED := 10.0
|
const UNWATCHED := 10.0
|
||||||
@@ -25,6 +31,7 @@ signal stolen(thief: Node, id: String, count: int)
|
|||||||
|
|
||||||
var world: Node3D
|
var world: Node3D
|
||||||
var enabled := true
|
var enabled := true
|
||||||
|
var gulls := false ## this land has gulls (the Cove)
|
||||||
var _acc := 0.0
|
var _acc := 0.0
|
||||||
var _unwatched: Dictionary = {} ## pickup instance id -> seconds left alone
|
var _unwatched: Dictionary = {} ## pickup instance id -> seconds left alone
|
||||||
var _rng := RandomNumberGenerator.new()
|
var _rng := RandomNumberGenerator.new()
|
||||||
@@ -32,6 +39,7 @@ var _rng := RandomNumberGenerator.new()
|
|||||||
func setup(w: Node3D) -> void:
|
func setup(w: Node3D) -> void:
|
||||||
world = w
|
world = w
|
||||||
_rng.seed = w.terrain.noise_seed + 997
|
_rng.seed = w.terrain.noise_seed + 997
|
||||||
|
gulls = w.has_wildlife("gulls")
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
if enabled:
|
if enabled:
|
||||||
@@ -85,14 +93,28 @@ func step(dt: float) -> void:
|
|||||||
_unwatched[k] = float(_unwatched.get(k, 0.0)) + since
|
_unwatched[k] = float(_unwatched.get(k, 0.0)) + since
|
||||||
if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(d) and alive().size() < MAX_THIEVES:
|
if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(d) and alive().size() < MAX_THIEVES:
|
||||||
send(d)
|
send(d)
|
||||||
|
if gulls and not night():
|
||||||
|
for r in racks():
|
||||||
|
if not gull_still_there(r):
|
||||||
|
continue
|
||||||
|
var k: int = r.get_instance_id()
|
||||||
|
seen[k] = true
|
||||||
|
if watched(r.global_position, UNWATCHED):
|
||||||
|
_unwatched.erase(k)
|
||||||
|
continue
|
||||||
|
_unwatched[k] = float(_unwatched.get(k, 0.0)) + since
|
||||||
|
if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(r) and alive().size() < MAX_THIEVES:
|
||||||
|
send(r)
|
||||||
for k in _unwatched.keys():
|
for k in _unwatched.keys():
|
||||||
if not seen.has(k):
|
if not seen.has(k):
|
||||||
_unwatched.erase(k)
|
_unwatched.erase(k)
|
||||||
|
|
||||||
## Sends a thief after `food`: a fox at dusk and night, otherwise a crow.
|
## Sends a thief after `food`: a fox at dusk and night, otherwise a crow (a
|
||||||
|
## gull on the sand, or at a smoking rack, where there are gulls).
|
||||||
func send(food: Node3D) -> Node3D:
|
func send(food: Node3D) -> Node3D:
|
||||||
var fox := night()
|
var fox := night()
|
||||||
var fp: Vector3 = food.global_position
|
var gull := not fox and gulls and (food.is_in_group("processor") or on_sand(food.global_position))
|
||||||
|
var fp: Vector3 = spot(food)
|
||||||
var from := Vector3.INF
|
var from := Vector3.INF
|
||||||
for _i in range(12):
|
for _i in range(12):
|
||||||
var a := _rng.randf() * TAU
|
var a := _rng.randf() * TAU
|
||||||
@@ -104,8 +126,8 @@ func send(food: Node3D) -> Node3D:
|
|||||||
break
|
break
|
||||||
if from == Vector3.INF:
|
if from == Vector3.INF:
|
||||||
return null
|
return null
|
||||||
var t: AnimatableBody3D = (FoxScript if fox else CrowScript).new()
|
var t: AnimatableBody3D = (FoxScript if fox else (GullScript if gull else CrowScript)).new()
|
||||||
t.setup("fox" if fox else "crow", world, from, _rng.randi())
|
t.setup("fox" if fox else ("gull" if gull else "crow"), world, from, _rng.randi())
|
||||||
t.thieves = self
|
t.thieves = self
|
||||||
t.food = food
|
t.food = food
|
||||||
t.state = "sneak" if fox else "fly"
|
t.state = "sneak" if fox else "fly"
|
||||||
@@ -148,3 +170,63 @@ func grab(fox: Node, food: Node3D) -> Dictionary:
|
|||||||
## A thief left with what it took (or empty-handed).
|
## A thief left with what it took (or empty-handed).
|
||||||
func gone(t: Node) -> void:
|
func gone(t: Node) -> void:
|
||||||
t.queue_free()
|
t.queue_free()
|
||||||
|
|
||||||
|
# --- gulls (the Cove) ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func on_sand(pos: Vector3) -> bool:
|
||||||
|
return world.terrain.on_sand(pos.x, pos.z)
|
||||||
|
|
||||||
|
static func is_fish(id: String) -> bool:
|
||||||
|
return Dictionary(ItemDB.get_item(id).get("food", {})).get("diet", {}).has("fish")
|
||||||
|
|
||||||
|
## This land's smoking racks.
|
||||||
|
func racks() -> Array:
|
||||||
|
return get_tree().get_nodes_in_group("processor").filter(func(r): return is_instance_valid(r) and not r.is_queued_for_deletion() \
|
||||||
|
and String(r.item_id) == "smoking_rack" and world.is_ancestor_of(r))
|
||||||
|
|
||||||
|
## Fish on a rack: the first id there is, or "".
|
||||||
|
func _rack_fish(r: Node3D) -> String:
|
||||||
|
for st in r.inventory.slots:
|
||||||
|
if st != null and is_fish(String(st.id)):
|
||||||
|
return String(st.id)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
## Whether a gull's target is still worth it: food on the ground, or a rack
|
||||||
|
## with fish on it.
|
||||||
|
func gull_still_there(t: Node3D) -> bool:
|
||||||
|
if t == null or not is_instance_valid(t) or t.is_queued_for_deletion():
|
||||||
|
return false
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
return not _rack_fish(t).is_empty()
|
||||||
|
return still_there(t)
|
||||||
|
|
||||||
|
## Where a thief lands for `t`: on food, or on the ground beside a rack.
|
||||||
|
func spot(t: Node3D) -> Vector3:
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
var p: Vector3 = t.global_position + t.global_basis.z * 0.8
|
||||||
|
return Vector3(p.x, world.terrain.sample_height(p.x, p.z), p.z)
|
||||||
|
return t.global_position
|
||||||
|
|
||||||
|
func gull_landed(_gull: Node, t: Node3D) -> void:
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
_tell(t.global_position, "A gull's at the fish on your smoking rack!")
|
||||||
|
else:
|
||||||
|
_tell(t.global_position, "A gull's landed by your %s!" % ItemDB.item_name(t.item_id).to_lower())
|
||||||
|
|
||||||
|
## A gull takes one and flies off with it. Returns what it's carrying.
|
||||||
|
func snatch(gull: Node, t: Node3D) -> Dictionary:
|
||||||
|
if not gull_still_there(t):
|
||||||
|
return {}
|
||||||
|
var c := {}
|
||||||
|
if t.is_in_group("processor"):
|
||||||
|
var id := _rack_fish(t)
|
||||||
|
t.inventory.remove(id, 1)
|
||||||
|
c = {"id": id, "count": 1, "extra": {}}
|
||||||
|
else:
|
||||||
|
c = {"id": String(t.item_id), "count": 1, "extra": t.extra.duplicate()}
|
||||||
|
t.amount = int(t.amount) - 1
|
||||||
|
if int(t.amount) <= 0:
|
||||||
|
world.take_dropped(t)
|
||||||
|
_tell(t.global_position, "A gull has snatched your %s!" % ItemDB.item_name(String(c.id)).to_lower())
|
||||||
|
stolen.emit(gull, String(c.id), 1)
|
||||||
|
return c
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ static func build(key: String) -> ArrayMesh:
|
|||||||
var m: ArrayMesh
|
var m: ArrayMesh
|
||||||
match key:
|
match key:
|
||||||
"stick": m = _stick()
|
"stick": m = _stick()
|
||||||
|
"driftwood": m = _driftwood()
|
||||||
|
"seagrass": m = _seagrass()
|
||||||
|
"rope": m = _rope()
|
||||||
|
"pine_resin": m = _pine_resin()
|
||||||
|
"pitch": m = _pitch()
|
||||||
|
"rowing_boat": m = _rowing_boat()
|
||||||
|
"treasure_map": m = _treasure_map()
|
||||||
|
"ship_compass": m = _ship_compass()
|
||||||
|
"spyglass": m = _spyglass()
|
||||||
|
"pirate_gold": m = _pirate_gold()
|
||||||
|
"cutlass": m = _cutlass()
|
||||||
"stone": m = _pebble(11, STONE, Vector3(0.14, 0.10, 0.12))
|
"stone": m = _pebble(11, STONE, Vector3(0.14, 0.10, 0.12))
|
||||||
"flint": m = _pebble(23, FLINT, Vector3(0.12, 0.07, 0.15), 0.5)
|
"flint": m = _pebble(23, FLINT, Vector3(0.12, 0.07, 0.15), 0.5)
|
||||||
"wood": m = _wood()
|
"wood": m = _wood()
|
||||||
@@ -78,6 +89,8 @@ static func build(key: String) -> ArrayMesh:
|
|||||||
"mackerel": m = _fish(MACKEREL, 0.2, false)
|
"mackerel": m = _fish(MACKEREL, 0.2, false)
|
||||||
"cod": m = _fish(COD, 0.3, false)
|
"cod": m = _fish(COD, 0.3, false)
|
||||||
"grilled_mackerel": m = _fish(MACKEREL, 0.2, true)
|
"grilled_mackerel": m = _fish(MACKEREL, 0.2, true)
|
||||||
|
"crab_meat": m = _crab_meat()
|
||||||
|
"cooked_crab": m = _cooked_crab()
|
||||||
"grilled_cod": m = _fish(COD, 0.3, true)
|
"grilled_cod": m = _fish(COD, 0.3, true)
|
||||||
"fiber_plant": m = _fiber_plant()
|
"fiber_plant": m = _fiber_plant()
|
||||||
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
|
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
|
||||||
@@ -184,6 +197,147 @@ static func _stick() -> ArrayMesh:
|
|||||||
P._cylinder(st, Vector3(0.05, 0.03, 0.0), Vector3(0.2, 0.05, 0.1), 0.012, 0.006, 4, HANDLE, r)
|
P._cylinder(st, Vector3(0.05, 0.03, 0.0), Vector3(0.2, 0.05, 0.1), 0.012, 0.006, 4, HANDLE, r)
|
||||||
return P._commit(st)
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A sea-bleached branch, twisted, with a stub of a side branch.
|
||||||
|
static func _driftwood() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(41)
|
||||||
|
const BLEACHED := Color(0.72, 0.68, 0.6)
|
||||||
|
const GREY := Color(0.58, 0.55, 0.5)
|
||||||
|
P._cylinder(st, Vector3(-0.5, 0.06, -0.04), Vector3(0.05, 0.08, 0.05), 0.07, 0.06, 6, BLEACHED, r, -1.0, GREY)
|
||||||
|
P._cylinder(st, Vector3(0.05, 0.08, 0.05), Vector3(0.48, 0.05, -0.02), 0.06, 0.035, 6, BLEACHED, r, -1.0, GREY)
|
||||||
|
P._cylinder(st, Vector3(0.1, 0.09, 0.05), Vector3(0.22, 0.12, 0.24), 0.03, 0.015, 5, GREY, r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A tangle of long, flat ribbons of seagrass.
|
||||||
|
static func _seagrass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(45)
|
||||||
|
for i in range(6):
|
||||||
|
var a := r.randf() * TAU
|
||||||
|
var col := Color(0.28, 0.42, 0.2).lerp(Color(0.5, 0.48, 0.25), r.randf())
|
||||||
|
_box(st, Vector3(r.randf_range(-0.03, 0.03), 0.01 + i * 0.006, r.randf_range(-0.03, 0.03)), Vector3(0.03, 0.006, 0.38),
|
||||||
|
col, Basis(Vector3.UP, a))
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A coil of seagrass rope.
|
||||||
|
static func _rope() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(47)
|
||||||
|
const ROPE := Color(0.6, 0.55, 0.36)
|
||||||
|
var n := 12
|
||||||
|
for k in range(2):
|
||||||
|
for i in range(n):
|
||||||
|
var a0 := TAU * i / n
|
||||||
|
var a1 := TAU * (i + 1) / n
|
||||||
|
var rr := 0.1 - k * 0.035
|
||||||
|
P._cylinder(st, Vector3(cos(a0) * rr, 0.025 + k * 0.03, sin(a0) * rr), Vector3(cos(a1) * rr, 0.025 + k * 0.03, sin(a1) * rr),
|
||||||
|
0.02, 0.02, 5, ROPE.darkened(r.randf() * 0.12), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A few lumps of sticky amber resin.
|
||||||
|
static func _pine_resin() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(49)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 49
|
||||||
|
n.frequency = 3.0
|
||||||
|
for i in range(4):
|
||||||
|
var c := Vector3(r.randf_range(-0.05, 0.05), 0.03, r.randf_range(-0.05, 0.05))
|
||||||
|
P._blob(st, c, Vector3.ONE * r.randf_range(0.025, 0.045), n, 0.25, Color(0.8, 0.5, 0.12).darkened(r.randf() * 0.25), r, 1)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A small clay pot of black pitch, a stick stood in it.
|
||||||
|
static func _pitch() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(51)
|
||||||
|
P._cylinder(st, Vector3(0, 0, 0), Vector3(0, 0.12, 0), 0.07, 0.085, 8, Color(0.55, 0.36, 0.24), r, 0.0)
|
||||||
|
P._cylinder(st, Vector3(0, 0.115, 0), Vector3(0, 0.125, 0), 0.078, 0.078, 8, Color(0.06, 0.05, 0.05), r, -1.0, Color(0.06, 0.05, 0.05))
|
||||||
|
P._cylinder(st, Vector3(0.01, 0.06, 0.0), Vector3(0.05, 0.24, 0.02), 0.008, 0.007, 4, HANDLE, r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A rowing boat, bow to -Z: a model 0.55 m long (moorings.gd shows it eight
|
||||||
|
## times over at the pier). Driftwood planks, pitch-black seams, two thwarts
|
||||||
|
## and a pair of oars.
|
||||||
|
static func _rowing_boat() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(53)
|
||||||
|
const WOOD := Color(0.66, 0.6, 0.5)
|
||||||
|
const SEAM := Color(0.1, 0.08, 0.07)
|
||||||
|
const OAR := Color(0.55, 0.42, 0.28)
|
||||||
|
var hw := 0.085 # half the beam amidships
|
||||||
|
# Bottom: a wide plank aft, narrowing to the bow.
|
||||||
|
_box(st, Vector3(0, 0.01, 0.07), Vector3(hw * 1.7, 0.012, 0.36), WOOD)
|
||||||
|
_box(st, Vector3(0, 0.014, -0.16), Vector3(hw * 1.1, 0.012, 0.12), WOOD)
|
||||||
|
for side in [-1.0, 1.0]:
|
||||||
|
# Sides amidships and aft, two strakes with a dark seam between.
|
||||||
|
_box(st, Vector3(side * hw, 0.03, 0.06), Vector3(0.008, 0.03, 0.38), WOOD.darkened(0.05))
|
||||||
|
_box(st, Vector3(side * hw, 0.047, 0.06), Vector3(0.0085, 0.004, 0.38), SEAM)
|
||||||
|
_box(st, Vector3(side * hw, 0.062, 0.06), Vector3(0.008, 0.028, 0.38), WOOD)
|
||||||
|
# Bow: the sides swept in to meet at the stem.
|
||||||
|
var bow := Basis(Vector3.UP, side * 0.53)
|
||||||
|
_box(st, Vector3(side * hw * 0.5, 0.048, -0.193), Vector3(0.008, 0.07, 0.17), WOOD, bow)
|
||||||
|
_box(st, Vector3(0, 0.055, -0.265), Vector3(0.014, 0.085, 0.014), WOOD.darkened(0.15)) # stem
|
||||||
|
_box(st, Vector3(0, 0.045, 0.25), Vector3(hw * 2.0, 0.06, 0.008), WOOD.darkened(0.08)) # transom
|
||||||
|
for z in [-0.02, 0.15]:
|
||||||
|
_box(st, Vector3(0, 0.055, z), Vector3(hw * 2.0, 0.008, 0.035), WOOD.darkened(0.12)) # thwarts
|
||||||
|
for side in [-1.0, 1.0]:
|
||||||
|
P._cylinder(st, Vector3(side * 0.04, 0.07, 0.17), Vector3(side * 0.05, 0.075, -0.13), 0.004, 0.004, 4, OAR, r)
|
||||||
|
_box(st, Vector3(side * 0.05, 0.075, -0.15), Vector3(0.022, 0.003, 0.05), OAR)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A rolled-up, torn sheet of old parchment tied with string.
|
||||||
|
static func _treasure_map() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(43)
|
||||||
|
const PARCH := Color(0.82, 0.74, 0.55)
|
||||||
|
P._cylinder(st, Vector3(-0.16, 0.04, 0), Vector3(0.16, 0.04, 0), 0.04, 0.04, 8, PARCH, r, -1.0, PARCH.darkened(0.15))
|
||||||
|
P._cylinder(st, Vector3(-0.01, 0.04, 0), Vector3(0.01, 0.04, 0), 0.043, 0.043, 8, Color(0.45, 0.12, 0.08), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
const BRASS := Color(0.78, 0.6, 0.25)
|
||||||
|
const GOLD := Color(0.95, 0.75, 0.2)
|
||||||
|
|
||||||
|
## A brass sea compass: a flat round case, a glass face, a red needle.
|
||||||
|
static func _ship_compass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(47)
|
||||||
|
P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 0.05, 0), 0.11, 0.11, 12, BRASS, r, -1.0, BRASS)
|
||||||
|
P._cylinder(st, Vector3(0, 0.05, 0), Vector3(0, 0.056, 0), 0.095, 0.095, 12, Color(0.9, 0.88, 0.8), r, -1.0, Color(0.9, 0.88, 0.8))
|
||||||
|
P._cylinder(st, Vector3(0, 0.06, 0.07), Vector3(0, 0.06, -0.07), 0.012, 0.004, 4, Color(0.75, 0.1, 0.08), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A brass telescope, drawn out: three tubes, the big end forward.
|
||||||
|
static func _spyglass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(49)
|
||||||
|
P._cylinder(st, Vector3(0, 0.04, 0.22), Vector3(0, 0.04, 0.06), 0.03, 0.03, 8, BRASS, r, -1.0, BRASS)
|
||||||
|
P._cylinder(st, Vector3(0, 0.04, 0.06), Vector3(0, 0.04, -0.1), 0.036, 0.036, 8, Color(0.3, 0.2, 0.12), r, -1.0, BRASS)
|
||||||
|
P._cylinder(st, Vector3(0, 0.04, -0.1), Vector3(0, 0.04, -0.26), 0.044, 0.048, 8, BRASS, r, -1.0, Color(0.6, 0.7, 0.8))
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A heap of old gold coins, and a few spilled.
|
||||||
|
static func _pirate_gold() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(53)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 53
|
||||||
|
P._blob(st, Vector3(0, 0.06, 0), Vector3(0.2, 0.09, 0.18), n, 0.25, GOLD, r, 1)
|
||||||
|
for i in range(7):
|
||||||
|
var a := i * 0.9
|
||||||
|
var c := Vector3(cos(a) * (0.16 + 0.05 * (i % 3)), 0.01, sin(a) * (0.16 + 0.05 * (i % 3)))
|
||||||
|
P._cylinder(st, c, c + Vector3(0, 0.012, 0), 0.03, 0.03, 8, GOLD.darkened(0.1 * (i % 3)), r, -1.0, GOLD)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A cutlass: a curved, broad blade, a brass basket guard, a dark grip.
|
||||||
|
static func _cutlass() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(59)
|
||||||
|
_handle(st, 0.14, r)
|
||||||
|
P._cylinder(st, Vector3(-0.06, 0.15, 0), Vector3(0.06, 0.15, 0), 0.025, 0.025, 5, BRASS, r)
|
||||||
|
P._cylinder(st, Vector3(0, 0.16, 0), Vector3(0.02, 0.45, 0), 0.03, 0.028, 4, Color(0.72, 0.74, 0.78), r)
|
||||||
|
P._cylinder(st, Vector3(0.02, 0.45, 0), Vector3(0.08, 0.72, 0), 0.028, 0.006, 4, Color(0.72, 0.74, 0.78), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
static func _wood() -> ArrayMesh:
|
static func _wood() -> ArrayMesh:
|
||||||
var st := P._begin()
|
var st := P._begin()
|
||||||
var r := _rng(9)
|
var r := _rng(9)
|
||||||
@@ -1109,6 +1263,34 @@ static func _barrow_key() -> ArrayMesh:
|
|||||||
# --- P4 food keeping ---------------------------------------------------------------------
|
# --- P4 food keeping ---------------------------------------------------------------------
|
||||||
|
|
||||||
## A crust of white salt crystals.
|
## A crust of white salt crystals.
|
||||||
|
## A few chunks of white crab meat in a cracked bit of shell.
|
||||||
|
static func _crab_meat() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(341)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 341
|
||||||
|
n.frequency = 2.6
|
||||||
|
P._blob(st, Vector3(0, 0.03, 0), Vector3(0.11, 0.03, 0.08), n, 0.2, Color(0.62, 0.22, 0.12), r, 1)
|
||||||
|
for i in range(4):
|
||||||
|
var c := Vector3(r.randf_range(-0.05, 0.05), 0.06, r.randf_range(-0.035, 0.035))
|
||||||
|
P._blob(st, c, Vector3(0.035, 0.022, 0.025), n, 0.25, Color(0.95, 0.9, 0.84).lerp(Color(0.95, 0.7, 0.62), r.randf() * 0.4), r, 0)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## A whole crab, boiled pink-red, legs tucked.
|
||||||
|
static func _cooked_crab() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(343)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 343
|
||||||
|
n.frequency = 2.4
|
||||||
|
var shell := Color(0.9, 0.35, 0.22)
|
||||||
|
P._blob(st, Vector3(0, 0.05, 0), Vector3(0.12, 0.04, 0.09), n, 0.1, shell, r, 1)
|
||||||
|
for sx in [-1.0, 1.0]:
|
||||||
|
P._blob(st, Vector3(sx * 0.1, 0.04, -0.1), Vector3(0.03, 0.025, 0.045), n, 0.1, shell, r, 0)
|
||||||
|
for z in [-0.03, 0.01, 0.05]:
|
||||||
|
P._cylinder(st, Vector3(sx * 0.1, 0.03, z), Vector3(sx * 0.15, 0.01, z + 0.02), 0.008, 0.004, 3, P._mul(shell, 0.85), r)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
static func _salt() -> ArrayMesh:
|
static func _salt() -> ArrayMesh:
|
||||||
var st := P._begin()
|
var st := P._begin()
|
||||||
var r := _rng(331)
|
var r := _rng(331)
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ func _c2s_say(text: String) -> void:
|
|||||||
var who: String = Net.players[peer].name
|
var who: String = Net.players[peer].name
|
||||||
if text.begins_with("/"):
|
if text.begins_with("/"):
|
||||||
print("[server] %s: %s" % [who, text])
|
print("[server] %s: %s" % [who, text])
|
||||||
|
if not Lands.land.is_empty():
|
||||||
|
# (Another land's server: commands run on the hub, which keeps
|
||||||
|
# server.json, and answers back here.)
|
||||||
|
Lands.send_hub({"t": "cmd", "line": text.substr(1), "id": String(Net.players[peer].id), "name": who, "peer": peer})
|
||||||
|
return
|
||||||
run(text.substr(1), peer)
|
run(text.substr(1), peer)
|
||||||
return
|
return
|
||||||
# Plain chat only: a line every GAP seconds, so nobody can flood it.
|
# Plain chat only: a line every GAP seconds, so nobody can flood it.
|
||||||
@@ -68,22 +73,44 @@ func _c2s_say(text: String) -> void:
|
|||||||
print("[chat] %s: %s" % [who, text])
|
print("[chat] %s: %s" % [who, text])
|
||||||
_all("%s: %s" % [who, text], "chat")
|
_all("%s: %s" % [who, text], "chat")
|
||||||
|
|
||||||
func _all(text: String, kind: String) -> void:
|
## A line for everyone here, and (`relay`) in every other land too (J3b).
|
||||||
|
func _all(text: String, kind: String, relay: bool = true) -> void:
|
||||||
if kind == "server":
|
if kind == "server":
|
||||||
print("[server] ", text)
|
print("[server] ", text)
|
||||||
for peer in Net.players:
|
for peer in Net.players:
|
||||||
_s2c_line.rpc_id(peer, text, kind)
|
_s2c_line.rpc_id(peer, text, kind)
|
||||||
|
if relay:
|
||||||
|
Lands.relay_line(text, kind)
|
||||||
|
|
||||||
|
## A command typed by someone in another land (J3b): who they are, and where
|
||||||
|
## to send the answer. `run()` sees them as REMOTE.
|
||||||
|
const Terrain = preload("res://scripts/world/terrain.gd")
|
||||||
|
const REMOTE := -1
|
||||||
|
var _caller: Dictionary = {}
|
||||||
|
|
||||||
|
func run_remote(line: String, caller: Dictionary) -> void:
|
||||||
|
print("[server] %s (in the %s): /%s" % [caller.name, caller.land, line])
|
||||||
|
_caller = caller
|
||||||
|
run(line, REMOTE)
|
||||||
|
_caller = {}
|
||||||
|
|
||||||
## An answer to whoever gave the command (peer 0 = the console).
|
## An answer to whoever gave the command (peer 0 = the console).
|
||||||
func _reply(peer: int, text: String) -> void:
|
func _reply(peer: int, text: String) -> void:
|
||||||
if peer == 0:
|
if peer == 0:
|
||||||
print(text)
|
print(text)
|
||||||
|
elif peer == REMOTE:
|
||||||
|
Lands.tell_land(String(_caller.land), {"t": "reply", "peer": int(_caller.peer), "text": text})
|
||||||
else:
|
else:
|
||||||
_s2c_line.rpc_id(peer, text, "reply")
|
_s2c_line.rpc_id(peer, text, "reply")
|
||||||
|
|
||||||
func _is_admin(peer: int) -> bool:
|
func _is_admin(peer: int) -> bool:
|
||||||
|
if peer == REMOTE:
|
||||||
|
return Net.config.is_admin(String(_caller.id))
|
||||||
return peer == 0 or (Net.players.has(peer) and Net.config.is_admin(String(Net.players[peer].id)))
|
return peer == 0 or (Net.players.has(peer) and Net.config.is_admin(String(Net.players[peer].id)))
|
||||||
|
|
||||||
|
## Another land's server should know: this command changed server.json.
|
||||||
|
const CHANGES_CONFIG := ["friend", "unfriend", "ban", "unban", "admin", "set", "release-name"]
|
||||||
|
|
||||||
## The player id a name belongs to (online or reserved), or "".
|
## The player id a name belongs to (online or reserved), or "".
|
||||||
func _id_of(n: String) -> String:
|
func _id_of(n: String) -> String:
|
||||||
var peer := Net.peer_by_name(n)
|
var peer := Net.peer_by_name(n)
|
||||||
@@ -102,16 +129,24 @@ func run(line: String, peer: int) -> void:
|
|||||||
if cmd in ADMIN_ONLY and not _is_admin(peer):
|
if cmd in ADMIN_ONLY and not _is_admin(peer):
|
||||||
_reply(peer, "Only admins can use /%s" % cmd)
|
_reply(peer, "Only admins can use /%s" % cmd)
|
||||||
return
|
return
|
||||||
var me: String = String(Net.players[peer].id) if Net.players.has(peer) else ""
|
var me: String = String(Net.players[peer].id) if Net.players.has(peer) else String(_caller.get("id", ""))
|
||||||
var by: String = String(Net.players[peer].name) if Net.players.has(peer) else "The server"
|
var by: String = String(Net.players[peer].name) if Net.players.has(peer) else String(_caller.get("name", "The server"))
|
||||||
|
_run_cmd(cmd, parts, rest, arg, peer, me, by)
|
||||||
|
if cmd in CHANGES_CONFIG:
|
||||||
|
Lands.tell_lands({"t": "config"})
|
||||||
|
|
||||||
|
func _run_cmd(cmd: String, parts: PackedStringArray, rest: String, arg: String, peer: int, me: String, by: String) -> void:
|
||||||
match cmd:
|
match cmd:
|
||||||
"help":
|
"help":
|
||||||
_reply(peer, "Commands: /players /rules /friend <name> /unfriend <name> /friends" +
|
_reply(peer, "Commands: /players /rules /friend <name> /unfriend <name> /friends" +
|
||||||
(" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else ""))
|
(" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else ""))
|
||||||
"players":
|
"players":
|
||||||
|
# Everyone, in every land (J3b: the hub's roster), with where they are.
|
||||||
var names: Array[String] = []
|
var names: Array[String] = []
|
||||||
for p in Net.players.values():
|
var many := Lands.roster.any(func(e): return String(e.land) != Lands.here())
|
||||||
names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else ""))
|
for p in (Lands.roster if not Lands.roster.is_empty() else Lands._here_list()):
|
||||||
|
names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else "")
|
||||||
|
+ (" in the %s" % Terrain.worldlet_name(String(p.land)) if many else ""))
|
||||||
_reply(peer, "%d online: %s" % [names.size(), ", ".join(names)])
|
_reply(peer, "%d online: %s" % [names.size(), ", ".join(names)])
|
||||||
"rules":
|
"rules":
|
||||||
var c: RefCounted = Net.config
|
var c: RefCounted = Net.config
|
||||||
@@ -142,10 +177,14 @@ func run(line: String, peer: int) -> void:
|
|||||||
_reply(peer, "Your friends: %s" % (", ".join(names) if not names.is_empty() else "nobody yet (/friend <name>)"))
|
_reply(peer, "Your friends: %s" % (", ".join(names) if not names.is_empty() else "nobody yet (/friend <name>)"))
|
||||||
"kick":
|
"kick":
|
||||||
var kp := Net.peer_by_name(arg)
|
var kp := Net.peer_by_name(arg)
|
||||||
if kp == 0:
|
var away := Lands.land_of_name(arg) if kp == 0 else ""
|
||||||
|
if kp == 0 and (away.is_empty() or away == Lands.here()):
|
||||||
_reply(peer, "Nobody called '%s' is online" % arg)
|
_reply(peer, "Nobody called '%s' is online" % arg)
|
||||||
return
|
return
|
||||||
|
if kp != 0:
|
||||||
Net.kick(kp, "You were kicked by %s" % by)
|
Net.kick(kp, "You were kicked by %s" % by)
|
||||||
|
else:
|
||||||
|
Lands.tell_land(away, {"t": "kick", "name": arg, "reason": "You were kicked by %s" % by})
|
||||||
_all("%s was kicked" % arg, "server")
|
_all("%s was kicked" % arg, "server")
|
||||||
"ban":
|
"ban":
|
||||||
var bid := _id_of(arg)
|
var bid := _id_of(arg)
|
||||||
@@ -156,6 +195,8 @@ func run(line: String, peer: int) -> void:
|
|||||||
var bp := Net.peer_by_name(arg)
|
var bp := Net.peer_by_name(arg)
|
||||||
if bp != 0:
|
if bp != 0:
|
||||||
Net.kick(bp, "You were banned by %s" % by)
|
Net.kick(bp, "You were banned by %s" % by)
|
||||||
|
elif not Lands.land_of_name(arg).is_empty():
|
||||||
|
Lands.tell_land(Lands.land_of_name(arg), {"t": "kick", "name": arg, "reason": "You were banned by %s" % by})
|
||||||
_all("%s was banned" % arg, "server")
|
_all("%s was banned" % arg, "server")
|
||||||
"unban":
|
"unban":
|
||||||
var uid: String = Net.config.name_owner(arg)
|
var uid: String = Net.config.name_owner(arg)
|
||||||
@@ -173,9 +214,11 @@ func run(line: String, peer: int) -> void:
|
|||||||
_reply(ap, "%s made you an admin (/help for the commands)" % by)
|
_reply(ap, "%s made you an admin (/help for the commands)" % by)
|
||||||
"save":
|
"save":
|
||||||
WorldNet.save()
|
WorldNet.save()
|
||||||
|
Lands.tell_lands({"t": "save"})
|
||||||
_reply(peer, "Saved")
|
_reply(peer, "Saved")
|
||||||
"backup":
|
"backup":
|
||||||
WorldNet.save()
|
WorldNet.save()
|
||||||
|
Lands.tell_lands({"t": "save"})
|
||||||
var b: String = WorldNet.backup("by %s" % by)
|
var b: String = WorldNet.backup("by %s" % by)
|
||||||
_reply(peer, "Backed up as %s (%d kept)" % [b, WorldNet.list_backups().size()] if not b.is_empty() else "Nothing to back up yet")
|
_reply(peer, "Backed up as %s (%d kept)" % [b, WorldNet.list_backups().size()] if not b.is_empty() else "Nothing to back up yet")
|
||||||
"backups":
|
"backups":
|
||||||
@@ -198,6 +241,7 @@ func run(line: String, peer: int) -> void:
|
|||||||
_reply(peer, "Use /weather %s or auto" % "|".join(PackedStringArray(states)))
|
_reply(peer, "Use /weather %s or auto" % "|".join(PackedStringArray(states)))
|
||||||
return
|
return
|
||||||
Net.set_weather("" if arg == "auto" else arg)
|
Net.set_weather("" if arg == "auto" else arg)
|
||||||
|
Lands.tell_lands({"t": "weather", "state": "" if arg == "auto" else arg})
|
||||||
_all("%s set the weather to %s" % [by, arg], "server")
|
_all("%s set the weather to %s" % [by, arg], "server")
|
||||||
"set":
|
"set":
|
||||||
if parts.size() < 3:
|
if parts.size() < 3:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const Creature = preload("res://scripts/creatures/creature.gd")
|
|||||||
const BearScript = preload("res://scripts/creatures/bear.gd")
|
const BearScript = preload("res://scripts/creatures/bear.gd")
|
||||||
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
const CrowScript = preload("res://scripts/creatures/crow.gd")
|
||||||
const GuardScript = preload("res://scripts/creatures/guard.gd")
|
const GuardScript = preload("res://scripts/creatures/guard.gd")
|
||||||
|
const GullScript = preload("res://scripts/creatures/gull.gd")
|
||||||
|
const CrabScript = preload("res://scripts/creatures/crab.gd")
|
||||||
const StagScript = preload("res://scripts/creatures/old_stag.gd") # (rears and lowers its antlers) # (falls apart in its own way)
|
const StagScript = preload("res://scripts/creatures/old_stag.gd") # (rears and lowers its antlers) # (falls apart in its own way)
|
||||||
const Bobber = preload("res://scripts/fishing/bobber.gd")
|
const Bobber = preload("res://scripts/fishing/bobber.gd")
|
||||||
const Projectile = preload("res://scripts/creatures/projectile.gd")
|
const Projectile = preload("res://scripts/creatures/projectile.gd")
|
||||||
@@ -99,7 +101,7 @@ func _server_tick(w: Node3D, delta: float) -> void:
|
|||||||
|
|
||||||
const PER_PACKET := 40
|
const PER_PACKET := 40
|
||||||
const ENTRY := 23
|
const ENTRY := 23
|
||||||
const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf"]
|
const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf", "drowned", "crab", "gull"]
|
||||||
const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash",
|
const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash",
|
||||||
"warn", "raid", "eat", "fly", "fly_off", "peck", "sneak", "carry", "circle", "telegraph", "stunned", "summon", "stomp"]
|
"warn", "raid", "eat", "fly", "fly_off", "peck", "sneak", "carry", "circle", "telegraph", "stunned", "summon", "stomp"]
|
||||||
|
|
||||||
@@ -198,7 +200,8 @@ func _s2c_life(packed: PackedByteArray, fl: Array) -> void:
|
|||||||
var c: Node3D = got if is_instance_valid(got) else null
|
var c: Node3D = got if is_instance_valid(got) else null
|
||||||
if c == null:
|
if c == null:
|
||||||
# (Kinds with their own look: the bear rears up, a crow flaps.)
|
# (Kinds with their own look: the bear rears up, a crow flaps.)
|
||||||
var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript, "old_stag": StagScript}.get(kind, Creature)
|
var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript, "drowned": GuardScript, "old_stag": StagScript,
|
||||||
|
"gull": GullScript, "crab": CrabScript}.get(kind, Creature)
|
||||||
c = script.new()
|
c = script.new()
|
||||||
c.puppet = true
|
c.puppet = true
|
||||||
c.setup(kind, world, e[2], cid)
|
c.setup(kind, world, e[2], cid)
|
||||||
|
|||||||
534
scripts/net/lands.gd
Normal file
534
scripts/net/lands.gd
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
extends Node
|
||||||
|
## Autoload "Lands" (J3, docs/WORLDLETS_DESIGN.md: a server per land).
|
||||||
|
##
|
||||||
|
## A server's world can span several lands (worldlets). The server players
|
||||||
|
## join, on the usual port, runs the world's home land (the Valley) and is the
|
||||||
|
## **hub**. When someone first heads for another land, the hub starts a
|
||||||
|
## **land server** for it: the same game, headless, on the next port (24243
|
||||||
|
## for the Cove, and so on), with `--land cove`. It shares the hub's config
|
||||||
|
## and world folder (worlds/<world>/: that land's state is
|
||||||
|
## worldlets/<land>.json, and characters are in players/, shared by all), so
|
||||||
|
## a character saved by one is loaded by the next. A land server stops a
|
||||||
|
## couple of minutes after its last player leaves, and whenever the hub
|
||||||
|
## stops.
|
||||||
|
##
|
||||||
|
## Hub and land servers talk over a private TCP link on 127.0.0.1 (one JSON
|
||||||
|
## object per line), never seen from outside. J3a carries: the land's
|
||||||
|
## "ready" (listening), its player count, the hub's clock, one-time passes
|
||||||
|
## for players crossing over, and asks about the tunnel.
|
||||||
|
##
|
||||||
|
## A crossing (PlayerNet, server side): the player's character is saved with
|
||||||
|
## where they're heading; the hub (or the land server, back the other way)
|
||||||
|
## makes a one-time pass, makes sure the other server is up, and sends the
|
||||||
|
## player's game `{port, pass, land}`. The game reconnects there with the
|
||||||
|
## pass (Net.join(..., pass)), and that server lets them straight in.
|
||||||
|
|
||||||
|
signal land_ready(land: String) ## hub: a land server is listening
|
||||||
|
signal hub_message(msg: Dictionary) ## land server: something from the hub
|
||||||
|
|
||||||
|
const LINK_PORT_OFFSET := 100 ## the hub's private link: its game port + 100 (24342)
|
||||||
|
const IDLE_STOP_SECONDS := 120.0 ## a land server with nobody on stops after this
|
||||||
|
const PASS_SECONDS := 60.0 ## a pass not used by then is no good
|
||||||
|
|
||||||
|
var hub := false ## this server is the hub
|
||||||
|
var land := "" ## this server is a land server, running this land
|
||||||
|
var hub_port := 0 ## (land server) the hub's game port
|
||||||
|
|
||||||
|
## Hub: the land servers it's running. land -> {"pid", "port", "io", "conn", "players", "ready", "idle"}
|
||||||
|
var servers: Dictionary = {}
|
||||||
|
var _link: TCPServer
|
||||||
|
var _unclaimed: Array = [] ## links not yet saying which land they are
|
||||||
|
## One-time passes, wherever a player is arriving: pass -> {"id", "t", "land"}
|
||||||
|
var passes: Dictionary = {}
|
||||||
|
|
||||||
|
## Land server: the link to the hub.
|
||||||
|
var _hub: StreamPeerTCP
|
||||||
|
var _hub_link: Dictionary = {} ## {"conn": _hub, "buf": the partial line}
|
||||||
|
|
||||||
|
# --- setup (called by Net.start_server) ------------------------------------------------
|
||||||
|
|
||||||
|
## Called once a server is listening. `args` are the command-line flags.
|
||||||
|
func server_started(args: Dictionary) -> void:
|
||||||
|
land = String(args.get("land", ""))
|
||||||
|
roster = []
|
||||||
|
if not Net.players_changed.is_connected(rebuild_roster):
|
||||||
|
Net.players_changed.connect(rebuild_roster)
|
||||||
|
if not land.is_empty():
|
||||||
|
hub_port = int(args.get("hub-port", Net.DEFAULT_PORT))
|
||||||
|
_hub = StreamPeerTCP.new()
|
||||||
|
_hub.connect_to_host("127.0.0.1", hub_port + LINK_PORT_OFFSET)
|
||||||
|
_hub_link = {"conn": _hub, "buf": ""}
|
||||||
|
return
|
||||||
|
hub = true
|
||||||
|
_link = TCPServer.new()
|
||||||
|
var err := _link.listen(game_port() + LINK_PORT_OFFSET, "127.0.0.1")
|
||||||
|
if err != OK:
|
||||||
|
push_warning("[lands] couldn't open the private link on %d (error %d): no other lands this run" % [game_port() + LINK_PORT_OFFSET, err])
|
||||||
|
_link = null
|
||||||
|
elif not _order().is_empty():
|
||||||
|
print("[lands] other lands start on demand. Players over the internet need %s forwarded" % ports_text(home(), game_port()))
|
||||||
|
|
||||||
|
## Called as a server stops: stops every land server it started.
|
||||||
|
func server_stopped() -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "stop"})
|
||||||
|
var pids: Array = servers.values().map(func(s): return int(s.pid))
|
||||||
|
if not pids.is_empty():
|
||||||
|
get_tree().create_timer(6.0).timeout.connect(func():
|
||||||
|
for pid in pids:
|
||||||
|
if pid != 0 and OS.is_process_running(pid):
|
||||||
|
OS.kill(pid))
|
||||||
|
servers.clear()
|
||||||
|
_unclaimed.clear()
|
||||||
|
if _link != null:
|
||||||
|
_link.stop()
|
||||||
|
_link = null
|
||||||
|
if _hub != null:
|
||||||
|
_hub.disconnect_from_host()
|
||||||
|
_hub = null
|
||||||
|
hub = false
|
||||||
|
land = ""
|
||||||
|
passes.clear()
|
||||||
|
|
||||||
|
func game_port() -> int:
|
||||||
|
return int(Net.config.v("port")) if Net.config != null else Net.DEFAULT_PORT
|
||||||
|
|
||||||
|
## The worldlet this server runs.
|
||||||
|
func here() -> String:
|
||||||
|
return land if not land.is_empty() else String(Net.config.v("worldlet")) if Net.config != null else "valley"
|
||||||
|
|
||||||
|
## The port land `l` runs on: the hub's, then one up for each land after it
|
||||||
|
## in the journey's order (data/worldlets: next).
|
||||||
|
func port_for(l: String) -> int:
|
||||||
|
if l == home():
|
||||||
|
return hub_port if not land.is_empty() else game_port()
|
||||||
|
var base := hub_port if not land.is_empty() else game_port()
|
||||||
|
return base + 1 + maxi(_order().find(l), 0)
|
||||||
|
|
||||||
|
## The world's home land (the hub's).
|
||||||
|
func home() -> String:
|
||||||
|
return String(Net.config.v("worldlet")) if Net.config != null else "valley"
|
||||||
|
|
||||||
|
## Every land after home, in the order the journey reaches them.
|
||||||
|
func _order() -> Array:
|
||||||
|
return journey_from(home())
|
||||||
|
|
||||||
|
## The lands a server whose home is `home` and whose port is `base` may run,
|
||||||
|
## after home: [[land, port]] (24243 for the Cove when the server's 24242).
|
||||||
|
## Friends playing over the internet need these forwarded too.
|
||||||
|
static func extra_ports(home_land: String, base: int) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
var i := 1
|
||||||
|
for l in journey_from(home_land):
|
||||||
|
out.append([l, base + i])
|
||||||
|
i += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
## "UDP 24242 and 24243 (the Cove)": what to forward for a server on `base`.
|
||||||
|
static func ports_text(home_land: String, base: int) -> String:
|
||||||
|
var extra := extra_ports(home_land, base)
|
||||||
|
if extra.is_empty():
|
||||||
|
return "UDP %d" % base
|
||||||
|
var T: Script = load("res://scripts/world/terrain.gd")
|
||||||
|
var parts: Array[String] = []
|
||||||
|
for e in extra:
|
||||||
|
parts.append("%d (the %s)" % [int(e[1]), T.worldlet_name(String(e[0]))])
|
||||||
|
return "UDP %d and %s" % [base, ", ".join(parts)]
|
||||||
|
|
||||||
|
static func journey_from(home_land: String) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
var at := home_land
|
||||||
|
for _i in range(16):
|
||||||
|
var d: Dictionary = load("res://scripts/world/terrain.gd").load_worldlet(at)
|
||||||
|
var nxt := ""
|
||||||
|
for c in d.get("next", []):
|
||||||
|
if c is Dictionary and not String(c.get("to", "")) in out and String(c.get("to", "")) != home_land \
|
||||||
|
and not load("res://scripts/world/terrain.gd").load_worldlet(String(c.get("to", ""))).is_empty():
|
||||||
|
nxt = String(c.to)
|
||||||
|
break
|
||||||
|
if nxt.is_empty():
|
||||||
|
break
|
||||||
|
out.append(nxt)
|
||||||
|
at = nxt
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- passes -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## A one-time pass for player `id` to come into land `l`.
|
||||||
|
func make_pass(id: String, l: String) -> String:
|
||||||
|
var p := Crypto.new().generate_random_bytes(16).hex_encode()
|
||||||
|
passes[p] = {"id": id, "t": 0.0, "land": l}
|
||||||
|
return p
|
||||||
|
|
||||||
|
## Whether `p` lets player `id` in here (it's used up either way).
|
||||||
|
func take_pass(p: String, id: String) -> bool:
|
||||||
|
if not passes.has(p):
|
||||||
|
return false
|
||||||
|
var e: Dictionary = passes[p]
|
||||||
|
passes.erase(p)
|
||||||
|
return String(e.id) == id and float(e.t) < PASS_SECONDS
|
||||||
|
|
||||||
|
# --- the hub ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Makes sure land `l` has a server (starting one if not). Calls `then`
|
||||||
|
## (with "" or what went wrong) once it's listening.
|
||||||
|
func ensure(l: String, then: Callable) -> void:
|
||||||
|
if not hub:
|
||||||
|
then.call("this isn't the hub")
|
||||||
|
return
|
||||||
|
if servers.has(l) and int(servers[l].pid) != 0 and not OS.is_process_running(int(servers[l].pid)):
|
||||||
|
print("[lands] the %s's server has gone: starting it again" % l)
|
||||||
|
servers.erase(l) # (crashed, and not noticed yet: start afresh)
|
||||||
|
if servers.has(l) and bool(servers[l].ready):
|
||||||
|
servers[l].idle = 0.0
|
||||||
|
then.call("")
|
||||||
|
return
|
||||||
|
if not servers.has(l):
|
||||||
|
var why := _start(l)
|
||||||
|
if not why.is_empty():
|
||||||
|
then.call(why)
|
||||||
|
return
|
||||||
|
var waiting: Callable
|
||||||
|
waiting = func(which: String):
|
||||||
|
if which == l:
|
||||||
|
land_ready.disconnect(waiting)
|
||||||
|
then.call("")
|
||||||
|
land_ready.connect(waiting)
|
||||||
|
|
||||||
|
func _start(l: String) -> String:
|
||||||
|
var args := PackedStringArray(["--headless"])
|
||||||
|
if not OS.has_feature("template"):
|
||||||
|
args.append_array(["--path", ProjectSettings.globalize_path("res://")])
|
||||||
|
args.append_array(["--", "--server", "--land", l, "--port", str(port_for(l)), "--hub-port", str(game_port()),
|
||||||
|
"--config", ProjectSettings.globalize_path(Net.config.path), "--exit-with", str(OS.get_process_id())])
|
||||||
|
for k in ["no-creatures", "debug-cheats"]:
|
||||||
|
if Net._args.has(k):
|
||||||
|
args.append("--" + k)
|
||||||
|
if Net._args.has("host-id"):
|
||||||
|
args.append_array(["--host-id", String(Net._args["host-id"])]) # (a hosted game: the host leaving stops it all)
|
||||||
|
var p: Dictionary = OS.execute_with_pipe(OS.get_executable_path(), args, false)
|
||||||
|
if p.is_empty():
|
||||||
|
return "couldn't start the %s's server" % l
|
||||||
|
var log := FileAccess.open(Net.config.path.get_base_dir().path_join("land-%s.log" % l), FileAccess.WRITE)
|
||||||
|
servers[l] = {"pid": int(p.pid), "port": port_for(l), "io": p.stdio, "log": log, "conn": null,
|
||||||
|
"players": 0, "ready": false, "idle": 0.0}
|
||||||
|
print("[lands] starting the %s's server on UDP %d" % [l, port_for(l)])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
for p in passes.keys():
|
||||||
|
passes[p].t += delta
|
||||||
|
if float(passes[p].t) > PASS_SECONDS:
|
||||||
|
passes.erase(p)
|
||||||
|
if hub:
|
||||||
|
_hub_tick(delta)
|
||||||
|
elif not land.is_empty():
|
||||||
|
_land_tick()
|
||||||
|
|
||||||
|
func _hub_tick(delta: float) -> void:
|
||||||
|
if _link != null:
|
||||||
|
while _link.is_connection_available():
|
||||||
|
var c := _link.take_connection()
|
||||||
|
_unclaimed.append({"conn": c, "buf": ""})
|
||||||
|
for u in _unclaimed.duplicate():
|
||||||
|
var claimed := ""
|
||||||
|
for msg in _read(u):
|
||||||
|
if claimed.is_empty():
|
||||||
|
if String(msg.get("t", "")) == "hello" and servers.has(String(msg.land)):
|
||||||
|
claimed = String(msg.land)
|
||||||
|
servers[claimed].conn = u.conn
|
||||||
|
_unclaimed.erase(u)
|
||||||
|
else:
|
||||||
|
_from_land(claimed, msg) # (what came in the same read: "ready", most likely)
|
||||||
|
if not claimed.is_empty():
|
||||||
|
servers[claimed].buf = u.buf
|
||||||
|
for l in servers.keys():
|
||||||
|
var s: Dictionary = servers[l]
|
||||||
|
_drain_output(s)
|
||||||
|
if int(s.pid) != 0 and not OS.is_process_running(int(s.pid)):
|
||||||
|
print("[lands] the %s's server stopped" % l)
|
||||||
|
_drain_output(s)
|
||||||
|
servers.erase(l)
|
||||||
|
rebuild_roster()
|
||||||
|
PlayerNet.beds_changed()
|
||||||
|
continue
|
||||||
|
if s.conn != null:
|
||||||
|
for msg in _read(s):
|
||||||
|
_from_land(l, msg)
|
||||||
|
s.idle = float(s.idle) + delta if int(s.players) == 0 and bool(s.ready) else 0.0
|
||||||
|
if float(s.idle) > IDLE_STOP_SECONDS:
|
||||||
|
print("[lands] nobody in the %s for %d s: stopping its server" % [l, int(IDLE_STOP_SECONDS)])
|
||||||
|
_send_land(l, {"t": "stop"})
|
||||||
|
s.idle = -INF # (asked once)
|
||||||
|
|
||||||
|
## A land server's output goes to land-<land>.log beside server.json.
|
||||||
|
func _drain_output(s: Dictionary) -> void:
|
||||||
|
if s.io == null:
|
||||||
|
return
|
||||||
|
for _i in range(16):
|
||||||
|
var chunk: PackedByteArray = s.io.get_buffer(8192)
|
||||||
|
if chunk.is_empty():
|
||||||
|
break
|
||||||
|
if s.log != null:
|
||||||
|
s.log.store_string(chunk.get_string_from_utf8())
|
||||||
|
s.log.flush()
|
||||||
|
|
||||||
|
func _from_land(l: String, msg: Dictionary) -> void:
|
||||||
|
match String(msg.get("t", "")):
|
||||||
|
"ready":
|
||||||
|
servers[l].ready = true
|
||||||
|
_send_land(l, {"t": "clock", "day": Net._world.day_night.day, "time": Net._world.day_night.time})
|
||||||
|
_send_land(l, {"t": "tunnel", "dug": _tunnel_dug_here()})
|
||||||
|
land_ready.emit(l)
|
||||||
|
"players":
|
||||||
|
servers[l].players = int(msg.n)
|
||||||
|
"pass":
|
||||||
|
# A player there is coming back here: let them in with this pass.
|
||||||
|
passes[String(msg.pass)] = {"id": String(msg.id), "t": 0.0, "land": home()}
|
||||||
|
"tunnel?":
|
||||||
|
_send_land(l, {"t": "tunnel", "dug": _tunnel_dug_here()})
|
||||||
|
"host_left":
|
||||||
|
Net._host_left.call_deferred()
|
||||||
|
"roster":
|
||||||
|
servers[l].list = msg.get("list", [])
|
||||||
|
rebuild_roster()
|
||||||
|
"line":
|
||||||
|
# Chat from that land: everyone here, and every other land.
|
||||||
|
Chat._all(String(msg.text), String(msg.kind), false)
|
||||||
|
for other in servers:
|
||||||
|
if other != l:
|
||||||
|
_send_land(other, msg)
|
||||||
|
"cmd":
|
||||||
|
Chat.run_remote(String(msg.line), {"id": String(msg.id), "name": String(msg.name), "land": l, "peer": int(msg.peer)})
|
||||||
|
"beds":
|
||||||
|
servers[l].beds = [int(msg.alive), int(msg.asleep)]
|
||||||
|
# Tell the lands the running total (minus their own), so each can
|
||||||
|
# say "N of M asleep", then see if the night passes.
|
||||||
|
_push_bed_totals()
|
||||||
|
PlayerNet.beds_changed()
|
||||||
|
|
||||||
|
## Sends `msg` to land `l`'s server.
|
||||||
|
func _send_land(l: String, msg: Dictionary) -> void:
|
||||||
|
if not servers.has(l) or servers[l].conn == null:
|
||||||
|
return
|
||||||
|
var c: StreamPeerTCP = servers[l].conn
|
||||||
|
c.poll()
|
||||||
|
if c.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
c.put_data((JSON.stringify(msg) + "\n").to_utf8_buffer())
|
||||||
|
|
||||||
|
## Hub: tells every land how far the cliff tunnel's dug (the Cove's sea
|
||||||
|
## cave leads up it once it's through).
|
||||||
|
func push_tunnel(dug: int) -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "tunnel", "dug": dug})
|
||||||
|
|
||||||
|
func _tunnel_dug_here() -> int:
|
||||||
|
var t: Node3D = Net._world.tunnel if Net._world != null else null
|
||||||
|
return int(t.dug) if t != null else 0
|
||||||
|
|
||||||
|
## Hub: tells every land the clock (so there's one clock everywhere).
|
||||||
|
func push_clock(day: int, time: float) -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "clock", "day": day, "time": time})
|
||||||
|
|
||||||
|
## Hub: a pass for player `id` to go to land `l` (the land server learns it).
|
||||||
|
func pass_to(l: String, id: String) -> String:
|
||||||
|
var p := make_pass(id, l)
|
||||||
|
_send_land(l, {"t": "pass", "pass": p, "id": id})
|
||||||
|
passes.erase(p) # (it's the land server's to take, not ours)
|
||||||
|
return p
|
||||||
|
|
||||||
|
# --- a land server --------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _said_hello := false
|
||||||
|
var _last_players := -1
|
||||||
|
var tunnel_dug := -1 ## (land server) how far the hub's tunnel is dug, last asked
|
||||||
|
|
||||||
|
func _land_tick() -> void:
|
||||||
|
if _hub == null:
|
||||||
|
return
|
||||||
|
_hub.poll()
|
||||||
|
var st := _hub.get_status()
|
||||||
|
if st == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
if not _said_hello:
|
||||||
|
_said_hello = true
|
||||||
|
send_hub({"t": "hello", "land": land})
|
||||||
|
send_hub({"t": "ready"})
|
||||||
|
var n := Net.players.size()
|
||||||
|
if n != _last_players or _roster_dirty:
|
||||||
|
_last_players = n
|
||||||
|
_roster_dirty = false
|
||||||
|
send_hub({"t": "players", "n": n})
|
||||||
|
send_hub({"t": "roster", "list": _here_list()})
|
||||||
|
for msg in _read(_hub_link):
|
||||||
|
_from_hub(msg)
|
||||||
|
elif st == StreamPeerTCP.STATUS_ERROR or (st == StreamPeerTCP.STATUS_NONE and _said_hello):
|
||||||
|
print("[lands] lost the hub: saving and stopping")
|
||||||
|
_hub = null
|
||||||
|
Net.stop()
|
||||||
|
get_tree().quit()
|
||||||
|
|
||||||
|
func _from_hub(msg: Dictionary) -> void:
|
||||||
|
match String(msg.get("t", "")):
|
||||||
|
"stop":
|
||||||
|
print("[lands] the hub asked us to stop: saving")
|
||||||
|
_hub = null
|
||||||
|
Net.stop()
|
||||||
|
get_tree().quit()
|
||||||
|
"pass":
|
||||||
|
passes[String(msg.pass)] = {"id": String(msg.id), "t": 0.0, "land": land}
|
||||||
|
"clock":
|
||||||
|
if Net._world != null:
|
||||||
|
var dn: Node = Net._world.day_night
|
||||||
|
var was_day: int = dn.day
|
||||||
|
var was_t: float = dn.time
|
||||||
|
var secs: float = ((int(msg.day) - was_day) + (float(msg.time) - was_t)) * dn.day_length
|
||||||
|
if secs > 1.0:
|
||||||
|
Net._world.skip_time(secs) # (catching up: a slept night, or time nobody was here)
|
||||||
|
dn.day = int(msg.day)
|
||||||
|
dn.time = float(msg.time)
|
||||||
|
dn.apply(true)
|
||||||
|
Net.push_clock()
|
||||||
|
"tunnel":
|
||||||
|
tunnel_dug = int(msg.dug)
|
||||||
|
"roster":
|
||||||
|
roster = msg.get("list", [])
|
||||||
|
Net.push_roster()
|
||||||
|
"line":
|
||||||
|
Chat._all(String(msg.text), String(msg.kind), false)
|
||||||
|
"reply":
|
||||||
|
if Net.players.has(int(msg.peer)):
|
||||||
|
Chat._s2c_line.rpc_id(int(msg.peer), String(msg.text), "reply")
|
||||||
|
"kick":
|
||||||
|
var kp: int = Net.peer_by_name(String(msg.name))
|
||||||
|
if kp != 0:
|
||||||
|
Net.kick(kp, String(msg.reason))
|
||||||
|
"config":
|
||||||
|
Net.reload_config()
|
||||||
|
Net.broadcast_rules()
|
||||||
|
"weather":
|
||||||
|
Net.set_weather(String(msg.state))
|
||||||
|
"save":
|
||||||
|
WorldNet.save()
|
||||||
|
"night":
|
||||||
|
PlayerNet.pass_night_here(float(msg.secs))
|
||||||
|
"beds_total":
|
||||||
|
PlayerNet._hub_beds = [int(msg.alive), int(msg.asleep)]
|
||||||
|
hub_message.emit(msg)
|
||||||
|
|
||||||
|
func send_hub(msg: Dictionary) -> void:
|
||||||
|
if _hub != null and _hub.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
_hub.put_data((JSON.stringify(msg) + "\n").to_utf8_buffer())
|
||||||
|
|
||||||
|
# --- everyone, everywhere (J3b) ------------------------------------------------------------
|
||||||
|
|
||||||
|
## Everyone on every land: [{"name", "id", "land", "admin"}]. The hub builds
|
||||||
|
## it from its own players and what each land reports; every land and every
|
||||||
|
## player's game gets a copy (Net.roster), for the player list and /players.
|
||||||
|
var roster: Array = []
|
||||||
|
var _roster_dirty := false
|
||||||
|
|
||||||
|
## The players on this server, as the roster lists them.
|
||||||
|
func _here_list() -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
for p in Net.players.values():
|
||||||
|
out.append({"name": String(p.name), "id": String(p.id), "land": here(), "admin": bool(p.get("admin", false))})
|
||||||
|
return out
|
||||||
|
|
||||||
|
## Hub: the roster again (its players changed, or a land's did), sent out.
|
||||||
|
func rebuild_roster() -> void:
|
||||||
|
if not hub:
|
||||||
|
if not land.is_empty():
|
||||||
|
_roster_dirty = true # (a land server: tell the hub, next tick)
|
||||||
|
return
|
||||||
|
var out: Array = _here_list()
|
||||||
|
for l in servers:
|
||||||
|
for e in servers[l].get("list", []):
|
||||||
|
var e2: Dictionary = (e as Dictionary).duplicate()
|
||||||
|
e2.land = l
|
||||||
|
out.append(e2)
|
||||||
|
roster = out
|
||||||
|
Net.push_roster()
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "roster", "list": out})
|
||||||
|
|
||||||
|
## The land a player called `n` is in ("" = not on anywhere).
|
||||||
|
func land_of_name(n: String) -> String:
|
||||||
|
for e in roster:
|
||||||
|
if String(e.name).to_lower() == n.to_lower():
|
||||||
|
return String(e.land)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
## Chat or a server message, from here to every other land.
|
||||||
|
func relay_line(text: String, kind: String) -> void:
|
||||||
|
if hub:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, {"t": "line", "text": text, "kind": kind})
|
||||||
|
elif not land.is_empty():
|
||||||
|
send_hub({"t": "line", "text": text, "kind": kind})
|
||||||
|
|
||||||
|
## Hub: sends `msg` to every land.
|
||||||
|
func tell_lands(msg: Dictionary) -> void:
|
||||||
|
for l in servers:
|
||||||
|
_send_land(l, msg)
|
||||||
|
|
||||||
|
## Hub: sends `msg` to land `l`.
|
||||||
|
func tell_land(l: String, msg: Dictionary) -> void:
|
||||||
|
_send_land(l, msg)
|
||||||
|
|
||||||
|
## Hub: tells each land the alive/asleep everywhere BUT that land, so a land
|
||||||
|
## server can show the right "N of M" and know when all are abed.
|
||||||
|
func _push_bed_totals() -> void:
|
||||||
|
var here_alive: int = PlayerNet._alive().size()
|
||||||
|
var here_asleep: int = PlayerNet._alive().filter(func(q): return q.in_bed).size()
|
||||||
|
for l in servers:
|
||||||
|
var a := here_alive
|
||||||
|
var s := here_asleep
|
||||||
|
for other in servers:
|
||||||
|
if other != l:
|
||||||
|
var b: Array = servers[other].get("beds", [0, 0])
|
||||||
|
a += int(b[0])
|
||||||
|
s += int(b[1])
|
||||||
|
_send_land(l, {"t": "beds_total", "alive": a, "asleep": s})
|
||||||
|
|
||||||
|
## Hub: how many are alive, and how many asleep, in every other land.
|
||||||
|
func beds_elsewhere() -> Array:
|
||||||
|
var alive := 0
|
||||||
|
var asleep := 0
|
||||||
|
for l in servers:
|
||||||
|
var b: Array = servers[l].get("beds", [0, 0])
|
||||||
|
alive += int(b[0])
|
||||||
|
asleep += int(b[1])
|
||||||
|
return [alive, asleep]
|
||||||
|
|
||||||
|
## Land server: a pass for player `id` to go back to the hub.
|
||||||
|
func pass_home(id: String) -> String:
|
||||||
|
var p := Crypto.new().generate_random_bytes(16).hex_encode()
|
||||||
|
send_hub({"t": "pass", "pass": p, "id": id})
|
||||||
|
return p
|
||||||
|
|
||||||
|
# --- the line protocol ------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Whole JSON lines waiting on `link` ({"conn", "buf"}); keeps the rest.
|
||||||
|
func _read(link: Dictionary) -> Array:
|
||||||
|
var c: StreamPeerTCP = link.conn
|
||||||
|
if c == null:
|
||||||
|
return []
|
||||||
|
c.poll()
|
||||||
|
if c.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
return []
|
||||||
|
var n := c.get_available_bytes()
|
||||||
|
if n > 0:
|
||||||
|
var got: Array = c.get_data(n)
|
||||||
|
if int(got[0]) == OK:
|
||||||
|
link.buf = String(link.get("buf", "")) + (got[1] as PackedByteArray).get_string_from_utf8()
|
||||||
|
var out: Array = []
|
||||||
|
var buf := String(link.get("buf", ""))
|
||||||
|
while buf.contains("\n"):
|
||||||
|
var line := buf.get_slice("\n", 0)
|
||||||
|
buf = buf.substr(line.length() + 1)
|
||||||
|
var m: Variant = JSON.parse_string(line)
|
||||||
|
if m is Dictionary:
|
||||||
|
out.append(m)
|
||||||
|
link.buf = buf
|
||||||
|
return out
|
||||||
1
scripts/net/lands.gd.uid
Normal file
1
scripts/net/lands.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cl2iw0a328ja8
|
||||||
@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
|
|||||||
const Identity = preload("res://scripts/net/identity.gd")
|
const Identity = preload("res://scripts/net/identity.gd")
|
||||||
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
||||||
|
|
||||||
const PROTOCOL := 16 ## 16: worldlets (the Cove), sea fish (mackerel, cod); 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand)
|
const PROTOCOL := 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
|
||||||
@@ -414,7 +426,9 @@ func _hosted_by(id: String) -> bool:
|
|||||||
|
|
||||||
func _host_left() -> void:
|
func _host_left() -> void:
|
||||||
print("[server] the host left: saving and stopping")
|
print("[server] the host left: saving and stopping")
|
||||||
stop() # (saves the world and everyone's characters)
|
if not Lands.land.is_empty():
|
||||||
|
Lands.send_hub({"t": "host_left"}) # (they left from another land: the hub stops too)
|
||||||
|
stop() # (saves the world and everyone's characters, and stops the other lands)
|
||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
|
|
||||||
# --- admin (chat.gd) ---------------------------------------------------------------
|
# --- admin (chat.gd) ---------------------------------------------------------------
|
||||||
@@ -456,11 +470,94 @@ func _s2c_weather(state: String) -> void:
|
|||||||
|
|
||||||
signal weather_changed(state: String)
|
signal weather_changed(state: String)
|
||||||
|
|
||||||
|
# --- crossing to another land (J3, lands.gd) ------------------------------------------
|
||||||
|
|
||||||
|
signal travelling(land: String) ## client: on our way to another land's server
|
||||||
|
signal arrived(land: String) ## client: in, at the other land (the world reloads)
|
||||||
|
|
||||||
|
var _travel: Dictionary = {} ## client: {"land", "address", "pass"} while crossing
|
||||||
|
var _hub_address := "" ## client: the address we joined (the hub's), for the host part
|
||||||
|
|
||||||
|
## Server: sends player `peer`'s game on to land `l` (its server's port, and
|
||||||
|
## the pass that lets them in), then lets them go.
|
||||||
|
func send_travel(peer: int, l: String, pass_: String) -> void:
|
||||||
|
_s2c_travel.rpc_id(peer, {"land": l, "port": Lands.port_for(l), "pass": pass_})
|
||||||
|
# (Their game goes on its own; this only tidies up one that doesn't.)
|
||||||
|
get_tree().create_timer(3.0).timeout.connect(func():
|
||||||
|
if _peer != null and is_server() and (players.has(peer) or _pending.has(peer)):
|
||||||
|
_peer.disconnect_peer(peer))
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_travel(d: Dictionary) -> void:
|
||||||
|
var host := String(parse_address(_hub_address if not _hub_address.is_empty() else get_identity().last_server)[0])
|
||||||
|
if host.is_empty():
|
||||||
|
host = "127.0.0.1"
|
||||||
|
_travel = {"land": String(d.land), "address": "%s:%d" % [host, int(d.port)], "pass": String(d.pass)}
|
||||||
|
print("[net] on to the %s (%s)" % [d.land, _travel.address])
|
||||||
|
travelling.emit(String(d.land))
|
||||||
|
var pw := _pending_password
|
||||||
|
_quiet_stop()
|
||||||
|
join(String(_travel.address), pw, String(_travel.pass))
|
||||||
|
|
||||||
|
## Closes the connection without telling anyone we left (a crossing).
|
||||||
|
func _quiet_stop() -> void:
|
||||||
|
if _peer != null:
|
||||||
|
_peer.close()
|
||||||
|
_peer = null
|
||||||
|
multiplayer.multiplayer_peer = null
|
||||||
|
players.clear()
|
||||||
|
_states.clear()
|
||||||
|
_out_q.clear()
|
||||||
|
_in_q.clear()
|
||||||
|
local_peer = 0
|
||||||
|
_set_state("offline")
|
||||||
|
|
||||||
|
func is_travelling() -> bool:
|
||||||
|
return not _travel.is_empty()
|
||||||
|
|
||||||
|
# --- everyone on every land (J3b, lands.gd) -------------------------------------------
|
||||||
|
|
||||||
|
## Client: everyone on the server, whichever land they're in: [{"name",
|
||||||
|
## "id", "land", "admin"}] (the hub's roster). Empty on a server with only
|
||||||
|
## one land running: then `players` is everyone.
|
||||||
|
var roster: Array = []
|
||||||
|
|
||||||
|
## Server: everyone here gets the roster.
|
||||||
|
func push_roster() -> void:
|
||||||
|
for peer in players:
|
||||||
|
_s2c_roster.rpc_id(peer, Lands.roster)
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_roster(list: Array) -> void:
|
||||||
|
roster = list
|
||||||
|
players_changed.emit()
|
||||||
|
|
||||||
|
## Everyone on the server to show in a list: the roster, or (one land) just
|
||||||
|
## those here. [{"name", "admin", "land"}]
|
||||||
|
func everyone() -> Array:
|
||||||
|
if not roster.is_empty():
|
||||||
|
return roster
|
||||||
|
var out: Array = []
|
||||||
|
for p in players.values():
|
||||||
|
out.append({"name": String(p.name), "admin": bool(p.get("admin", false)), "land": String(world_info.get("worldlet", ""))})
|
||||||
|
return out
|
||||||
|
|
||||||
|
## Server: server.json read again (the hub changed it: a /set, a ban, a
|
||||||
|
## new admin, a friend), keeping this run's command-line overrides.
|
||||||
|
func reload_config() -> void:
|
||||||
|
if config == null:
|
||||||
|
return
|
||||||
|
var ov: Dictionary = config.overrides
|
||||||
|
config = ServerConfig.open(config.path)
|
||||||
|
config.overrides = ov
|
||||||
|
|
||||||
## Everyone gets the clock now (after a night's sleep, an admin's /time).
|
## Everyone gets the clock now (after a night's sleep, an admin's /time).
|
||||||
func push_clock() -> void:
|
func push_clock() -> void:
|
||||||
_clock_left = CLOCK_INTERVAL
|
_clock_left = CLOCK_INTERVAL
|
||||||
for peer in players:
|
for peer in players:
|
||||||
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
||||||
|
if Lands.hub:
|
||||||
|
Lands.push_clock(_world.day_night.day, _world.day_night.time)
|
||||||
|
|
||||||
## Where a player is, as far as the server knows.
|
## Where a player is, as far as the server knows.
|
||||||
func player_pos(peer: int) -> Vector3:
|
func player_pos(peer: int) -> Vector3:
|
||||||
@@ -541,6 +638,12 @@ func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> St
|
|||||||
return "too fast (%.0f m in %.2f s)" % [flat, dt]
|
return "too fast (%.0f m in %.2f s)" % [flat, dt]
|
||||||
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
|
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
|
||||||
return "rising too fast"
|
return "rising too fast"
|
||||||
|
# (Down the cliff tunnel, J1: its own space under the island, where its
|
||||||
|
# rock is the ground. The way in and out is a server teleport.)
|
||||||
|
if _world != null and _world.underground_at(to):
|
||||||
|
return "" if teleported or _world.underground_at(from) else "into the tunnel without going in"
|
||||||
|
if _world != null and _world.underground_at(from):
|
||||||
|
return "out of the tunnel without climbing out"
|
||||||
if not _terrain.inside_boundary(to.x, to.z, -3.0):
|
if not _terrain.inside_boundary(to.x, to.z, -3.0):
|
||||||
return "outside the island"
|
return "outside the island"
|
||||||
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
|
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
|
||||||
@@ -594,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)
|
||||||
@@ -619,6 +724,23 @@ func _c2s_proof(sig: PackedByteArray) -> void:
|
|||||||
if not why.is_empty():
|
if not why.is_empty():
|
||||||
_reject(peer, why)
|
_reject(peer, why)
|
||||||
return
|
return
|
||||||
|
# Lands (J3): a land server only lets in someone crossing with a pass the
|
||||||
|
# hub gave them; a pass anywhere must be theirs and fresh. And on the
|
||||||
|
# hub, a character who's in another land is sent straight on there.
|
||||||
|
var pass_ := String(hello.get("pass", ""))
|
||||||
|
if not pass_.is_empty():
|
||||||
|
if not Lands.take_pass(pass_, id):
|
||||||
|
_reject(peer, "That way in has expired: join the server again")
|
||||||
|
return
|
||||||
|
elif not Lands.land.is_empty():
|
||||||
|
_reject(peer, "Join this server's main address (this is one of its other lands)")
|
||||||
|
return
|
||||||
|
elif Lands.hub:
|
||||||
|
var away: String = PlayerNet.land_of(id)
|
||||||
|
if not away.is_empty() and away != Lands.home():
|
||||||
|
_pending.erase(peer)
|
||||||
|
PlayerNet.send_on(peer, id, away)
|
||||||
|
return
|
||||||
_pending.erase(peer)
|
_pending.erase(peer)
|
||||||
config.reserve_name(name, id)
|
config.reserve_name(name, id)
|
||||||
if config.data.admins.is_empty():
|
if config.data.admins.is_empty():
|
||||||
@@ -641,7 +763,8 @@ func _c2s_proof(sig: PackedByteArray) -> void:
|
|||||||
# (The world's own seed: an existing world keeps the seed it was made with.)
|
# (The world's own seed: an existing world keeps the seed it was made with.)
|
||||||
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(_terrain.worldlet_id), "spawn": start, "yaw": yaw,
|
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(_terrain.worldlet_id), "spawn": start, "yaw": yaw,
|
||||||
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
|
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
|
||||||
"weather": _world.weather.override}
|
"weather": _world.weather.override,
|
||||||
|
"tunnel_dug": Lands.tunnel_dug if not Lands.land.is_empty() else Lands._tunnel_dug_here()}
|
||||||
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
||||||
for other in players:
|
for other in players:
|
||||||
if other != peer:
|
if other != peer:
|
||||||
@@ -683,7 +806,12 @@ static func parse_address(a: String) -> Array:
|
|||||||
return [a, DEFAULT_PORT]
|
return [a, DEFAULT_PORT]
|
||||||
|
|
||||||
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
|
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
|
||||||
func join(address: String, password: String = "") -> void:
|
## `pass_` is a one-time pass from a crossing to another land (J3).
|
||||||
|
func join(address: String, password: String = "", pass_: String = "") -> void:
|
||||||
|
_pending_pass = pass_
|
||||||
|
if pass_.is_empty():
|
||||||
|
_hub_address = address.strip_edges()
|
||||||
|
_travel = {}
|
||||||
if state != "offline":
|
if state != "offline":
|
||||||
stop()
|
stop()
|
||||||
var me := get_identity()
|
var me := get_identity()
|
||||||
@@ -705,11 +833,13 @@ func join(address: String, password: String = "") -> void:
|
|||||||
_reject_reason = ""
|
_reject_reason = ""
|
||||||
_pending_password = password
|
_pending_password = password
|
||||||
_join_left = JOIN_TIMEOUT
|
_join_left = JOIN_TIMEOUT
|
||||||
me.last_server = address.strip_edges()
|
if pass_.is_empty():
|
||||||
|
me.last_server = address.strip_edges() # (always the hub's: rejoining sends you on)
|
||||||
me.save_profile()
|
me.save_profile()
|
||||||
_set_state("connecting")
|
_set_state("connecting")
|
||||||
|
|
||||||
var _pending_password := ""
|
var _pending_password := ""
|
||||||
|
var _pending_pass := ""
|
||||||
|
|
||||||
func leave() -> void:
|
func leave() -> void:
|
||||||
var was := state
|
var was := state
|
||||||
@@ -722,7 +852,7 @@ func leave() -> void:
|
|||||||
func _on_connected() -> void:
|
func _on_connected() -> void:
|
||||||
var me := get_identity()
|
var me := get_identity()
|
||||||
_c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name,
|
_c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name,
|
||||||
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password})
|
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password, "pass": _pending_pass})
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_challenge(nonce: PackedByteArray) -> void:
|
func _s2c_challenge(nonce: PackedByteArray) -> void:
|
||||||
@@ -745,6 +875,10 @@ func _s2c_welcome(d: Dictionary) -> void:
|
|||||||
_set_state("connected")
|
_set_state("connected")
|
||||||
joined.emit()
|
joined.emit()
|
||||||
players_changed.emit()
|
players_changed.emit()
|
||||||
|
if not _travel.is_empty():
|
||||||
|
var l := String(_travel.land)
|
||||||
|
_travel = {}
|
||||||
|
arrived.emit(l)
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_rejected(reason: String) -> void:
|
func _s2c_rejected(reason: String) -> void:
|
||||||
@@ -807,10 +941,17 @@ func _run_queue(q: Array) -> void:
|
|||||||
f.call()
|
f.call()
|
||||||
|
|
||||||
func _fail(reason: String) -> void:
|
func _fail(reason: String) -> void:
|
||||||
|
var crossing := not _travel.is_empty()
|
||||||
|
_travel = {}
|
||||||
|
if not crossing:
|
||||||
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
|
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
|
||||||
stop()
|
stop()
|
||||||
last_reason = reason
|
last_reason = reason
|
||||||
join_failed.emit(reason)
|
join_failed.emit(reason)
|
||||||
|
if crossing:
|
||||||
|
# (The world we were in is still on screen: it goes back to the title.)
|
||||||
|
last_reason = "Couldn't get through to the next land (%s). Join again to carry on." % reason
|
||||||
|
left.emit(last_reason)
|
||||||
|
|
||||||
func _on_connection_failed() -> void:
|
func _on_connection_failed() -> void:
|
||||||
_fail("Couldn't reach the server")
|
_fail("Couldn't reach the server")
|
||||||
@@ -818,6 +959,11 @@ func _on_connection_failed() -> void:
|
|||||||
func _on_server_disconnected() -> void:
|
func _on_server_disconnected() -> void:
|
||||||
var was := state
|
var was := state
|
||||||
var reason := _reject_reason
|
var reason := _reject_reason
|
||||||
|
if not _travel.is_empty():
|
||||||
|
if was == "connecting":
|
||||||
|
_fail(reason if not reason.is_empty() else "the server hung up") # (the next land wouldn't have us)
|
||||||
|
return # (the land we're leaving letting us go)
|
||||||
|
var land_name := on_land()
|
||||||
stop_hosting()
|
stop_hosting()
|
||||||
stop()
|
stop()
|
||||||
if was == "connecting":
|
if was == "connecting":
|
||||||
@@ -825,8 +971,19 @@ func _on_server_disconnected() -> void:
|
|||||||
join_failed.emit(last_reason)
|
join_failed.emit(last_reason)
|
||||||
else:
|
else:
|
||||||
last_reason = reason if not reason.is_empty() else "The server closed the connection"
|
last_reason = reason if not reason.is_empty() else "The server closed the connection"
|
||||||
|
if reason.is_empty() and not land_name.is_empty():
|
||||||
|
# (Another land's server went: the rest of the server's still up,
|
||||||
|
# and joining again starts that land's server afresh.)
|
||||||
|
last_reason = "Lost the connection to the %s. Join the server again to carry on there." % \
|
||||||
|
preload("res://scripts/world/terrain.gd").worldlet_name(land_name)
|
||||||
left.emit(last_reason)
|
left.emit(last_reason)
|
||||||
|
|
||||||
|
## Client: the land we're in when it's not the server's home land (we're on
|
||||||
|
## one of its other lands' servers), else "".
|
||||||
|
func on_land() -> String:
|
||||||
|
var here := String(world_info.get("worldlet", ""))
|
||||||
|
return here if not here.is_empty() and here != String(server_info.get("worldlet", here)) else ""
|
||||||
|
|
||||||
func _notification(what: int) -> void:
|
func _notification(what: int) -> void:
|
||||||
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
|
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
|
||||||
if _peer != null:
|
if _peer != null:
|
||||||
|
|||||||
@@ -79,11 +79,20 @@ func server_join(peer: int, id: String, display: String) -> Vector3:
|
|||||||
p.load_data(d)
|
p.load_data(d)
|
||||||
var t: Node3D = WorldNet.world.terrain
|
var t: Node3D = WorldNet.world.terrain
|
||||||
var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF
|
var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF
|
||||||
if pos.is_finite() and t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0:
|
var here_ := String(d.get("land", Lands.here())) == Lands.here()
|
||||||
|
if here_ and d.has("arrive"):
|
||||||
|
# Just crossed over: where the way in comes out (J3).
|
||||||
|
pos = WorldNet.world.arrival_point(String(d.arrive))
|
||||||
|
elif not here_:
|
||||||
|
pos = Vector3.INF # (saved somewhere else: start at this land's start)
|
||||||
|
var ok_spot: bool = pos.is_finite() and (WorldNet.world.underground_at(pos) \
|
||||||
|
or (t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0))
|
||||||
|
if ok_spot:
|
||||||
at = pos
|
at = pos
|
||||||
print("[server] %s's character loaded" % display)
|
print("[server] %s's character loaded" % display)
|
||||||
if at != Vector3.INF:
|
if at != Vector3.INF:
|
||||||
p.global_position = at
|
p.global_position = at
|
||||||
|
beds_changed.call_deferred() # (one more awake: everyone's count changes)
|
||||||
return at
|
return at
|
||||||
|
|
||||||
func place_at(peer: int, pos: Vector3) -> void:
|
func place_at(peer: int, pos: Vector3) -> void:
|
||||||
@@ -127,10 +136,54 @@ func _save(p: Node3D) -> void:
|
|||||||
if f == null:
|
if f == null:
|
||||||
printerr("[server] can't save %s's character" % p.display)
|
printerr("[server] can't save %s's character" % p.display)
|
||||||
return
|
return
|
||||||
f.store_string(JSON.stringify(p.to_data(), "\t"))
|
var d: Dictionary = p.to_data()
|
||||||
|
# Which land the character's in (J3: characters are shared by every land
|
||||||
|
# of the world); heading somewhere, it's there they'll turn up next.
|
||||||
|
d["land"] = String(p.get_meta("heading", Lands.here()))
|
||||||
|
if p.has_meta("arrive"):
|
||||||
|
d["arrive"] = String(p.get_meta("arrive"))
|
||||||
|
f.store_string(JSON.stringify(d, "\t"))
|
||||||
f.close()
|
f.close()
|
||||||
DirAccess.rename_absolute(tmp, path)
|
DirAccess.rename_absolute(tmp, path)
|
||||||
|
|
||||||
|
## Which land player `id`'s character is in ("" = not saved yet, or here).
|
||||||
|
func land_of(id: String) -> String:
|
||||||
|
return String(_read(_file(id)).get("land", ""))
|
||||||
|
|
||||||
|
## The hub: player `id` (at `peer`, not let in yet) left their character in
|
||||||
|
## land `l`: start its server if need be and send them on there.
|
||||||
|
func send_on(peer: int, id: String, l: String) -> void:
|
||||||
|
print("[server] %s's character is in the %s: sending them on" % [id.substr(0, 8), l])
|
||||||
|
Lands.ensure(l, func(why: String):
|
||||||
|
if not why.is_empty():
|
||||||
|
Net._reject(peer, "The %s isn't open right now (%s)" % [l, why])
|
||||||
|
return
|
||||||
|
Net.send_travel(peer, l, Lands.pass_to(l, id)))
|
||||||
|
|
||||||
|
## A crossing, on the server (the tunnel's sea cave, the cave back up): the
|
||||||
|
## character is saved as heading for `to`, arriving at `arrive`, and their
|
||||||
|
## game is sent on (to a land server, or back to the hub).
|
||||||
|
func cross(peer: int, to: String, arrive: String) -> void:
|
||||||
|
var p: Node3D = proxies.get(peer)
|
||||||
|
if p == null or p.has_meta("heading"):
|
||||||
|
return
|
||||||
|
p.set_meta("heading", to)
|
||||||
|
p.set_meta("arrive", arrive)
|
||||||
|
_save(p)
|
||||||
|
var id: String = p.player_id
|
||||||
|
print("[server] %s crosses to the %s" % [p.display, to])
|
||||||
|
if to == Lands.home() and not Lands.land.is_empty():
|
||||||
|
Net.send_travel(peer, to, Lands.pass_home(id))
|
||||||
|
return
|
||||||
|
Lands.ensure(to, func(why: String):
|
||||||
|
if not why.is_empty():
|
||||||
|
p.remove_meta("heading")
|
||||||
|
p.remove_meta("arrive")
|
||||||
|
_save(p)
|
||||||
|
tell(peer, "The way's blocked for now (%s)" % why)
|
||||||
|
return
|
||||||
|
Net.send_travel(peer, to, Lands.pass_to(to, id)))
|
||||||
|
|
||||||
static func _read(path: String) -> Dictionary:
|
static func _read(path: String) -> Dictionary:
|
||||||
if not FileAccess.file_exists(path):
|
if not FileAccess.file_exists(path):
|
||||||
return {}
|
return {}
|
||||||
@@ -160,6 +213,7 @@ func server_moved(peer: int, from: Vector3, to: Vector3, yaw: float, anim: int,
|
|||||||
if p.in_bed and to.distance_to(p.bed_at) > 1.5:
|
if p.in_bed and to.distance_to(p.bed_at) > 1.5:
|
||||||
p.in_bed = false
|
p.in_bed = false
|
||||||
tell(p.peer, "You got out of bed")
|
tell(p.peer, "You got out of bed")
|
||||||
|
beds_changed()
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if not Net.is_server() or proxies.is_empty():
|
if not Net.is_server() or proxies.is_empty():
|
||||||
@@ -300,8 +354,56 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
"hit":
|
"hit":
|
||||||
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
|
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
|
||||||
var at: Vector3 = _v(a, 1)
|
var at: Vector3 = _v(a, 1)
|
||||||
if n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
|
if n == null and _s(a, 0) == "treasure:" + String(p.player_id) and w.treasure != null:
|
||||||
|
var m: Node3D = w.treasure.mound_for(String(p.player_id)) # (your own X: treasure.gd)
|
||||||
|
if m != null and at.is_finite() and at.distance_to(m.global_position) < 2.5:
|
||||||
|
Actions.harvest_hit(p, m, at)
|
||||||
|
elif n == null and w.tunnel != null:
|
||||||
|
var f: Node3D = w.tunnel.face_by_uid(_s(a, 0)) # (the cliff tunnel's seam or face)
|
||||||
|
if f != null and at.is_finite():
|
||||||
|
Actions.harvest_hit(p, f, at) # (the tunnel tells everyone: tunnel.gd)
|
||||||
|
elif n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
|
||||||
WorldNet.node_hit(n, at, p.global_position)
|
WorldNet.node_hit(n, at, p.global_position)
|
||||||
|
"tunnel_prop":
|
||||||
|
if w.tunnel != null:
|
||||||
|
w.tunnel.set_prop(p, _i(a, 0))
|
||||||
|
"wreck_chest":
|
||||||
|
# The wreck's sea chest: each player's share, once.
|
||||||
|
if w.wreck != null and p.global_position.distance_to(w.wreck.chest.global_position) < 4.0:
|
||||||
|
if w.wreck.open_for(p, String(p.player_id)):
|
||||||
|
WorldNet.wreck_state(w.wreck.to_data())
|
||||||
|
"moor":
|
||||||
|
# The mooring post at the end of the pier: tie up your boat.
|
||||||
|
if w.moorings != null and p.global_position.distance_to(w.moorings.post.global_position) < 4.0:
|
||||||
|
if w.moorings.moor(p, String(p.player_id)):
|
||||||
|
WorldNet.boats_state(w.moorings.to_data())
|
||||||
|
"tunnel_go":
|
||||||
|
# Down into the cliff tunnel, or back out at the top: a teleport
|
||||||
|
# the server makes (the tunnel's under the island), from nearby.
|
||||||
|
var t: Node3D = w.tunnel
|
||||||
|
if t == null or not t.opened:
|
||||||
|
return
|
||||||
|
var go_in := _s(a, 0) == "in"
|
||||||
|
var from: Vector3 = t.seam_out if go_in else t.top
|
||||||
|
if p.global_position.distance_to(from) > 4.5:
|
||||||
|
return
|
||||||
|
if go_in:
|
||||||
|
t.go_in(p)
|
||||||
|
else:
|
||||||
|
t.go_out(p)
|
||||||
|
Net.teleport(p.peer, p.global_position)
|
||||||
|
_s2c_respawn.rpc_id(p.peer, p.global_position, p.rotation.y, "")
|
||||||
|
"cross":
|
||||||
|
# The sea cave's mouth (the Valley), or the sea cave (the Cove).
|
||||||
|
var where := _s(a, 0)
|
||||||
|
if where == "tunnel" and w.tunnel != null and w.tunnel.dug >= w.tunnel.STRETCHES \
|
||||||
|
and p.global_position.distance_to(w.tunnel.cave_mouth) < 4.0:
|
||||||
|
cross(p.peer, w.tunnel.to, "sea_cave")
|
||||||
|
elif where == "sea_cave" and w.sea_cave != null and w.sea_cave.open() \
|
||||||
|
and p.global_position.distance_to(w.sea_cave.front) < 6.0:
|
||||||
|
cross(p.peer, w.sea_cave.to, "tunnel_bottom")
|
||||||
|
elif where == "sea_cave" and w.sea_cave != null:
|
||||||
|
tell(p.peer, w.sea_cave.interact_prompt(p))
|
||||||
"pickup":
|
"pickup":
|
||||||
var key := _s(a, 0)
|
var key := _s(a, 0)
|
||||||
var pk: Node3D = WorldNet.find_pickup(key)
|
var pk: Node3D = WorldNet.find_pickup(key)
|
||||||
@@ -436,7 +538,7 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
for m in w.get_tree().get_nodes_in_group("processor"):
|
for m in w.get_tree().get_nodes_in_group("processor"):
|
||||||
m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0)
|
m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0)
|
||||||
"debug_spawn":
|
"debug_spawn":
|
||||||
if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf"]) and _v(a, 1).is_finite():
|
if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf", "drowned", "crab", "gull"]) and _v(a, 1).is_finite():
|
||||||
w.creatures.spawn(_s(a, 0), _v(a, 1))
|
w.creatures.spawn(_s(a, 0), _v(a, 1))
|
||||||
"debug_boss":
|
"debug_boss":
|
||||||
# Tests: ["hold", secs] stands the Old Stag still (stunned);
|
# Tests: ["hold", secs] stands the Old Stag still (stunned);
|
||||||
@@ -456,6 +558,12 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
w.day_night.apply(true)
|
w.day_night.apply(true)
|
||||||
w.weather.snap()
|
w.weather.snap()
|
||||||
Net.push_clock()
|
Net.push_clock()
|
||||||
|
"debug_tunnel":
|
||||||
|
# (tests) dig the cliff tunnel to `n` stretches, all propped.
|
||||||
|
if cheats and w.tunnel != null:
|
||||||
|
var n := clampi(_i(a, 0), 0, w.tunnel.STRETCHES)
|
||||||
|
w.tunnel.load_data({"opened": true, "dug": n, "props": range(1, n + 1)})
|
||||||
|
w.tunnel._tell_everyone()
|
||||||
"debug_tp":
|
"debug_tp":
|
||||||
var to := _v(a, 0)
|
var to := _v(a, 0)
|
||||||
if cheats and to.is_finite():
|
if cheats and to.is_finite():
|
||||||
@@ -515,28 +623,66 @@ func go_to_bed(p: Node3D) -> void:
|
|||||||
p.in_bed = true
|
p.in_bed = true
|
||||||
p.bed_at = p.global_position
|
p.bed_at = p.global_position
|
||||||
if not _maybe_pass_night():
|
if not _maybe_pass_night():
|
||||||
var n: int = _alive().size()
|
# (Counting everyone, in every land: on another land's server, the
|
||||||
var asleep: int = _alive().filter(func(q): return q.in_bed).size()
|
# hub's last count, with this bed in it.)
|
||||||
tell(p.peer, "You're in bed (%d of %d asleep). The night passes when %s." % [asleep, n,
|
var counts := sleep_counts()
|
||||||
"everyone's in bed" if _beds_needed(n) >= n else "%d are" % _beds_needed(n)])
|
if not Lands.land.is_empty():
|
||||||
for q in _alive():
|
counts = [_alive().size() + _hub_beds[0], _alive().filter(func(q): return q.in_bed).size() + _hub_beds[1]]
|
||||||
if q != p:
|
var n: int = counts[0]
|
||||||
tell(q.peer, "%s is in bed (%d of %d asleep)" % [p.display, asleep, n])
|
var asleep: int = counts[1]
|
||||||
|
var need: int = _beds_needed(n)
|
||||||
|
tell(p.peer, waiting_text(asleep, n, need))
|
||||||
|
# Everyone else, in every land, hears it in chat (so whoever's still
|
||||||
|
# up knows they're being waited for).
|
||||||
|
Chat._all("%s is in bed (%d of %d asleep%s)" % [p.display, asleep, n,
|
||||||
|
"" if need >= n else ", %d needed" % need], "server")
|
||||||
|
|
||||||
|
## What a player who's just got into bed is told while the others are up.
|
||||||
|
static func waiting_text(asleep: int, n: int, need: int) -> String:
|
||||||
|
var left := need - asleep
|
||||||
|
var who := "everyone else" if need >= n else ("%d more" % left)
|
||||||
|
return "You're in bed: waiting for %s to go to bed (%d of %d asleep)" % [who, asleep, n]
|
||||||
|
|
||||||
func _alive() -> Array:
|
func _alive() -> Array:
|
||||||
return proxies.values().filter(func(q): return not q.survival.dead)
|
return proxies.values().filter(func(q): return not q.survival.dead)
|
||||||
|
|
||||||
## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires,
|
## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires,
|
||||||
## regrowth and weather move on; sleepers wake rested.
|
## regrowth and weather move on; sleepers wake rested.
|
||||||
|
## One clock for every land (J3b): the night passes when enough of everyone,
|
||||||
|
## in every land, is in bed. Another land's server reports its beds to the
|
||||||
|
## hub, which decides, and tells every land to pass the night too.
|
||||||
func _maybe_pass_night() -> bool:
|
func _maybe_pass_night() -> bool:
|
||||||
var w: Node3D = WorldNet.world
|
var w: Node3D = WorldNet.world
|
||||||
var live := _alive()
|
if w == null:
|
||||||
if w == null or live.is_empty():
|
|
||||||
return false
|
return false
|
||||||
var asleep := live.filter(func(q): return q.in_bed)
|
if not Lands.land.is_empty():
|
||||||
if asleep.size() < _beds_needed(live.size()):
|
_report_beds()
|
||||||
|
return false
|
||||||
|
Lands._push_bed_totals()
|
||||||
|
var counts := sleep_counts()
|
||||||
|
if counts[0] == 0 or counts[1] < _beds_needed(counts[0]):
|
||||||
return false
|
return false
|
||||||
var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length
|
var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length
|
||||||
|
Lands.tell_lands({"t": "night", "secs": secs})
|
||||||
|
pass_night_here(secs)
|
||||||
|
return true
|
||||||
|
|
||||||
|
## [alive, asleep] on this server and (the hub) every other land.
|
||||||
|
func sleep_counts() -> Array:
|
||||||
|
var live := _alive()
|
||||||
|
var counts := [live.size(), live.filter(func(q): return q.in_bed).size()]
|
||||||
|
if Lands.hub:
|
||||||
|
var e: Array = Lands.beds_elsewhere()
|
||||||
|
counts = [counts[0] + int(e[0]), counts[1] + int(e[1])]
|
||||||
|
return counts
|
||||||
|
|
||||||
|
## The night passes here: until 06:00 for everyone on this server.
|
||||||
|
func pass_night_here(secs: float) -> void:
|
||||||
|
var w: Node3D = WorldNet.world
|
||||||
|
if w == null:
|
||||||
|
return
|
||||||
|
var live := _alive()
|
||||||
|
var asleep := live.filter(func(q): return q.in_bed)
|
||||||
w.skip_time(secs)
|
w.skip_time(secs)
|
||||||
for q in live:
|
for q in live:
|
||||||
if q.in_bed:
|
if q.in_bed:
|
||||||
@@ -545,7 +691,20 @@ func _maybe_pass_night() -> bool:
|
|||||||
_s2c_slept.rpc_id(q.peer, asleep.has(q))
|
_s2c_slept.rpc_id(q.peer, asleep.has(q))
|
||||||
Net.push_clock()
|
Net.push_clock()
|
||||||
WorldNet.save()
|
WorldNet.save()
|
||||||
return true
|
if not Lands.land.is_empty():
|
||||||
|
_report_beds()
|
||||||
|
|
||||||
|
## Someone got into or out of bed, or came or went: (a land server) the hub
|
||||||
|
## hears the new count; (the hub) the night may pass.
|
||||||
|
func beds_changed() -> void:
|
||||||
|
if not Lands.land.is_empty():
|
||||||
|
_report_beds()
|
||||||
|
elif Net.is_server():
|
||||||
|
_maybe_pass_night()
|
||||||
|
|
||||||
|
func _report_beds() -> void:
|
||||||
|
var live := _alive()
|
||||||
|
Lands.send_hub({"t": "beds", "alive": live.size(), "asleep": live.filter(func(q): return q.in_bed).size()})
|
||||||
|
|
||||||
## The chest or grave `nid`, if this player may use it right now.
|
## The chest or grave `nid`, if this player may use it right now.
|
||||||
func _container(p: Node3D, nid: int) -> Node3D:
|
func _container(p: Node3D, nid: int) -> Node3D:
|
||||||
@@ -556,6 +715,10 @@ func _container(p: Node3D, nid: int) -> Node3D:
|
|||||||
|
|
||||||
const Rules = preload("res://scripts/net/rules.gd")
|
const Rules = preload("res://scripts/net/rules.gd")
|
||||||
|
|
||||||
|
## (Land server) the hub's latest [alive, asleep] in every OTHER land, for
|
||||||
|
## the "N of M asleep" message and the all-abed check here.
|
||||||
|
var _hub_beds := [0, 0]
|
||||||
|
|
||||||
## Tells the player why not, if there's a reason.
|
## Tells the player why not, if there's a reason.
|
||||||
func _allowed(why: String) -> bool:
|
func _allowed(why: String) -> bool:
|
||||||
if why.is_empty():
|
if why.is_empty():
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const MARKS := {
|
|||||||
"build": [Color(0.62, 0.45, 0.28), "Your buildings"],
|
"build": [Color(0.62, 0.45, 0.28), "Your buildings"],
|
||||||
"camp": [Color(1.00, 0.80, 0.35), "Camp"],
|
"camp": [Color(1.00, 0.80, 0.35), "Camp"],
|
||||||
"grave": [Color(0.85, 0.85, 0.85), "Your things"],
|
"grave": [Color(0.85, 0.85, 0.85), "Your things"],
|
||||||
|
"wreck": [Color(0.55, 0.38, 0.22), "The wreck"],
|
||||||
|
"treasure": [Color(0.9, 0.12, 0.1), "X: your treasure"],
|
||||||
}
|
}
|
||||||
|
|
||||||
var world: Node3D
|
var world: Node3D
|
||||||
@@ -31,6 +33,8 @@ var _tex: TextureRect
|
|||||||
var _marks: Control
|
var _marks: Control
|
||||||
var _title: Label
|
var _title: Label
|
||||||
var _facing: Label
|
var _facing: Label
|
||||||
|
var _others_key: Label
|
||||||
|
var _key: Dictionary = {} ## mark kind -> its line in the key
|
||||||
|
|
||||||
func setup(w: Node3D, p: Node3D) -> void:
|
func setup(w: Node3D, p: Node3D) -> void:
|
||||||
world = w
|
world = w
|
||||||
@@ -75,10 +79,14 @@ func setup(w: Node3D, p: Node3D) -> void:
|
|||||||
var you := UITheme.label("● You", 14)
|
var you := UITheme.label("● You", 14)
|
||||||
you.add_theme_color_override("font_color", Color(1, 1, 1))
|
you.add_theme_color_override("font_color", Color(1, 1, 1))
|
||||||
legend.add_child(you)
|
legend.add_child(you)
|
||||||
|
_others_key = UITheme.label("● Other players (co-op)", 14)
|
||||||
|
_others_key.add_theme_color_override("font_color", Color(0.6, 1.0, 0.65))
|
||||||
|
legend.add_child(_others_key)
|
||||||
for k in MARKS:
|
for k in MARKS:
|
||||||
var l := UITheme.label("■ %s" % MARKS[k][1], 14)
|
var l := UITheme.label("■ %s" % MARKS[k][1], 14)
|
||||||
l.add_theme_color_override("font_color", MARKS[k][0])
|
l.add_theme_color_override("font_color", MARKS[k][0])
|
||||||
legend.add_child(l)
|
legend.add_child(l)
|
||||||
|
_key[k] = l
|
||||||
_facing = UITheme.label("", 15, UITheme.TEXT_DIM)
|
_facing = UITheme.label("", 15, UITheme.TEXT_DIM)
|
||||||
_facing.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
_facing.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
box.add_child(_facing)
|
box.add_child(_facing)
|
||||||
@@ -92,6 +100,14 @@ func is_open() -> bool:
|
|||||||
func open() -> void:
|
func open() -> void:
|
||||||
_tex.texture = ImageTexture.create_from_image(render(world))
|
_tex.texture = ImageTexture.create_from_image(render(world))
|
||||||
_title.text = "The %s" % preload("res://scripts/world/terrain.gd").worldlet_name(world.worldlet_id())
|
_title.text = "The %s" % preload("res://scripts/world/terrain.gd").worldlet_name(world.worldlet_id())
|
||||||
|
_others_key.visible = world.is_online() and not bool(Net.server_info.get("pvp", true))
|
||||||
|
# The key lists this land's places only (the Cove has no barrow...).
|
||||||
|
var here := {"den": world.den != null and world.den.found, "barrow": world.barrow != null,
|
||||||
|
"stones": world.stones != null, "seam": world.tunnel != null, "pier": world.pier != null,
|
||||||
|
"cave": world.sea_cave != null, "wreck": world.wreck != null,
|
||||||
|
"treasure": world.wreck != null and player.inventory.count("treasure_map") > 0}
|
||||||
|
for k in _key:
|
||||||
|
_key[k].visible = bool(here.get(k, true))
|
||||||
visible = true
|
visible = true
|
||||||
player.ui_open = true
|
player.ui_open = true
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
@@ -127,11 +143,24 @@ func _draw_marks() -> void:
|
|||||||
for m in world.map_marks():
|
for m in world.map_marks():
|
||||||
var at := _to_map(m.pos)
|
var at := _to_map(m.pos)
|
||||||
var col: Color = MARKS.get(m.kind, [Color.WHITE])[0]
|
var col: Color = MARKS.get(m.kind, [Color.WHITE])[0]
|
||||||
|
if m.kind == "treasure":
|
||||||
|
# An inked X, as on the old chart.
|
||||||
|
for d in [Vector2(1, 1), Vector2(1, -1)]:
|
||||||
|
_marks.draw_line(at - d * 8.0, at + d * 8.0, Color(0, 0, 0, 0.8), 5.0)
|
||||||
|
_marks.draw_line(at - d * 8.0, at + d * 8.0, col, 3.0)
|
||||||
|
continue
|
||||||
var r := 3.0 if m.kind == "build" else 6.0
|
var r := 3.0 if m.kind == "build" else 6.0
|
||||||
_marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), col)
|
_marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), col)
|
||||||
_marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), Color(0, 0, 0, 0.7), false, 1.0)
|
_marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), Color(0, 0, 0, 0.7), false, 1.0)
|
||||||
if m.kind in ["den", "barrow", "stones", "seam", "pier", "cave"]:
|
if m.kind in ["den", "barrow", "stones", "seam", "pier", "cave", "wreck"]:
|
||||||
_marks.draw_string(font, at + Vector2(9, 5), String(m.label), HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col)
|
_marks.draw_string(font, at + Vector2(9, 5), String(m.label), HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col)
|
||||||
|
# Other players in this land: only on a co-op server (with PvP on, the
|
||||||
|
# map never gives anyone away). Green dots with their names.
|
||||||
|
for o in others(world):
|
||||||
|
var at2 := _to_map(o.pos)
|
||||||
|
_marks.draw_circle(at2, 5.5, Color(0, 0, 0, 0.8))
|
||||||
|
_marks.draw_circle(at2, 4.0, Color(0.45, 0.95, 0.5))
|
||||||
|
_marks.draw_string(font, at2 + Vector2(8, 5), String(o.name), HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.6, 1.0, 0.65))
|
||||||
# You: a dot (the way you're facing is on the compass).
|
# You: a dot (the way you're facing is on the compass).
|
||||||
var p := player.global_position
|
var p := player.global_position
|
||||||
if world.underground_at(p) and world.tunnel != null:
|
if world.underground_at(p) and world.tunnel != null:
|
||||||
@@ -143,6 +172,19 @@ func _draw_marks() -> void:
|
|||||||
_marks.draw_string(font, Vector2(SIDE - 26, 24), "N", HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(1.0, 0.55, 0.35))
|
_marks.draw_string(font, Vector2(SIDE - 26, 24), "N", HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(1.0, 0.55, 0.35))
|
||||||
_marks.draw_line(Vector2(SIDE - 20, 30), Vector2(SIDE - 20, 48), Color(1.0, 0.55, 0.35), 2.0)
|
_marks.draw_line(Vector2(SIDE - 20, 30), Vector2(SIDE - 20, 48), Color(1.0, 0.55, 0.35), 2.0)
|
||||||
|
|
||||||
|
## The other players to show: [{name, pos}], everyone in this land on a
|
||||||
|
## server with PvP off; nobody single-player or with PvP on (rivals and
|
||||||
|
## adversarial), so the map can't be used to hunt anyone down.
|
||||||
|
static func others(w: Node3D) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
if not w.is_online() or bool(Net.server_info.get("pvp", true)) or w.net_sync == null:
|
||||||
|
return out
|
||||||
|
for peer in w.net_sync.avatars:
|
||||||
|
var a: Node3D = w.net_sync.avatars[peer]
|
||||||
|
if is_instance_valid(a) and a.visible:
|
||||||
|
out.append({"name": String(a.info.get("name", "?")), "pos": a.global_position})
|
||||||
|
return out
|
||||||
|
|
||||||
## The map image: the terrain, coloured and shaded, blank where unexplored.
|
## The map image: the terrain, coloured and shaded, blank where unexplored.
|
||||||
static func render(w: Node3D) -> Image:
|
static func render(w: Node3D) -> Image:
|
||||||
var t: Node3D = w.terrain
|
var t: Node3D = w.terrain
|
||||||
@@ -163,6 +205,8 @@ static func render(w: Node3D) -> Image:
|
|||||||
|
|
||||||
static func ground_colour(t: Node3D, x: float, z: float) -> Color:
|
static func ground_colour(t: Node3D, x: float, z: float) -> Color:
|
||||||
var h: float = t.sample_height(x, z)
|
var h: float = t.sample_height(x, z)
|
||||||
|
if t.near_stream(x, z, 0.3):
|
||||||
|
return Color(0.32, 0.56, 0.66) # (the stream, a little wider than it is so it shows)
|
||||||
var surface: float = t.water_level if t.inside_boundary(x, z, -2.0) else t.sea_level
|
var surface: float = t.water_level if t.inside_boundary(x, z, -2.0) else t.sea_level
|
||||||
if h < surface:
|
if h < surface:
|
||||||
var deep := clampf((surface - h) / 6.0, 0.0, 1.0)
|
var deep := clampf((surface - h) / 6.0, 0.0, 1.0)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
extends Control
|
extends Control
|
||||||
## Pause menu (ESC): resume, controls, fullscreen, mouse sensitivity, quit.
|
## Pause menu (ESC): resume, controls, report a bug, fullscreen, mouse
|
||||||
|
## sensitivity, volumes, quit.
|
||||||
## Pauses the scene tree while open; runs with PROCESS_MODE_ALWAYS so it still
|
## Pauses the scene tree while open; runs with PROCESS_MODE_ALWAYS so it still
|
||||||
## gets input.
|
## gets input.
|
||||||
|
|
||||||
const UITheme = preload("res://scripts/ui/theme.gd")
|
const UITheme = preload("res://scripts/ui/theme.gd")
|
||||||
|
const BugReport = preload("res://scripts/core/bug_report.gd")
|
||||||
|
|
||||||
const CONTROLS := [
|
const CONTROLS := [
|
||||||
["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"],
|
["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"],
|
||||||
@@ -23,6 +25,8 @@ const CONTROLS := [
|
|||||||
var player: CharacterBody3D
|
var player: CharacterBody3D
|
||||||
var _main: VBoxContainer
|
var _main: VBoxContainer
|
||||||
var _controls: VBoxContainer
|
var _controls: VBoxContainer
|
||||||
|
var _report: VBoxContainer
|
||||||
|
var _report_note: Label
|
||||||
var _quit_btn: Button
|
var _quit_btn: Button
|
||||||
var _quit_armed := false
|
var _quit_armed := false
|
||||||
var _online := false ## on a server: no pausing, no saving here
|
var _online := false ## on a server: no pausing, no saving here
|
||||||
@@ -60,6 +64,7 @@ func bind(p: CharacterBody3D) -> void:
|
|||||||
_main.add_child(title)
|
_main.add_child(title)
|
||||||
_main.add_child(_button("Resume", close))
|
_main.add_child(_button("Resume", close))
|
||||||
_main.add_child(_button("Controls", _show_controls.bind(true)))
|
_main.add_child(_button("Controls", _show_controls.bind(true)))
|
||||||
|
_main.add_child(_button("Report a bug", _show_report.bind(true)))
|
||||||
|
|
||||||
var fs := CheckButton.new()
|
var fs := CheckButton.new()
|
||||||
fs.text = "Fullscreen"
|
fs.text = "Fullscreen"
|
||||||
@@ -174,6 +179,31 @@ func bind(p: CharacterBody3D) -> void:
|
|||||||
_controls.add_child(grid)
|
_controls.add_child(grid)
|
||||||
_controls.add_child(_button("Back", _show_controls.bind(false)))
|
_controls.add_child(_button("Back", _show_controls.bind(false)))
|
||||||
|
|
||||||
|
# Report a bug page (0.6.0, the playtest: bug_report.gd).
|
||||||
|
_report = VBoxContainer.new()
|
||||||
|
_report.add_theme_constant_override("separation", 10)
|
||||||
|
_report.visible = false
|
||||||
|
stack.add_child(_report)
|
||||||
|
var rt := UITheme.label("Report a bug", 26)
|
||||||
|
rt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_report.add_child(rt)
|
||||||
|
var how := UITheme.label("This opens a new issue on Emberwild's Gitea page in your browser, filled in with your version, computer and where you are in the game. Say what happened, then press Create Issue. (You'll need to be signed in there.) Nothing is sent from the game itself.", 15)
|
||||||
|
how.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||||
|
how.custom_minimum_size = Vector2(380, 0)
|
||||||
|
_report.add_child(how)
|
||||||
|
_report.add_child(_button("Open the bug report page", _on_report_open))
|
||||||
|
_report.add_child(_button("Copy the details", _on_report_copy))
|
||||||
|
_report.add_child(_button("Show the log file", _on_report_log))
|
||||||
|
var lp := UITheme.label("For a crash or an error, attach the log:\n%s" % BugReport.log_path(), 13, UITheme.TEXT_DIM)
|
||||||
|
lp.autowrap_mode = TextServer.AUTOWRAP_ARBITRARY
|
||||||
|
lp.custom_minimum_size = Vector2(380, 0)
|
||||||
|
_report.add_child(lp)
|
||||||
|
_report_note = UITheme.label("", 14, UITheme.SELECTED)
|
||||||
|
_report_note.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||||
|
_report_note.custom_minimum_size = Vector2(380, 0)
|
||||||
|
_report.add_child(_report_note)
|
||||||
|
_report.add_child(_button("Back", _show_report.bind(false)))
|
||||||
|
|
||||||
func _button(text: String, cb: Callable) -> Button:
|
func _button(text: String, cb: Callable) -> Button:
|
||||||
var b := Button.new()
|
var b := Button.new()
|
||||||
b.text = text
|
b.text = text
|
||||||
@@ -189,14 +219,24 @@ func is_open() -> bool:
|
|||||||
func open() -> void:
|
func open() -> void:
|
||||||
visible = true
|
visible = true
|
||||||
_show_controls(false)
|
_show_controls(false)
|
||||||
|
_show_report(false)
|
||||||
_disarm_quit()
|
_disarm_quit()
|
||||||
player.ui_open = true
|
player.ui_open = true
|
||||||
get_tree().paused = not _online # a shared world can't be paused by one player
|
get_tree().paused = not _online # a shared world can't be paused by one player
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
if _online:
|
if _online:
|
||||||
|
# Everyone on the server, in whichever land (J3b), with where they are
|
||||||
|
# once anyone's somewhere else.
|
||||||
|
var all: Array = Net.everyone()
|
||||||
|
var lands := {}
|
||||||
|
for p in all:
|
||||||
|
lands[String(p.get("land", ""))] = true
|
||||||
var names: Array[String] = []
|
var names: Array[String] = []
|
||||||
for p in Net.players.values():
|
for p in all:
|
||||||
names.append(String(p.name) + (" (admin)" if p.get("admin", false) else ""))
|
var where := ""
|
||||||
|
if lands.size() > 1 and not String(p.get("land", "")).is_empty():
|
||||||
|
where = " · %s" % preload("res://scripts/world/terrain.gd").worldlet_name(String(p.land))
|
||||||
|
names.append(String(p.name) + (" (admin)" if p.get("admin", false) else "") + where)
|
||||||
names.sort()
|
names.sort()
|
||||||
_players_label.text = "%s · %d online\n%s" % [Net.server_info.get("server_name", "Server"), names.size(), ", ".join(names)]
|
_players_label.text = "%s · %d online\n%s" % [Net.server_info.get("server_name", "Server"), names.size(), ", ".join(names)]
|
||||||
|
|
||||||
@@ -206,10 +246,12 @@ func close() -> void:
|
|||||||
get_tree().paused = false
|
get_tree().paused = false
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||||
|
|
||||||
## ESC on the controls page goes back; on the main page it resumes.
|
## ESC on the controls or bug report page goes back; on the main page it resumes.
|
||||||
func back() -> void:
|
func back() -> void:
|
||||||
if _controls.visible:
|
if _controls.visible:
|
||||||
_show_controls(false)
|
_show_controls(false)
|
||||||
|
elif _report.visible:
|
||||||
|
_show_report(false)
|
||||||
else:
|
else:
|
||||||
close()
|
close()
|
||||||
|
|
||||||
@@ -217,6 +259,31 @@ func _show_controls(on: bool) -> void:
|
|||||||
_controls.visible = on
|
_controls.visible = on
|
||||||
_main.visible = not on
|
_main.visible = not on
|
||||||
|
|
||||||
|
func _show_report(on: bool) -> void:
|
||||||
|
_report.visible = on
|
||||||
|
_main.visible = not on
|
||||||
|
_report_note.text = ""
|
||||||
|
|
||||||
|
func _world() -> Node:
|
||||||
|
return player.get_parent() if player != null else null
|
||||||
|
|
||||||
|
func _on_report_open() -> void:
|
||||||
|
if DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_FULLSCREEN:
|
||||||
|
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) # (so the browser isn't hidden behind the game)
|
||||||
|
if BugReport.open(_world()):
|
||||||
|
_report_note.text = "Opened in your browser. (Can't post there? Copy the details and send them to whoever gave you the game.)"
|
||||||
|
else:
|
||||||
|
DisplayServer.clipboard_set(BugReport.issue_url(_world()))
|
||||||
|
_report_note.text = "Couldn't open a browser: the link's on your clipboard to paste into one."
|
||||||
|
|
||||||
|
func _on_report_copy() -> void:
|
||||||
|
DisplayServer.clipboard_set(BugReport.details_text(_world()))
|
||||||
|
_report_note.text = "Copied: paste them into your message."
|
||||||
|
|
||||||
|
func _on_report_log() -> void:
|
||||||
|
OS.shell_open(BugReport.log_dir())
|
||||||
|
_report_note.text = "The log is godot.log in that folder."
|
||||||
|
|
||||||
func _on_fullscreen(on: bool) -> void:
|
func _on_fullscreen(on: bool) -> void:
|
||||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if on else DisplayServer.WINDOW_MODE_WINDOWED)
|
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if on else DisplayServer.WINDOW_MODE_WINDOWED)
|
||||||
|
|
||||||
|
|||||||
@@ -426,7 +426,9 @@ func _fill_host() -> void:
|
|||||||
_host_seed_label.text = "Seed" if not existing else "Seed (kept)"
|
_host_seed_label.text = "Seed" if not existing else "Seed (kept)"
|
||||||
_host_seed.text = "" if not existing else str(int(c.data.seed))
|
_host_seed.text = "" if not existing else str(int(c.data.seed))
|
||||||
_show_host_worldlet(existing, meta_path)
|
_show_host_worldlet(existing, meta_path)
|
||||||
_host_msg.text = "Your hosted island carries on where you left it." if existing else "A new island will be made."
|
_host_msg.text = ("Your hosted island carries on where you left it." if existing else "A new island will be made.") + \
|
||||||
|
"\nFriends on your network join at your address. Over the internet, forward %s on your router." % \
|
||||||
|
Lands.ports_text(picked_worldlet(_host_worldlet), int(c.data.port))
|
||||||
_host_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
|
_host_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
|
||||||
_host_btn.disabled = false
|
_host_btn.disabled = false
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func _search(max_spread: float, from_spawn: float, from_den: float) -> Vector3:
|
|||||||
hs.append(terrain.sample_height(c.x + cos(b) * rad, c.y + sin(b) * rad))
|
hs.append(terrain.sample_height(c.x + cos(b) * rad, c.y + sin(b) * rad))
|
||||||
var h0: float = terrain.sample_height(c.x, c.y)
|
var h0: float = terrain.sample_height(c.x, c.y)
|
||||||
hs.append(h0)
|
hs.append(h0)
|
||||||
if hs.min() < terrain.water_level + 1.0:
|
if hs.min() < terrain.water_level + 1.0 or terrain.near_stream(c.x, c.y, MOUND_R + 4.0):
|
||||||
continue
|
continue
|
||||||
var spread: float = hs.max() - hs.min()
|
var spread: float = hs.max() - hs.min()
|
||||||
if spread > max_spread:
|
if spread > max_spread:
|
||||||
|
|||||||
22
scripts/world/boat.gd
Normal file
22
scripts/world/boat.gd
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
extends StaticBody3D
|
||||||
|
## A rowing boat tied up at the pier (moorings.gd): whose it is, and, for
|
||||||
|
## its owner, "Set sail".
|
||||||
|
|
||||||
|
const PostScript = preload("res://scripts/world/mooring_post.gd")
|
||||||
|
|
||||||
|
var moorings: Node3D
|
||||||
|
var owner_id := ""
|
||||||
|
var rest := Transform3D() ## where it's tied up (it bobs about this)
|
||||||
|
|
||||||
|
func mine(player: Node) -> bool:
|
||||||
|
return PostScript.id_of(player) == owner_id
|
||||||
|
|
||||||
|
func interact_prompt(player: Node) -> String:
|
||||||
|
if mine(player):
|
||||||
|
return "Your rowing boat [E] Set sail"
|
||||||
|
var who := String(moorings.moored.get(owner_id, {}).get("name", ""))
|
||||||
|
return "%s's rowing boat" % who if not who.is_empty() else "Someone's rowing boat"
|
||||||
|
|
||||||
|
func interact(player: Node) -> void:
|
||||||
|
if mine(player):
|
||||||
|
moorings.set_sail(player)
|
||||||
1
scripts/world/boat.gd.uid
Normal file
1
scripts/world/boat.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bd8yx310myk8m
|
||||||
47
scripts/world/crew_site.gd
Normal file
47
scripts/world/crew_site.gd
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
extends Node3D
|
||||||
|
## Where a band of the drowned crew rose (treasure.gd): what their guard
|
||||||
|
## brains (guard.gd) ask of their "barrow". They're up and angry from the
|
||||||
|
## start, chase whoever's nearest within LEASH of the dig, and go back to
|
||||||
|
## stand round it when nobody is. By day, once nobody's been near for a
|
||||||
|
## while, they sink back into the ground.
|
||||||
|
|
||||||
|
const LEASH := 20.0
|
||||||
|
const SINK_AFTER := 30.0 ## seconds with nobody near, by day
|
||||||
|
|
||||||
|
var world: Node3D
|
||||||
|
var spot := Vector3.ZERO
|
||||||
|
var alarmed := true
|
||||||
|
var crew: Array = []
|
||||||
|
var _alone := 0.0
|
||||||
|
|
||||||
|
func center() -> Vector3:
|
||||||
|
return spot
|
||||||
|
|
||||||
|
func wakes_for(p: Node3D) -> bool:
|
||||||
|
return alarmed and not p.survival.dead
|
||||||
|
|
||||||
|
func alarm() -> void:
|
||||||
|
alarmed = true
|
||||||
|
|
||||||
|
## (Out in the open: they walk on the ground.)
|
||||||
|
func floor_y_at(_p: Vector3) -> float:
|
||||||
|
return -INF
|
||||||
|
|
||||||
|
func is_night() -> bool:
|
||||||
|
var h: float = world.day_night.hours()
|
||||||
|
return h >= 19.5 or h < 5.5
|
||||||
|
|
||||||
|
func alive() -> Array:
|
||||||
|
return crew.filter(func(g): return is_instance_valid(g) and not g.is_dead())
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
var anyone := false
|
||||||
|
for p in world.players():
|
||||||
|
if not p.survival.dead and p.global_position.distance_to(spot) < LEASH:
|
||||||
|
anyone = true
|
||||||
|
_alone = 0.0 if anyone or is_night() else _alone + delta
|
||||||
|
if alive().is_empty() or _alone > SINK_AFTER:
|
||||||
|
for g in crew:
|
||||||
|
if is_instance_valid(g) and not g.is_dead():
|
||||||
|
g.queue_free()
|
||||||
|
queue_free()
|
||||||
1
scripts/world/crew_site.gd.uid
Normal file
1
scripts/world/crew_site.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dn5ytepdehl8k
|
||||||
25
scripts/world/mooring_post.gd
Normal file
25
scripts/world/mooring_post.gd
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
extends StaticBody3D
|
||||||
|
## The mooring post at the end of the pier (moorings.gd): E ties up the
|
||||||
|
## rowing boat in your bag. Online, your game asks the server.
|
||||||
|
|
||||||
|
var moorings: Node3D
|
||||||
|
|
||||||
|
## Whose boat: the player's id (a server's character, or, at home, yours).
|
||||||
|
static func id_of(player: Node) -> String:
|
||||||
|
var pid: Variant = player.get("player_id")
|
||||||
|
if pid != null and not String(pid).is_empty():
|
||||||
|
return String(pid)
|
||||||
|
return String(Net.get_identity().player_id)
|
||||||
|
|
||||||
|
func interact_prompt(player: Node) -> String:
|
||||||
|
if moorings.has_boat(id_of(player)):
|
||||||
|
return "Mooring post (your boat's tied up here)"
|
||||||
|
if player.inventory.count(moorings.BOAT_ITEM) > 0:
|
||||||
|
return "Mooring post [E] Tie up your boat"
|
||||||
|
return "Mooring post (build a rowing boat at a workbench, and tie it up here)"
|
||||||
|
|
||||||
|
func interact(player: Node) -> void:
|
||||||
|
if moorings.world.is_online():
|
||||||
|
PlayerNet.ask("moor", [])
|
||||||
|
return
|
||||||
|
moorings.moor(player, id_of(player))
|
||||||
1
scripts/world/mooring_post.gd.uid
Normal file
1
scripts/world/mooring_post.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dt0os4fg56rxi
|
||||||
158
scripts/world/moorings.gd
Normal file
158
scripts/world/moorings.gd
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
extends Node3D
|
||||||
|
## The Cove's moorings (0.6.0, docs/WORLDLETS_DESIGN.md §5): berths along
|
||||||
|
## the end of the pier, and a mooring post on the deck. Build a rowing boat at
|
||||||
|
## a workbench, carry it down to the pier and press E at the post: it's tied
|
||||||
|
## up at a berth of its own, one boat for each player, and it stays there
|
||||||
|
## (saved with the Cove; online the server decides, and everyone sees
|
||||||
|
## everyone's boat). Your own boat's prompt is "Set sail", but nobody's
|
||||||
|
## charted the way to the Highlands yet: that's the next part of the journey,
|
||||||
|
## and the ship's compass from the pirate chest is what it'll need.
|
||||||
|
|
||||||
|
const ItemMeshes = preload("res://scripts/items/item_meshes.gd")
|
||||||
|
const BoatScript = preload("res://scripts/world/boat.gd")
|
||||||
|
const PostScript = preload("res://scripts/world/mooring_post.gd")
|
||||||
|
const BERTHS := 6 ## three each side (any further in, it shallows)
|
||||||
|
const BOAT_SCALE := 8.0 ## the item's model (0.55 m) up to a boat (4.4 m)
|
||||||
|
const BERTH_GAP := 5.0
|
||||||
|
const BOAT_ITEM := "rowing_boat"
|
||||||
|
|
||||||
|
var world: Node3D
|
||||||
|
var pier: Node3D
|
||||||
|
var moored: Dictionary = {} ## player id -> {"berth": i, "name": who}
|
||||||
|
var boats: Dictionary = {} ## player id -> boat.gd
|
||||||
|
var post: StaticBody3D
|
||||||
|
var _t := 0.0
|
||||||
|
|
||||||
|
func setup(w: Node3D) -> bool:
|
||||||
|
world = w
|
||||||
|
pier = w.pier
|
||||||
|
if pier == null:
|
||||||
|
return false
|
||||||
|
add_to_group("moorings")
|
||||||
|
post = StaticBody3D.new()
|
||||||
|
post.name = "MooringPost"
|
||||||
|
post.collision_layer = 1 | 2
|
||||||
|
post.set_script(PostScript)
|
||||||
|
post.moorings = self
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
pier._box(st, Vector3(0, 0.45, 0), Vector3(0.24, 0.9, 0.24), pier.POST)
|
||||||
|
pier._box(st, Vector3(0, 0.8, 0), Vector3(0.32, 0.08, 0.32), pier.POST.darkened(0.2))
|
||||||
|
pier._box(st, Vector3(0, 0.62, 0), Vector3(0.27, 0.1, 0.27), Color(0.62, 0.55, 0.38)) # (a coil of rope)
|
||||||
|
st.generate_normals()
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
mi.mesh = st.commit()
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.vertex_color_use_as_albedo = true
|
||||||
|
mat.roughness = 0.9
|
||||||
|
mi.material_override = mat
|
||||||
|
post.add_child(mi)
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = Vector3(0.3, 0.9, 0.3)
|
||||||
|
cs.shape = box
|
||||||
|
cs.position = Vector3(0, 0.45, 0)
|
||||||
|
post.add_child(cs)
|
||||||
|
add_child(post)
|
||||||
|
post.global_position = pier.end - pier.dir * 1.2 + _right() * (pier.WIDTH * 0.5 - 0.3)
|
||||||
|
return true
|
||||||
|
|
||||||
|
func _right() -> Vector3:
|
||||||
|
return pier.dir.cross(Vector3.UP).normalized()
|
||||||
|
|
||||||
|
## Where berth `i` is: alongside the end of the pier, in pairs, bow out to sea.
|
||||||
|
func berth_xf(i: int) -> Transform3D:
|
||||||
|
var side := 1.0 if i % 2 == 0 else -1.0
|
||||||
|
var along: Vector3 = pier.end - pier.dir * (2.0 + (i / 2) * BERTH_GAP)
|
||||||
|
var p: Vector3 = along + _right() * side * (pier.WIDTH * 0.5 + 1.0)
|
||||||
|
p.y = world.terrain.sea_level - 0.08
|
||||||
|
return Transform3D(Basis.looking_at(pier.dir, Vector3.UP), p)
|
||||||
|
|
||||||
|
func has_boat(id: String) -> bool:
|
||||||
|
return moored.has(id)
|
||||||
|
|
||||||
|
func _free_berth() -> int:
|
||||||
|
var used := moored.values().map(func(m): return int(m.berth))
|
||||||
|
for i in range(BERTHS):
|
||||||
|
if not i in used:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
## Player `p` (id `id`) ties up the boat in their bag. Says why not and
|
||||||
|
## returns false when they can't.
|
||||||
|
func moor(p: Node, id: String) -> bool:
|
||||||
|
if has_boat(id):
|
||||||
|
Actions.tell(p, "Your boat's already tied up here")
|
||||||
|
return false
|
||||||
|
if p.inventory.count(BOAT_ITEM) <= 0:
|
||||||
|
Actions.tell(p, "Build a rowing boat at a workbench and bring it here to tie it up")
|
||||||
|
return false
|
||||||
|
var b := _free_berth()
|
||||||
|
if b < 0:
|
||||||
|
Actions.tell(p, "Every berth along the pier is taken")
|
||||||
|
return false
|
||||||
|
p.inventory.remove(BOAT_ITEM, 1)
|
||||||
|
var who := String(p.get("display")) if p.get("display") != null else ""
|
||||||
|
moored[id] = {"berth": b, "name": who}
|
||||||
|
_sync()
|
||||||
|
Actions.tell(p, "You tie your boat up at the end of the pier")
|
||||||
|
Sfx.at("creak", post.global_position)
|
||||||
|
if Net.is_server():
|
||||||
|
WorldNet.mark_changed()
|
||||||
|
return true
|
||||||
|
|
||||||
|
## Your boat: off to the Highlands. Not yet.
|
||||||
|
func set_sail(p: Node) -> void:
|
||||||
|
if p.inventory.count("ship_compass") <= 0:
|
||||||
|
Actions.tell(p, "Past the bay it's open sea: without a ship's compass you'd be lost within the hour")
|
||||||
|
return
|
||||||
|
Actions.tell(p, "The compass needle points north over the water, but nobody's charted the way to the Highlands yet. (That's the next part of the journey: coming in a later version.)")
|
||||||
|
|
||||||
|
func _sync() -> void:
|
||||||
|
for id in boats.keys():
|
||||||
|
if not moored.has(id):
|
||||||
|
boats[id].queue_free()
|
||||||
|
boats.erase(id)
|
||||||
|
for id in moored:
|
||||||
|
if boats.has(id):
|
||||||
|
continue
|
||||||
|
var b: StaticBody3D = StaticBody3D.new()
|
||||||
|
b.name = "Boat_%d" % int(moored[id].berth)
|
||||||
|
b.collision_layer = 1 | 2
|
||||||
|
b.set_script(BoatScript)
|
||||||
|
b.moorings = self
|
||||||
|
b.owner_id = String(id)
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
mi.name = "Hull"
|
||||||
|
mi.mesh = ItemMeshes.build(BOAT_ITEM)
|
||||||
|
mi.material_override = ItemDB.item_material()
|
||||||
|
mi.scale = Vector3.ONE * BOAT_SCALE
|
||||||
|
b.add_child(mi)
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = Vector3(1.4, 0.6, 4.2)
|
||||||
|
cs.shape = box
|
||||||
|
cs.position = Vector3(0, 0.3, 0)
|
||||||
|
b.add_child(cs)
|
||||||
|
add_child(b)
|
||||||
|
b.global_transform = berth_xf(int(moored[id].berth))
|
||||||
|
b.rest = b.global_transform
|
||||||
|
boats[id] = b
|
||||||
|
|
||||||
|
## A gentle bob on the swell (just the look).
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
_t += delta
|
||||||
|
for id in boats:
|
||||||
|
var b: Node3D = boats[id]
|
||||||
|
var k := float(int(moored[id].berth)) * 1.7
|
||||||
|
b.global_transform = b.rest.translated(Vector3(0, sin(_t * 1.1 + k) * 0.05, 0)) * Transform3D(Basis(Vector3(0, 0, 1), sin(_t * 0.8 + k) * 0.03), Vector3.ZERO)
|
||||||
|
|
||||||
|
func to_data() -> Dictionary:
|
||||||
|
return {"moored": moored.duplicate(true)}
|
||||||
|
|
||||||
|
func load_data(d: Dictionary) -> void:
|
||||||
|
moored = {}
|
||||||
|
var m: Dictionary = d.get("moored", {})
|
||||||
|
for id in m:
|
||||||
|
moored[String(id)] = {"berth": int(m[id].get("berth", 0)), "name": String(m[id].get("name", ""))}
|
||||||
|
_sync()
|
||||||
1
scripts/world/moorings.gd.uid
Normal file
1
scripts/world/moorings.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dlc5hki2qd50v
|
||||||
@@ -409,6 +409,8 @@ func _scatter_trees() -> void:
|
|||||||
node.drops_final = {"wood": [2, 3], "stick": [2, 4]}
|
node.drops_final = {"wood": [2, 3], "stick": [2, 4]}
|
||||||
node.max_hp = 3.0
|
node.max_hp = 3.0
|
||||||
node.hp = 3.0
|
node.hp = 3.0
|
||||||
|
elif vi in conifers:
|
||||||
|
node.drops_final["pine_resin"] = [1, 2] # (0.6.0: pitch for a boat)
|
||||||
var cs := CollisionShape3D.new()
|
var cs := CollisionShape3D.new()
|
||||||
cs.shape = cap
|
cs.shape = cap
|
||||||
cs.position = Vector3(0, cap.height * 0.5 + 0.1, 0)
|
cs.position = Vector3(0, cap.height * 0.5 + 0.1, 0)
|
||||||
@@ -423,6 +425,8 @@ func _scatter_trees() -> void:
|
|||||||
func _on_clifftop(x: float, z: float) -> bool:
|
func _on_clifftop(x: float, z: float) -> bool:
|
||||||
if pier != null and pier.covers(x, z, 2.0):
|
if pier != null and pier.covers(x, z, 2.0):
|
||||||
return true
|
return true
|
||||||
|
if terrain.near_stream(x, z, 1.2):
|
||||||
|
return true # (the stream's bed and banks)
|
||||||
for c in keep_clear:
|
for c in keep_clear:
|
||||||
if Vector2(x - c[0].x, z - c[0].z).length() < float(c[1]):
|
if Vector2(x - c[0].x, z - c[0].z).length() < float(c[1]):
|
||||||
return true
|
return true
|
||||||
@@ -615,7 +619,7 @@ func _place_logs() -> void:
|
|||||||
continue
|
continue
|
||||||
var h: float = terrain.sample_height(x, z)
|
var h: float = terrain.sample_height(x, z)
|
||||||
var n: Vector3 = terrain.sample_normal(x, z)
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0:
|
if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0 or terrain.near_stream(x, z, 1.5):
|
||||||
continue
|
continue
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
var shape: Shape3D
|
var shape: Shape3D
|
||||||
@@ -662,9 +666,9 @@ func _scatter_pickups() -> void:
|
|||||||
rng.seed = world_seed + 6
|
rng.seed = world_seed + 6
|
||||||
# Order matters: new kinds go at the END so existing saves keep their ids.
|
# Order matters: new kinds go at the END so existing saves keep their ids.
|
||||||
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150,
|
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150,
|
||||||
"iron_nugget": 70, "clay": 60, "salt": 40}
|
"iron_nugget": 70, "clay": 60, "salt": 40, "driftwood": 45, "seagrass": 40}
|
||||||
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45,
|
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45,
|
||||||
"iron_nugget": 0.25, "clay": 0.3, "salt": 0.25}
|
"iron_nugget": 0.25, "clay": 0.3, "salt": 0.25, "driftwood": 0.5, "seagrass": 0.4}
|
||||||
for id: String in want:
|
for id: String in want:
|
||||||
# Fibre grows as a plant (wild flax) you pick, 2 fibre a clump.
|
# Fibre grows as a plant (wild flax) you pick, 2 fibre a clump.
|
||||||
var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else ItemDB.mesh_for(id)
|
var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else ItemDB.mesh_for(id)
|
||||||
@@ -680,7 +684,7 @@ func _scatter_pickups() -> void:
|
|||||||
continue
|
continue
|
||||||
var h: float = terrain.sample_height(x, z)
|
var h: float = terrain.sample_height(x, z)
|
||||||
var n: Vector3 = terrain.sample_normal(x, z)
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
if h < terrain.water_level + 0.3 or n.y < 0.8 or (pier != null and pier.covers(x, z, 1.0)):
|
if h < terrain.water_level + 0.3 or n.y < 0.8 or (pier != null and pier.covers(x, z, 1.0)) or terrain.near_stream(x, z, 0.3):
|
||||||
continue
|
continue
|
||||||
var chance := 1.0
|
var chance := 1.0
|
||||||
match id:
|
match id:
|
||||||
@@ -707,10 +711,20 @@ func _scatter_pickups() -> void:
|
|||||||
var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h)
|
var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h)
|
||||||
chance = shore * 0.95 if by_lake else 0.0
|
chance = shore * 0.95 if by_lake else 0.0
|
||||||
"salt":
|
"salt":
|
||||||
# Crusts on the cliff tops above the sea (P4).
|
# Crusts on the cliff tops above the sea (P4), or (the Cove)
|
||||||
|
# on the sand just above the waterline.
|
||||||
chance = 0.95 if terrain.sea_weight(x, z) > 0.6 and _near_rim(x, z) > 0.35 else 0.0
|
chance = 0.95 if terrain.sea_weight(x, z) > 0.6 and _near_rim(x, z) > 0.35 else 0.0
|
||||||
|
if terrain.beach_weight(x, z) > 0.6 and h < terrain.water_level + 1.2:
|
||||||
|
chance = 0.6
|
||||||
|
"driftwood":
|
||||||
|
# Washed up along a beach (the Cove), near the waterline:
|
||||||
|
# what a boat's hull is made of.
|
||||||
|
chance = 0.95 if terrain.beach_weight(x, z) > 0.6 and h < terrain.water_level + 1.6 else 0.0
|
||||||
|
"seagrass":
|
||||||
|
# (0.6.0) Washed up at the tide line, for a boat's rope.
|
||||||
|
chance = 0.95 if terrain.beach_weight(x, z) > 0.6 and h < terrain.water_level + 0.8 else 0.0
|
||||||
# A few right by the spawn so the first craft is quick.
|
# A few right by the spawn so the first craft is quick.
|
||||||
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt"]:
|
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt", "driftwood", "seagrass"]:
|
||||||
chance += 0.5
|
chance += 0.5
|
||||||
if keep > chance:
|
if keep > chance:
|
||||||
continue
|
continue
|
||||||
@@ -742,7 +756,7 @@ func _scatter_berry_bushes(root: Node3D) -> void:
|
|||||||
continue
|
continue
|
||||||
var h: float = terrain.sample_height(x, z)
|
var h: float = terrain.sample_height(x, z)
|
||||||
var n: Vector3 = terrain.sample_normal(x, z)
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
if h < terrain.water_level + 0.6 or n.y < 0.85:
|
if h < terrain.water_level + 0.6 or n.y < 0.85 or terrain.near_stream(x, z, 1.0):
|
||||||
continue
|
continue
|
||||||
var f := _forest(x, z)
|
var f := _forest(x, z)
|
||||||
var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5
|
var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5
|
||||||
@@ -824,7 +838,7 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
|
|||||||
if not terrain.in_bounds(x, z):
|
if not terrain.in_bounds(x, z):
|
||||||
continue
|
continue
|
||||||
var h: float = terrain.sample_height(x, z)
|
var h: float = terrain.sample_height(x, z)
|
||||||
if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0:
|
if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0 or terrain.near_stream(x, z, -0.2):
|
||||||
continue
|
continue
|
||||||
var n: Vector3 = terrain.sample_normal(x, z)
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
if n.y < 0.72:
|
if n.y < 0.72:
|
||||||
|
|||||||
39
scripts/world/rock_pile.gd
Normal file
39
scripts/world/rock_pile.gd
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Boulders for hand-placed rockwork (the cliff tunnel's seam and its faces,
|
||||||
|
## the Cove's sea cave): the same low-poly rocks as the scenery and the wolf
|
||||||
|
## den (ProcMesh.rock, rock.gdshader: moss on top, snow up high), each with a
|
||||||
|
## convex collider that matches it.
|
||||||
|
|
||||||
|
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
|
||||||
|
|
||||||
|
## The scenery's rock material, for a world's terrain.
|
||||||
|
static func material(terrain: Node3D) -> ShaderMaterial:
|
||||||
|
var m := ShaderMaterial.new()
|
||||||
|
m.shader = preload("res://shaders/rock.gdshader")
|
||||||
|
m.set_shader_parameter("noise_tex", terrain.noise_tex)
|
||||||
|
m.set_shader_parameter("snow_height", terrain.snow_height)
|
||||||
|
return m
|
||||||
|
|
||||||
|
## One boulder under `parent` at local `at`, `radii` across (a rock mesh is
|
||||||
|
## about 1 m round before scaling), turned `turn` radians. Its collider goes
|
||||||
|
## on `body` (which must share `parent`'s transform), unless `body` is null.
|
||||||
|
static func add(parent: Node3D, body: CollisionObject3D, seed: int, at: Vector3, radii: Vector3, turn: float,
|
||||||
|
mat: Material, tall: bool = false) -> MeshInstance3D:
|
||||||
|
var mesh := ProcMesh.rock(seed, tall)
|
||||||
|
var b := Basis(Vector3.UP, turn).scaled(radii)
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.material_override = mat
|
||||||
|
mi.transform = Transform3D(b, at)
|
||||||
|
parent.add_child(mi)
|
||||||
|
if body != null:
|
||||||
|
var hull := mesh.create_convex_shape(true, true) as ConvexPolygonShape3D
|
||||||
|
var pts := PackedVector3Array()
|
||||||
|
for p: Vector3 in hull.points:
|
||||||
|
pts.append(b * p)
|
||||||
|
hull.points = pts
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
cs.shape = hull
|
||||||
|
cs.position = at
|
||||||
|
body.add_child(cs)
|
||||||
|
return mi
|
||||||
1
scripts/world/rock_pile.gd.uid
Normal file
1
scripts/world/rock_pile.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ijajgdb0unan
|
||||||
@@ -8,6 +8,8 @@ extends Node3D
|
|||||||
## Placed from the terrain alone, like the pier, so it's the same for
|
## Placed from the terrain alone, like the pier, so it's the same for
|
||||||
## everyone; nothing about it is saved here (the tunnel is the Valley's).
|
## everyone; nothing about it is saved here (the tunnel is the Valley's).
|
||||||
|
|
||||||
|
const RockPile = preload("res://scripts/world/rock_pile.gd")
|
||||||
|
|
||||||
var world: Node3D
|
var world: Node3D
|
||||||
var to := "valley"
|
var to := "valley"
|
||||||
var front := Vector3.ZERO ## where you stand in front of it (arriving from the tunnel)
|
var front := Vector3.ZERO ## where you stand in front of it (arriving from the tunnel)
|
||||||
@@ -49,6 +51,11 @@ func place(w: Node3D) -> bool:
|
|||||||
|
|
||||||
## Whether the Valley's tunnel has been dug right down to here.
|
## Whether the Valley's tunnel has been dug right down to here.
|
||||||
func open() -> bool:
|
func open() -> bool:
|
||||||
|
var full: int = preload("res://scripts/world/tunnel.gd").STRETCHES
|
||||||
|
if not Lands.land.is_empty():
|
||||||
|
return Lands.tunnel_dug >= full # (a land server: the hub says how far it's dug)
|
||||||
|
if world.is_online():
|
||||||
|
return int(Net.world_info.get("tunnel_dug", 0)) >= full # (as the server said when we came in)
|
||||||
if not Saves.has_world():
|
if not Saves.has_world():
|
||||||
return false
|
return false
|
||||||
var s: Dictionary = Saves.load_state(Saves.current_id, to)
|
var s: Dictionary = Saves.load_state(Saves.current_id, to)
|
||||||
@@ -66,44 +73,36 @@ func interact(_player: Node) -> void:
|
|||||||
Actions.notify.emit(interact_prompt(null))
|
Actions.notify.emit(interact_prompt(null))
|
||||||
|
|
||||||
func _build(w: Node3D) -> void:
|
func _build(w: Node3D) -> void:
|
||||||
var rock := StandardMaterial3D.new()
|
var rock: ShaderMaterial = RockPile.material(w.terrain)
|
||||||
rock.albedo_color = Color(0.42, 0.40, 0.38)
|
|
||||||
rock.roughness = 1.0
|
|
||||||
if w.terrain.noise_tex != null:
|
|
||||||
rock.albedo_texture = w.terrain.noise_tex
|
|
||||||
rock.uv1_triplanar = true
|
|
||||||
rock.uv1_scale = Vector3(0.18, 0.18, 0.18)
|
|
||||||
var body := StaticBody3D.new()
|
var body := StaticBody3D.new()
|
||||||
add_child(body)
|
add_child(body)
|
||||||
# (local -Z faces the beach; the rocks are behind, +Z)
|
# A heap of big boulders at the foot of the headland, with a dark mouth
|
||||||
for part in [[Vector3(-2.0, 1.6, 1.0), Vector3(1.8, 3.6, 3.0)], [Vector3(2.0, 1.4, 1.0), Vector3(1.8, 3.2, 3.0)],
|
# between them (local -Z faces the beach; the heap is behind, +Z), and
|
||||||
[Vector3(0, 3.5, 1.0), Vector3(5.8, 1.4, 3.2)], [Vector3(0, 1.4, 2.4), Vector3(2.4, 3.0, 0.6)],
|
# seaweed-dark stones scattered on the sand in front.
|
||||||
[Vector3(-3.4, 1.0, 2.0), Vector3(2.0, 2.4, 3.0)], [Vector3(3.2, 0.8, 2.2), Vector3(1.8, 2.0, 2.6)]]:
|
var s: int = w.terrain.noise_seed + 5100
|
||||||
var mi := MeshInstance3D.new()
|
for part in [[Vector3(-2.4, 1.3, 1.0), Vector3(1.3, 2.1, 1.7), 0.5, true],
|
||||||
var bm := BoxMesh.new()
|
[Vector3(2.4, 1.15, 1.0), Vector3(1.25, 1.9, 1.6), 2.0, true],
|
||||||
bm.size = part[1]
|
[Vector3(0.0, 3.75, 1.2), Vector3(2.9, 1.0, 1.9), 0.3, false],
|
||||||
mi.mesh = bm
|
[Vector3(0.0, 1.8, 3.2), Vector3(2.4, 2.4, 1.4), 1.1, false],
|
||||||
mi.material_override = rock
|
[Vector3(-4.3, 0.8, 2.0), Vector3(1.8, 1.5, 1.8), 2.6, false],
|
||||||
mi.position = part[0]
|
[Vector3(4.1, 0.6, 2.2), Vector3(1.6, 1.3, 1.7), 0.7, false],
|
||||||
mi.rotation.y = part[0].x * 0.06
|
[Vector3(-2.0, 4.4, 2.8), Vector3(1.8, 1.2, 1.6), 1.7, false],
|
||||||
add_child(mi)
|
[Vector3(2.3, 4.1, 3.0), Vector3(1.6, 1.1, 1.5), 2.9, false]]:
|
||||||
var cs := CollisionShape3D.new()
|
RockPile.add(self, body, s, part[0], part[1], part[2], rock, part[3])
|
||||||
var box := BoxShape3D.new()
|
s += 1
|
||||||
box.size = part[1]
|
for stone in [[Vector3(-1.4, 0.08, -1.6), 0.4], [Vector3(1.6, 0.06, -2.2), 0.3],
|
||||||
cs.shape = box
|
[Vector3(-3.2, 0.06, -1.0), 0.35], [Vector3(0.4, 0.05, -2.9), 0.22], [Vector3(3.4, 0.05, -1.2), 0.28]]:
|
||||||
cs.position = part[0]
|
RockPile.add(self, null, s, stone[0], Vector3.ONE * float(stone[1]), float(s), rock)
|
||||||
cs.rotation.y = part[0].x * 0.06
|
s += 1
|
||||||
body.add_child(cs)
|
|
||||||
var dark := MeshInstance3D.new()
|
var dark := MeshInstance3D.new()
|
||||||
var q := QuadMesh.new()
|
var bm := BoxMesh.new()
|
||||||
q.size = Vector2(2.2, 2.8)
|
bm.size = Vector3(2.0, 2.6, 0.1)
|
||||||
dark.mesh = q
|
dark.mesh = bm
|
||||||
var m := StandardMaterial3D.new()
|
var m := StandardMaterial3D.new()
|
||||||
m.albedo_color = Color(0.02, 0.02, 0.025)
|
m.albedo_color = Color(0.015, 0.014, 0.018)
|
||||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
dark.material_override = m
|
dark.material_override = m
|
||||||
dark.position = Vector3(0, 1.4, 2.08)
|
dark.position = Vector3(0, 1.3, 1.6)
|
||||||
dark.rotation.y = PI
|
|
||||||
add_child(dark)
|
add_child(dark)
|
||||||
# What you press E on: the opening (interactables layer: you walk into it).
|
# What you press E on: the opening (interactables layer: you walk into it).
|
||||||
var spot := StaticBody3D.new()
|
var spot := StaticBody3D.new()
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func _find_site() -> Vector3:
|
|||||||
hs.append(terrain.sample_height(c.x + cos(b) * RING_R, c.y + sin(b) * RING_R))
|
hs.append(terrain.sample_height(c.x + cos(b) * RING_R, c.y + sin(b) * RING_R))
|
||||||
var h0: float = terrain.sample_height(c.x, c.y)
|
var h0: float = terrain.sample_height(c.x, c.y)
|
||||||
hs.append(h0)
|
hs.append(h0)
|
||||||
if hs.max() - hs.min() > float(pass_[0]) or hs.min() < terrain.water_level + 1.0:
|
if hs.max() - hs.min() > float(pass_[0]) or hs.min() < terrain.water_level + 1.0 or terrain.near_stream(c.x, c.y, RING_R + 3.0):
|
||||||
continue
|
continue
|
||||||
return Vector3(c.x, h0, c.y)
|
return Vector3(c.x, h0, c.y)
|
||||||
return Vector3.INF
|
return Vector3.INF
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func _ready() -> void:
|
|||||||
elif saves != null and saves.has_world():
|
elif saves != null and saves.has_world():
|
||||||
noise_seed = saves.world_seed()
|
noise_seed = saves.world_seed()
|
||||||
# (worlds from before worldlets have none: they're the Valley)
|
# (worlds from before worldlets have none: they're the Valley)
|
||||||
worldlet_id = String(saves.read_meta(saves.current_id).get("worldlet", "valley"))
|
worldlet_id = String(saves.worldlet_of(saves.current_id)) # (a land server's own land, or the world's)
|
||||||
var why := use_worldlet(worldlet_id)
|
var why := use_worldlet(worldlet_id)
|
||||||
if not why.is_empty():
|
if not why.is_empty():
|
||||||
push_error("[terrain] %s: using the Valley" % why)
|
push_error("[terrain] %s: using the Valley" % why)
|
||||||
@@ -174,11 +174,18 @@ func _wall_threshold(x: float, z: float) -> float:
|
|||||||
# (data/worldlets/<id>.json "terrain"). Each block takes the ground so far and
|
# (data/worldlets/<id>.json "terrain"). Each block takes the ground so far and
|
||||||
# returns it changed; "hills" starts it. A worldlet can only combine these.
|
# returns it changed; "hills" starts it. A worldlet can only combine these.
|
||||||
|
|
||||||
const BLOCKS := ["hills", "rim", "sea_cliff", "beach", "lake", "ocean_edge"]
|
const BLOCKS := ["hills", "rim", "sea_cliff", "beach", "lake", "stream", "ocean_edge"]
|
||||||
var worldlet_id := "valley"
|
var worldlet_id := "valley"
|
||||||
var worldlet: Dictionary = {} ## the definition, as loaded
|
var worldlet: Dictionary = {} ## the definition, as loaded
|
||||||
var _blocks: Array = [] ## its terrain blocks, in order
|
var _blocks: Array = [] ## its terrain blocks, in order
|
||||||
var lakes: Array = [] ## every "lake" block's {center: Vector2, radius} (W2: several)
|
var lakes: Array = [] ## every "lake" block's {center: Vector2, radius} (W2: several)
|
||||||
|
var stream_path := PackedVector2Array() ## a "stream" block's points, spring first (W3: the Cove's)
|
||||||
|
var stream_width := 2.4
|
||||||
|
var stream_depth := 0.6
|
||||||
|
var _stream_profile := PackedFloat32Array() ## its water surface, every STREAM_STEP m along it
|
||||||
|
var _stream_len := 0.0 ## its length
|
||||||
|
var _stream_end := 0.0 ## where it meets the sea (its water ends there)
|
||||||
|
const STREAM_STEP := 1.0
|
||||||
var _has_sea := true ## a sea cliff (the Valley's)
|
var _has_sea := true ## a sea cliff (the Valley's)
|
||||||
var _has_beach := false ## a beach (W2)
|
var _has_beach := false ## a beach (W2)
|
||||||
var _valley := 0.0 ## the hills before anything else (the sea cliff eases from it)
|
var _valley := 0.0 ## the hills before anything else (the sea cliff eases from it)
|
||||||
@@ -231,6 +238,8 @@ func use_worldlet(id: String) -> String:
|
|||||||
resolution = int(d.get("resolution", resolution))
|
resolution = int(d.get("resolution", resolution))
|
||||||
_blocks = d.get("terrain", [])
|
_blocks = d.get("terrain", [])
|
||||||
lakes = []
|
lakes = []
|
||||||
|
stream_path = PackedVector2Array()
|
||||||
|
_stream_profile = PackedFloat32Array()
|
||||||
_has_sea = _has_block("sea_cliff")
|
_has_sea = _has_block("sea_cliff")
|
||||||
_has_beach = _has_block("beach")
|
_has_beach = _has_block("beach")
|
||||||
for b in _blocks:
|
for b in _blocks:
|
||||||
@@ -252,6 +261,12 @@ func use_worldlet(id: String) -> String:
|
|||||||
cliff_top = float(b.get("cliff_top", cliff_top))
|
cliff_top = float(b.get("cliff_top", cliff_top))
|
||||||
cliff_offset = float(b.get("cliff_offset", cliff_offset))
|
cliff_offset = float(b.get("cliff_offset", cliff_offset))
|
||||||
cliff_drop_width = float(b.get("drop_width", cliff_drop_width))
|
cliff_drop_width = float(b.get("drop_width", cliff_drop_width))
|
||||||
|
"stream":
|
||||||
|
stream_path = PackedVector2Array()
|
||||||
|
for q in b.get("points", []):
|
||||||
|
stream_path.append(Vector2(float(q[0]), float(q[1])))
|
||||||
|
stream_width = float(b.get("width", stream_width))
|
||||||
|
stream_depth = float(b.get("depth", stream_depth))
|
||||||
"beach":
|
"beach":
|
||||||
beach_angle_deg = float(b.get("angle_deg", beach_angle_deg))
|
beach_angle_deg = float(b.get("angle_deg", beach_angle_deg))
|
||||||
beach_half_width_deg = float(b.get("half_width_deg", beach_half_width_deg))
|
beach_half_width_deg = float(b.get("half_width_deg", beach_half_width_deg))
|
||||||
@@ -285,10 +300,14 @@ func _has_block(t: String) -> bool:
|
|||||||
return true
|
return true
|
||||||
return false
|
return false
|
||||||
|
|
||||||
func _height_fn(x: float, z: float) -> float:
|
## The ground at (x, z). `stop_at_stream`: as it is before the stream cuts
|
||||||
|
## its bed (what the stream's water level is worked out from).
|
||||||
|
func _height_fn(x: float, z: float, stop_at_stream: bool = false) -> float:
|
||||||
_ensure_noise()
|
_ensure_noise()
|
||||||
if _blocks.is_empty():
|
if _blocks.is_empty():
|
||||||
use_worldlet(worldlet_id)
|
use_worldlet(worldlet_id)
|
||||||
|
if not stop_at_stream and stream_path.size() >= 2 and _stream_profile.is_empty():
|
||||||
|
_make_stream_profile()
|
||||||
var land := 0.0
|
var land := 0.0
|
||||||
for b in _blocks:
|
for b in _blocks:
|
||||||
match String(b.type):
|
match String(b.type):
|
||||||
@@ -302,6 +321,10 @@ func _height_fn(x: float, z: float) -> float:
|
|||||||
land = _blk_beach(x, z, land)
|
land = _blk_beach(x, z, land)
|
||||||
"lake":
|
"lake":
|
||||||
land = _blk_lake(x, z, land, b._c, b._r)
|
land = _blk_lake(x, z, land, b._c, b._r)
|
||||||
|
"stream":
|
||||||
|
if stop_at_stream:
|
||||||
|
return land
|
||||||
|
land = _blk_stream(x, z, land)
|
||||||
"ocean_edge":
|
"ocean_edge":
|
||||||
land = _blk_ocean_edge(x, z, land)
|
land = _blk_ocean_edge(x, z, land)
|
||||||
return land
|
return land
|
||||||
@@ -354,6 +377,101 @@ func _blk_lake(x: float, z: float, land: float, at: Vector2, r: float) -> float:
|
|||||||
ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0
|
ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0
|
||||||
return lerpf(land, water_level - 3.2, smoothstep(r, r * 0.35, ld))
|
return lerpf(land, water_level - 3.2, smoothstep(r, r * 0.35, ld))
|
||||||
|
|
||||||
|
## A stream (W3): runs along its points from a spring to the sea. Its water
|
||||||
|
## surface comes down step by step from the ground at the spring, never
|
||||||
|
## rising (the running minimum of the land along it), so it always runs
|
||||||
|
## downhill. The bed is cut stream_depth below the surface, the banks rise a
|
||||||
|
## little above it (so the water never hangs over lower ground) and blend
|
||||||
|
## into the land, and where the land is high it cuts a little valley.
|
||||||
|
func _make_stream_profile() -> void:
|
||||||
|
_stream_profile = PackedFloat32Array()
|
||||||
|
_stream_len = 0.0
|
||||||
|
for i in range(1, stream_path.size()):
|
||||||
|
_stream_len += stream_path[i - 1].distance_to(stream_path[i])
|
||||||
|
_stream_end = _stream_len
|
||||||
|
var surf := INF
|
||||||
|
var n := int(_stream_len / STREAM_STEP) + 1
|
||||||
|
for i in range(n + 1):
|
||||||
|
var p := _stream_point(i * STREAM_STEP)
|
||||||
|
var g := _height_fn(p.x, p.y, true)
|
||||||
|
surf = minf(surf - 0.01, g - 0.25) if surf < INF else g - 0.25
|
||||||
|
surf = maxf(surf, water_level)
|
||||||
|
_stream_profile.append(surf)
|
||||||
|
if g < water_level - 0.2 and _stream_end >= _stream_len:
|
||||||
|
_stream_end = i * STREAM_STEP # (into the sea: the stream's done)
|
||||||
|
|
||||||
|
## The point `along` metres down the stream.
|
||||||
|
func _stream_point(along: float) -> Vector2:
|
||||||
|
var left := along
|
||||||
|
for i in range(1, stream_path.size()):
|
||||||
|
var seg := stream_path[i - 1].distance_to(stream_path[i])
|
||||||
|
if left <= seg or i == stream_path.size() - 1:
|
||||||
|
return stream_path[i - 1].lerp(stream_path[i], clampf(left / maxf(seg, 0.001), 0.0, 1.0))
|
||||||
|
left -= seg
|
||||||
|
return stream_path[stream_path.size() - 1]
|
||||||
|
|
||||||
|
## How far (x, z) is from the stream's line, and how far along it that is.
|
||||||
|
func _stream_near(x: float, z: float) -> Vector2:
|
||||||
|
var p := Vector2(x, z)
|
||||||
|
var best := INF
|
||||||
|
var best_along := 0.0
|
||||||
|
var run := 0.0
|
||||||
|
for i in range(1, stream_path.size()):
|
||||||
|
var a := stream_path[i - 1]
|
||||||
|
var b := stream_path[i]
|
||||||
|
var ab := b - a
|
||||||
|
var t := clampf((p - a).dot(ab) / maxf(ab.length_squared(), 0.001), 0.0, 1.0)
|
||||||
|
var d := p.distance_to(a + ab * t)
|
||||||
|
if d < best:
|
||||||
|
best = d
|
||||||
|
best_along = run + t * ab.length()
|
||||||
|
run += ab.length()
|
||||||
|
return Vector2(best, best_along)
|
||||||
|
|
||||||
|
func _stream_surface_at(along: float) -> float:
|
||||||
|
var f := clampf(along / STREAM_STEP, 0.0, _stream_profile.size() - 1.001)
|
||||||
|
var i := int(f)
|
||||||
|
return lerpf(_stream_profile[i], _stream_profile[i + 1], f - i)
|
||||||
|
|
||||||
|
func _blk_stream(x: float, z: float, land: float) -> float:
|
||||||
|
if _stream_profile.size() < 2:
|
||||||
|
return land
|
||||||
|
var near := _stream_near(x, z)
|
||||||
|
var hw := stream_width * 0.5
|
||||||
|
var k := near.x / hw
|
||||||
|
if k > 6.0:
|
||||||
|
return land
|
||||||
|
var along := near.y
|
||||||
|
var surf := _stream_surface_at(along)
|
||||||
|
var target: float
|
||||||
|
if k < 1.0:
|
||||||
|
target = surf - stream_depth * (1.0 - k * k)
|
||||||
|
else:
|
||||||
|
target = surf + 0.12 + (k - 1.0) * 0.3
|
||||||
|
# Down to the bed, up to the banks; where the land's high, a gentle
|
||||||
|
# valley rather than a slot.
|
||||||
|
var w := smoothstep(6.0, 1.6, k) if land > target else smoothstep(2.6, 1.6, k)
|
||||||
|
w *= smoothstep(0.0, 5.0, along) # (the spring)
|
||||||
|
w *= 1.0 - smoothstep(_stream_end - 8.0, _stream_end, along) # (into the sea)
|
||||||
|
return lerpf(land, target, w)
|
||||||
|
|
||||||
|
## The stream's water surface at (x, z), or -INF where there's no stream.
|
||||||
|
func stream_surface(x: float, z: float) -> float:
|
||||||
|
if _stream_profile.size() < 2:
|
||||||
|
return -INF
|
||||||
|
var near := _stream_near(x, z)
|
||||||
|
if near.x > stream_width * 0.5 or near.y > _stream_end - 1.0 or near.y < 0.5:
|
||||||
|
return -INF
|
||||||
|
return _stream_surface_at(near.y)
|
||||||
|
|
||||||
|
## Whether (x, z) is in or by the stream (within `margin` m of its banks):
|
||||||
|
## scenery and landmarks keep clear.
|
||||||
|
func near_stream(x: float, z: float, margin: float = 1.0) -> bool:
|
||||||
|
if _stream_profile.size() < 2:
|
||||||
|
return false
|
||||||
|
var near := _stream_near(x, z)
|
||||||
|
return near.x < stream_width * 0.5 + margin and near.y < _stream_end
|
||||||
|
|
||||||
## A beach (W2): on its side of the rim the land eases down to a sandy
|
## A beach (W2): on its side of the rim the land eases down to a sandy
|
||||||
## waterline, then shelves gently into the sea (beach_slope deeper per
|
## waterline, then shelves gently into the sea (beach_slope deeper per
|
||||||
## metre), so you can wade, then swim, until the current turns you back.
|
## metre), so you can wade, then swim, until the current turns you back.
|
||||||
@@ -572,6 +690,11 @@ func _build_water() -> void:
|
|||||||
if absf(sea_level - water_level) > 0.05: # (by a beach the sea is the same surface: no second sheet)
|
if absf(sea_level - water_level) > 0.05: # (by a beach the sea is the same surface: no second sheet)
|
||||||
_add_water("Water", disc, water_level, {})
|
_add_water("Water", disc, water_level, {})
|
||||||
|
|
||||||
|
if _stream_profile.size() >= 2:
|
||||||
|
# (Murkier than the lake, so half a metre of water reads as water, not a path.)
|
||||||
|
_add_water("Stream", _stream_mesh(), 0.0, {"clarity": 0.45,
|
||||||
|
"shallow_col": Color(0.20, 0.42, 0.46), "deep_col": Color(0.05, 0.16, 0.22)})
|
||||||
|
|
||||||
var ocean := PlaneMesh.new()
|
var ocean := PlaneMesh.new()
|
||||||
ocean.size = Vector2(8000.0, 8000.0)
|
ocean.size = Vector2(8000.0, 8000.0)
|
||||||
_add_water("Ocean", ocean, sea_level, {
|
_add_water("Ocean", ocean, sea_level, {
|
||||||
@@ -580,6 +703,40 @@ func _build_water() -> void:
|
|||||||
"clarity": 6.0,
|
"clarity": 6.0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
## The stream's water: a ribbon down its bed, just under the tops of the
|
||||||
|
## banks, each point at the surface there. It stops where it meets the sea
|
||||||
|
## (whose own water carries on from there).
|
||||||
|
func _stream_mesh() -> ArrayMesh:
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
var hw := stream_width * 0.5 * 1.05
|
||||||
|
var prev_l := Vector3.ZERO
|
||||||
|
var prev_r := Vector3.ZERO
|
||||||
|
var have := false
|
||||||
|
var along := 0.6
|
||||||
|
var stop := _stream_end - 1.0
|
||||||
|
while along < stop:
|
||||||
|
var p := _stream_point(along)
|
||||||
|
var q := _stream_point(minf(along + 0.5, _stream_len))
|
||||||
|
var dir := (q - p).normalized() if q.distance_to(p) > 0.001 else Vector2(0, 1)
|
||||||
|
var side := Vector2(-dir.y, dir.x) * hw
|
||||||
|
var y := _stream_surface_at(along) + 0.02
|
||||||
|
if y <= water_level + 0.06:
|
||||||
|
break # (level with the sea: its water takes over)
|
||||||
|
var l := Vector3(p.x + side.x, y, p.y + side.y)
|
||||||
|
var r := Vector3(p.x - side.x, y, p.y - side.y)
|
||||||
|
if have:
|
||||||
|
for v in [prev_l, l, prev_r, prev_r, l, r]:
|
||||||
|
st.set_normal(Vector3.UP)
|
||||||
|
st.set_tangent(Plane(1, 0, 0, 1))
|
||||||
|
st.set_uv(Vector2(v.x, v.z) / size + Vector2(0.5, 0.5))
|
||||||
|
st.add_vertex(v)
|
||||||
|
prev_l = l
|
||||||
|
prev_r = r
|
||||||
|
have = true
|
||||||
|
along += 0.75
|
||||||
|
return st.commit()
|
||||||
|
|
||||||
func _add_water(label: String, mesh: Mesh, y: float, params: Dictionary) -> void:
|
func _add_water(label: String, mesh: Mesh, y: float, params: Dictionary) -> void:
|
||||||
var mat := ShaderMaterial.new()
|
var mat := ShaderMaterial.new()
|
||||||
mat.shader = preload("res://shaders/water.gdshader")
|
mat.shader = preload("res://shaders/water.gdshader")
|
||||||
@@ -611,6 +768,14 @@ func sea_weight(x: float, z: float) -> float:
|
|||||||
_ensure_noise()
|
_ensure_noise()
|
||||||
return _sea_w(x, z)
|
return _sea_w(x, z)
|
||||||
|
|
||||||
|
## Whether (x, z) is on the sand (0.6.0): the beach side of the rim, low
|
||||||
|
## enough for the sandy shoreline (terrain.gdshader paints sand below
|
||||||
|
## water_level + SAND_TOP), or under the water off it.
|
||||||
|
const SAND_TOP := 1.2
|
||||||
|
func on_sand(x: float, z: float) -> bool:
|
||||||
|
_ensure_noise()
|
||||||
|
return _beach_w(x, z) > 0.5 and sample_height(x, z) < water_level + SAND_TOP
|
||||||
|
|
||||||
## How strongly (x, z) lies on the beach side of the rim (0..1; W2).
|
## How strongly (x, z) lies on the beach side of the rim (0..1; W2).
|
||||||
func beach_weight(x: float, z: float) -> float:
|
func beach_weight(x: float, z: float) -> float:
|
||||||
_ensure_noise()
|
_ensure_noise()
|
||||||
|
|||||||
209
scripts/world/treasure.gd
Normal file
209
scripts/world/treasure.gd
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
extends Node3D
|
||||||
|
## The pirate treasure (0.6.0, docs/WORLDLETS_DESIGN.md §5). Each player's
|
||||||
|
## treasure map (the wreck's sea chest) has its own X (world.treasure_spot).
|
||||||
|
## While you carry your map, a mound of loose earth by a marker stone shows
|
||||||
|
## at your X (only to you); a pickaxe digs it, and the pirate chest comes up:
|
||||||
|
## a ship's compass, a spyglass, a cutlass and pirate gold. The map's used up.
|
||||||
|
## Once for each player.
|
||||||
|
##
|
||||||
|
## The drowned crew guard it: digging at night brings them up round you on
|
||||||
|
## the first blow; by day they rise as the chest comes up. They're the Cove's
|
||||||
|
## barrow guards (guard.gd, kind "drowned", at a crew_site.gd).
|
||||||
|
##
|
||||||
|
## Single-player, all here. Online, the server keeps whose treasure's been
|
||||||
|
## dug and how far, gives the loot and runs the crew; your game only shows
|
||||||
|
## your own mound and asks the server to dig it.
|
||||||
|
|
||||||
|
const MoundScript = preload("res://scripts/world/treasure_mound.gd")
|
||||||
|
const CrewSite = preload("res://scripts/world/crew_site.gd")
|
||||||
|
const GuardScript = preload("res://scripts/creatures/guard.gd")
|
||||||
|
const RockPile = preload("res://scripts/world/rock_pile.gd")
|
||||||
|
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
|
||||||
|
const LOOT := {"ship_compass": 1, "spyglass": 1, "cutlass": 1, "pirate_gold": 3}
|
||||||
|
const CREW := 3
|
||||||
|
const DIG_HP := 18.0
|
||||||
|
|
||||||
|
var world: Node3D
|
||||||
|
var dug: Array = [] ## the player ids whose treasure's been dug up
|
||||||
|
var left: Dictionary = {} ## player id -> what's left of their mound (dug a bit)
|
||||||
|
var mounds: Dictionary = {} ## player id -> treasure_mound.gd
|
||||||
|
var crews: Dictionary = {} ## player id -> crew_site.gd, while they're up
|
||||||
|
var force_crew := false ## (tests) raise the crew even with creatures off
|
||||||
|
var _look_left := 0.0
|
||||||
|
|
||||||
|
func setup(w: Node3D) -> bool:
|
||||||
|
world = w
|
||||||
|
if w.wreck == null:
|
||||||
|
return false
|
||||||
|
Actions.harvested.connect(_on_harvested)
|
||||||
|
add_to_group("treasure")
|
||||||
|
return true
|
||||||
|
|
||||||
|
# --- the mounds ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Player `id`'s mound (made if need be), or null if theirs is dug or they
|
||||||
|
## have no X here.
|
||||||
|
func mound_for(id: String) -> StaticBody3D:
|
||||||
|
if id in dug:
|
||||||
|
return null
|
||||||
|
if mounds.has(id) and is_instance_valid(mounds[id]):
|
||||||
|
return mounds[id]
|
||||||
|
var at: Vector3 = world.treasure_spot(id)
|
||||||
|
if at == Vector3.INF:
|
||||||
|
return null
|
||||||
|
var m: StaticBody3D = MoundScript.new()
|
||||||
|
m.name = "Mound_%s" % id.substr(0, 8)
|
||||||
|
m.treasure = self
|
||||||
|
m.owner_id = id
|
||||||
|
m.uid = "treasure:" + id
|
||||||
|
m.max_hp = DIG_HP
|
||||||
|
m.hp = clampf(float(left.get(id, DIG_HP)), 0.5, DIG_HP)
|
||||||
|
m.collision_layer = 1
|
||||||
|
m.add_to_group("harvestable")
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = Vector3(1.6, 0.6, 1.6)
|
||||||
|
cs.shape = box
|
||||||
|
cs.position = Vector3(0, 0.2, 0)
|
||||||
|
m.add_child(cs)
|
||||||
|
if not world.server_mode:
|
||||||
|
_dress(m, id)
|
||||||
|
add_child(m)
|
||||||
|
m.global_position = at
|
||||||
|
mounds[id] = m
|
||||||
|
return m
|
||||||
|
|
||||||
|
## How a mound looks: loose earth, a marker stone, two sticks crossed in it.
|
||||||
|
func _dress(m: Node3D, id: String) -> void:
|
||||||
|
var st := ProcMesh._begin()
|
||||||
|
var r := RandomNumberGenerator.new()
|
||||||
|
r.seed = hash(id)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = hash(id) % 1000
|
||||||
|
n.frequency = 1.5
|
||||||
|
ProcMesh._blob(st, Vector3(0, 0.05, 0), Vector3(0.9, 0.28, 0.8), n, 0.3, Color(0.34, 0.25, 0.16), r, 0)
|
||||||
|
ProcMesh._cylinder(st, Vector3(-0.25, 0.1, -0.25), Vector3(0.25, 0.7, 0.25), 0.03, 0.025, 4, Color(0.4, 0.3, 0.18), r)
|
||||||
|
ProcMesh._cylinder(st, Vector3(0.25, 0.1, -0.25), Vector3(-0.25, 0.7, 0.25), 0.03, 0.025, 4, Color(0.4, 0.3, 0.18), r)
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
mi.mesh = ProcMesh._commit(st)
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.vertex_color_use_as_albedo = true
|
||||||
|
mat.vertex_color_is_srgb = true
|
||||||
|
mat.roughness = 1.0
|
||||||
|
mi.material_override = mat
|
||||||
|
m.add_child(mi)
|
||||||
|
RockPile.add(m, null, hash(id) % 997, Vector3(1.3, 0.35, 0.2), Vector3(0.35, 0.8, 0.3), 0.6, RockPile.material(world.terrain), true)
|
||||||
|
|
||||||
|
## (Your game) your mound shows while you carry your map and it's not dug.
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if world.server_mode:
|
||||||
|
return
|
||||||
|
_look_left -= delta
|
||||||
|
if _look_left > 0.0:
|
||||||
|
return
|
||||||
|
_look_left = 0.5
|
||||||
|
var me: String = world.my_id()
|
||||||
|
var want: bool = not me in dug and world.player.inventory.count("treasure_map") > 0
|
||||||
|
if want:
|
||||||
|
mound_for(me)
|
||||||
|
elif mounds.has(me) and is_instance_valid(mounds[me]):
|
||||||
|
mounds[me].queue_free()
|
||||||
|
mounds.erase(me)
|
||||||
|
|
||||||
|
# --- digging ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## A blow on a mound (treasure_mound.gd): remembered, and at night the first
|
||||||
|
## one brings the crew up.
|
||||||
|
func mound_hit(m: Node) -> void:
|
||||||
|
left[m.owner_id] = m.hp
|
||||||
|
if Net.is_server():
|
||||||
|
WorldNet.treasure_state(to_data())
|
||||||
|
if _night() and not crews.has(m.owner_id):
|
||||||
|
_raise_crew(m.owner_id, m.global_position)
|
||||||
|
|
||||||
|
func _on_harvested(p: Node, node: Node, felled: bool) -> void:
|
||||||
|
if not felled or node == null or node.get_script() != MoundScript or node.treasure != self:
|
||||||
|
return
|
||||||
|
_dug_up(p, String(node.owner_id), node.global_position)
|
||||||
|
|
||||||
|
## The chest comes up: the loot for the player who dug it, the map used up.
|
||||||
|
func _dug_up(p: Node, id: String, at: Vector3) -> void:
|
||||||
|
if id in dug:
|
||||||
|
return
|
||||||
|
dug.append(id)
|
||||||
|
left.erase(id)
|
||||||
|
if mounds.has(id) and is_instance_valid(mounds[id]):
|
||||||
|
mounds[id].queue_free()
|
||||||
|
mounds.erase(id)
|
||||||
|
p.inventory.remove("treasure_map", 1)
|
||||||
|
var spilled := false
|
||||||
|
for item in LOOT:
|
||||||
|
var rest: int = p.inventory.add(item, int(LOOT[item]))
|
||||||
|
if rest > 0:
|
||||||
|
spilled = true
|
||||||
|
world.drop_item(item, rest, Transform3D(Basis(), at + Vector3(0, 0.3, 0)))
|
||||||
|
Actions.tell(p, "The pirate chest! A ship's compass, a spyglass, a cutlass and %d pirate gold%s" % [
|
||||||
|
int(LOOT.pirate_gold), " (some at your feet: your bag's full)" if spilled else ""])
|
||||||
|
Sfx.at("door_open", at)
|
||||||
|
if not crews.has(id):
|
||||||
|
_raise_crew(id, at)
|
||||||
|
if crews.has(id):
|
||||||
|
Actions.tell(p, "Something's clawing its way up out of the ground...")
|
||||||
|
if Net.is_server():
|
||||||
|
WorldNet.treasure_state(to_data())
|
||||||
|
|
||||||
|
func _night() -> bool:
|
||||||
|
var h: float = world.day_night.hours()
|
||||||
|
return h >= 19.5 or h < 5.5
|
||||||
|
|
||||||
|
## The drowned crew rise round `at` (where creatures run: here alone, or the
|
||||||
|
## server; not with --no-creatures, unless a test asks).
|
||||||
|
func _raise_crew(id: String, at: Vector3) -> void:
|
||||||
|
if world.is_online():
|
||||||
|
return
|
||||||
|
var running: bool = force_crew or (not "--no-creatures" in OS.get_cmdline_user_args() \
|
||||||
|
and (world.server_mode or world.creatures.enabled))
|
||||||
|
if not running:
|
||||||
|
return
|
||||||
|
var site: Node3D = CrewSite.new()
|
||||||
|
site.name = "Crew_%s" % id.substr(0, 8)
|
||||||
|
site.world = world
|
||||||
|
site.spot = at
|
||||||
|
add_child(site)
|
||||||
|
var t: Node3D = world.terrain
|
||||||
|
for i in range(CREW):
|
||||||
|
var a := TAU * i / CREW + float(hash(id) % 100) * 0.06
|
||||||
|
var post := at + Vector3(cos(a), 0, sin(a)) * 5.0
|
||||||
|
post.y = t.sample_height(post.x, post.z)
|
||||||
|
var g: AnimatableBody3D = GuardScript.new()
|
||||||
|
g.setup("drowned", world, post, hash(id) % 10000 + i)
|
||||||
|
g.barrow = site
|
||||||
|
g.post = post
|
||||||
|
g.post_yaw = atan2(-(at - post).x, -(at - post).z)
|
||||||
|
g.state = "charge"
|
||||||
|
g.yaw = g.post_yaw
|
||||||
|
g.rotation.y = g.yaw
|
||||||
|
g.top_level = true
|
||||||
|
site.add_child(g)
|
||||||
|
g.global_position = post
|
||||||
|
site.crew.append(g)
|
||||||
|
crews[id] = site
|
||||||
|
site.tree_exited.connect(func(): crews.erase(id))
|
||||||
|
|
||||||
|
# --- saving ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func to_data() -> Dictionary:
|
||||||
|
return {"dug": dug.duplicate(), "left": left.duplicate()}
|
||||||
|
|
||||||
|
func load_data(d: Dictionary) -> void:
|
||||||
|
dug = (d.get("dug", []) as Array).duplicate()
|
||||||
|
left = (d.get("left", {}) as Dictionary).duplicate()
|
||||||
|
for id in mounds.keys():
|
||||||
|
var m: Node = mounds[id]
|
||||||
|
if not is_instance_valid(m):
|
||||||
|
mounds.erase(id)
|
||||||
|
elif id in dug:
|
||||||
|
m.queue_free()
|
||||||
|
mounds.erase(id)
|
||||||
|
else:
|
||||||
|
m.hp = clampf(float(left.get(id, DIG_HP)), 0.5, DIG_HP)
|
||||||
1
scripts/world/treasure.gd.uid
Normal file
1
scripts/world/treasure.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dscr3836h55qo
|
||||||
35
scripts/world/treasure_mound.gd
Normal file
35
scripts/world/treasure_mound.gd
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
extends StaticBody3D
|
||||||
|
## Your X, on the ground (treasure.gd): a mound of loose earth by a marker
|
||||||
|
## stone, with two sticks crossed in it. It answers like a boulder does
|
||||||
|
## (Actions.harvest_hit), so a pickaxe digs it; the treasure decides the rest.
|
||||||
|
|
||||||
|
var treasure: Node3D
|
||||||
|
var owner_id := ""
|
||||||
|
var uid := ""
|
||||||
|
var tool_kind := "pickaxe"
|
||||||
|
var min_tier := 1
|
||||||
|
var max_hp := 18.0
|
||||||
|
var hp := 18.0
|
||||||
|
var depleted := false
|
||||||
|
var kind := "rock"
|
||||||
|
var chip_color := Color(0.36, 0.27, 0.18)
|
||||||
|
|
||||||
|
func display_name() -> String:
|
||||||
|
return "Loose earth"
|
||||||
|
|
||||||
|
func refusal() -> String:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
func interact_prompt(_player: Node) -> String:
|
||||||
|
return "Loose earth by a marker stone: X marks the spot. [LMB] Dig with a pickaxe (about %d blows left)" % ceili(hp)
|
||||||
|
|
||||||
|
func interact(player: Node) -> void:
|
||||||
|
Actions.tell(player, interact_prompt(player))
|
||||||
|
|
||||||
|
## One blow (from Actions.harvest_hit).
|
||||||
|
func hit(damage: float, _at: Vector3, _from: Vector3) -> Dictionary:
|
||||||
|
hp = maxf(hp - damage, 0.0)
|
||||||
|
treasure.mound_hit(self)
|
||||||
|
if hp <= 0.0 and not depleted:
|
||||||
|
depleted = true
|
||||||
|
return {}
|
||||||
1
scripts/world/treasure_mound.gd.uid
Normal file
1
scripts/world/treasure_mound.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dc0iu7ixkjwkv
|
||||||
@@ -21,6 +21,7 @@ extends Node3D
|
|||||||
## for now: on a server it isn't built (J3 brings travel online).
|
## for now: on a server it isn't built (J3 brings travel online).
|
||||||
|
|
||||||
const TunnelFace = preload("res://scripts/world/tunnel_face.gd")
|
const TunnelFace = preload("res://scripts/world/tunnel_face.gd")
|
||||||
|
const RockPile = preload("res://scripts/world/rock_pile.gd")
|
||||||
|
|
||||||
const ORIGIN := Vector3(0.0, -260.0, 0.0) ## where the tunnel space sits (well under the island)
|
const ORIGIN := Vector3(0.0, -260.0, 0.0) ## where the tunnel space sits (well under the island)
|
||||||
const STRETCHES := 6
|
const STRETCHES := 6
|
||||||
@@ -54,8 +55,9 @@ var bottom := Vector3.ZERO ## inside, at the bottom by the cave
|
|||||||
var cave_mouth := Vector3.ZERO ## the far end of the sea cave
|
var cave_mouth := Vector3.ZERO ## the far end of the sea cave
|
||||||
var aabb := AABB()
|
var aabb := AABB()
|
||||||
|
|
||||||
var _rock_mat: StandardMaterial3D
|
var _rock_mat: StandardMaterial3D ## the tunnel's walls
|
||||||
var _face_mat: StandardMaterial3D
|
var _boulder_mat: ShaderMaterial ## the seam's outcrop (rock_pile.gd)
|
||||||
|
var _face_mat: ShaderMaterial ## the faces you dig
|
||||||
var _wood_mat: StandardMaterial3D
|
var _wood_mat: StandardMaterial3D
|
||||||
var _rock: CSGCombiner3D
|
var _rock: CSGCombiner3D
|
||||||
var _cuts: Node3D
|
var _cuts: Node3D
|
||||||
@@ -141,8 +143,20 @@ func dig_refusal(stage: int) -> String:
|
|||||||
return "It's loose: prop the last stretch first ([E] at its mouth, %d wood)" % PROP_WOOD
|
return "It's loose: prop the last stretch first ([E] at its mouth, %d wood)" % PROP_WOOD
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
## The face called `uid` (what a player's game asks the server to hit).
|
||||||
|
func face_by_uid(uid: String) -> Node3D:
|
||||||
|
return _face if _face != null and is_instance_valid(_face) and String(_face.uid) == uid and not _face.depleted else null
|
||||||
|
|
||||||
|
## On a server, everyone playing (and the other lands) hears how the tunnel
|
||||||
|
## stands after each change.
|
||||||
|
func _tell_everyone() -> void:
|
||||||
|
if Net.is_server():
|
||||||
|
WorldNet.tunnel_state(to_data())
|
||||||
|
|
||||||
func face_hit(f: Node) -> void:
|
func face_hit(f: Node) -> void:
|
||||||
face_hp = f.hp
|
face_hp = f.hp
|
||||||
|
if not f.depleted:
|
||||||
|
_tell_everyone()
|
||||||
|
|
||||||
func face_broken(f: Node) -> void:
|
func face_broken(f: Node) -> void:
|
||||||
face_hp = FACE_HP
|
face_hp = FACE_HP
|
||||||
@@ -156,9 +170,14 @@ func face_broken(f: Node) -> void:
|
|||||||
else:
|
else:
|
||||||
Actions.notify.emit("The rock gives. Prop this stretch before you dig on")
|
Actions.notify.emit("The rock gives. Prop this stretch before you dig on")
|
||||||
_rebuild.call_deferred()
|
_rebuild.call_deferred()
|
||||||
|
_tell_everyone()
|
||||||
|
|
||||||
## [E] at the mouth of stretch `k`: sets a timber prop there.
|
## [E] at the mouth of stretch `k`: sets a timber prop there. (Online, your
|
||||||
|
## game asks the server, which sets it and tells everyone.)
|
||||||
func set_prop(player: Node, k: int) -> bool:
|
func set_prop(player: Node, k: int) -> bool:
|
||||||
|
if world.is_online():
|
||||||
|
PlayerNet.ask("tunnel_prop", [k])
|
||||||
|
return true
|
||||||
if k < 1 or k > dug or props[k]:
|
if k < 1 or k > dug or props[k]:
|
||||||
return false
|
return false
|
||||||
if player.inventory.count("wood") < PROP_WOOD:
|
if player.inventory.count("wood") < PROP_WOOD:
|
||||||
@@ -168,14 +187,22 @@ func set_prop(player: Node, k: int) -> bool:
|
|||||||
props[k] = true
|
props[k] = true
|
||||||
Actions.notify.emit("Propped. The next face is safe to dig")
|
Actions.notify.emit("Propped. The next face is safe to dig")
|
||||||
_rebuild.call_deferred()
|
_rebuild.call_deferred()
|
||||||
|
_tell_everyone()
|
||||||
return true
|
return true
|
||||||
|
|
||||||
# --- going in and out ----------------------------------------------------------
|
# --- going in and out ----------------------------------------------------------
|
||||||
|
|
||||||
|
## (Online, your game asks the server, which moves you: player_net.gd.)
|
||||||
func go_in(player: Node3D) -> void:
|
func go_in(player: Node3D) -> void:
|
||||||
|
if world.is_online() and player == world.player:
|
||||||
|
PlayerNet.ask("tunnel_go", ["in"])
|
||||||
|
return
|
||||||
_move(player, top + Vector3(0, 0.1, 0), Vector3(1, 0, 0))
|
_move(player, top + Vector3(0, 0.1, 0), Vector3(1, 0, 0))
|
||||||
|
|
||||||
func go_out(player: Node3D) -> void:
|
func go_out(player: Node3D) -> void:
|
||||||
|
if world.is_online() and player == world.player:
|
||||||
|
PlayerNet.ask("tunnel_go", ["out"])
|
||||||
|
return
|
||||||
_move(player, seam_out + Vector3(0, 0.2, 0), -Vector3(seam_at.x, 0.0, seam_at.z).normalized())
|
_move(player, seam_out + Vector3(0, 0.2, 0), -Vector3(seam_at.x, 0.0, seam_at.z).normalized())
|
||||||
|
|
||||||
func _move(player: Node3D, at: Vector3, facing: Vector3) -> void:
|
func _move(player: Node3D, at: Vector3, facing: Vector3) -> void:
|
||||||
@@ -193,6 +220,7 @@ func to_data() -> Dictionary:
|
|||||||
return {"opened": opened, "dug": dug, "face_hp": face_hp, "props": p}
|
return {"opened": opened, "dug": dug, "face_hp": face_hp, "props": p}
|
||||||
|
|
||||||
func load_data(d: Dictionary) -> void:
|
func load_data(d: Dictionary) -> void:
|
||||||
|
var was := [opened, dug, props.duplicate()]
|
||||||
opened = bool(d.get("opened", false))
|
opened = bool(d.get("opened", false))
|
||||||
dug = clampi(int(d.get("dug", 0)), 0, STRETCHES)
|
dug = clampi(int(d.get("dug", 0)), 0, STRETCHES)
|
||||||
face_hp = float(d.get("face_hp", FACE_HP))
|
face_hp = float(d.get("face_hp", FACE_HP))
|
||||||
@@ -200,6 +228,11 @@ func load_data(d: Dictionary) -> void:
|
|||||||
for k in d.get("props", []):
|
for k in d.get("props", []):
|
||||||
if int(k) >= 1 and int(k) <= STRETCHES:
|
if int(k) >= 1 and int(k) <= STRETCHES:
|
||||||
props[int(k)] = true
|
props[int(k)] = true
|
||||||
|
# (Online, a blow on the face only changes how much is left of it: no
|
||||||
|
# need to rebuild the rock for that.)
|
||||||
|
if was == [opened, dug, props] and _face != null and is_instance_valid(_face):
|
||||||
|
_face.hp = clampf(face_hp, 0.5, _face.max_hp)
|
||||||
|
return
|
||||||
_rebuild()
|
_rebuild()
|
||||||
|
|
||||||
# --- building it ---------------------------------------------------------------
|
# --- building it ---------------------------------------------------------------
|
||||||
@@ -212,8 +245,8 @@ func _make_materials() -> void:
|
|||||||
_rock_mat.albedo_texture = terrain.noise_tex
|
_rock_mat.albedo_texture = terrain.noise_tex
|
||||||
_rock_mat.uv1_triplanar = true
|
_rock_mat.uv1_triplanar = true
|
||||||
_rock_mat.uv1_scale = Vector3(0.18, 0.18, 0.18)
|
_rock_mat.uv1_scale = Vector3(0.18, 0.18, 0.18)
|
||||||
_face_mat = _rock_mat.duplicate()
|
_boulder_mat = RockPile.material(terrain)
|
||||||
_face_mat.albedo_color = Color(0.30, 0.27, 0.25)
|
_face_mat = RockPile.material(terrain) # (the same rock: the seam's cleft and pick marks set it apart)
|
||||||
_wood_mat = StandardMaterial3D.new()
|
_wood_mat = StandardMaterial3D.new()
|
||||||
_wood_mat.albedo_color = Color(0.42, 0.29, 0.17)
|
_wood_mat.albedo_color = Color(0.42, 0.29, 0.17)
|
||||||
_wood_mat.roughness = 0.9
|
_wood_mat.roughness = 0.9
|
||||||
@@ -322,13 +355,37 @@ func _make_face(stage: int, center: Vector3, size: Vector3) -> StaticBody3D:
|
|||||||
box.size = size
|
box.size = size
|
||||||
cs.shape = box
|
cs.shape = box
|
||||||
f.add_child(cs)
|
f.add_child(cs)
|
||||||
var mi := MeshInstance3D.new()
|
|
||||||
var bm := BoxMesh.new()
|
|
||||||
bm.size = size
|
|
||||||
mi.mesh = bm
|
|
||||||
mi.material_override = _face_mat
|
|
||||||
f.add_child(mi)
|
|
||||||
add_child(f)
|
add_child(f)
|
||||||
|
var s: int = terrain.noise_seed + 4200 + stage * 7
|
||||||
|
if stage == 0:
|
||||||
|
# The seam: two slabs of rock with a dark cleft between them, and
|
||||||
|
# old pick marks (pale chips) on either side.
|
||||||
|
var gap := MeshInstance3D.new()
|
||||||
|
var gm := BoxMesh.new()
|
||||||
|
gm.size = Vector3(0.28, size.y * 0.85, 0.3)
|
||||||
|
gap.mesh = gm
|
||||||
|
var dm := StandardMaterial3D.new()
|
||||||
|
dm.albedo_color = Color(0.02, 0.018, 0.015)
|
||||||
|
dm.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
gap.material_override = dm
|
||||||
|
gap.position = Vector3(0.05, -0.05, 0.05)
|
||||||
|
f.add_child(gap)
|
||||||
|
RockPile.add(f, null, s, Vector3(-0.52, 0.0, 0.1), Vector3(0.55, size.y * 0.5, 0.42), 0.3, _face_mat, true)
|
||||||
|
RockPile.add(f, null, s + 1, Vector3(0.58, -0.05, 0.12), Vector3(0.52, size.y * 0.47, 0.4), 2.1, _face_mat, true)
|
||||||
|
var chip := StandardMaterial3D.new()
|
||||||
|
chip.albedo_color = Color(0.62, 0.58, 0.52)
|
||||||
|
for c in [Vector3(-0.32, 0.35, -0.3), Vector3(0.36, -0.2, -0.3), Vector3(-0.25, -0.45, -0.28)]:
|
||||||
|
var m := MeshInstance3D.new()
|
||||||
|
var cm := BoxMesh.new()
|
||||||
|
cm.size = Vector3(0.12, 0.05, 0.03)
|
||||||
|
m.mesh = cm
|
||||||
|
m.material_override = chip
|
||||||
|
m.position = c
|
||||||
|
m.rotation.z = c.x * 2.0
|
||||||
|
f.add_child(m)
|
||||||
|
else:
|
||||||
|
# A face down the tunnel: rough rock filling the passage.
|
||||||
|
RockPile.add(f, null, s, Vector3(-0.05, 0.0, 0.0), Vector3(0.45, size.y * 0.55, size.z * 0.55), 0.1, _face_mat)
|
||||||
f.global_position = center
|
f.global_position = center
|
||||||
return f
|
return f
|
||||||
|
|
||||||
@@ -344,35 +401,39 @@ func _build_seam() -> void:
|
|||||||
# (local -Z faces inland, towards the valley; the outcrop is behind, +Z)
|
# (local -Z faces inland, towards the valley; the outcrop is behind, +Z)
|
||||||
var body := StaticBody3D.new()
|
var body := StaticBody3D.new()
|
||||||
_seam.add_child(body)
|
_seam.add_child(body)
|
||||||
for part in [[Vector3(-1.5, 1.2, 0.4), Vector3(1.0, 2.6, 2.0)], [Vector3(1.5, 1.1, 0.4), Vector3(1.0, 2.4, 2.0)],
|
# An outcrop of boulders round a cleft: two tall ones either side, one
|
||||||
[Vector3(0, 2.6, 0.4), Vector3(4.0, 0.9, 2.2)], [Vector3(0, 1.1, 1.1), Vector3(2.2, 2.4, 0.6)]]:
|
# across the top, a big one behind and two lower ones at the shoulders,
|
||||||
var mi := MeshInstance3D.new()
|
# with loose stones fallen at its foot. (Each sits a little into the
|
||||||
var bm := BoxMesh.new()
|
# ground, which isn't quite flat.)
|
||||||
bm.size = part[1]
|
var s: int = terrain.noise_seed + 4100
|
||||||
mi.mesh = bm
|
for part in [[Vector3(-2.05, 0.95, 0.5), Vector3(1.0, 1.55, 1.3), 0.4, true],
|
||||||
mi.material_override = _rock_mat
|
[Vector3(2.05, 0.85, 0.55), Vector3(0.95, 1.45, 1.25), 1.9, true],
|
||||||
mi.position = part[0]
|
[Vector3(0.0, 2.95, 0.65), Vector3(2.3, 0.7, 1.4), 0.2, false],
|
||||||
_seam.add_child(mi)
|
[Vector3(0.0, 1.4, 1.9), Vector3(2.3, 2.3, 1.3), 3.0, false],
|
||||||
var cs := CollisionShape3D.new()
|
[Vector3(-1.2, 2.6, 2.4), Vector3(1.7, 1.5, 1.3), 1.3, false],
|
||||||
var box := BoxShape3D.new()
|
[Vector3(1.3, 2.5, 2.3), Vector3(1.6, 1.4, 1.3), 2.5, false],
|
||||||
box.size = part[1]
|
[Vector3(-3.3, 0.35, 1.2), Vector3(1.2, 0.9, 1.2), 2.2, false],
|
||||||
cs.shape = box
|
[Vector3(3.2, 0.3, 1.0), Vector3(1.0, 0.8, 1.1), 0.9, false]]:
|
||||||
cs.position = part[0]
|
RockPile.add(_seam, body, s, part[0], part[1], part[2], _boulder_mat, part[3])
|
||||||
body.add_child(cs)
|
s += 1
|
||||||
|
for stone in [[Vector3(-0.8, 0.06, -1.0), 0.28], [Vector3(1.1, 0.05, -1.3), 0.22],
|
||||||
|
[Vector3(0.35, 0.04, -1.7), 0.18], [Vector3(-1.6, 0.04, -1.6), 0.2]]:
|
||||||
|
RockPile.add(_seam, null, s, stone[0], Vector3.ONE * float(stone[1]), float(s), _boulder_mat)
|
||||||
|
s += 1
|
||||||
if not opened:
|
if not opened:
|
||||||
_face = _make_face(0, _seam.global_transform * Vector3(0, 1.1, -0.35), Vector3(2.0, 2.2, 0.5))
|
_face = _make_face(0, _seam.global_transform * Vector3(0, 1.1, -0.2), Vector3(2.0, 2.2, 0.6))
|
||||||
_face.global_basis = _seam.global_basis
|
_face.global_basis = _seam.global_basis
|
||||||
else:
|
else:
|
||||||
|
# The cleft dug open: black inside, set back between the boulders.
|
||||||
var dark := MeshInstance3D.new()
|
var dark := MeshInstance3D.new()
|
||||||
var q := QuadMesh.new()
|
var bm := BoxMesh.new()
|
||||||
q.size = Vector2(2.0, 2.1)
|
bm.size = Vector3(1.6, 2.0, 0.1)
|
||||||
dark.mesh = q
|
dark.mesh = bm
|
||||||
var m := StandardMaterial3D.new()
|
var m := StandardMaterial3D.new()
|
||||||
m.albedo_color = Color(0.02, 0.02, 0.02)
|
m.albedo_color = Color(0.015, 0.012, 0.01)
|
||||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
dark.material_override = m
|
dark.material_override = m
|
||||||
dark.position = Vector3(0, 1.05, 0.78)
|
dark.position = Vector3(0, 1.0, 0.45)
|
||||||
dark.rotation.y = PI # (a quad faces +Z: turn it to face inland)
|
|
||||||
_seam.add_child(dark)
|
_seam.add_child(dark)
|
||||||
var spot := _spot_body(Vector3(2.0, 2.1, 0.4), "in", 0)
|
var spot := _spot_body(Vector3(2.0, 2.1, 0.4), "in", 0)
|
||||||
_seam.add_child(spot)
|
_seam.add_child(spot)
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ var _pieces: Node3D
|
|||||||
var hud: CanvasLayer
|
var hud: CanvasLayer
|
||||||
var pier: Node3D ## the Cove's pier (scripts/world/pier.gd), or null
|
var pier: Node3D ## the Cove's pier (scripts/world/pier.gd), or null
|
||||||
var tunnel: Node3D ## the cliff tunnel (scripts/world/tunnel.gd, J1), or null
|
var tunnel: Node3D ## the cliff tunnel (scripts/world/tunnel.gd, J1), or null
|
||||||
|
var wreck: Node3D ## the Cove's wreck (scripts/world/wreck.gd, 0.6.0), or null
|
||||||
|
var treasure: Node3D ## the pirate treasure (scripts/world/treasure.gd), or null
|
||||||
|
var moorings: Node3D ## the boats tied up at the pier (scripts/world/moorings.gd), or null
|
||||||
var sea_cave: Node3D ## the Cove's end of it (scripts/world/sea_cave.gd), or null
|
var sea_cave: Node3D ## the Cove's end of it (scripts/world/sea_cave.gd), or null
|
||||||
var traveling := false ## on the way to another worldlet (the scene is about to change)
|
var traveling := false ## on the way to another worldlet (the scene is about to change)
|
||||||
var _session_time := 0.0 ## seconds played since the last save
|
var _session_time := 0.0 ## seconds played since the last save
|
||||||
@@ -72,6 +75,7 @@ var bobber: Node3D ## bobber.gd: the cast fishing float, if any
|
|||||||
var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site)
|
var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site)
|
||||||
var bears: Node3D ## bear_range.gd: the island's bear (null online as a client)
|
var bears: Node3D ## bear_range.gd: the island's bear (null online as a client)
|
||||||
var thieves: Node3D ## thieves.gd: crows and foxes after food left out
|
var thieves: Node3D ## thieves.gd: crows and foxes after food left out
|
||||||
|
var shore: Node3D ## shore.gd: the Cove's crabs (null in a land without them)
|
||||||
var hazards: Node3D ## hazards.gd: lightning on trees, hard-winter ice (P4)
|
var hazards: Node3D ## hazards.gd: lightning on trees, hard-winter ice (P4)
|
||||||
var stones: Node3D ## standing_stones.gd: the altar and the Old Stag (P5)
|
var stones: Node3D ## standing_stones.gd: the altar and the Old Stag (P5)
|
||||||
var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run)
|
var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run)
|
||||||
@@ -365,9 +369,35 @@ func _on_terrain_ready() -> void:
|
|||||||
props.pier = pr
|
props.pier = pr
|
||||||
else:
|
else:
|
||||||
pr.free()
|
pr.free()
|
||||||
|
# The moorings (0.6.0): rowing boats tied up at the end of the pier.
|
||||||
|
if pier != null and not menu_mode:
|
||||||
|
var mo := preload("res://scripts/world/moorings.gd").new()
|
||||||
|
mo.name = "Moorings"
|
||||||
|
add_child(mo)
|
||||||
|
if mo.setup(self):
|
||||||
|
moorings = mo
|
||||||
|
else:
|
||||||
|
mo.free()
|
||||||
|
# The wreck (0.6.0): on the Cove's beach, with the sea chest's treasure maps.
|
||||||
|
var wk := preload("res://scripts/world/wreck.gd").new()
|
||||||
|
wk.name = "Wreck"
|
||||||
|
add_child(wk)
|
||||||
|
if not menu_mode and wk.place(self):
|
||||||
|
wreck = wk
|
||||||
|
props.keep_clear.append([wk.global_position, 9.0])
|
||||||
|
var tr := preload("res://scripts/world/treasure.gd").new()
|
||||||
|
tr.name = "Treasure"
|
||||||
|
add_child(tr)
|
||||||
|
if tr.setup(self):
|
||||||
|
treasure = tr
|
||||||
|
else:
|
||||||
|
tr.free()
|
||||||
|
else:
|
||||||
|
wk.free()
|
||||||
# The journey's ways on (J1): the Valley's cliff tunnel and the Cove's sea
|
# The journey's ways on (J1): the Valley's cliff tunnel and the Cove's sea
|
||||||
# cave. Single-player for now (J3 brings travel online).
|
# cave. On a server too (J3): the server decides the digging and the
|
||||||
if not server_mode and not menu_mode and not is_online():
|
# crossings, and every player's game has the same rock to see and walk.
|
||||||
|
if not menu_mode:
|
||||||
var tn := preload("res://scripts/world/tunnel.gd").new()
|
var tn := preload("res://scripts/world/tunnel.gd").new()
|
||||||
tn.name = "Tunnel"
|
tn.name = "Tunnel"
|
||||||
add_child(tn)
|
add_child(tn)
|
||||||
@@ -393,13 +423,16 @@ func _on_terrain_ready() -> void:
|
|||||||
props.static_cuts.append([den.global_transform, 2.2, 3.4])
|
props.static_cuts.append([den.global_transform, 2.2, 3.4])
|
||||||
_update_grass_cuts()
|
_update_grass_cuts()
|
||||||
# The barrow: scenery too (P3), out on open ground. Its guards only run
|
# The barrow: scenery too (P3), out on open ground. Its guards only run
|
||||||
# where creatures do (_setup_bear).
|
# where creatures do (_setup_bear). (Each land has its own set pieces:
|
||||||
|
# the barrow and the stones are the Valley's: has_place().)
|
||||||
|
if has_place("barrow"):
|
||||||
barrow = preload("res://scripts/world/barrow.gd").new()
|
barrow = preload("res://scripts/world/barrow.gd").new()
|
||||||
barrow.name = "Barrow"
|
barrow.name = "Barrow"
|
||||||
add_child(barrow)
|
add_child(barrow)
|
||||||
if barrow.place(self):
|
if barrow.place(self):
|
||||||
_update_grass_cuts()
|
_update_grass_cuts()
|
||||||
# The standing stones (P5): where the Old Stag is called.
|
# The standing stones (P5): where the Old Stag is called.
|
||||||
|
if has_place("stones"):
|
||||||
stones = preload("res://scripts/world/standing_stones.gd").new()
|
stones = preload("res://scripts/world/standing_stones.gd").new()
|
||||||
stones.name = "StandingStones"
|
stones.name = "StandingStones"
|
||||||
add_child(stones)
|
add_child(stones)
|
||||||
@@ -419,6 +452,7 @@ func _on_terrain_ready() -> void:
|
|||||||
hazards.name = "Hazards"
|
hazards.name = "Hazards"
|
||||||
add_child(hazards)
|
add_child(hazards)
|
||||||
hazards.setup(self, not is_online())
|
hazards.setup(self, not is_online())
|
||||||
|
if stones != null:
|
||||||
stones.deciding = not is_online()
|
stones.deciding = not is_online()
|
||||||
if server_mode:
|
if server_mode:
|
||||||
if Saves.has_world():
|
if Saves.has_world():
|
||||||
@@ -446,6 +480,7 @@ func _on_terrain_ready() -> void:
|
|||||||
if not state.is_empty():
|
if not state.is_empty():
|
||||||
apply_save(state)
|
apply_save(state)
|
||||||
print("[world] loaded '%s'" % Saves.current_id)
|
print("[world] loaded '%s'" % Saves.current_id)
|
||||||
|
catch_up(state.get("catch_up_to", {}))
|
||||||
_arrive(Saves.take_arrival(Saves.current_id))
|
_arrive(Saves.take_arrival(Saves.current_id))
|
||||||
player.set_physics_process(true)
|
player.set_physics_process(true)
|
||||||
set_physics_process(true)
|
set_physics_process(true)
|
||||||
@@ -489,6 +524,14 @@ func _setup_bear() -> void:
|
|||||||
thieves.enabled = not "--no-creatures" in OS.get_cmdline_user_args()
|
thieves.enabled = not "--no-creatures" in OS.get_cmdline_user_args()
|
||||||
if barrow != null and barrow.found and thieves.enabled:
|
if barrow != null and barrow.found and thieves.enabled:
|
||||||
barrow.start()
|
barrow.start()
|
||||||
|
if has_wildlife("crabs"):
|
||||||
|
shore = preload("res://scripts/creatures/shore.gd").new()
|
||||||
|
shore.name = "Shore"
|
||||||
|
add_child(shore)
|
||||||
|
shore.setup(self)
|
||||||
|
shore.enabled = thieves.enabled
|
||||||
|
if not has_place("bear"):
|
||||||
|
return # (bears only in the lands that list one: the Valley)
|
||||||
bears = preload("res://scripts/creatures/bear_range.gd").new()
|
bears = preload("res://scripts/creatures/bear_range.gd").new()
|
||||||
bears.name = "Bear"
|
bears.name = "Bear"
|
||||||
add_child(bears)
|
add_child(bears)
|
||||||
@@ -682,6 +725,9 @@ func collect_save() -> Dictionary:
|
|||||||
"barrow": barrow.save_data() if barrow != null else {},
|
"barrow": barrow.save_data() if barrow != null else {},
|
||||||
"stones": stones.save_data() if stones != null else {},
|
"stones": stones.save_data() if stones != null else {},
|
||||||
"tunnel": tunnel.to_data() if tunnel != null else {},
|
"tunnel": tunnel.to_data() if tunnel != null else {},
|
||||||
|
"wreck": wreck.to_data() if wreck != null else {},
|
||||||
|
"treasure": treasure.to_data() if treasure != null else {},
|
||||||
|
"moorings": moorings.to_data() if moorings != null else {},
|
||||||
"map": map_data(),
|
"map": map_data(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,6 +743,10 @@ func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, drop
|
|||||||
"bear": bears.save_data() if bears != null else _saved_bear,
|
"bear": bears.save_data() if bears != null else _saved_bear,
|
||||||
"barrow": barrow.save_data() if barrow != null else {},
|
"barrow": barrow.save_data() if barrow != null else {},
|
||||||
"stones": stones.save_data() if stones != null else {},
|
"stones": stones.save_data() if stones != null else {},
|
||||||
|
"tunnel": tunnel.to_data() if tunnel != null else {},
|
||||||
|
"wreck": wreck.to_data() if wreck != null else {},
|
||||||
|
"treasure": treasure.to_data() if treasure != null else {},
|
||||||
|
"moorings": moorings.to_data() if moorings != null else {},
|
||||||
}
|
}
|
||||||
|
|
||||||
## The live pieces (placed and not being removed).
|
## The live pieces (placed and not being removed).
|
||||||
@@ -830,6 +880,12 @@ func _apply_save(s: Dictionary, with_player: bool) -> void:
|
|||||||
stones.load_data(s.get("stones", {}))
|
stones.load_data(s.get("stones", {}))
|
||||||
if tunnel != null:
|
if tunnel != null:
|
||||||
tunnel.load_data(s.get("tunnel", {}))
|
tunnel.load_data(s.get("tunnel", {}))
|
||||||
|
if wreck != null:
|
||||||
|
wreck.load_data(s.get("wreck", {}))
|
||||||
|
if treasure != null:
|
||||||
|
treasure.load_data(s.get("treasure", {}))
|
||||||
|
if moorings != null:
|
||||||
|
moorings.load_data(s.get("moorings", {}))
|
||||||
if with_player:
|
if with_player:
|
||||||
load_map_data(String(s.get("map", "")))
|
load_map_data(String(s.get("map", "")))
|
||||||
# The bear (bear_range.gd): applied when it's set up, after loading.
|
# The bear (bear_range.gd): applied when it's set up, after loading.
|
||||||
@@ -962,6 +1018,12 @@ func map_marks() -> Array:
|
|||||||
add.call("pier", (pier.start + pier.end) * 0.5, "The pier")
|
add.call("pier", (pier.start + pier.end) * 0.5, "The pier")
|
||||||
if sea_cave != null:
|
if sea_cave != null:
|
||||||
add.call("cave", sea_cave.global_position, "Sea cave")
|
add.call("cave", sea_cave.global_position, "Sea cave")
|
||||||
|
if wreck != null:
|
||||||
|
add.call("wreck", wreck.global_position, "The wreck")
|
||||||
|
# Your X: shown while you carry the map (even where you haven't been).
|
||||||
|
var x := treasure_spot(my_id())
|
||||||
|
if x != Vector3.INF and player.inventory.count("treasure_map") > 0:
|
||||||
|
out.append({"kind": "treasure", "pos": x, "label": "X"})
|
||||||
for piece in all_pieces():
|
for piece in all_pieces():
|
||||||
var id: String = String(piece.get("item_id"))
|
var id: String = String(piece.get("item_id"))
|
||||||
if piece.is_in_group("bed"):
|
if piece.is_in_group("bed"):
|
||||||
@@ -974,6 +1036,70 @@ func map_marks() -> Array:
|
|||||||
add.call("grave", piece.global_position, "Your things")
|
add.call("grave", piece.global_position, "Your things")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
# --- the pirate treasure (0.6.0, docs/WORLDLETS_DESIGN.md §5) ----------------------
|
||||||
|
|
||||||
|
var _treasure_spots: Dictionary = {} ## player id -> Vector3 (worked out once)
|
||||||
|
|
||||||
|
## Where player `id`'s treasure is buried (each player's map has its own X):
|
||||||
|
## a dry, gentle spot in the Cove's hills, picked from the world's seed and
|
||||||
|
## the player's id, clear of the water, the stream and the other places.
|
||||||
|
## INF where there's no treasure (no wreck in this land).
|
||||||
|
func treasure_spot(id: String) -> Vector3:
|
||||||
|
if wreck == null:
|
||||||
|
return Vector3.INF
|
||||||
|
if _treasure_spots.has(id):
|
||||||
|
return _treasure_spots[id]
|
||||||
|
var spots := treasure_spots()
|
||||||
|
var at: Vector3 = Vector3.INF if spots.is_empty() else spots[posmod(hash(id), spots.size())]
|
||||||
|
_treasure_spots[id] = at
|
||||||
|
return at
|
||||||
|
|
||||||
|
## The world's hiding places for treasure: up to 12, at least 25 m apart,
|
||||||
|
## picked from its seed (so each player's X is one of these, and different
|
||||||
|
## players' are usually far apart).
|
||||||
|
var _hiding: Array = []
|
||||||
|
func treasure_spots() -> Array:
|
||||||
|
if not _hiding.is_empty() or wreck == null:
|
||||||
|
return _hiding
|
||||||
|
var t: Node3D = terrain
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = t.noise_seed * 7919 + 2024
|
||||||
|
var avoid: Array = [wreck.global_position, _spawn]
|
||||||
|
for n in [den, pier, sea_cave]:
|
||||||
|
if n != null and n.is_inside_tree():
|
||||||
|
avoid.append(n.global_position if n != pier else (pier.start + pier.end) * 0.5)
|
||||||
|
for _try in range(3000):
|
||||||
|
if _hiding.size() >= 12:
|
||||||
|
break
|
||||||
|
var a := rng.randf() * TAU
|
||||||
|
var r: float = sqrt(rng.randf()) * t.play_radius * 0.85
|
||||||
|
var x: float = cos(a) * r
|
||||||
|
var z: float = sin(a) * r
|
||||||
|
if not t.inside_boundary(x, z, 14.0) or t.beach_weight(x, z) > 0.3 or t.near_stream(x, z, 4.0):
|
||||||
|
continue
|
||||||
|
var h: float = t.sample_height(x, z)
|
||||||
|
if h < t.water_level + 2.0 or t.sample_normal(x, z).y < 0.92 or h > t.snow_height - 6.0:
|
||||||
|
continue
|
||||||
|
if avoid.any(func(q): return Vector2(q.x - x, q.z - z).length() < 22.0):
|
||||||
|
continue
|
||||||
|
if _hiding.any(func(q): return Vector2(q.x - x, q.z - z).length() < 25.0):
|
||||||
|
continue
|
||||||
|
_hiding.append(Vector3(x, h, z))
|
||||||
|
return _hiding
|
||||||
|
|
||||||
|
## The id of the player at this game (yours; a server's character's own).
|
||||||
|
func my_id() -> String:
|
||||||
|
return String(Net.get_identity().player_id)
|
||||||
|
|
||||||
|
## Right-click with the treasure map: your X goes on your map, and it opens.
|
||||||
|
func read_map(p: Node3D) -> void:
|
||||||
|
if wreck == null:
|
||||||
|
Actions.notify.emit("The X is in the hills above the Cove: read it there")
|
||||||
|
return
|
||||||
|
Actions.notify.emit("X marks the spot, in the hills above the bay: it's on your map now (N). Take a pickaxe")
|
||||||
|
if hud != null and hud.map != null and not hud.map.is_open():
|
||||||
|
hud.map.open()
|
||||||
|
|
||||||
## In the cliff tunnel (J1): no daylight, no weather, sheltered.
|
## In the cliff tunnel (J1): no daylight, no weather, sheltered.
|
||||||
func underground_at(p: Vector3) -> bool:
|
func underground_at(p: Vector3) -> bool:
|
||||||
return tunnel != null and tunnel.contains(p)
|
return tunnel != null and tunnel.contains(p)
|
||||||
@@ -985,7 +1111,15 @@ func underground_at(p: Vector3) -> bool:
|
|||||||
## at `arrive` ("sea_cave", "tunnel_bottom"). Each worldlet keeps its own
|
## at `arrive` ("sea_cave", "tunnel_bottom"). Each worldlet keeps its own
|
||||||
## state file (Saves.state_path), so what you built here waits for you.
|
## state file (Saves.state_path), so what you built here waits for you.
|
||||||
func travel(to: String, arrive: String) -> void:
|
func travel(to: String, arrive: String) -> void:
|
||||||
if traveling or not Saves.has_world() or is_online() or server_mode:
|
if server_mode:
|
||||||
|
return # (on a server, the crossing is the player's game asking: player_net.gd "cross")
|
||||||
|
if is_online():
|
||||||
|
# The server checks, saves your character and sends you on (J3).
|
||||||
|
if not traveling and Time.get_ticks_msec() > _cross_asked + 3000:
|
||||||
|
_cross_asked = Time.get_ticks_msec()
|
||||||
|
PlayerNet.ask("cross", ["tunnel" if arrive == "sea_cave" else "sea_cave"])
|
||||||
|
return
|
||||||
|
if traveling or not Saves.has_world():
|
||||||
return
|
return
|
||||||
traveling = true
|
traveling = true
|
||||||
var id := Saves.current_id
|
var id := Saves.current_id
|
||||||
@@ -995,6 +1129,10 @@ func travel(to: String, arrive: String) -> void:
|
|||||||
var there: Dictionary = Saves.load_state(id, to)
|
var there: Dictionary = Saves.load_state(id, to)
|
||||||
if there.is_empty():
|
if there.is_empty():
|
||||||
there = {"time": here.time} # (a new place: the same day and hour)
|
there = {"time": here.time} # (a new place: the same day and hour)
|
||||||
|
else:
|
||||||
|
# One clock for every land: the one you're going to catches up on the
|
||||||
|
# time you were away when it loads (catch_up()).
|
||||||
|
there["catch_up_to"] = here.time
|
||||||
there["player"] = here.player
|
there["player"] = here.player
|
||||||
Saves.save_state(id, there, 0.0, to)
|
Saves.save_state(id, there, 0.0, to)
|
||||||
Saves.set_worldlet(id, to, arrive)
|
Saves.set_worldlet(id, to, arrive)
|
||||||
@@ -1006,21 +1144,66 @@ func travel(to: String, arrive: String) -> void:
|
|||||||
get_tree().change_scene_to_file.call_deferred(scene_file_path if not scene_file_path.is_empty() else "res://scenes/world.tscn")
|
get_tree().change_scene_to_file.call_deferred(scene_file_path if not scene_file_path.is_empty() else "res://scenes/world.tscn")
|
||||||
|
|
||||||
signal travelled(to: String) ## saved and on the way to another worldlet
|
signal travelled(to: String) ## saved and on the way to another worldlet
|
||||||
|
var _cross_asked := -100000 ## (online: when we last asked the server to cross)
|
||||||
|
|
||||||
|
## Online (J3): the server's sending us on to another land's server.
|
||||||
|
func _on_net_travelling(to: String) -> void:
|
||||||
|
traveling = true
|
||||||
|
player.set_physics_process(false)
|
||||||
|
if net_sync != null:
|
||||||
|
net_sync.set_process(false) # (nothing more to tell the land we're leaving,
|
||||||
|
net_sync.set_physics_process(false) # nor the next one about this one)
|
||||||
|
if hud != null:
|
||||||
|
hud.toast("On to the %s..." % Terrain.worldlet_name(to))
|
||||||
|
hud.fade(func(): pass)
|
||||||
|
|
||||||
|
## Online: in at the other land: the world reloads as that land (its terrain
|
||||||
|
## comes from what that server says, Net.world_info).
|
||||||
|
func _on_net_arrived(_to: String) -> void:
|
||||||
|
travelled.emit(_to)
|
||||||
|
if get_tree().current_scene == self:
|
||||||
|
get_tree().change_scene_to_file.call_deferred(scene_file_path if not scene_file_path.is_empty() else "res://scenes/world.tscn")
|
||||||
|
|
||||||
|
## Back in a land you've been to before: its clock comes forward to `to`
|
||||||
|
## ({day, time}, the clock where you've come from), and everything in it
|
||||||
|
## catches up on the time between, as after a night's sleep (kilns and
|
||||||
|
## smelters work, fires burn, food goes off, trees and berries grow back).
|
||||||
|
## Returns the seconds caught up.
|
||||||
|
func catch_up(to: Dictionary) -> float:
|
||||||
|
if to.is_empty():
|
||||||
|
return 0.0
|
||||||
|
var secs: float = ((int(to.get("day", day_night.day)) - day_night.day) \
|
||||||
|
+ (float(to.get("time", day_night.time)) - day_night.time)) * day_night.day_length
|
||||||
|
if secs <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
skip_time(secs)
|
||||||
|
print("[world] %s caught up on %.0f s while you were away" % [worldlet_id(), secs])
|
||||||
|
return secs
|
||||||
|
|
||||||
## Just arrived from another worldlet: put you where you came in.
|
## Just arrived from another worldlet: put you where you came in.
|
||||||
|
## Where the way in called `at` puts you ("sea_cave", "tunnel_bottom"), or
|
||||||
|
## INF if this land has no such way in. (A server places crossing players
|
||||||
|
## here too: player_net.gd.)
|
||||||
|
func arrival_point(at: String) -> Vector3:
|
||||||
|
match at:
|
||||||
|
"sea_cave":
|
||||||
|
if sea_cave != null:
|
||||||
|
return sea_cave.front
|
||||||
|
"tunnel_bottom":
|
||||||
|
if tunnel != null and tunnel.dug >= tunnel.STRETCHES:
|
||||||
|
return tunnel.bottom + Vector3(0.8, 0.0, 0.0)
|
||||||
|
return Vector3.INF
|
||||||
|
|
||||||
func _arrive(at: String) -> void:
|
func _arrive(at: String) -> void:
|
||||||
if at.is_empty():
|
if at.is_empty():
|
||||||
return
|
return
|
||||||
var pos := Vector3.INF
|
var pos := arrival_point(at)
|
||||||
var facing := Vector3.FORWARD
|
var facing := Vector3.FORWARD
|
||||||
match at:
|
match at:
|
||||||
"sea_cave":
|
"sea_cave":
|
||||||
if sea_cave != null:
|
if sea_cave != null:
|
||||||
pos = sea_cave.front
|
|
||||||
facing = (sea_cave.front - sea_cave.global_position).normalized()
|
facing = (sea_cave.front - sea_cave.global_position).normalized()
|
||||||
"tunnel_bottom":
|
"tunnel_bottom":
|
||||||
if tunnel != null and tunnel.dug >= tunnel.STRETCHES:
|
|
||||||
pos = tunnel.bottom + Vector3(0.8, 0.0, 0.0)
|
|
||||||
facing = Vector3(1, 0, 0)
|
facing = Vector3(1, 0, 0)
|
||||||
if pos == Vector3.INF:
|
if pos == Vector3.INF:
|
||||||
push_warning("[world] nowhere to arrive at '%s': the start instead" % at)
|
push_warning("[world] nowhere to arrive at '%s': the start instead" % at)
|
||||||
@@ -1122,6 +1305,9 @@ func _update_grass_cuts() -> void:
|
|||||||
## fishing (and later swimming, boats) work on any world that answers it.
|
## fishing (and later swimming, boats) work on any world that answers it.
|
||||||
func water_at(x: float, z: float) -> Dictionary:
|
func water_at(x: float, z: float) -> Dictionary:
|
||||||
var ground: float = terrain.sample_height(x, z)
|
var ground: float = terrain.sample_height(x, z)
|
||||||
|
var ss: float = terrain.stream_surface(x, z)
|
||||||
|
if ground < ss:
|
||||||
|
return {"type": "stream", "surface": ss, "depth": ss - ground}
|
||||||
var beach: bool = terrain.beach_weight(x, z) > 0.5
|
var beach: bool = terrain.beach_weight(x, z) > 0.5
|
||||||
if terrain.inside_boundary(x, z, -2.0) and ground < terrain.water_level:
|
if terrain.inside_boundary(x, z, -2.0) and ground < terrain.water_level:
|
||||||
if beach and not _in_lake(x, z):
|
if beach and not _in_lake(x, z):
|
||||||
@@ -1139,6 +1325,21 @@ func water_at(x: float, z: float) -> Dictionary:
|
|||||||
# and its ways out. (W0 adds these; systems move over to them as each worldlet
|
# and its ways out. (W0 adds these; systems move over to them as each worldlet
|
||||||
# needs them to be different.)
|
# needs them to be different.)
|
||||||
|
|
||||||
|
## Whether this land has set piece `kind` ("den", "barrow", "stones",
|
||||||
|
## "bear", "pier", "wreck"...): its worldlet's "places". A worldlet that
|
||||||
|
## doesn't list them has the Valley's set (den, barrow, stones, bear).
|
||||||
|
const DEFAULT_PLACES := ["den", "barrow", "stones", "bear"]
|
||||||
|
func has_place(kind: String) -> bool:
|
||||||
|
var places: Array = terrain.worldlet.get("places", [])
|
||||||
|
if places.is_empty():
|
||||||
|
return kind in DEFAULT_PLACES
|
||||||
|
return places.any(func(p): return p is Dictionary and String(p.get("type", "")) == kind)
|
||||||
|
|
||||||
|
## Whether this land has wildlife `kind` beyond the usual ("crabs",
|
||||||
|
## "gulls"): its worldlet's "wildlife" list (0.6.0; the Cove's).
|
||||||
|
func has_wildlife(kind: String) -> bool:
|
||||||
|
return kind in Array(terrain.worldlet.get("wildlife", []))
|
||||||
|
|
||||||
## This worldlet's id ("valley") and its whole definition.
|
## This worldlet's id ("valley") and its whole definition.
|
||||||
func worldlet_id() -> String:
|
func worldlet_id() -> String:
|
||||||
return terrain.worldlet_id
|
return terrain.worldlet_id
|
||||||
@@ -1161,6 +1362,11 @@ func water_bodies() -> Array:
|
|||||||
"beach":
|
"beach":
|
||||||
out.append({"type": "sea", "surface": terrain.sea_level,
|
out.append({"type": "sea", "surface": terrain.sea_level,
|
||||||
"center": terrain.shore_point(12.0), "radius": 0.0})
|
"center": terrain.shore_point(12.0), "radius": 0.0})
|
||||||
|
"stream":
|
||||||
|
if terrain.stream_path.size() >= 2:
|
||||||
|
var mid: Vector2 = terrain._stream_point(terrain._stream_end * 0.5)
|
||||||
|
out.append({"type": "stream", "surface": terrain._stream_surface_at(terrain._stream_end * 0.5),
|
||||||
|
"center": mid, "radius": terrain.stream_width * 0.5, "path": terrain.stream_path})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
## Whether (x, z) is in one of the lakes or ponds (not the sea off a beach).
|
## Whether (x, z) is in one of the lakes or ponds (not the sea off a beach).
|
||||||
@@ -1199,6 +1405,8 @@ func _go_online() -> void:
|
|||||||
load_online_map() # (your map of this server, from your own computer)
|
load_online_map() # (your map of this server, from your own computer)
|
||||||
Net.left.connect(func(_why = ""): save_online_map(), CONNECT_ONE_SHOT)
|
Net.left.connect(func(_why = ""): save_online_map(), CONNECT_ONE_SHOT)
|
||||||
Net.left.connect(_on_net_left)
|
Net.left.connect(_on_net_left)
|
||||||
|
Net.travelling.connect(_on_net_travelling)
|
||||||
|
Net.arrived.connect(_on_net_arrived)
|
||||||
get_tree().auto_accept_quit = false # leave the server cleanly on window close
|
get_tree().auto_accept_quit = false # leave the server cleanly on window close
|
||||||
# The island's changes come from the server from now on.
|
# The island's changes come from the server from now on.
|
||||||
props.regrow_enabled = false
|
props.regrow_enabled = false
|
||||||
@@ -1213,6 +1421,9 @@ func _go_online() -> void:
|
|||||||
if Net.hosting:
|
if Net.hosting:
|
||||||
var at := Net.my_addresses()
|
var at := Net.my_addresses()
|
||||||
Actions.notify.emit("You're hosting. Friends join at %s" % (", ".join(PackedStringArray(at)) if not at.is_empty() else "this computer's address"))
|
Actions.notify.emit("You're hosting. Friends join at %s" % (", ".join(PackedStringArray(at)) if not at.is_empty() else "this computer's address"))
|
||||||
|
var home_land := String(Net.server_info.get("worldlet", "valley"))
|
||||||
|
if not Lands.extra_ports(home_land, Net._host_port).is_empty():
|
||||||
|
Actions.notify.emit("Over the internet, forward %s" % Lands.ports_text(home_land, Net._host_port))
|
||||||
|
|
||||||
func _on_net_left(_reason: String) -> void:
|
func _on_net_left(_reason: String) -> void:
|
||||||
WorldNet.leave_world()
|
WorldNet.leave_world()
|
||||||
|
|||||||
150
scripts/world/wreck.gd
Normal file
150
scripts/world/wreck.gd
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
extends Node3D
|
||||||
|
## The Cove's wreck (docs/WORLDLETS_DESIGN.md §5, 0.6.0): an old ship driven
|
||||||
|
## up on the sand at the far end of the bay from the sea cave, her bow in the
|
||||||
|
## shallows, one side stove in. Inside, the captain's sea chest: a torn
|
||||||
|
## treasure map and a few useful things, **once for each player** (single-
|
||||||
|
## player and online alike: who's had theirs is saved with the Cove).
|
||||||
|
##
|
||||||
|
## Placed from the terrain alone, like the pier, so it's the same for
|
||||||
|
## everyone; only `opened` (who's had their share) is saved.
|
||||||
|
|
||||||
|
const Pier = preload("res://scripts/world/pier.gd")
|
||||||
|
const OAK := Color(0.36, 0.25, 0.16)
|
||||||
|
const DARK := Color(0.24, 0.17, 0.11)
|
||||||
|
const WET := Color(0.2, 0.18, 0.15)
|
||||||
|
const IRON := Color(0.22, 0.22, 0.24)
|
||||||
|
## What's in the sea chest, for each player once.
|
||||||
|
const LOOT := {"treasure_map": 1, "nails": 4, "salt": 2}
|
||||||
|
|
||||||
|
var world: Node3D
|
||||||
|
var opened: Array = [] ## the player ids who've had their share
|
||||||
|
var chest: StaticBody3D
|
||||||
|
var front := Vector3.ZERO ## where you stand to reach into the chest
|
||||||
|
|
||||||
|
func place(w: Node3D) -> bool:
|
||||||
|
world = w
|
||||||
|
var t: Node3D = w.terrain
|
||||||
|
if not w.has_place("wreck") or not t._has_beach:
|
||||||
|
return false
|
||||||
|
# The far end of the beach from the sea cave, where the sand meets the water.
|
||||||
|
var a := deg_to_rad(t.beach_angle_deg - t.beach_half_width_deg * 0.8)
|
||||||
|
var dir := Vector2(cos(a), sin(a))
|
||||||
|
var r: float = t._shore_d() - t._rim_offset(dir.x, dir.y)
|
||||||
|
var at2 := dir * (r - 1.0)
|
||||||
|
var at := Vector3(at2.x, maxf(t.sample_height(at2.x, at2.y), t.water_level - 0.3), at2.y)
|
||||||
|
# Bow out to sea (+Z local), a little askew, heeled over on her side.
|
||||||
|
var out := Vector3(dir.x, 0.0, dir.y).rotated(Vector3.UP, 0.35)
|
||||||
|
global_transform = Transform3D(Basis.looking_at(-out, Vector3.UP), at)
|
||||||
|
_build()
|
||||||
|
add_to_group("wreck")
|
||||||
|
return true
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
var body := StaticBody3D.new()
|
||||||
|
body.name = "Hull"
|
||||||
|
add_child(body)
|
||||||
|
var heel := Basis(Vector3(0, 0, 1), deg_to_rad(-14.0))
|
||||||
|
var parts: Array = [
|
||||||
|
# [centre, size, colour] in the hull's frame (+Z the bow, +X starboard)
|
||||||
|
[Vector3(0, 0.12, 0), Vector3(0.35, 0.25, 9.0), DARK], # keel
|
||||||
|
[Vector3(-1.45, 0.75, 0), Vector3(0.14, 1.3, 8.2), OAK], # port side, whole
|
||||||
|
[Vector3(1.45, 0.75, 2.6), Vector3(0.14, 1.3, 3.0), OAK], # starboard, forward
|
||||||
|
[Vector3(1.45, 0.55, -3.0), Vector3(0.14, 0.9, 2.2), OAK], # starboard, aft (stove in between)
|
||||||
|
[Vector3(0, 0.75, -4.1), Vector3(2.9, 1.3, 0.14), OAK], # transom
|
||||||
|
[Vector3(0, 0.05, 0), Vector3(2.8, 0.1, 7.6), WET], # bilge boards
|
||||||
|
[Vector3(0, 1.8, 1.4), Vector3(0.22, 2.6, 0.22), DARK], # mast stump
|
||||||
|
]
|
||||||
|
for p in parts:
|
||||||
|
var c: Vector3 = heel * p[0]
|
||||||
|
Pier._box(st, c, p[1], p[2])
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = p[1]
|
||||||
|
cs.shape = box
|
||||||
|
cs.transform = Transform3D(heel, c)
|
||||||
|
body.add_child(cs)
|
||||||
|
# Ribs over the open hull, some broken off short.
|
||||||
|
for i in range(6):
|
||||||
|
var z := -3.4 + i * 1.3
|
||||||
|
for side in [-1.0, 1.0]:
|
||||||
|
if side > 0.0 and z > -1.8 and z < 1.0:
|
||||||
|
continue # (the stove-in side)
|
||||||
|
var h := 1.9 if (i + int(side)) % 3 != 0 else 1.2
|
||||||
|
Pier._box(st, heel * Vector3(side * 1.3, h * 0.5 + 0.5, z), Vector3(0.12, h, 0.14), DARK)
|
||||||
|
# Planks strewn on the sand on the open side.
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = 919
|
||||||
|
for i in range(5):
|
||||||
|
var off := Vector3(2.6 + rng.randf() * 2.5, 0.05, -2.0 + rng.randf() * 4.0)
|
||||||
|
Pier._box(st, off, Vector3(0.3, 0.06, 1.6 + rng.randf()), OAK.darkened(rng.randf() * 0.3))
|
||||||
|
st.generate_normals()
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
mi.name = "Ship"
|
||||||
|
mi.mesh = st.commit()
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.vertex_color_use_as_albedo = true
|
||||||
|
mat.vertex_color_is_srgb = true # (the colours above are picked by eye: dark, weathered oak)
|
||||||
|
mat.roughness = 0.95
|
||||||
|
mi.material_override = mat
|
||||||
|
add_child(mi)
|
||||||
|
# The captain's sea chest, wedged inside by the break.
|
||||||
|
chest = StaticBody3D.new()
|
||||||
|
chest.name = "SeaChest"
|
||||||
|
chest.collision_layer = 1 | 2
|
||||||
|
chest.set_script(preload("res://scripts/world/wreck_chest.gd"))
|
||||||
|
chest.wreck = self
|
||||||
|
var cst := SurfaceTool.new()
|
||||||
|
cst.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
Pier._box(cst, Vector3(0, 0.25, 0), Vector3(0.9, 0.5, 0.55), Color(0.3, 0.2, 0.12))
|
||||||
|
Pier._box(cst, Vector3(0, 0.55, 0), Vector3(0.92, 0.12, 0.57), Color(0.27, 0.18, 0.1))
|
||||||
|
for x in [-0.32, 0.32]:
|
||||||
|
Pier._box(cst, Vector3(x, 0.32, 0), Vector3(0.06, 0.64, 0.6), IRON)
|
||||||
|
cst.generate_normals()
|
||||||
|
var cmi := MeshInstance3D.new()
|
||||||
|
cmi.mesh = cst.commit()
|
||||||
|
cmi.material_override = mat
|
||||||
|
chest.add_child(cmi)
|
||||||
|
var ccs := CollisionShape3D.new()
|
||||||
|
var cb := BoxShape3D.new()
|
||||||
|
cb.size = Vector3(0.9, 0.62, 0.55)
|
||||||
|
ccs.shape = cb
|
||||||
|
ccs.position = Vector3(0, 0.31, 0)
|
||||||
|
chest.add_child(ccs)
|
||||||
|
add_child(chest)
|
||||||
|
chest.transform = Transform3D(heel * Basis(Vector3.UP, 0.4), heel * Vector3(0.5, 0.12, -0.4))
|
||||||
|
front = global_transform * (heel * Vector3(2.4, 0.0, -0.4))
|
||||||
|
front.y = world.terrain.sample_height(front.x, front.z)
|
||||||
|
|
||||||
|
# --- the sea chest -------------------------------------------------------------
|
||||||
|
|
||||||
|
## Whether player `id` has had their share of the chest.
|
||||||
|
func had(id: String) -> bool:
|
||||||
|
return id in opened
|
||||||
|
|
||||||
|
## Gives player `p` (id `id`) their share: the map and the rest, once.
|
||||||
|
## Returns false (and says why) when they've had it, or their bag's too full.
|
||||||
|
func open_for(p: Node, id: String) -> bool:
|
||||||
|
if had(id):
|
||||||
|
Actions.tell(p, "The sea chest's empty: you've had what was in it")
|
||||||
|
return false
|
||||||
|
for item in LOOT:
|
||||||
|
if p.inventory.space_for(item) < int(LOOT[item]):
|
||||||
|
Actions.tell(p, "Make room in your bag first")
|
||||||
|
return false
|
||||||
|
for item in LOOT:
|
||||||
|
p.inventory.add(item, int(LOOT[item]))
|
||||||
|
opened.append(id)
|
||||||
|
Actions.tell(p, "In the captain's sea chest: a torn treasure map, %d nails and %d salt. Right-click the map to read it" % [
|
||||||
|
int(LOOT.nails), int(LOOT.salt)])
|
||||||
|
Sfx.at("door_open", chest.global_position)
|
||||||
|
if Net.is_server():
|
||||||
|
WorldNet.mark_changed()
|
||||||
|
return true
|
||||||
|
|
||||||
|
func to_data() -> Dictionary:
|
||||||
|
return {"opened": opened.duplicate()}
|
||||||
|
|
||||||
|
func load_data(d: Dictionary) -> void:
|
||||||
|
opened = (d.get("opened", []) as Array).duplicate()
|
||||||
1
scripts/world/wreck.gd.uid
Normal file
1
scripts/world/wreck.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dydutxxckscny
|
||||||
23
scripts/world/wreck_chest.gd
Normal file
23
scripts/world/wreck_chest.gd
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
extends StaticBody3D
|
||||||
|
## The wreck's sea chest (wreck.gd): what you look at and press E on. Online,
|
||||||
|
## your game asks the server, which gives you your share once.
|
||||||
|
|
||||||
|
var wreck: Node3D
|
||||||
|
|
||||||
|
func interact_prompt(player: Node) -> String:
|
||||||
|
if wreck.had(_id(player)):
|
||||||
|
return "The captain's sea chest (empty: you've had what was in it)"
|
||||||
|
return "[E] Open the captain's sea chest"
|
||||||
|
|
||||||
|
func interact(player: Node) -> void:
|
||||||
|
if wreck.world.is_online():
|
||||||
|
PlayerNet.ask("wreck_chest", [])
|
||||||
|
return
|
||||||
|
wreck.open_for(player, _id(player))
|
||||||
|
|
||||||
|
## Whose share: the player's id (a server's character, or, at home, yours).
|
||||||
|
static func _id(player: Node) -> String:
|
||||||
|
var pid: Variant = player.get("player_id")
|
||||||
|
if pid != null and not String(pid).is_empty():
|
||||||
|
return String(pid)
|
||||||
|
return String(Net.get_identity().player_id)
|
||||||
1
scripts/world/wreck_chest.gd.uid
Normal file
1
scripts/world/wreck_chest.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://oeh0v78qjmnh
|
||||||
@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
|
|||||||
echo "Importing the project first (fresh copy)..."
|
echo "Importing the project first (fresh copy)..."
|
||||||
godot --headless --path . --import >/dev/null 2>&1
|
godot --headless --path . --import >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_tunnel test_map test_terrain_edges test_boundary; do
|
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_net_lands test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_wreck test_shore test_boat test_bug_report test_tunnel test_map test_terrain_edges test_boundary; do
|
||||||
extra=""
|
extra=""
|
||||||
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
||||||
# A suite that waits on a world that failed to load would hang forever,
|
# A suite that waits on a world that failed to load would hang forever,
|
||||||
|
|||||||
160
tests/test_boat.gd
Normal file
160
tests/test_boat.gd
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## 0.6.0 (docs/WORLDLETS_DESIGN.md §5): the rowing boat. Seagrass washes up
|
||||||
|
## at the Cove's tide line and pine trees give resin when felled; seagrass
|
||||||
|
## twists into rope, resin and salt boil into pitch at a campfire, and
|
||||||
|
## driftwood, rope, pitch and nails make a rowing boat at a workbench. The
|
||||||
|
## mooring post at the end of the pier ties it up at a berth of its own, one
|
||||||
|
## for each player, and it's saved there. "Set sail" needs the ship's
|
||||||
|
## compass, and even then the Highlands aren't charted yet. No pier, no
|
||||||
|
## moorings: none in the Valley.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_boat.gd
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var S: Node
|
||||||
|
var A: Node
|
||||||
|
var DB: Node
|
||||||
|
var _toasts: Array[String] = []
|
||||||
|
|
||||||
|
func check(cond: bool, what: String) -> void:
|
||||||
|
print((" ok " if cond else " FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
_fails += 1
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
S = root.get_node("Saves")
|
||||||
|
A = root.get_node("Actions")
|
||||||
|
DB = root.get_node("ItemDB")
|
||||||
|
A.notify.connect(func(t): _toasts.append(t))
|
||||||
|
S.root_dir = "user://test_boat_worlds"
|
||||||
|
for x in S.list_worlds():
|
||||||
|
S.delete_world(x.id)
|
||||||
|
await _body()
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
for x in S.list_worlds():
|
||||||
|
S.delete_world(x.id)
|
||||||
|
DirAccess.remove_absolute(S.root_dir)
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
func _load() -> Node3D:
|
||||||
|
var x: Node3D = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(x)
|
||||||
|
for _i in range(8):
|
||||||
|
await physics_frame
|
||||||
|
x.creatures.enabled = false
|
||||||
|
x.player.set_physics_process(false)
|
||||||
|
x.player.survival.set_process(false)
|
||||||
|
return x
|
||||||
|
|
||||||
|
func _drop(x: Node3D) -> void:
|
||||||
|
x.queue_free()
|
||||||
|
for _i in range(3):
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
func _toast_has(s: String) -> bool:
|
||||||
|
return _toasts.any(func(t): return String(t).contains(s))
|
||||||
|
|
||||||
|
func _body() -> void:
|
||||||
|
print("the Valley")
|
||||||
|
S.current_id = S.create_world("Valley", 1337)
|
||||||
|
var w: Node3D = await _load()
|
||||||
|
check(w.moorings == null, "no pier, no moorings")
|
||||||
|
var resin: Array = w.props.resource_nodes.values().filter(func(n): return n.drops_final.has("pine_resin"))
|
||||||
|
check(resin.size() > 20, "its pines give resin when felled (%d trees)" % resin.size())
|
||||||
|
await _drop(w)
|
||||||
|
|
||||||
|
print("the Cove")
|
||||||
|
var cove: String = S.create_world("Cove", 1337, {"worldlet": "cove"})
|
||||||
|
S.current_id = cove
|
||||||
|
w = await _load()
|
||||||
|
var t: Node3D = w.terrain
|
||||||
|
var mo: Node3D = w.moorings
|
||||||
|
check(mo != null, "moorings at the pier")
|
||||||
|
if mo == null:
|
||||||
|
return
|
||||||
|
check(mo.post.global_position.distance_to(w.pier.end) < 2.0, "a mooring post at the end of the pier")
|
||||||
|
var deep := true
|
||||||
|
var depths: Array = []
|
||||||
|
for i in range(mo.BERTHS):
|
||||||
|
var b: Transform3D = mo.berth_xf(i)
|
||||||
|
var wa: Dictionary = w.water_at(b.origin.x, b.origin.z)
|
||||||
|
depths.append("%.1f" % float(wa.get("depth", -1.0)))
|
||||||
|
deep = deep and not wa.is_empty() and float(wa.depth) > 0.6 and not w.pier.covers(b.origin.x, b.origin.z, 0.2)
|
||||||
|
check(deep, "%d berths alongside it, all in open water (%s m deep)" % [mo.BERTHS, ", ".join(depths)])
|
||||||
|
var grass: Array = w.props.pickups.values().filter(func(p): return p.item_id == "seagrass")
|
||||||
|
check(grass.size() >= 15 and grass.all(func(p): return t.on_sand(p.global_position.x, p.global_position.z)),
|
||||||
|
"seagrass at the tide line (%d)" % grass.size())
|
||||||
|
|
||||||
|
print("building it")
|
||||||
|
var p: Node3D = w.player
|
||||||
|
p.global_position = w.build.debug_find_site(w._spawn, 3.0)
|
||||||
|
var inv = p.inventory
|
||||||
|
inv.add("seagrass", 12)
|
||||||
|
inv.add("pine_resin", 6)
|
||||||
|
inv.add("salt", 3)
|
||||||
|
inv.add("driftwood", 12)
|
||||||
|
inv.add("nails", 6)
|
||||||
|
var ok := true
|
||||||
|
for i in range(4):
|
||||||
|
ok = A.craft(p, "rope") and ok
|
||||||
|
check(ok and inv.count("rope") == 4 and inv.count("seagrass") == 0, "3 seagrass twist into a rope, by hand")
|
||||||
|
check(not A.craft(p, "pitch"), "pitch needs a fire")
|
||||||
|
var fire: Node3D = w.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), p.global_position + Vector3(2, 0, 0)))
|
||||||
|
for i in range(3):
|
||||||
|
ok = A.craft(p, "pitch") and ok
|
||||||
|
check(ok and inv.count("pitch") == 3 and inv.count("salt") == 0, "2 resin and a salt boil into pitch at a campfire")
|
||||||
|
check(not A.craft(p, "rowing_boat"), "the boat needs a workbench")
|
||||||
|
w.spawn_piece(DB.get_item("workbench"), Transform3D(Basis(), p.global_position + Vector3(-2, 0, 0)))
|
||||||
|
check(A.craft(p, "rowing_boat") and inv.count("rowing_boat") == 1, "driftwood, rope, pitch and nails: a rowing boat")
|
||||||
|
check(inv.count("driftwood") == 0 and inv.count("rope") == 0 and inv.count("pitch") == 0 and inv.count("nails") == 0, "(all used)")
|
||||||
|
w.remove_piece(fire)
|
||||||
|
|
||||||
|
print("tying it up")
|
||||||
|
p.global_position = mo.post.global_position + w.pier.dir * -1.0
|
||||||
|
var me: String = mo.post.id_of(p)
|
||||||
|
check(mo.post.interact_prompt(p).contains("[E] Tie up your boat"), "the post: [E] Tie up your boat")
|
||||||
|
_toasts.clear()
|
||||||
|
mo.post.interact(p)
|
||||||
|
check(mo.has_boat(me) and inv.count("rowing_boat") == 0, "it's tied up, out of your bag")
|
||||||
|
check(_toast_has("tie your boat up"), "(and it says so)")
|
||||||
|
var boat: Node3D = mo.boats.get(me)
|
||||||
|
check(boat != null and boat.global_position.distance_to(mo.berth_xf(int(mo.moored[me].berth)).origin) < 0.3, "at a berth by the pier")
|
||||||
|
check(boat != null and boat.interact_prompt(p) == "Your rowing boat [E] Set sail", "\"Your rowing boat [E] Set sail\"")
|
||||||
|
check(mo.post.interact_prompt(p).contains("your boat's tied up"), "the post knows")
|
||||||
|
inv.add("rowing_boat", 1)
|
||||||
|
_toasts.clear()
|
||||||
|
check(not mo.moor(p, me) and inv.count("rowing_boat") == 1, "one boat each: a second doesn't go in")
|
||||||
|
inv.remove("rowing_boat", 1)
|
||||||
|
|
||||||
|
print("setting sail")
|
||||||
|
_toasts.clear()
|
||||||
|
boat.interact(p)
|
||||||
|
check(_toast_has("without a ship's compass"), "without a ship's compass you'd be lost (%s)" % [_toasts])
|
||||||
|
inv.add("ship_compass", 1)
|
||||||
|
_toasts.clear()
|
||||||
|
boat.interact(p)
|
||||||
|
check(_toast_has("nobody's charted the way to the Highlands"), "with it: the Highlands aren't charted yet")
|
||||||
|
check(mo.has_boat(me), "(and it stays tied up)")
|
||||||
|
|
||||||
|
print("someone else's")
|
||||||
|
inv.add("rowing_boat", 1)
|
||||||
|
check(mo.moor(p, "someone-else"), "someone else ties up theirs")
|
||||||
|
check(mo.boats.size() == 2 and int(mo.moored["someone-else"].berth) != int(mo.moored[me].berth), "at a berth of its own")
|
||||||
|
check(mo.boats["someone-else"].interact_prompt(p) == "Someone's rowing boat", "not yours: \"Someone's rowing boat\"")
|
||||||
|
_toasts.clear()
|
||||||
|
mo.boats["someone-else"].interact(p)
|
||||||
|
check(_toasts.is_empty(), "and you can't sail it")
|
||||||
|
|
||||||
|
print("saved")
|
||||||
|
var state: Dictionary = w.collect_save()
|
||||||
|
S.save_state(cove, state, 1.0)
|
||||||
|
await _drop(w)
|
||||||
|
w = await _load()
|
||||||
|
mo = w.moorings
|
||||||
|
check(mo != null and mo.has_boat(me) and mo.has_boat("someone-else") and mo.boats.size() == 2, "both boats are still there after a reload")
|
||||||
|
await _drop(w)
|
||||||
|
_finished = true
|
||||||
1
tests/test_boat.gd.uid
Normal file
1
tests/test_boat.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://sbe5ddhndij5
|
||||||
76
tests/test_bug_report.gd
Normal file
76
tests/test_bug_report.gd
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## 0.6.0, the playtest: Report a bug. The pause menu has a "Report a bug"
|
||||||
|
## page; its link is a new issue on the Emberwild repo, filled in with
|
||||||
|
## questions to answer and the details (version, computer, mode, land, time,
|
||||||
|
## where you are), short enough for a browser; the log's path is shown.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_bug_report.gd
|
||||||
|
|
||||||
|
const BugReport = preload("res://scripts/core/bug_report.gd")
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var S: Node
|
||||||
|
|
||||||
|
func check(cond: bool, what: String) -> void:
|
||||||
|
print((" ok " if cond else " FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
_fails += 1
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
S = root.get_node("Saves")
|
||||||
|
S.root_dir = "user://test_bug_worlds"
|
||||||
|
for x in S.list_worlds():
|
||||||
|
S.delete_world(x.id)
|
||||||
|
await _body()
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
for x in S.list_worlds():
|
||||||
|
S.delete_world(x.id)
|
||||||
|
DirAccess.remove_absolute(S.root_dir)
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
func _body() -> void:
|
||||||
|
S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"})
|
||||||
|
var w: Node3D = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(w)
|
||||||
|
for _i in range(8):
|
||||||
|
await physics_frame
|
||||||
|
w.creatures.enabled = false
|
||||||
|
w.player.set_physics_process(false)
|
||||||
|
|
||||||
|
print("the link")
|
||||||
|
var url: String = BugReport.issue_url(w)
|
||||||
|
check(url.begins_with("https://git.psbailey.uk/Admin/Emberwild/issues/new?title=Bug%3A%20&body="), "a new issue on the Emberwild repo")
|
||||||
|
check(url.length() < 4000, "short enough for any browser (%d characters)" % url.length())
|
||||||
|
var body := url.get_slice("&body=", 1).uri_decode()
|
||||||
|
var ver: String = String(ProjectSettings.get_setting("application/config/version"))
|
||||||
|
for want in ["**What happened?**", "**How can we make it happen again?**", "- Version: Emberwild %s" % ver,
|
||||||
|
"- Computer: ", "- Mode: single-player", "- Land: Cove", "- Time: day ", "- Where: ", "- Seed: 1337", "godot.log"]:
|
||||||
|
check(body.contains(want), "it says %s" % want)
|
||||||
|
check(BugReport.log_path().ends_with("logs/godot.log") and BugReport.log_path().begins_with("/"), "the log's full path (%s)" % BugReport.log_path())
|
||||||
|
check(not BugReport.details_text(w).contains("**"), "Copy gives just the details")
|
||||||
|
check(not body.contains(OS.get_environment("HOME")) and body.contains("~/"), "and not your user name (the home folder is ~)")
|
||||||
|
|
||||||
|
print("the pause menu")
|
||||||
|
var menu: Control = load("res://scripts/ui/pause_menu.gd").new() # (headless there's no HUD: build the menu as it does)
|
||||||
|
w.add_child(menu)
|
||||||
|
menu.bind(w.player)
|
||||||
|
var btn: Button = null
|
||||||
|
for c in menu._main.get_children():
|
||||||
|
if c is Button and c.text == "Report a bug":
|
||||||
|
btn = c
|
||||||
|
check(btn != null, "a Report a bug button")
|
||||||
|
if btn != null:
|
||||||
|
menu.open()
|
||||||
|
btn.pressed.emit()
|
||||||
|
check(menu._report.visible and not menu._main.visible, "it opens the Report a bug page")
|
||||||
|
var labels: Array = menu._report.get_children().filter(func(c): return c is Button).map(func(c): return c.text)
|
||||||
|
check("Open the bug report page" in labels and "Copy the details" in labels and "Show the log file" in labels, "with %s" % [labels])
|
||||||
|
menu.back()
|
||||||
|
check(menu._main.visible and not menu._report.visible, "Esc goes back")
|
||||||
|
menu.close()
|
||||||
|
menu.queue_free()
|
||||||
|
_finished = true
|
||||||
1
tests/test_bug_report.gd.uid
Normal file
1
tests/test_bug_report.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bqvh4okrloluq
|
||||||
@@ -59,9 +59,35 @@ func _body() -> void:
|
|||||||
check(world.crossings().any(func(c): return c.to == "valley") and world.crossings().any(func(c): return c.by == "sail"),
|
check(world.crossings().any(func(c): return c.to == "valley") and world.crossings().any(func(c): return c.by == "sail"),
|
||||||
"its ways on: back to the Valley, and on by sail")
|
"its ways on: back to the Valley, and on by sail")
|
||||||
check(world._spawn.y > t.water_level + 1.0, "you start on dry land")
|
check(world._spawn.y > t.water_level + 1.0, "you start on dry land")
|
||||||
check(world.den != null and world.barrow != null and world.stones != null, "the den, the barrow and the stones found places")
|
check(world.den != null and world.den.found, "the Cove's wolves have a den")
|
||||||
|
check(world.barrow == null and world.stones == null and world.bears == null,
|
||||||
|
"but the barrow, the standing stones and the bear are the Valley's alone")
|
||||||
check(t.get_node_or_null("Water") == null, "one water surface (the bay is the sea)")
|
check(t.get_node_or_null("Water") == null, "one water surface (the bay is the sea)")
|
||||||
|
|
||||||
|
print("the stream")
|
||||||
|
check(t.stream_path.size() >= 2 and t.get_node_or_null("Stream") != null, "a stream, with water in it")
|
||||||
|
var downhill := true
|
||||||
|
for i in range(1, t._stream_profile.size()):
|
||||||
|
downhill = downhill and t._stream_profile[i] <= t._stream_profile[i - 1] + 0.0001
|
||||||
|
check(downhill and t._stream_profile[0] > t.water_level + 1.5, "running downhill all the way from its spring (%.1f m) to the sea" % t._stream_profile[0])
|
||||||
|
var mid: Vector2 = t._stream_point(t._stream_end * 0.3)
|
||||||
|
var sw: Dictionary = world.water_at(mid.x, mid.y)
|
||||||
|
check(sw.get("type", "") == "stream" and float(sw.depth) > 0.3 and float(sw.depth) < 1.0,
|
||||||
|
"stream water, wading deep (%.1f m)" % float(sw.get("depth", 0.0)))
|
||||||
|
var side: Vector2 = mid + Vector2(t.stream_width * 1.2, 0.0)
|
||||||
|
check(world.water_at(side.x, side.y).is_empty() and t.sample_height(side.x, side.y) > float(sw.surface),
|
||||||
|
"its banks are dry, and above the water")
|
||||||
|
check(DB.water_fishing("stream").get("fish", []).any(func(f): return f.id == "trout"), "trout in the stream")
|
||||||
|
var away := true
|
||||||
|
for p in [world.den.global_position, world._spawn]:
|
||||||
|
away = away and not t.near_stream(p.x, p.z, 3.0)
|
||||||
|
check(away, "the den and the start are clear of it")
|
||||||
|
var in_bed := 0
|
||||||
|
for n in world.props.resource_nodes.values():
|
||||||
|
if is_instance_valid(n) and t.near_stream(n.global_position.x, n.global_position.z, 0.0):
|
||||||
|
in_bed += 1
|
||||||
|
check(in_bed == 0, "no trees or boulders standing in it")
|
||||||
|
|
||||||
print("the pier")
|
print("the pier")
|
||||||
var pier: Node3D = world.pier
|
var pier: Node3D = world.pier
|
||||||
check(pier != null and pier.is_in_group("pier"), "a pier")
|
check(pier != null and pier.is_in_group("pier"), "a pier")
|
||||||
|
|||||||
451
tests/test_net_lands.gd
Normal file
451
tests/test_net_lands.gd
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## J3a (docs/WORLDLETS_DESIGN.md: a server per land): the journey on a server.
|
||||||
|
##
|
||||||
|
## A real headless server (the hub, the Valley, with --debug-cheats), Alice
|
||||||
|
## (this test) and Bob (tests/net_world_bot.gd), who stays in the Valley.
|
||||||
|
## Alice digs the cliff tunnel's seam and first stretch online (the server
|
||||||
|
## decides, and her game sees the rock change), props it, goes down, lights
|
||||||
|
## it. With the tunnel dug through, she walks out of the sea cave's mouth:
|
||||||
|
## the hub starts the Cove's server, she's sent on there with a pass, and
|
||||||
|
## arrives on the Cove's shore with her bag, on the same day and hour, while
|
||||||
|
## Bob carries on in the Valley. Back up the sea cave to the hub; then out
|
||||||
|
## to the Cove again, leave the game, and join the hub again: she's sent
|
||||||
|
## straight on to the Cove, where she left off.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_net_lands.gd
|
||||||
|
|
||||||
|
const PORT := 24371
|
||||||
|
const DIR := "user://test_net"
|
||||||
|
const SRV := DIR + "/server_lands"
|
||||||
|
const BOBS := DIR + "/bob_lands"
|
||||||
|
const SEED := 1337
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var _pids: Array[int] = []
|
||||||
|
var Net: Node
|
||||||
|
var WN: Node
|
||||||
|
var PN: Node
|
||||||
|
var A: Node
|
||||||
|
var L: Node
|
||||||
|
var world: Node3D
|
||||||
|
var player: CharacterBody3D
|
||||||
|
var _bob_dir := ""
|
||||||
|
var _cmd := 0
|
||||||
|
var _toasts: Array[String] = []
|
||||||
|
var _arrived := ""
|
||||||
|
|
||||||
|
func check(cond: bool, what: String) -> void:
|
||||||
|
print((" ok " if cond else " FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
_fails += 1
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
Engine.max_fps = 60
|
||||||
|
Net = root.get_node("Net")
|
||||||
|
WN = root.get_node("WorldNet")
|
||||||
|
PN = root.get_node("PlayerNet")
|
||||||
|
A = root.get_node("Actions")
|
||||||
|
L = root.get_node("Lands")
|
||||||
|
A.notify.connect(func(t): _toasts.append(t))
|
||||||
|
Net.arrived.connect(func(l): _arrived = l)
|
||||||
|
for d in [SRV, BOBS]:
|
||||||
|
_rm_tree(d)
|
||||||
|
# (EMBERWILD_SERVER_BIN=<an exported game>: the hub, and the land servers
|
||||||
|
# it starts, are that build: checks a release does it too.)
|
||||||
|
_spawn(["--server", "--port", str(PORT), "--config", ProjectSettings.globalize_path(SRV + "/server.json"),
|
||||||
|
"--server-name", "Lands Test", "--seed", str(SEED), "--debug-cheats", "--no-creatures"], "",
|
||||||
|
OS.get_environment("EMBERWILD_SERVER_BIN"))
|
||||||
|
await _sleep(4.0)
|
||||||
|
await _body()
|
||||||
|
if world != null and is_instance_valid(world):
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
Net.leave()
|
||||||
|
await _sleep(0.5)
|
||||||
|
for pid in _pids:
|
||||||
|
if OS.is_process_running(pid):
|
||||||
|
OS.kill(pid)
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
await _sleep(1.0)
|
||||||
|
if _fails == 0 or OS.get_environment("KEEP_SRV").is_empty():
|
||||||
|
_rm_tree(SRV)
|
||||||
|
_rm_tree(BOBS)
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
func _body() -> void:
|
||||||
|
var me = load("res://scripts/net/identity.gd").open(DIR + "/a_lands")
|
||||||
|
me.name = "Alice"
|
||||||
|
Net.identity = me
|
||||||
|
var r := await _join()
|
||||||
|
check(r == "", "Alice joins the hub (%s)" % r)
|
||||||
|
if r != "":
|
||||||
|
return
|
||||||
|
await _load_world()
|
||||||
|
check(world.worldlet_id() == "valley" and world.tunnel != null, "the Valley, with its cliff tunnel, online")
|
||||||
|
var b0 := await _new_bob()
|
||||||
|
check(bool(b0.get("ok", false)), "Bob joins too (%s)" % b0.get("why", "ok"))
|
||||||
|
PN.ask("debug_give", ["antler_pickaxe", 1])
|
||||||
|
PN.ask("debug_give", ["wood", 10])
|
||||||
|
PN.ask("debug_give", ["torch_stand", 2])
|
||||||
|
await _sleep(0.6)
|
||||||
|
_hold("antler_pickaxe")
|
||||||
|
await _sleep(0.3)
|
||||||
|
|
||||||
|
print("digging the seam, online")
|
||||||
|
var t: Node3D = world.tunnel
|
||||||
|
await _tp(t.seam_out + Vector3(0, 0.2, 0))
|
||||||
|
var seam: Node3D = t._face
|
||||||
|
check(seam != null and seam.stage == 0, "the seam, as the server sees it")
|
||||||
|
await _dig_face(30)
|
||||||
|
check(t.opened, "dug open: the server says so, and Alice's game has the opening")
|
||||||
|
t.go_in(player) # (asks the server, which moves her)
|
||||||
|
await _until(func(): return world.underground_at(player.global_position), 3.0)
|
||||||
|
check(world.underground_at(player.global_position), "down into the tunnel (the server takes her)")
|
||||||
|
await _dig_face(40)
|
||||||
|
check(t.dug == 1, "dug the first stretch (%d)" % t.dug)
|
||||||
|
t.set_prop(player, 1)
|
||||||
|
await _until(func(): return bool(t.props[1]), 3.0)
|
||||||
|
check(t.props[1], "propped it, online")
|
||||||
|
PN.ask("debug_tunnel", [t.STRETCHES])
|
||||||
|
await _until(func(): return t.dug == t.STRETCHES, 3.0)
|
||||||
|
check(t.dug == t.STRETCHES and t.props.count(true) >= t.STRETCHES - 1, "(the rest dug by the test's cheat: down to the sea cave)")
|
||||||
|
var slot := _slot("torch_stand")
|
||||||
|
var floor_at: Vector3 = t._mouth(2) + Vector3(0.9, 0, 0.5) # (on the landing, beside the way down)
|
||||||
|
await _tp(floor_at + Vector3(1.0, 0.1, 0))
|
||||||
|
A.place(player, slot, Transform3D(Basis(), floor_at), world)
|
||||||
|
await _until(func(): return world.all_pieces().any(func(p): return p.item_id == "torch_stand"), 3.0)
|
||||||
|
check(world.all_pieces().any(func(p): return p.item_id == "torch_stand" and world.underground_at(p.global_position)),
|
||||||
|
"a torch stand set down there, for everyone")
|
||||||
|
|
||||||
|
# (A hut with beds in the Valley, for Bob, later.)
|
||||||
|
var vsite: Vector3 = world.build.debug_find_site(world._spawn + Vector3(0, 0, 14), 3.0)
|
||||||
|
PN.ask("debug_hut", [vsite])
|
||||||
|
await _until(func(): return _beds().size() >= 1, 8.0)
|
||||||
|
var bob_bed: int = WN.nid(_beds()[0]) if not _beds().is_empty() else 0
|
||||||
|
var bob_bed_at: Vector3 = _beds()[0].global_position if not _beds().is_empty() else Vector3.ZERO
|
||||||
|
|
||||||
|
print("out to the Cove")
|
||||||
|
var day0: int = world.day_night.day
|
||||||
|
var stone0: int = player.inventory.count("stone")
|
||||||
|
await _tp(t.cave_mouth + Vector3(0.8, 0.2, 0))
|
||||||
|
_arrived = ""
|
||||||
|
world.travel("cove", "sea_cave") # (what walking into the mouth does)
|
||||||
|
await _until(func(): return not _arrived.is_empty(), 45.0)
|
||||||
|
check(_arrived == "cove", "the hub starts the Cove's server and sends her on (%s)" % (_arrived if not _arrived.is_empty() else "never arrived: %s" % [_toasts.slice(-3)]))
|
||||||
|
if _arrived != "cove":
|
||||||
|
return
|
||||||
|
check(FileAccess.file_exists(SRV + "/land-cove.log"), "(the Cove's server logs beside server.json)")
|
||||||
|
await _load_world()
|
||||||
|
check(world.worldlet_id() == "cove", "Alice is in the Cove")
|
||||||
|
check(world.sea_cave != null and player.global_position.distance_to(world.sea_cave.front) < 2.0,
|
||||||
|
"on its shore, by the sea cave (%.1f m off)" % player.global_position.distance_to(world.sea_cave.front))
|
||||||
|
check(player.inventory.count("antler_pickaxe") == 1 and player.inventory.count("stone") == stone0, "with her bag as it was")
|
||||||
|
check(world.day_night.day == day0, "the same day: one clock for every land")
|
||||||
|
check(world.sea_cave.open(), "the sea cave leads back up (the hub says the tunnel's through)")
|
||||||
|
var bob := await _bob({"do": "report"})
|
||||||
|
check(not bob.has("error") and not bob.is_empty(), "Bob carries on in the Valley, on the hub (%s)" % bob.get("error", "ok"))
|
||||||
|
print("the wreck's sea chest, online")
|
||||||
|
await _tp(world.wreck.front + Vector3(0, 0.1, 0))
|
||||||
|
_toasts.clear()
|
||||||
|
world.wreck.chest.interact(player)
|
||||||
|
await _until(func(): return player.inventory.count("treasure_map") == 1, 4.0)
|
||||||
|
check(player.inventory.count("treasure_map") == 1, "Alice's share: the server gives her a treasure map")
|
||||||
|
await _until(func(): return world.wreck.had(world.my_id()), 3.0)
|
||||||
|
check(world.wreck.chest.interact_prompt(player).contains("empty"), "and her game knows the chest's empty for her now")
|
||||||
|
check(world.map_marks().any(func(m): return m.kind == "treasure"), "her X is on her map")
|
||||||
|
print("digging up the treasure, online")
|
||||||
|
await _sleep(0.8)
|
||||||
|
var mound: Node3D = world.treasure.mounds.get(world.my_id())
|
||||||
|
check(mound != null, "her mound shows at her X")
|
||||||
|
if mound != null:
|
||||||
|
await _tp(mound.global_position + Vector3(1.4, 0.2, 0))
|
||||||
|
_hold("antler_pickaxe")
|
||||||
|
await _sleep(0.3)
|
||||||
|
for _i in range(12):
|
||||||
|
if player.inventory.count("ship_compass") > 0:
|
||||||
|
break
|
||||||
|
PN.ask("debug_stats", [{"stamina": 100.0}])
|
||||||
|
if is_instance_valid(mound):
|
||||||
|
A.harvest_hit(player, mound, mound.global_position)
|
||||||
|
await _sleep(0.15)
|
||||||
|
await _until(func(): return player.inventory.count("ship_compass") == 1, 4.0)
|
||||||
|
check(player.inventory.count("ship_compass") == 1 and player.inventory.count("cutlass") == 1,
|
||||||
|
"the server digs it up for her: the compass and the cutlass in her bag")
|
||||||
|
await _until(func(): return world.my_id() in world.treasure.dug, 3.0)
|
||||||
|
check(world.my_id() in world.treasure.dug and not world.treasure.mounds.has(world.my_id()), "and her mound's gone")
|
||||||
|
print("tying up a boat, online")
|
||||||
|
PN.ask("debug_give", ["rowing_boat", 1])
|
||||||
|
await _until(func(): return player.inventory.count("rowing_boat") == 1, 3.0)
|
||||||
|
var mo: Node3D = world.moorings
|
||||||
|
var was: Vector3 = player.global_position
|
||||||
|
await _tp(mo.post.global_position - world.pier.dir * 1.0 + Vector3(0, 0.2, 0))
|
||||||
|
check(mo.post.interact_prompt(player).contains("Tie up your boat"), "at the mooring post with her boat")
|
||||||
|
mo.post.interact(player)
|
||||||
|
await _until(func(): return mo.has_boat(world.my_id()), 4.0)
|
||||||
|
check(mo.has_boat(world.my_id()) and mo.boats.has(world.my_id()), "the server ties it up, and her game shows it at its berth")
|
||||||
|
await _until(func(): return player.inventory.count("rowing_boat") == 0, 3.0)
|
||||||
|
check(player.inventory.count("rowing_boat") == 0, "(out of her bag)")
|
||||||
|
check(mo.boats[world.my_id()].interact_prompt(player).contains("Set sail"), "it's hers: [E] Set sail")
|
||||||
|
await _tp(was + Vector3(0, 0.2, 0)) # (back up the beach, where there's room for a hut)
|
||||||
|
await _across_lands(bob_bed, bob_bed_at)
|
||||||
|
|
||||||
|
print("back up to the Valley")
|
||||||
|
_arrived = ""
|
||||||
|
await _tp(world.sea_cave.front)
|
||||||
|
world.sea_cave.interact(player)
|
||||||
|
await _until(func(): return not _arrived.is_empty(), 30.0)
|
||||||
|
check(_arrived == "valley", "into the sea cave: back to the hub (%s)" % _arrived)
|
||||||
|
if _arrived != "valley":
|
||||||
|
return
|
||||||
|
await _load_world()
|
||||||
|
check(world.worldlet_id() == "valley" and world.underground_at(player.global_position)
|
||||||
|
and player.global_position.distance_to(world.tunnel.bottom) < 2.5, "arriving at the bottom of the tunnel")
|
||||||
|
|
||||||
|
print("leave from the Cove, and come back")
|
||||||
|
await _tp(world.tunnel.cave_mouth + Vector3(0.8, 0.2, 0))
|
||||||
|
_arrived = ""
|
||||||
|
world.travel("cove", "sea_cave")
|
||||||
|
await _until(func(): return not _arrived.is_empty(), 30.0)
|
||||||
|
await _load_world()
|
||||||
|
var spot: Vector3 = world.sea_cave.front + Vector3(4.0, 0.0, 2.0)
|
||||||
|
spot.y = world.terrain.sample_height(spot.x, spot.z) + 0.1
|
||||||
|
await _tp(spot)
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
world = null
|
||||||
|
Net.leave()
|
||||||
|
await _sleep(1.5)
|
||||||
|
_arrived = ""
|
||||||
|
r = await _join()
|
||||||
|
await _until(func(): return not _arrived.is_empty(), 30.0)
|
||||||
|
check(_arrived == "cove", "joining the hub again sends her straight on to the Cove (%s)" % _arrived)
|
||||||
|
if _arrived == "cove":
|
||||||
|
await _load_world()
|
||||||
|
check(world.worldlet_id() == "cove" and player.global_position.distance_to(spot) < 1.5,
|
||||||
|
"where she left off (%.1f m off)" % player.global_position.distance_to(spot))
|
||||||
|
print("the Cove's server crashes")
|
||||||
|
var why := {"text": ""}
|
||||||
|
var on_left := func(t): why.text = t
|
||||||
|
Net.left.connect(on_left)
|
||||||
|
var out: Array = []
|
||||||
|
OS.execute("lsof", ["-nP", "-t", "-iUDP:%d" % (PORT + 1)], out)
|
||||||
|
var pid := String(out[0] if not out.is_empty() else "").strip_edges().get_slice("\n", 0)
|
||||||
|
check(pid.is_valid_int(), "(found the Cove's server: pid %s)" % pid)
|
||||||
|
if pid.is_valid_int():
|
||||||
|
OS.kill(int(pid))
|
||||||
|
await _until(func(): return not String(why.text).is_empty(), 15.0)
|
||||||
|
Net.left.disconnect(on_left)
|
||||||
|
check(String(why.text).begins_with("Lost the connection to the Cove"), "Alice is told what happened (%s)" % why.text)
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
world = null
|
||||||
|
_arrived = ""
|
||||||
|
r = await _join()
|
||||||
|
await _until(func(): return not _arrived.is_empty(), 30.0)
|
||||||
|
check(_arrived == "cove", "joining again: the hub starts the Cove's server afresh and sends her back (%s)" % _arrived)
|
||||||
|
if _arrived == "cove":
|
||||||
|
await _load_world()
|
||||||
|
check(world.worldlet_id() == "cove" and player.inventory.count("antler_pickaxe") == 1, "in the Cove, with her things")
|
||||||
|
print("an admin in the Cove kicks someone in the Valley")
|
||||||
|
await _until(func(): return Net.roster.any(func(e): return String(e.name) == "Bob"), 4.0)
|
||||||
|
root.get_node("Chat").say("/kick Bob")
|
||||||
|
await _until(func(): return not Net.roster.any(func(e): return String(e.name) == "Bob"), 6.0)
|
||||||
|
check(not Net.roster.any(func(e): return String(e.name) == "Bob"), "Bob's gone from the server (%s)" % [Net.roster.map(func(e): return e.name)])
|
||||||
|
_finished = true
|
||||||
|
|
||||||
|
# --- J3b: chat, the player list, commands and sleep across lands --------------------------
|
||||||
|
|
||||||
|
func _across_lands(bob_bed: int, bob_bed_at: Vector3) -> void:
|
||||||
|
var C: Node = root.get_node("Chat")
|
||||||
|
print("chat across lands")
|
||||||
|
await _bob({"do": "say", "text": "hello from the Valley"})
|
||||||
|
await _until(func(): return C.heard.has("Bob: hello from the Valley"), 4.0)
|
||||||
|
check(C.heard.has("Bob: hello from the Valley"), "Bob in the Valley says hello; Alice hears it in the Cove")
|
||||||
|
await _sleep(1.2) # (chat's flood gap)
|
||||||
|
C.say("hi from the Cove")
|
||||||
|
var heard := false
|
||||||
|
for _i in range(15):
|
||||||
|
var h := await _bob({"do": "heard"})
|
||||||
|
if (h.get("heard", []) as Array).has("Alice: hi from the Cove"):
|
||||||
|
heard = true
|
||||||
|
break
|
||||||
|
await _sleep(0.2)
|
||||||
|
check(heard, "and Bob hears her back")
|
||||||
|
|
||||||
|
print("the player list")
|
||||||
|
await _until(func(): return Net.roster.size() >= 2, 4.0)
|
||||||
|
var where := {}
|
||||||
|
for e in Net.roster:
|
||||||
|
where[String(e.name)] = String(e.land)
|
||||||
|
check(where.get("Alice", "") == "cove" and where.get("Bob", "") == "valley", "everyone, and where they are (%s)" % [where])
|
||||||
|
C.say("/players")
|
||||||
|
await _until(func(): return C.heard.any(func(l): return String(l).begins_with("2 online")), 4.0)
|
||||||
|
var line: String = ""
|
||||||
|
for l in C.heard:
|
||||||
|
if String(l).begins_with("2 online"):
|
||||||
|
line = String(l)
|
||||||
|
check(line.contains("Bob in the Valley") and line.contains("Alice (admin) in the Cove"), "/players from the Cove: %s" % line)
|
||||||
|
C.say("/set wolf_grace_days 5")
|
||||||
|
await _until(func(): return int(Net.server_info.get("wolf_grace_days", 0)) == 5, 4.0)
|
||||||
|
check(int(Net.server_info.get("wolf_grace_days", 0)) == 5, "an admin's /set from the Cove: the hub keeps server.json, and the Cove's rules follow")
|
||||||
|
|
||||||
|
print("sleep needs everyone, in every land")
|
||||||
|
await _bob({"do": "ask", "what": "debug_time", "args": [world.day_night.day, 21.0]})
|
||||||
|
await _until(func(): return world.day_night.hours() > 20.5, 15.0)
|
||||||
|
check(world.day_night.hours() > 20.5, "night in the Valley is night in the Cove (%s)" % world.day_night.clock_text())
|
||||||
|
var day_n: int = world.day_night.day
|
||||||
|
var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(0, 0, 14), 3.0)
|
||||||
|
PN.ask("debug_hut", [site])
|
||||||
|
await _until(func(): return _beds().size() >= 1, 8.0)
|
||||||
|
check(not _beds().is_empty() and bob_bed != 0, "beds in the Cove and the Valley")
|
||||||
|
if _beds().is_empty() or bob_bed == 0:
|
||||||
|
return
|
||||||
|
await _bob({"do": "tp", "to": [bob_bed_at.x, bob_bed_at.y + 0.1, bob_bed_at.z + 0.9]})
|
||||||
|
await _bob({"do": "bed", "nid": bob_bed})
|
||||||
|
await _bob({"do": "bed", "nid": bob_bed})
|
||||||
|
await _until(func(): return C.heard.any(func(l): return String(l).begins_with("Bob is in bed")), 4.0)
|
||||||
|
check(C.heard.has("Bob is in bed (1 of 2 asleep)"), "Bob gets into bed in the Valley; Alice hears it in the Cove (%s)" % [C.heard.slice(-2)])
|
||||||
|
await _sleep(1.0)
|
||||||
|
check(world.day_night.hours() > 20.5, "and the night goes on: Alice is still up, in another land")
|
||||||
|
var mine: Node3D = _beds()[0]
|
||||||
|
await _tp(mine.global_position + Vector3(0, 0.1, 0.9))
|
||||||
|
_toasts.clear()
|
||||||
|
A.use_bed(player, mine)
|
||||||
|
await _sleep(0.5)
|
||||||
|
A.use_bed(player, mine)
|
||||||
|
await _until(func(): return world.day_night.hours() < 7.0, 5.0)
|
||||||
|
check(world.day_night.hours() >= 5.9 and world.day_night.hours() < 7.0 and world.day_night.day == day_n + 1,
|
||||||
|
"Alice gets into bed in the Cove, and the night passes for both (%s, day %d)" % [world.day_night.clock_text(), world.day_night.day])
|
||||||
|
var life := await _bob({"do": "life"})
|
||||||
|
check(float(life.get("hours", 0)) >= 5.9 and float(life.get("hours", 0)) < 7.0, "Bob wakes to morning in the Valley too (%.1f h)" % float(life.get("hours", 0)))
|
||||||
|
|
||||||
|
func _beds() -> Array:
|
||||||
|
return WN.pieces.values().filter(func(p): return is_instance_valid(p) and p.item_id == "bed")
|
||||||
|
|
||||||
|
# --- helpers -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## (Re)loads the world for whichever server we're on now.
|
||||||
|
func _load_world() -> void:
|
||||||
|
if world != null and is_instance_valid(world):
|
||||||
|
world.queue_free()
|
||||||
|
for _i in range(3):
|
||||||
|
await process_frame
|
||||||
|
var r0: int = WN.received
|
||||||
|
world = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(world)
|
||||||
|
for _i in range(8):
|
||||||
|
await physics_frame
|
||||||
|
player = world.player
|
||||||
|
player.set_physics_process(false)
|
||||||
|
await _until(func(): return WN.received > r0, 10.0)
|
||||||
|
await _sleep(0.3)
|
||||||
|
|
||||||
|
func _tp(at: Vector3) -> void:
|
||||||
|
PN.ask("debug_tp", [at])
|
||||||
|
await _until(func(): return player.global_position.distance_to(at) < 0.5, 3.0)
|
||||||
|
await _sleep(0.2)
|
||||||
|
|
||||||
|
## Swings at the tunnel's face (whichever it is now) until it gives.
|
||||||
|
func _dig_face(limit: int) -> void:
|
||||||
|
var t: Node3D = world.tunnel
|
||||||
|
var stage0: int = t.dug if t.opened else -1
|
||||||
|
for _i in range(limit):
|
||||||
|
var f: Node3D = t._face
|
||||||
|
if f == null or not is_instance_valid(f) or (t.dug if t.opened else -1) != stage0:
|
||||||
|
break
|
||||||
|
PN.ask("debug_stats", [{"stamina": 100.0}])
|
||||||
|
A.harvest_hit(player, f, f.global_position)
|
||||||
|
await _sleep(0.12)
|
||||||
|
await _until(func(): return (t.dug if t.opened else -1) != stage0, 3.0)
|
||||||
|
await _sleep(0.3)
|
||||||
|
|
||||||
|
func _slot(id: String) -> int:
|
||||||
|
for i in range(player.inventory.size()):
|
||||||
|
var s = player.inventory.get_slot(i)
|
||||||
|
if s != null and s.id == id:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
func _hold(id: String) -> void:
|
||||||
|
var i := _slot(id)
|
||||||
|
if i >= 0:
|
||||||
|
player.select(i)
|
||||||
|
PN.ask("select", [i])
|
||||||
|
|
||||||
|
func _join() -> String:
|
||||||
|
var state := {"done": false, "reason": ""}
|
||||||
|
var ok := func(): state.done = true
|
||||||
|
var bad := func(why): state.done = true; state.reason = why
|
||||||
|
Net.joined.connect(ok)
|
||||||
|
Net.join_failed.connect(bad)
|
||||||
|
Net.join("127.0.0.1:%d" % PORT)
|
||||||
|
var t := 0.0
|
||||||
|
while not state.done and t < 25.0:
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
Net.joined.disconnect(ok)
|
||||||
|
Net.join_failed.disconnect(bad)
|
||||||
|
return state.reason if state.done else "test timed out"
|
||||||
|
|
||||||
|
func _new_bob() -> Dictionary:
|
||||||
|
_bob_dir = "%s/b1" % BOBS
|
||||||
|
DirAccess.make_dir_recursive_absolute(_bob_dir)
|
||||||
|
_cmd = 0
|
||||||
|
_spawn(["--identity", DIR + "/bob_lands_id", "--name", "Bob", "--address", "127.0.0.1:%d" % PORT, "--dir", _bob_dir],
|
||||||
|
"res://tests/net_world_bot.gd")
|
||||||
|
return await _bob_result(0, 30.0)
|
||||||
|
|
||||||
|
func _bob(cmd: Dictionary) -> Dictionary:
|
||||||
|
_cmd += 1
|
||||||
|
var f := FileAccess.open("%s/cmd_%d.json" % [_bob_dir, _cmd], FileAccess.WRITE)
|
||||||
|
f.store_string(JSON.stringify(cmd))
|
||||||
|
f.close()
|
||||||
|
return await _bob_result(_cmd, 20.0)
|
||||||
|
|
||||||
|
func _bob_result(n: int, limit: float) -> Dictionary:
|
||||||
|
var path := "%s/res_%d.json" % [_bob_dir, n]
|
||||||
|
var t := 0.0
|
||||||
|
while not FileAccess.file_exists(path) and t < limit:
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
await _sleep(0.05)
|
||||||
|
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null
|
||||||
|
return d if d is Dictionary else {"error": "Bob gave no answer to command %d" % n}
|
||||||
|
|
||||||
|
func _spawn(user_args: Array, script: String = "", bin: String = "") -> int:
|
||||||
|
var args := PackedStringArray(["--headless"])
|
||||||
|
if bin.is_empty():
|
||||||
|
args.append_array(["--path", ProjectSettings.globalize_path("res://")])
|
||||||
|
if not script.is_empty():
|
||||||
|
args.append_array(["--script", script])
|
||||||
|
args.append("--")
|
||||||
|
for x in user_args:
|
||||||
|
args.append(str(x))
|
||||||
|
args.append_array(["--exit-with", str(OS.get_process_id())])
|
||||||
|
var pid := OS.create_process(OS.get_executable_path() if bin.is_empty() else bin, args)
|
||||||
|
_pids.append(pid)
|
||||||
|
return pid
|
||||||
|
|
||||||
|
func _until(cond: Callable, limit: float) -> bool:
|
||||||
|
var t := 0.0
|
||||||
|
while not cond.call() and t < limit:
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
return cond.call()
|
||||||
|
|
||||||
|
func _sleep(s: float) -> void:
|
||||||
|
await create_timer(s).timeout
|
||||||
|
|
||||||
|
static func _rm_tree(d: String) -> void:
|
||||||
|
var da := DirAccess.open(d)
|
||||||
|
if da == null:
|
||||||
|
return
|
||||||
|
for sub in da.get_directories():
|
||||||
|
_rm_tree(d.path_join(sub))
|
||||||
|
for f in da.get_files():
|
||||||
|
da.remove(f)
|
||||||
|
DirAccess.remove_absolute(d)
|
||||||
1
tests/test_net_lands.gd.uid
Normal file
1
tests/test_net_lands.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dh6t6lxjmhj8m
|
||||||
@@ -269,7 +269,8 @@ func _sleeping() -> void:
|
|||||||
await _sleep(0.5)
|
await _sleep(0.5)
|
||||||
A.use_bed(player, beds[0])
|
A.use_bed(player, beds[0])
|
||||||
await _sleep(1.0)
|
await _sleep(1.0)
|
||||||
check(_toasts.any(func(t): return t.begins_with("You're in bed (1 of 2")), "Alice gets into bed: 1 of 2 asleep (%s)" % [_toasts])
|
check(_toasts.any(func(t): return t == "You're in bed: waiting for everyone else to go to bed (1 of 2 asleep)"),
|
||||||
|
"Alice gets into bed: waiting for the others, 1 of 2 asleep (%s)" % [_toasts])
|
||||||
check(world.day_night.hours() > 20.5, "and the night goes on, because Bob's still up")
|
check(world.day_night.hours() > 20.5, "and the night goes on, because Bob's still up")
|
||||||
await _bob({"do": "bed", "nid": WN.nid(beds[1])})
|
await _bob({"do": "bed", "nid": WN.nid(beds[1])})
|
||||||
await _bob({"do": "bed", "nid": WN.nid(beds[1])})
|
await _bob({"do": "bed", "nid": WN.nid(beds[1])})
|
||||||
|
|||||||
@@ -194,9 +194,14 @@ func _pvp() -> void:
|
|||||||
await _bob({"do": "ask", "what": "hit_player", "args": [Net.local_peer]})
|
await _bob({"do": "ask", "what": "hit_player", "args": [Net.local_peer]})
|
||||||
await _sleep(0.6)
|
await _sleep(0.6)
|
||||||
check(PN.hurts == hurts0 and player.survival.health >= h0 - 0.5, "with PvP off, Bob's club does nothing to Alice")
|
check(PN.hurts == hurts0 and player.survival.health >= h0 - 0.5, "with PvP off, Bob's club does nothing to Alice")
|
||||||
|
# The map (issue #1): with PvP off, Bob is on Alice's map...
|
||||||
|
var MS: Script = load("res://scripts/ui/map_screen.gd")
|
||||||
|
await _until(func(): return not MS.others(world).is_empty(), 3.0)
|
||||||
|
check(MS.others(world).any(func(o): return o.name == "Bob"), "co-op: Bob shows on Alice's map (%s)" % [MS.others(world).map(func(o): return o.name)])
|
||||||
C.say("/set pvp true")
|
C.say("/set pvp true")
|
||||||
await _until(func(): return bool(Net.server_info.get("pvp", false)), 2.0)
|
await _until(func(): return bool(Net.server_info.get("pvp", false)), 2.0)
|
||||||
check(bool(Net.server_info.get("pvp", false)), "Alice turns PvP on, and every game hears the new rules")
|
check(bool(Net.server_info.get("pvp", false)), "Alice turns PvP on, and every game hears the new rules")
|
||||||
|
check(MS.others(world).is_empty(), "...and with PvP on, nobody's on the map but you")
|
||||||
await _bob({"do": "ask", "what": "hit_player", "args": [Net.local_peer]})
|
await _bob({"do": "ask", "what": "hit_player", "args": [Net.local_peer]})
|
||||||
await _until(func(): return PN.hurts > hurts0, 2.0)
|
await _until(func(): return PN.hurts > hurts0, 2.0)
|
||||||
await _sleep(0.3)
|
await _sleep(0.3)
|
||||||
|
|||||||
312
tests/test_shore.gd
Normal file
312
tests/test_shore.gd
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## 0.6.0 (docs/WORLDLETS_DESIGN.md §5): the Cove's own wildlife. Crabs come
|
||||||
|
## up the beach at dusk (never in the Valley, never by day), keep to the
|
||||||
|
## sand, pinch anyone who walks too close and let them go once they back
|
||||||
|
## off; a dead one gives crab meat, which cooks; at dawn they go back into
|
||||||
|
## the sea. Gulls are the Cove's crows: food left on the sand draws a gull
|
||||||
|
## (inland it's still a crow), and so does fish on a smoking rack nobody's
|
||||||
|
## watching; a gull snatches one and flies off, and drops it if you shoot it.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_shore.gd
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var S: Node
|
||||||
|
var A: Node
|
||||||
|
var DB: Node
|
||||||
|
var _toasts: Array[String] = []
|
||||||
|
var w: Node3D
|
||||||
|
var player: CharacterBody3D
|
||||||
|
var sh: Node
|
||||||
|
var th: Node
|
||||||
|
var beach := Vector3.ZERO ## a spot on the sand, just above the waterline
|
||||||
|
|
||||||
|
func check(cond: bool, what: String) -> void:
|
||||||
|
print((" ok " if cond else " FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
_fails += 1
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
S = root.get_node("Saves")
|
||||||
|
A = root.get_node("Actions")
|
||||||
|
DB = root.get_node("ItemDB")
|
||||||
|
A.notify.connect(func(t): _toasts.append(t))
|
||||||
|
S.root_dir = "user://test_shore_worlds"
|
||||||
|
for x in S.list_worlds():
|
||||||
|
S.delete_world(x.id)
|
||||||
|
await _body()
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
for x in S.list_worlds():
|
||||||
|
S.delete_world(x.id)
|
||||||
|
DirAccess.remove_absolute(S.root_dir)
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
func _load() -> Node3D:
|
||||||
|
var x: Node3D = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(x)
|
||||||
|
for _i in range(8):
|
||||||
|
await physics_frame
|
||||||
|
return x
|
||||||
|
|
||||||
|
func _set_time(hours: float) -> void:
|
||||||
|
w.day_night.day = 3
|
||||||
|
w.day_night.time = hours / 24.0
|
||||||
|
w.day_night.apply(true)
|
||||||
|
|
||||||
|
func _ground(p: Vector3) -> Vector3:
|
||||||
|
return Vector3(p.x, w.terrain.sample_height(p.x, p.z), p.z)
|
||||||
|
|
||||||
|
func _toast_has(s: String) -> bool:
|
||||||
|
return _toasts.any(func(t): return String(t).contains(s))
|
||||||
|
|
||||||
|
## Runs the crabs (shore.gd and each crab) for up to `secs`.
|
||||||
|
func _sim_crabs(secs: float, until: Callable = Callable()) -> float:
|
||||||
|
var t := 0.0
|
||||||
|
while t < secs:
|
||||||
|
sh.step(0.1)
|
||||||
|
for c in sh.get_children():
|
||||||
|
if is_instance_valid(c) and not c.is_queued_for_deletion():
|
||||||
|
c.set_physics_process(false)
|
||||||
|
c.step(0.1)
|
||||||
|
t += 0.1
|
||||||
|
if until.is_valid() and until.call():
|
||||||
|
break
|
||||||
|
if int(t * 10) % 10 == 0:
|
||||||
|
await physics_frame
|
||||||
|
return t
|
||||||
|
|
||||||
|
## Runs the thieves for up to `secs`.
|
||||||
|
func _sim_thieves(secs: float, until: Callable = Callable()) -> float:
|
||||||
|
var t := 0.0
|
||||||
|
while t < secs:
|
||||||
|
th.step(0.1)
|
||||||
|
for c in th.get_children():
|
||||||
|
if is_instance_valid(c) and not c.is_queued_for_deletion():
|
||||||
|
c.set_physics_process(false)
|
||||||
|
c.step(0.1)
|
||||||
|
t += 0.1
|
||||||
|
if until.is_valid() and until.call():
|
||||||
|
break
|
||||||
|
if int(t * 10) % 10 == 0:
|
||||||
|
await physics_frame
|
||||||
|
return t
|
||||||
|
|
||||||
|
func _thief(kind: String) -> Node3D:
|
||||||
|
for c in th.alive():
|
||||||
|
if c.kind == kind:
|
||||||
|
return c
|
||||||
|
return null
|
||||||
|
|
||||||
|
func _clear_thieves() -> void:
|
||||||
|
for c in th.get_children():
|
||||||
|
c.queue_free()
|
||||||
|
for d in w.all_dropped():
|
||||||
|
d.queue_free()
|
||||||
|
await physics_frame
|
||||||
|
|
||||||
|
## A spot on the sand just above the waterline, in the middle of the bay.
|
||||||
|
func _find_beach() -> Vector3:
|
||||||
|
var t: Node3D = w.terrain
|
||||||
|
var a := deg_to_rad(float(t.beach_angle_deg))
|
||||||
|
for off in [0.0, 0.25, -0.25, 0.5, -0.5]:
|
||||||
|
for r in range(20, 140):
|
||||||
|
var x := cos(a + off) * r
|
||||||
|
var z := sin(a + off) * r
|
||||||
|
var h: float = t.sample_height(x, z)
|
||||||
|
if t.beach_weight(x, z) > 0.85 and h > t.water_level + 0.5 and h < t.water_level + 0.9:
|
||||||
|
return Vector3(x, h, z)
|
||||||
|
return Vector3.INF
|
||||||
|
|
||||||
|
func _body() -> void:
|
||||||
|
print("the Valley")
|
||||||
|
S.current_id = S.create_world("Valley", 1337)
|
||||||
|
w = await _load()
|
||||||
|
check(w.shore == null, "no crabs in the Valley")
|
||||||
|
check(w.thieves != null and not w.thieves.gulls, "and no gulls")
|
||||||
|
w.queue_free()
|
||||||
|
for _i in range(3):
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"})
|
||||||
|
w = await _load()
|
||||||
|
player = w.player
|
||||||
|
player.set_physics_process(false)
|
||||||
|
player.survival.set_process(false)
|
||||||
|
w.day_night.frozen = true
|
||||||
|
w.weather.set_process(false)
|
||||||
|
w.weather.force("clear")
|
||||||
|
if w.wolves != null:
|
||||||
|
w.wolves.enabled = false
|
||||||
|
for c in w.creatures.alive():
|
||||||
|
c.queue_free()
|
||||||
|
sh = w.shore
|
||||||
|
th = w.thieves
|
||||||
|
check(sh != null and th.gulls, "the Cove has crabs and gulls")
|
||||||
|
if sh == null:
|
||||||
|
return
|
||||||
|
sh.set_physics_process(false)
|
||||||
|
th.enabled = false
|
||||||
|
w.creatures.set_process(false) # (keeps rabbits out of the way; creatures.enabled stays true)
|
||||||
|
beach = _find_beach()
|
||||||
|
check(beach != Vector3.INF, "a spot on the beach (%s)" % [beach])
|
||||||
|
if beach == Vector3.INF:
|
||||||
|
return
|
||||||
|
await _crabs()
|
||||||
|
await _gulls()
|
||||||
|
_net()
|
||||||
|
_finished = true
|
||||||
|
|
||||||
|
func _crabs() -> void:
|
||||||
|
print("crabs")
|
||||||
|
_set_time(12.0)
|
||||||
|
player.global_position = beach
|
||||||
|
await _sim_crabs(10.0)
|
||||||
|
check(sh.alive().is_empty(), "none by day")
|
||||||
|
_set_time(18.0)
|
||||||
|
await _sim_crabs(20.0, func(): return sh.alive().size() >= sh.BUDGET)
|
||||||
|
var crabs: Array = sh.alive()
|
||||||
|
check(crabs.size() == sh.BUDGET, "at dusk they come up the beach (%d)" % crabs.size())
|
||||||
|
var t: Node3D = w.terrain
|
||||||
|
check(crabs.all(func(c): return t.on_sand(c.global_position.x, c.global_position.z)),
|
||||||
|
"on the sand at the waterline (%s)" % [crabs.map(func(c): return "%.2f" % (c.global_position.y - t.water_level))])
|
||||||
|
check(crabs.all(func(c): return c.global_position.distance_to(beach) >= sh.NOT_WITHIN), "and not right beside you")
|
||||||
|
print("keeping to the sand")
|
||||||
|
player.global_position = beach
|
||||||
|
await _sim_crabs(40.0)
|
||||||
|
crabs = sh.alive()
|
||||||
|
check(crabs.size() >= 3 and crabs.all(func(c): return t.water_level + 0.1 < c.global_position.y and c.global_position.y < t.water_level + t.SAND_TOP + 0.2),
|
||||||
|
"40 s on, they're all still on the sand (%d)" % crabs.size())
|
||||||
|
print("a pinch")
|
||||||
|
for c in crabs:
|
||||||
|
c.queue_free()
|
||||||
|
await physics_frame
|
||||||
|
var crab: Node3D = sh.spawn(beach + Vector3(1.6, 0, 0))
|
||||||
|
crab.set_physics_process(false)
|
||||||
|
player.global_position = _ground(beach)
|
||||||
|
player.survival.health = player.survival.MAX
|
||||||
|
var h0: float = player.survival.health
|
||||||
|
sh.enabled = false # (just this one)
|
||||||
|
await _sim_crabs(3.0)
|
||||||
|
check(crab.state == "charge", "walk within a couple of metres and it comes at you")
|
||||||
|
check(player.survival.health < h0 and player.survival.health > h0 - 20.0, "and pinches (%.0f -> %.0f)" % [h0, player.survival.health])
|
||||||
|
check(crab.interact_prompt(player).contains("claws up"), "(\"%s\")" % crab.interact_prompt(player))
|
||||||
|
player.global_position = _ground(crab.global_position + Vector3(9, 0, 0))
|
||||||
|
await _sim_crabs(8.0)
|
||||||
|
check(crab.state in ["wander", "idle"], "back off and it lets you go (%s)" % crab.state)
|
||||||
|
var h1: float = player.survival.health
|
||||||
|
await _sim_crabs(5.0)
|
||||||
|
check(is_equal_approx(player.survival.health, h1), "no more pinches from over there")
|
||||||
|
print("crab supper")
|
||||||
|
crab.take_hit(20.0, player.global_position)
|
||||||
|
check(crab.is_dead() and crab.interact_prompt(player).contains("Crack it open"), "a blow kills it: [E] Crack it open")
|
||||||
|
player.global_position = _ground(crab.global_position + Vector3(0.8, 0, 0))
|
||||||
|
var before: int = player.inventory.count("crab_meat")
|
||||||
|
A.butcher(player, crab)
|
||||||
|
var got: int = player.inventory.count("crab_meat") - before
|
||||||
|
check(got >= 1 and got <= 2, "crab meat (%d)" % got)
|
||||||
|
check(DB.get_item("crab_meat").has("food") and DB.get_item("cooked_crab").has("food"), "it's food, raw and cooked")
|
||||||
|
var r: Dictionary = DB.recipes.get("cooked_crab", {})
|
||||||
|
check(not r.is_empty() and String(r.get("station", "")) == "campfire" and int(Dictionary(r.inputs).get("crab_meat", 0)) == 2,
|
||||||
|
"two of it cook into a Cooked Crab at a campfire")
|
||||||
|
print("dawn")
|
||||||
|
sh.enabled = true
|
||||||
|
_set_time(19.0)
|
||||||
|
await _sim_crabs(20.0, func(): return sh.alive().size() >= 3)
|
||||||
|
check(sh.alive().size() >= 3, "(a few more come up)")
|
||||||
|
_set_time(7.0)
|
||||||
|
await _sim_crabs(2.0)
|
||||||
|
check(sh.alive().all(func(c): return c.state == "home"), "at dawn they head back to the sea")
|
||||||
|
await _sim_crabs(45.0, func(): return sh.get_children().filter(func(c): return not c.is_queued_for_deletion()).is_empty())
|
||||||
|
check(sh.get_children().filter(func(c): return not c.is_queued_for_deletion()).is_empty(), "and are gone")
|
||||||
|
await _sim_crabs(10.0)
|
||||||
|
check(sh.alive().is_empty(), "none come up by day")
|
||||||
|
sh.enabled = false
|
||||||
|
|
||||||
|
func _gulls() -> void:
|
||||||
|
print("gulls on the sand")
|
||||||
|
_set_time(12.0)
|
||||||
|
_toasts.clear()
|
||||||
|
var fish: Node3D = w.drop_item("mackerel", 3, Transform3D(Basis(), beach + Vector3(0, 0.15, 0)))
|
||||||
|
player.global_position = _ground(beach + Vector3(-25, 0, 0))
|
||||||
|
check(not th.on_sand(w.build.debug_find_site(w._spawn, 6.0)), "(the grass above the beach isn't sand)")
|
||||||
|
await _sim_thieves(30.0, func(): return _thief("gull") != null or _thief("crow") != null)
|
||||||
|
var gull := _thief("gull")
|
||||||
|
check(gull != null and _thief("crow") == null, "food left on the sand draws a gull, not a crow")
|
||||||
|
if gull == null:
|
||||||
|
return
|
||||||
|
await _sim_thieves(30.0, func(): return not gull.carry.is_empty())
|
||||||
|
check(String(gull.carry.get("id", "")) == "mackerel" and int(fish.amount) == 2, "it snatches one mackerel (%d left)" % int(fish.amount))
|
||||||
|
check(_toast_has("gull has snatched"), "and you hear about it (%s)" % [_toasts])
|
||||||
|
check(gull.state == "fly_off", "then it's off with it")
|
||||||
|
print("shooting one")
|
||||||
|
await _sim_thieves(1.0)
|
||||||
|
gull.take_hit(10.0, player.global_position)
|
||||||
|
check(gull.is_dead(), "an arrow brings it down")
|
||||||
|
var back: Array = w.all_dropped().filter(func(d): return d.item_id == "mackerel" and d != fish)
|
||||||
|
check(back.size() == 1 and int(back[0].amount) == 1, "and it drops the mackerel")
|
||||||
|
check(int(gull.butcher_drops().get("feather", 0)) >= 1, "(feathers)")
|
||||||
|
await _clear_thieves()
|
||||||
|
|
||||||
|
print("inland")
|
||||||
|
var site: Vector3 = w.build.debug_find_site(w._spawn, 6.0)
|
||||||
|
if th.on_sand(site):
|
||||||
|
site = _ground(Vector3(-40, 0, -10))
|
||||||
|
var berries: Node3D = w.drop_item("berries", 3, Transform3D(Basis(), _ground(site) + Vector3(0, 0.15, 0)))
|
||||||
|
player.global_position = _ground(site + Vector3(25, 0, 0))
|
||||||
|
await _sim_thieves(30.0, func(): return not th.alive().is_empty())
|
||||||
|
check(_thief("crow") != null and _thief("gull") == null, "food left inland still draws a crow")
|
||||||
|
berries.queue_free()
|
||||||
|
await _clear_thieves()
|
||||||
|
|
||||||
|
print("a smoking rack")
|
||||||
|
var rp := _ground(site + Vector3(3, 0, 3))
|
||||||
|
var rack: Node3D = w.spawn_piece(DB.get_item("smoking_rack"), Transform3D(Basis(), rp))
|
||||||
|
rack.set_process(false)
|
||||||
|
rack.inventory.add("mackerel", 1) # (one: not enough to smoke, so it stays raw)
|
||||||
|
rack.inventory.add("smoked_fish", 2)
|
||||||
|
player.global_position = _ground(rp + Vector3(6, 0, 0))
|
||||||
|
await _sim_thieves(40.0)
|
||||||
|
check(th.alive().is_empty(), "with you beside it, nothing comes")
|
||||||
|
player.global_position = _ground(rp + Vector3(25, 0, 0))
|
||||||
|
_toasts.clear()
|
||||||
|
await _sim_thieves(30.0, func(): return _thief("gull") != null)
|
||||||
|
gull = _thief("gull")
|
||||||
|
check(gull != null, "left alone, a gull comes for the fish on it")
|
||||||
|
if gull != null:
|
||||||
|
await _sim_thieves(30.0, func(): return not gull.carry.is_empty())
|
||||||
|
check(rack.inventory.count("mackerel") + rack.inventory.count("smoked_fish") == 2, "and takes one (%s)" % [gull.carry])
|
||||||
|
check(_toast_has("smoking rack"), "(\"A gull's at the fish on your smoking rack!\")")
|
||||||
|
await _clear_thieves()
|
||||||
|
rack.inventory.remove("mackerel", 5)
|
||||||
|
rack.inventory.remove("smoked_fish", 5)
|
||||||
|
rack.inventory.add("wood", 2)
|
||||||
|
await _sim_thieves(40.0)
|
||||||
|
check(th.alive().is_empty(), "no fish on it, no gull")
|
||||||
|
print("at night")
|
||||||
|
_set_time(22.0)
|
||||||
|
var meat: Node3D = w.drop_item("raw_meat", 2, Transform3D(Basis(), beach + Vector3(0, 0.15, 0)))
|
||||||
|
player.global_position = _ground(beach + Vector3(25, 0, 0))
|
||||||
|
await _sim_thieves(30.0, func(): return not th.alive().is_empty())
|
||||||
|
check(_thief("fox") != null and _thief("gull") == null, "at night it's a fox, sand or not")
|
||||||
|
meat.queue_free()
|
||||||
|
await _clear_thieves()
|
||||||
|
|
||||||
|
func _net() -> void:
|
||||||
|
print("online")
|
||||||
|
var CN: Node = root.get_node("CreatureNet")
|
||||||
|
for k in [["crab", "home"], ["crab", "charge"], ["gull", "peck"]]:
|
||||||
|
var c: Node3D = (sh.CrabScript if k[0] == "crab" else th.GullScript).new()
|
||||||
|
c.setup(k[0], w, beach, 5)
|
||||||
|
c.set_physics_process(false)
|
||||||
|
w.add_child(c)
|
||||||
|
c.set_meta("cid", 42)
|
||||||
|
c.state = k[1]
|
||||||
|
var buf := PackedByteArray()
|
||||||
|
buf.resize(CN.ENTRY)
|
||||||
|
CN._encode(buf, 0, c)
|
||||||
|
var e: Array = CN._decode(buf, 0)
|
||||||
|
check(e[1] == k[0] and e[4] == k[1], "a %s goes over the network (%s, %s)" % [k[0], e[1], e[4]])
|
||||||
|
c.free()
|
||||||
1
tests/test_shore.gd.uid
Normal file
1
tests/test_shore.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cydvl5o72vanm
|
||||||
@@ -235,9 +235,25 @@ func _body() -> void:
|
|||||||
check(player.inventory.count("stone") == stone0 and player.inventory.count("antler_pickaxe") == 1, "with your bag as it was")
|
check(player.inventory.count("stone") == stone0 and player.inventory.count("antler_pickaxe") == 1, "with your bag as it was")
|
||||||
check(w.day_night.day == day0, "on the same day")
|
check(w.day_night.day == day0, "on the same day")
|
||||||
check(w.tunnel == null, "(the Cove has no tunnel of its own: it's the Valley's)")
|
check(w.tunnel == null, "(the Cove has no tunnel of its own: it's the Valley's)")
|
||||||
|
# Save and quit in the Cove, then load the world again from the menu.
|
||||||
|
print("save and quit in the Cove")
|
||||||
|
var spot: Vector3 = w.sea_cave.front + Vector3(5.0, 0.0, 3.0)
|
||||||
|
spot.y = w.terrain.sample_height(spot.x, spot.z) + 0.1
|
||||||
|
player.global_position = spot
|
||||||
|
w.save_now()
|
||||||
|
await _drop_world(w)
|
||||||
|
check(S.list_worlds().any(func(m): return String(m.get("worldlet", "")) == "cove"), "the Load list has the world in the Cove")
|
||||||
|
w = await _load_world()
|
||||||
|
player = w.player
|
||||||
|
check(w.worldlet_id() == "cove", "loading it again: still in the Cove")
|
||||||
|
check(player.global_position.distance_to(spot) < 1.0, "right where you saved (%.1f m off)" % player.global_position.distance_to(spot))
|
||||||
|
|
||||||
print("back up home")
|
print("back up home")
|
||||||
check(w.sea_cave.open(), "the sea cave leads back up (the tunnel's dug)")
|
check(w.sea_cave.open(), "the sea cave leads back up (the tunnel's dug)")
|
||||||
|
# A day and a half in the Cove before going home.
|
||||||
|
w.day_night.advance(w.day_night.day_length * 1.5)
|
||||||
|
var cove_day: int = w.day_night.day
|
||||||
|
var cove_time: float = w.day_night.time
|
||||||
_went = ""
|
_went = ""
|
||||||
w.sea_cave.interact(player)
|
w.sea_cave.interact(player)
|
||||||
check(_went == "valley", "into the cave: back to the Valley")
|
check(_went == "valley", "into the cave: back to the Valley")
|
||||||
@@ -247,6 +263,8 @@ func _body() -> void:
|
|||||||
player = w.player
|
player = w.player
|
||||||
check(w.worldlet_id() == "valley" and w.underground_at(player.global_position) and player.global_position.distance_to(t.bottom) < 2.0,
|
check(w.worldlet_id() == "valley" and w.underground_at(player.global_position) and player.global_position.distance_to(t.bottom) < 2.0,
|
||||||
"arriving at the bottom of the tunnel")
|
"arriving at the bottom of the tunnel")
|
||||||
|
check(w.day_night.day == cove_day and absf(w.day_night.time - cove_time) < 0.01,
|
||||||
|
"one clock for every land: home is on the Cove's day and hour (day %d), not the one you left it" % w.day_night.day)
|
||||||
t.go_out(player)
|
t.go_out(player)
|
||||||
await physics_frame
|
await physics_frame
|
||||||
check(not w.underground_at(player.global_position) and player.global_position.distance_to(t.seam_out) < 1.0, "climb out at the top, on the clifftop")
|
check(not w.underground_at(player.global_position) and player.global_position.distance_to(t.seam_out) < 1.0, "climb out at the top, on the clifftop")
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ func _body(S: Node, Net: Node) -> void:
|
|||||||
await process_frame
|
await process_frame
|
||||||
check(hp.disabled and TS.picked_worldlet(hp) == "valley", "an island already made: shows its worldlet, locked")
|
check(hp.disabled and TS.picked_worldlet(hp) == "valley", "an island already made: shows its worldlet, locked")
|
||||||
check(title._host_seed_label.text == "Seed (kept)", "(like its seed)")
|
check(title._host_seed_label.text == "Seed (kept)", "(like its seed)")
|
||||||
|
check(title._host_msg.text.contains("forward UDP 24242 and 24243 (the Cove)"),
|
||||||
|
"the Host page says which ports to forward, the Cove's too (%s)" % title._host_msg.text.get_slice("\n", 1))
|
||||||
|
var L: Node = root.get_node("Lands")
|
||||||
|
check(L.extra_ports("valley", 24242) == [["cove", 24243]], "(the Valley's lands: the Cove on 24243)")
|
||||||
# A worldlet that isn't a start (an island made elsewhere) is still shown.
|
# A worldlet that isn't a start (an island made elsewhere) is still shown.
|
||||||
var extra := OptionButton.new()
|
var extra := OptionButton.new()
|
||||||
extra.add_item("Valley")
|
extra.add_item("Valley")
|
||||||
|
|||||||
183
tests/test_wreck.gd
Normal file
183
tests/test_wreck.gd
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## 0.6.0 (docs/WORLDLETS_DESIGN.md §5): the Cove's wreck and the treasure map.
|
||||||
|
## The wreck is on the Cove's beach (and not in the Valley); driftwood and
|
||||||
|
## salt wash up on the sand; the captain's sea chest gives each player a
|
||||||
|
## treasure map, nails and salt, once; the map's X is that player's own (and
|
||||||
|
## somewhere else for the next player); reading the map tells you, and the X
|
||||||
|
## is on your N map while you carry it; who's had their share is saved.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_wreck.gd
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var S: Node
|
||||||
|
var A: Node
|
||||||
|
var _toasts: Array[String] = []
|
||||||
|
|
||||||
|
func check(cond: bool, what: String) -> void:
|
||||||
|
print((" ok " if cond else " FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
_fails += 1
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
S = root.get_node("Saves")
|
||||||
|
A = root.get_node("Actions")
|
||||||
|
A.notify.connect(func(t): _toasts.append(t))
|
||||||
|
S.root_dir = "user://test_wreck_worlds"
|
||||||
|
for w in S.list_worlds():
|
||||||
|
S.delete_world(w.id)
|
||||||
|
await _body()
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
for w in S.list_worlds():
|
||||||
|
S.delete_world(w.id)
|
||||||
|
DirAccess.remove_absolute(S.root_dir)
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
func _load() -> Node3D:
|
||||||
|
var w: Node3D = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(w)
|
||||||
|
for _i in range(8):
|
||||||
|
await physics_frame
|
||||||
|
w.creatures.enabled = false
|
||||||
|
w.player.set_physics_process(false)
|
||||||
|
w.player.survival.set_process(false)
|
||||||
|
return w
|
||||||
|
|
||||||
|
func _drop(w: Node3D) -> void:
|
||||||
|
w.queue_free()
|
||||||
|
for _i in range(3):
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
func _body() -> void:
|
||||||
|
print("the Valley")
|
||||||
|
S.current_id = S.create_world("Valley", 1337)
|
||||||
|
var w: Node3D = await _load()
|
||||||
|
check(w.wreck == null, "no wreck in the Valley")
|
||||||
|
check(not w.props.pickups.values().any(func(p): return p.item_id == "driftwood"), "and no driftwood")
|
||||||
|
await _drop(w)
|
||||||
|
|
||||||
|
print("the wreck")
|
||||||
|
S.current_id = S.create_world("Cove", 1337, {"worldlet": "cove"})
|
||||||
|
w = await _load()
|
||||||
|
var t: Node3D = w.terrain
|
||||||
|
var wk: Node3D = w.wreck
|
||||||
|
check(wk != null and t.beach_weight(wk.global_position.x, wk.global_position.z) > 0.5, "a wreck on the Cove's beach")
|
||||||
|
if wk == null:
|
||||||
|
return
|
||||||
|
check(wk.global_position.distance_to(w.sea_cave.global_position) > 60.0, "at the far end of the bay from the sea cave (%.0f m)" % wk.global_position.distance_to(w.sea_cave.global_position))
|
||||||
|
check(w.water_at(wk.front.x, wk.front.z).is_empty() or float(w.water_at(wk.front.x, wk.front.z).depth) < 0.6, "you can stand beside it to reach the chest")
|
||||||
|
var drift: Array = w.props.pickups.values().filter(func(p): return p.item_id == "driftwood")
|
||||||
|
check(drift.size() >= 20 and drift.all(func(p): return t.beach_weight(p.global_position.x, p.global_position.z) > 0.5),
|
||||||
|
"driftwood along the sand (%d)" % drift.size())
|
||||||
|
check(w.props.pickups.values().any(func(p): return p.item_id == "salt"), "and salt crusts by the waterline")
|
||||||
|
|
||||||
|
print("the captain's sea chest")
|
||||||
|
var p: Node3D = w.player
|
||||||
|
var me: String = w.my_id()
|
||||||
|
p.global_position = wk.front
|
||||||
|
check(wk.chest.interact_prompt(p).begins_with("[E] Open"), "[E] Open the captain's sea chest")
|
||||||
|
wk.chest.interact(p)
|
||||||
|
check(p.inventory.count("treasure_map") == 1 and p.inventory.count("nails") >= 4 and p.inventory.count("salt") >= 2,
|
||||||
|
"a torn treasure map, nails and salt")
|
||||||
|
check(_toasts.any(func(s): return String(s).begins_with("In the captain's sea chest")), "(and it says so)")
|
||||||
|
_toasts.clear()
|
||||||
|
wk.chest.interact(p)
|
||||||
|
check(p.inventory.count("treasure_map") == 1 and _toasts.has("The sea chest's empty: you've had what was in it"), "once: the second time it's empty")
|
||||||
|
check(wk.open_for(p, "someone-else"), "someone else gets their own share")
|
||||||
|
p.inventory.remove("treasure_map", 1) # (it went in this bag: theirs, not ours)
|
||||||
|
|
||||||
|
print("the map")
|
||||||
|
var x: Vector3 = w.treasure_spot(me)
|
||||||
|
check(x != Vector3.INF and t.inside_boundary(x.x, x.z, 10.0) and x.y > t.water_level + 1.5, "your X: dry ground in the hills (%s)" % x)
|
||||||
|
var others := 0
|
||||||
|
for i in range(8):
|
||||||
|
if w.treasure_spot("player-%d" % i).distance_to(x) > 20.0:
|
||||||
|
others += 1
|
||||||
|
check(others >= 5, "and other players' X are mostly somewhere else (%d of 8)" % others)
|
||||||
|
check(w.treasure_spot(me) == x, "(the same every time for you)")
|
||||||
|
check(w.map_marks().any(func(m): return m.kind == "treasure" and m.pos == x), "on your map while you carry it, even where you haven't been")
|
||||||
|
_toasts.clear()
|
||||||
|
w.read_map(p)
|
||||||
|
check(_toasts.any(func(s): return String(s).begins_with("X marks the spot")), "reading it: X marks the spot")
|
||||||
|
p.inventory.remove("treasure_map", 1)
|
||||||
|
check(not w.map_marks().any(func(m): return m.kind == "treasure"), "(no map, no X)")
|
||||||
|
p.inventory.add("treasure_map", 1)
|
||||||
|
w.explore(wk.global_position)
|
||||||
|
check(w.map_marks().any(func(m): return m.kind == "wreck"), "the wreck's on the map once you've seen it")
|
||||||
|
|
||||||
|
print("the dig")
|
||||||
|
var tr: Node3D = w.treasure
|
||||||
|
check(tr != null, "the Cove has the pirate treasure")
|
||||||
|
for _i in range(3):
|
||||||
|
await process_frame
|
||||||
|
await create_timer(0.6).timeout
|
||||||
|
var m: Node3D = tr.mounds.get(me)
|
||||||
|
check(m != null and m.global_position.distance_to(x) < 0.1, "with your map, a mound of loose earth at your X")
|
||||||
|
check(tr.mounds.size() == 1, "(only yours: nobody else's shows)")
|
||||||
|
if m == null:
|
||||||
|
return
|
||||||
|
p.inventory.add("stone_pickaxe", 1)
|
||||||
|
_hold(p, "stone_pickaxe")
|
||||||
|
tr.force_crew = true
|
||||||
|
w.day_night.time = 21.0 / 24.0 # night
|
||||||
|
w.day_night.apply(true)
|
||||||
|
p.global_position = m.global_position + Vector3(1.4, 0, 0)
|
||||||
|
p.survival.stamina = 100.0
|
||||||
|
A.harvest_hit(p, m, m.global_position)
|
||||||
|
check(tr.crews.has(me) and tr.crews[me].alive().size() == 3, "at night, the first blow brings up the drowned crew (%d)" % (tr.crews[me].alive().size() if tr.crews.has(me) else 0))
|
||||||
|
if tr.crews.has(me):
|
||||||
|
var g: Node3D = tr.crews[me].alive()[0]
|
||||||
|
check(g.kind == "drowned" and g.state == "charge", "drowned sailors, coming for you")
|
||||||
|
g.set_physics_process(false)
|
||||||
|
for c in tr.crews[me].alive():
|
||||||
|
c.queue_free()
|
||||||
|
tr.crews[me].queue_free()
|
||||||
|
await process_frame
|
||||||
|
var blows := 1
|
||||||
|
while is_instance_valid(m) and not m.depleted and blows < 40:
|
||||||
|
p.survival.stamina = 100.0
|
||||||
|
A.harvest_hit(p, m, m.global_position)
|
||||||
|
blows += 1
|
||||||
|
check(blows == 18, "18 blows of a stone pickaxe (%d)" % blows)
|
||||||
|
check(p.inventory.count("ship_compass") == 1 and p.inventory.count("spyglass") == 1 and p.inventory.count("cutlass") == 1 \
|
||||||
|
and p.inventory.count("pirate_gold") == 3, "the pirate chest: a ship's compass, a spyglass, a cutlass and 3 pirate gold")
|
||||||
|
check(p.inventory.count("treasure_map") == 0, "and the map's used up")
|
||||||
|
check(me in tr.dug and not tr.mounds.has(me), "your treasure's dug: no more mound")
|
||||||
|
check(not w.map_marks().any(func(mk): return mk.kind == "treasure"), "(and no X)")
|
||||||
|
_hold(p, "cutlass")
|
||||||
|
var ms: Dictionary = A.melee_stats(p)
|
||||||
|
check(bool(ms.armed) and float(ms.damage) >= 30.0, "the cutlass hits hard (%s)" % ms.damage)
|
||||||
|
var other: Node3D = tr.mound_for("someone-else")
|
||||||
|
check(other != null and other.global_position.distance_to(x) > 1.0, "someone else's treasure is still in the ground, somewhere else")
|
||||||
|
check(DB_has("pirate_gold") and A.can_place(p, _slot(p, "pirate_gold")), "pirate gold can be set down as a trophy")
|
||||||
|
|
||||||
|
print("saved")
|
||||||
|
w.save_now()
|
||||||
|
await _drop(w)
|
||||||
|
w = await _load()
|
||||||
|
check(w.wreck.had(me) and w.wreck.had("someone-else") and not w.wreck.had("a-stranger"), "who's had their share is kept")
|
||||||
|
check(me in w.treasure.dug, "and whose treasure's been dug")
|
||||||
|
await _drop(w)
|
||||||
|
_finished = true
|
||||||
|
|
||||||
|
func DB_has(id: String) -> bool:
|
||||||
|
return not root.get_node("ItemDB").get_item(id).is_empty()
|
||||||
|
|
||||||
|
func _slot(p: Node, id: String) -> int:
|
||||||
|
for i in range(p.inventory.size()):
|
||||||
|
var s = p.inventory.get_slot(i)
|
||||||
|
if s != null and s.id == id:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
func _hold(p: Node, id: String) -> void:
|
||||||
|
var i := _slot(p, id)
|
||||||
|
if i < 0:
|
||||||
|
return
|
||||||
|
var tmp = p.inventory.get_slot(0)
|
||||||
|
p.inventory.set_slot(0, p.inventory.get_slot(i))
|
||||||
|
p.inventory.set_slot(i, tmp)
|
||||||
|
p.select(0)
|
||||||
1
tests/test_wreck.gd.uid
Normal file
1
tests/test_wreck.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://chnwnd4xdcolj
|
||||||
@@ -616,6 +616,29 @@ def make_voices():
|
|||||||
_encode(os.path.join(d, "fox_yip_%d.ogg" % i),
|
_encode(os.path.join(d, "fox_yip_%d.ogg" % i),
|
||||||
[voice(200 + i, 0.18, lambda q, k=i: 800 + 80 * k + 500 * sin(math.pi * q), _swell(0.05, 0.5),
|
[voice(200 + i, 0.18, lambda q, k=i: 800 + 80 * k + 500 * sin(math.pi * q), _swell(0.05, 0.5),
|
||||||
[(1100, 3, 1.0), (2600, 4, 0.4)], breath=0.2, rasp=0.2, jitter=0.02)], SR_FX, 5)
|
[(1100, 3, 1.0), (2600, 4, 0.4)], breath=0.2, rasp=0.2, jitter=0.02)], SR_FX, 5)
|
||||||
|
# 0.6.0, the Cove: a gull's "kee-ow" (and a laughing "ha-ha-ha"), and a
|
||||||
|
# crab's claws clicking.
|
||||||
|
_encode(os.path.join(d, "gull_cry_0.ogg"),
|
||||||
|
[voice(210, 0.55, lambda q: 1350 - 500 * q ** 1.4 + 120 * sin(math.pi * min(1, q * 3)), _swell(0.06, 0.55),
|
||||||
|
[(1700, 4, 1.0), (3300, 5, 0.5)], breath=0.25, rasp=0.25, rasp_hz=70, jitter=0.03)], SR_FX, 5)
|
||||||
|
hs = [0.0, 0.14, 0.28, 0.42]
|
||||||
|
def genv(q, ps=hs):
|
||||||
|
t = q * 0.6
|
||||||
|
return max(max(0.0, min(1, (t - p) / 0.015)) * max(0.0, 1 - (t - p) / 0.11) if t >= p else 0.0 for p in ps)
|
||||||
|
_encode(os.path.join(d, "gull_cry_1.ogg"),
|
||||||
|
[voice(211, 0.6, lambda q: 1150 - 150 * q, genv, [(1600, 4, 1.0), (3100, 5, 0.5)],
|
||||||
|
breath=0.3, rasp=0.3, rasp_hz=70, jitter=0.03)], SR_FX, 5)
|
||||||
|
for i in range(2):
|
||||||
|
rng = random.Random(220 + i)
|
||||||
|
cb = fx_buf(0.3)
|
||||||
|
for t in [0.0, 0.07, 0.11, 0.2][: 2 + i * 2]:
|
||||||
|
i0 = int(t * SR_FX)
|
||||||
|
f = rng.uniform(2600, 3600)
|
||||||
|
for j in range(int(0.012 * SR_FX)):
|
||||||
|
if i0 + j < len(cb):
|
||||||
|
cb[i0 + j] += sin(TAU * f * j / SR_FX) * exp(-j / 60) * 0.9
|
||||||
|
add_noise(cb, SR_FX, t, 0.004, 0.7, lp=0.95, hp=0.4, env=lambda q: 1 - q, rng=rng)
|
||||||
|
_encode(os.path.join(d, "crab_click_%d.ogg" % i), [cb], SR_FX, 5)
|
||||||
|
|
||||||
|
|
||||||
def make_sfx():
|
def make_sfx():
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ The tutorial island is calm: no wolf pack (just one demonstration wolf), no bear
|
|||||||
- `1` to `8` or the mouse wheel pick a hotbar slot.
|
- `1` to `8` or the mouse wheel pick a hotbar slot.
|
||||||
- `K` and `M` move the hotbar up and down your bag's rows, so `1` to `8` pick from the row above or below. The hotbar shows which row you're on.
|
- `K` and `M` move the hotbar up and down your bag's rows, so `1` to `8` pick from the row above or below. The hotbar shows which row you're on.
|
||||||
- `Q` drops what you're holding at your feet; `Shift` and `Q` drops just one.
|
- `Q` drops what you're holding at your feet; `Shift` and `Q` drops just one.
|
||||||
- `N` opens the map. It starts blank and fills in as you explore, marking the places you've found, your camp and your bed. You're the white dot.
|
- `N` opens the map. It starts blank and fills in as you explore, marking the places you've found, your camp and your bed. You're the white dot. On a co-op server, the other players in your land show as green dots with their names; with PvP on (friendly rivals or adversarial), the map shows nobody but you.
|
||||||
- The compass across the top of the screen shows which way you're facing.
|
- The compass across the top of the screen shows which way you're facing.
|
||||||
- `Tab` or `I` opens your inventory and crafting.
|
- `Tab` or `I` opens your inventory and crafting.
|
||||||
- In the inventory: `Left click` picks up or puts down a stack, `Right click` splits a stack or puts down one, `Shift` + click moves a stack to or from a chest, `Shift` + click on **Craft** makes up to five.
|
- In the inventory: `Left click` picks up or puts down a stack, `Right click` splits a stack or puts down one, `Shift` + click moves a stack to or from a chest, `Shift` + click on **Craft** makes up to five.
|
||||||
@@ -332,7 +332,7 @@ It doesn't run and ignores fire. If dawn comes, or everyone leaves or dies, it f
|
|||||||
|
|
||||||
## The journey {#journey}
|
## The journey {#journey}
|
||||||
|
|
||||||
The Valley is only the start. Beyond it lie other lands, each reached a different way, and each with its own ground, weather and creatures. The first is the **Cove**, down at the foot of the sea cliffs. For now the journey is single-player; on a server everyone stays in the Valley.
|
The Valley is only the start. Beyond it lie other lands, each reached a different way, and each with its own ground, weather and creatures. The first is the **Cove**, down at the foot of the sea cliffs. On a server, players can be in different lands at once: the tunnel and its lights are shared, and walking out to the Cove takes you on to the Cove's own server (started for you, on the server's next UDP port: 24243 if the server's on 24242). Friends playing over the internet need that port forwarded too. Leave from the Cove and you'll be back there next time you join.
|
||||||
|
|
||||||
### The cliff tunnel
|
### The cliff tunnel
|
||||||
|
|
||||||
@@ -345,12 +345,19 @@ The Valley is only the start. Beyond it lie other lands, each reached a differen
|
|||||||
|
|
||||||
### The Cove
|
### The Cove
|
||||||
|
|
||||||
Low hills run down to a sheltered sandy bay, with mountains behind and a small pond inland. Wade out from the beach and it gets deeper slowly; swim too far and a strong current carries you back to the shore.
|
Low hills run down to a sheltered sandy bay, with mountains behind, a small pond inland, and a stream that runs from a spring under the mountains down to the sea. You can wade the stream anywhere, and trout bite in it. Wade out from the beach and it gets deeper slowly; swim too far and a strong current carries you back to the shore.
|
||||||
|
|
||||||
- **The pier** runs out over the bay. Fish off its end for **mackerel**, the usual catch. **Cod** are bigger but only bite where the water's at least 2.2 m deep, so cast well out. Both bite best at dawn and dusk, and grill at a campfire, salt or smoke like lake fish.
|
- **The pier** runs out over the bay. Fish off its end for **mackerel**, the usual catch. **Cod** are bigger but only bite where the water's at least 2.2 m deep, so cast well out. Both bite best at dawn and dusk, and grill at a campfire, salt or smoke like lake fish.
|
||||||
- **The sea cave** at the end of the beach is the way back: `E` goes into it and up the same tunnel, arriving at the bottom.
|
- **The sea cave** at the end of the beach is the way back: `E` goes into it and up the same tunnel, arriving at the bottom.
|
||||||
|
- **The beach** has its own pickings: **driftwood** (it burns, and it's what a boat is built from), **salt** crusted above the waterline, and **seagrass** at the tide line.
|
||||||
|
- **The wreck** lies at the far end of the bay. Inside is the captain's **sea chest**: a torn **treasure map**, 4 nails and 2 salt, once for each player.
|
||||||
|
- **The treasure:** right-click the map to read it, and an **X** goes on your map (`N`). Every player's map has its own X. There, only you can see a low mound by a marker stone; dig it with any pickaxe (18 blows with a stone one). Inside the pirate chest are the **ship's compass**, a **spyglass** (hold right-click to look through it), a **cutlass** (quick, and harder-hitting than the iron spear) and **pirate gold** (nothing takes coin yet, but you can set a heap of it in your hut). The map's used up.
|
||||||
|
- **The drowned crew:** three of the ship's crew guard it. Dig at night and they rise at the first blow; by day, when the chest comes up. They're slow but hit hard; bone, so blunt weapons do more.
|
||||||
|
- **Gulls** are the Cove's crows: by day they come for food left on the sand, and for fish on a smoking rack that nobody's watching. A gull snatches one and flies off with it; shoot it down and it drops it.
|
||||||
|
- **Crabs** come up the beach from dusk till dawn and keep to the sand. Walk within a couple of metres and one comes at you and pinches; back off and it lets you go. A dead one gives **crab meat**: two cook into a **cooked crab** at a campfire. At dawn they go back into the sea.
|
||||||
|
- **The rowing boat:** twist 3 seagrass into a **rope** by hand; boil 2 **pine resin** (from felled pines, in any land) and a salt into **pitch** at a campfire. A **rowing boat** is 12 driftwood, 4 rope, 3 pitch and 6 nails at a workbench. Take it to the **mooring post** at the end of the pier and `E` ties it up at a berth of its own, one boat each. Its **Set sail** needs the ship's compass, and even then the way to the Highlands isn't charted yet: that's the next part of the journey.
|
||||||
|
|
||||||
Each land keeps its own state. What you build in the Valley waits for you while you're away, and time stands still in a land you're not in.
|
Each land keeps its own state, and there's one clock for them all. What you build in the Valley waits for you while you're away, and when you come back the time you were gone catches up, as after a night's sleep: kilns and smelters will have done their work, fires burned down and food gone off. Nothing roams a land while you're away, so your base won't have been raided.
|
||||||
|
|
||||||
## Multiplayer {#multiplayer}
|
## Multiplayer {#multiplayer}
|
||||||
|
|
||||||
@@ -360,7 +367,7 @@ Set a name (3 to 16 letters, digits, spaces, `_` or `-`), tunic colour, hair col
|
|||||||
|
|
||||||
### Hosting
|
### Hosting
|
||||||
|
|
||||||
**Host Game** starts a server on your computer and puts you in. Pick a server name, a rules preset, an optional password, the port (24242 by default) and, the first time, a seed. As its first player you're its admin. A message tells you the addresses friends can join at. Leaving saves the island and stops the server; next time it carries on. On a Mac, allow incoming connections if asked.
|
**Host Game** starts a server on your computer and puts you in. Pick a server name, a rules preset, an optional password, the port (24242 by default) and, the first time, a seed. As its first player you're its admin. A message tells you the addresses friends can join at, and the page says which UDP ports to forward for friends over the internet (24242, and 24243 for the Cove's server). Leaving saves the island and stops the server; next time it carries on. On a Mac, allow incoming connections if asked.
|
||||||
|
|
||||||
### Dedicated servers
|
### Dedicated servers
|
||||||
|
|
||||||
@@ -372,7 +379,7 @@ A spare computer can run an always-on server with no window, set up in its `serv
|
|||||||
|
|
||||||
### Playing together
|
### Playing together
|
||||||
|
|
||||||
The server decides everything, so the pause menu doesn't pause; it lists who's online and has **Disconnect**. Each player has their own bed. Only you can open your grave. Looking at someone's piece shows whose it is. More players means more animals about, and the wolf pack splits between the players it hunts, preferring those away from a fire. The night passes once enough players are in bed.
|
The server decides everything, so the pause menu doesn't pause; it lists who's online, and which land each of them is in, and has **Disconnect**. Chat reaches everyone, whatever land they're in. Each player has their own bed. Only you can open your grave. Looking at someone's piece shows whose it is. More players means more animals about, and the wolf pack splits between the players it hunts, preferring those away from a fire. The night passes once enough players are in bed, counting everyone in every land: get into bed while others are up and you'll see "waiting for everyone else to go to bed", and everyone hears in chat that you're in bed.
|
||||||
|
|
||||||
### Rules and presets
|
### Rules and presets
|
||||||
|
|
||||||
@@ -411,7 +418,7 @@ Each extra player at the stones when it's called gives the Old Stag 60% more hea
|
|||||||
|
|
||||||
### The pause menu
|
### The pause menu
|
||||||
|
|
||||||
`Esc` opens it (and pauses your own world). It has **Resume**, **Controls**, **Fullscreen**, **Mouse sensitivity**, volumes for **Music**, **Sounds**, **Ambience** and **Old Tam's voice**, **Old Tam's tips** on or off with **Show again**, **Check for updates at start**, and **Save & Quit** to the title or desktop. The game autosaves every 2 minutes and when you sleep. In the tutorial it also offers **Skip this tutorial step** and **Leave the tutorial**.
|
`Esc` opens it (and pauses your own world). It has **Resume**, **Controls**, **Report a bug**, **Fullscreen**, **Mouse sensitivity**, volumes for **Music**, **Sounds**, **Ambience** and **Old Tam's voice**, **Old Tam's tips** on or off with **Show again**, **Check for updates at start**, and **Save & Quit** to the title or desktop. The game autosaves every 2 minutes and when you sleep. In the tutorial it also offers **Skip this tutorial step** and **Leave the tutorial**.
|
||||||
|
|
||||||
### Old Tam's tips
|
### Old Tam's tips
|
||||||
|
|
||||||
@@ -421,6 +428,10 @@ In your own worlds Old Tam offers a one-off tip the first time something comes u
|
|||||||
|
|
||||||
The music follows what's happening: calm by day, a night theme after dark, a storm theme in storms, blizzards and wolf moons, danger music when something is coming for you, and a boss theme for the Old Stag. Between pieces there's ambience: birds by day, crickets and owls at night. Indoors everything is quieter, and the music dips while Old Tam speaks.
|
The music follows what's happening: calm by day, a night theme after dark, a storm theme in storms, blizzards and wolf moons, danger music when something is coming for you, and a boss theme for the Old Stag. Between pieces there's ambience: birds by day, crickets and owls at night. Indoors everything is quieter, and the music dips while Old Tam speaks.
|
||||||
|
|
||||||
|
### Reporting a bug
|
||||||
|
|
||||||
|
**Report a bug** in the pause menu opens a new issue on Emberwild's [Gitea page](https://git.psbailey.uk/Admin/Emberwild/issues) in your browser, already filled in with your version, computer, mode, land, the in-game time and where you are. Say what happened and how to make it happen again, then press **Create Issue** (you'll need to be signed in there). Nothing is sent from the game itself. **Copy the details** puts the same details on your clipboard instead, and **Show the log file** opens the folder with `godot.log`: attach it for a crash or an error.
|
||||||
|
|
||||||
### Updates
|
### Updates
|
||||||
|
|
||||||
With the update check on, the title screen tells you when a newer Emberwild is out and offers a **Download** button or the release page.
|
With the update check on, the title screen tells you when a newer Emberwild is out and offers a **Download** button or the release page.
|
||||||
|
|||||||
@@ -4,117 +4,338 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Emberwild: a self-hosted co-op survival game</title>
|
<title>Emberwild: a self-hosted co-op survival game</title>
|
||||||
<meta name="description" content="Emberwild is a low-poly survival game: gather, hunt, build a home and hold out through the night, alone or with friends on your own server.">
|
<meta name="description" content="Emberwild is a low-poly survival game: gather, hunt, build a home and hold out through the night, then dig through the sea cliffs to the Cove. Alone, or with friends on your own server.">
|
||||||
|
<meta name="theme-color" content="#15110d">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="{{SITE_URL}}/">
|
||||||
<meta property="og:title" content="Emberwild">
|
<meta property="og:title" content="Emberwild">
|
||||||
<meta property="og:description" content="Survive the island. Build a home. Self-hosted co-op survival.">
|
<meta property="og:description" content="Survive the island. Build a home. Self-hosted co-op survival for Mac and Windows.">
|
||||||
<meta property="og:image" content="poster.jpg">
|
<meta property="og:image" content="{{SITE_URL}}/poster.jpg">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #15110d; --panel: #221b14; --line: #3a2e22;
|
--bg: #15110d; --bg2: #1b1510; --panel: #221b14; --line: #3a2e22;
|
||||||
--text: #f1e8da; --dim: #b8a892; --ember: #e8833a; --ember-hi: #f6a35c;
|
--text: #f1e8da; --dim: #bcae98; --ember: #e8833a; --ember-hi: #f6a35c;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
body { margin: 0; background: var(--bg); color: var(--text);
|
body { margin: 0; background: var(--bg); color: var(--text);
|
||||||
font: 17px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
font: 17px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||||
a { color: var(--ember-hi); }
|
a { color: var(--ember-hi); }
|
||||||
a:focus-visible, button:focus-visible { outline: 3px solid var(--ember-hi); outline-offset: 3px; }
|
a:focus-visible, button:focus-visible, video:focus-visible { outline: 3px solid var(--ember-hi); outline-offset: 3px; }
|
||||||
.wrap { max-width: 980px; margin: 0 auto; padding: 0 20px; }
|
img { max-width: 100%; height: auto; display: block; }
|
||||||
header { padding: 48px 0 20px; text-align: center; }
|
.skip { position: absolute; left: -9999px; top: 8px; background: var(--ember); color: #1a120a; padding: 8px 14px; border-radius: 6px; z-index: 10; }
|
||||||
h1 { font-size: clamp(44px, 9vw, 84px); letter-spacing: .06em; margin: 0; font-weight: 600; }
|
.skip:focus { left: 8px; }
|
||||||
.tag { color: var(--dim); font-size: 20px; margin: 6px 0 0; }
|
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 22px; }
|
||||||
.guide-link { margin: 10px 0 0; font-size: 17px; }
|
|
||||||
.video { margin: 28px 0 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden;
|
/* Top bar */
|
||||||
background: #000; aspect-ratio: 16 / 9; }
|
nav.top { position: absolute; inset: 0 0 auto 0; z-index: 2; }
|
||||||
.video video { width: 100%; height: 100%; display: block; }
|
nav.top .wrap { display: flex; align-items: center; gap: 22px; padding-top: 16px; padding-bottom: 16px; }
|
||||||
section { margin: 44px 0; }
|
nav.top .brand { font-weight: 700; letter-spacing: .14em; color: var(--text); text-decoration: none; margin-right: auto; }
|
||||||
h2 { font-size: 26px; margin: 0 0 14px; font-weight: 600; }
|
nav.top a.l { color: var(--text); text-decoration: none; font-size: 15px; opacity: .9; }
|
||||||
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
nav.top a.l:hover { color: var(--ember-hi); }
|
||||||
.dl { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 20px; }
|
@media (max-width: 620px) { nav.top a.l.opt { display: none; } }
|
||||||
.dl.mine { border-color: var(--ember); }
|
|
||||||
.dl h3 { margin: 0 0 4px; font-size: 20px; }
|
/* Hero */
|
||||||
.dl p { margin: 0 0 14px; color: var(--dim); font-size: 15px; }
|
.hero { position: relative; min-height: min(88vh, 760px); display: flex; align-items: flex-end;
|
||||||
|
background: #0d0a07 url("shots/cove.jpg") center 35% / cover no-repeat; }
|
||||||
|
.hero::before { content: ""; position: absolute; inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(13,10,7,.55) 0%, rgba(13,10,7,.15) 35%, rgba(21,17,13,.85) 78%, var(--bg) 100%); }
|
||||||
|
.hero .wrap { position: relative; padding-bottom: 56px; width: 100%; }
|
||||||
|
.pill { display: inline-block; font-size: 14px; padding: 3px 12px; border-radius: 999px;
|
||||||
|
background: rgba(232,131,58,.18); border: 1px solid rgba(246,163,92,.55); color: var(--ember-hi); }
|
||||||
|
.hero h1 { font-size: clamp(52px, 11vw, 112px); line-height: 1; letter-spacing: .07em; margin: 14px 0 8px; font-weight: 700;
|
||||||
|
text-shadow: 0 2px 24px rgba(0,0,0,.55); }
|
||||||
|
.hero .tag { font-size: clamp(19px, 2.6vw, 25px); margin: 0 0 6px; text-shadow: 0 1px 10px rgba(0,0,0,.7); }
|
||||||
|
.hero .sub { color: var(--dim); margin: 0 0 26px; max-width: 640px; text-shadow: 0 1px 8px rgba(0,0,0,.8); }
|
||||||
|
.ctas { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||||
.btn { display: inline-block; background: var(--ember); color: #1a120a; text-decoration: none;
|
.btn { display: inline-block; background: var(--ember); color: #1a120a; text-decoration: none;
|
||||||
font-weight: 600; padding: 11px 20px; border-radius: 8px; }
|
font-weight: 650; padding: 12px 22px; border-radius: 9px; border: 1px solid transparent; }
|
||||||
.btn:hover { background: var(--ember-hi); }
|
.btn:hover { background: var(--ember-hi); }
|
||||||
.note { color: var(--dim); font-size: 14px; margin-top: 12px; }
|
.btn.big { font-size: 19px; padding: 15px 28px; }
|
||||||
.note code { color: var(--text); }
|
.btn.ghost { background: rgba(21,17,13,.55); color: var(--text); border-color: rgba(241,232,218,.35); }
|
||||||
ul.features { columns: 2 260px; padding-left: 20px; margin: 0; }
|
.btn.ghost:hover { border-color: var(--ember-hi); color: var(--ember-hi); background: rgba(21,17,13,.75); }
|
||||||
ul.features li { margin: 0 0 6px; break-inside: avoid; }
|
.hero .small { color: var(--dim); font-size: 14px; margin: 12px 0 0; }
|
||||||
footer { border-top: 1px solid var(--line); padding: 24px 0 40px; color: var(--dim); font-size: 14px; }
|
.hero .small a { color: var(--text); }
|
||||||
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto; } }
|
|
||||||
|
section { padding: 56px 0; }
|
||||||
|
section.alt { background: var(--bg2); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||||
|
h2 { font-size: clamp(26px, 3.4vw, 34px); line-height: 1.2; margin: 0 0 8px; font-weight: 650; }
|
||||||
|
.lead { color: var(--dim); margin: 0 0 26px; max-width: 720px; }
|
||||||
|
|
||||||
|
/* Trailer */
|
||||||
|
.video { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: #000; aspect-ratio: 16 / 9;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,.45); }
|
||||||
|
.video video { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
/* Feature cards */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; }
|
||||||
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
|
||||||
|
.card img { aspect-ratio: 16 / 9; object-fit: cover; width: 100%; }
|
||||||
|
.card .body { padding: 16px 18px 18px; }
|
||||||
|
.card h3 { margin: 0 0 6px; font-size: 20px; }
|
||||||
|
.card p { margin: 0; color: var(--dim); font-size: 15.5px; }
|
||||||
|
|
||||||
|
/* What's new */
|
||||||
|
.new { display: grid; grid-template-columns: 1.3fr 1fr; gap: 28px; align-items: start; }
|
||||||
|
@media (max-width: 820px) { .new { grid-template-columns: 1fr; } }
|
||||||
|
ul.ticks { list-style: none; padding: 0; margin: 0; }
|
||||||
|
ul.ticks li { padding: 8px 0 8px 30px; position: relative; border-bottom: 1px solid var(--line); }
|
||||||
|
ul.ticks li:last-child { border-bottom: 0; }
|
||||||
|
ul.ticks li::before { content: ""; position: absolute; left: 4px; top: 17px; width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
background: var(--ember); box-shadow: 0 0 10px rgba(232,131,58,.8); }
|
||||||
|
.callout { background: var(--panel); border: 1px solid var(--ember); border-radius: 12px; padding: 20px 22px; }
|
||||||
|
.callout h3 { margin: 0 0 8px; font-size: 20px; }
|
||||||
|
.callout p { margin: 0 0 10px; color: var(--dim); font-size: 15.5px; }
|
||||||
|
.callout p:last-child { margin: 0; }
|
||||||
|
kbd { font: 600 13px/1 ui-monospace, SFMono-Regular, Menlo, monospace; background: #2f251b; border: 1px solid #4a3b2c;
|
||||||
|
border-bottom-width: 2px; border-radius: 5px; padding: 3px 6px; color: var(--text); }
|
||||||
|
|
||||||
|
/* Gallery */
|
||||||
|
.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; margin: 0; }
|
||||||
|
.gallery figure { margin: 0; position: relative; border-radius: 10px; overflow: hidden; border: 1px solid var(--line); background: #000; }
|
||||||
|
.gallery a { display: block; }
|
||||||
|
.gallery img { aspect-ratio: 16 / 9; object-fit: cover; width: 100%; transition: transform .35s ease; }
|
||||||
|
.gallery a:hover img { transform: scale(1.03); }
|
||||||
|
.gallery figcaption { position: absolute; left: 0; right: 0; bottom: 0; padding: 22px 14px 10px; font-size: 14.5px;
|
||||||
|
background: linear-gradient(transparent, rgba(0,0,0,.78)); pointer-events: none; }
|
||||||
|
|
||||||
|
/* Downloads */
|
||||||
|
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px; }
|
||||||
|
.dl { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 22px; }
|
||||||
|
.dl.mine { border-color: var(--ember); box-shadow: 0 0 0 1px var(--ember) inset; }
|
||||||
|
.dl h3 { margin: 0 0 2px; font-size: 21px; }
|
||||||
|
.dl .meta { margin: 0 0 16px; color: var(--dim); font-size: 15px; }
|
||||||
|
.dl .yours { display: none; font-size: 13px; color: var(--ember-hi); margin-left: 8px; font-weight: 600; }
|
||||||
|
.dl.mine .yours { display: inline; }
|
||||||
|
.note { color: var(--dim); font-size: 14.5px; margin: 14px 0 0; }
|
||||||
|
.note code, td code, p code { color: var(--text); background: #2a2119; padding: 1px 6px; border-radius: 5px; font-size: .92em; }
|
||||||
|
|
||||||
|
/* Friends */
|
||||||
|
.steps { counter-reset: s; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; margin: 0 0 22px; padding: 0; list-style: none; }
|
||||||
|
.steps li { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 18px 18px 18px 60px; position: relative; color: var(--dim); font-size: 15.5px; }
|
||||||
|
.steps li b { color: var(--text); }
|
||||||
|
.steps li::before { counter-increment: s; content: counter(s); position: absolute; left: 18px; top: 16px; width: 28px; height: 28px;
|
||||||
|
border-radius: 50%; background: var(--ember); color: #1a120a; font-weight: 700; display: grid; place-items: center; font-size: 15px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; max-width: 640px; font-size: 15.5px; }
|
||||||
|
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); }
|
||||||
|
th { color: var(--dim); font-weight: 600; }
|
||||||
|
|
||||||
|
footer { border-top: 1px solid var(--line); padding: 28px 0 44px; color: var(--dim); font-size: 14.5px; }
|
||||||
|
footer .wrap { display: flex; flex-wrap: wrap; gap: 10px 26px; justify-content: space-between; }
|
||||||
|
footer nav a { margin-right: 18px; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
.gallery img { transition: none; }
|
||||||
|
.gallery a:hover img { transform: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="wrap">
|
<a class="skip" href="#main">Skip to the page</a>
|
||||||
|
|
||||||
|
<nav class="top" aria-label="Site">
|
||||||
|
<div class="wrap">
|
||||||
|
<a class="brand" href="#">EMBERWILD</a>
|
||||||
|
<a class="l opt" href="#trailer">Trailer</a>
|
||||||
|
<a class="l opt" href="#world">The game</a>
|
||||||
|
<a class="l" href="guide.html">Guide</a>
|
||||||
|
<a class="l" href="#download">Download</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="hero">
|
||||||
|
<div class="wrap">
|
||||||
|
<span class="pill">Version {{VERSION}} · playtest</span>
|
||||||
<h1>EMBERWILD</h1>
|
<h1>EMBERWILD</h1>
|
||||||
<p class="tag">Survive the island. Build a home.</p>
|
<p class="tag">Survive the island. Build a home. Find what lies beyond.</p>
|
||||||
<p class="guide-link"><a href="guide.html">Read the guide</a>: how to play, and everything in the game.</p>
|
<p class="sub">A low-poly survival game for Mac and Windows. Gather, hunt and build, hold out
|
||||||
|
through wolf moons and hard winters, then dig down through the sea cliffs to the Cove.
|
||||||
|
Alone, or with friends on a server you run yourself.</p>
|
||||||
|
<div class="ctas">
|
||||||
|
<a class="btn big" id="hero-dl" href="#download">Download free</a>
|
||||||
|
<a class="btn ghost" href="#trailer">Watch the trailer</a>
|
||||||
|
<a class="btn ghost" href="guide.html">Read the guide</a>
|
||||||
|
</div>
|
||||||
|
<p class="small" id="hero-other">For macOS (Apple silicon and Intel) and Windows (64-bit).</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
<section id="trailer" aria-labelledby="trailer-title">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 id="trailer-title">The trailer</h2>
|
||||||
|
<p class="lead">Under a minute, recorded in the game.</p>
|
||||||
<div class="video">
|
<div class="video">
|
||||||
<video controls preload="metadata" poster="poster.jpg" aria-label="Emberwild trailer">
|
<video controls preload="metadata" poster="poster.jpg" aria-label="Emberwild trailer">
|
||||||
<source src="trailer.mp4" type="video/mp4">
|
<source src="trailer.mp4" type="video/mp4">
|
||||||
Your browser can't play this video. <a href="trailer.mp4">Download the trailer</a>.
|
Your browser can't play this video. <a href="trailer.mp4">Download the trailer</a>.
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<main class="wrap">
|
<section id="world" class="alt" aria-labelledby="world-title">
|
||||||
<section aria-labelledby="dl-title">
|
<div class="wrap">
|
||||||
|
<h2 id="world-title">What you'll do</h2>
|
||||||
|
<p class="lead">Every tree, animal and hut is built from low-poly shapes in the game's
|
||||||
|
own code, under a lot of weather.</p>
|
||||||
|
<div class="cards">
|
||||||
|
<article class="card">
|
||||||
|
<img src="shots/meadow.jpg" alt="A sunny meadow with deer among the trees" width="1280" height="720" loading="lazy">
|
||||||
|
<div class="body"><h3>Survive</h3>
|
||||||
|
<p>Gather, craft and cook. Keep warm, fed and dry, and eat a mix of meat, fish and
|
||||||
|
greens. Food goes off; salt and smoke keep it.</p></div>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<img src="shots/hut.jpg" alt="A wooden hut with a campfire at dusk" width="1280" height="720" loading="lazy">
|
||||||
|
<div class="body"><h3>Build</h3>
|
||||||
|
<p>Floors, walls and roofs that snap together, in wood and then stone. A roof, a
|
||||||
|
fire and a bed make a home you'll be glad of at night.</p></div>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<img src="shots/wolves.jpg" alt="Wolves circling a campfire under a red moon" width="1280" height="720" loading="lazy">
|
||||||
|
<div class="body"><h3>Hold out</h3>
|
||||||
|
<p>Wolves move in, and every seventh night the moon turns red. There's a bear in
|
||||||
|
the deep woods, a barrow, and the Old Stag at the standing stones.</p></div>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<img src="shots/treasure.jpg" alt="A marker stone in the Cove's hills at dusk, with a drowned sailor among the trees" width="1280" height="720" loading="lazy">
|
||||||
|
<div class="body"><h3>Explore</h3>
|
||||||
|
<p>Dig through the sea cliffs to the Cove: a sandy bay, a pier for sea fishing, a
|
||||||
|
wreck, and a pirate treasure for each player, guarded by the drowned crew.</p></div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="new" aria-labelledby="new-title">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 id="new-title">New in {{VERSION}}</h2>
|
||||||
|
<p class="lead">The Cove gets its own. This is a playtest build: thanks for trying it.</p>
|
||||||
|
<div class="new">
|
||||||
|
<ul class="ticks">
|
||||||
|
<li>A wreck on the Cove's beach, with a treasure map in the captain's sea chest for each player.</li>
|
||||||
|
<li>Your own X on your map: dig up the pirate chest for a ship's compass, a spyglass, a cutlass and gold.</li>
|
||||||
|
<li>The drowned crew rise to guard it. Dig by day if you're wise.</li>
|
||||||
|
<li>Crabs on the sand from dusk till dawn, and gulls after any food you leave out.</li>
|
||||||
|
<li>Build a rowing boat from driftwood, seagrass rope and pitch, and tie it up at the pier.</li>
|
||||||
|
<li>Report a bug, straight from the pause menu.</li>
|
||||||
|
</ul>
|
||||||
|
<aside class="callout" aria-labelledby="bug-title">
|
||||||
|
<h3 id="bug-title">Found a bug?</h3>
|
||||||
|
<p>Press <kbd>Esc</kbd> in the game, then <b>Report a bug</b>. It opens a new issue
|
||||||
|
on <a href="{{REPO_URL}}/issues">our Gitea</a>, already filled in with your
|
||||||
|
version, computer and where you are. Say what happened and press Create Issue.</p>
|
||||||
|
<p>You'll need a free account there. <a href="{{REPO_URL}}/src/branch/master/docs/PLAYTEST.md">What to try, and what isn't a bug yet</a>.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="alt" aria-labelledby="gallery-title">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 id="gallery-title">Screenshots</h2>
|
||||||
|
<p class="lead">Straight from the game. Click one to see it full size.</p>
|
||||||
|
<div class="gallery">
|
||||||
|
<figure><a href="shots/cove.jpg"><img src="shots/cove.jpg" alt="The Cove's bay and pier on a clear morning, a rowing boat tied up" width="1280" height="720" loading="lazy"></a>
|
||||||
|
<figcaption>The Cove, and a boat at the pier</figcaption></figure>
|
||||||
|
<figure><a href="shots/crabs.jpg"><img src="shots/crabs.jpg" alt="Red crabs on the sand at dusk" width="1280" height="720" loading="lazy"></a>
|
||||||
|
<figcaption>Crabs come up the beach at dusk</figcaption></figure>
|
||||||
|
<figure><a href="shots/winter.jpg"><img src="shots/winter.jpg" alt="A hut in a snowy clearing in a blizzard" width="1280" height="720" loading="lazy"></a>
|
||||||
|
<figcaption>Weather the winter</figcaption></figure>
|
||||||
|
<figure><a href="shots/hut.jpg"><img src="shots/hut.jpg" alt="A wooden hut with a campfire at dusk" width="1280" height="720" loading="lazy"></a>
|
||||||
|
<figcaption>A home of your own</figcaption></figure>
|
||||||
|
<figure><a href="shots/wolves.jpg"><img src="shots/wolves.jpg" alt="Wolves circling a campfire under a red moon" width="1280" height="720" loading="lazy"></a>
|
||||||
|
<figcaption>The wolf moon</figcaption></figure>
|
||||||
|
<figure><a href="shots/treasure.jpg"><img src="shots/treasure.jpg" alt="A marker stone and a mound of loose earth in the hills at dusk" width="1280" height="720" loading="lazy"></a>
|
||||||
|
<figcaption>X marks the spot</figcaption></figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="download" aria-labelledby="dl-title">
|
||||||
|
<div class="wrap">
|
||||||
<h2 id="dl-title">Download version {{VERSION}}</h2>
|
<h2 id="dl-title">Download version {{VERSION}}</h2>
|
||||||
|
<p class="lead">Free. Released {{DATE}}. The game tells you on its title screen when a newer version is out.</p>
|
||||||
<div class="downloads">
|
<div class="downloads">
|
||||||
<div class="dl" data-os="mac">
|
<div class="dl" data-os="mac">
|
||||||
<h3>macOS</h3>
|
<h3>macOS <span class="yours">· your computer</span></h3>
|
||||||
<p>Apple silicon and Intel · {{MAC_SIZE}}</p>
|
<p class="meta">Apple silicon and Intel · {{MAC_SIZE}}</p>
|
||||||
<a class="btn" href="{{MAC_URL}}">Download for Mac</a>
|
<a class="btn" href="{{MAC_URL}}">Download for Mac</a>
|
||||||
<p class="note">First launch: right-click the app and choose <b>Open</b>
|
<p class="note">Unzip it. The first time, right-click the app and choose <b>Open</b>
|
||||||
(it isn't notarised by Apple yet).</p>
|
(it isn't notarised by Apple yet).</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dl" data-os="win">
|
<div class="dl" data-os="win">
|
||||||
<h3>Windows</h3>
|
<h3>Windows <span class="yours">· your computer</span></h3>
|
||||||
<p>64-bit · {{WIN_SIZE}}</p>
|
<p class="meta">64-bit · {{WIN_SIZE}}</p>
|
||||||
<a class="btn" href="{{WIN_URL}}">Download for Windows</a>
|
<a class="btn" href="{{WIN_URL}}">Download for Windows</a>
|
||||||
<p class="note">Unzip, then run <code>Emberwild.exe</code>. If SmartScreen
|
<p class="note">Unzip, then run <code>Emberwild.exe</code>. If SmartScreen warns,
|
||||||
warns, choose <b>More info</b> > <b>Run anyway</b> (it isn't signed yet).</p>
|
choose <b>More info</b> > <b>Run anyway</b> (it isn't signed yet).</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="note">Released {{DATE}}. {{NOTES}} The game tells you on its title
|
<p class="note"><a href="{{RELEASES_URL}}">All releases, release notes and checksums</a>.</p>
|
||||||
screen when a newer version is out.
|
</div>
|
||||||
<a href="{{RELEASES_URL}}">All releases and checksums</a>.</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="what-title">
|
<section id="friends" class="alt" aria-labelledby="host-title">
|
||||||
<h2 id="what-title">What it is</h2>
|
<div class="wrap">
|
||||||
<ul class="features">
|
|
||||||
<li>Gather, craft and cook; keep warm, fed and dry.</li>
|
|
||||||
<li>Hunt deer, boar and rabbits; fish the lake.</li>
|
|
||||||
<li>Build a home from floors, walls and roofs that snap together.</li>
|
|
||||||
<li>Wolves move in, and every seventh night the moon turns red.</li>
|
|
||||||
<li>Weather that matters: storms, blizzards and hard winters.</li>
|
|
||||||
<li>A bear, a barrow, and the Old Stag at the standing stones.</li>
|
|
||||||
<li>Dig down through the sea cliffs to the Cove, and fish off its pier.</li>
|
|
||||||
<li>A map that fills in as you explore, and a compass.</li>
|
|
||||||
<li>Play alone, or host a server for friends on your own machine.</li>
|
|
||||||
<li>A guided tutorial with Old Tam the hermit.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section aria-labelledby="host-title">
|
|
||||||
<h2 id="host-title">Playing with friends</h2>
|
<h2 id="host-title">Playing with friends</h2>
|
||||||
<p>Choose <b>Host Game</b> on the title screen, and friends choose <b>Join Game</b>
|
<p class="lead">No accounts and no company servers: one of you hosts, on your own computer.</p>
|
||||||
with your address (UDP port 24242). Everyone needs the same version. A spare
|
<ol class="steps">
|
||||||
computer can run a dedicated server from the same download:
|
<li><b>Host Game</b> on the title screen. Pick a name, a rules preset (co-op, rivals
|
||||||
<code>Emberwild --headless -- --server</code>.</p>
|
or adversarial) and a password if you like.</li>
|
||||||
|
<li>Friends choose <b>Join Game</b> and type your address. Everyone needs the same
|
||||||
|
version.</li>
|
||||||
|
<li>On the same network, or over Tailscale, that's it. Over the internet, forward the
|
||||||
|
ports below on your router.</li>
|
||||||
|
</ol>
|
||||||
|
<table>
|
||||||
|
<caption class="note" style="caption-side: bottom; text-align: left;">The Cove runs as its own server on the next port, started when someone first goes there.</caption>
|
||||||
|
<thead><tr><th scope="col">UDP port</th><th scope="col">For</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>24242</code></td><td>The Valley (the server you join)</td></tr>
|
||||||
|
<tr><td><code>24243</code></td><td>The Cove</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="note">A spare computer can run a dedicated server from the same download:
|
||||||
|
<code>Emberwild --headless -- --server</code>.
|
||||||
|
<a href="guide.html#multiplayer">More in the guide</a>.</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="wrap">Emberwild {{VERSION}}. Made with Godot. <a href="guide.html">The guide</a>.</footer>
|
<footer>
|
||||||
|
<div class="wrap">
|
||||||
|
<nav aria-label="More">
|
||||||
|
<a href="guide.html">The guide</a>
|
||||||
|
<a href="{{RELEASES_URL}}">Releases</a>
|
||||||
|
<a href="{{REPO_URL}}/issues">Report a bug</a>
|
||||||
|
<a href="{{REPO_URL}}">Source</a>
|
||||||
|
</nav>
|
||||||
|
<span>Emberwild {{VERSION}}. Made with Godot.</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Point out the download for the visitor's own computer (it still works without this).
|
// The visitor's own computer: its download first and marked, and the
|
||||||
|
// hero's button goes straight to it. (Without this, the page still works.)
|
||||||
(function () {
|
(function () {
|
||||||
var ua = navigator.userAgent;
|
var ua = navigator.userAgent;
|
||||||
var os = /Mac/i.test(ua) && !/iPhone|iPad/i.test(ua) ? "mac" : /Win/i.test(ua) ? "win" : "";
|
var os = /iPhone|iPad|Android/i.test(ua) ? "" : /Mac/i.test(ua) ? "mac" : /Win/i.test(ua) ? "win" : "";
|
||||||
var card = os && document.querySelector('.dl[data-os="' + os + '"]');
|
if (!os) return;
|
||||||
if (card) { card.classList.add("mine"); card.parentNode.insertBefore(card, card.parentNode.firstChild); }
|
var card = document.querySelector('.dl[data-os="' + os + '"]');
|
||||||
|
if (!card) return;
|
||||||
|
card.classList.add("mine");
|
||||||
|
card.parentNode.insertBefore(card, card.parentNode.firstChild);
|
||||||
|
var link = card.querySelector("a.btn");
|
||||||
|
var hero = document.getElementById("hero-dl");
|
||||||
|
hero.href = link.href;
|
||||||
|
hero.textContent = os === "mac" ? "Download for Mac" : "Download for Windows";
|
||||||
|
var other = document.getElementById("hero-other");
|
||||||
|
other.innerHTML = "Free · {{VERSION}} · " + card.querySelector(".meta").textContent.split("·").pop().trim() +
|
||||||
|
' · <a href="#download">' + (os === "mac" ? "Windows" : "Mac") + " and install notes</a>";
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ def inline(s):
|
|||||||
s = esc(s)
|
s = esc(s)
|
||||||
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
|
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
|
||||||
s = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", s)
|
s = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", s)
|
||||||
|
# [text](https://...) links (after esc: the URL has no quotes or brackets).
|
||||||
|
s = re.sub(r"\[([^\]]+)\]\((https://[^)\s\"<>]+)\)", r'<a href="\2">\1</a>', s)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
16
tools/site/make_shots.sh
Executable file
16
tools/site/make_shots.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# The download page's screenshots (tools/site/shots.gd), rendered in a window:
|
||||||
|
#
|
||||||
|
# tools/site/make_shots.sh # -> build/shots/*.jpg (make_site.sh copies them)
|
||||||
|
#
|
||||||
|
# Takes under a minute. Run it again when the game's look changes.
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.." || exit 1
|
||||||
|
. tools/godot_env.sh
|
||||||
|
mkdir -p build/shots
|
||||||
|
rm -f build/shots/*.jpg
|
||||||
|
"$GODOT" --path . --resolution 1280x720 --script tools/site/shots.gd -- "$PWD/build/shots" > build/shots/render.log 2>&1
|
||||||
|
if grep -q "SCRIPT ERROR" build/shots/render.log || [ -z "$(ls build/shots/*.jpg 2>/dev/null)" ]; then
|
||||||
|
echo "rendering the screenshots failed: see build/shots/render.log"; exit 1
|
||||||
|
fi
|
||||||
|
grep "^shots:" build/shots/render.log
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
# build/site/index.html the page, with this release's download links
|
# build/site/index.html the page, with this release's download links
|
||||||
# build/site/poster.jpg the trailer's title frame
|
# build/site/poster.jpg the trailer's title frame
|
||||||
# build/site/trailer.mp4 the trailer (tools/trailer/make_trailer.sh)
|
# build/site/trailer.mp4 the trailer (tools/trailer/make_trailer.sh)
|
||||||
|
# build/site/shots/ the screenshots (tools/site/make_shots.sh)
|
||||||
#
|
#
|
||||||
# Copy the folder to any static web host. Gitea itself won't serve HTML, so
|
# Copy the folder to any static web host. Gitea itself won't serve HTML, so
|
||||||
# the page lives elsewhere and links to the files on the Gitea release.
|
# the page lives elsewhere and links to the files on the Gitea release.
|
||||||
@@ -14,15 +15,23 @@
|
|||||||
|
|
||||||
cd "$(dirname "$0")/../.." || exit 1
|
cd "$(dirname "$0")/../.." || exit 1
|
||||||
REPO=${REPO:-https://git.psbailey.uk/Admin/Emberwild}
|
REPO=${REPO:-https://git.psbailey.uk/Admin/Emberwild}
|
||||||
|
SITE=${SITE:-https://emberwild-95k.pages.dev} # (where the page is served: for link previews)
|
||||||
|
SHOTS="meadow hut wolves winter cove treasure crabs"
|
||||||
[ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; }
|
[ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; }
|
||||||
[ -f build/Emberwild-trailer.mp4 ] || { echo "no trailer: run tools/trailer/make_trailer.sh first"; exit 1; }
|
[ -f build/Emberwild-trailer.mp4 ] || { echo "no trailer: run tools/trailer/make_trailer.sh first"; exit 1; }
|
||||||
|
for s in $SHOTS; do
|
||||||
|
[ -f "build/shots/$s.jpg" ] || { echo "no build/shots/$s.jpg: run tools/site/make_shots.sh first"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
rm -rf build/site && mkdir -p build/site
|
rm -rf build/site && mkdir -p build/site
|
||||||
cp build/Emberwild-trailer.mp4 build/site/trailer.mp4
|
cp build/Emberwild-trailer.mp4 build/site/trailer.mp4
|
||||||
cp build/trailer/poster.jpg build/site/poster.jpg
|
cp build/trailer/poster.jpg build/site/poster.jpg
|
||||||
python3 - "$REPO" <<'EOF'
|
mkdir -p build/site/shots
|
||||||
|
for s in $SHOTS; do cp "build/shots/$s.jpg" build/site/shots/; done
|
||||||
|
python3 - "$REPO" "$SITE" <<'EOF'
|
||||||
import html, json, sys
|
import html, json, sys
|
||||||
repo = sys.argv[1].rstrip("/")
|
repo = sys.argv[1].rstrip("/")
|
||||||
|
site = sys.argv[2].rstrip("/")
|
||||||
v = json.load(open("build/version.json"))
|
v = json.load(open("build/version.json"))
|
||||||
tag = "v" + v["version"]
|
tag = "v" + v["version"]
|
||||||
def dl(plat):
|
def dl(plat):
|
||||||
@@ -36,7 +45,7 @@ page = open("tools/site/index.html").read()
|
|||||||
for k, val in {
|
for k, val in {
|
||||||
"VERSION": v["version"], "DATE": v.get("date", ""), "NOTES": v.get("notes", ""),
|
"VERSION": v["version"], "DATE": v.get("date", ""), "NOTES": v.get("notes", ""),
|
||||||
"MAC_URL": mac_url, "MAC_SIZE": mac_size, "WIN_URL": win_url, "WIN_SIZE": win_size,
|
"MAC_URL": mac_url, "MAC_SIZE": mac_size, "WIN_URL": win_url, "WIN_SIZE": win_size,
|
||||||
"RELEASES_URL": repo + "/releases",
|
"RELEASES_URL": repo + "/releases", "REPO_URL": repo, "SITE_URL": site,
|
||||||
}.items():
|
}.items():
|
||||||
page = page.replace("{{%s}}" % k, html.escape(str(val), quote=True))
|
page = page.replace("{{%s}}" % k, html.escape(str(val), quote=True))
|
||||||
assert "{{" not in page, "a placeholder was left unfilled"
|
assert "{{" not in page, "a placeholder was left unfilled"
|
||||||
@@ -55,7 +64,8 @@ rm -f "$DATA"
|
|||||||
|
|
||||||
# Cloudflare Pages refuses any file over 25 MiB: catch it here, not at deploy.
|
# Cloudflare Pages refuses any file over 25 MiB: catch it here, not at deploy.
|
||||||
LIMIT=26214400
|
LIMIT=26214400
|
||||||
for f in build/site/*; do
|
for f in build/site/* build/site/shots/*; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
s=$(stat -f %z "$f" 2>/dev/null || stat -c %s "$f")
|
s=$(stat -f %z "$f" 2>/dev/null || stat -c %s "$f")
|
||||||
if [ "$s" -ge "$LIMIT" ]; then
|
if [ "$s" -ge "$LIMIT" ]; then
|
||||||
echo "$f is $s bytes: over Cloudflare Pages' 25 MiB limit (re-encode the trailer smaller)"; exit 1
|
echo "$f is $s bytes: over Cloudflare Pages' 25 MiB limit (re-encode the trailer smaller)"; exit 1
|
||||||
|
|||||||
126
tools/site/shots.gd
Normal file
126
tools/site/shots.gd
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
extends "res://tools/trailer/trailer.gd"
|
||||||
|
## The download page's screenshots (tools/site/make_shots.sh): the trailer's
|
||||||
|
## shots, each caught as one still instead of filmed, plus a couple of the
|
||||||
|
## Cove's own (the treasure dig and its drowned crew, crabs on the sand).
|
||||||
|
## godot --path . --resolution 1280x720 --script tools/site/shots.gd -- <out dir>
|
||||||
|
## Writes <name>.jpg for each, 1280x720.
|
||||||
|
|
||||||
|
## How far into each trailer shot the still is taken (0..1).
|
||||||
|
const AT := {"meadow": 0.3, "hut": 0.8, "wolves": 0.15, "winter": 0.5, "cove": 0.85}
|
||||||
|
|
||||||
|
var _out := ""
|
||||||
|
var _name := ""
|
||||||
|
var made: Array[String] = []
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var args := OS.get_cmdline_user_args()
|
||||||
|
_out = args[0] if not args.is_empty() else ProjectSettings.globalize_path("res://build/shots")
|
||||||
|
DirAccess.make_dir_recursive_absolute(_out)
|
||||||
|
var H: Node = root.get_node("Hints")
|
||||||
|
H.path = "user://shots_hints.json"
|
||||||
|
H.load_prefs()
|
||||||
|
H.enabled = false
|
||||||
|
var S: Node = root.get_node("Saves")
|
||||||
|
S.root_dir = "user://shots_worlds"
|
||||||
|
_overlay()
|
||||||
|
await _enter_world("")
|
||||||
|
_calm()
|
||||||
|
await _stage()
|
||||||
|
for s in [["meadow", _shot_meadow], ["hut", _shot_hut], ["wolves", _shot_wolves], ["winter", _shot_blizzard]]:
|
||||||
|
_name = s[0]
|
||||||
|
await (s[1] as Callable).call()
|
||||||
|
await _enter_world("cove")
|
||||||
|
_calm()
|
||||||
|
_name = "cove"
|
||||||
|
await _shot_cove()
|
||||||
|
_name = "treasure"
|
||||||
|
await _shot_treasure()
|
||||||
|
_name = "crabs"
|
||||||
|
await _shot_crabs()
|
||||||
|
for w in S.list_worlds():
|
||||||
|
S.delete_world(w.id)
|
||||||
|
DirAccess.remove_absolute(S.root_dir)
|
||||||
|
DirAccess.remove_absolute(H.path)
|
||||||
|
print("shots: ", ", ".join(made))
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
## No wildlife wandering into frame, nothing coming for food left out.
|
||||||
|
func _calm() -> void:
|
||||||
|
world.creatures.enabled = false
|
||||||
|
for c in world.creatures.get_children():
|
||||||
|
c.queue_free()
|
||||||
|
if world.thieves != null:
|
||||||
|
world.thieves.enabled = false
|
||||||
|
if world.shore != null:
|
||||||
|
world.shore.enabled = false
|
||||||
|
|
||||||
|
## One still, part-way through the shot `f` would film; no captions, no fades.
|
||||||
|
func _shot(secs: float, f: Callable, _caption: String = "", _fade: float = 0.5) -> void:
|
||||||
|
var t := secs * float(AT.get(_name, 0.6))
|
||||||
|
for _i in range(4):
|
||||||
|
f.call(t, t / secs)
|
||||||
|
_bare()
|
||||||
|
await process_frame
|
||||||
|
world.props._update_grass(100000)
|
||||||
|
var wait := 1.2 if _name == "winter" else 0.6 # (let the snow fill the air)
|
||||||
|
var left := wait
|
||||||
|
while left > 0.0:
|
||||||
|
f.call(t, t / secs)
|
||||||
|
_bare()
|
||||||
|
await process_frame
|
||||||
|
left -= 1.0 / 60.0
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
var img := root.get_viewport().get_texture().get_image()
|
||||||
|
var path := _out.path_join(_name + ".jpg")
|
||||||
|
img.save_jpg(path, 0.84)
|
||||||
|
made.append(_name)
|
||||||
|
|
||||||
|
func _bare() -> void:
|
||||||
|
for l in [big, sub, cap]:
|
||||||
|
l.modulate.a = 0.0
|
||||||
|
black.modulate.a = 0.0
|
||||||
|
|
||||||
|
## The Cove's treasure at dusk: your mound by its marker stone, and the
|
||||||
|
## drowned crew risen round it.
|
||||||
|
func _shot_treasure() -> void:
|
||||||
|
var p: Node3D = world.player
|
||||||
|
p.inventory.add("treasure_map", 1)
|
||||||
|
await create_timer(1.0).timeout # (the mound shows once the map's in your bag)
|
||||||
|
var tr: Node3D = world.treasure
|
||||||
|
var m: Node3D = tr.mounds.get(world.my_id()) if tr != null else null
|
||||||
|
if m == null:
|
||||||
|
return
|
||||||
|
tr.force_crew = true
|
||||||
|
tr._raise_crew(world.my_id(), m.global_position)
|
||||||
|
var eye: Vector3 = _g(m.global_position + Vector3(3.6, 0, 2.8), 1.6)
|
||||||
|
for g in tr.crews[world.my_id()].crew:
|
||||||
|
g.set_physics_process(false)
|
||||||
|
var to: Vector3 = eye - g.global_position
|
||||||
|
g.rotation.y = atan2(-to.x, -to.z)
|
||||||
|
await _shot(1.0, func(_t: float, _k: float):
|
||||||
|
_clock(18.1)
|
||||||
|
cam.global_position = eye
|
||||||
|
cam.look_at(m.global_position + Vector3(0, 0.6, 0))
|
||||||
|
_park_player(m.global_position + Vector3(1.2, 0, 0)))
|
||||||
|
|
||||||
|
## Crabs on the sand at dusk, the pier behind.
|
||||||
|
func _shot_crabs() -> void:
|
||||||
|
var b := _g(Vector3(4.0, 0, 77.0))
|
||||||
|
var offs := [Vector3(0, 0, 0), Vector3(1.2, 0, 0.6), Vector3(-0.9, 0, 1.1), Vector3(2.2, 0, -0.5)]
|
||||||
|
for i in range(offs.size()):
|
||||||
|
var c: Node3D = world.shore.spawn(b + offs[i]) if world.shore != null else null
|
||||||
|
if c == null:
|
||||||
|
continue
|
||||||
|
c.set_physics_process(false)
|
||||||
|
c.yaw = 0.6 * i
|
||||||
|
c.rotation.y = c.yaw
|
||||||
|
if i == 0:
|
||||||
|
c.state = "charge"
|
||||||
|
c.speed = 1.2
|
||||||
|
c._animate(0.1)
|
||||||
|
var eye := _g(b + Vector3(-1.0, 0, 2.4), 1.7) # (from down the sand, looking back up the beach)
|
||||||
|
await _shot(1.0, func(_t: float, _k: float):
|
||||||
|
_clock(18.4)
|
||||||
|
cam.global_position = eye
|
||||||
|
cam.look_at(b + Vector3(0.5, 0.0, 0.2))
|
||||||
|
_park_player(eye))
|
||||||
1
tools/site/shots.gd.uid
Normal file
1
tools/site/shots.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bll0xov1lash4
|
||||||
@@ -322,8 +322,11 @@ func _shot_cove() -> void:
|
|||||||
if pier == null:
|
if pier == null:
|
||||||
return
|
return
|
||||||
var side: Vector3 = pier.dir.cross(Vector3.UP)
|
var side: Vector3 = pier.dir.cross(Vector3.UP)
|
||||||
|
# (From out over the bay, with the stream's mouth along the shore.)
|
||||||
var from: Vector3 = pier.end + pier.dir * 18.0 + side * 14.0 + Vector3(0, 2.5, 0)
|
var from: Vector3 = pier.end + pier.dir * 18.0 + side * 14.0 + Vector3(0, 2.5, 0)
|
||||||
var to: Vector3 = pier.end + pier.dir * 6.0 + side * 7.0 + Vector3(0, 1.6, 0)
|
var to: Vector3 = pier.end + pier.dir * 6.0 + side * 7.0 + Vector3(0, 1.6, 0)
|
||||||
|
if world.moorings != null: # (0.6.0: a couple of rowing boats tied up at the end)
|
||||||
|
world.moorings.load_data({"moored": {"a": {"berth": 0, "name": ""}, "b": {"berth": 3, "name": ""}}})
|
||||||
await _shot(6.0, func(_t: float, k: float):
|
await _shot(6.0, func(_t: float, k: float):
|
||||||
_clock(8.2 + k * 0.3)
|
_clock(8.2 + k * 0.3)
|
||||||
var e := k * k * (3.0 - 2.0 * k)
|
var e := k * k * (3.0 - 2.0 * k)
|
||||||
|
|||||||
Reference in New Issue
Block a user