6 Commits

159 changed files with 3363 additions and 119 deletions

View File

@@ -16,7 +16,7 @@ addon SDK with guard rails.
## Status ## Status
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design. See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
**Emberwild 0.3.0** is released (download page and player guide: **Emberwild 0.4.0** is released (download page and player guide:
https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far: https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing. - M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin). - M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
@@ -26,8 +26,14 @@ https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
- T1, T2, T4 (`docs/TUTORIAL_DESIGN.md`): the tutorial with Old Tam (spoken), - T1, T2, T4 (`docs/TUTORIAL_DESIGN.md`): the tutorial with Old Tam (spoken),
and his tips in normal worlds. and his tips in normal worlds.
- M4 so far: packaged builds, the update check, music, ambience, sound effects - M4 so far: packaged builds, the update check, music, ambience, sound effects
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages. and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages,
Next: the worldlets and the journey between them (`docs/WORLDLETS_DESIGN.md`). the map (N) and compass.
- W0-W3, J1 (`docs/WORLDLETS_DESIGN.md`): worldlets as data, the Cove (beach,
pier, sea fishing), and the cliff tunnel between them, with a save file per
worldlet (single-player).
Next: J3, the journey on a server ("spread out"), then the Highlands.
Running from the source, `godot --path . -- --dev-worldlets` lets New World
start in any worldlet, to try one directly.
## Requirements ## Requirements
- **Godot 4.3+** (standard build; no C# needed — this project uses GDScript). - **Godot 4.3+** (standard build; no C# needed — this project uses GDScript).

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -543,6 +543,72 @@
}, },
"keeps": 4 "keeps": 4
}, },
{
"id": "mackerel",
"name": "Mackerel",
"category": "food",
"stack": 20,
"description": "A quick, striped sea fish, caught off the pier. Grill it at a campfire. (Fish)",
"mesh": "mackerel",
"food": {
"hunger": 5,
"health": -2,
"diet": {
"fish": 10
},
"risk": 0.2
},
"keeps": 2
},
{
"id": "cod",
"name": "Cod",
"category": "food",
"stack": 20,
"description": "A big sea fish from deeper water: cast well out. (Fish)",
"mesh": "cod",
"food": {
"hunger": 8,
"health": -2,
"diet": {
"fish": 16
},
"risk": 0.2
},
"keeps": 2
},
{
"id": "grilled_mackerel",
"name": "Grilled Mackerel",
"category": "food",
"stack": 20,
"description": "Oily and good. (Fish)",
"mesh": "grilled_mackerel",
"food": {
"hunger": 22,
"health": 8,
"diet": {
"fish": 36
}
},
"keeps": 4
},
{
"id": "grilled_cod",
"name": "Grilled Cod",
"category": "food",
"stack": 20,
"description": "Thick white flakes: a proper meal. (Fish)",
"mesh": "grilled_cod",
"food": {
"hunger": 32,
"health": 12,
"diet": {
"fish": 52
}
},
"keeps": 4
},
{ {
"id": "iron_nugget", "id": "iron_nugget",
"name": "Iron Nugget", "name": "Iron Nugget",

View File

@@ -249,6 +249,26 @@
"trout": 1 "trout": 1
} }
}, },
{
"id": "grilled_mackerel",
"output": "grilled_mackerel",
"count": 1,
"station": "campfire",
"category": "cooking",
"inputs": {
"mackerel": 1
}
},
{
"id": "grilled_cod",
"output": "grilled_cod",
"count": 1,
"station": "campfire",
"category": "cooking",
"inputs": {
"cod": 1
}
},
{ {
"id": "charcoal_kiln", "id": "charcoal_kiln",
"output": "charcoal_kiln", "output": "charcoal_kiln",
@@ -578,6 +598,28 @@
"salt": 1 "salt": 1
} }
}, },
{
"id": "salted_mackerel",
"output": "salted_fish",
"count": 2,
"station": "",
"category": "cooking",
"inputs": {
"mackerel": 2,
"salt": 1
}
},
{
"id": "salted_cod",
"output": "salted_fish",
"count": 2,
"station": "",
"category": "cooking",
"inputs": {
"cod": 1,
"salt": 1
}
},
{ {
"id": "antler_pickaxe", "id": "antler_pickaxe",
"output": "antler_pickaxe", "output": "antler_pickaxe",

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

@@ -0,0 +1,22 @@
{
"_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.",
"id": "cove",
"name": "Cove",
"description": "Low hills running down to a sheltered sandy bay, with a pier out over the water for sea fishing.",
"start": false,
"size": 320,
"resolution": 256,
"terrain": [
{"type": "hills", "height_scale": 5.5, "detail": 1.2},
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 52.0, "rim_warp": 14.0, "wall_frac": 0.45},
{"type": "beach", "angle_deg": 90.0, "half_width_deg": 55.0, "fade_deg": 22.0, "shore_offset": -16.0, "slope": 0.08, "current_out": 22.0, "wall_out": 36.0},
{"type": "lake", "center": [-30.0, -40.0], "radius": 11.0, "water_level": -2.6},
{"type": "ocean_edge"}
],
"climate": {"snowline": 42.0},
"places": [{"type": "pier", "at": "beach"}],
"next": [
{"to": "valley", "by": "tunnel", "where": "sea_cave"},
{"to": "highlands", "by": "sail", "where": "pier"}
]
}

View File

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

View File

@@ -87,7 +87,11 @@ phases M2.0-M2.7).
- PvP / raiding rules, building damage - PvP / raiding rules, building damage
- Better UI, settings, controls. *Done so far:* volume sliders, tips and - Better UI, settings, controls. *Done so far:* volume sliders, tips and
update settings in the pause menu; `Q` drops items; `K`/`M` put any bag row update settings in the pause menu; `Q` drops items; `K`/`M` put any bag row
on the 1-8 keys (0.3.0). on the 1-8 keys (0.3.0); the map on `N`, filling in as you explore, and a
compass along the top (0.4.0, Gitea issue #1).
- More places: the worldlets and the journey (`docs/WORLDLETS_DESIGN.md`).
*Done (0.4.0):* W0-W3 and J1, the Cove reached down the cliff tunnel
(single-player). Next J3 (multiplayer, "spread out"), then J2/J4.
- 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

View File

@@ -1,7 +1,98 @@
# Worldlets design # Worldlets design
Status: **proposal**. Nothing here is built yet, apart from the first piece Status: **W0-W3 and J1 built** (the Cove without its stream yet; the
of groundwork (`world.water_at()` and `data/fish.json`, see §6). tunnel single-player only); the rest is planned.
*As built in J1:*
- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip,
7 m in from the edge, its angle from the seed) and the tunnel space, out
of sight 260 m under the island: a CSG rock block with the landings and
six sloping stretches cut out (6 m on, 4 m down each, switching back on
two lanes: 24 m down), then a sea cave. Faces (`tunnel_face.gd`) answer
like boulders, so the pick works on them: tier 3 only ("Too hard: you
need a better pick"), 10 blows for the seam and 15 per stretch. Each
stretch must be propped (2 wood, [E] at its mouth) before the next face
takes a blow ("It's loose..."): 10 wood all the way down.
- Underground: no sun, moon, sky reflections or sky-tinted fog
(`day_night.underground`), no weather, no lake water, sheltered. Candles
and torch stands place anywhere on its floor and save as ordinary pieces.
- Walking out of the sea cave's mouth travels (`world.travel()`): the
worldlet you leave is saved, your character and the clock go with you,
and the other loads, arriving at its sea cave (`sea_cave.gd`, on the
Cove's beach where the headland starts). [E] at the cave goes back up the
same tunnel to its bottom, once it's dug through.
- Saves: `world.json` is the home worldlet's state, as before;
others are `worldlets/<id>.json`; meta.json's `worldlet` is where you
are, `home` where you began, `arrive` where the next load puts you.
- `tests/test_tunnel.gd`: dig through, walk every stretch down and back
up, light it, reload, out to the Cove, back up, out at the top, and the
chest at home is still there.
- Not yet: Old Tam's tip about the seam, the Guide page, and travel on a
server (J3: the tunnel isn't built online). The journey between
worldlets is §10, and the two ways to play (the journey, or one land) are in
its decisions.
*As built in W3:*
- `data/worldlets/cove.json`: low hills, mountains behind, a wide sandy
bay (a `beach` block) and a small pond. Not a start; its `next` is back
to the Valley (tunnel, J1) and on to the Highlands (sail).
- The pier (`scripts/world/pier.gd`), a world "place" (`"places":
[{"type": "pier"}]`): planks on posts from where the sand is at deck
height to 24 m past the waterline. Worked out from the terrain, so it's
the same for everyone and never saved or dismantled; scenery keeps clear
of it. ~1.9 m of sea at its end, ~2.7 m a cast beyond.
- Sea fish in `data/fish.json`: mackerel (common) and cod, which only bites
in water 2.2 m or deeper (a per-fish `min_depth`). Grilled at a campfire,
salted, or smoked, like the lake's.
- New World has no "Where": the journey always begins in the Valley (the
Host page keeps it, for One land mode). Started with
`godot --path . -- --dev-worldlets`, New World and Host offer every
worldlet, the others marked "(testing)", to try one directly.
- No sea stacks without a sea cliff.
- Tests: `test_cove` (walk onto the pier, catch a mackerel off its end, cod
only in deep water), and the Cove in `test_terrain_edges` and
`test_boundary`.
- Still to do: the stream (it needs water that runs downhill, which the
one flat water surface can't do: its own ribbon of water, planned with
the Cove's sea cave when the tunnel arrives).
*As built in W2:*
- `lake` blocks can repeat: every lake and pond is its own body
(`terrain.lakes`, `world.water_bodies()`), all at one surface
(`water_level`, set by the first). The first is still "the lake" to the
older code (ice, its fish, the tutorial).
- A `beach` block (angle, width, `shore_offset`, `slope`, `current_out`,
`wall_out`): the land eases to a waterline and shelves gently into the
sea, which is then the same surface as the ponds (one ocean sheet, no
inland disc). The invisible wall stands well out in the water, and before
it a current (`terrain.current_at()`, `world._current()`) carries
swimmers back faster than they can swim, with a toast.
- `water_at()` tells the sea off a beach from a pond.
- Test-only worldlets live in `tests/worldlets/` (never listed or offered).
`testbay.json` (two ponds and a beach) runs through
`test_water_bodies`, `test_terrain_edges` (8 seeds) and `test_boundary`
(swimming out across the beach).
*As built in W1:* a "Where" picker on the New World and Host Game pages
(worldlets with `"start": true`; only the Valley, so it's greyed out), the
worldlet in the Load list, and a hosted island keeps its worldlet like its
seed.
*As built in W0:*
- `data/worldlets/valley.json`: the Valley as data, its terrain as five
building blocks in order (`hills`, `rim`, `sea_cliff`, `lake`,
`ocean_edge`) with every number that used to be hard-coded.
- `terrain.gd` runs a worldlet's blocks (`use_worldlet()`); each block is
the old code's arithmetic, unchanged. `tests/test_worldlets.gd` checks
four seeds give exactly the same height grid as before (SHA-256 of the
grid, taken from the old code).
- The worldlet is chosen like the seed: a server's (it sends its real one),
else the save's `meta.json` "worldlet", else the Valley (worlds from
before W0). New worlds and servers record it.
- The WorldInfo API on the world: `worldlet_id()`, `water_bodies()`,
`climate()`, `crossings()` (the planned ways on), beside `water_at()`.
Systems still read the terrain's numbers directly; they move over to
these as each new worldlet needs them to differ (W1-W3).
## 1. What a worldlet is ## 1. What a worldlet is
A **worldlet** is a small, complete world type: a ~320 m playable area with A **worldlet** is a small, complete world type: a ~320 m playable area with
@@ -224,6 +315,41 @@ you've learned and gathered there:
- **Through:** the far end opens onto the next worldlet. Walking out loads - **Through:** the far end opens onto the next worldlet. Walking out loads
it (a short fade, "The Cove"), with your bag, cloak and stats as they are. it (a short fade, "The Cove"), with your bag, cloak and stats as they are.
### The first tunnel: down the sea cliff to the Cove
The Valley's sea side ends at a sheer cliff, its top about level with the
valley and the sea some 25 m below. The Cove is down there, so the first
tunnel goes **down**, inside the cliff, from the clifftop to the shore.
- **The seam:** a cracked outcrop on the clifftop strip near the edge (the
sea_cliff block marks where), with old pick marks and loose stones at its
foot. It can't be dug until you have the antler pickaxe (tier 3); a lesser
pick just chips it ("Too hard: you need a better pick").
- **Digging down:** the tunnel is about six stretches, each a few metres on
and a few down: a sloping passage that turns back on itself like a
stair, so the 25 m drop fits inside the cliff. A stretch takes many pick
blows (stamina, and wear on the pick) to open.
- **Props:** each new stretch needs a **timber prop** (2 wood) set at its
mouth before you can dig the next. Leave one out and the face won't take
another blow ("It's loose: prop it first"); nothing caves in on you, so
it's a cost, not a trap. That's 10 wood all the way down.
- **Light:** the tunnel has no daylight at all. Every stretch is pitch dark
until you bring light: a torch in your hand works, but your hands are full
with the pick, so the way to do it is to set **candles and torch stands**
as you go (tallow candles from bear fat, torch stands from wood). Placed
lights stay, so the way is lit for the trip back and for friends.
- **The bottom:** the last stretch breaks out into a sea cave, its mouth
opening on the Cove's shingle beach. Walking out of the mouth is the
crossing: a fade, then the Cove, arriving at the cave's Cove end. The way
back starts there and climbs the same tunnel.
- **What's saved:** the tunnel belongs to the Valley's save: how far it's
dug, the props, the lights. On a server it's shared: anyone can dig on
from where someone stopped, and the lights are everyone's.
- **How it's built:** the tunnel is its own small space (a "between" scene
next to the worldlet), made from a kit of rock pieces (straight, turn,
slope down, the cave mouth) laid out from the seed, with box-shaped
colliders. The heightmap can't have holes or overhangs, so the passage is
never cut into the terrain itself: you walk into the seam and you're in
the tunnel scene.
### Why go back ### Why go back
- **Each worldlet has things the others lack.** Clay and salt only in the - **Each worldlet has things the others lack.** Clay and salt only in the
Valley and Cove; little wood in the Highlands; no bears outside the Valley and Cove; little wood in the Highlands; no bears outside the
@@ -285,6 +411,22 @@ A server today runs one island. Options:
- **The other crossings are shared:** a dug tunnel, a fixed climbing rope - **The other crossings are shared:** a dug tunnel, a fixed climbing rope
and a dug burrow are changes to the world, there for everyone once one and a dug burrow are changes to the world, there for everyone once one
player has made them (like a door or a bridge). player has made them (like a door or a bridge).
- **Two ways to play (Paul, Nov 2026).**
- **The journey** (the standard game), single-player and multiplayer
alike: every new world starts in the Valley and the worldlets come in
their fixed order (Valley, Cove, Highlands, Marsh, Ember). Each
worldlet's shape is set by its definition; the seed only changes the
detail (hills, trees, where the den is), so the journey is the same
adventure for everyone.
- **One land** (a hosting choice): no journey, no crossings, the server
is locked to one worldlet, and the host picks which, from all of them.
Co-op or PvP as now (the rules preset). In code: a `mode` in
`server.json` ("journey" / "one_land"); in one-land mode the Host
page's "Where" lists every worldlet rather than only the starts, and
the crossings (`world.crossings()`) answer none, so a tunnel seam
can't be dug. Kept, like the seed and the worldlet, once the island
exists. Worth building once the Cove exists (until then there's only
the Valley to pick).
### Server cost, measured (0.3.1) ### Server cost, measured (0.3.1)
One worldlet, a dedicated server on the M5 Pro, never pausing: about One worldlet, a dedicated server on the M5 Pro, never pausing: about

View File

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

View File

@@ -19,7 +19,7 @@ config/description="Low-poly survival game — Valheim look, Rust depth, self-ho
run/main_scene="res://scenes/title.tscn" run/main_scene="res://scenes/title.tscn"
config/features=PackedStringArray("4.7", "Forward Plus") config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg" config/icon="res://icon.svg"
config/version="0.3.1" config/version="0.4.0"
[autoload] [autoload]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -75,6 +75,10 @@ static func build(key: String) -> ArrayMesh:
"trout": m = _fish(TROUT, 0.24, false) "trout": m = _fish(TROUT, 0.24, false)
"grilled_perch": m = _fish(PERCH, 0.16, true) "grilled_perch": m = _fish(PERCH, 0.16, true)
"grilled_trout": m = _fish(TROUT, 0.24, true) "grilled_trout": m = _fish(TROUT, 0.24, true)
"mackerel": m = _fish(MACKEREL, 0.2, false)
"cod": m = _fish(COD, 0.3, false)
"grilled_mackerel": m = _fish(MACKEREL, 0.2, true)
"grilled_cod": m = _fish(COD, 0.3, true)
"fiber_plant": m = _fiber_plant() "fiber_plant": m = _fiber_plant()
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6) "iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
"iron_ore": m = _ore() "iron_ore": m = _ore()
@@ -694,6 +698,8 @@ static func _cloak(col: Color = HIDE) -> ArrayMesh:
const PERCH := Color(0.45, 0.52, 0.30) const PERCH := Color(0.45, 0.52, 0.30)
const TROUT := Color(0.50, 0.55, 0.58) const TROUT := Color(0.50, 0.55, 0.58)
const MACKEREL := Color(0.22, 0.42, 0.48)
const COD := Color(0.56, 0.52, 0.40)
const GRILLED := Color(0.55, 0.36, 0.18) const GRILLED := Color(0.55, 0.36, 0.18)
const FLOAT_RED := Color(0.85, 0.15, 0.12) const FLOAT_RED := Color(0.85, 0.15, 0.12)

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
const Identity = preload("res://scripts/net/identity.gd") const Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.gd") const ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 15 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand) const PROTOCOL := 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 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
@@ -304,14 +304,14 @@ var _host_out := ""
var _host_port := DEFAULT_PORT var _host_port := DEFAULT_PORT
## Settings for a hosted game: name, password, port, a rules preset ## Settings for a hosted game: name, password, port, a rules preset
## (co-op / rivals / adversarial) and the seed for a new island. ## (co-op / rivals / adversarial) and the seed and worldlet for a new island.
const PRESETS := { const PRESETS := {
"co-op": {"pvp": "false", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"}, "co-op": {"pvp": "false", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"rivals": {"pvp": "true", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"}, "rivals": {"pvp": "true", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"adversarial": {"pvp": "true", "raiding": "true", "locks": "owner", "death_penalty": "drop_all"}, "adversarial": {"pvp": "true", "raiding": "true", "locks": "owner", "death_penalty": "drop_all"},
} }
func host(server_name: String, password: String, port: int, preset: String, seed: int) -> void: func host(server_name: String, password: String, port: int, preset: String, seed: int, worldlet: String = "valley") -> void:
if hosting or state != "offline": if hosting or state != "offline":
return return
if _host_pid != 0 and OS.is_process_running(_host_pid): if _host_pid != 0 and OS.is_process_running(_host_pid):
@@ -324,8 +324,9 @@ func host(server_name: String, password: String, port: int, preset: String, seed
c.data.password = password c.data.password = password
c.data.port = port c.data.port = port
var fresh := not FileAccess.file_exists(host_config.get_base_dir().path_join("worlds/server-world/meta.json")) var fresh := not FileAccess.file_exists(host_config.get_base_dir().path_join("worlds/server-world/meta.json"))
if fresh: if fresh: # (an island keeps its seed and worldlet once it's made)
c.data.seed = seed c.data.seed = seed
c.data.worldlet = worldlet
c.save() c.save()
_host_port = port _host_port = port
var args := PackedStringArray(["--headless"]) var args := PackedStringArray(["--headless"])
@@ -638,7 +639,7 @@ func _c2s_proof(sig: PackedByteArray) -> void:
print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "", print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
players.size(), int(config.v("max_players"))]) players.size(), int(config.v("max_players"))])
# (The world's own seed: an existing world keeps the seed it was made with.) # (The world's own seed: an existing world keeps the seed it was made with.)
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, "yaw": yaw, var world := {"seed": int(_terrain.noise_seed), "worldlet": String(_terrain.worldlet_id), "spawn": start, "yaw": yaw,
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time, "island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
"weather": _world.weather.override} "weather": _world.weather.override}
_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})

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ func open_server_world(config: RefCounted) -> Node3D:
var id := String(config.v("world")).validate_filename() var id := String(config.v("world")).validate_filename()
if id.is_empty(): if id.is_empty():
id = "server-world" id = "server-world"
Saves.current_id = Saves.open_or_create(id, int(config.v("seed"))) Saves.current_id = Saves.open_or_create(id, int(config.v("seed")), String(config.v("worldlet")))
backup("start-up") # (the state it's about to load, before anyone changes it) backup("start-up") # (the state it's about to load, before anyone changes it)
_backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0 _backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0
var w: Node3D = load("res://scenes/world.tscn").instantiate() var w: Node3D = load("res://scenes/world.tscn").instantiate()

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -15,6 +15,7 @@ const CONTROLS := [
["E on a bed", "Set spawn point / sleep (night, under a roof)"], ["E on a bed", "Set spawn point / sleep (night, under a roof)"],
["1 - 8, wheel", "Hotbar (the bag row you're on)"], ["K / M", "Hotbar shows the bag row above / below"], ["1 - 8, wheel", "Hotbar (the bag row you're on)"], ["K / M", "Hotbar shows the bag row above / below"],
["Q (shift+Q)", "Drop what you're holding (just one)"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"], ["Q (shift+Q)", "Drop what you're holding (just one)"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"],
["N", "Map (fills in as you explore; the compass is along the top)"],
["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"], ["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"],
["Esc", "This menu (game autosaves every 2 min)"], ["Esc", "This menu (game autosaves every 2 min)"],
] ]

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -31,6 +31,8 @@ const FLOWER_COLORS := [
@export var grass_tiles_per_update: int = 6 @export var grass_tiles_per_update: int = 6
var terrain: Node3D var terrain: Node3D
var pier: Node3D ## the Cove's pier, kept clear of scenery (set by the world)
var keep_clear: Array = [] ## [[Vector3, radius]]: the tunnel's seam, the sea cave
var _player: Node3D var _player: Node3D
var _spawn := Vector3.ZERO var _spawn := Vector3.ZERO
var _forest_noise := FastNoiseLite.new() var _forest_noise := FastNoiseLite.new()
@@ -417,7 +419,13 @@ func _scatter_trees() -> void:
## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so ## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so
## the view out to sea is clear and you can walk right up to the edge. ## the view out to sea is clear and you can walk right up to the edge.
## (Also kept clear: the Cove's pier and the sand by its foot.)
func _on_clifftop(x: float, z: float) -> bool: func _on_clifftop(x: float, z: float) -> bool:
if pier != null and pier.covers(x, z, 2.0):
return true
for c in keep_clear:
if Vector2(x - c[0].x, z - c[0].z).length() < float(c[1]):
return true
return terrain.sea_weight(x, z) > 0.5 and not terrain.inside_boundary(x, z, 10.0) \ return terrain.sea_weight(x, z) > 0.5 and not terrain.inside_boundary(x, z, 10.0) \
and terrain.inside_boundary(x, z, -8.0) and terrain.inside_boundary(x, z, -8.0)
@@ -486,7 +494,7 @@ func _scatter_rocks() -> void:
# Sea stacks: tall pillars standing off the cliff (no collision, unreachable). # Sea stacks: tall pillars standing off the cliff (no collision, unreachable).
var placed := 0 var placed := 0
var tries := 0 var tries := 0
while placed < 9 and tries < 200: while placed < 9 and tries < 200 and terrain._has_sea: # (only off a sea cliff)
tries += 1 tries += 1
var ang: float = terrain.sea_angle_deg + rng.randf_range(-1.0, 1.0) * (terrain.sea_half_width_deg + 8.0) var ang: float = terrain.sea_angle_deg + rng.randf_range(-1.0, 1.0) * (terrain.sea_half_width_deg + 8.0)
var p: Vector2 = terrain.coast_point(ang, rng.randf_range(10.0, 48.0)) var p: Vector2 = terrain.coast_point(ang, rng.randf_range(10.0, 48.0))
@@ -672,7 +680,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: if h < terrain.water_level + 0.3 or n.y < 0.8 or (pier != null and pier.covers(x, z, 1.0)):
continue continue
var chance := 1.0 var chance := 1.0
match id: match id:

120
scripts/world/sea_cave.gd Normal file
View File

@@ -0,0 +1,120 @@
extends Node3D
## The Cove's sea cave (J1): the bottom of the Valley's cliff tunnel, seen
## from the shore. A dark opening in the rocks at the end of the beach,
## where the headland meets the sand. [E] goes in and up the tunnel, back to
## the Valley, once that tunnel has been dug through from the top. (Until
## then the cave runs back into solid rock.)
##
## 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).
var world: Node3D
var to := "valley"
var front := Vector3.ZERO ## where you stand in front of it (arriving from the tunnel)
## Places the cave if this worldlet has a way back by tunnel from a sea cave.
func place(w: Node3D) -> bool:
world = w
var t: Node3D = w.terrain
var way: Dictionary = {}
for c in w.crossings():
if c is Dictionary and c.get("by", "") == "tunnel" and c.get("where", "") == "sea_cave":
way = c
if way.is_empty() or not t._has_beach:
return false
to = String(way.to)
# At the end of the beach, where the headland starts: the first dry,
# gentle spot coming in from the waterline.
var a := deg_to_rad(t.beach_angle_deg + t.beach_half_width_deg + t.beach_fade_deg * 0.35)
var dir := Vector2(cos(a), sin(a))
var at := Vector3.INF
for i in range(60):
var r: float = t._shore_d() - 2.0 - i * 0.5 - t._rim_offset(dir.x, dir.y)
var p := dir * r
var h: float = t.sample_height(p.x, p.y)
if h > t.water_level + 0.5 and t.sample_normal(p.x, p.y).y > 0.86 and t.inside_boundary(p.x, p.y, 3.0):
at = Vector3(p.x, h, p.y)
break
if at == Vector3.INF:
return false
# Facing back along the beach (towards its middle), so you see it from the sand.
var mid: Vector2 = t.shore_point(-6.0)
var face := Vector3(mid.x - at.x, 0.0, mid.y - at.z).normalized()
global_transform = Transform3D(Basis.looking_at(face, Vector3.UP), at)
front = at + face * 2.4
front.y = t.sample_height(front.x, front.z)
_build(w)
add_to_group("sea_cave")
return true
## Whether the Valley's tunnel has been dug right down to here.
func open() -> bool:
if not Saves.has_world():
return false
var s: Dictionary = Saves.load_state(Saves.current_id, to)
return int(s.get("tunnel", {}).get("dug", 0)) >= preload("res://scripts/world/tunnel.gd").STRETCHES
func interact_prompt(_player: Node) -> String:
if open():
return "[E] Go into the sea cave and up the tunnel to the %s" % preload("res://scripts/world/terrain.gd").worldlet_name(to)
return "A sea cave. It runs back into solid rock: the way up hasn't been dug"
func interact(_player: Node) -> void:
if open():
world.travel(to, "tunnel_bottom")
else:
Actions.notify.emit(interact_prompt(null))
func _build(w: Node3D) -> void:
var rock := StandardMaterial3D.new()
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()
add_child(body)
# (local -Z faces the beach; the rocks are behind, +Z)
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)],
[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)],
[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 mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = part[1]
mi.mesh = bm
mi.material_override = rock
mi.position = part[0]
mi.rotation.y = part[0].x * 0.06
add_child(mi)
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = part[1]
cs.shape = box
cs.position = part[0]
cs.rotation.y = part[0].x * 0.06
body.add_child(cs)
var dark := MeshInstance3D.new()
var q := QuadMesh.new()
q.size = Vector2(2.2, 2.8)
dark.mesh = q
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.02, 0.02, 0.025)
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
dark.material_override = m
dark.position = Vector3(0, 1.4, 2.08)
dark.rotation.y = PI
add_child(dark)
# What you press E on: the opening (interactables layer: you walk into it).
var spot := StaticBody3D.new()
spot.collision_layer = 2
spot.collision_mask = 0
var cs2 := CollisionShape3D.new()
var b2 := BoxShape3D.new()
b2.size = Vector3(2.2, 2.8, 1.6)
cs2.shape = b2
spot.add_child(cs2)
spot.position = Vector3(0, 1.4, 1.2)
spot.set_script(preload("res://scripts/world/sea_cave_spot.gd"))
spot.cave = self
add_child(spot)

View File

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

View File

@@ -0,0 +1,10 @@
extends StaticBody3D
## The sea cave's opening (sea_cave.gd): what you look at and press E on.
var cave: Node3D
func interact_prompt(player: Node) -> String:
return cave.interact_prompt(player)
func interact(player: Node) -> void:
cave.interact(player)

View File

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

View File

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

View File

@@ -41,6 +41,15 @@ signal ready_built ## emitted once the mesh + collision exist
@export var cliff_offset: float = 4.0 ## cliff edge, in rim distance past play_radius @export var cliff_offset: float = 4.0 ## cliff edge, in rim distance past play_radius
@export var cliff_drop_width: float = 6.0 ## horizontal run of the drop (smaller = sheerer) @export var cliff_drop_width: float = 6.0 ## horizontal run of the drop (smaller = sheerer)
@export_group("Beach") # (W2: a worldlet's "beach" block; the Valley has none)
@export var beach_angle_deg: float = 90.0
@export var beach_half_width_deg: float = 40.0
@export var beach_fade_deg: float = 20.0
@export var shore_offset: float = -10.0 ## waterline, in rim distance past play_radius
@export var beach_slope: float = 0.08 ## metres deeper per metre out
@export var current_out: float = 20.0 ## the current starts this far past the waterline
@export var wall_out: float = 34.0 ## and the invisible wall stands here
@export_group("Water & snow") @export_group("Water & snow")
@export var water_level: float = -2.6 @export var water_level: float = -2.6
@export var lake_center: Vector2 = Vector2(14.0, -46.0) @export var lake_center: Vector2 = Vector2(14.0, -46.0)
@@ -75,8 +84,15 @@ func _ready() -> void:
var saves := get_node_or_null("/root/Saves") var saves := get_node_or_null("/root/Saves")
if net != null and net.state == "connected" and net.world_info.has("seed"): if net != null and net.state == "connected" and net.world_info.has("seed"):
noise_seed = int(net.world_info.seed) noise_seed = int(net.world_info.seed)
worldlet_id = String(net.world_info.get("worldlet", worldlet_id))
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)
worldlet_id = String(saves.read_meta(saves.current_id).get("worldlet", "valley"))
var why := use_worldlet(worldlet_id)
if not why.is_empty():
push_error("[terrain] %s: using the Valley" % why)
use_worldlet("valley")
_build() _build()
# --- height function -------------------------------------------------------- # --- height function --------------------------------------------------------
@@ -125,24 +141,182 @@ func _cliff_d() -> float:
return play_radius + cliff_offset return play_radius + cliff_offset
## 1 on the open-sea side of the rim, 0 where the mountains are, smooth between ## 1 on the open-sea side of the rim, 0 where the mountains are, smooth between
## (the headlands). Depends only on the angle from the centre. ## (the headlands). Depends only on the angle from the centre. (0 everywhere
## in a worldlet with no sea cliff.)
func _sea_w(x: float, z: float) -> float: func _sea_w(x: float, z: float) -> float:
if not _has_sea:
return 0.0
var diff := absf(wrapf(atan2(z, x) - deg_to_rad(sea_angle_deg), -PI, PI)) var diff := absf(wrapf(atan2(z, x) - deg_to_rad(sea_angle_deg), -PI, PI))
return 1.0 - smoothstep(deg_to_rad(sea_half_width_deg), deg_to_rad(sea_half_width_deg + sea_fade_deg), diff) return 1.0 - smoothstep(deg_to_rad(sea_half_width_deg), deg_to_rad(sea_half_width_deg + sea_fade_deg), diff)
## Boundary threshold in rim distance: partway up the mountains, or just ## The same for a beach (W2): 1 where the land runs down into the sea.
## inside the cliff edge on the sea side. func _beach_w(x: float, z: float) -> float:
if not _has_beach:
return 0.0
var diff := absf(wrapf(atan2(z, x) - deg_to_rad(beach_angle_deg), -PI, PI))
return 1.0 - smoothstep(deg_to_rad(beach_half_width_deg), deg_to_rad(beach_half_width_deg + beach_fade_deg), diff)
## The waterline of the beach, in rim distance.
func _shore_d() -> float:
return play_radius + shore_offset
## Boundary threshold in rim distance: partway up the mountains, just
## inside the cliff edge on the sea side, or well out in the water off a
## beach (where the current turns you back before you reach it).
func _wall_threshold(x: float, z: float) -> float: func _wall_threshold(x: float, z: float) -> float:
return lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z)) var t := lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z))
if _has_beach:
t = lerpf(t, _shore_d() + wall_out, _beach_w(x, z))
return t
# --- worldlets (docs/WORLDLETS_DESIGN.md, W0) ---------------------------------------
# A worldlet's terrain is a list of building blocks applied in order
# (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.
const BLOCKS := ["hills", "rim", "sea_cliff", "beach", "lake", "ocean_edge"]
var worldlet_id := "valley"
var worldlet: Dictionary = {} ## the definition, as loaded
var _blocks: Array = [] ## its terrain blocks, in order
var lakes: Array = [] ## every "lake" block's {center: Vector2, radius} (W2: several)
var _has_sea := true ## a sea cliff (the Valley's)
var _has_beach := false ## a beach (W2)
var _valley := 0.0 ## the hills before anything else (the sea cliff eases from it)
var _detail_k := 1.4
## The definition of worldlet `id` (data/worldlets/<id>.json), or {} if none.
static func load_worldlet(id: String) -> Dictionary:
var p := "res://data/worldlets/%s.json" % id
if not FileAccess.file_exists(p):
p = "res://tests/worldlets/%s.json" % id # (test-only worldlets: never listed or offered)
if not FileAccess.file_exists(p):
return {}
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(p))
return d if d is Dictionary else {}
## Every worldlet there is: [{id, name, description}].
static func worldlets() -> Array:
var out: Array = []
for f in DirAccess.get_files_at("res://data/worldlets"):
if f.ends_with(".json"):
var d := load_worldlet(f.get_basename())
if not d.is_empty():
out.append({"id": String(d.id), "name": String(d.get("name", d.id)), "description": String(d.get("description", "")),
"start": bool(d.get("start", false))})
out.sort_custom(func(a, b): return a.id == "valley" or (b.id != "valley" and a.name < b.name))
return out
## The worldlets a new world can start in (the rest are reached on the journey).
static func starting_worldlets() -> Array:
return worldlets().filter(func(w): return w.start)
## A worldlet's display name ("Valley"), or its id if there's no such worldlet.
static func worldlet_name(id: String) -> String:
var d := load_worldlet(id)
return String(d.get("name", id)) if not d.is_empty() else id
## Takes on worldlet `id`: its size and every block's numbers. Returns ""
## on success, else what's wrong (an unknown worldlet or block keeps the old
## settings, so the game still has an island).
func use_worldlet(id: String) -> String:
var d := load_worldlet(id)
if d.is_empty():
return "no worldlet '%s'" % id
for b in d.get("terrain", []):
if not (b is Dictionary) or not String(b.get("type", "")) in BLOCKS:
return "worldlet '%s': unknown terrain block %s" % [id, str(b)]
worldlet_id = id
worldlet = d
size = int(d.get("size", size))
resolution = int(d.get("resolution", resolution))
_blocks = d.get("terrain", [])
lakes = []
_has_sea = _has_block("sea_cliff")
_has_beach = _has_block("beach")
for b in _blocks:
match String(b.type):
"hills":
height_scale = float(b.get("height_scale", height_scale))
_detail_k = float(b.get("detail", _detail_k))
"rim":
play_radius = float(b.get("play_radius", play_radius))
rim_width = float(b.get("rim_width", rim_width))
mountain_height = float(b.get("mountain_height", mountain_height))
rim_warp = float(b.get("rim_warp", rim_warp))
wall_frac = float(b.get("wall_frac", wall_frac))
"sea_cliff":
sea_angle_deg = float(b.get("angle_deg", sea_angle_deg))
sea_half_width_deg = float(b.get("half_width_deg", sea_half_width_deg))
sea_fade_deg = float(b.get("fade_deg", sea_fade_deg))
sea_level = float(b.get("sea_level", sea_level))
cliff_top = float(b.get("cliff_top", cliff_top))
cliff_offset = float(b.get("cliff_offset", cliff_offset))
cliff_drop_width = float(b.get("drop_width", cliff_drop_width))
"beach":
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_fade_deg = float(b.get("fade_deg", beach_fade_deg))
shore_offset = float(b.get("shore_offset", shore_offset))
beach_slope = float(b.get("slope", beach_slope))
current_out = float(b.get("current_out", current_out))
wall_out = float(b.get("wall_out", wall_out))
"lake":
# Every lake and pond shares one surface (water_level): the
# first lake sets it, and is "the lake" to the older code
# (lake_center/lake_radius: ice, its fish, the tutorial).
var c: Array = b.get("center", [lake_center.x, lake_center.y])
var at := Vector2(float(c[0]), float(c[1]))
var r := float(b.get("radius", lake_radius))
b["_c"] = at
b["_r"] = r
if lakes.is_empty():
lake_center = at
lake_radius = r
water_level = float(b.get("water_level", water_level))
lakes.append({"center": at, "radius": r})
if _has_beach:
sea_level = water_level # (a beach runs into the sea: one surface)
snow_height = float(d.get("climate", {}).get("snowline", snow_height))
return ""
func _has_block(t: String) -> bool:
for b in _blocks:
if String(b.type) == t:
return true
return false
func _height_fn(x: float, z: float) -> float: func _height_fn(x: float, z: float) -> float:
_ensure_noise() _ensure_noise()
var valley := _noise.get_noise_2d(x, z) * height_scale if _blocks.is_empty():
valley += _detail.get_noise_2d(x, z) * 1.4 use_worldlet(worldlet_id)
var d := _rim_d(x, z) var land := 0.0
for b in _blocks:
match String(b.type):
"hills":
land = _blk_hills(x, z)
"rim":
land = _blk_rim(x, z, land)
"sea_cliff":
land = _blk_sea_cliff(x, z, land)
"beach":
land = _blk_beach(x, z, land)
"lake":
land = _blk_lake(x, z, land, b._c, b._r)
"ocean_edge":
land = _blk_ocean_edge(x, z, land)
return land
# Mountain rim. ## Rolling hills: the ground everything else is built on.
var land := valley func _blk_hills(x: float, z: float) -> float:
var valley := _noise.get_noise_2d(x, z) * height_scale
valley += _detail.get_noise_2d(x, z) * _detail_k
_valley = valley
return valley
## The mountain rim: rises from play_radius, stepped into ledges.
func _blk_rim(x: float, z: float, land: float) -> float:
var valley := _valley
var d := _rim_d(x, z)
var t := smoothstep(play_radius, play_radius + rim_width, d) var t := smoothstep(play_radius, play_radius + rim_width, d)
if t > 0.0: if t > 0.0:
var ridge := 1.0 - absf(_ridge.get_noise_2d(x, z)) var ridge := 1.0 - absf(_ridge.get_noise_2d(x, z))
@@ -153,7 +327,12 @@ func _height_fn(x: float, z: float) -> float:
var stepped := floorf(m / band) * band + smoothstep(0.35, 0.65, fposmod(m, band) / band) * band var stepped := floorf(m / band) * band + smoothstep(0.35, 0.65, fposmod(m, band) / band) * band
m = lerpf(m, stepped, 0.45 * t) m = lerpf(m, stepped, 0.45 * t)
land = valley * (1.0 - t * 0.6) + m land = valley * (1.0 - t * 0.6) + m
return land
## The sea side of the rim: the valley eases to a cliff top, then drops sheer.
func _blk_sea_cliff(x: float, z: float, land: float) -> float:
var valley := _valley
var d := _rim_d(x, z)
# Sea side: the valley eases to a cliff top, then drops sheer to the seabed. # Sea side: the valley eases to a cliff top, then drops sheer to the seabed.
# The edge sits 1 m past the invisible wall at every angle, including the # The edge sits 1 m past the invisible wall at every angle, including the
# headlands where the wall moves outward. (With the edge fixed at _cliff_d(), # headlands where the wall moves outward. (With the edge fixed at _cliff_d(),
@@ -167,14 +346,34 @@ func _height_fn(x: float, z: float) -> float:
var seabed := sea_level - 7.0 - minf(maxf(d - edge, 0.0), 60.0) * 0.08 + _detail.get_noise_2d(x, z) * 2.0 var seabed := sea_level - 7.0 - minf(maxf(d - edge, 0.0), 60.0) * 0.08 + _detail.get_noise_2d(x, z) * 2.0
coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d)) coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d))
land = lerpf(land, coast, sw) land = lerpf(land, coast, sw)
return land
# Lake basin: blend the ground down to a bed below the waterline. ## A lake basin: the ground blends down to a bed below the waterline.
var ld := Vector2(x - lake_center.x, z - lake_center.y).length() func _blk_lake(x: float, z: float, land: float, at: Vector2, r: float) -> float:
var ld := Vector2(x - at.x, z - at.y).length()
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
land = lerpf(land, water_level - 3.2, smoothstep(lake_radius, lake_radius * 0.35, ld)) return lerpf(land, water_level - 3.2, smoothstep(r, r * 0.35, ld))
# Ocean all round the chunk edge, so the world is an island and the edge of ## A beach (W2): on its side of the rim the land eases down to a sandy
# the mesh is always hidden under deep water. ## waterline, then shelves gently into the sea (beach_slope deeper per
## metre), so you can wade, then swim, until the current turns you back.
func _blk_beach(x: float, z: float, land: float) -> float:
var bw := _beach_w(x, z)
if bw <= 0.0:
return land
var d := _rim_d(x, z)
var shore := _shore_d()
var profile: float
if d < shore:
profile = water_level + (shore - d) * 0.12 + _detail.get_noise_2d(x * 0.5, z * 0.5) * 0.3
else:
profile = maxf(water_level - (d - shore) * beach_slope, water_level - 14.0) + _detail.get_noise_2d(x, z) * 0.25
var coast := lerpf(_valley, profile, smoothstep(shore - 50.0, shore - 8.0, d))
return lerpf(land, coast, bw)
## Ocean all round the chunk edge, so the world is an island and the edge of
## the mesh is always hidden under deep water.
func _blk_ocean_edge(x: float, z: float, land: float) -> float:
var e := smoothstep(size * 0.5 - 28.0, size * 0.5 - 4.0, sqrt(x * x + z * z)) var e := smoothstep(size * 0.5 - 28.0, size * 0.5 - 4.0, sqrt(x * x + z * z))
return lerpf(land, sea_level - 10.0 + _detail.get_noise_2d(x, z) * 2.0, e) return lerpf(land, sea_level - 10.0 + _detail.get_noise_2d(x, z) * 2.0, e)
@@ -370,7 +569,8 @@ func _build_water() -> void:
arrays[Mesh.ARRAY_INDEX] = idx arrays[Mesh.ARRAY_INDEX] = idx
var disc := ArrayMesh.new() var disc := ArrayMesh.new()
disc.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) disc.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
_add_water("Water", disc, water_level, {}) 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, {})
var ocean := PlaneMesh.new() var ocean := PlaneMesh.new()
ocean.size = Vector2(8000.0, 8000.0) ocean.size = Vector2(8000.0, 8000.0)
@@ -411,6 +611,26 @@ func sea_weight(x: float, z: float) -> float:
_ensure_noise() _ensure_noise()
return _sea_w(x, z) return _sea_w(x, z)
## How strongly (x, z) lies on the beach side of the rim (0..1; W2).
func beach_weight(x: float, z: float) -> float:
_ensure_noise()
return _beach_w(x, z)
## Off a beach, the current that turns swimmers back before the wall: 0
## (none) to 1 (full strength, just inside the wall). Always 0 elsewhere.
func current_at(x: float, z: float) -> float:
_ensure_noise()
if _beach_w(x, z) < 0.5:
return 0.0
var from := _shore_d() + current_out
return smoothstep(from, maxf(_wall_threshold(x, z) - 3.0, from + 1.0), _rim_d(x, z))
## A point out in the bay off the beach, `past_shore` metres beyond the waterline.
func shore_point(past_shore: float) -> Vector2:
_ensure_noise()
var dir := Vector2(cos(deg_to_rad(beach_angle_deg)), sin(deg_to_rad(beach_angle_deg)))
return dir * (_shore_d() + past_shore - _rim_offset(dir.x, dir.y))
## Point on the coast along `angle_deg` (atan2(z, x) convention), at rim ## Point on the coast along `angle_deg` (atan2(z, x) convention), at rim
## distance `past_edge` beyond the cliff edge (negative = inland of it). ## distance `past_edge` beyond the cliff edge (negative = inland of it).
func coast_point(angle_deg: float, past_edge: float) -> Vector2: func coast_point(angle_deg: float, past_edge: float) -> Vector2:

451
scripts/world/tunnel.gd Normal file
View File

@@ -0,0 +1,451 @@
extends Node3D
## The cliff tunnel (J1, docs/WORLDLETS_DESIGN.md §10): the Valley's way down
## to the Cove, dug by hand.
##
## - **The seam:** a cracked outcrop on the clifftop strip near the edge.
## Only the antler pickaxe (tier 3) bites into it; once it gives, it's a
## dark opening: [E] goes down into the tunnel.
## - **The tunnel:** its own small space, out of sight beneath the island
## (the heightmap can't have holes), so going in and out is a step through
## a doorway. Six sloping stretches, each one turning back under the last
## like a stair, about 24 m down in all. The end of the dug part is a rock
## face; each stretch dug must be propped (2 wood, [E] at its mouth) before
## the next face takes a blow. Nothing caves in: it's a cost, not a trap.
## - **Dark:** no daylight reaches it (day_night.underground). Bring light:
## candles and torch stands can be set anywhere on its floor, and stay.
## - **The bottom:** the last stretch breaks into a sea cave; walking out of
## its mouth is the crossing to the Cove (world.travel()).
##
## How far it's dug, the face's damage and the props are saved with the
## Valley (to_data()); the lights are ordinary placed pieces. Single-player
## for now: on a server it isn't built (J3 brings travel online).
const TunnelFace = preload("res://scripts/world/tunnel_face.gd")
const ORIGIN := Vector3(0.0, -260.0, 0.0) ## where the tunnel space sits (well under the island)
const STRETCHES := 6
const RUN := 6.0 ## each stretch: this far along...
const DROP := 4.0 ## ...and this far down
const W := 2.6 ## passage width
const H := 3.0 ## passage height
const LAND := 3.4 ## landing length, where it turns back
const LAND_EXT := 2.0 ## how far a landing's headroom reaches into its stretches
const GAP := 1.4 ## rock between the two lanes
const CAVE_LEN := 7.0 ## the sea cave passage at the bottom
const PROP_WOOD := 2
const FACE_HP := 45.0 ## 15 blows of the antler pickaxe
const SEAM_HP := 30.0
var world: Node3D
var terrain: Node3D
var to := "cove" ## the worldlet at the bottom
# Saved state.
var opened := false ## the seam has given way
var dug := 0 ## stretches dug (0..STRETCHES)
var face_hp := FACE_HP ## damage to the face being dug (the seam's, before it opens)
var props: Array = [] ## props[k] for stretch k (1-based; [0] unused)
# Where things are (world coordinates).
var seam_at := Vector3.ZERO ## the seam, on the ground
var seam_out := Vector3.ZERO ## where you stand outside it (inland)
var top := Vector3.ZERO ## inside, at the top
var bottom := Vector3.ZERO ## inside, at the bottom by the cave
var cave_mouth := Vector3.ZERO ## the far end of the sea cave
var aabb := AABB()
var _rock_mat: StandardMaterial3D
var _face_mat: StandardMaterial3D
var _wood_mat: StandardMaterial3D
var _rock: CSGCombiner3D
var _cuts: Node3D
var _face: StaticBody3D
var _seam: Node3D
var _spots: Node3D
## Builds the tunnel for `w` if its worldlet has a way on by tunnel from
## its sea cliff. Returns false (and builds nothing) otherwise.
func setup(w: Node3D) -> bool:
world = w
terrain = w.terrain
var way: Dictionary = {}
for c in w.crossings():
if c is Dictionary and c.get("by", "") == "tunnel" and c.get("where", "") == "sea_cliff":
way = c
if way.is_empty() or not terrain._has_sea:
return false
to = String(way.to)
props.resize(STRETCHES + 1)
props.fill(false)
_make_materials()
_place_seam()
_layout()
_rebuild()
add_to_group("tunnel")
return true
# --- where it is ----------------------------------------------------------------
func _lane_z(k: int) -> float:
return 0.0 if k % 2 == 1 else W + GAP
func _landing_x(k: int) -> float:
return (k % 2) * (LAND + RUN)
func _floor_y(k: int) -> float:
return -k * DROP
## Inside the tunnel's space (with a margin), in world coordinates.
func contains(p: Vector3) -> bool:
return aabb.grow(1.0).has_point(p)
func _layout() -> void:
var x0 := -LAND * 0.5 - 3.0 - CAVE_LEN
var x1 := LAND * 1.5 + RUN + 3.0
var y0 := _floor_y(STRETCHES) - 3.0
var y1 := H + 3.0
var z0 := -W * 0.5 - 3.0
var z1 := W * 1.5 + GAP + 3.0
aabb = AABB(ORIGIN + Vector3(x0, y0, z0), Vector3(x1 - x0, y1 - y0, z1 - z0))
top = ORIGIN + Vector3(_landing_x(0) - LAND * 0.25, 0.0, _lane_z(1))
var zb := _lane_z(STRETCHES)
bottom = ORIGIN + Vector3(_landing_x(STRETCHES), _floor_y(STRETCHES), zb)
cave_mouth = bottom + Vector3(-LAND * 0.5 - CAVE_LEN + 0.6, 0.0, 0.0)
## The seam: on the clifftop strip, a few metres in from the edge, at an
## angle the seed picks.
func _place_seam() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = terrain.noise_seed + 404
for _try in range(40):
var a: float = terrain.sea_angle_deg + rng.randf_range(-18.0, 18.0)
var p: Vector2 = terrain.coast_point(a, -7.0)
if not terrain.inside_boundary(p.x, p.y, 2.0):
continue
var n: Vector3 = terrain.sample_normal(p.x, p.y)
if n.y < 0.9:
continue
seam_at = Vector3(p.x, terrain.sample_height(p.x, p.y), p.y)
break
var inland := -Vector3(seam_at.x, 0.0, seam_at.z).normalized()
seam_out = seam_at + inland * 2.2
seam_out.y = terrain.sample_height(seam_out.x, seam_out.z)
# --- digging ---------------------------------------------------------------------
## Why the face at `stage` won't take a blow ("" = it will).
func dig_refusal(stage: int) -> String:
if stage >= 2:
for k in range(1, stage):
if not props[k]:
return "It's loose: prop the last stretch first ([E] at its mouth, %d wood)" % PROP_WOOD
return ""
func face_hit(f: Node) -> void:
face_hp = f.hp
func face_broken(f: Node) -> void:
face_hp = FACE_HP
if f.stage == 0:
opened = true
Actions.notify.emit("The seam gives way: a dark passage leads down into the cliff")
else:
dug = f.stage
if dug >= STRETCHES:
Actions.notify.emit("You break through into a sea cave. You can hear the waves")
else:
Actions.notify.emit("The rock gives. Prop this stretch before you dig on")
_rebuild.call_deferred()
## [E] at the mouth of stretch `k`: sets a timber prop there.
func set_prop(player: Node, k: int) -> bool:
if k < 1 or k > dug or props[k]:
return false
if player.inventory.count("wood") < PROP_WOOD:
Actions.notify.emit("A timber prop needs %d wood" % PROP_WOOD)
return false
player.inventory.remove("wood", PROP_WOOD)
props[k] = true
Actions.notify.emit("Propped. The next face is safe to dig")
_rebuild.call_deferred()
return true
# --- going in and out ----------------------------------------------------------
func go_in(player: Node3D) -> void:
_move(player, top + Vector3(0, 0.1, 0), Vector3(1, 0, 0))
func go_out(player: Node3D) -> void:
_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:
player.global_position = at
player.velocity = Vector3.ZERO
player.rotation = Vector3(0.0, atan2(-facing.x, -facing.z), 0.0)
# --- saving ----------------------------------------------------------------------
func to_data() -> Dictionary:
var p: Array = []
for k in range(1, STRETCHES + 1):
if props[k]:
p.append(k)
return {"opened": opened, "dug": dug, "face_hp": face_hp, "props": p}
func load_data(d: Dictionary) -> void:
opened = bool(d.get("opened", false))
dug = clampi(int(d.get("dug", 0)), 0, STRETCHES)
face_hp = float(d.get("face_hp", FACE_HP))
props.fill(false)
for k in d.get("props", []):
if int(k) >= 1 and int(k) <= STRETCHES:
props[int(k)] = true
_rebuild()
# --- building it ---------------------------------------------------------------
func _make_materials() -> void:
_rock_mat = StandardMaterial3D.new()
_rock_mat.albedo_color = Color(0.40, 0.38, 0.36)
_rock_mat.roughness = 1.0
if terrain.noise_tex != null:
_rock_mat.albedo_texture = terrain.noise_tex
_rock_mat.uv1_triplanar = true
_rock_mat.uv1_scale = Vector3(0.18, 0.18, 0.18)
_face_mat = _rock_mat.duplicate()
_face_mat.albedo_color = Color(0.30, 0.27, 0.25)
_wood_mat = StandardMaterial3D.new()
_wood_mat.albedo_color = Color(0.42, 0.29, 0.17)
_wood_mat.roughness = 0.9
func _rebuild() -> void:
for n in [_rock, _seam, _spots, _face]:
if n != null and is_instance_valid(n):
n.queue_free()
_face = null
_build_rock()
_build_seam()
_spots = Node3D.new()
_spots.name = "Spots"
_spots.top_level = true
add_child(_spots)
# The face being dug: at the mouth of the next stretch (or the seam).
if opened and dug < STRETCHES:
var k := dug + 1
var mouth := _mouth(k)
_face = _make_face(k, mouth + Vector3(0, H * 0.5, 0), Vector3(0.5, H, W + 0.2))
# Props set (posts and a beam), and spots to set the missing ones.
for k in range(1, dug + 1):
var mouth := _mouth(k)
if props[k]:
_add_prop_mesh(mouth)
else:
_add_spot(mouth + Vector3(0, H * 0.5, 0), Vector3(0.6, H, W), "prop", k)
_add_spot(ORIGIN + Vector3(_landing_x(0) - LAND * 0.5 + 0.35, H * 0.5, _lane_z(1)), Vector3(0.4, H, W), "out", 0)
if dug >= STRETCHES:
_add_mouth()
## The start of stretch k, on the floor of landing k-1 (world coordinates).
func _mouth(k: int) -> Vector3:
var s := signf(_landing_x(k) - _landing_x(k - 1))
return ORIGIN + Vector3(_landing_x(k - 1) + s * LAND * 0.5, _floor_y(k - 1), _lane_z(k))
func _build_rock() -> void:
_rock = CSGCombiner3D.new()
_rock.name = "Rock"
_rock.use_collision = true
_rock.collision_layer = 1
_rock.top_level = true
add_child(_rock)
_rock.global_position = ORIGIN
var base := CSGBox3D.new()
base.size = aabb.size
base.position = aabb.get_center() - ORIGIN
base.material = _rock_mat
_rock.add_child(base)
if not opened:
return
# Landings: the top one on lane A only; the rest span both lanes.
for k in range(0, dug + 1):
var x := _landing_x(k)
var y := _floor_y(k)
var zlo := -W * 0.5 if k < STRETCHES else _lane_z(k) - W * 0.5
var zhi := W * 0.5 if k == 0 else W * 1.5 + GAP
if k == STRETCHES:
zhi = _lane_z(k) + W * 0.5
_cut(Vector3(x, y + H * 0.5, (zlo + zhi) * 0.5), Vector3(LAND, H, zhi - zlo), 0.0)
# Headroom LAND_EXT on into the stretch going down from here (in its
# lane only): a sloping cut's end is square to the slope, so on its
# own it would leave a lip of rock across the top of it at head height.
if k < dug:
var side := signf(_landing_x(k + 1) - x)
_cut(Vector3(x + side * (LAND + LAND_EXT) * 0.5, y + H * 0.5, _lane_z(k + 1)), Vector3(LAND_EXT, H, W), 0.0)
# The stretches dug: sloping boxes from one landing down to the next.
for k in range(1, dug + 1):
var a := _mouth(k) - ORIGIN
var s := signf(_landing_x(k) - _landing_x(k - 1))
var b := Vector3(_landing_x(k) - s * LAND * 0.5, _floor_y(k), _lane_z(k))
var d := b - a
var length := Vector2(d.x, d.y).length()
var ang := atan2(d.y, d.x)
var up := Vector3(-sin(ang), cos(ang), 0.0)
if up.y < 0.0:
up = -up
_cut((a + b) * 0.5 + up * H * 0.5, Vector3(length + 0.3, H, W), ang)
# The sea cave, off the bottom landing.
if dug >= STRETCHES:
var c := bottom - ORIGIN + Vector3(-LAND * 0.5 - CAVE_LEN * 0.5, 0.0, 0.0)
_cut(c + Vector3(0, H * 0.6, 0), Vector3(CAVE_LEN + 0.2, H * 1.2, W + 0.8), 0.0)
func _cut(center: Vector3, size: Vector3, z_rot: float) -> void:
var b := CSGBox3D.new()
b.operation = CSGShape3D.OPERATION_SUBTRACTION
b.size = size
b.position = center
b.rotation = Vector3(0.0, 0.0, z_rot)
b.material = _rock_mat
_rock.add_child(b)
func _make_face(stage: int, center: Vector3, size: Vector3) -> StaticBody3D:
var f: StaticBody3D = TunnelFace.new()
f.name = "Face%d" % stage
f.tunnel = self
f.stage = stage
f.uid = "tunnel_face_%d" % stage
f.max_hp = SEAM_HP if stage == 0 else FACE_HP
f.hp = clampf(face_hp, 0.5, f.max_hp)
f.top_level = true
f.collision_layer = 1
f.add_to_group("harvestable")
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = size
cs.shape = box
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)
f.global_position = center
return f
## The seam on the clifftop: an outcrop with a cracked face, then (dug) a
## dark doorway into the cliff.
func _build_seam() -> void:
_seam = Node3D.new()
_seam.name = "Seam"
_seam.top_level = true
add_child(_seam)
var inland := -Vector3(seam_at.x, 0.0, seam_at.z).normalized()
_seam.global_transform = Transform3D(Basis.looking_at(inland, Vector3.UP), seam_at)
# (local -Z faces inland, towards the valley; the outcrop is behind, +Z)
var body := StaticBody3D.new()
_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)],
[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)]]:
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = part[1]
mi.mesh = bm
mi.material_override = _rock_mat
mi.position = part[0]
_seam.add_child(mi)
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = part[1]
cs.shape = box
cs.position = part[0]
body.add_child(cs)
if not opened:
_face = _make_face(0, _seam.global_transform * Vector3(0, 1.1, -0.35), Vector3(2.0, 2.2, 0.5))
_face.global_basis = _seam.global_basis
else:
var dark := MeshInstance3D.new()
var q := QuadMesh.new()
q.size = Vector2(2.0, 2.1)
dark.mesh = q
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.02, 0.02, 0.02)
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
dark.material_override = m
dark.position = Vector3(0, 1.05, 0.78)
dark.rotation.y = PI # (a quad faces +Z: turn it to face inland)
_seam.add_child(dark)
var spot := _spot_body(Vector3(2.0, 2.1, 0.4), "in", 0)
_seam.add_child(spot)
spot.position = Vector3(0, 1.05, -0.2)
## A box you can look at and press E on (layer 2: you walk through it).
func _spot_body(size: Vector3, what: String, k: int) -> StaticBody3D:
var s := StaticBody3D.new()
s.set_script(preload("res://scripts/world/tunnel_spot.gd"))
s.tunnel = self
s.what = what
s.k = k
s.collision_layer = 2
s.collision_mask = 0
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = size
cs.shape = box
s.add_child(cs)
return s
func _add_spot(center: Vector3, size: Vector3, what: String, k: int) -> void:
var s := _spot_body(size, what, k)
_spots.add_child(s)
s.global_position = center
## Two posts and a beam across the mouth of a stretch.
func _add_prop_mesh(mouth: Vector3) -> void:
for part in [[Vector3(0, H * 0.5 - 0.1, -W * 0.5 + 0.18), Vector3(0.22, H - 0.2, 0.22)],
[Vector3(0, H * 0.5 - 0.1, W * 0.5 - 0.18), Vector3(0.22, H - 0.2, 0.22)],
[Vector3(0, H - 0.25, 0), Vector3(0.26, 0.24, W)]]:
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = part[1]
mi.mesh = bm
mi.material_override = _wood_mat
_spots.add_child(mi)
mi.global_position = mouth + part[0]
## The sea cave's mouth: grey daylight and the sound of the sea; walk into
## it to go out onto the Cove's shore.
func _add_mouth() -> void:
var glow := MeshInstance3D.new()
var q := QuadMesh.new()
q.size = Vector2(W + 0.8, H * 1.2)
glow.mesh = q
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.75, 0.82, 0.88)
m.emission_enabled = true
m.emission = Color(0.6, 0.7, 0.78)
m.emission_energy_multiplier = 1.6
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
glow.material_override = m
_spots.add_child(glow)
glow.global_position = cave_mouth + Vector3(-0.55, H * 0.6, 0.0)
glow.rotation.y = PI * 0.5
var light := OmniLight3D.new()
light.light_color = Color(0.7, 0.8, 0.9)
light.light_energy = 1.2
light.omni_range = 6.0
_spots.add_child(light)
light.global_position = cave_mouth + Vector3(0.8, H * 0.6, 0.0)
var area := Area3D.new()
area.collision_layer = 0
area.collision_mask = 1 | 2 | 4 | 8 | 16 | 32
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(1.0, H, W + 0.6)
cs.shape = box
area.add_child(cs)
_spots.add_child(area)
area.global_position = cave_mouth + Vector3(0, H * 0.5, 0)
area.body_entered.connect(func(b):
if b == world.player:
world.travel(to, "sea_cave"))
_add_spot(cave_mouth + Vector3(1.2, H * 0.5, 0), Vector3(0.4, H, W), "cave", 0)

View File

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

View File

@@ -0,0 +1,44 @@
extends StaticBody3D
## A rock face to dig through (J1): the seam on the clifftop, or the end of
## the tunnel dug so far. It answers the same questions a boulder does
## (Actions.harvest_hit), so a swing of the pick works on it, but the tunnel
## (tunnel.gd) owns the rules: which pick, whether it's propped, and what
## happens when it gives.
var tunnel: Node3D
var stage := 0 ## 0 = the seam; k = the face at the start of stretch k
var uid := ""
var tool_kind := "pickaxe"
var min_tier := 3 ## the antler pickaxe
var max_hp := 45.0
var hp := 45.0
var depleted := false
var kind := "rock"
var chip_color := Color(0.42, 0.40, 0.38)
func display_name() -> String:
return "Cracked seam" if stage == 0 else "Rock face"
## Why the next blow won't land ("" = it will), before the tool check.
func refusal() -> String:
return tunnel.dig_refusal(stage)
func interact_prompt(_player: Node) -> String:
var why := refusal()
var left := ceili(hp / 3.0)
if not why.is_empty():
return "%s: %s" % [display_name(), why]
return "%s: [LMB] dig with the antler pickaxe (about %d blows left)" % [display_name(), left]
func interact(player: Node) -> void:
Actions.tell(player, interact_prompt(player))
## One blow (from Actions.harvest_hit). Returns what it knocks loose.
func hit(damage: float, _at: Vector3, _from: Vector3) -> Dictionary:
hp = maxf(hp - damage, 0.0)
tunnel.face_hit(self)
if hp <= 0.0 and not depleted:
depleted = true
tunnel.face_broken(self)
return {"stone": 3}
return {"stone": 1} if randf() < 0.4 else {}

View File

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

View File

@@ -0,0 +1,35 @@
extends StaticBody3D
## Something to press E on in or by the cliff tunnel (tunnel.gd): the
## seam's doorway ("in"), the way out at the top ("out"), the mouth of a
## stretch that needs propping ("prop", stretch k), and the sea cave's
## mouth ("cave"). On the interactables layer, so you walk through it.
var tunnel: Node3D
var what := ""
var k := 0
func interact_prompt(player: Node) -> String:
match what:
"in":
return "[E] Go down into the tunnel"
"out":
return "[E] Climb out onto the clifftop"
"prop":
var have: int = player.inventory.count("wood")
return "Unpropped: [E] Set a timber prop (%d wood, you have %d)" % [tunnel.PROP_WOOD, have]
"cave":
return "The sea cave: walk on out to the %s" % Terrain.worldlet_name(tunnel.to)
return ""
func interact(player: Node) -> void:
match what:
"in":
tunnel.go_in(player)
"out":
tunnel.go_out(player)
"prop":
tunnel.set_prop(player, k)
"cave":
tunnel.world.travel(tunnel.to, "sea_cave")
const Terrain = preload("res://scripts/world/terrain.gd")

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