13 Commits

Author SHA1 Message Date
Paul Bailey
2b7e52cd82 0.4.0: map (N) that fills in as you explore and a compass (closes #1); Old Tam's seam and Cove tips (voiced); the journey and sea fish in the guide; trailer with the tunnel and the Cove; protocol 16 2026-11-06 18:36:20 +00:00
Paul Bailey
980ee53579 J1: the cliff tunnel (seam, six propped stretches, dark, lights), travel Valley <-> Cove through the sea cave, a save file per worldlet; New World has no Where (--dev-worldlets to try others) 2026-11-06 17:51:58 +00:00
Paul Bailey
793b214786 W3: the Cove (bay, pond, pier for sea fishing), mackerel and cod (cod only in deep water), non-start worldlets offered for testing when run from source 2026-11-06 17:08:42 +00:00
Paul Bailey
9b88a10609 W2: several lakes per worldlet, a beach block shelving into the sea with a current that turns swimmers back; testbay test worldlet through the water, edge and boundary suites; the two ways to play (journey, one land) recorded 2026-11-06 16:48:20 +00:00
Paul Bailey
68e6d814d9 W1: choose a worldlet (New World and Host Game pages, kept once an island exists), worldlet shown in the Load list; Godot .uid files 2026-11-06 16:10:32 +00:00
Paul Bailey
db4a659236 W0: terrain from worldlet building blocks (Valley in data/worldlets, identical heights), WorldInfo API, worldlet in saves and servers; the cliff tunnel plan 2026-11-06 15:51:43 +00:00
Paul Bailey
f4df2bc99d Journey: one boat each, no passengers; tunnels, ropes and burrows shared 2026-11-06 14:30:51 +00:00
Paul Bailey
5584f74a8b Journey: multiplayer spread out (decided) 2026-11-06 14:17:56 +00:00
Paul Bailey
77517be778 Journey decisions: extendable ending, four crossings, biome hazards; server cost measured 2026-11-06 14:16:07 +00:00
Paul Bailey
40f5e25c41 Version 0.3.1 2026-11-06 14:05:24 +00:00
Paul Bailey
eaeb841c1d Lights to place indoors (tallow candle, torch stand); docs brought up to date; the journey plan; sticky teleports in net tests; protocol 15 2026-11-06 13:56:43 +00:00
Paul Bailey
5f40fa3f7b Version 0.3.0 2026-11-06 11:58:27 +00:00
Paul Bailey
4b2e687188 Uses for every material (clay jar, bedroll, pelt rug, fang necklace, bear cloak, pemmican, bait), Q to drop, K/M bag rows on 1-8, help pages; protocol 14 2026-11-06 11:49:42 +00:00
177 changed files with 5205 additions and 160 deletions

View File

@@ -16,7 +16,24 @@ 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.
Current milestone: **M0 — playable single-player world skeleton** (scaffolding on disk; needs Godot 4 installed to open & run). **Emberwild 0.4.0** is released (download page and player guide:
https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
- P1-P5 (`docs/PROGRESSION_DESIGN.md`): iron, stone building, the wolf moon and
alpha, the bear, crows and foxes, the barrow, hazards, the Old Stag. P6: a use
for every material, lights to place, dropping items, bag rows on the hotbar.
- T1, T2, T4 (`docs/TUTORIAL_DESIGN.md`): the tutorial with Old Tam (spoken),
and his tips in normal worlds.
- M4 so far: packaged builds, the update check, music, ambience, sound effects
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages,
the map (N) and compass.
- W0-W3, J1 (`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

@@ -347,7 +347,7 @@
"name": "Hide", "name": "Hide",
"category": "resource", "category": "resource",
"stack": 20, "stack": 20,
"description": "Raw hide from a deer or boar. Makes a warm cloak.", "description": "Rawhide from an animal. For cloaks and bedrolls.",
"mesh": "hide" "mesh": "hide"
}, },
{ {
@@ -355,7 +355,7 @@
"name": "Feather", "name": "Feather",
"category": "resource", "category": "resource",
"stack": 50, "stack": 50,
"description": "Found in meadows. Fletching for arrows.", "description": "Fletching for arrows, and stuffing for a bedroll.",
"mesh": "feather" "mesh": "feather"
}, },
{ {
@@ -443,7 +443,7 @@
"name": "Wolf Pelt", "name": "Wolf Pelt",
"category": "resource", "category": "resource",
"stack": 20, "stack": 20,
"description": "Thick grey fur. The warmest thing on the island.", "description": "A grey wolf pelt. For wolf and alpha cloaks, or a rug.",
"mesh": "wolf_pelt" "mesh": "wolf_pelt"
}, },
{ {
@@ -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",
@@ -564,7 +630,7 @@
"name": "Clay", "name": "Clay",
"category": "resource", "category": "resource",
"stack": 50, "stack": 50,
"description": "Grey clay from the lake shore. For kilns and smelters.", "description": "Grey clay from the lake shore. For kilns, smelters and, fired at a campfire, clay jars that keep food fresh.",
"mesh": "clay" "mesh": "clay"
}, },
{ {
@@ -928,7 +994,7 @@
"name": "Alpha Pelt", "name": "Alpha Pelt",
"category": "resource", "category": "resource",
"stack": 5, "stack": 5,
"description": "The alpha's black fur, thick as a blanket.", "description": "The alpha's dark pelt. For the alpha cloak or a fang necklace.",
"mesh": "alpha_pelt" "mesh": "alpha_pelt"
}, },
{ {
@@ -936,7 +1002,7 @@
"name": "Wolf Fang", "name": "Wolf Fang",
"category": "resource", "category": "resource",
"stack": 20, "stack": 20,
"description": "A long yellowed fang from the alpha. A trophy, and a fine clasp.", "description": "A long yellowed fang from the alpha. For the alpha cloak, and a fang necklace that blunts wolf bites.",
"mesh": "wolf_fang" "mesh": "wolf_fang"
}, },
{ {
@@ -956,7 +1022,7 @@
"name": "Bear Hide", "name": "Bear Hide",
"category": "resource", "category": "resource",
"stack": 5, "stack": 5,
"description": "A huge brown hide. Makes a fur rug.", "description": "A huge brown hide. For a fur rug or a bear cloak, the warmest there is.",
"mesh": "bear_hide" "mesh": "bear_hide"
}, },
{ {
@@ -964,7 +1030,7 @@
"name": "Bear Fat", "name": "Bear Fat",
"category": "resource", "category": "resource",
"stack": 20, "stack": 20,
"description": "Rendered fat. Burns three times as long as wood in a fire.", "description": "Thick white fat. Burns long on a fire, and makes pemmican and tallow candles.",
"mesh": "bear_fat", "mesh": "bear_fat",
"fuel": 180 "fuel": 180
}, },
@@ -996,7 +1062,7 @@
"name": "Rotten Food", "name": "Rotten Food",
"category": "resource", "category": "resource",
"stack": 50, "stack": 50,
"description": "It's gone off. Good for nothing: throw it out.", "description": "It's gone off. Q drops it. Carried while fishing it's bait: bites come sooner, and each bite takes one.",
"mesh": "rotten_food" "mesh": "rotten_food"
}, },
{ {
@@ -1129,6 +1195,148 @@
}, },
"station": "forge" "station": "forge"
} }
},
{
"id": "clay_jar",
"name": "Clay Jar",
"category": "placeable",
"stack": 5,
"description": "Fired clay with a lid. Holds 6 stacks, and food kept in it lasts three times as long.",
"mesh": "clay_jar",
"place": {
"piece": "jar",
"footprint": [
0.7,
0.7
]
}
},
{
"id": "bedroll",
"name": "Bedroll",
"category": "placeable",
"stack": 1,
"description": "Hide stuffed with feathers. Sets your spawn point like a bed, and you can sleep the night out under the open sky (but not in rain or snow).",
"mesh": "bedroll",
"place": {
"piece": "bed",
"footprint": [
0.9,
2.0
],
"open_air": true
}
},
{
"id": "pelt_rug",
"name": "Wolf-pelt Rug",
"category": "placeable",
"stack": 1,
"description": "Wolf pelts stitched into a rug. In a sheltered room: a little warmer, and you get Rested twice as fast.",
"mesh": "pelt_rug",
"place": {
"piece": "rug",
"footprint": [
1.4,
1.8
]
}
},
{
"id": "fang_necklace",
"name": "Fang Necklace",
"category": "tool",
"stack": 1,
"description": "The alpha's fangs on a strip of its hide. Carry it anywhere in your bag and wolves, the alpha too, bite 30% less hard.",
"mesh": "fang_necklace",
"charm": {
"against": "wolf",
"damage": 0.7
}
},
{
"id": "bear_cloak",
"name": "Bear Cloak",
"category": "tool",
"stack": 1,
"description": "Right-click to wear it (+40 warmth, rain barely touches you): the warmest cloak there is. Take it off from the inventory screen.",
"mesh": "bear_cloak",
"wear": {
"warmth": 40,
"wet_chill": 0.3
}
},
{
"id": "pemmican",
"name": "Pemmican",
"category": "food",
"stack": 20,
"description": "Cooked meat and berries pounded into bear fat. Filling, counts as meat and greens, and keeps 30 days.",
"mesh": "pemmican",
"keeps": 30,
"food": {
"hunger": 40,
"health": 10,
"diet": {
"meat": 28,
"plants": 28
}
}
},
{
"id": "tallow_candle",
"name": "Tallow Candle",
"category": "placeable",
"stack": 20,
"description": "Bear fat and a fibre wick. Set it on a floor or a step and it lights a small room, steadily, for good.",
"mesh": "tallow_candle",
"place": {
"piece": "lamp",
"footprint": [
0.2,
0.2
]
},
"lamp": {
"range": 7.0,
"energy": 1.2,
"height": 0.2,
"flicker": 0.08,
"flame": 0.022,
"color": [
1.0,
0.82,
0.6
]
}
},
{
"id": "torch_stand",
"name": "Torch Stand",
"category": "placeable",
"stack": 5,
"description": "A torch on a wooden stand. Lights a whole room or a doorway, and never goes out.",
"mesh": "torch_stand",
"place": {
"piece": "lamp",
"footprint": [
0.5,
0.5
]
},
"lamp": {
"range": 11.0,
"energy": 1.6,
"height": 1.45,
"flicker": 0.22,
"flame": 0.06,
"color": [
1.0,
0.74,
0.5
],
"shadows": true
}
} }
] ]
} }

View File

@@ -249,6 +249,26 @@
"trout": 1 "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",
@@ -589,6 +631,98 @@
"iron_bar": 1, "iron_bar": 1,
"wood": 2 "wood": 2
} }
},
{
"id": "clay_jar",
"output": "clay_jar",
"count": 1,
"station": "campfire",
"category": "furniture",
"inputs": {
"clay": 8
}
},
{
"id": "bedroll",
"output": "bedroll",
"count": 1,
"station": "workbench",
"category": "furniture",
"inputs": {
"hide": 3,
"feather": 6,
"fiber": 4
}
},
{
"id": "pelt_rug",
"output": "pelt_rug",
"count": 1,
"station": "workbench",
"category": "furniture",
"inputs": {
"wolf_pelt": 3,
"fiber": 2
}
},
{
"id": "fang_necklace",
"output": "fang_necklace",
"count": 1,
"station": "workbench",
"category": "clothing",
"inputs": {
"wolf_fang": 2,
"alpha_pelt": 1,
"fiber": 2
}
},
{
"id": "bear_cloak",
"output": "bear_cloak",
"count": 1,
"station": "workbench",
"category": "clothing",
"inputs": {
"bear_hide": 2,
"hide": 2,
"fiber": 4
}
},
{
"id": "pemmican",
"output": "pemmican",
"count": 3,
"station": "campfire",
"category": "cooking",
"inputs": {
"cooked_meat": 2,
"berries": 3,
"bear_fat": 1
}
},
{
"id": "tallow_candle",
"output": "tallow_candle",
"count": 4,
"station": "",
"category": "furniture",
"inputs": {
"bear_fat": 1,
"fiber": 1
}
},
{
"id": "torch_stand",
"output": "torch_stand",
"count": 1,
"station": "",
"category": "furniture",
"inputs": {
"wood": 2,
"stick": 2,
"fiber": 2
}
} }
] ]
} }

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

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

View File

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

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
@@ -164,3 +255,197 @@ its edge or animals that spawn in the sea.
wolves sooner, or keep the same grace period everywhere? wolves sooner, or keep the same grace period everywhere?
4. **Which second worldlet first:** Cove (sea fishing, the beach you 4. **Which second worldlet first:** Cove (sea fishing, the beach you
mentioned) is my suggestion. mentioned) is my suggestion.
## 10. The journey: tunnelling out (proposal, Nov 2026)
A goal for the game, from Paul: once you have a good enough pick, you tunnel
through the mountain rim into the next, slightly harder worldlet, and now and
then go back to earlier ones for what only they have. This is option B
(linked worldlets) from §2, reached by digging rather than a path.
### The shape of it
- **A chain of worldlets**, each a little harder: Valley (today's island) →
Cove (sea, salt, mackerel) → Highlands (cold, deep snow, bigger packs) →
Marsh (bog iron, sickness, mists) → the Ember, the volcanic heart the game
is named for: the end of the journey.
- **The goal**, something to tell players on day 1: Old Tam talks of the
land beyond the mountains, where the fire in the earth still burns. Reach
the Ember and light the old beacon there. (Whether there's an ending or
it's open-ended is a question below.)
- **Gates:** each tunnel needs the best tool from the worldlet you're in.
The antler pickaxe (from the Old Stag) opens the Valley's tunnel; the
Highlands need a pick made from something found in the Cove, and so on.
Each worldlet has its own boss or trial, like the Old Stag.
### A different crossing each time (Paul, Nov 2026)
Players have to work out how to get from each worldlet to the next, and
it's a different way every time: tunnelling, climbing, burrowing, sailing.
Each crossing is that worldlet's puzzle, and what it needs comes from what
you've learned and gathered there:
| From → to | Crossing | Needs (a first idea) |
|---|---|---|
| Valley → Cove | **Tunnel** through the rim at a seam | the antler pickaxe, timber props, light |
| Cove → Highlands | **Sail** out of the bay and round the headland | a boat (planks, nails, a hide sail, rope), calm weather, food that keeps |
| Highlands → Marsh | **Climb** a frozen cliff | climbing picks and rope, warmth to last the climb (bear cloak, pemmican) |
| Marsh → Ember | **Burrow** under the old lava crust | a digging tool from bog iron, and a way through the heat and gas |
- **The hint, not the answer:** each worldlet shows its way out without
explaining it: a seam in the rock with old pick marks, a wrecked boat on
the beach, rope frayed on a cliff ledge. Old Tam (and his tips) nudge,
and the guide can keep it vague.
- **Coming back** uses the same crossing in reverse (the tunnel stays dug,
the boat stays moored, the rope stays fixed), so supply runs get easier
once you've made the way.
- **New systems this needs, one per crossing:** digging into a rim seam
(tunnel and burrow share most of it), a boat you build and steer on the
water, and climbing a marked face. Each is a phase of its own (J1 tunnel;
later the sail, the climb, the burrow).
### Tunnelling (the first crossing)
- **Where:** each worldlet's rim has one or two **seams**: cracked, darker
rock faces with a few loose stones at the foot, findable by exploring
(and Old Tam's tips). The rim stays a wall everywhere else.
- **How:** hit a seam with a good enough pick, many times. Each stretch dug
needs **timber props** (wood) or it falls back in, and it's dark in there:
candles, torch stands or a lantern. The tunnel is a short, built-for-it
cave (a few bends, maybe a side chamber with ore or a hazard), so it
stays hand-made rather than a hole through the terrain mesh, which the
heightmap can't do.
- **Through:** the far end opens onto the next worldlet. Walking out loads
it (a short fade, "The Cove"), with your bag, cloak and stats as they are.
### The first tunnel: down the sea cliff to the Cove
The Valley's sea side ends at a sheer cliff, its top about level with the
valley and the sea some 25 m below. The Cove is down there, so the first
tunnel goes **down**, inside the cliff, from the clifftop to the shore.
- **The seam:** a cracked outcrop on the clifftop strip near the edge (the
sea_cliff block marks where), with old pick marks and loose stones at its
foot. It can't be dug until you have the antler pickaxe (tier 3); a lesser
pick just chips it ("Too hard: you need a better pick").
- **Digging down:** the tunnel is about six stretches, each a few metres on
and a few down: a sloping passage that turns back on itself like a
stair, so the 25 m drop fits inside the cliff. A stretch takes many pick
blows (stamina, and wear on the pick) to open.
- **Props:** each new stretch needs a **timber prop** (2 wood) set at its
mouth before you can dig the next. Leave one out and the face won't take
another blow ("It's loose: prop it first"); nothing caves in on you, so
it's a cost, not a trap. That's 10 wood all the way down.
- **Light:** the tunnel has no daylight at all. Every stretch is pitch dark
until you bring light: a torch in your hand works, but your hands are full
with the pick, so the way to do it is to set **candles and torch stands**
as you go (tallow candles from bear fat, torch stands from wood). Placed
lights stay, so the way is lit for the trip back and for friends.
- **The bottom:** the last stretch breaks out into a sea cave, its mouth
opening on the Cove's shingle beach. Walking out of the mouth is the
crossing: a fade, then the Cove, arriving at the cave's Cove end. The way
back starts there and climbs the same tunnel.
- **What's saved:** the tunnel belongs to the Valley's save: how far it's
dug, the props, the lights. On a server it's shared: anyone can dig on
from where someone stopped, and the lights are everyone's.
- **How it's built:** the tunnel is its own small space (a "between" scene
next to the worldlet), made from a kit of rock pieces (straight, turn,
slope down, the cave mouth) laid out from the seed, with box-shaped
colliders. The heightmap can't have holes or overhangs, so the passage is
never cut into the terrain itself: you walk into the seam and you're in
the tunnel scene.
### Why go back
- **Each worldlet has things the others lack.** Clay and salt only in the
Valley and Cove; little wood in the Highlands; no bears outside the
Valley (so bear fat for candles and pemmican is a supply run); a fish or
herb only one place has. Food that keeps (pemmican, salted and smoked
meat, clay jars) becomes what you carry between them.
- **Your bases stay** where you built them: each worldlet keeps its own
save file, so your Valley hut, chests and jars are waiting.
- **Time:** a worldlet you're not in stands still (as a server does with
nobody on), so a base you left doesn't starve or get raided.
### Building it (phases)
| Phase | Delivers | Done when |
|---|---|---|
| **W0-W2** | the worldlet groundwork above (terrain pipeline, worldlet in the save, several water bodies) | as §8 |
| **J1** | seams in the rim, digging with props and light, a tunnel scene, travel between two worldlets in single-player, per-worldlet save files | dig through from the Valley, walk to the Cove, walk back to your hut |
| **W3** | the Cove (§5) as the second worldlet | its suites pass, and its tunnel back works |
| **J2** | gates and supply needs: tool tiers per tunnel, resources only some worldlets have | a test bot can't open the second tunnel without the Cove's pick |
| **J3** | multiplayer travel (see below) | two players in different worldlets on one server, or the rule we choose |
| **J4** | Highlands, Marsh, the Ember and its ending | the journey can be finished |
### Multiplayer: the hard part
A server today runs one island. Options:
- **Everyone together:** travel needs every player at the tunnel (like the
sleep rule). Simplest; one worldlet runs at a time.
- **Spread out:** the server runs each worldlet that has someone in it.
The most freedom, and the most work and CPU.
- **Party leader:** whoever goes through brings the others with a vote.
### Decisions (Paul, Nov 2026)
- **Extendable, not finished:** the Ember has a last boss, and after it you
keep playing. The chain is built so more worldlets (and crossings) can be
added after the Ember later: each worldlet names the next, nothing assumes
the Ember is the end.
- **The crossings:** tunnel, sail, climb, burrow, in that order.
- **Harder by biome, not just by numbers:** each worldlet has its own,
stronger animals and its own hazards. The Marsh, for one: mosquitoes whose
bites can make you sick, and bog, mist and the like. Others in the same
spirit per biome (to design with each worldlet).
- **Multiplayer: spread out.** Each player goes where they like; the server
runs every worldlet someone is in, and the rest stand still. Built in J3,
after single-player travel works. What that means for the code:
- one server process holds several worldlet scenes, each with its own
clock, weather, creatures and save file;
- every player belongs to one worldlet at a time, and the server only
sends them that worldlet's world, creatures, sounds and chat-free events
(chat and the player list stay server-wide);
- a crossing moves a player from one worldlet to another (save their
place, load or wake the other worldlet, send them its world);
- sleep: "everyone in bed" counts the players in that worldlet only;
- the Old Stag and other bosses scale with the players in their worldlet.
- **Boats: one each, no passengers.** A boat belongs to whoever built it
(like a bed): only the owner, and their friends under the server's locks
rule, can sail it, and nobody else can dismantle it. It stays moored where
it was last left (the Cove end or the Highlands end), so nobody is
stranded by someone else taking the only boat. Lose or wreck it and you
build another, which is harder where wood is scarce (the Highlands), so
plan the trip back. Each player has to earn their own crossing by boat.
- **The other crossings are shared:** a dug tunnel, a fixed climbing rope
and a dug burrow are changes to the world, there for everyone once one
player has made them (like a door or a bridge).
- **Two ways to play (Paul, Nov 2026).**
- **The journey** (the standard game), single-player and multiplayer
alike: every new world starts in the Valley and the worldlets come in
their fixed order (Valley, Cove, Highlands, Marsh, Ember). Each
worldlet's shape is set by its definition; the seed only changes the
detail (hills, trees, where the den is), so the journey is the same
adventure for everyone.
- **One land** (a hosting choice): no journey, no crossings, the server
is locked to one worldlet, and the host picks which, from all of them.
Co-op or PvP as now (the rules preset). In code: a `mode` in
`server.json` ("journey" / "one_land"); in one-land mode the Host
page's "Where" lists every worldlet rather than only the starts, and
the crossings (`world.crossings()`) answer none, so a tunnel seam
can't be dug. Kept, like the seed and the worldlet, once the island
exists. Worth building once the Cove exists (until then there's only
the Valley to pick).
### Server cost, measured (0.3.1)
One worldlet, a dedicated server on the M5 Pro, never pausing: about
**12% of one CPU core and 270 MB of memory**, with nobody on. Players add
creatures, weather and pickups around them. So with *spread out*, the worst
case of 8 players in all 5 worldlets is about five of today's servers in
one process: roughly 1.4 GB and a little over half a core before players,
and worldlets with nobody in them stand still and cost next to nothing.
Comfortable on the home Mac; a small cloud server (2 GB) would be tight.
### (Answered) questions
1. **The ending:** a real ending at the Ember (light the beacon, a last
boss, the credits) or open-ended (the journey is the goal, keep living)?
2. **The crossings:** do the four above fit (tunnel, sail, climb, burrow),
and in that order? Should a worldlet have more than one way out (a hard
one and a long one)? And for the tunnel: only at marked seams, or
anywhere in the rim (more freedom, harder to make good tunnels)?
3. **Multiplayer:** everyone together, spread out, or a party vote?
4. **How much harder** each worldlet: stronger animals and weather only, or
new rules too (thirst in the Ember, sickness in the Marsh)?
5. **Order:** Cove second (sea fishing, gentle) as planned, or straight
into the Highlands for a harder step?

View File

@@ -6,7 +6,7 @@ runnable=true
dedicated_server=false 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.2.1" application/short_version="0.4.0"
application/version="0.2.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.2.1" application/file_version="0.4.0"
application/product_version="0.2.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.2.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 · 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

@@ -2,8 +2,15 @@ extends "res://scripts/building/piece.gd"
## Bed: E sets your spawn point here. Once it's yours, E at night sleeps until ## Bed: E sets your spawn point here. Once it's yours, E at night sleeps until
## morning, if the bed is under a roof with walls around it. ## morning, if the bed is under a roof with walls around it.
func _build() -> void:
add_to_group("bed") # (a bed or a bedroll: either is a spawn point)
func display_name() -> String: func display_name() -> String:
return "Bed" return String(def.get("name", "Bed"))
## A bedroll: you can sleep in the open (world.sleep_block_reason).
func open_air() -> bool:
return bool(def.get("place", {}).get("open_air", false))
func is_spawn() -> bool: func is_spawn() -> bool:
var w := get_parent().get_parent() var w := get_parent().get_parent()
@@ -11,8 +18,8 @@ func is_spawn() -> bool:
func interact_prompt(_player: Node) -> String: func interact_prompt(_player: Node) -> String:
if not is_spawn(): if not is_spawn():
return "Bed [E] Set spawn point" return "%s [E] Set spawn point" % display_name()
return "Bed (your spawn point) [E] Sleep" return "%s (your spawn point) [E] Sleep" % display_name()
func interact(player: Node) -> void: func interact(player: Node) -> void:
Actions.use_bed(player, self) Actions.use_bed(player, self)

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

29
scripts/building/jar.gd Normal file
View File

@@ -0,0 +1,29 @@
extends "res://scripts/building/piece.gd"
## Clay jar: 6 slots of storage, opened with E like a chest, and food kept in
## it lasts three times as long (Inventory.keep_mult).
const SLOTS := 6
const KEEP := 3.0
var inventory := Inventory.new(SLOTS)
func _build() -> void:
inventory.keep_mult = KEEP
func display_name() -> String:
return "Clay Jar"
func dismantle_block_reason() -> String:
for s in inventory.slots:
if s != null:
return "Empty the jar first"
return ""
func interact_prompt(_player: Node) -> String:
var used := 0
for s in inventory.slots:
if s != null:
used += 1
return "Clay jar (%d/%d, food keeps 3x as long) [E] Open" % [used, SLOTS]
func interact(player: Node) -> void:
player.open_inventory(self)

View File

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

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

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

View File

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

View File

@@ -23,6 +23,8 @@ const RECIPES := {
{"inputs": {"raw_meat": 2, "wood": 1}, "output": "smoked_meat", "count": 2, "seconds": 60.0}, {"inputs": {"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

@@ -31,6 +31,10 @@ func _ready() -> void:
_bind("inventory", [KEY_TAB, KEY_I]) _bind("inventory", [KEY_TAB, KEY_I])
_bind("rotate_piece", [KEY_R]) _bind("rotate_piece", [KEY_R])
_bind("dismantle", [KEY_X]) _bind("dismantle", [KEY_X])
_bind("drop", [KEY_Q]) # (shift+Q drops just one)
_bind("row_up", [KEY_K]) # the 1-8 keys pick from the bag row above...
_bind("row_down", [KEY_M]) # ...or below
_bind("map", [KEY_N]) # the map (Gitea issue #1)
for i in range(8): 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)
@@ -254,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
@@ -736,8 +748,53 @@ static func harvest_sound(tool_kind: String, felled: bool) -> String:
## A piece going down: wood, stone, or something soft. ## A piece going down: wood, stone, or something soft.
static func place_sound(id: String) -> String: static func place_sound(id: String) -> String:
if id.contains("stone") or id in ["campfire", "forge", "hearth", "stonecutter"]: if id.contains("stone") or id.contains("jar") or id in ["campfire", "forge", "hearth", "stonecutter"]:
return "place_stone" return "place_stone"
if id.contains("rug") or id.contains("bed") or id == "smoking_rack": if id.contains("rug") or id.contains("bed") or id == "smoking_rack":
return "place_soft" return "place_soft"
return "place_wood" return "place_wood"
# --- charms and bait ------------------------------------------------------------------
## A charm carried anywhere in the bag (the fang necklace: {"against": "wolf",
## "damage": 0.7}) takes the edge off hits from what it's against. `by` is the
## hit's "a wolf" / "a alpha wolf" / "a spirit wolf" (survival.damage).
static func charm_mult(inv: Inventory, by: String) -> float:
var m := 1.0
if by.is_empty():
return m
for s in inv.slots:
if s == null:
continue
var c: Dictionary = ItemDB.get_item(String(s.id)).get("charm", {})
if not c.is_empty() and by.to_lower().contains(String(c.get("against", "?"))):
m = minf(m, float(c.get("damage", 1.0)))
return m
const BAIT := "rotten_food"
const BAIT_WAIT := 0.6 ## carried while fishing: bites come this much sooner
## Drops the stack in `slot` (or `count` of it) at your feet, a little in
## front (Q drops what's in your hand). Online the server does it, for everyone.
func drop(player: Node3D, slot: int, count: int = 0) -> bool:
if _online():
return _ask("drop", [slot, count])
var s = player.inventory.get_slot(slot)
if s == null:
return false
var n: int = int(s.count) if count <= 0 else mini(count, int(s.count))
# (on a server the player is a proxy under PlayerNet, not a child of the world)
var world: Node = WorldNet.world if Net.is_server() else player.get_parent()
if world == null or not world.has_method("drop_item"):
return false
var fwd: Vector3 = -player.global_basis.z
fwd.y = 0.0
var at: Vector3 = player.global_position + fwd.normalized() * 0.9
at.y = world.terrain.sample_height(at.x, at.z) + 0.05 if world.get("terrain") != null else player.global_position.y
var extra := Inventory.extra_of(s)
if world.drop_item(String(s.id), n, Transform3D(Basis(), at), extra) == null:
return false
player.inventory.take_from(slot, n)
Sfx.at("pickup", at)
notify.emit("Dropped %d %s" % [n, ItemDB.item_name(String(s.id))])
return true

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

@@ -65,6 +65,10 @@ static func extra_of(stack: Dictionary) -> Dictionary:
static var clock := 0.0 static var clock := 0.0
static var day_seconds := 1200.0 static var day_seconds := 1200.0
static var rot_speed := 1.0 static var rot_speed := 1.0
## How many times longer food keeps in here (a clay jar: 3), and the clock at
## the last spoil() (the time since is what's slowed).
var keep_mult := 1.0
var _last_clock := -1.0
const ROTTEN := "rotten_food" const ROTTEN := "rotten_food"
static func _db() -> Node: static func _db() -> Node:
@@ -110,6 +114,15 @@ func spoil() -> int:
return 0 return 0
var rotted := 0 var rotted := 0
var touched := false var touched := false
# A clay jar (keep_mult 3): food in it ages at a third of the rate, so its
# "rot" stamp moves back by two thirds of the time that's passed.
if keep_mult > 1.0:
if _last_clock >= 0.0 and clock > _last_clock:
var later := (clock - _last_clock) * (1.0 - 1.0 / keep_mult)
for s in slots:
if s != null and s.has("rot") and float(s.rot) > _last_clock:
s.rot = float(s.rot) + later
_last_clock = clock
for i in range(slots.size()): for i in range(slots.size()):
var s = slots[i] var s = slots[i]
if s == null or keeps_days(String(s.id)) <= 0.0: if s == null or keeps_days(String(s.id)) <= 0.0:

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
@@ -74,6 +75,8 @@ func _dawn_dusk() -> bool:
return (h > 5.0 and h < 8.5) or (h > 17.0 and h < 20.5) return (h > 5.0 and h < 8.5) or (h > 17.0 and h < 20.5)
## How long until the next bite (seconds), from data/fish.json. ## How long until the next bite (seconds), from data/fish.json.
var baited := false ## this wait was shortened by bait (rotten food)
func _next_wait() -> float: func _next_wait() -> float:
var mods: Dictionary = ItemDB.fishing.modifiers var mods: Dictionary = ItemDB.fishing.modifiers
var w: Array = table.get("bite_wait", [5.0, 14.0]) var w: Array = table.get("bite_wait", [5.0, 14.0])
@@ -82,11 +85,16 @@ func _next_wait() -> float:
k *= float(mods.get("dawn_dusk_wait", 0.6)) # fish feed at dawn and dusk k *= float(mods.get("dawn_dusk_wait", 0.6)) # fish feed at dawn and dusk
if world.weather.precip > 0.3: if world.weather.precip > 0.3:
k *= float(mods.get("rain_wait", 0.8)) k *= float(mods.get("rain_wait", 0.8))
# Rotten food in your bag is bait: the fish come sooner, and a bite takes one.
baited = player != null and player.get("inventory") != null and player.inventory.count(Actions.BAIT) > 0
if baited:
k *= Actions.BAIT_WAIT
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:
@@ -114,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"
@@ -139,6 +148,8 @@ func step(dt: float) -> void:
bite_left = BITE_WINDOW bite_left = BITE_WINDOW
Actions.tell(player, "Bite! Click to reel in") Actions.tell(player, "Bite! Click to reel in")
Sfx.at("bite", global_position) Sfx.at("bite", global_position)
if baited and player.inventory.remove(Actions.BAIT, 1):
baited = false
"biting": "biting":
bite_left -= dt bite_left -= dt
# The float bobs hard and goes under. # The float bobs hard and goes under.

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()
@@ -115,6 +119,14 @@ static func build(key: String) -> ArrayMesh:
"bear_hide": m = _pelt(BEAR_FUR) "bear_hide": m = _pelt(BEAR_FUR)
"bear_fat": m = _pebble(271, Color(0.92, 0.86, 0.68), Vector3(0.1, 0.06, 0.08), 0.1) "bear_fat": m = _pebble(271, Color(0.92, 0.86, 0.68), Vector3(0.1, 0.06, 0.08), 0.1)
"fur_rug": m = _rug() "fur_rug": m = _rug()
"pelt_rug": m = _rug(WOLF_FUR, 0.85)
"bear_cloak": m = _cloak(BEAR_FUR)
"clay_jar": m = _jar()
"tallow_candle": m = _candle()
"torch_stand": m = _torch_stand()
"bedroll": m = _bedroll()
"fang_necklace": m = _necklace()
"pemmican": m = _pebble(331, Color(0.42, 0.24, 0.16), Vector3(0.1, 0.05, 0.07), 0.15)
"barrow_key": m = _barrow_key() "barrow_key": m = _barrow_key()
"rotten_food": m = _pebble(321, Color(0.33, 0.3, 0.16), Vector3(0.11, 0.06, 0.1), 0.45) "rotten_food": m = _pebble(321, Color(0.33, 0.3, 0.16), Vector3(0.11, 0.06, 0.1), 0.45)
"salt": m = _salt() "salt": m = _salt()
@@ -686,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)
@@ -999,19 +1013,81 @@ static func _fang() -> ArrayMesh:
const BEAR_FUR := Color(0.36, 0.24, 0.15) const BEAR_FUR := Color(0.36, 0.24, 0.15)
## A bearskin laid flat: body, four splayed legs and the head toward -Z. ## A bearskin laid flat: body, four splayed legs and the head toward -Z.
static func _rug() -> ArrayMesh: ## A skin rug with its head at -Z: the bear's (fur_rug), or wolves' a bit smaller.
static func _rug(fur: Color = BEAR_FUR, k: float = 1.0) -> ArrayMesh:
var st := P._begin() var st := P._begin()
var r := _rng(273) var r := _rng(273)
_box(st, Vector3(0, 0.015, 0.1), Vector3(1.0, 0.03, 1.5), BEAR_FUR) _box(st, Vector3(0, 0.015, 0.1) * k, Vector3(1.0, 0.03 / k, 1.5) * k, fur)
_box(st, Vector3(0, 0.02, 0.1), Vector3(0.8, 0.03, 1.3), P._mul(BEAR_FUR, 1.12)) _box(st, Vector3(0, 0.02, 0.1) * k, Vector3(0.8, 0.03 / k, 1.3) * k, P._mul(fur, 1.12))
for sx in [-1.0, 1.0]: for sx in [-1.0, 1.0]:
for sz in [-0.45, 0.6]: for sz in [-0.45, 0.6]:
_box(st, Vector3(sx * 0.62, 0.012, sz), Vector3(0.42, 0.024, 0.24), _board(r, BEAR_FUR), Basis(Vector3.UP, sx * (0.5 if sz < 0 else -0.5))) _box(st, Vector3(sx * 0.62, 0.012, sz) * k, Vector3(0.42, 0.024 / k, 0.24) * k, _board(r, fur), Basis(Vector3.UP, sx * (0.5 if sz < 0 else -0.5)))
_box(st, Vector3(0, 0.05, -0.85), Vector3(0.38, 0.1, 0.34), P._mul(BEAR_FUR, 0.95)) _box(st, Vector3(0, 0.05, -0.85) * k, Vector3(0.38, 0.1, 0.34) * k, P._mul(fur, 0.95))
_box(st, Vector3(0, 0.045, -1.06), Vector3(0.18, 0.08, 0.14), Color(0.58, 0.45, 0.32)) _box(st, Vector3(0, 0.045, -1.06) * k, Vector3(0.18, 0.08, 0.14) * k, Color(0.58, 0.45, 0.32))
for sx in [-0.14, 0.14]: for sx in [-0.14, 0.14]:
_box(st, Vector3(sx, 0.11, -0.8), Vector3(0.08, 0.05, 0.05), P._mul(BEAR_FUR, 0.8)) _box(st, Vector3(sx, 0.11, -0.8) * k, Vector3(0.08, 0.05, 0.05) * k, P._mul(fur, 0.8))
_box(st, Vector3(0, 0.02, 0.92), Vector3(0.12, 0.03, 0.12), BEAR_FUR) _box(st, Vector3(0, 0.02, 0.92) * k, Vector3(0.12, 0.03, 0.12) * k, fur)
return P._commit(st)
## A fired clay jar with a wooden lid, about knee high.
static func _jar() -> ArrayMesh:
var st := P._begin()
var r := _rng(337)
P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 0.12, 0), 0.16, 0.26, 10, P._mul(CLAY_FIRED, 0.92), r, -1.0, CLAY_FIRED)
P._cylinder(st, Vector3(0, 0.12, 0), Vector3(0, 0.42, 0), 0.26, 0.24, 10, CLAY_FIRED, r)
P._cylinder(st, Vector3(0, 0.42, 0), Vector3(0, 0.52, 0), 0.24, 0.16, 10, P._mul(CLAY_FIRED, 1.06), r)
P._cylinder(st, Vector3(0, 0.52, 0), Vector3(0, 0.56, 0), 0.17, 0.17, 10, P._mul(CLAY_FIRED, 0.85), r)
P._cylinder(st, Vector3(0, 0.56, 0), Vector3(0, 0.6, 0), 0.19, 0.17, 10, PLANK_DARK, r, -1.0, PLANK) # lid
_box(st, Vector3(0, 0.63, 0), Vector3(0.08, 0.04, 0.05), PLANK_DARK)
return P._commit(st)
## A squat tallow candle on a clay dish; the wick's top is at y = 0.2
## (lamp.gd puts the flame there).
static func _candle() -> ArrayMesh:
var st := P._begin()
var r := _rng(359)
P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 0.02, 0), 0.08, 0.09, 8, CLAY_FIRED, r, -1.0, P._mul(CLAY_FIRED, 1.1))
P._cylinder(st, Vector3(0, 0.02, 0), Vector3(0, 0.16, 0), 0.04, 0.036, 8, Color(0.93, 0.89, 0.74), r, -1.0, Color(0.96, 0.93, 0.8))
P._cylinder(st, Vector3(0, 0.16, 0), Vector3(0, 0.19, 0), 0.004, 0.003, 4, ASH, r)
return P._commit(st)
## A torch in a wooden stand: three splayed feet, a post, the head at 1.4 m
## (lamp.gd's flame sits just above it).
static func _torch_stand() -> ArrayMesh:
var st := P._begin()
var r := _rng(367)
for i in range(3):
var a := TAU * i / 3.0
P._cylinder(st, Vector3(cos(a) * 0.28, 0.0, sin(a) * 0.28), Vector3(0, 0.45, 0), 0.03, 0.035, 5, _board(r, PLANK_DARK), r)
P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 1.3, 0), 0.04, 0.035, 6, _board(r), r)
_box(st, Vector3(0, 0.5, 0), Vector3(0.12, 0.05, 0.12), BINDING)
P._cylinder(st, Vector3(0, 1.28, 0), Vector3(0, 1.42, 0), 0.05, 0.06, 6, CLOTH, r, -1.0, ASH)
return P._commit(st)
## A hide bedroll: a flat mat with its rolled-up end and a feather pillow.
static func _bedroll() -> ArrayMesh:
var st := P._begin()
var r := _rng(347)
_box(st, Vector3(0, 0.03, 0.15), Vector3(0.8, 0.06, 1.6), HIDE)
_box(st, Vector3(0, 0.065, 0.2), Vector3(0.66, 0.02, 1.35), P._mul(HIDE, 1.12))
P._cylinder(st, Vector3(-0.4, 0.13, 0.95), Vector3(0.4, 0.13, 0.95), 0.12, 0.12, 8, _board(r, HIDE), r, -1.0, P._mul(HIDE, 0.8))
_box(st, Vector3(0, 0.1, -0.5), Vector3(0.5, 0.09, 0.28), FEATHER)
for sx in [-0.25, 0.25]:
_box(st, Vector3(sx, 0.18, 0.95), Vector3(0.04, 0.26, 0.04), BINDING)
return P._commit(st)
## A cord with five fangs hanging from a dark scrap of pelt.
static func _necklace() -> ArrayMesh:
var st := P._begin()
var r := _rng(353)
for i in range(12):
var a := TAU * i / 12.0
var b := TAU * (i + 1) / 12.0
P._cylinder(st, Vector3(cos(a) * 0.12, 0.01, sin(a) * 0.1), Vector3(cos(b) * 0.12, 0.01, sin(b) * 0.1), 0.006, 0.006, 4, BINDING, r)
_box(st, Vector3(0, 0.015, 0.1), Vector3(0.09, 0.02, 0.05), ALPHA_FUR)
for i in range(5):
var x := (i - 2) * 0.035
P._cylinder(st, Vector3(x, 0.012, 0.12), Vector3(x * 1.2, 0.012, 0.19 - absf(i - 2) * 0.012), 0.012, 0.002, 5, Color(0.88, 0.82, 0.62), r)
return P._commit(st) return P._commit(st)
# --- P3 the barrow -------------------------------------------------------------------- # --- P3 the barrow --------------------------------------------------------------------

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 := 13 ## 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) 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

@@ -341,7 +341,7 @@ func _run(p: Node3D, what: String, a: Array) -> void:
WorldNet.piece_state(f) WorldNet.piece_state(f)
"bed": "bed":
var b := WorldNet.piece(_i(a, 0)) var b := WorldNet.piece(_i(a, 0))
if b != null and b.item_id == "bed" and _allowed(Rules.use_problem(p.player_id, b)): if b != null and b.is_in_group("bed") and _allowed(Rules.use_problem(p.player_id, b)):
Actions.use_bed(p, b) Actions.use_bed(p, b)
"inv_click": "inv_click":
# Slot `a[1]` of your own inventory (a[0] = 0) or a chest's. # Slot `a[1]` of your own inventory (a[0] = 0) or a chest's.
@@ -365,6 +365,10 @@ func _run(p: Node3D, what: String, a: Array) -> void:
var slot := _i(a, 1) var slot := _i(a, 1)
if slot >= 0 and slot < from.size(): if slot >= 0 and slot < from.size():
Actions.transfer(p, from, slot, to, c) Actions.transfer(p, from, slot, to, c)
"drop":
var ds := _i(a, 0)
if ds >= 0 and ds < p.inventory.size():
Actions.drop(p, ds, _i(a, 1))
"close": "close":
# The inventory screen closed: what's on the cursor goes back in # The inventory screen closed: what's on the cursor goes back in
# your bag, or at your feet if it's full. # your bag, or at your feet if it's full.

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

@@ -63,7 +63,7 @@ func held_item_id() -> String:
return s.id if s != null else "" return s.id if s != null else ""
func select(i: int) -> void: func select(i: int) -> void:
selected = wrapi(i, 0, HOTBAR) selected = wrapi(i, 0, inventory.size()) # (any bag slot: player.gd's rows)
selected_changed.emit() selected_changed.emit()
func held_heat() -> float: func held_heat() -> float:
@@ -85,7 +85,7 @@ func bed_piece(world: Node3D) -> Node3D:
if not has_bed_spawn: if not has_bed_spawn:
return null return null
for p in world.all_pieces(): for p in world.all_pieces():
if p.item_id == "bed" and claims_bed(p): if p.is_in_group("bed") and claims_bed(p):
return p return p
return null return null

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

@@ -237,13 +237,22 @@ func _unhandled_input(event: InputEvent) -> void:
return return
for i in range(8): for i in range(8):
if event.is_action_pressed("hotbar_%d" % (i + 1)): if event.is_action_pressed("hotbar_%d" % (i + 1)):
player.select(i) player.select_in_row(i)
return return
if event.is_action_pressed("row_up"):
player.change_row(-1)
return
if event.is_action_pressed("row_down"):
player.change_row(1)
return
if event.is_action_pressed("drop"):
Actions.drop(player, player.selected, 1 if Input.is_key_pressed(KEY_SHIFT) else 0)
return
if event is InputEventMouseButton and event.pressed and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: if event is InputEventMouseButton and event.pressed and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
if event.button_index == MOUSE_BUTTON_WHEEL_UP: if event.button_index == MOUSE_BUTTON_WHEEL_UP:
player.select(player.selected - 1) player.select_in_row(player.selected % player.HOTBAR - 1)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
player.select(player.selected + 1) player.select_in_row(player.selected % player.HOTBAR + 1)
if event.is_action_released("attack") and _drawing: if event.is_action_released("attack") and _drawing:
_release_bow() _release_bow()
return return

View File

@@ -49,10 +49,26 @@ func held_item_id() -> String:
var s = inventory.get_slot(selected) var s = inventory.get_slot(selected)
return s.id if s != null else "" return s.id if s != null else ""
## Holds the item in bag slot `i` (any slot: 1-8 pick from the current row,
## K and M move between rows of 8).
func select(i: int) -> void: func select(i: int) -> void:
selected = wrapi(i, 0, HOTBAR) selected = wrapi(i, 0, inventory.size())
selected_changed.emit() selected_changed.emit()
## The bag row (0 = the top) the 1-8 keys and the hotbar show.
func row() -> int:
return selected / HOTBAR
func rows() -> int:
return inventory.size() / HOTBAR
## Column `col` of the current row; and the same column a row up or down.
func select_in_row(col: int) -> void:
select(row() * HOTBAR + wrapi(col, 0, HOTBAR))
func change_row(by: int) -> void:
select(wrapi(row() + by, 0, rows()) * HOTBAR + selected % HOTBAR)
## Called by workbenches and chests (container = null for plain crafting). ## Called by workbenches and chests (container = null for plain crafting).
func open_inventory(container: Node) -> void: func open_inventory(container: Node) -> void:
inventory_requested.emit(container) inventory_requested.emit(container)

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.
@@ -274,6 +275,8 @@ func reset_after_death() -> void:
func damage(amount: float, from: Vector3, by: String = "") -> void: func damage(amount: float, from: Vector3, by: String = "") -> void:
if dead: if dead:
return return
if player != null and player.get("inventory") != null:
amount *= Actions.charm_mult(player.inventory, by) # (a fang necklace against wolves)
cause = "Killed by %s" % by if not by.is_empty() else "You were killed" cause = "Killed by %s" % by if not by.is_empty() else "You were killed"
health = maxf(health - amount, 0.0) health = maxf(health - amount, 0.0)
_hurt_cd = HURT_NO_REGEN _hurt_cd = HURT_NO_REGEN

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

@@ -12,6 +12,7 @@ var player: CharacterBody3D
var _prompt: Label var _prompt: Label
var _held_name: Label var _held_name: Label
var _hotbar: Array = [] var _hotbar: Array = []
var _row_label: Label
var _toasts: VBoxContainer var _toasts: VBoxContainer
var _screen: Control var _screen: Control
var _menu: Control var _menu: Control
@@ -20,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:
@@ -66,6 +69,16 @@ func bind(p: CharacterBody3D) -> void:
bar.add_child(s) bar.add_child(s)
_hotbar.append(s) _hotbar.append(s)
root.add_child(bar) root.add_child(bar)
# Which bag row the hotbar is showing (K / M change it), beside the bar.
_row_label = UITheme.label("", 14, UITheme.TEXT_DIM)
_row_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM)
_row_label.grow_horizontal = Control.GROW_DIRECTION_BOTH
_row_label.grow_vertical = Control.GROW_DIRECTION_BEGIN
_row_label.offset_left = 300
_row_label.offset_right = 420
_row_label.offset_bottom = -40
_row_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(_row_label)
_held_name = UITheme.label("", 15, UITheme.TEXT_DIM) _held_name = UITheme.label("", 15, UITheme.TEXT_DIM)
_held_name.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM) _held_name.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM)
@@ -185,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()
@@ -376,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()
@@ -387,14 +411,23 @@ 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()
func _refresh() -> void: func _refresh() -> void:
var first: int = player.row() * _hotbar.size()
for i in range(_hotbar.size()): for i in range(_hotbar.size()):
_hotbar[i].set_stack(player.inventory.get_slot(i)) _hotbar[i].set_stack(player.inventory.get_slot(first + i))
_hotbar[i].set_selected(i == player.selected) _hotbar[i].set_selected(first + i == player.selected)
_row_label.text = "Row %d of %d\nK / M" % [player.row() + 1, player.rows()]
var id: String = player.held_item_id() var id: String = player.held_item_id()
_held_name.text = ItemDB.item_name(id) if not id.is_empty() else "" _held_name.text = ItemDB.item_name(id) if not id.is_empty() else ""

View File

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

View File

@@ -74,7 +74,7 @@ func bind(p: CharacterBody3D) -> void:
if i == 7: if i == 7:
pass pass
pv.add_child(grid) pv.add_child(grid)
pv.add_child(UITheme.label("Row 1 is your hotbar. Shift-click moves stacks to/from a chest.", 13, UITheme.TEXT_DIM)) pv.add_child(UITheme.label("1-8 pick from the row you're on; K / M change row. Shift-click moves stacks to/from a chest. Q drops what you hold.", 13, UITheme.TEXT_DIM))
var wr := HBoxContainer.new() var wr := HBoxContainer.new()
wr.add_theme_constant_override("separation", 10) wr.add_theme_constant_override("separation", 10)
_worn_label = UITheme.label("", 15) _worn_label = UITheme.label("", 15)

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

@@ -13,7 +13,9 @@ const CONTROLS := [
["Left click (fishing rod)", "Cast / reel in when the float dips"], ["Left click (fishing rod)", "Cast / reel in when the float dips"],
["R", "Rotate piece (building pieces: quarter turns)"], ["X", "Dismantle the piece you're looking at"], ["R", "Rotate piece (building pieces: quarter turns)"], ["X", "Dismantle the piece you're looking at"],
["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"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"], ["1 - 8, wheel", "Hotbar (the bag row you're on)"], ["K / M", "Hotbar shows the bag row above / below"],
["Q (shift+Q)", "Drop what you're holding (just one)"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"],
["N", "Map (fills in as you explore; the compass is along the top)"],
["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"], ["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)

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