39 Commits

Author SHA1 Message Date
Paul Bailey
2a9d47630e Trailer: encode standard-range yuv420p so Safari and QuickTime play it 2026-11-05 19:37:24 +00:00
Paul Bailey
1d0008d34b Trailer tool (Movie Maker + ffmpeg) and a static download page built per release 2026-11-05 19:31:33 +00:00
Paul Bailey
608c66ea54 Update feed: the repo is now Admin/Emberwild 2026-11-05 19:15:05 +00:00
Paul Bailey
6ab72f2c05 Update check: version feed from the git server's releases, title-screen banner, clearer version refusals 2026-11-05 16:56:11 +00:00
Paul Bailey
a8eaf0d12e Packaged builds: macOS (universal, ad-hoc signed) and Windows, tools/export.sh 2026-11-05 16:40:47 +00:00
Paul Bailey
bd7d10a829 T4: Old Tam's tips in normal worlds, with a setting 2026-11-05 16:22:38 +00:00
Paul Bailey
73d27f1dcb Tutorial design: T3 on hold until worldlets 2026-11-05 15:08:58 +00:00
Paul Bailey
dbcc6f6e59 Tutorial: Old Tam's hut outline beside the fire, pieces snap into it 2026-11-05 15:01:59 +00:00
Paul Bailey
f93ebe1bea T2 tutorial: all five chapters with Old Tam, scripted dusk and demo wolf, rabbits for the hunt, title-screen Tutorial entry, pause-menu skip/leave, goodbye card; full bot playthrough test 2026-11-05 14:43:00 +00:00
Paul Bailey
5b5dd3d609 T1 tutorial: Actions events, the Guide autoload, chapter 1 with Old Tam the hermit, guide panel and marker, saved progress; roadmap: auto-update plan 2026-11-05 14:14:32 +00:00
Paul Bailey
e03411ffdb P5 the Old Stag: standing stones and altar, three-phase boss with charges, stun window, spirit wolves and stomps; antler pickaxe and trophy; group scaling and credit for every fighter; online 3-player test (protocol 12) 2026-11-05 13:39:06 +00:00
Paul Bailey
3389ec9a21 P4 hazards: food spoilage with rot speed setting, smoking rack and salt, food poisoning, harsher blizzards, hard winters with lake ice, lightning on trees, snow on roofs (protocol 11) 2026-11-05 11:49:13 +00:00
Paul Bailey
657c3140fe Plan: P4/P5 decisions (lightning hits trees only, server-set rot speed, Old Stag in, antler pickaxe stats) 2026-11-05 09:29:55 +00:00
Paul Bailey
9528f91e47 P3 thieves and barrow: crows and foxes steal food left out; the barrow with its guards, coffin loot and key pieces; blunt club vs bone (protocol 10) 2026-11-05 09:23:31 +00:00
Paul Bailey
96aac29606 P3 bear: one bear per island raids unattended food, palisades and closed huts keep it out, warns then charges, hide/fat/meat, fur rug (Cozy), death saved (protocol 8) 2026-11-05 07:51:07 +00:00
Paul Bailey
fc58c8d0e1 P3 alpha: wolf moon every 7th night, the alpha breaks wooden doors, iron-banded door, broken-door repair, alpha pelt and cloak (protocol 7) 2026-11-05 01:40:52 +00:00
Paul Bailey
411bfef34a P2 stone: stonecutter, cut stone, stone floors/walls/pillars/stairs/windows/doorway, hearth; wooden stairs, windows, posts, palisade and gate; stone support rules 2026-11-05 01:14:07 +00:00
Paul Bailey
24c280d885 P1 iron: nuggets, ore, clay, kiln, smelter, forge, iron tools and arrows, lantern, tool wear and repair 2026-11-05 00:31:07 +00:00
Paul Bailey
ab1f417c32 M2.7: Host Game, backups, launchd/systemd samples, hosting and Tailscale guide 2026-11-04 23:19:00 +00:00
Paul Bailey
2ab79b68c6 M2.6: server rules (PvP, raiding, locks and friends, death penalty, difficulty, wolf grace), chat, admin commands, server console 2026-11-04 22:54:40 +00:00
Paul Bailey
47fc116839 Crafting tabs (tools, weapons, cooking, building...); panel fits at 1280x720; steadier smoothness test 2026-11-04 22:01:54 +00:00
Paul Bailey
a211fe4c63 M2.5: wildlife, wolves, hunting, fishing and sleeping on the server 2026-11-04 21:25:10 +00:00
Paul Bailey
26be17b05f Progression plan: iron, stone building, new adversaries (docs/PROGRESSION_DESIGN.md) 2026-11-04 20:50:42 +00:00
Paul Bailey
35a656e996 M2.4: the server decides online (characters, inventory, stats, death, owned graves, per-player saves); death screen with Respawn after 3 s 2026-11-04 19:18:31 +00:00
Paul Bailey
8e8f10eb96 Tutorial world plan (docs/TUTORIAL_DESIGN.md), roadmap updated 2026-11-04 18:17:26 +00:00
Paul Bailey
c87b241b29 play.sh to start the game; Godot checks shared with run_server.sh 2026-11-04 18:12:56 +00:00
Paul Bailey
80786ab796 Wild flax: fibre plants in meadows and by the lake (2 fibre each) 2026-11-04 18:12:56 +00:00
Paul Bailey
0c18b6d49e M2.3: the shared world (trees, pickups, pieces, chests, fires, doors, graves, clock on the server) 2026-11-04 18:12:49 +00:00
Paul Bailey
74cbf96458 Wolf den search steps into the valley when the rim is steep everywhere 2026-11-04 16:02:40 +00:00
Paul Bailey
d489bb57ed Rename the game to Emberwild (data copied from the old folder) 2026-11-04 15:45:10 +00:00
Paul Bailey
ae2592822e Server launcher and start-up checks for other machines
- server/run_server.sh: checks Godot is 4.7+, imports a fresh copy once
  (the class cache in .godot/ isn't in git), then starts the server
- The dedicated server refuses to start, with a clear reason, on Godot
  older than 4.7 or when the game's scripts failed to load, instead of
  claiming to listen
- tests/run_all.sh imports a fresh copy first
- Setup doc: 4.7+ (not 4.3+), importing a fresh copy, running a server
2026-11-04 15:34:28 +00:00
Paul Bailey
d0f473e57b M2.2: player bodies, name tags, shared island and checked movement
- Joining loads the server's island (seed, spawn, clock); the server
  generates the same terrain from its seed and keeps a world clock that
  pauses when empty
- Other players are procedural low-poly characters in their chosen colours
  with name tags (hidden by walls, fading with distance), walk/run/swim
  animation and held items
- Movement: local and instant, sent at 20 Hz stamped with the sender's
  clock; the server refuses impossible moves (speed, rising, leaving the
  island, underground) and sends the player back; flagged respawns allowed
- Avatars drawn 100 ms behind and interpolated, with brief extrapolation
- Online: no pausing, player list in the Esc menu, Disconnect back to the
  title (with the reason); animals off and world changes local-only until
  M2.3-M2.5, shown on the HUD
- test_net_world: real server + walking and cheating bots at 100 ms +/-
  30 ms simulated latency (27 checks)
2026-11-04 15:08:14 +00:00
Paul Bailey
f62f71658f M2.1: dedicated server, joining, identity handshake, names, lobby
- Net autoload: headless dedicated server (--server) on UDP 24242 via ENet,
  server.json preferences (flags override per run, never saved), reserved
  names in names.json
- Identity: RSA-2048 key per install (Godot has no Ed25519), player id =
  key hash, challenge/response so ids can't be faked; profile with name
  and limited appearance (tunic, hair, beard)
- Server refuses bans, allowlist misses, duplicate logins, bad or taken
  names, wrong passwords, old protocol versions and a full server, each
  with a clear reason; first player on a new server becomes admin
- Title screen: Profile, Join Game, and a lobby with server info, rules
  and who's online
- test_net_lobby: real headless server + bot processes over UDP (38
  checks); --exit-with so test servers never outlive the test
2026-11-04 13:40:27 +00:00
Paul Bailey
89683e78fd M2 multiplayer plan: hosting, identity and names, server rules, phases
Decisions: UDP 24242, limited appearance in v1, global chat only, pause
when empty, first player to join becomes admin (for now).
2026-11-04 13:08:14 +00:00
Paul Bailey
4630ea5d44 Fish per water type in data/fish.json; worldlets design
- data/fish.json: fish, weights, dawn/dusk odds, bite timing and minimum
  depth per water type; ItemDB.define_water() registers them
- world.water_at(x, z) answers lake / sea / dry land; the float uses it,
  so a worldlet with a sea only needs a "sea" entry
- Clear messages for shallow water and water with nothing to catch
- Lake fish: more of them, bigger, nearer the shore, jumping with splash rings
- docs/WORLDLETS_DESIGN.md: proposal for data-defined world types
2026-11-04 11:41:04 +00:00
Paul Bailey
f174221268 Wolves with a den, fishing, and a mixed diet
Wolves
- Den: a boulder cave at the foot of the rim, sited from the seed (away
  from the sea and spawn, mouth toward the valley, clearing in front)
- Grace period: no wolves days 1-2; they move in on day 3 and howl; hunting
  from night 4; pack of 2, 3 from day 6, 4 from day 9; replaced by day
- Wary pack hunters: circle and dart in one at a time; a lit campfire is a
  safe zone, a torch makes them hesitate, walls block bites (line of
  sight); hit one and the pack turns; hurt wolves flee; a lone wolf is
  timid; by day they defend the den; home at dawn
- Glowing eyes, synthesised howls, can't sleep with wolves prowling
- Wolf pelt and Wolf Cloak (+25 warmth)
- Animals take a committed detour around obstacles

Fishing
- Fishing rod (workbench); cast a float onto the lake, 1.5 s to land a bite
- Perch and rarer trout; faster bites at dawn/dusk and in rain; grill them
- Visible fish near the shore that jump with splash rings

Mixed diet
- Foods belong to meat, fish or greens; each level runs down over ~1.5 days
- One group low: no healing; two low: Malnourished (health drains);
  all three good: Balanced diet (heal x1.5); HUD diet bars; saved

Tests: test_wolves and test_fishing_diet; run_all.sh covers 9 suites
2026-11-04 11:32:16 +00:00
Paul Bailey
67bf85523d M1d: creatures, hunting, weapons, food buffs and the hide cloak
- Rabbits, deer and boars with procedural meshes and animated legs, a
  state machine (idle/wander/flee/charge/dead) and heightmap steering that
  avoids water, cliffs, the world edge, trees and buildings
- Spawner keeps a habitat-based budget around the player (6/4/3)
- Melee needs a weapon or tool (club, spear, axe, pickaxe); box sweep hits
- Wooden spear (thrust or throw) and crude bow with draw-scaled arrows;
  projectiles stick in the world as saved pickups
- Carcasses: E to butcher for meat, hide and embedded arrows
- Boars charge and gore when hit; hurt flash and a brief healing pause
- Cooked meat gives Well fed (faster stamina and healing); buffs saved
- Hide cloak: wear for warmth, saved, goes in the grave on death
- Feathers as meadow pickups (appended, existing save ids unchanged)
- New test_creatures_combat suite; run_all.sh gets a per-suite time limit
2026-11-04 10:55:44 +00:00
Paul Bailey
fed96ef118 M1c: weather, snap building, shelter, beds and regrowth
- Seeded weather (clear/cloudy/rain/storm, snow and blizzards on cold days
  and above the snowline), lightning, synthesised rain/wind/thunder sound
- Rain/snow particles stopped by roofs; wet ground, settled snow and wind
  via global shader uniforms
- Snap-together wood floor, wall, door, roof and gable with structural
  support, collapse and X to dismantle
- Shelter detection: dry under a roof, warmer and Rested inside with a fire;
  rain burns an uncovered campfire faster
- Bed sets the spawn point and lets you sleep through the night
- Trees, boulders and ground pickups regrow; timers saved
- Grass cleared under floors; shorter HUD hint
- New test_weather_building suite (62 checks) and tests/run_all.sh, which
  also fails on script errors
2026-11-04 10:16:41 +00:00
Paul Bailey
fc7eb3cfed M1b: survival stats, day/night, fire fuel, cooking, foraging, torches, death + grave
- Health, stamina, hunger, warmth and wet with HUD bars, status tags and clock
- 20-minute day/night cycle with moon, stars and lit clouds
- Campfire fuel (2 min start, 15 min max), heat radius, cooking needs a lit fire
- Berry bushes (regrow 10 min), mushrooms, roasted mushroom, torch
- Stamina costs for sprint, jump and harvest hits
- Death respawns at island start and leaves a grave bag
- Saves include stats, time of day, fire fuel, regrowth timers and graves
- New test_survival_stats suite; tests fail if a script error aborts them
2026-11-04 01:19:41 +00:00
143 changed files with 28598 additions and 226 deletions

2
.gitignore vendored
View File

@@ -3,7 +3,7 @@
.import/
*.import
export.cfg
export_presets.cfg
# (export_presets.cfg is kept: signing passwords live in .godot/export_credentials.cfg)
# Exported builds
/build/

View File

@@ -34,6 +34,34 @@ godot --path .
godot --headless --path . -- --server
```
## Packaged builds
```bash
tools/export.sh # build/Emberwild-<version>-macos.zip and -windows.zip
tools/export.sh macOS # or just one ("macOS" / "Windows")
```
Needs the Godot export templates for your Godot version (Editor > Manage
Export Templates). The version number is `application/short_version` in
`export_presets.cfg`.
- **Mac:** a universal .app, ad-hoc signed but not notarised. On another Mac,
the first launch needs right-click > Open (or System Settings > Privacy &
Security > Open Anyway).
- **Windows:** unzip and run `Emberwild.exe` (keep `Emberwild.pck` beside
it). It's unsigned, so SmartScreen says "Windows protected your PC":
More info > Run anyway.
- A packaged copy is also a dedicated server:
`Emberwild.app/Contents/MacOS/Emberwild --headless -- --server` (Mac) or
`Emberwild.exe --headless -- --server` (Windows). Saves and settings live in
the same user folder as a copy run from source.
## Trailer and download page
```bash
tools/trailer/make_trailer.sh # build/Emberwild-trailer.mp4: recorded in-game (Movie Maker), needs ffmpeg
tools/site/make_site.sh # build/site/: index.html + poster.jpg + trailer.mp4 for the current release
```
The page's download links point at the Gitea release named in
`build/version.json`, so rebuild it after each `tools/export.sh` release.
Gitea doesn't serve HTML pages: copy `build/site/` to any static web host.
## Layout
```
project.godot Godot project definition

29
data/fish.json Normal file
View File

@@ -0,0 +1,29 @@
{
"_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.",
"modifiers": {
"dawn_dusk_wait": 0.6,
"rain_wait": 0.8,
"missed_wait": 1.3
},
"waters": {
"lake": {
"min_depth": 0.5,
"bite_wait": [
5.0,
14.0
],
"fish": [
{
"id": "perch",
"weight": 78,
"dawn_dusk": 60
},
{
"id": "trout",
"weight": 22,
"dawn_dusk": 40
}
]
}
}
}

38
data/hints.json Normal file
View File

@@ -0,0 +1,38 @@
{
"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).",
"hints": [
{"id": "died", "when": "event:died", "title": "Back on your feet",
"text": "Dying's not the end out here. Whatever you carried is waiting where you fell, so go back for it before anything else. Sleep in a bed once and that's where you'll wake."},
{"id": "freezing", "when": "status:Freezing", "title": "Freezing",
"text": "You're freezing, and that eats at your health. Get to a fire or under a roof, now. A cloak helps too."},
{"id": "cold", "when": "status:Cold", "hold": 3, "title": "Getting cold",
"text": "Feel that chill? A fire warms you, a roof and walls keep the wind off, and a hide cloak buys you time."},
{"id": "sick", "when": "status:Food poisoning", "title": "Bad food",
"text": "Raw meat or something past its best: your belly's turned. It passes. Rest, don't eat for a bit, and cook your meat next time."},
{"id": "rotten", "when": "has:rotten_food", "title": "Food goes off",
"text": "Food doesn't keep forever. Eat the fresh stuff first, smoke meat and fish on a rack, or salt it. Rotten food's no good to anyone: throw it out."},
{"id": "wet", "when": "status:Wet", "hold": 2, "title": "Soaked",
"text": "Wet clothes steal your warmth. Stand under a roof or by a fire and you'll dry off."},
{"id": "hungry", "when": "status:Hungry", "hold": 2, "title": "Hungry",
"text": "Your belly's empty. Berries and mushrooms tide you over, but cooked meat and fish fill you up for longer."},
{"id": "diet", "when": "status:Poor diet|Malnourished", "hold": 2, "title": "Mix it up",
"text": "Eat the same thing every day and your body notices. Watch the three diet bars: meat, fish and greens. Keep them all topped up and you'll be stronger for it."},
{"id": "howl", "when": "event:howl", "title": "Wolves",
"text": "Hear that? Wolves have moved into the hills. They'll come hunting soon after dark. Walls, a door and a fire are your friends."},
{"id": "wolf_moon", "when": "wolf_moon", "title": "The red moon",
"text": "A red moon. The pack's bolder tonight and the big one leads them. Stay behind a good door, or stand by a fire with a weapon ready."},
{"id": "night", "when": "night", "hold": 5, "title": "Nightfall",
"text": "Dark and cold till morning. Keep near a fire or carry a torch. A bed under a roof lets you sleep through it."},
{"id": "hard_winter", "when": "hard_winter", "hold": 3, "title": "Hard winter",
"text": "A hard winter's come in: a few days of bitter cold, and blizzards that freeze you even by day. Stock wood and food, and stay close to home."},
{"id": "snow", "when": "weather:snow|blizzard", "hold": 3, "title": "Snow",
"text": "Snow's falling. It's colder out in it, and heavy snow piles up on roofs. Keep your fire fed."},
{"id": "storm", "when": "weather:storm", "hold": 3, "title": "Storm",
"text": "A storm. Lightning goes for the trees, so don't shelter under one. Your buildings are safe enough."},
{"id": "stamina", "when": "stamina_low", "title": "Out of breath",
"text": "You're spent. Stop swinging and running a moment and your stamina comes back. Rested by a fire, it comes back faster."},
{"id": "sheltered", "when": "status:Sheltered", "hold": 3, "title": "A proper shelter",
"text": "Roof and walls: the weather can't touch you here. Sit by a fire in here a little while and you'll be Rested."}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,594 @@
{
"recipes": [
{ "id": "stone_axe", "output": "stone_axe", "count": 1, "station": "",
"inputs": { "stick": 2, "flint": 2 } },
{ "id": "stone_pickaxe", "output": "stone_pickaxe", "count": 1, "station": "",
"inputs": { "stick": 2, "stone": 3, "flint": 1 } },
{ "id": "campfire", "output": "campfire", "count": 1, "station": "",
"inputs": { "stone": 5, "stick": 4 } },
{ "id": "workbench", "output": "workbench", "count": 1, "station": "",
"inputs": { "wood": 10, "flint": 2 } },
{ "id": "club", "output": "club", "count": 1, "station": "",
"inputs": { "wood": 4 } },
{ "id": "chest", "output": "chest", "count": 1, "station": "workbench",
"inputs": { "wood": 10, "stick": 4 } }
{
"id": "stone_axe",
"output": "stone_axe",
"count": 1,
"station": "",
"category": "tools",
"inputs": {
"stick": 2,
"flint": 2
}
},
{
"id": "stone_pickaxe",
"output": "stone_pickaxe",
"count": 1,
"station": "",
"category": "tools",
"inputs": {
"stick": 2,
"stone": 3,
"flint": 1
}
},
{
"id": "campfire",
"output": "campfire",
"count": 1,
"station": "",
"category": "stations",
"inputs": {
"stone": 5,
"stick": 4
}
},
{
"id": "workbench",
"output": "workbench",
"count": 1,
"station": "",
"category": "stations",
"inputs": {
"wood": 10,
"flint": 2
}
},
{
"id": "club",
"output": "club",
"count": 1,
"station": "",
"category": "weapons",
"inputs": {
"wood": 4
}
},
{
"id": "chest",
"output": "chest",
"count": 1,
"station": "workbench",
"category": "furniture",
"inputs": {
"wood": 10,
"stick": 4
}
},
{
"id": "torch",
"output": "torch",
"count": 1,
"station": "",
"category": "tools",
"inputs": {
"stick": 1,
"fiber": 2
}
},
{
"id": "roasted_mushroom",
"output": "roasted_mushroom",
"count": 1,
"station": "campfire",
"category": "cooking",
"inputs": {
"mushroom": 1
}
},
{
"id": "wood_floor",
"output": "wood_floor",
"count": 1,
"station": "workbench",
"category": "building",
"inputs": {
"wood": 2
}
},
{
"id": "wood_wall",
"output": "wood_wall",
"count": 1,
"station": "workbench",
"category": "building",
"inputs": {
"wood": 2
}
},
{
"id": "wood_door",
"output": "wood_door",
"count": 1,
"station": "workbench",
"category": "building",
"inputs": {
"wood": 4
}
},
{
"id": "wood_roof",
"output": "wood_roof",
"count": 1,
"station": "workbench",
"category": "building",
"inputs": {
"wood": 2,
"fiber": 1
}
},
{
"id": "wood_gable",
"output": "wood_gable",
"count": 1,
"station": "workbench",
"category": "building",
"inputs": {
"wood": 1
}
},
{
"id": "bed",
"output": "bed",
"count": 1,
"station": "workbench",
"category": "furniture",
"inputs": {
"wood": 8,
"fiber": 6
}
},
{
"id": "cooked_meat",
"output": "cooked_meat",
"count": 1,
"station": "campfire",
"category": "cooking",
"inputs": {
"raw_meat": 1
}
},
{
"id": "spear",
"output": "spear",
"count": 1,
"station": "workbench",
"category": "weapons",
"inputs": {
"wood": 3,
"flint": 2,
"fiber": 1
}
},
{
"id": "bow",
"output": "bow",
"count": 1,
"station": "workbench",
"category": "weapons",
"inputs": {
"wood": 4,
"fiber": 6
}
},
{
"id": "arrow",
"output": "arrow",
"count": 5,
"station": "workbench",
"category": "weapons",
"inputs": {
"stick": 2,
"flint": 1,
"feather": 2
}
},
{
"id": "hide_cloak",
"output": "hide_cloak",
"count": 1,
"station": "workbench",
"category": "clothing",
"inputs": {
"hide": 4,
"fiber": 4
}
},
{
"id": "wolf_cloak",
"output": "wolf_cloak",
"count": 1,
"station": "workbench",
"category": "clothing",
"inputs": {
"wolf_pelt": 3,
"hide": 2,
"fiber": 4
}
},
{
"id": "fishing_rod",
"output": "fishing_rod",
"count": 1,
"station": "workbench",
"category": "tools",
"inputs": {
"stick": 2,
"fiber": 3,
"flint": 1
}
},
{
"id": "grilled_perch",
"output": "grilled_perch",
"count": 1,
"station": "campfire",
"category": "cooking",
"inputs": {
"perch": 1
}
},
{
"id": "grilled_trout",
"output": "grilled_trout",
"count": 1,
"station": "campfire",
"category": "cooking",
"inputs": {
"trout": 1
}
},
{
"id": "charcoal_kiln",
"output": "charcoal_kiln",
"count": 1,
"station": "",
"category": "stations",
"inputs": {
"stone": 10,
"clay": 6
}
},
{
"id": "smelter",
"output": "smelter",
"count": 1,
"station": "workbench",
"category": "stations",
"inputs": {
"stone": 15,
"clay": 10,
"iron_nugget": 2
}
},
{
"id": "forge",
"output": "forge",
"count": 1,
"station": "workbench",
"category": "stations",
"inputs": {
"iron_bar": 6,
"stone": 10,
"wood": 4
}
},
{
"id": "iron_axe",
"output": "iron_axe",
"count": 1,
"station": "forge",
"category": "tools",
"inputs": {
"iron_bar": 2,
"wood": 2
}
},
{
"id": "iron_pickaxe",
"output": "iron_pickaxe",
"count": 1,
"station": "forge",
"category": "tools",
"inputs": {
"iron_bar": 3,
"wood": 2
}
},
{
"id": "lantern",
"output": "lantern",
"count": 1,
"station": "forge",
"category": "tools",
"inputs": {
"iron_bar": 2,
"fiber": 2
}
},
{
"id": "iron_spear",
"output": "iron_spear",
"count": 1,
"station": "forge",
"category": "weapons",
"inputs": {
"iron_bar": 2,
"wood": 3
}
},
{
"id": "iron_arrow",
"output": "iron_arrow",
"count": 10,
"station": "forge",
"category": "weapons",
"inputs": {
"iron_bar": 1,
"stick": 4,
"feather": 4
}
},
{
"id": "nails",
"output": "nails",
"count": 10,
"station": "forge",
"category": "building",
"inputs": {
"iron_bar": 1
}
},
{
"id": "stonecutter",
"output": "stonecutter",
"count": 1,
"station": "workbench",
"category": "stations",
"inputs": {
"wood": 8,
"iron_bar": 4,
"stone": 10
}
},
{
"id": "cut_stone",
"output": "cut_stone",
"count": 1,
"station": "stonecutter",
"category": "building",
"inputs": {
"stone": 2
}
},
{
"id": "stone_floor",
"output": "stone_floor",
"count": 1,
"station": "stonecutter",
"category": "building",
"inputs": {
"cut_stone": 4
}
},
{
"id": "stone_wall",
"output": "stone_wall",
"count": 1,
"station": "stonecutter",
"category": "building",
"inputs": {
"cut_stone": 6
}
},
{
"id": "stone_pillar",
"output": "stone_pillar",
"count": 1,
"station": "stonecutter",
"category": "building",
"inputs": {
"cut_stone": 3
}
},
{
"id": "stone_stairs",
"output": "stone_stairs",
"count": 1,
"station": "stonecutter",
"category": "building",
"inputs": {
"cut_stone": 4
}
},
{
"id": "stone_window",
"output": "stone_window",
"count": 1,
"station": "stonecutter",
"category": "building",
"inputs": {
"cut_stone": 5
}
},
{
"id": "stone_doorway",
"output": "stone_doorway",
"count": 1,
"station": "stonecutter",
"category": "building",
"inputs": {
"cut_stone": 6,
"wood": 4
}
},
{
"id": "stone_hearth",
"output": "stone_hearth",
"count": 1,
"station": "stonecutter",
"category": "stations",
"inputs": {
"cut_stone": 10
}
},
{
"id": "wood_stairs",
"output": "wood_stairs",
"count": 1,
"station": "workbench",
"category": "building",
"inputs": {
"wood": 4,
"nails": 4
}
},
{
"id": "wood_window",
"output": "wood_window",
"count": 1,
"station": "workbench",
"category": "building",
"inputs": {
"wood": 2,
"nails": 2
}
},
{
"id": "wood_pillar",
"output": "wood_pillar",
"count": 1,
"station": "workbench",
"category": "building",
"inputs": {
"wood": 2
}
},
{
"id": "palisade",
"output": "palisade",
"count": 1,
"station": "workbench",
"category": "building",
"inputs": {
"wood": 4,
"nails": 2
}
},
{
"id": "palisade_gate",
"output": "palisade_gate",
"count": 1,
"station": "workbench",
"category": "building",
"inputs": {
"wood": 6,
"nails": 4
}
},
{
"id": "iron_door",
"output": "iron_door",
"count": 1,
"station": "forge",
"category": "building",
"inputs": {
"wood": 4,
"iron_bar": 2,
"nails": 4
}
},
{
"id": "alpha_cloak",
"output": "alpha_cloak",
"count": 1,
"station": "workbench",
"category": "clothing",
"inputs": {
"alpha_pelt": 1,
"wolf_pelt": 2,
"wolf_fang": 1,
"fiber": 4
}
},
{
"id": "fur_rug",
"output": "fur_rug",
"count": 1,
"station": "workbench",
"category": "furniture",
"inputs": {
"bear_hide": 1,
"fiber": 2
}
},
{
"id": "smoking_rack",
"output": "smoking_rack",
"count": 1,
"station": "",
"category": "stations",
"inputs": {
"stick": 6,
"wood": 4,
"fiber": 4
}
},
{
"id": "salted_meat",
"output": "salted_meat",
"count": 2,
"station": "",
"category": "cooking",
"inputs": {
"raw_meat": 2,
"salt": 1
}
},
{
"id": "salted_fish",
"output": "salted_fish",
"count": 2,
"station": "",
"category": "cooking",
"inputs": {
"perch": 2,
"salt": 1
}
},
{
"id": "salted_trout",
"output": "salted_fish",
"count": 2,
"station": "",
"category": "cooking",
"inputs": {
"trout": 1,
"salt": 1
}
},
{
"id": "antler_pickaxe",
"output": "antler_pickaxe",
"count": 1,
"station": "forge",
"category": "tools",
"inputs": {
"stag_antler": 1,
"iron_bar": 1,
"wood": 2
}
}
]
}

356
data/tutorial.json Normal file
View File

@@ -0,0 +1,356 @@
{
"guide": {
"name": "Old Tam",
"about": "the hermit who's lived on this island longer than he'll admit"
},
"chapters": [
{
"id": "tools",
"title": "First tools"
},
{
"id": "food",
"title": "Fire and food"
},
{
"id": "hunt",
"title": "Hunting"
},
{
"id": "fish",
"title": "Fishing"
},
{
"id": "night",
"title": "Shelter and night"
}
],
"steps": [
{
"id": "look",
"chapter": "tools",
"title": "Look around",
"text": "Awake at last! Washed up on my island, have you. Stretch your legs: WASD to walk, the mouse to look. Shift if you're in a hurry.",
"goal": {
"walk": 10
}
},
{
"id": "sticks",
"chapter": "tools",
"title": "Gather sticks and stones",
"text": "Empty hands are no use here. Look at a stick or a stone on the ground and press E. Three of each, mind.",
"marker": {
"nearest": "pickup:stick"
},
"goal": {
"all": [
{
"gained": "stick",
"count": 3
},
{
"gained": "stone",
"count": 3
}
]
}
},
{
"id": "flax",
"chapter": "tools",
"title": "Pick wild flax",
"text": "See the pale, tall plants? Wild flax. Pick some. The fibre makes torches, bows and a decent bed.",
"marker": {
"nearest": "pickup:fiber"
},
"goal": {
"gained": "fiber",
"count": 2
}
},
{
"id": "axe",
"chapter": "tools",
"title": "Craft a stone axe",
"text": "Now an axe. Two sticks and two flint: flint lies about, more by the water. Tab opens your pack, and the recipe's in there.",
"marker": {
"nearest": "pickup:flint",
"unless_have": {
"flint": 2
}
},
"goal": {
"crafted": "stone_axe"
}
},
{
"id": "tree",
"chapter": "tools",
"title": "Chop down a tree",
"text": "Axe in hand (the number keys), hold the left button at a trunk and keep at it. A tree gives wood, and sticks when it falls.",
"marker": {
"nearest": "tree"
},
"goal": {
"felled": "tree"
}
},
{
"id": "pickaxe",
"chapter": "tools",
"title": "Craft a pickaxe and break a boulder",
"text": "Rocks are next. A pickaxe takes two sticks, three stone and a flint. Then go and break a boulder for stone and flint of your own.",
"marker": {
"nearest": "rock"
},
"goal": {
"all": [
{
"crafted": "stone_pickaxe"
},
{
"felled": "rock"
}
]
}
},
{
"id": "campfire",
"chapter": "food",
"title": "Build a campfire",
"text": "Five stone and four sticks make a campfire. Pick it in your pack, then place it with the left button (R turns it). Fire gives light, warmth and somewhere to cook. Feed it wood with E.",
"goal": {
"placed": "campfire"
}
},
{
"id": "berries",
"chapter": "food",
"title": "Pick berries and eat some",
"text": "Those red berries on the bushes are safe. Pick some with E, then hold them and press the right button to eat.",
"goal": {
"all": [
{
"gained": "berries"
},
{
"ate": "berries"
}
]
},
"marker": {
"nearest": "bush"
}
},
{
"id": "mushroom",
"chapter": "food",
"title": "Roast a mushroom",
"text": "Mushrooms grow under the trees. Raw ones are all right; roasted at the fire, much better. Find one and roast it (stand by the fire and open your pack).",
"goal": {
"all": [
{
"gained": "mushroom"
},
{
"crafted": "roasted_mushroom"
}
]
},
"marker": {
"nearest": "pickup:mushroom",
"unless_have": {
"mushroom": 1
}
}
},
{
"id": "diet",
"chapter": "food",
"title": "Mind your diet",
"text": "See the three Diet bars, bottom left? Meat, Fish, Greens. Each food fills one. Let one run low and you stop healing; let two, and you'll waste away on a full belly. Keep all three up and you heal faster. Press Enter when you've got that.",
"goal": {
"continue": true
}
},
{
"id": "workbench",
"chapter": "hunt",
"title": "Build a workbench",
"text": "Better tools want a workbench nearby. Ten wood and two flint. Chop a few more trees if you're short.",
"goal": {
"placed": "workbench"
}
},
{
"id": "weapon",
"chapter": "hunt",
"title": "Make a weapon",
"text": "You'll not catch your supper with your bare hands. A club is four wood. A spear, at the bench, reaches further.",
"goal": {
"any": [
{
"crafted": "club"
},
{
"crafted": "spear"
}
]
}
},
{
"id": "rabbit",
"chapter": "hunt",
"title": "Hunt a rabbit",
"text": "There, rabbits! They bolt if you run at them. Walk up quiet, weapon in hand, and strike.",
"goal": {
"killed": "rabbit"
},
"marker": {
"nearest": "creature:rabbit"
},
"setup": {
"spawn": {
"kind": "rabbit",
"count": 3,
"distance": 14.0
}
}
},
{
"id": "cook",
"chapter": "hunt",
"title": "Butcher it and cook the meat",
"text": "E on the carcass to butcher it. Raw meat turns your stomach; cooked at the fire it heals you and keeps you Well fed.",
"goal": {
"all": [
{
"butchered": "rabbit"
},
{
"crafted": "cooked_meat"
}
]
},
"marker": {
"nearest": "carcass"
}
},
{
"id": "animals",
"chapter": "hunt",
"title": "Know your animals",
"text": "Deer are quick: a bow's your friend there. Boars are calm until you hit one, then they come for you. Wolves you'll meet soon enough. Press Enter.",
"goal": {
"continue": true
}
},
{
"id": "rod",
"chapter": "fish",
"title": "Make a fishing rod",
"text": "Two sticks, three fibre and a flint, at the workbench.",
"goal": {
"crafted": "fishing_rod"
}
},
{
"id": "fish",
"chapter": "fish",
"title": "Catch a fish and grill it",
"text": "Down to the lake. Rod in hand, the left button casts. When the float dips, click quick, before it gets away. Grill what you catch at the fire.",
"goal": {
"all": [
{
"caught": "any"
},
{
"any": [
{
"crafted": "grilled_perch"
},
{
"crafted": "grilled_trout"
}
]
}
]
},
"marker": {
"nearest": "lake"
}
},
{
"id": "hut",
"chapter": "night",
"title": "Build a little hut",
"text": "Nights get cold, and things prowl. Build a hut: I've marked out a good spot by your fire, pale as a ghost. Floors, walls and a door, then the roof, all from the bench. Put a piece near its place in the outline and it'll go right in. Done when you can stand inside out of the weather.",
"goal": {
"shelter": true
},
"marker": {
"nearest": "outline"
},
"setup": {
"outline": "hut"
}
},
{
"id": "bed",
"chapter": "night",
"title": "Make a bed and sleep spot",
"text": "Eight wood and six fibre make a bed. Put it inside, then E on it: that's where you'll wake if anything happens to you.",
"goal": {
"all": [
{
"placed": "bed"
},
{
"bed_spawn": true
}
]
}
},
{
"id": "torch",
"chapter": "night",
"title": "Night is coming: make a torch",
"text": "There, the sun's going down. It gets dark and cold. A torch (a stick and two fibre) lights the way and keeps you a little warm.",
"goal": {
"crafted": "torch"
},
"setup": {
"time": 18.6
}
},
{
"id": "wolf",
"chapter": "night",
"title": "Wolves",
"text": "Hush. See it, at the edge of the light? Wolves. From the fourth night they hunt. They won't come close to a lit fire, a torch makes them think twice, and they can't bite through walls. Just watch.",
"goal": {
"demo_wolf": true
},
"setup": {
"demo_wolf": true
}
},
{
"id": "sleep",
"chapter": "night",
"title": "Sleep till morning",
"text": "Off to bed with you. You can only sleep at night, under a roof with walls round you, and not with wolves about.",
"goal": {
"slept": true
},
"setup": {
"time": 21.0
}
}
],
"finish": {
"title": "You'll do",
"text": "That's the island, near enough: gather, craft, eat a bit of everything, keep warm and dry, and be indoors or by a fire when the wolves are about. E picks up and uses, Tab is your pack, X takes a thing down, R turns what you're placing, the right button eats or wears. Off you go, then."
}
}

137
docs/HOSTING.md Normal file
View File

@@ -0,0 +1,137 @@
# Hosting a game
Three ways to run a server, from easiest:
1. **Host Game** on the title screen. A server runs on your computer while
you play, and stops when you leave. Good for an evening with friends.
2. **A dedicated server** on a spare computer (like the home server Mac):
`server/run_server.sh`, or as a service that starts on boot. Good for a
world that's always there.
3. **A small cloud server** (Linux): the same as 2, using the systemd file.
Whichever you use, friends need to reach your computer on **UDP port
24242** (or the one you pick). On the same Wi-Fi that just works. From
elsewhere, use **Tailscale** (recommended) or port forwarding (below).
## 1. Host Game (from the title screen)
- Title screen > **Host Game**. Pick a server name, the rules (co-op,
friendly rivals or adversarial), an optional password, the port, and
(the first time) a seed. **Start**.
- The game starts a windowless copy of itself as the server, then joins it.
You're its first player, so you're its **admin** (`T` then `/help`).
- When you're in, a message says the addresses friends can join at: your
local network address (192.168.x.x) and, if Tailscale is running, your
100.x address.
- **Leaving** (Disconnect, or quitting the game) saves the island and
stops the server. Next time, Host Game carries on with the same island
(the seed field is locked once it exists).
- Its files: `~/Library/Application Support/Godot/app_userdata/Emberwild/host/`
(macOS; `%APPDATA%\Godot\app_userdata\Emberwild\host\` on Windows):
`server.json`, `server.log`, and the world in `worlds/server-world/`.
- macOS may ask once whether Godot may accept incoming network
connections: say **Allow**, or friends can't connect.
## 2. A dedicated server
On the machine that will run it (it needs Godot 4.7 or newer and a copy of
the game, e.g. a git clone):
```bash
server/run_server.sh # settings from server.json
server/run_server.sh --server-name "Paul's island" --password secret
```
It checks Godot's version, imports a fresh copy the first time, and starts
the server with no window. **You can type admin commands into its terminal**
(the same as in chat, without the `/`): `players`, `rules`,
`set pvp true`, `time 08:00`, `backup`, `say hello`... Stop it with Ctrl+C.
The first player to join a new server becomes its admin (so join it
yourself before telling friends the address). More admins: `/admin <name>`.
**As a service** (starts on boot, restarts if it stops):
- **macOS:** `server/com.emberwild.server.plist`: edit the paths in it,
then `cp server/com.emberwild.server.plist ~/Library/LaunchAgents/` and
`launchctl load -w ~/Library/LaunchAgents/com.emberwild.server.plist`.
It runs while you're logged in (set the Mac to log in automatically for an
always-on server). Log: `~/Library/Logs/emberwild-server.log`.
- **Linux:** `server/emberwild-server.service`: the steps are in the file
(`sudo systemctl enable --now emberwild-server`; logs with
`journalctl -u emberwild-server -f`).
As a service there's no terminal to type commands into: use chat in game,
or edit `server.json` and restart.
## Settings and rules
`server.json` (made with defaults on first run; its path is printed at
start-up). Admins can change the rules in game with `/set` (they're saved
to the file). The full list is in `docs/MULTIPLAYER_PLAN.md` §4 and §6;
the ones you'll want first:
| Setting | Default | |
|---|---|---|
| `server_name`, `motd`, `password` | | what players see; a password keeps strangers out |
| `port` | 24242 | UDP |
| `max_players` | 8 | |
| `pvp`, `raiding` | false, false | can players hurt each other / take down others' builds |
| `locks` | owner_and_friends | who can open your chests and doors (`/friend <name>`) |
| `death_penalty` | grave | or `keep`, or `drop_all` |
| `difficulty` | normal | easy / normal / hard |
| `sleep` | all_online | or `half`, `anyone`, `off` |
| `backups_kept`, `backup_hours` | 10, 6 | see below |
| `check_updates` | true | at start-up, say in the log if a newer Emberwild is out (and whether its protocol differs, so new players couldn't join) |
| `allowlist`, `banned`, `admins` | | player ids (use `/ban`, `/admin` rather than editing) |
## Backups
The server copies the world (the island and every player's character) to
`worlds/<world>/backups/<date_time>/`:
- when it starts (the state it's about to load),
- every `backup_hours` while it runs,
- whenever an admin types `/backup` (`/backups` lists them).
It keeps the newest `backups_kept`. **To restore one:** stop the server,
copy the files from the backup folder (`meta.json`, `world.json` and the
`players` folder) into `worlds/<world>/`, replacing what's there, and start
it again.
The world also saves within 10 seconds of any change and when a player
leaves, so stopping it with Ctrl+C loses at most a few seconds.
## Letting friends in from outside your home
### Tailscale (recommended)
Tailscale puts your devices and your friends' on a private network, with
nothing opened to the internet and no router settings.
1. Install Tailscale on the server computer ([tailscale.com/download](https://tailscale.com/download))
and sign in.
2. Share the server with each friend: in the Tailscale admin console,
**Machines** > the server's **...** menu > **Share**, and send them the
link. (Or invite them to your tailnet.)
3. They install Tailscale, sign in and accept the share.
4. In the game they **Join** with the server's Tailscale address,
`100.x.y.z` (shown in Tailscale, and in the game when you host), or its
machine name (with MagicDNS on), e.g. `home-mac`.
To check it works: `tailscale ping <server name>` from the friend's
computer.
### Port forwarding
On your router, forward **UDP 24242** to the server computer's local
address (give that computer a fixed local address first). Friends join
with your public IP address. It needs no extra software, but your home IP
and that port are public, so set a `password`.
### A cloud server
Any small Linux server works (it needs little: a couple of GB of memory).
Install Godot, clone the game, use the systemd file, and open UDP 24242 in
the provider's firewall.
## Troubleshooting
- **"Different game version":** the server and the player are on different
versions of the game. Update both (`git pull`), and restart the server.
- **"No answer from the server":** wrong address, the server isn't running,
or a firewall is in the way (macOS: allow incoming connections for Godot;
Tailscale: is it connected on both ends?).
- **"Port 24242 is already in use":** another server is running on this
computer (a dedicated one, or a Host Game that didn't stop). Stop it, or
host on another port.
- **The server says the game's scripts didn't load:** it's a fresh copy
that hasn't been imported, or Godot is older than 4.7. `run_server.sh`
handles both; see `docs/SETUP_ON_NEW_MACHINE.md`.

406
docs/MULTIPLAYER_PLAN.md Normal file
View File

@@ -0,0 +1,406 @@
# M2 multiplayer plan
Status: **plan, decisions agreed** (§12), nothing built yet. It refines `ARCHITECTURE.md` §2-3 into
concrete decisions for this codebase, as it stands after M1.
## 1. Goals
- **Self-hosted**, 2-8 players (up to 16 possible). No central service, no
accounts: a world lives on whoever hosts it.
- **Three ways to play, the same rules (Actions):**
- **Single-player**: no server; your game decides, exactly as now
(decided in M2.4, §7).
- **Host & play**: "Host game" on the title screen, and friends join you.
- **Dedicated server**: a headless "server only" process with no local
player. It keeps the world running (or paused) for whoever connects.
- **Server-authoritative:** the server decides everything, and clients send
intents (the Actions layer already works this way).
- **Co-op or adversarial**, chosen by server rules (§5).
## 2. Running a server
*Built in M2.1* (`scripts/net/net.gd`, `server_config.gd`, `identity.gd`).
The server accepts players, checks who they are and keeps the player list.
Since M2.3 it also runs the shared island (§5). Flags: `--server`,
`--port`, `--config`, `--server-name`, `--max-players`, `--password`,
`--world`, `--seed`, and `--exit-with <pid>` (quit when that process exits; the tests
use it so a crashed test never leaves a server running). Flags apply to
that run only and are never written to `server.json`.
```bash
# Dedicated server (no window, no player)
godot --headless --path . -- --server --config server.json
# or quick flags (override the config file)
godot --headless --path . -- --server --world "Valley of Us" --port 24242
```
- **Transport:** ENet over **UDP, one port**, default **24242**,
configurable. It works on Linux, macOS and Windows.
- **Reaching it from outside:**
- **Tailscale (recommended for friends).** Share the server machine from
your tailnet; friends install Tailscale and join `100.x.y.z:24242` (or
the MagicDNS name). Nothing is exposed to the internet.
- **Port forwarding.** Forward UDP 24242 on your router. No extra
software, but your home IP and that port are public.
- **A small cloud server.** The same command on a cheap Linux server.
- **Cloudflare:** a normal Cloudflare tunnel doesn't carry raw UDP.
Possible later via a **WebSocket transport** (Godot's
`WebSocketMultiplayerPeer`, TCP, a little more stutter on poor links)
behind `cloudflared`. It's planned as an optional `--transport ws` and
isn't needed for v1.
- **Runs as a service:** a sample systemd unit and a launchd plist go in
`server/`. Logs go to stdout, and a console accepts admin commands (§6).
## 3. Players: identity and names
- **Identity:** on first run each game install generates a private key
(RSA-2048, since Godot's Crypto has no Ed25519) and keeps it in
`user://identity/key.pem`, with the name and appearance in
`user://identity/profile.json`. The public key's hash is
the **player id**. On joining, the client signs a server-chosen challenge,
so nobody can claim someone else's id. No passwords and no central
accounts.
- **Name:** the first time you open the game it asks for a **display
name**, which you can change later in Settings.
- 3-16 characters: letters, digits, space, `_` and `-`. No leading or
trailing spaces, and no names that only differ by case.
- Shown on a **name tag over your character** to other players (a
billboarded `Label3D`, fading with distance, hidden behind walls), in
the player list (Tab) and in chat.
- A server **reserves a name for the player id that first used it**, so
someone else can't join as "Paul". If your name is taken there, you're
asked to pick another. Admins can rename and release names.
- **Character:** players need a visible body (today there's none). M2
includes a simple procedural low-poly character with idle, walk, run,
swim and swing poses, plus a held-item attachment. v1 appearance is
deliberately limited: tunic colour and hair colour from small palettes,
and a beard on/off. It's stored with your identity and sent on join.
## 4. Server preferences
One file, `server.json`, created with defaults on first run and editable
by hand. Admins can also change most settings in game (§6). `*` marks
settings fixed when the world is created.
```json
{
"server_name": "Paul's island",
"motd": "Welcome! Be nice to the wolves.",
"port": 24242,
"max_players": 8,
"password": "",
"world": "valley-of-us",
"worldlet": "valley", "_note": "* (see WORLDLETS_DESIGN.md)",
"seed": 1337, "_note2": "*",
"pvp": false,
"raiding": false,
"locks": "owner_and_friends",
"death_penalty": "grave",
"sleep": "all_online",
"pause_when_empty": true,
"day_length_minutes": 20,
"wolf_grace_days": 3,
"difficulty": "normal",
"autosave_minutes": 2,
"backups_kept": 10,
"admins": [], "_note3": "empty = first player to join becomes admin",
"allowlist": [],
"banned": []
}
```
What each rule means:
| Setting | Values | Effect |
|---|---|---|
| `pvp` | false / true | Whether players can hurt each other. With it off, weapons pass through other players. |
| `raiding` | false / true | Whether players can damage or dismantle **other players'** pieces. |
| `locks` | `off` / `owner` / `owner_and_friends` | Who may open or take from a chest, use a door or bed, or dismantle a piece. Pieces remember who placed them. |
| `death_penalty` | `grave` / `keep` / `drop_all` | Today's grave bag; keep everything; or drop it all on the ground (hardcore-ish). |
| `sleep` | `all_online` / `half` / `anyone` / `off` | When a night can be slept through (how many of those online must be in bed). |
| `pause_when_empty` | true / false | Freeze the world when nobody's on (fires don't burn out, no time passes). |
| `wolf_grace_days` | 0-10 | Days before wolves arrive; hunts start the night after. |
| `difficulty` | `easy` / `normal` / `hard` | Multipliers on hunger and diet drain, animal damage and pack size. |
Common setups:
- **Co-op (default):** PvP off, raiding off, locks for owner and friends.
- **Friendly rivals:** PvP on, raiding off. You can fight, but bases are
safe.
- **Adversarial:** PvP on, raiding on, locks for owners only, and
`drop_all` on death.
**Friends** are per player: a player lists other players (from the player
list) whose doors and chests they share. It's stored on the server.
## 5. Who owns what (the authority model)
| State | Owner | Clients get |
|---|---|---|
| Terrain, scenery, den site, weather | regenerated from worldlet + seed (deterministic) | nothing to sync except the seed, the clock and edits |
| Day/night clock | server | the time, every few seconds (clients interpolate) |
| Trees, rocks, pickups, bushes: damage, felled, regrowth | server | events (`node_hit`, `node_felled`, `regrown`), plus the full list on join |
| Building pieces, chests, fires, doors, beds, graves, dropped items | server (with an owner per piece) | create, remove and change events, plus a snapshot on join |
| Animals and wolves (AI, health) | server | position, yaw and state at 10-20 Hz; clients interpolate and animate |
| Projectiles, fishing floats | server | spawn events; clients simulate the flight visually |
| Each player's position | **client-predicted**, server-validated | everyone else sees it interpolated |
| Each player's inventory, stats, diet, buffs, cloak, bed | server | the owning client only (others see the held item and name) |
**Intents to RPCs.** Each of today's `Actions.*` intents (craft, pickup,
harvest_hit, place, dismantle, toggle_door, use_bed, melee, throw_spear,
shoot, butcher, cast_line, reel, wear, take_off, forage, eat, add_fuel,
transfer...) becomes a client-to-server RPC. The server runs the same
function it runs today, with the server's copy of the player, then sends
back the results. The rules don't move; only who calls them.
**Movement** *(built in M2.2: `net.gd`, `net_sync.gd`, `avatar.gd`)*. Your
own player moves locally with no delay, and the game sends its position,
facing, animation and held item to the server 20 times a second, each
stamped with the sender's own clock. The server checks every move against
the last accepted one:
- no faster than 9.5 m/s across the ground (sprint is 8) plus 1 m of slack;
- no rising faster than 9 m/s;
- not outside the island (a 3 m margin past the wall);
- not underground.
A refused move sends the player back to their last good position.
Respawning is a flagged teleport, allowed if it lands within 25 m of the
island spawn. Normal play is never corrected, so there's no rubber-banding.
This is the Valheim model, and simpler than replaying inputs on the server.
Since M2.3 the server also refuses a move whose straight line crosses a
wall, door frame (or closed door) or gable at waist height, checked from
both ends so someone a wall was built on top of can still step out.
The server sends everyone's positions 20 times a second. Each one is stamped
with when the player moved (their clock plus a smoothed per-player offset),
not when it was sent. Stamping by send time bunched moves into some
snapshots and left others empty, so avatars surged. Avatars are drawn
100 ms behind and interpolated, carrying on along their last velocity for up
to 0.25 s if data stops. The server also sends its seed, spawn point and
clock (every 10 s; the clock pauses when the server is empty), so every
player has the same island, sky and weather.
Measured in `tests/test_net_world.gd` at 100 ms ± 30 ms simulated latency:
a 4 m/s walk is seen at a median of 4.2 m/s, top 5.0, with no freezes, and
it ends within a centimetre of where the walker stopped.
**The shared world** *(built in M2.3: `world_net.gd`, autoload WorldNet)*.
The dedicated server runs the real world scene headless with nobody of its
own in it (`world.gd` `server_mode`), and saves it like a single-player
world, in `worlds/<world>/` next to `server.json`. It owns trees and rocks
(damage, felling, regrowth), pickups, berry bushes, dropped items, every
placed piece (walls, doors, chests, graves, fires, beds), fire fuel, door
state and the clock. Weather is worked out from the seed and the clock, so
it needs no syncing.
- A client's `Actions.*` intents that change the island become requests
(`_c2s_hit`, `_c2s_place`, `_c2s_take`, `_c2s_chest_click`...). The server
checks reach (from where it last saw that player), applies the change,
and tells everyone (`_s2c_node`, `_s2c_piece_added`...). Nothing changes
on your screen until the server says so, so two people grabbing the same
stick get one stick between them.
- Whatever a change earns you (wood, a pickup, a dismantled wall and
anything that collapsed with it, items out of a chest) the server sends
to you (`_s2c_give`). What doesn't fit lands at your feet.
- Pieces have a net id that's saved with them, so they keep it across
server restarts.
- Chest clicks happen on the server (someone else may be using the chest),
which hands back what you're holding.
- On joining, your world asks for a snapshot of everything once its
scenery is up, then gets each change in order. Regrowth only happens on
the server, never on top of a player.
- The world (fires, regrowth, the clock) pauses while nobody's on.
**Your character on the server** *(built in M2.4: `player_net.gd`,
`server_player.gd`, autoload PlayerNet)*. Online the server decides;
offline there's no server and your game decides, exactly as before.
- Each player has a character on the server with their inventory, hotbar
choice, cloak, inventory-screen cursor, bed and survival stats. The stats
run there: warmth from the server's fires and buildings, rain, hunger and
the diet. Sprinting and jumping cost stamina there too.
- Online, every `Actions.*` intent becomes a request (`PlayerNet.ask("eat",
[slot])`). The server runs the same Actions function on your character
and sends back your inventory (after every request, and whenever it
changes) and your stats (4 times a second). What your own game has in
its bag counts for nothing: a modified game that adds walls to its bag
can't build with them.
- Clicks in your own bag also happen on your screen at once, so dragging
items doesn't wait for the network. The server's copy replaces yours as
soon as it has handled every click you sent.
- Death happens on the server: a grave with your name on it (only you can
open it until the M2.6 rules), then you wake at your bed if it's still
standing, otherwise the island start.
- Characters are saved in `worlds/<world>/players/<player id>.json`, with
the world, so you come back where you left off.
**Wildlife, hunting, fishing and sleep** *(built in M2.5: `creature_net.gd`,
autoload CreatureNet)*.
- The server's world runs the animals and the wolf pack. Animals live
around every player (the budget per player, up to twice it however many
are on), and go once they're far from all of them. A player joining gets
some wildlife about them straight away.
- **The pack splits between the players it hunts**: wolf 1 takes the
nearest to the den, wolf 2 the next, preferring players away from a fire.
Each hunted player has their own circle and their own one-wolf-at-a-time
darts. Fire, torches and walls work per player as in single-player.
- Ten times a second everyone gets every animal, wolf and carcass (23 bytes
each, split so no packet goes over the MTU: an oversized unreliable packet
is simply lost) and every fishing float. Games show them as **puppets**
(`creature.gd`/`bobber.gd` `puppet`) that animate and can be hit or
butchered, and decide nothing. Something missing for a second goes.
- **Hitting** (club, spear thrust) sends the creature's id; the server runs
Actions.melee on your character there. **Shooting and throwing** send only
the aim: the arrow or spear starts from where the server knows your eyes
are. The server's projectile hits; everyone else's screens fly a copy for
show (sent once, when it's loosed). An arrow that misses lands as a
dropped item for anyone.
- **Fishing**: one float per player, on the server. Bites, misses and a
snapped line are told to the player whose float it is (`Actions.tell`).
- **Sleeping** follows `sleep` in `server.json`: `all_online` (default),
`half` or `anyone`. Getting into a bed you've claimed puts you "in bed"
(moving more than 1.5 m gets you out); once enough players are in bed
the night passes for everyone (fires burn down, things regrow, the clock
jumps to 06:00) and sleepers wake rested.
- The pack's howls and "the wolves are hunting tonight" go to everyone;
being bitten or gored flashes your screen red as in single-player.
Still to come: the server doesn't yet stop you sprinting when you're out of
stamina (it charges you, but doesn't stop you).
**Interest.** On a 320 m worldlet with 8 players, the server sends every
entity to everyone at first. Distance-based interest is a later
optimisation.
## 6. Admin and console
*(Built in M2.6: `chat.gd`, autoload Chat; `rules.gd`.)* In-game chat
starts with `/`; the server console takes the same commands without it
(the server reads its terminal when started with `--console`, which
`server/run_server.sh` does).
- Anyone: `/help`, `/players`, `/rules`, `/friend <name>`,
`/unfriend <name>`, `/friends`.
- Admins: `/kick <name>`, `/ban <name>`, `/unban <name>`, `/admin <name>`,
`/save`, `/time 08:00`, `/weather clear|cloudy|rain|storm|snow|blizzard|auto`,
`/set <rule> <value>` (changes it for everyone at once and saves it to
`server.json`: pvp, raiding, locks, death_penalty, sleep,
pause_when_empty, difficulty, wolf_grace_days, motd, server_name; bad
values are refused with what's allowed), `/say <message>`,
`/release-name <name>`.
- Not built: `/backup` (comes with backups in M2.7) and `/rename` (the name
is the player's own, set in their profile; `/release-name` frees one).
Chat: `T` to talk, Enter to send, Esc to cancel. It's global, so everyone
on the server sees it (no proximity chat in v1). Lines fade after 14 s and
all come back while you type. Plain chat is limited to a line every 0.4 s
per player (commands aren't).
**How the rules work** *(M2.6)*:
- Pieces remember who placed them (and graves who died). Everyone's game
knows, so the prompt says "Bob's · Chest". Pieces from before M2.6 are
anyone's.
- `locks`: who may open or take from chests and graves, open doors and use
beds. `owner_and_friends` (the default) lets the people you've
`/friend`ed in; `owner` keeps everyone else out; `off` opens everything.
- `raiding`: taking down a piece you aren't allowed to use. Off by default.
- `pvp`: with it off, weapons and arrows go straight through other players.
On, a swing at someone's avatar is checked on the server (reach,
weapon, stamina, cooldown) and arrows and thrown spears hit people.
Deaths say who did it ("Killed by Bob").
- `death_penalty`: `grave` (default), `keep` (wake with everything) or
`drop_all` (it's all scattered where you fell, for anyone).
- `difficulty`: easy / normal / hard scales hunger and diet drain (x0.75 /
1 / 1.3), animal and wolf damage (x0.7 / 1 / 1.4) and the pack (-1 / 0 /
+1 wolf).
- `wolf_grace_days`: the day wolves move in; they hunt from the next night.
- `sleep`: `all_online` (default), `half`, `anyone` or `off`.
## 7. Saves on the server
```
worlds/<world>/
meta.json world name, worldlet, seed, created, play time
world.json shared state: pieces (with owner ids), resource nodes,
pickups, regrowth, dropped items, clock
players/<player id>.json position, inventory, stats, diet, buffs, cloak, bed,
friends, name, appearance
backups/<timestamp>/ rolling copies (backups_kept)
```
*As of M2.3:* `worlds/<world>/meta.json` and `world.json` (with pieces'
net ids, no owners yet) are written next to `server.json` within 10 s of
any change, when a player leaves, and every `autosave_minutes`. (Ctrl+C
kills Godot without running any code, so there's no save on the way out.) An
existing world keeps the seed it was made with, even if `server.json`
changes. *As of M2.4:* `players/<player id>.json` too (position, facing,
inventory, hotbar choice, stats, diet, buffs, cloak, bed), saved with the
world, when the player leaves and when they die. Graves carry their
owner. Backups come in M2.7.
*Decision (M2.4):* single-player stays as it is, with no server inside it
("if it's single-player there is no server, so the game decides"). So
single-player saves need no migration: they keep working unchanged, and
`tests/test_save_load.gd` still proves it. The old idea of running
single-player as a loopback server is dropped.
- *(Originally: single-player saves migrated to a player file on first
load. Not needed since the M2.4 decision above.)*
- Per-player graves already work, since graves are pieces. With
`death_penalty: grave` they get an owner, and only the owner (and friends,
depending on locks) can loot them.
## 8. What has to change in the code
Today the code assumes one player in a few places. Each becomes "every
player" or "this player":
- `world.player` holds a dictionary of players by peer, and the local
player is a client-side notion.
- Wildlife spawns around each player within the budget. Wolves target the
nearest player who is outside, unprotected and not by a fire.
- The fishing float, the bow draw, the build ghost and the hands are per
player (some are client-only visuals).
- Sleeping follows the `sleep` rule, and it fast-forwards for everyone.
- HUD toasts become per-player server messages (`Actions.notify` gets a
target player).
- Autosave and the window-close save happen on the server. Clients just
disconnect.
- `Saves` splits world and player files (§7).
## 9. Phases
| Phase | Delivers | Done when |
|---|---|---|
| **M2.0** | worldlet W0 (terrain from data, no visible change), recommended first | seed 1337 generates the same height grid as today; all suites pass |
| **M2.1** *(done)* | network skeleton: `--server`, ENet on 24242, Join screen (address, name), handshake with identity and challenge, name rules, player list | two clients and a headless server on one Mac connect, see the player list, and disconnect cleanly |
| **M2.2** *(done)* | player bodies and name tags; predicted movement, interpolation for others | two players walk around together at 100 ms simulated latency without visible rubber-banding |
| **M2.3** *(done)* | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it |
| **M2.4** *(done)* | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads |
| **M2.5** *(done)* | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens |
| **M2.6** *(done)* | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 |
| **M2.7** *(done, see docs/HOSTING.md)* | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour |
## 10. Testing
- **Headless multi-process tests:** a script starts a headless server and
two headless "bot" clients that send scripted intents, then checks both
clients' views against the server's.
- **Network conditions:** a test transport adds latency, jitter and packet
loss, so prediction and reconciliation are tested at 100-200 ms.
- **Rules tests:** one per server rule (PvP off means no damage between
players; raiding off means you can't dismantle a stranger's wall; locks
keep strangers out of your chest).
- **Save tests:** world and player file round trips, backups.
- The single-player suites keep running as they are (no server).
- `tests/test_net_shared.gd` (M2.3) is the two-world version: this test is
one player and `tests/net_world_bot.gd` (a second process with its own
world, following commands the test writes as files) is the other. A
`--debug-regrow <k>` server flag speeds regrowth up for it.
## 11. Risks
- **Prediction feel** is the hard part. It gets an early phase (M2.2) so
it's tuned before everything else depends on it.
- **Sleeping and pausing with several players** can feel unfair, which is
why the rules are configurable.
- **Performance:** wildlife and wolves for 8 spread-out players. The
budget is per player but capped per world.
- **Security:** the server checks every intent (reach, cost, ownership,
cooldowns). Clients are never trusted for results. That's already how
Actions is written.
## 12. Decisions (agreed with Paul, 2026-11-04)
1. **Port:** UDP **24242** by default (configurable).
2. **Appearance in v1: limited.** Pick a tunic colour and a hair colour from
small palettes, plus a beard on/off toggle. More options later.
3. **Chat: global only.** No proximity chat in v1.
4. **Empty server: pause.** `pause_when_empty` defaults to `true`, so the
world freezes (no time, burning or regrowth) while nobody is on.
5. **Admin bootstrap: the first player** to join a new server with an
empty `admins` list becomes admin, and their id is written to
`server.json`. This is a stopgap and will be revisited before servers
are shared more widely (risk: whoever joins first wins, so start a new
server and join it yourself before telling friends the address).

318
docs/PROGRESSION_DESIGN.md Normal file
View File

@@ -0,0 +1,318 @@
# Progression and challenges design
Status: **P1-P5 built** (iron, stone, adversaries, hazards, the Old Stag).
*As built in P1:*
- Iron nuggets (70, in the scree under the rim), clay (60, on the lake
shore) and rusty boulders (about a quarter of the boulders, mostly near
the rim; iron ore when mined) are on every island, old worlds included.
- Charcoal kiln (2 wood -> 1 charcoal, 20 s), smelter (1 ore or 3 nuggets +
1 charcoal -> 1 bar, 30 s) and forge. Kiln and smelter open like chests
and work on their own (also through a slept night and on a server).
- At the forge: iron axe and pickaxe (twice as fast), iron spear, iron
arrows (x1.5 damage, the bow uses them first), nails, lantern.
- **Durability** (decided: yes): stone tools 60 uses, club 80, wooden spear
50, bow 100, rod 80, iron tools 200 (iron spear 180). A bar on the slot;
a warning near the end; it breaks at 0. Repair in the crafting panel's
Repair tab: stone and wood at a workbench (a flint, wood or fibre), iron
at the forge (an iron bar). Wear stays with the item in chests, graves,
on the ground (a thrown spear) and in saves.
- Not yet: the cooking pot and stews, the iron-banded door (with P3, when
something can break doors), iron armour.
*As built in P2:*
- Stonecutter (workbench: 8 wood, 4 iron, 10 stone) cuts 2 stone into a
block of cut stone. No granite outcrops yet: the stonecutter is the only
source.
- At the stonecutter: stone floor (4), wall (6), pillar (3), stairs (4),
window (5), doorway with a wooden door (6 + 4 wood), hearth (10).
- At the workbench: wooden stairs, window and palisade (with nails), a
wooden post, a palisade gate.
- Support: stone loses 5% going up (wood 10%); wood can't hold stone up
("Stone needs stone or the ground under it"); through a pillar any step
up or down counts as vertical, so a post under a span carries it.
- Hearth: a campfire (fuel, cooking, wolves) with 1.5x the heat radius that
rain never speeds up. A sheltered room with mostly stone walls is 6
warmer. Shelter rays now run 0.6 m up, under window sills, so a room with
windows still counts.
- Palisade: 3 m tall, blocks like a wall online too. No fences.
*As built in P3 so far (the alpha):*
- Wolf moon: nights 7, 14, 21... (worked out from the day, so every game on
a server agrees). The moon turns red and a toast warns you.
- The alpha (135 HP, bites for 16) comes out of the den with the pack and
leaves at dawn. It isn't afraid of fire and never runs. If it can't reach
you it batters the nearest shut door near you: a wooden door (or the
wooden leaf in a stone doorway or palisade gate) gives way after about
five blows and hangs broken until mended (E, 2 wood); then it comes in.
- Iron-banded door (forge: 2 iron, 4 wood, 4 nails) can't be broken.
- Drops: alpha pelt, 1-2 fangs, meat. Alpha cloak (+35 warmth) at the
workbench. Network protocol 7.
*As built in P3 so far (the bear):*
- One bear per island (220 HP, hits for 24, runs at 7 m/s: slower than you
sprinting). Home is the densest forest near the rim, at least 60 m from
the spawn and the den, picked from the seed. It sleeps there at night.
- From day 3, by day, it sniffs out food within 60 m: a chest with food in
it or food left on the ground, with nobody within 12 m. It walks over,
eats all the food (nothing else) and wanders off. Food it can't walk to
is safe: fenced in = all 32 level rays from it hit building pieces (a
palisade ring, a closed hut; an open door lets it in). It breaks nothing.
- Closer than 8 m it rears up and warns you; stay within 6 m and it
charges. Hit it and it charges; under a quarter health it runs.
- Killed, it comes back 3 days later at dawn, when nobody's near its home.
Its death (and a wounded bear's health) is saved with the world.
- Drops: bear hide, 2-3 fat (fire fuel, three times wood), 5-7 meat. Fur
rug (bear hide, 2 fibre): in a sheltered room, Cozy: +4 warmth and
Rested twice as fast. Network protocol 8.
*As built in P3 (crows and foxes):*
- Food left on the ground with nobody within 10 m for 20 s draws a thief
from 35 m away (at most 3 at once): a crow by day, a fox from 17:30 to
06:30. Food in a chest is never touched by them.
- A crow flies in, lands and eats one every 3 s; anyone within 7 m and it
takes off. Shot down: 1-2 feathers.
- A fox slinks up and runs off with the whole stack; come within 10 m
while it's creeping in and it bolts empty-handed. Kill it while it's
carrying and it drops the food; let it get 45 m from everyone and it's
gone. Raw meat and a hide.
*As built in P3 (the barrow):*
- One per island, on open ground (55 m+ from the spawn, 70 m+ from the
den; looser on lumpy islands): a grass mound you can walk over, a stone
passage into a 4 x 4 m chamber with a coffin, ringed by standing stones.
- Three guards (80 HP, hit for 20, move at 3.4 m/s: slower than you walk)
stand at their posts. They wake when someone is inside the stone ring at
night or any time after day 10, when the coffin is opened, or when one
is hit. Fire and torches don't bother them. Beyond 22 m from the barrow
they give up and walk back to their posts.
- The club is blunt: +50% on bone. Guards drop a little iron.
- The coffin: 1-3 iron bars, 3-6 nuggets and a barrow key piece (three
open the standing stones, P5). It fills again 5 days after it was
emptied, at dawn with nobody about, and the fallen guards are back.
Emptied coffin and fallen guards are saved. Network protocol 10.
- Not yet: iron scrap (bars and nuggets instead); the standing stones and
the Old Stag (P5).
*As built in P4 (hazards):*
- Spoilage: food carries a "rots at" world time on its stack (stamped when
it first goes into a bag or chest; merged stacks average it), so it rots
in bags, chests, graves, racks and on the ground, and through sleep and
saves. Raw meat and fish 2 days, berries 3, mushrooms and cooked food 4,
roasted mushrooms 6, smoked 12, salted 20. Rotten food is junk; food
rotting on the ground just goes. A bar on the slot runs green to brown;
the tooltip says Fresh / Going off / Nearly rotten, with hours left.
- Rot speed: normal, slow (half) or off. Chosen on the New World page
(meta.json "rot"); on a server, "rot" in server.json or /set rot.
- Smoking rack (by hand: 6 sticks, 4 wood, 4 fibre): 2 raw meat + a wood,
or 2 perch (or 1 trout) + a wood, make 2 smoked, a minute a batch.
- Salt: 40 crusts on the cliff tops above the sea. 2 raw meat (or 2 perch,
or 1 trout) + a salt: 2 salted, by hand.
- Food poisoning: raw meat 35%, raw fish 20%. A minute of half-speed
stamina and no eating. Cooked, smoked and salted food are safe.
- Blizzards: fog x7; out in one, an extra 18 warmth lost on top of the
cold, and you cool twice as fast. A hard-winter blizzard freezes you by
day without a coat; a hide cloak keeps you out of Freezing; a hearth room
stays Warm. Snow piles on roofs (a look only).
- Hard winters: days 12-14, 24-26, ... (every 12 days): always cold, 8
colder than a cold day, snowier, half the animals, the lake's edge frozen
(walkable ice, no fishing through it). Warned the day before.
- Lightning (decided: trees only): in a storm, a strike every 50-110 s on a
tree within 90 m of someone, the taller and nearer the rim the likelier.
It burns 40 s (you can warm yourself by it), falls, and leaves 2 charcoal.
- Network protocol 11.
*As built in P5 (the Old Stag):*
- The standing stones: 7 tall stones in a ring at the foot of the rim with
an altar in the middle (from the seed; 60 m+ from the spawn and den, 50 m+
from the barrow, 42 m+ from the bear's home). E on the altar sets barrow
key pieces in its three hollows; with all three in, at night, it calls
the Old Stag.
- The Old Stag: 900 health, 3.8 m at the shoulder. It circles, lowers its
antlers (1.2 s warning), and charges in a straight line through where
you were: 30 if it catches you, nothing if you step aside. Then it's
stunned for 2.5 s, when hits do half as much again. Below 2/3 it calls 3
spirit wolves; below 1/3 2 more, quicker warnings (0.8 s), a 1.5 s stun,
and a stamp (15 to anyone within 5.5 m, every 4 s). No fear of fire.
- It goes back into the mist at dawn, or 5 s after everyone's left the
stones or died: the key pieces stay in the altar for another night.
- Beaten: its antler and a trophy (a placeable stand) where it fell, the
key pieces used up, and the world remembers (day and, on a server, who;
everyone's told). Three new key pieces call it again.
- In a group (on a server): anyone can set key pieces in the altar. It has
60% more health for each extra living player within 45 m when it's
called (900 alone, 1980 for three), and calls one more wolf each time for
each extra player (up to 4 more). It remembers everyone whose blow,
arrow or spear hit it; when it falls, all of them are named ("Beaten by
Alice, Bob and Cara on day 2") and saved. The antler and trophy go to
whoever picks them up first (tests/test_net_boss.gd: three players).
- Antler pickaxe (forge: the antler, an iron bar, 2 wood): tier 3, 3.0 a hit
(iron 2.0), 250 uses (iron 200), 4 stamina a swing (iron 6).
- Network protocol 12; creature health now goes over the wire in tenths
(up to 6553), so the stag's 900 fits.
Today the island runs out of goals after a few days: once you have stone
tools, a hut, a bow and a fire, the only threats are wolves at night, the
cold and your diet. This plan adds a **second tier of materials** (iron and
cut stone) that makes you go somewhere risky to get them, and **new
adversaries and hazards** that grow over the days, so there's always a next
thing to build toward and a reason to.
All content stays procedural or CC0, data-driven (`data/items.json`,
`data/recipes.json`, new `data/creatures.json`), and runs through Actions,
so it works on a server the same way everything else does after M2.4/M2.5.
## 1. The progression in one picture
```
stone tools ──> find iron (nuggets, rust-streaked boulders, the barrow)
│ │
│ clay + stone ──> charcoal kiln, smelter
│ │ (ore + charcoal -> iron bar, takes time and fuel)
│ v
│ forge ──> iron axe / pickaxe / spear / arrows,
│ │ nails, hinges, lantern, cooking pot
│ v
└──────────> stonecutter (needs iron) ──> stone walls, floors,
pillars, stairs, hearth
```
Each step needs something from a harder, more dangerous part of the island,
so progression and challenge pull on each other.
## 2. Iron
**Where it comes from:**
- **Iron nuggets**: small ground pickups in the scree at the foot of the
mountain rim and in stream beds. A handful per day; enough for your first
bars, not for a base. (Regrow slowly, like flint.)
- **Rust-streaked boulders**: a new resource node, red-brown streaks in the
rock, mostly high on the rim slopes and around the wolf den. Needs a stone
pickaxe; gives stone plus 2-4 iron ore. These are the main supply.
- **The barrow** (§4): iron scrap and a few ready bars inside, guarded.
**Clay** (new): lumps on the lake shore and in the shallows, picked by hand.
**Processing** (all new pieces, placed like a campfire, state on the server):
| Piece | Recipe | Does |
|---|---|---|
| Charcoal kiln | 10 stone, 6 clay | wood in, charcoal out (1 per 2 wood, 30 s each) |
| Smelter | 15 stone, 10 clay, 2 iron nuggets | iron ore or 3 nuggets + 1 charcoal -> 1 iron bar (45 s); burns charcoal, goes out in heavy rain without a roof |
| Forge (anvil + hearth) | 6 iron bars, 10 stone, 4 wood | the station for iron recipes |
Smelting **takes real time** and the smelter keeps working while you're
away (like a campfire burning), so you load it, go and do something else,
and come back. On a server everyone can see the glow and use it.
**What iron makes** (at the forge):
| Item | Recipe | Why you want it |
|---|---|---|
| Iron Axe | 2 iron, 2 wood | tier 2: fells trees in half the hits, better weapon (14) |
| Iron Pickaxe | 3 iron, 2 wood | tier 2: mines rust boulders fast, and **granite** outcrops (cut stone) |
| Iron Spear | 2 iron, 3 wood | 26 damage, 40 thrown |
| Iron Arrows x10 | 1 iron, 4 sticks, 4 feathers | +50% damage |
| Nails x10 | 1 iron | building upgrades (below) |
| Iron Lantern | 2 iron, 1 resin | hands-free light you hang on a wall or carry; wolves hesitate like a torch, rain doesn't put it out |
| Cooking Pot | 3 iron | placed over a fire: **stews** that count for two diet groups at once (e.g. meat + greens), so a balanced diet is easier once you've earned it |
| Iron-banded Door | 2 iron, 4 wood, nails | strong door (wolves, later raiders, can't break it) |
| Iron armour (later) | bars + hide | damage reduction; heavy, slows stamina regen |
**Tool durability** (new, recommended): tools and weapons wear with use and
break (stone ~60 uses, iron ~200). Repair at a workbench (stone) or forge
(iron) for a little material. This is what makes iron worth getting rather
than a nice-to-have, and keeps you coming back to the forge.
## 3. Stone building
**Cut stone** comes from granite outcrops (iron pickaxe) or from cutting
ordinary stone at a **stonecutter** (8 wood, 4 iron, 10 stone).
New pieces (same 2 m grid, snap with the wood ones):
| Piece | Recipe | Notes |
|---|---|---|
| Stone floor | 4 cut stone | |
| Stone wall | 6 cut stone | |
| Stone pillar | 3 cut stone | carries load: resets support for what's on top |
| Stone stairs | 4 cut stone | first real stairs (also a wooden version) |
| Stone hearth | 10 cut stone | an indoor fire: heat in a radius 1.5x a campfire's, never rained out, cooks |
| Arch / doorway | 6 cut stone | takes a wood or iron door |
**Support rules:** stone that touches the ground has support 1 and loses
only 5% going up, so you can build tall stone towers and use **stone at the
bottom, wood on top** (Valheim style). Wood can't hold stone up: a stone
piece on a wooden one has no support. Stone pillars inside a wooden hall
let it span further. Interior walls and doors work as they do now.
**Why bother:** stone is sturdier (§4: things that break walls can't break
stone), it's warmer (a stone room counts as better shelter, a little extra
warmth), it doesn't burn (§5, lightning), and it lets you build big.
**Wooden upgrades** too, using nails: windows (light, still sheltered),
wooden stairs, fences and a **palisade wall + gate** (a ring round your base,
taller than a wolf can jump).
## 4. Adversaries
Each one is a different kind of problem, not just more HP.
| Adversary | Where / when | What it does | What beats it | Drops |
|---|---|---|---|---|
| **Wolves** (today) | nights from day 4 | pack circles, darts in | fire, torch, walls | pelt |
| **Alpha wolf** | every 7th night (the "wolf moon": the moon turns red, a big howl) | leads the pack, 3x HP, **breaks wooden doors** | iron door, stone walls, a fight with iron weapons | alpha pelt (warmest cloak), fang |
| **Bear** | daytime, deep forest near the rim; one per island | wanders, sniffs out food: **raids unattended chests with food and campfires with meat** | a palisade, stone storage, or a hard fight (spears, arrows from cover) | big hide (rugs, warm bedding: better rest), fat (resin/lantern fuel), lots of meat |
| **Barrow guards** | the **barrow**: an ancient burial mound with a stone ring, generated from the seed (like the den) | skeletal guards wake if you enter at night (or always after day 10); slow, hit hard, ignore torches | daylight raids, blunt weapons (club does +50% to bone) | iron scrap, a few iron bars, a **barrow key piece** (§6) |
| **Crows / foxes** | anytime, near food | steal berries and raw meat left on the ground or in open fires | a closed chest | — |
Creatures run on the server (needs M2.5's server-side creature framework),
so on a server everyone faces the same bear and the same alpha.
## 5. Hazards and pressure (not creatures)
- **Food spoilage**: raw meat and fish rot after 2 in-game days (berries
3, mushrooms 4). A **smoking rack** (wood, fibre) and **salt** (from the
sea-cliff rocks) preserve them. Cooked food keeps longer. Makes storing
food a choice, and feeds the bear/crow raids. *Decided: a server (and a
new single-player world) can set slower rot*, like the difficulty rules.
- **Food poisoning**: raw meat sometimes makes you sick (stamina regen
halved, can't eat for a minute). Cooking fixes it.
- **Blizzards**: on cold days a blizzard can roll in: visibility drops,
warmth falls fast outside, snow piles on roofs. Being sheltered matters.
- **Lightning**: in storms, lightning can strike tall things near the rim;
a struck tree catches fire and burns for a while. *Decided: trees only,
for now* (buildings are never struck; losing a base to weather can feel
unfair). Wooden pieces burning may come later as a server rule.
- **Hard winters**: every ~12 days, three cold days in a row (snow down to
the valley, the lake edge freezes, fewer animals). A seasonal goal: stock
food and fuel before it.
## 6. Something to aim for: the Old Stag (optional boss)
Three **barrow key pieces** (from barrow chests) open the **standing
stones** on the rim. At night they summon the **Old Stag**, a huge antlered
spirit: charges, summons a wolf pack, has phases. Beating it drops its
antler: the **antler pickaxe** and a trophy. The world remembers it was
beaten (a message on a server). *Decided: the boss is in.* The antler
pickaxe mines faster than iron, lasts a little longer and costs less
stamina a swing (no granite: P2 didn't add it).
It's a milestone, not a gate: nothing is locked behind it except bragging
rights and the best pickaxe. More bosses can come with new worldlets.
## 7. Phases and tests
| Phase | Delivers | Done when |
|---|---|---|
| **P1 Iron** | nuggets, rust boulders, clay, charcoal kiln, smelter, forge, iron tools/weapons/arrows/nails/lantern/pot, durability and repair | a test smelts a bar from ore it mined and crafts an iron axe; the axe fells a tree in half the hits and wears out after ~200 uses |
| **P2 Stone** | stonecutter, granite, stone pieces, stairs, pillars, hearth, stone-on-wood rule, windows, palisade | a stone tower 10 m high stands; stone on wood refuses; a hearth keeps a stone room Sheltered and Warm in a blizzard |
| **P3 Adversaries** | alpha wolf + wolf moon, bear and food raids, crows/foxes, the barrow and its guards | the alpha breaks a wooden door but not an iron one; the bear empties an unguarded food chest and ignores one behind a palisade |
| **P4 Hazards** | spoilage and preserving, food poisoning, blizzards, hard winters, (lightning) | meat rots on schedule and keeps on the rack; a blizzard drops warmth outdoors and not in a hearth room |
| **P5 Boss** | key pieces, standing stones, the Old Stag | a scripted fight test reaches the end and drops the antler |
**Order:** P1 and P2 are items, recipes and pieces through Actions, so they
work online straight away and can come any time (before or after M2.5).
P3 and P5 are creatures, so they should come **after M2.5**, built directly
on the server-side creature code instead of being written twice. P4 can come
any time; spoilage on a server runs with the shared clock.
## 8. Decisions
1. **Durability:** tools wear out and are repaired (built in P1).
2. **Lightning and fire:** lightning strikes trees only, for now; it never
sets buildings alight.
3. **Raids:** adversaries can damage and raid (the alpha breaks wooden
doors, the bear empties food chests; built in P3).
4. **The boss:** yes, the Old Stag (P5).
5. **Order:** P1, P2, M2.5, P3 done; P4 next, then P5.
6. **Spoilage:** a server (or a new world) can set slower rot.
7. **Antler pickaxe:** mines faster than iron, lasts a little longer, less
stamina a swing.

View File

@@ -23,20 +23,50 @@ actually run and see. Timelines assume solo dev + heavy AI assistance, steady
## M1 — Survival loop (single-player) · ~3–5 weeks
**Goal:** the core game verbs.
- [ ] Resource nodes: chop tree → wood, mine rock → stone
- [ ] Inventory + simple hotbar UI
- [ ] Crafting (recipe: wood → tools / building pieces)
- [ ] Health / stamina, day-night cycle
- [ ] Snap-together building: place a wall/floor, resource cost, basic support rule
Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d).
- [x] Resource nodes: chop tree → wood, mine rock → stone *(M1a)*
- [x] Inventory + simple hotbar UI, chests *(M1a)*
- [x] Crafting (recipe: wood → tools / building pieces) *(M1a)*
- [x] Pause menu, title screen, world slots, save/load, autosave *(M1a)*
- [x] Health / stamina / hunger / warmth, day-night cycle, fire fuel, cooking, foraging, torches, death + grave *(M1b)*
- [x] Weather (rain, storm, snow), wet/shelter *(M1c)*
- [x] Snap-together building: floor/wall/door/roof/gable, resource cost, support + collapse, dismantling *(M1c)*
- [x] Bed spawn point and sleeping, resource regrowth *(M1c)*
- [x] Creatures (rabbit, deer, boar), hunting, club/spear/bow, butchering, cooked-food buffs, hide cloak *(M1d)*
- [x] Wolves at night with a den and a 3-day grace period, wolf cloak *(M1e)*
- [x] Fishing (rod, perch and trout, grilling) and a mixed diet *(M1e)*
- [ ] Hide armour, more recipes, sea fishing *(later M1 polish)*
- [x] Wild flax (fibre plants) *(polish)*
- [x] Iron: nuggets, ore, clay, kiln, smelter, forge, iron tools and arrows, lantern, tool wear and repair *(P1)*
- [x] Stone building: stonecutter, cut stone, stone floors, walls, pillars, stairs, windows, doorways, hearth; wooden stairs, windows, posts, palisade and gate *(P2)*
- [x] The wolf moon and the alpha: breaks wooden doors, iron-banded doors hold, alpha pelt and cloak *(P3, part 1)*
- [x] The bear: raids unattended food, palisades and closed huts keep it out, bear hide rug, bear fat *(P3, part 2)*
- [x] Crows and foxes steal food left out; the barrow, its guards, the coffin and key pieces *(P3, part 3)*
- [x] Hazards: food spoilage (rot speed setting), smoking rack and salt, food poisoning, harsher blizzards, hard winters, lightning on trees *(P4)*
- [x] The standing stones and the Old Stag: a three-phase boss, its antler and trophy, the antler pickaxe *(P5)*
- [ ] **Iron, stone building and new adversaries**: iron nuggets and ore,
smelting and a forge, iron tools and durability, cut-stone building,
an alpha wolf, a bear, a barrow with guards, spoilage and blizzards
*(phases P1-P5, `docs/PROGRESSION_DESIGN.md`)*
- [ ] **Tutorial world**: a guided first island that teaches gathering,
crafting, chopping, food and the mixed diet, hunting, fishing,
shelter and the night *(phases T1-T4, `docs/TUTORIAL_DESIGN.md`)*
- **Done when:** gather → craft → build a tiny shelter, single-player.
## M2 — Multiplayer · ~2–4 months
**Goal:** self-hosted, authoritative, 2–8 players.
- [ ] Refactor state to server-authoritative (single-player becomes loopback server)
- [ ] Headless dedicated server mode (`--headless -- --server`)
- [ ] ENet transport, client prediction for local movement, interpolation for others
- [ ] Replicate resource nodes, inventory, building, health
- [ ] Persistence: world save/load on the server
Detailed plan in `docs/MULTIPLAYER_PLAN.md` (hosting on UDP 24242 via Tailscale
or port forwarding, player identity and names, server rules for co-op/PvP, and
phases M2.0-M2.7).
- [x] Server-authoritative online (single-player stays as it is, with no server) *(M2.4)*
- [x] Headless dedicated server, joining, identity handshake, names, player list *(M2.1)*
- [x] ENet transport, player bodies and name tags, local movement checked by the server, interpolation for others *(M2.2)*
- [x] Replicate resource nodes, pickups, building, chests, fires, doors, clock *(M2.3)*
- [x] Persistence: world save/load on the server *(M2.3)*
- [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)*
- [x] Animals, wolves, hunting, fishing and sleeping on the server *(M2.5)*
- [x] Server rules (PvP, raiding, locks and friends, death penalty, difficulty, wolf grace), chat and admin commands, server console *(M2.6)*
- [x] Host Game from the title screen, backups, launchd and systemd samples, hosting and Tailscale guide (`docs/HOSTING.md`) *(M2.7)*
- **Done when:** a friend joins your self-hosted server and you build together.
## M3 — Addon SDK + sandbox · ~2–3 months
@@ -57,7 +87,62 @@ actually run and see. Timelines assume solo dev + heavy AI assistance, steady
- PvP / raiding rules, building damage
- Better UI, settings, controls
- Audio, music, weather
- 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
signed, not notarised) and a Windows x86_64 .exe + .pck (unsigned), zipped
in build/. The same build runs a dedicated server (`--headless -- --server`).
Still to do: notarising (Apple Developer account), Windows signing, the
game icon on the .exe (rcedit), Linux, and the update check below.
- **Updates**: the game (and a dedicated server) needs to notice when a new
version is out, download what's needed and install it. See below.
### Updates (to design with the packaged builds)
Needed because every network change bumps the protocol (12 as of P5), and
an out-of-date game can't join an up-to-date server: today that means
`git pull` and a restart on every machine.
*Built so far (the check):* `scripts/core/updates.gd` asks the feed in
Project Settings (`emberwild/updates/feed`, now the git server's
`releases/latest` API; Gitea, Forgejo and GitHub answer alike) once at
start-up. It reads the release's `version.json` asset (written by
`tools/export.sh`: version, protocol, per-platform file, sha256 and size).
The title screen shows the version bottom left and, when a newer one is
out, a card with its notes and **Download** (this platform's zip, or the
release page). A "Different game version" refusal now names the server's
version and protocol and says whether you or the server needs updating;
a dedicated server says in its log when it's behind (`check_updates` in
server.json). Pause menu: **Check for updates at start**. Tests and bots
never touch the network. Still to do: downloading, verifying and
swapping it in (below).
**To publish a release:** bump `config/version` in project.godot and the
four version numbers in export_presets.cfg, run
`NOTES="..." tools/export.sh`, and make a release on the git server (tag
`v<version>`) with the two zips and build/version.json attached. The repo
(or at least its releases) must be readable without logging in.
- **Knowing there's an update:** the game checks a small version file
(version number, protocol, per-platform download URLs and checksums) at
start-up and from the title screen. The server checks too. When joining
a server with a newer protocol, the "different game version" refusal
should offer to update instead of just saying so.
- **Getting it:** download only what changed where possible (the game's
`.pck` data separately from the engine binary, which rarely changes), to
a staging folder, verified by checksum (and signature) before anything
is replaced.
- **Installing it:** swap in the new files on the next start (a tiny
launcher or a restart-and-replace step), keep the previous version to
roll back to if the new one fails to start. Mac: signed and notarised so
Gatekeeper accepts it; Windows: signed. Worlds, profiles and server
config live in user data, so they're never touched; saves stay
readable by newer versions (they already load older saves).
- **Servers:** a headless server can update itself at a quiet moment
(nobody on, or after warning players), or an admin runs an update
command; the launchd/systemd service files restart it.
- **Where updates come from:** GitHub Releases is the simple start (the
version file and builds attached to a release); itch.io's butler or
Steam would handle it for us if the game ships there.
- **Addons (M3)** will want the same: a version per addon and the server
telling players which addons (and versions) it needs.
## Honest overall estimate
A genuinely playable **self-hosted survival game with a working sandboxed addon

View File

@@ -4,17 +4,66 @@ This project is a self-contained git repo. Everything needed to build and run is
in it except **Godot itself** (which you install once per machine). No assets to
download — the M0 world is generated in code.
## 1. Install Godot 4.3+
## 1. Install Godot 4.7+
- **macOS / Windows / Linux:** download the *standard* build (NOT the .NET/C#
build — this project is pure GDScript) from https://godotengine.org/download
- Use **4.7.x or newer 4.x**. The project was upgraded to `4.7.2.stable` (an
older editor will warn that the project is from a newer version).
- Use **4.7.x or newer 4.x**. The project is on `4.7.2.stable`. **Older
versions (4.3 etc.) can't run it**: they fail with script errors. Check
with `godot --version`, or `/Applications/Godot.app/Contents/MacOS/Godot
--version` on a Mac.
- macOS shortcut: `brew install --cask godot`. If Homebrew can't unpack it,
download the zip from the GitHub release page and move `Godot.app` to
`/Applications`.
`/Applications` (replacing any older one).
## 2. First run on a fresh copy: import once
Godot keeps its list of named classes (e.g. `Inventory`) in `.godot/`, which
isn't in git. Opening the project in the editor creates it. If you only run
**headless** (a server, or the tests), import once after cloning:
```bash
godot --headless --path . --import
```
Without it you get errors like `Could not find type "Inventory"`.
`server/run_server.sh` and `tests/run_all.sh` do this for you.
## Playing
```bash
./play.sh
```
It finds Godot (on the PATH, or `/Applications/Godot.app`; set `GODOT=` to
pick one), checks it's 4.7+, imports a fresh copy once, then opens the game.
## 3. Running a dedicated server
```bash
server/run_server.sh # settings from server.json
server/run_server.sh --server-name "Paul's island" --password secret --port 24242
```
It checks the Godot version, imports a fresh copy, then starts the server on
UDP 24242 with no window. Settings live in `server.json` (its path is printed
at start-up; on macOS it's under
`~/Library/Application Support/Godot/app_userdata/Emberwild/server/`).
The shared island is saved in `worlds/server-world/` in that same folder,
within 10 seconds of any change (and when someone leaves). Stop it with
Ctrl+C. Players join with **Join Game** on the title screen. Over
Tailscale, use the server machine's 100.x address or its machine name. See
`docs/MULTIPLAYER_PLAN.md` for all the options, and `docs/HOSTING.md` for
hosting from the title screen, running it as a service, backups and
Tailscale.
## Tests
Run everything (fails on any failed check or script error):
```bash
tests/run_all.sh
```
Or one at a time:
```bash
# Wolves: den, grace period, hunting, fire and walls, pack tactics
godot --headless --path . --script res://tests/test_wolves.gd
# Fishing and the mixed diet
godot --headless --path . --script res://tests/test_fishing_diet.gd
# Wildlife, melee, spear, bow, butchering, cooking + buffs, cloak, drops
godot --headless --path . --script res://tests/test_creatures_combat.gd
# Weather, shelter, snap building, support, doors, beds, regrowth
godot --headless --path . --script res://tests/test_weather_building.gd
# Boundary: tries to walk/jump out of the valley in 24 directions
godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd
# Terrain: no deep pits / floating water at the boundary, across 8 seeds
@@ -23,14 +72,26 @@ godot --headless --path . --script res://tests/test_terrain_edges.gd
godot --headless --path . --script res://tests/test_survival_core.gd
# Save/load round trip + world slots (uses user://test_worlds, not your saves)
godot --headless --path . --script res://tests/test_save_load.gd
# Multiplayer lobby: a real headless server on UDP 24343 + scripted bot players
godot --headless --path . --script res://tests/test_net_lobby.gd
# Multiplayer world: avatars, smooth movement at 100 ms latency, cheat checks (UDP 24345)
godot --headless --path . --script res://tests/test_net_world.gd
# Survival stats: hunger, warmth, fire fuel, cooking, foraging, death + grave
godot --headless --path . --script res://tests/test_survival_stats.gd
```
## Save files
Worlds are per machine, under
`~/Library/Application Support/Godot/app_userdata/Valheimlike/worlds/` on
macOS (`%APPDATA%\Godot\app_userdata\Valheimlike\worlds\` on Windows). Copy a
`~/Library/Application Support/Godot/app_userdata/Emberwild/worlds/` on
macOS (`%APPDATA%\Godot\app_userdata\Emberwild\worlds\` on Windows). Copy a
world's folder across to move it to another machine.
The game used to be called Valheimlike, and its data lived in
`app_userdata/Valheimlike/`. The first time Emberwild starts it copies worlds,
your identity and server settings across to `app_userdata/Emberwild/`
(`scripts/core/boot.gd`). The old folder is left alone as a backup, and you
can delete it once you're happy.
## 2. Get the project onto the machine
You have the folder (from the zip, or a git remote). Then:

View File

@@ -55,7 +55,13 @@ Item fields: `id`, `name`, `description`, `stack`, `category`
| Animals *(M1d)* | weapon | raw meat, hide, bone |
Tool **tiers** gate harder resources later (e.g. iron ore needs a tier-2
pickaxe). Felled trees leave stumps. Resources regrow over time *(M1c)*.
pickaxe). Felled trees leave stumps.
**Regrowth** *(built in M1c, `props.gd`)*: felled trees grow back after
2400 s (2 days), mined boulders after 3600 s, picked-up sticks/stones/flint/
mushrooms after 900 s. A timer waits (retries every 2 min) while you're
standing on the spot or something is built there. Timers are saved, and
sleeping advances them.
### 2.4 Crafting & stations
Recipes list inputs, an output and an optional **station** that must be within
@@ -65,12 +71,34 @@ panel shows every recipe you know, greyed out with the missing inputs in red.
### 2.5 Building
- **Placement mode**: holding a placeable shows a ghost preview (green = valid,
red = blocked). R rotates it, left-click places it.
- M1a pieces: campfire, workbench, chest.
- *(M1c)* Snap-together pieces: wood floor, wall, door, roof, stairs, with
snap points and Valheim-style **structural support** (stability falls off
with distance from the ground; unsupported pieces collapse).
- **Shelter detection**: you count as sheltered when rays upward hit a roof and
most sideways rays hit walls. That drives "dry" and "rested" status.
- M1a pieces: campfire, workbench, chest. M1c adds a bed.
- *(built in M1c)* Snap-together wood pieces, crafted at a workbench
(shift-click Craft makes 5): floor (2 wood), wall (2), door (4), roof
(2 wood + 1 fiber), gable (1). Everything is 2 m on a side. A roof rises
1 m over 2 m, so two roofs from opposite walls of a 4 m hut meet at a ridge,
and gables close the ends. Geometry and snap points live in
`build_piece.gd`, rules in `build_system.gd`.
- **Snapping**: the ghost sits on your crosshair, then moves to the nearest
pair of snap points (its own and a nearby piece's), with rotation locked to
that piece's 90-degree grid. R turns building pieces a quarter turn. Away
from other pieces, floors sit level on the highest ground under them.
- **Support**: touching the ground = 100%. Otherwise a piece takes the best
of its neighbours minus 10% going up/down or 25% sideways. Under 30% it
can't be placed, so an overhang reaches about 4 floors.
- **Dismantling**: X on any placed piece puts it back in your inventory
(chests must be empty, graves can't be). Pieces left below 30% support
collapse and come back to you too. Loose chests/fires/beds drop to the
ground.
- **Shelter detection**: roofed = 3 of 5 rays straight up hit a building piece.
Sheltered = roofed and at least 6 of 8 sideways rays hit walls within 8 m.
Roofed keeps rain off you (and off a campfire). Sheltered cuts the weather's
chill to a quarter, adds +8 warmth, and with a fire nearby makes you Rested
after 20 s (stamina regen +50% for 5 min).
- **Bed**: E sets your spawn point (death respawns you there while it
stands). E again at night (19:30 to 05:30) sleeps until 06:00 if the bed is
sheltered: the clock, fires, regrowth and weather move on, you lose a
little food, and you wake dry, warm and Rested.
- Grass doesn't grow through floors.
### 2.6 Survival stats *(M1b)*
| Stat | Drains from | Restored by | At zero |
@@ -84,28 +112,160 @@ Status effects: **Wet** (rain or swimming: warmth drains faster, lasts after
you're out), **Cold** / **Freezing**, **Warm** (near a fire), **Sheltered**,
**Rested** (sat by a fire under a roof: stamina regen buff).
Built in M1b (`scripts/player/survival.gd`), current numbers:
- Hunger: full to empty in 1500 s (about 1.25 days), 1.5x faster below 30
warmth. Eating is refused at 98+. Berries +9, mushroom +6, roasted
mushroom +24.
- Warmth eases toward a target: air warmth (22 at night to 68 at noon, -0.9 per metre above 12 m) plus
up to +70 from a fire or a held torch. Warms at 7/s, cools at 1.6/s.
- Wet: 90 s after leaving deep water, -22 to the warmth target, dries 3x
faster by a fire.
- Stamina: regen 24/s after a 1 s pause. Sprint 14/s, jump 8, harvest hit 6.
- Health: -0.6/s starving, -0.5/s below 12 warmth, +0.5/s when fed (>40)
and warm (>35).
- Campfire: starts with 2 min of fuel, holds 15 min, +fuel with wood (E),
heat radius 7 m. Out of fuel = no heat, no cooking.
- Berry bushes regrow after 10 min. Torch = 1 stick + 2 fiber.
### 2.7 Time & weather *(M1b / M1c)*
- Day/night cycle: 20 real minutes per day by default. The sun, sky, fog and
ambient light follow time of day. Nights are dark enough that a campfire or
torch matters.
- Weather is a seeded state machine: clear → cloudy → rain → storm, plus
**snow** on cold days and always near the snowline. Each state sets the
cloud cover, fog, wind (grass/trees already read wind), particles and
sound, plus a warmth modifier. Transitions blend over about a minute.
- *(built in M1c)* Weather is a function of the world seed and the absolute
world time (`weather.gd`), so it isn't saved and every M2 client can
compute the same weather. A slow noise "front" picks the state. Measured
over 60 days on seed 1337: clear 38%, cloudy about 30%, rain/snow 33%,
storm 12%. A wet spell lasts about 6 minutes. A new world's first
10 minutes are always clear.
- About 1 day in 4 (never day 1) is a **cold day** (-8 warmth): rain falls as
snow and storms become blizzards. Above the snowline (28 m) it always
snows.
- Warmth from weather: cloudy -3, rain -6, storm -12, snow -14, blizzard -24.
Rain on you (no roof) keeps you Wet. Snow chills but doesn't soak. Rain on
an uncovered campfire burns its fuel up to 4x faster.
- Each state sets cloud cover, sun and ambient light, fog, wind and
precipitation, blending over about 50 s. Storms flash lightning, with
thunder 0.5 to 2.5 s later.
- Visuals and sound (`weather_fx.gd`, client side): GPU rain and snow
particles stopped by a heightfield collider, so it doesn't rain indoors.
Wet ground, settled snow (on terrain, rocks, grass and tree crowns) and wind
strength are global shader uniforms. Rain, wind and thunder sounds are
synthesised from filtered noise, and rain is muffled under a roof.
### 2.8 Creatures & hunting *(M1d)*
- **Rabbit**: skittish, fast, gives a little meat.
- **Deer**: flees on sight or sound, gives meat and hide.
- **Boar**: neutral; charges if hit; gives meat and hide.
- *(later)* **Wolf**: night predator, hunts in packs.
- Simple state machines (idle / wander / flee / attack) on navigation-free
steering over the heightmap. Spawned around the player within budget.
### 2.8 Creatures & hunting *(built in M1d)*
| Animal | HP | Run speed | Notices you | Behaviour | Butchering gives |
|---|---|---|---|---|---|
| Rabbit | 10 | 7 m/s, zig-zags | 7 m (12 m if you sprint) | flees | 1 raw meat |
| Deer | 40 | 9 m/s | 16 m (30 m if you sprint) | flees, outruns you | 2-3 meat, 1-2 hide |
| Boar | 60 | 6.6 m/s | never | neutral; hit it and it charges and gores you for 14 every 1.6 s for 18 s, and flees below 30% HP | 3-4 meat, 1-2 hide |
### 2.9 Weapons & combat *(M1d)*
- Club (hand), wooden spear (thrust; can be thrown), bow + arrows (workbench;
needs feathers and flint).
- A melee swing is a short shape cast in front of the camera. It costs stamina
and has a cooldown. Damage numbers come from item data.
- **Wolf** *(built)*: 45 HP, 8.6 m/s, bites for 9 (every 1.2 s at most).
Butchering gives 1-2 meat and a wolf pelt. See 2.8a.
- `scripts/creatures/creature.gd` runs each animal's state machine (idle,
wander, flee, charge, dead). Steering is over the heightmap with no navmesh:
a probe ahead turns the animal away from water, steep drops, the world edge
and trees, rocks or buildings. Legs are animated at the hips. The meshes are
procedural (`creature_meshes.gd`).
- `spawner.gd` keeps up to 6 rabbits, 4 deer and 3 boars, spawning 30-70 m
away and despawning them past 100 m. Each kind prefers its habitat: rabbits
in meadows, deer at forest edges, boars deep in the woods. A kind waits
25 s to respawn after one dies. Animals aren't saved, so a loaded world
repopulates straight away.
- A kill leaves a **carcass** (on the interactable layer). E butchers it, and
arrows stuck in it come back to you. Carcasses rot away after 5 min.
- Feathers lie in the meadows as ground pickups (70, regrowing like sticks).
### 2.8a Wolves and the den *(built)*
- **The den** (`den.gd`): a cave of piled boulders at the foot of the
mountain rim, chosen from the seed. It sits away from the sea and the
spawn, opens toward the valley, and has a clearing in front of its mouth.
It has a rock roof (it counts as shelter) and old bones on the floor.
- **Grace period** (`wolf_pack.gd`):
- Days 1-2: no wolves at all.
- Day 3: wolves move into the den and howl at night, with a toast
warning. They don't hunt yet.
- Night 4 onward: the pack hunts (night is 19:30 to 05:30, the same as
the bed). Pack size is 2, then 3 from day 6 and 4 from day 9. Wolves
you kill are replaced at the den by day, while you're away from it.
- **Temperament: wary pack hunters**, neither berserkers nor harmless:
- The pack circles you about 10 m out, and one wolf at a time darts in to
bite, every 4.5 s or so.
- A **lit campfire** within 8 m is a safe zone: they circle 14 m out
around the fire and never close in.
- A **held torch** makes them hesitate (2.5x longer between darts).
- **Walls work**: a bite needs a clear line from the wolf to you, so a
closed hut is safe. You also can't sleep while wolves prowl within 40 m.
- Hit one and the whole pack turns on you. Below 40% HP a wolf runs off
for the night.
- The **last wolf** of a pack is timid: it keeps its distance, backs off,
and won't start a fight.
- **By day** they rest in the den but defend it. Within 14 m they get up
and watch you; within 8 m (or if you hit one) they attack.
- At dawn they go home. Once out of your sight they slip straight back.
- Wolves glow-eyed at night (emissive eyes). Howls are synthesised.
- Animals now take a short committed detour when blocked, so they slide
round huts and rocks instead of turning back into them.
- **Wolf Cloak** (3 wolf pelt + 2 hide + 4 fiber, workbench): +25 warmth,
wet chill x0.35.
### 2.8b Fishing *(built)*
- **Fishing Rod** (2 sticks, 3 fiber, 1 flint at a workbench). LMB casts
the float in a lobbed arc. It has to land on lake water at least 0.5 m
deep: on land you're told so and nothing bites.
- A bite comes after 5-14 s, sooner at dawn and dusk (x0.6) and in the rain
(x0.8). The float bobs and dips; you have **1.5 s** to click and land the
fish, or it gets away and you wait again. Clicking with nothing biting
just reels in. Walking more than about 30 m off snaps the line, and
switching away from the rod reels in.
- Catch: perch (common) and trout (22%, or 40% at dawn and dusk). Grill
them at a campfire. A few fish swim visibly in the lake, and some jump.
- Fish, weights and bite timing live in `data/fish.json` per water type.
The float asks `world.water_at()` what it landed in (lake, sea...), so a
worldlet with a sea only needs a "sea" entry there (docs/WORLDLETS_DESIGN.md).
### 2.8c Mixed diet *(built)*
Every food belongs to a group: **meat** (raw meat 8, cooked 40), **fish**
(raw 8-12, grilled perch 30, grilled trout 45) or **greens** (berries 14,
mushroom 10, roasted mushroom 30). Each group has a level (0-100, starting
at 60) that runs down over about 1.5 days (1800 s). The HUD shows three
small Diet bars, and a low group blinks.
- **One group under 15**: Poor diet (need fish). No natural healing.
- **Two or more under 15**: Malnourished. Health drains 0.12/s and stamina
recovers at 70%. Living on berries alone keeps you full but kills you in
about a day and a half.
- **All three at 40 or more**: Balanced diet, healing x1.5.
- Diet is saved. After death each group is at least 30, so you don't
respawn straight into malnutrition.
### 2.9 Weapons & combat *(built in M1d)*
| Weapon | Recipe | Use | Damage |
|---|---|---|---|
| Bare hands, food, torch, resources | | | 0: you need a weapon (the prompt says so) |
| Axe / pickaxe | | LMB | 6 (poor weapons) |
| Wooden Club | 4 wood | LMB, reach 2.4 m | 12 |
| Wooden Spear | 3 wood, 2 flint, 1 fiber (workbench) | LMB thrust (reach 3.1 m); RMB throws it (24 m/s, then pick it up) | 18 / 30 thrown |
| Crude Bow | 4 wood, 6 fiber (workbench) | hold LMB to draw (0.9 s full), release | 8-32 by draw |
| Flint Arrow x5 | 2 sticks, 1 flint, 2 feathers (workbench) | ammunition; misses stick in the world and can be picked up | |
- A melee swing is a box sweep in front of the camera, tall enough to reach a
rabbit at your feet. It costs stamina and has a per-weapon cooldown.
Animals are hit first, then trees and rocks.
- Arrows and thrown spears are projectiles (`projectile.gd`) with gravity,
ray-cast segment by segment so fast arrows can't skip through a target.
Damage scales with speed at impact. Arrows and spears lying in the world
are saved.
- Getting hurt flashes the screen red and stops natural healing for 6 s.
Animals and players use the same `damage()` path, ready for M2.
### 2.9a Food buffs and clothing *(built in M1d)*
- Raw meat: +5 food, -4 health (it can't take you below 1). Cooked meat, made
at a campfire, gives +34 food, +12 health and **Well fed** for 10 min:
stamina regen x1.3 and healing x2. The HUD shows the time left. Buffs are
saved. They don't stack, so the strongest applies.
- **Hide Cloak** (4 hide + 4 fiber at a workbench): RMB to wear it. It gives
+15 warmth and cuts the chill from being wet by 40%. You take it off from
the inventory screen. It's saved, and it goes into your grave when you
die.
### 2.10 Saving & worlds *(built)*
- **One continuous world per save slot.** The title screen offers Continue
@@ -115,15 +275,18 @@ you're out), **Cold** / **Freezing**, **Warm** (near a fire), **Sheltered**,
- **Autosave** every 2 minutes, on Save & Quit (to the title or desktop) and
when the window is closed.
- Saves live in `user://worlds/<id>/` (on macOS:
`~/Library/Application Support/Godot/app_userdata/Valheimlike/worlds/`) as
`~/Library/Application Support/Godot/app_userdata/Emberwild/worlds/`) as
`meta.json` + `world.json`. Writes are atomic, and the previous save is
kept as `world.json.bak`.
- A save stores only what the player changed: player position and look,
inventory, hotbar slot; placed pieces and chest contents; tree and boulder
damage, stumps, mined rocks; collected pickups. Everything else regenerates
from the seed. *(Later: stats, time of day, weather, regrowth timers.)*
from the seed. Since M1b it also stores stats, time of day, bush regrowth
timers, campfire fuel and graves. Since M1c: building pieces (door open or
shut), the bed spawn point and regrowth timers. Weather isn't saved: it
follows from the seed and the time.
### 2.11 Death *(M1c)*
### 2.11 Death *(built in M1b)*
- On death you respawn at your bed (or the island spawn) and leave a **grave
bag** with your inventory where you died (Valheim-style: punishing but
recoverable). The world carries on.
@@ -146,9 +309,9 @@ you're out), **Cold** / **Freezing**, **Warm** (near a fire), **Sheltered**,
| Phase | Delivers | Playable "done when" |
|---|---|---|
| **M1a** *(done)* | items/recipes data, inventory + hotbar, pickups, chop/mine with tools, crafting UI, campfire / workbench / chest placement, chest storage, pause menu, **title screen + world slots + save/load + autosave** (pulled forward from M1c) | you craft an axe, fell a tree, build a fire, stash wood in a chest, quit and continue |
| **M1b** | hunger/warmth/stamina/health HUD, day/night cycle, campfire warmth + fuel, cooking, bushes (fiber, berries), torches | you survive your first night by the fire |
| **M1c** | weather (rain, storm, snow) + wet/shelter, snap building (floor/wall/roof/door) with support, death + grave bag, resource regrowth | you build a hut and wait out a storm |
| **M1d** | rabbit/deer/boar AI, club/spear/bow, hides, cooked food buffs | you hunt a deer and eat its meat |
| **M1b** *(done)* | hunger/warmth/stamina/health HUD, day/night cycle with moon and stars, campfire warmth + fuel, cooking, bushes (fiber, berries), mushrooms, torches, **death + grave bag** (pulled forward from M1c) | you survive your first night by the fire |
| **M1c** *(done)* | weather (rain, storm, snow, blizzard, lightning, sound) + wet/shelter/rested, snap building (floor/wall/door/roof/gable) with support, dismantling and collapse, bed spawn point + sleeping, resource regrowth | you build a hut and wait out a storm |
| **M1d** *(done)* | rabbit/deer/boar AI and spawning, club/spear (thrown)/bow + arrows, butchering, hides, hide cloak, cooked meat + Well fed buff, feathers | you hunt a deer and eat its meat |
## 5. Open decisions (current defaults in bold)
- Thirst stat? **No**, to keep the stats readable (Valheim has none).

237
docs/TUTORIAL_DESIGN.md Normal file
View File

@@ -0,0 +1,237 @@
# Tutorial world design
Status: **T1, T2 and T4 built**: the whole tutorial, on the Valley island
(seed 1337), with Old Tam the hermit, and his tips in normal worlds. T3
(its own island) waits for the worldlets.
*As built in T4:*
- 16 tips in data/hints.json, in Old Tam's voice: soaked, cold, freezing,
hungry, a poor diet, food poisoning, food gone rotten, nightfall, the
wolves' howl, the red moon, a hard winter, snow, a storm, out of
breath, a first proper shelter, and back on your feet after dying.
- The `Hints` autoload (scripts/core/hints.gd) checks your own survival
statuses, the weather, the clock and your bag, plus two events (a howl,
a death), so tips work the same online (the server mirrors statuses).
Each shows once per player (user://hints.json), in list order, 16 s
apart, never while you're dead, and not in the tutorial world or on a
server.
- A card top right (scripts/ui/hint_card.gd) for 14 s; H puts it away.
Pause menu: **Old Tam's tips** on/off and **Show again**. On by default.
- tests/test_hints.gd. Headless runs never start tips unless a test asks
(so the suites can't use up your real ones).
*As built in T2:*
- All five chapters, 22 steps (data/tutorial.json), in Old Tam's voice:
first tools; fire and food (campfire, berries, a roasted mushroom, the
diet bars); hunting (workbench, a weapon, a rabbit, butcher and cook,
a tip about the other animals); fishing (rod, a catch, grilled); shelter
and night (a hut you can stand inside, a bed and spawn, nightfall and a
torch, the demo wolf, sleeping).
- More goals: `any` (club or spear, perch or trout), `shelter`,
`bed_spawn`, `demo_wolf`, `continue` (Enter on the reading steps).
- Step set-ups: the clock jumps to dusk (18:36) and to bedtime (21:00);
three rabbits appear 14 m off for the hunt; the demo wolf
(scripts/creatures/tutorial_wolf.gd) circles at 11 m for 18 s and slinks
off, never biting.
- Markers for berry bushes, rabbits, the carcass, the lake shore.
- The tutorial island is calm: no wolf pack, no bear, no thieves.
- Title screen: **Tutorial** above New World (it says "carry on" once
started; with no worlds yet: "New here? Try the tutorial"). Pause menu:
Skip this step, Leave the tutorial (saved, carry on later).
- The end: Old Tam's goodbye card, **Start your own world** (to the New
World page) or **Keep exploring**.
- tests/test_tutorial_full.gd: a bot plays all 22 steps through Actions,
in order, to the card. (Bulk materials, like a hut's wood, go straight
into its bag; every goal is done as a player does it.)
- The hut outline (§2 step 17): Old Tam marks out a 20-piece hut, pale
as a ghost, on the first clear flat spot 5 m or more from your fire
(scripts/ui/hut_outline.gd). A piece held within 2 m of its matching
ghost snaps right into it; each ghost fades as it's filled, and the
marker points at the outline. Building elsewhere still counts. The
outline goes when the step ends or the tutorial is left.
- Not yet: sounds for the tick.
*As built in T1:*
- Actions has signals for what players do: `crafted`, `harvested` (with
whether it fell), `placed`, `ate`, `killed`, `butchered`, `caught`,
`slept` (plus the old `item_gained`).
- The `Guide` autoload (scripts/core/guide.gd) runs data/tutorial.json:
goals `walk`, `gained`, `crafted`, `felled`, `placed`, `ate`, `killed`,
`butchered`, `caught`, `slept`, `continue`, and `all` of several. It
counts from the tutorial's start, so a step done early ticks the moment
it comes up. Markers: the nearest pickup of a kind, tree or boulder,
hidden once you have what it's for (`unless_have`).
- The guide panel (scripts/ui/guide.gd) under the clock: chapter, step n of
m, title, how, and a count ("Sticks 2/3 · Stone 0/3"); a tick when a
step's done; H hides it. The marker is a pale glowing post and ring, with
an arrow and distance at the screen edge when it's off-screen.
- A world with `"tutorial": true` in its meta.json runs it (the title
screen entry comes in T2); `-- --tutorial` runs it on any world for now.
Progress is saved with the world.
- Chapter 1 (First tools) is in the data; chapters 2-5 come with T2. The
guide is **Old Tam**, the island's hermit (decided): the steps are in his
voice, with his name on the panel.
A short, guided first world that teaches the survival loop by having you
do it: gather, craft, chop, eat, hunt, cook, fish, keep a mixed diet,
build a shelter and get through a night. It uses the real game systems
(Actions, crafting, creatures, diet), so what you learn is exactly how the
real game behaves, and nothing in it can drift out of date.
## 1. What the player sees
- The title screen gets **Tutorial** (above New World). The first time the
game starts with no worlds, it suggests it: "New here? Try the tutorial
(about 20 minutes)".
- You wake on a small, calm island (the **Tutorial worldlet**, §4) on a
clear morning. A short path loops round it past a few marked spots.
- A **guide panel** (top right, under the clock) shows the current step: a
title, one or two lines of how, and a progress count ("Sticks 2/3").
A soft **marker** (a pale glowing post or a ring on the ground, plus an
arrow at the screen edge when it's off-screen) shows where to go.
- When a step is done: a tick, a short sound, and the next step slides in.
`H` hides the panel; the pause menu has **Skip step** and **Leave
tutorial**.
- Nothing is forced. You can wander off, do steps early (they tick
straight away) or ignore them. The panel only waits for the thing it
asked for.
## 2. The steps
Grouped into chapters. Each step completes on a real game event, never on
a timer. Numbers below match today's recipes and items.
**1. First tools**
1. *Look around.* Move with WASD, look with the mouse. (Done: walked 10 m.)
2. *Pick up 3 sticks and 3 stones.* "Look at one and press E." Marker on a
cluster by the path.
3. *Pick wild flax.* "The pale tall plants give fibre, for torches, bows
and beds."
4. *Open your inventory (Tab) and craft a Stone Axe.* Highlights the recipe.
5. *Put the axe in your hand* (1-8 or the hotbar) *and chop a tree.* "Hold
LMB at the trunk. Trees give wood, and sticks when they fall."
6. *Craft a Stone Pickaxe and break a boulder* for stone and flint.
**2. Fire and food**
7. *Build a campfire* (place with LMB, rotate with R). "Fire gives light
and warmth, and you cook on it. Feed it wood or sticks with E."
8. *Pick berries* from a bush, then *eat some* (RMB with them in hand).
9. *Find mushrooms* under the trees. "Raw is fine, roasted at the fire is
much better." Roast one.
10. **The diet.** The panel points at the HUD's three Diet bars: Meat,
Fish, Greens. "Each food fills one. Keep all three up: let one run low
and you stop healing; two, and you start starving even on a full
stomach. All three high: you heal faster." Done when you've read it
(Continue).
**3. Hunting**
11. *Craft a Wooden Club*, or a Spear at a workbench (build a workbench
first: "most tools and weapons need one nearby").
12. *Hunt a rabbit.* The tutorial island has a fenced meadow with rabbits
that stay nearby. "Rabbits bolt if you sprint at them. Walk up close."
13. *Butcher it* (E on the carcass) *and cook the meat at the fire.*
"Raw meat hurts you. Cooked meat heals and makes you Well fed."
14. *(Optional, shown as a tip)* Deer outrun you (use a bow); boars are
calm until you hit one, then they charge. The panel shows each animal
with a small picture: what it drops and how to take it.
**4. Fishing**
15. *Craft a Fishing Rod* (2 sticks, 3 fibre, 1 flint at the workbench).
16. *Catch a fish* from the pond. "Cast with LMB. When the float dips,
click within a second and a half." Grill it. Fish bar fills.
**5. Shelter and night**
17. *Build a small hut*: 4 floors, walls, a door and a roof. A ghost
outline shows where (you can build anywhere, the outline is a
suggestion). "Pieces need support: walls on floors, roofs on walls."
18. *Craft a bed, place it inside and set your spawn* (E).
19. **Night.** The clock jumps to dusk. "It gets cold and dark. Stay by a
fire, carry a torch, or get inside." Craft a torch.
20. **Wolves** (from a safe distance). One wolf appears at the edge of the
firelight and circles. "Wolves come out from night 4 in a real world.
They won't come close to a lit fire, a torch makes them hesitate, and
they can't bite through walls." It slinks off. (A scripted wolf: it
never attacks in the tutorial.)
21. *Sleep in your bed until morning.* "You can only sleep at night, under
a roof, with walls round you, and not with wolves about."
**Finish.** A summary card: what you learned, plus a reminder of the
shortcuts (E, Tab, X to dismantle, R to rotate, RMB to eat or wear), then
**Start your own world** (to New World) or **Keep exploring** (stays on the
tutorial island).
Also covered as short tips when they first become relevant, in the
tutorial and later in real worlds (§5): getting wet and cold in rain,
death and your grave, stamina, carcasses rotting, regrowth, dismantling.
## 3. How it works (code)
- **`data/tutorial.json`**: the steps as data. Each has an id, chapter,
title, text, an optional marker ("near": a named spot), an optional
set-up (give an item, set the time, spawn the wolf) and a **goal**: an
event plus a count.
```json
{"id": "sticks", "title": "Gather sticks and stones",
"text": "Look at one and press E.",
"marker": "start_pickups",
"goal": {"all": [{"gained": "stick", "count": 3}, {"gained": "stone", "count": 3}]}}
```
- **Goal events** come from what already exists: `Actions.item_gained`,
crafting, placing a piece, `Actions.notify`-level events, a creature's
death, a carcass butchered, eating (by food group), a fish caught,
sleeping, walking a distance. A small **`Guide` autoload** listens to
those, checks the current step's goal and moves on. Adding a few signals
to Actions (`crafted`, `placed`, `ate`, `killed`, `caught`, `slept`) is
the main engine change, and they're useful for addons (M3) too.
- **The guide panel and marker** are UI only (`scripts/ui/guide.gd`).
- **Progress is saved** in the tutorial world's save (current step, done
steps), so you can quit and come back.
- **Scripted bits** are a handful of small hooks, not a scripting language:
set the time, spawn a calm creature group in a fenced area, spawn the
"demo wolf" (a wolf in a state that circles and leaves), show a ghost
outline for the hut.
## 4. The tutorial island
A **worldlet** (docs/WORLDLETS_DESIGN.md), about 160 m across, with a
fixed seed, so everyone gets the same island:
- small and calm: one pond (for fishing), a stand of trees, a few
boulders, berry bushes and mushrooms along the path, flax by the pond;
- a fenced rabbit meadow; no wolves except the scripted one; deer and a
boar further off for the optional tip;
- **named spots** ("start_pickups", "first_tree", "pond", "hut_site"...)
that the steps' markers point at;
- the clock starts at 08:00, clear weather, and the wolf grace rules are
replaced by the script.
Until the worldlet work is done (W0-W2), a **first version** can run on
the normal Valley island with a fixed seed (a sheltered spot near the
spawn as the tutorial area), with markers placed by searching for the
nearest tree, bush, boulder and so on. Everything in §3 carries over
unchanged when the proper tutorial island arrives.
## 5. Hints in real worlds (optional, same system)
The same Guide can run **light hints** in normal worlds, off by default
for experienced players and on for anyone who finished or skipped the
tutorial: one-off tips the first time something happens (first rain, first
night, the Day 3 howl, a low diet bar, first death). A setting turns them
off.
## 6. Multiplayer
The tutorial is **single-player only** (it's your own world). Hints in
real worlds work online too, since they only listen to your own events.
## 7. Phases and tests
| Phase | Delivers | Done when |
|---|---|---|
| **T1** | Actions events (`crafted`, `placed`, `ate`, `killed`, `caught`, `slept`), the Guide autoload, `data/tutorial.json`, the guide panel and markers | a headless test plays the first chapter by calling Actions and every step ticks in order |
| **T2** | the whole tutorial on the Valley island (fixed seed): all chapters, scripted night and demo wolf, title-screen entry, saved progress | a headless bot completes every step; screenshots of each chapter |
| **T3** | the tutorial island worldlet (after W0-W2) with the fenced meadow and named spots. *On hold until the worldlet work starts (Paul, Nov 5).* | the same bot completes it there, and the shared suites pass on it |
| **T4** | hints in real worlds, with a setting | each hint fires once, on the right event |
T1-T2 don't depend on anything else and could come before or after M2.4.
## 8. Questions for you
1. **Length:** roughly 20 minutes for everything above. Shorter (just
chapters 1-2, the rest as hints), or is full-length right?
2. **Tone of the text:** plain and friendly (as written), or with a bit of
character, like an old hermit who lives on the island and talks you
through it?
3. **Before or after the rest of M2?** My suggestion: after M2.4, so the
tutorial teaches the game as it'll be when you play with friends.

166
docs/WORLDLETS_DESIGN.md Normal file
View File

@@ -0,0 +1,166 @@
# Worldlets design
Status: **proposal**. Nothing here is built yet, apart from the first piece
of groundwork (`world.water_at()` and `data/fish.json`, see §6).
## 1. What a worldlet is
A **worldlet** is a small, complete world type: a ~320 m playable area with
its own shape, water, climate, wildlife and features, generated from a seed
like today's island. The current map becomes the first worldlet, **"Valley"**:
a mountain ring, a lake and a sea cliff. Others can differ in kind, not just
in seed:
| Worldlet | Shape | Water | Twist |
|---|---|---|---|
| **Valley** (today) | mountain ring + sea cliff | lake | wolves' den in the rim |
| **Cove** | low hills down to a sandy bay | sea + harbour, a stream | sea fishing, a pier, tides later |
| **Highlands** | plateau, cliffs, a tarn | small cold lake | snow most days, harsher nights |
| **Marsh** | flat, reedy, pools | many shallow pools | fog, insects, bog iron later |
| **Tutorial** | small (~160 m), calm, fixed seed | one pond | guided steps, a fenced rabbit meadow, named spots (docs/TUTORIAL_DESIGN.md) |
A worldlet is **data plus a few generator building blocks**, not a new
scene with its own code. That keeps the game's systems shared (building,
survival, creatures, saving) and makes worldlets addable by addons later
(§7).
## 2. The big decision: one worldlet per save, or linked worldlets?
**A. One worldlet per save.** New World asks for a name, a seed and a
worldlet type. Simple, and it fits everything already built.
**B. Linked worldlets in one save.** Several worldlets form one world, and
you travel between them (a mountain pass, a cave tunnel, a boat). Each keeps
its own state. More exciting, but much more work, especially in M2: a server
would have to simulate several places at once, or keep all players together.
**Recommendation: build A first, with a save layout that allows B later.**
Each worldlet's state goes in its own file from day one
(`worlds/<id>/worldlets/<worldlet>.json`), and the player's own state moves
to `worlds/<id>/player.json`. Then B is "add travel and a second file",
not "migrate every save".
## 3. Definition format
`data/worldlets/<id>.json`. Every number that's an `@export` on
`terrain.gd` today moves here, plus the lists that are hard-coded:
```json
{
"id": "cove",
"name": "Cove",
"description": "Low hills running down to a sheltered bay.",
"size": 320,
"terrain": {
"base": {"height_scale": 7.0, "octaves": 4},
"boundary": [
{"type": "mountains", "from_deg": 60, "to_deg": 300, "height": 50},
{"type": "beach", "from_deg": 300, "to_deg": 60, "slope": 0.08}
],
"features": [
{"type": "cove", "angle_deg": 0, "width": 60},
{"type": "stream", "from": "mountains", "to": "cove"}
]
},
"water": {"sea_level": -1.5, "lakes": []},
"climate": {"cold_day_chance": 0.1, "snowline": 45, "day_length": 1200},
"wildlife": {
"budget": {"rabbit": 5, "deer": 3, "boar": 2},
"wolves": {"den": "rim", "den_day": 3, "hunt_day": 5}
},
"places": [{"type": "pier", "at": "cove"}],
"spawn": {"near": "cove", "facing": "sea"}
}
```
Generator **building blocks** (GDScript, shared by all worldlets): base
hills, mountain rim, sea cliff, beach slope, cove/bay, lake basin, stream
bed, plateau. A worldlet only picks and parameterises them, so it can't do
anything a block can't.
## 4. What changes in the code
Most game code already asks the terrain generic questions:
`sample_height`, `sample_normal`, `inside_boundary` and `find_spawn`. The
work is in the places that know about *this* island's single lake and
cliff. Today:
| Assumption | Used in | Becomes |
|---|---|---|
| one lake at `water_level` | player (swimming), creature, spawner, den, build_system, props, lake_fish, world | `world.water_at(x, z)` (done for fishing) |
| `lake_center` / `lake_radius` | lake_fish, world (views) | the water bodies list |
| `sea_angle_deg`, `coast_point` | props (sea stacks, cliff-top strip), world (views) | the boundary segment list |
| `snow_height` | props, spawner, weather | `climate.snowline` |
| `play_radius` | props, den | boundary queries |
The steps:
1. **Terrain pipeline.** `terrain.gd` becomes a runner that stacks the
chosen building blocks into the height grid. The grid, collision,
`sample_height` and the boundary wall stay exactly as they are.
2. **A `WorldInfo` API** on the world node, for everything outside terrain:
`water_at`, `water_bodies()`, `boundary_at(angle)`, `features(type)`,
`climate`. No system reads terrain exports directly any more.
3. **Water bodies.** Each lake, pond and the sea is a body with a surface,
shape and type. The water mesh is built per body. Swimming, the "too
deep" check, animal steering and fishing all go through `water_at`.
4. **Props and places by feature.** Scenery reads densities from the
definition. The den, the sea stacks and (in Cove) the pier are "places"
positioned by features, not hard-coded angles.
5. **Saves.** `meta.json` gains `"worldlet": "valley"`. Older saves default
to Valley, so nothing breaks.
6. **New World screen.** Pick a worldlet type (with a short description and
a preview render), then name and seed as now.
## 5. The Cove and sea fishing
- **Beach boundary.** Sand slopes gently into shallow water. You can wade
and swim out until the water is deep, and then a current carries you back
(the beach equivalent of the invisible wall, with a toast).
- **Harbour.** A sheltered bay with a generated wooden pier. The pier is
built from the building pieces, so it's "placed by the world" and can't
be dismantled.
- **Sea fish** in `data/fish.json` under `"sea"`: mackerel (common),
cod (bigger, deeper water), and maybe crab pots later. Casting from the
pier reaches deep water. The fishing code needs no changes: it already
asks `water_at` what it landed in and looks up that water's fish.
- Later: tides (sea level shifting slowly), a raft or rowing boat, and
driftwood on the beach.
## 6. Done already (groundwork)
- `world.water_at(x, z)` returns `{type, surface, depth}` for the lake and
the sea, and fishing uses it instead of `terrain.water_level`.
- `data/fish.json` holds the fish per water type, bite timings and
dawn/dusk weights. `ItemDB.define_water()` registers a water's fish, so an
addon or a worldlet can add "sea" without code.
## 7. How it fits the roadmap
- **Before M2 (recommended):** steps 1-6 for Valley alone, with no visible
change, then the Cove. Doing the refactor before multiplayer means M2
replicates "worldlet id + seed + edits", not one hard-coded island.
- **M2:** the server loads one worldlet per save (option A). Clients
regenerate it from id + seed, which is the same determinism we rely on
now.
- **M3 (addons):** `define_worldlet()` takes the same JSON. Addons can only
combine building blocks, so a worldlet addon can't run arbitrary code in
the generator.
- **Option B (linked worldlets)** fits after M2, once we know how the
server handles players in different places.
## 8. Phasing and tests
| Phase | Delivers | Done when |
|---|---|---|
| **W0** | terrain pipeline + WorldInfo API; Valley defined in JSON | seed 1337 generates the **same height grid** as today (a hash test), and all 9 suites still pass |
| **W1** | worldlet picked on New World and saved in meta; old saves = Valley | load an old save and a new Valley save; both play |
| **W2** | water bodies (several per world), beach boundary block, swim-back current | a test worldlet with two ponds and a beach passes the boundary and terrain-edge tests |
| **W3** | **Cove** worldlet: bay, stream, pier place, sea fish | you catch a mackerel off the pier |
| **W4** | (optional) Highlands / Marsh | each passes the shared suites |
| **W5** | (after M2, optional) linked worldlets and travel | walk through a pass from Valley into Cove and back |
Every existing suite runs per worldlet (boundary, terrain edges, survival,
building, wildlife, fishing), so a new worldlet can't ship with a hole in
its edge or animals that spawn in the sea.
## 9. Questions for you
1. **A or B?** One worldlet per save to start, or do you want travel between
them early? (Recommendation: A, with the save layout ready for B.)
2. **Worldlet size:** keep ~320 m, or let some be bigger?
3. **Difficulty per worldlet:** should harsher worldlets (Highlands) bring
wolves sooner, or keep the same grace period everywhere?
4. **Which second worldlet first:** Cove (sea fishing, the beach you
mentioned) is my suggestion.

101
export_presets.cfg Normal file
View File

@@ -0,0 +1,101 @@
[preset.0]
name="macOS"
platform="macOS"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="data/*.json"
exclude_filter="tests/*, docs/*, tools/*, server/*"
export_path="build/macos/Emberwild.zip"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
export/distribution_type=1
binary_format/architecture="universal"
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
application/icon=""
application/icon_interpolation=4
application/bundle_identifier="com.pbailey.emberwild"
application/signature=""
application/app_category="Games"
application/short_version="0.1.0"
application/version="0.1.0"
application/copyright=""
display/high_res=true
codesign/codesign=1
codesign/installer_identity=""
codesign/apple_team_id=""
codesign/identity=""
codesign/entitlements/custom_file=""
codesign/entitlements/allow_jit_code_execution=false
codesign/entitlements/allow_unsigned_executable_memory=false
codesign/entitlements/allow_dyld_environment_variables=false
codesign/entitlements/disable_library_validation=false
codesign/entitlements/audio_input=false
codesign/entitlements/camera=false
codesign/entitlements/location=false
codesign/entitlements/address_book=false
codesign/entitlements/calendars=false
codesign/entitlements/photos_library=false
codesign/entitlements/apple_events=false
codesign/entitlements/debugging=false
codesign/entitlements/app_sandbox/enabled=false
codesign/entitlements/app_sandbox/network_server=false
codesign/entitlements/app_sandbox/network_client=false
notarization/notarization=0
privacy/network_volumes_usage_description=""
[preset.1]
name="Windows"
platform="Windows Desktop"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="data/*.json"
exclude_filter="tests/*, docs/*, tools/*, server/*"
export_path="build/windows/Emberwild/Emberwild.exe"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
binary_format/architecture="x86_64"
codesign/enable=false
application/modify_resources=false
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version="0.1.0"
application/product_version="0.1.0"
application/company_name=""
application/product_name="Emberwild"
application/file_description="Emberwild"
application/copyright=""
application/trademarks=""
application/export_angle=0
application/export_d3d12=0
application/d3d12_agility_sdk_multiarch=true

12
play.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# Starts Emberwild (the game, with its window and title screen).
#
# ./play.sh
#
# Set GODOT=/path/to/godot to use a particular Godot. Any extra arguments go
# to Godot (e.g. ./play.sh --fullscreen). Checks Godot is 4.7+ and imports a
# fresh copy once first, the same as server/run_server.sh.
cd "$(dirname "$0")" || exit 1
. tools/godot_env.sh
exec "$GODOT" --path . "$@"

View File

@@ -14,17 +14,27 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Valheimlike"
config/name="Emberwild"
config/description="Low-poly survival game — Valheim look, Rust depth, self-hosted, moddable."
run/main_scene="res://scenes/title.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
config/version="0.1.0"
[autoload]
Boot="*res://scripts/core/boot.gd"
ItemDB="*res://scripts/core/item_db.gd"
Actions="*res://scripts/core/actions.gd"
Guide="*res://scripts/core/guide.gd"
Hints="*res://scripts/core/hints.gd"
Updates="*res://scripts/core/updates.gd"
Saves="*res://scripts/core/saves.gd"
Net="*res://scripts/net/net.gd"
WorldNet="*res://scripts/net/world_net.gd"
PlayerNet="*res://scripts/net/player_net.gd"
CreatureNet="*res://scripts/net/creature_net.gd"
Chat="*res://scripts/net/chat.gd"
[display]
@@ -64,8 +74,29 @@ jump={
common/physics_ticks_per_second=30
[shader_globals]
weather_wet={
"type": "float",
"value": 0.0
}
weather_snow={
"type": "float",
"value": 0.0
}
weather_wind={
"type": "float",
"value": 1.0
}
[emberwild]
updates/feed="https://git.psbailey.uk/api/v1/repos/Admin/Emberwild/releases/latest"
[rendering]
textures/vram_compression/import_etc2_astc=true
lights_and_shadows/directional_shadow/size=8192
lights_and_shadows/directional_shadow/soft_shadow_filter_quality=3
anti_aliasing/quality/msaa_3d=2

View File

@@ -43,5 +43,5 @@ offset_left = 16.0
offset_top = 12.0
offset_right = 600.0
offset_bottom = 60.0
text = "WASD move · Shift sprint · Space jump · E interact · LMB use/place · R rotate · 1-8 hotbar · Tab inventory & crafting · ESC menu"
text = "E interact · LMB use/place · RMB eat · R rotate · X dismantle · Tab inventory & crafting · Esc menu & controls"
label_settings = SubResource("Label_1")

18
scripts/building/bed.gd Normal file
View File

@@ -0,0 +1,18 @@
extends "res://scripts/building/piece.gd"
## 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.
func display_name() -> String:
return "Bed"
func is_spawn() -> bool:
var w := get_parent().get_parent()
return w.get("bed_spawn") != null and w.has_bed_spawn and (w.bed_spawn as Vector3).distance_to(global_position) < 0.3
func interact_prompt(_player: Node) -> String:
if not is_spawn():
return "Bed [E] Set spawn point"
return "Bed (your spawn point) [E] Sleep"
func interact(player: Node) -> void:
Actions.use_bed(player, self)

View File

@@ -0,0 +1,177 @@
extends "res://scripts/building/piece.gd"
## A snap-together building piece: wood floor, wall, roof, gable or door frame.
##
## Geometry lives here (not in items.json) because it has to match the meshes
## in item_meshes.gd exactly:
## - `shapes`: the colliders, also used (shrunk slightly) for overlap checks.
## - `snaps`: snap points in local space. Two pieces are connected when a snap
## point of one coincides with a snap point of the other; build_system.gd
## uses that for snapping and for structural support.
## - `center`: rough centre of mass, used to tell vertical from sideways
## support.
##
## All pieces are 2 m on a side so they line up on a 2 m grid.
## Origins: floor = centre of the top surface; wall, door and gable = bottom
## centre; roof = centre of the low edge, rising 1 m over 2 m toward -Z
## (about 27 degrees), so two roofs from opposite walls of a 4 m hut meet at a
## ridge.
const BUILD_LAYER := 4 ## physics layer 3: shelter + regrowth queries
const ROOF_ANGLE := 0.4636476 ## atan(1 / 2)
const ROOF_T := 0.12 ## roof panel thickness
static var _kinds := {}
var kind := "wall"
var support := 1.0 ## 0..1, set by build_system.gd
var grounded := false
static func kinds() -> Dictionary:
if _kinds.is_empty():
_kinds = _define()
return _kinds
static func _define() -> Dictionary:
var k := {}
var wall_snaps := [
Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0),
Vector3(-1, 2, 0), Vector3(0, 2, 0), Vector3(1, 2, 0)]
k["floor"] = {
"shapes": [_box(Vector3(0, -0.1, 0), Vector3(2, 0.2, 2))],
"snaps": [Vector3(-1, 0, -1), Vector3(0, 0, -1), Vector3(1, 0, -1), Vector3(-1, 0, 0), Vector3(1, 0, 0),
Vector3(-1, 0, 1), Vector3(0, 0, 1), Vector3(1, 0, 1)],
"center": Vector3(0, -0.1, 0),
}
k["wall"] = {
"shapes": [_box(Vector3(0, 1, 0), Vector3(2, 2, 0.2))],
"snaps": wall_snaps,
"center": Vector3(0, 1, 0),
}
# Door frame: 1.1 m x 1.9 m opening (the player is 0.8 m x 1.8 m).
k["door"] = {
"shapes": [_box(Vector3(-0.775, 1, 0), Vector3(0.45, 2, 0.2)), _box(Vector3(0.775, 1, 0), Vector3(0.45, 2, 0.2)),
_box(Vector3(0, 1.95, 0), Vector3(1.1, 0.1, 0.2))],
"snaps": wall_snaps,
"center": Vector3(0, 1, 0),
}
# Gable: right triangle, 0 m tall at -X, 1 m at +X (fills the end of a
# roof; rotate 180 degrees for the other half).
k["gable"] = {
"shapes": [_prism([Vector2(-1, 0), Vector2(1, 0), Vector2(1, 1)], 0.2)],
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(1, 1, 0)],
"center": Vector3(0.33, 0.33, 0),
}
# --- P2 (docs/PROGRESSION_DESIGN.md §3) ---
# Pillar: a 0.4 m post standing on a floor corner or edge, carrying what's
# on top (a floor's corner meets its top).
# (The collider stops 0.2 m short of the top: that's where a floor resting
# on it has its slab, and the overlap check would count it as in the way.)
k["pillar"] = {
"shapes": [_box(Vector3(0, 0.9, 0), Vector3(0.4, 1.8, 0.4))],
"snaps": [Vector3(0, 0, 0), Vector3(0, 2, 0)],
"center": Vector3(0, 1, 0),
}
# Stairs: 2 m wide, climbing 2 m over 4 m toward -Z (the roof's slope):
# low edge on a floor's edge (or the ground), top edge at the next floor
# up. One smooth ramp to walk on; the steps are in the mesh.
var sn := Vector3(0, cos(ROOF_ANGLE), sin(ROOF_ANGLE))
k["stairs"] = {
"shapes": [{"type": "box", "center": Vector3(0, 1, -2) - sn * 0.1,
"size": Vector3(2, 0.2, sqrt(20.0)), "basis": Basis(Vector3.RIGHT, ROOF_ANGLE)}],
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0),
Vector3(-1, 2, -4), Vector3(0, 2, -4), Vector3(1, 2, -4)],
"center": Vector3(0, 1, -2),
}
# Window: a wall with a 1 x 0.7 m opening, 1 m up.
k["window"] = {
"shapes": [_box(Vector3(0, 0.5, 0), Vector3(2, 1, 0.2)), _box(Vector3(0, 1.85, 0), Vector3(2, 0.3, 0.2)),
_box(Vector3(-0.75, 1.35, 0), Vector3(0.5, 0.7, 0.2)), _box(Vector3(0.75, 1.35, 0), Vector3(0.5, 0.7, 0.2))],
"snaps": wall_snaps,
"center": Vector3(0, 1, 0),
}
# Palisade: 2 m of sharpened logs, 3 m tall, for a ring round your camp.
k["palisade"] = {
"shapes": [_box(Vector3(0, 1.5, 0), Vector3(2, 3, 0.36))],
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0)],
"center": Vector3(0, 1.5, 0),
}
var n := Vector3(0, cos(ROOF_ANGLE), sin(ROOF_ANGLE)) # panel normal (up and out)
# (Ends exactly at the ridge. The seam where two roofs meet is handled by
# build_system.roofed_at sampling several rays.)
k["roof"] = {
"shapes": [{"type": "box", "center": Vector3(0, 0.5, -1) + n * ROOF_T * 0.5,
"size": Vector3(2, ROOF_T, sqrt(5.0)), "basis": Basis(Vector3.RIGHT, ROOF_ANGLE)}],
"snaps": [Vector3(-1, 0, 0), Vector3(0, 0, 0), Vector3(1, 0, 0),
Vector3(-1, 1, -2), Vector3(0, 1, -2), Vector3(1, 1, -2)],
"center": Vector3(0, 0.5, -1),
}
return k
static func _box(c: Vector3, s: Vector3) -> Dictionary:
return {"type": "box", "center": c, "size": s, "basis": Basis()}
## A prism: a 2D outline in the XY plane, extruded `t` along Z (centred).
static func _prism(outline: Array, t: float) -> Dictionary:
return {"type": "prism", "outline": outline, "thickness": t}
## Builds a Shape3D for one shape entry, shrunk by `margin` on every side
## (margin > 0 is for overlap tests, so touching neighbours don't count).
## Returns [shape, local transform].
static func make_shape(s: Dictionary, margin: float = 0.0) -> Array:
if s.type == "box":
var b := BoxShape3D.new()
var size: Vector3 = s.size
b.size = Vector3(maxf(size.x - margin * 2.0, 0.02), maxf(size.y - margin * 2.0, 0.02), maxf(size.z - margin * 2.0, 0.02))
return [b, Transform3D(s.basis, s.center)]
var outline: Array = s.outline
var c := Vector2.ZERO
for p in outline:
c += p
c /= outline.size()
var pts := PackedVector3Array()
var hz := maxf(float(s.thickness) * 0.5 - margin, 0.01)
for p: Vector2 in outline:
var q := p
if margin > 0.0:
q = c + (p - c) * (1.0 - margin * 2.0 / maxf((p - c).length(), 0.01))
pts.append(Vector3(q.x, q.y, -hz))
pts.append(Vector3(q.x, q.y, hz))
var cv := ConvexPolygonShape3D.new()
cv.points = pts
return [cv, Transform3D()]
static func is_build(def: Dictionary) -> bool:
return def.has("place") and def.place.has("kind")
## "wood" or "stone" (items.json place.material). Stone loses less support
## going up and can't stand on wood (build_system.gd).
var material := "wood"
static func material_of(d: Dictionary) -> String:
return String(d.get("place", {}).get("material", "wood"))
func _make_collision(_mi: MeshInstance3D) -> void:
kind = def.place.kind
material = material_of(def)
collision_layer = 1 | BUILD_LAYER
add_to_group("build")
for s in kinds()[kind].shapes:
var made := make_shape(s)
var cs := CollisionShape3D.new()
cs.shape = made[0]
cs.transform = made[1]
add_child(cs)
func snap_points() -> Array:
var out := []
for p in kinds()[kind].snaps:
out.append(global_transform * p)
return out
func display_name() -> String:
return ItemDB.item_name(item_id)
func interact_prompt(_player: Node) -> String:
if support < 0.999:
return "%s (support %d%%)" % [display_name(), roundi(support * 100.0)]
return display_name()

View File

@@ -0,0 +1,435 @@
extends Node
## Building rules: snap placement, validity, structural support and shelter.
##
## Snapping (Valheim-style): for a ghost near existing pieces, every pairing of
## one of the ghost's snap points with a nearby piece's snap point gives a
## candidate transform (rotation locked to that piece's 90-degree grid). The
## best-scoring candidate that passes the validity check wins. Away from other
## pieces you can place freely on the ground.
##
## Support: a piece touching the ground has support 1. Every other piece takes
## the best support among the pieces it shares a snap point with, minus a loss
## (small going up or down, larger sideways). Below MIN_SUPPORT a piece can't
## be placed, and if dismantling a piece leaves others below it they collapse.
## So you can build a hut, a second floor or a short overhang, but not a
## bridge hanging in mid-air.
##
## Shelter: a ray up hits a roof (build layer), and most of 8 sideways rays hit
## walls. Drives the Wet, Sheltered and Rested rules and campfire rain.
const BuildPiece = preload("res://scripts/building/build_piece.gd")
const SNAP_EPS := 0.08 ## snap points closer than this are connected
const SNAP_RADIUS := 1.3 ## how far from the aim point a snap point may be
const MIN_SUPPORT := 0.3
const VERTICAL_LOSS := 0.1
const HORIZONTAL_LOSS := 0.25
const GROUND_EPS := 0.3 ## a snap point this close to the ground touches it
## Overlap checks shrink shapes by this much. Must exceed half a wall's
## thickness: a floor's edge sits on a wall's centre line by design.
const OVERLAP_MARGIN := 0.11
const CELL := 0.5
var world: Node3D
var terrain: Node3D
var pieces_root: Node3D
var _hash: Dictionary = {} ## Vector3i -> Array of [piece, point]
var last_blocker: Object ## what the last failed overlap check hit (debugging)
var outline: Node3D = null ## the tutorial's hut outline, while it's up (placement snaps to it)
var _dirty := true
func setup(w: Node3D) -> void:
world = w
terrain = w.terrain
pieces_root = w._pieces
func mark_dirty() -> void:
_dirty = true
func build_pieces() -> Array:
var out := []
for p in pieces_root.get_children():
if p.is_in_group("build") and not p.is_queued_for_deletion():
out.append(p)
return out
static func _cell(p: Vector3) -> Vector3i:
return Vector3i(floori(p.x / CELL), floori(p.y / CELL), floori(p.z / CELL))
func _ensure_hash() -> void:
if not _dirty:
return
_dirty = false
_hash.clear()
for piece in build_pieces():
for pt in piece.snap_points():
var k := _cell(pt)
if not _hash.has(k):
_hash[k] = []
_hash[k].append([piece, pt])
## Pieces with a snap point within SNAP_EPS of any of `points`.
func _touching(points: Array, exclude: Node = null) -> Array:
_ensure_hash()
var out := []
for pt: Vector3 in points:
var c := _cell(pt)
for dx in range(-1, 2):
for dy in range(-1, 2):
for dz in range(-1, 2):
for e in _hash.get(c + Vector3i(dx, dy, dz), []):
if e[0] != exclude and not out.has(e[0]) and (e[1] as Vector3).distance_to(pt) < SNAP_EPS:
out.append(e[0])
return out
static func _world_snaps(kind: String, xf: Transform3D) -> Array:
var out := []
for p in BuildPiece.kinds()[kind].snaps:
out.append(xf * p)
return out
func _grounded(points: Array) -> bool:
for pt: Vector3 in points:
if pt.y <= terrain.sample_height(pt.x, pt.z) + GROUND_EPS:
return true
return false
const STONE_VERTICAL_LOSS := 0.05 ## stone stacks tall (P2)
## Support lost passing from a piece centred at `from_center` to one of
## `material` centred at `to_center`. Through a pillar (`via_pillar`: either
## piece is one) any real step up or down counts as vertical: a pillar under a
## floor's corner carries it straight down to the ground.
static func _loss(from_center: Vector3, to_center: Vector3, material: String = "wood", via_pillar: bool = false) -> float:
var d := to_center - from_center
var horiz := Vector2(d.x, d.z).length()
if absf(d.y) >= horiz * 0.99 or (via_pillar and absf(d.y) > 0.5):
return STONE_VERTICAL_LOSS if material == "stone" else VERTICAL_LOSS
return HORIZONTAL_LOSS
## Wood can't hold stone up (stone on wood has no support from it).
static func _carries(below: Node, material: String) -> bool:
return not (material == "stone" and below.material == "wood")
## Support a piece of `kind` (and `material`) would have at `xf`.
func support_for(kind: String, xf: Transform3D, exclude: Node = null, material: String = "wood") -> float:
var pts := _world_snaps(kind, xf)
if _grounded(pts):
return 1.0
var center: Vector3 = xf * BuildPiece.kinds()[kind].center
var best := 0.0
for n in _touching(pts, exclude):
if not _carries(n, material):
continue
var nc: Vector3 = n.global_transform * BuildPiece.kinds()[n.kind].center
best = maxf(best, n.support * (1.0 - _loss(nc, center, material, kind == "pillar" or n.kind == "pillar")))
return best
## Recomputes every piece's support from the ground up. Returns the pieces now
## below MIN_SUPPORT (the caller collapses them).
func recompute() -> Array:
mark_dirty()
var pieces := build_pieces()
var nbrs := {}
var centers := {}
for p in pieces:
var pts: Array = p.snap_points()
p.grounded = _grounded(pts)
p.support = 1.0 if p.grounded else 0.0
nbrs[p] = _touching(pts, p)
centers[p] = p.global_transform * BuildPiece.kinds()[p.kind].center
# Relax until stable (support only ever grows from 0, bounded by 1).
for _iter in range(200):
var changed := false
for p in pieces:
if p.grounded:
continue
var best := 0.0
for n in nbrs[p]:
if _carries(n, p.material):
best = maxf(best, n.support * (1.0 - _loss(centers[n], centers[p], p.material, p.kind == "pillar" or n.kind == "pillar")))
if best > p.support + 0.0001:
p.support = best
changed = true
if not changed:
break
var weak := []
for p in pieces:
if p.support < MIN_SUPPORT:
weak.append(p)
return weak
# --- placement --------------------------------------------------------------
## Why a building piece can't go at `xf` ("" = it can).
func check(def: Dictionary, xf: Transform3D, exclude: Node = null) -> String:
var kind: String = def.place.kind
var info: Dictionary = BuildPiece.kinds()[kind]
var c: Vector3 = xf * info.center
if not terrain.inside_boundary(c.x, c.z, 1.5):
return "Too close to the edge of the world"
if c.y < terrain.water_level + 0.1:
return "Can't build in water"
var space := world.get_world_3d().direct_space_state
for s in info.shapes:
var made := BuildPiece.make_shape(s, OVERLAP_MARGIN)
var q := PhysicsShapeQueryParameters3D.new()
q.shape = made[0]
q.transform = xf * (made[1] as Transform3D)
q.collision_mask = 1
var ex: Array[RID] = [terrain.get_node("TerrainBody").get_rid()]
if exclude != null:
ex.append(exclude.get_rid())
q.exclude = ex
var hits := space.intersect_shape(q, 1)
if not hits.is_empty():
last_blocker = hits[0].collider
return "Something's in the way"
var mat := BuildPiece.material_of(def)
if support_for(kind, xf, exclude, mat) < MIN_SUPPORT:
if mat == "stone" and _rests_on_wood(kind, xf, exclude):
return "Stone needs stone or the ground under it (wood can't hold it)"
return "Not enough support"
return ""
func _rests_on_wood(kind: String, xf: Transform3D, exclude: Node) -> bool:
for n in _touching(_world_snaps(kind, xf), exclude):
if n.material == "wood":
return true
return false
## Where a building piece goes when the player aims at `hit` (a ray result
## dictionary: position, normal, collider). `yaw` is the player's preferred
## rotation. Returns {xf, ok, reason, snapped}.
func placement(def: Dictionary, hit: Dictionary, yaw: float) -> Dictionary:
var kind: String = def.place.kind
var info: Dictionary = BuildPiece.kinds()[kind]
var hp: Vector3 = hit.position
var cands := []
# The tutorial's hut outline (ui/hut_outline.gd): a piece aimed near an
# empty slot of its own kind goes there, like building to a plan.
if outline != null and is_instance_valid(outline):
for s in outline.slots:
if s[0] == String(def.id) and (s[2] as Node3D).visible:
var sxf: Transform3D = s[1]
var d := sxf.origin.distance_to(hp)
if d < 2.0:
cands.append([d * 0.3, sxf])
for piece in build_pieces():
if piece.global_position.distance_to(hp) > 4.5:
continue
var pyaw: float = piece.global_basis.get_euler().y
var k := roundf(wrapf(yaw - pyaw, -PI, PI) / (PI * 0.5))
var basis := Basis(Vector3.UP, pyaw + k * PI * 0.5)
# The ghost sits at the aim point first (origin on the crosshair), then
# moves the short distance that makes one of its snap points meet one
# of this piece's. Closest pair wins, so the piece follows your aim.
var ghost := Transform3D(basis, hp)
for se: Vector3 in piece.snap_points():
if se.distance_to(hp) > SNAP_RADIUS + 2.5:
continue
for sp: Vector3 in info.snaps:
var d := (ghost * sp).distance_to(se)
if d > SNAP_RADIUS:
continue
var xf := Transform3D(basis, se - basis * sp)
var score := d + 0.05 * (xf * (info.center as Vector3)).distance_to(hp)
cands.append([score, xf])
cands.sort_custom(func(a, b): return a[0] < b[0])
var tried: Array[Vector3] = []
var first_reason := ""
var first_xf := Transform3D()
for cand in cands:
var xf: Transform3D = cand[1]
var dup := false
for o in tried:
if o.distance_to(xf.origin) < 0.05:
dup = true
break
if dup:
continue
tried.append(xf.origin)
if tried.size() > 14:
break
var why := check(def, xf)
if why.is_empty():
return {"xf": xf, "ok": true, "reason": "", "snapped": true}
if first_reason.is_empty():
first_reason = why
first_xf = xf
# Nothing to snap to: free placement on the ground or on top of a piece.
var n: Vector3 = hit.get("normal", Vector3.UP)
if n.y > 0.6:
var xf := _free_xf(kind, hp, yaw, hit.get("collider") == terrain.get_node("TerrainBody"))
var why := check(def, xf)
if why.is_empty() or first_reason.is_empty():
return {"xf": xf, "ok": why.is_empty(), "reason": why, "snapped": false}
if not first_reason.is_empty():
return {"xf": first_xf, "ok": false, "reason": first_reason, "snapped": true}
return {"xf": Transform3D(Basis(Vector3.UP, yaw), hp), "ok": false, "reason": "Needs flat ground or a piece to attach to", "snapped": false}
## Free placement. On the ground a floor sits level on the highest point under
## it (so it floats a little on a slope instead of sinking in); walls sit on
## the lowest point so there's no gap underneath.
func _free_xf(kind: String, hp: Vector3, yaw: float, on_terrain: bool) -> Transform3D:
var basis := Basis(Vector3.UP, yaw)
var origin := hp
if on_terrain:
var probe := []
match kind:
"floor":
probe = [Vector3(-1, 0, -1), Vector3(1, 0, -1), Vector3(-1, 0, 1), Vector3(1, 0, 1), Vector3.ZERO]
"pillar":
probe = [Vector3.ZERO]
_:
probe = [Vector3(-1, 0, 0), Vector3.ZERO, Vector3(1, 0, 0)]
var hs: Array[float] = []
for p: Vector3 in probe:
var w: Vector3 = hp + basis * p
hs.append(terrain.sample_height(w.x, w.z))
origin.y = (hs.max() + 0.03) if kind == "floor" else hs.min()
return Transform3D(basis, origin)
# --- shelter ----------------------------------------------------------------
func _ray(from: Vector3, to: Vector3) -> bool:
var q := PhysicsRayQueryParameters3D.create(from, to, BuildPiece.BUILD_LAYER)
return not world.get_world_3d().direct_space_state.intersect_ray(q).is_empty()
## A roof (any building piece) above `pos` (feet height): at least 3 of 5
## rays up (centre and 30 cm around it) hit one, so a seam doesn't count as
## open sky but standing under the edge of an eave doesn't count as indoors.
func roofed_at(pos: Vector3) -> bool:
var hits := 0
for o in [Vector3.ZERO, Vector3(0.3, 0, 0), Vector3(-0.3, 0, 0), Vector3(0, 0, 0.3), Vector3(0, 0, -0.3)]:
if _ray(pos + o + Vector3(0, 0.3, 0), pos + o + Vector3(0, 14, 0)):
hits += 1
return hits >= 3
## Height of the sideways shelter rays: under a window's sill (1 m), so a
## room with windows still counts as walled in.
const WALL_RAY_Y := 0.6
## 0..1 fraction of 8 horizontal directions blocked by walls within 8 m.
func enclosure_at(pos: Vector3) -> float:
var from := pos + Vector3(0, WALL_RAY_Y, 0)
var hits := 0
for i in range(8):
var a := TAU * i / 8.0
if _ray(from, from + Vector3(cos(a), 0, sin(a)) * 8.0):
hits += 1
return hits / 8.0
func sheltered_at(pos: Vector3) -> bool:
return roofed_at(pos) and enclosure_at(pos) >= 0.74
## 0..1 fraction of 8 horizontal directions whose nearest wall (within 8 m)
## is stone. A stone room keeps you a little warmer (survival.gd).
func stone_walls_at(pos: Vector3) -> float:
var from := pos + Vector3(0, WALL_RAY_Y, 0)
var space := world.get_world_3d().direct_space_state
var hits := 0
for i in range(8):
var a := TAU * i / 8.0
var q := PhysicsRayQueryParameters3D.create(from, from + Vector3(cos(a), 0, sin(a)) * 8.0, BuildPiece.BUILD_LAYER)
var hit := space.intersect_ray(q)
if hit.is_empty():
continue
var c: Object = hit.collider
if c != null and c.has_meta("owner_piece"):
c = c.get_meta("owner_piece")
if c != null and c.get("material") == "stone":
hits += 1
return hits / 8.0
# --- dev helper ---------------------------------------------------------------
## Builds a 4 x 4 m hut through the same snapping a player uses, aiming at the
## spots a player would aim at: 4 floors, 7 walls and a door (south side, +Z
## in hut space), 4 roofs meeting at a ridge, 4 gables. Used by the tests and
## the screenshot views. A coroutine (await it): like a player, it lets a
## physics step pass after each piece so the next overlap check sees it.
## `mat` "stone" builds the floors and walls in stone (a stone doorway, a
## wooden roof on top).
## Returns {"placed": n, "failed": [...], "pieces": [...]}.
func debug_build_hut(center: Vector3, yaw: float, mat: String = "wood") -> Dictionary:
var out := {"placed": 0, "failed": [], "pieces": []}
var floor_id := mat + "_floor"
var wall_id := mat + "_wall"
var door_id := "stone_doorway" if mat == "stone" else "wood_door"
# Floors: the first sits on the ground, the rest snap to it.
var f0: Node3D = await _hut_place(out, center, yaw, floor_id, Vector3(-1, 0, -1), 0.0, null)
if f0 == null:
return out
var top: float = f0.global_position.y - center.y
for l in [Vector3(1, 0, -1), Vector3(-1, 0, 1), Vector3(1, 0, 1)]:
await _hut_place(out, center, yaw, floor_id, l, 0.0, null)
# Walls: aim at the floor just inside each edge.
var q := PI * 0.5
for x in [-1.0, 1.0]:
await _hut_place(out, center, yaw, wall_id, Vector3(x, top, -1.9), 0.0, f0) # north
await _hut_place(out, center, yaw, door_id if x > 0 else wall_id, Vector3(x, top, 1.9), 0.0, f0) # south
for z in [-1.0, 1.0]:
await _hut_place(out, center, yaw, wall_id, Vector3(1.9, top, z), q, f0) # east
await _hut_place(out, center, yaw, wall_id, Vector3(-1.9, top, z), q, f0) # west
# Roofs on the north and south wall tops, rising to a ridge along X.
for x in [-1.0, 1.0]:
await _hut_place(out, center, yaw, "wood_roof", Vector3(x, top + 2.0, 2.0), 0.0, f0)
await _hut_place(out, center, yaw, "wood_roof", Vector3(x, top + 2.0, -2.0), PI, f0)
# Gables on the east and west wall tops, high end toward the ridge.
for x in [-2.0, 2.0]:
await _hut_place(out, center, yaw, "wood_gable", Vector3(x, top + 2.0, 1.0), q, f0)
await _hut_place(out, center, yaw, "wood_gable", Vector3(x, top + 2.0, -1.0), -q, f0)
return out
## One aimed placement for debug_build_hut. `on` = the piece the aim lands on
## (null = the ground).
func _hut_place(out: Dictionary, center: Vector3, yaw: float, id: String, local: Vector3, ryaw: float, on: Node3D) -> Node3D:
var def: Dictionary = ItemDB.get_item(id)
var w: Vector3 = center + Basis(Vector3.UP, yaw) * local
var tb: Node = terrain.get_node("TerrainBody")
var hit_y: float = terrain.sample_height(w.x, w.z) if on == null else w.y
var hit := {"position": Vector3(w.x, hit_y, w.z), "normal": Vector3.UP, "collider": tb if on == null else on}
var res := placement(def, hit, yaw + ryaw)
if not res.ok:
out.failed.append("%s@%s: %s" % [id, local, res.reason])
return null
var p: Node3D = world.spawn_piece(def, res.xf)
out.placed += 1
out.pieces.append(p)
await world.get_tree().physics_frame
return p
## A spot near `near` with a clear, fairly flat `half`-metre square: nothing
## solid in the way and the ground within 0.6 m across it. Searches outward
## in rings. Returns `near` itself if nothing is found.
func debug_find_site(near: Vector3, half: float, step: float = 4.0, min_dist: float = 0.0) -> Vector3:
var space := world.get_world_3d().direct_space_state
var box := BoxShape3D.new()
box.size = Vector3(half * 2.0 + 2.0, 6.0, half * 2.0 + 2.0)
var ex: Array[RID] = [terrain.get_node("TerrainBody").get_rid(), world.player.get_rid()]
var rings := int(ceil(48.0 / step))
for ring in range(0, rings):
if ring * step < min_dist:
continue
var n := maxi(1, ring * 6)
for i in range(n):
var a := TAU * i / n
var c := near + Vector3(cos(a), 0, sin(a)) * ring * step
if not terrain.inside_boundary(c.x, c.z, half + 4.0):
continue
var hs: Array[float] = []
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
hs.append(terrain.sample_height(c.x + d.x * half, c.z + d.y * half))
if hs.max() - hs.min() > 0.6 or hs.min() < terrain.water_level + 0.5:
continue
c.y = hs[4]
var q := PhysicsShapeQueryParameters3D.new()
q.shape = box
q.transform = Transform3D(Basis(), c + Vector3(0, 3.0, 0))
q.collision_mask = 1
q.exclude = ex
if space.intersect_shape(q, 1).is_empty():
return c
return near

View File

@@ -1,13 +1,26 @@
extends "res://scripts/building/piece.gd"
## Campfire: flickering light, flames, embers and smoke. A "campfire" station
## (cooking arrives in M1b along with fuel and warmth).
## Campfire: light, warmth and cooking, while it has fuel.
##
## Starts with a little kindling. E adds wood (or sticks) from your inventory;
## it burns down in play time and goes out at zero. While lit it is a
## "campfire" station (cooking) and a heat source (warmth within ~7 m).
const MAX_FUEL := 900.0 ## 15 min
const START_FUEL := 120.0
const HEAT_RADIUS := 7.0
var fuel := START_FUEL
var _light: OmniLight3D
var _fx: Array[CPUParticles3D] = []
var _t := 0.0
var _noise := FastNoiseLite.new()
var _lit := true
var _roofed_cache := -1.0 ## -1 unknown, 0 open sky, 1 under a roof
var _roof_timer := 0.0
func _build() -> void:
add_to_group("station:campfire")
add_to_group("heat")
_noise.frequency = 2.5
_light = OmniLight3D.new()
_light.light_color = Color(1.0, 0.62, 0.3)
@@ -18,30 +31,101 @@ func _build() -> void:
_light.position = Vector3(0, 0.7, 0)
add_child(_light)
add_child(_particles("Flames", 26, 0.55, Vector3(0, 0.12, 0), 0.5, 1.0, 0.5, 0.9, _gradient([
_fx.append(_particles("Flames", 26, 0.55, Vector3(0, 0.12, 0), 0.5, 1.0, 0.5, 0.9, _gradient([
[0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.15, Color(1.0, 0.62, 0.2, 0.75)],
[0.6, Color(0.95, 0.3, 0.06, 0.45)], [1.0, Color(0.5, 0.08, 0.02, 0.0)]]), true, 0.32, 0.12))
add_child(_particles("Embers", 10, 2.0, Vector3(0, 0.3, 0), 0.8, 2.0, 0.5, 1.0, _gradient([
_fx.append(_particles("Embers", 10, 2.0, Vector3(0, 0.3, 0), 0.8, 2.0, 0.5, 1.0, _gradient([
[0.0, Color(1.0, 0.75, 0.35, 1.0)], [1.0, Color(1.0, 0.3, 0.05, 0.0)]]), true, 0.04, 0.15))
var smoke := _particles("Smoke", 10, 5.0, Vector3(0, 1.0, 0), 0.6, 1.1, 0.6, 1.4, _gradient([
[0.0, Color(0.55, 0.54, 0.52, 0.0)], [0.25, Color(0.58, 0.57, 0.55, 0.07)],
[1.0, Color(0.7, 0.7, 0.7, 0.0)]]), false, 0.9, 0.15)
smoke.spread = 25.0
smoke.gravity = Vector3(0.35, 0.25, 0.1) # drifts with the wind
# TODO(M1b): smoke renders as an opaque dark ball whatever its ramp colour
# (alpha-blended unshaded particles; additive flames are fine). Disabled
# until that's fixed, likely by moving to a small particle shader.
smoke.queue_free()
for p in _fx:
add_child(p)
# TODO: smoke. Alpha-blended unshaded particles rendered as an opaque dark
# ball whatever their ramp colour (additive flames are fine). Needs a small
# particle shader.
_set_lit(fuel > 0.0)
func display_name() -> String:
return "Campfire"
func is_lit() -> bool:
return _lit
func station_active() -> bool:
return _lit
## 0..1 warmth this fire gives at `pos`.
func heat_at(pos: Vector3) -> float:
if not _lit:
return 0.0
return 1.0 - smoothstep(1.5, HEAT_RADIUS, pos.distance_to(global_position))
func add_fuel(seconds: float) -> void:
fuel = minf(fuel + seconds, MAX_FUEL)
_set_lit(true)
func interact_prompt(player: Node) -> String:
var has_fuel: bool = player.inventory.count("wood") + player.inventory.count("stick") + player.inventory.count("bear_fat") > 0
if _lit:
var left := "%d:%02d" % [int(fuel) / 60, int(fuel) % 60]
var hiss := ", hissing in the rain" if burn_rate() > 1.2 else ""
return "Campfire (%s left%s)%s" % [left, hiss, " [E] Add fuel" if has_fuel else " (add wood or sticks)"]
return "Campfire (out)%s" % (" [E] Add fuel to light" if has_fuel else " (needs wood or sticks)")
func interact(player: Node) -> void:
Actions.add_fuel(player, self)
## How much faster fuel burns right now: rain on an uncovered fire makes it
## hiss and eat through its wood (up to 4x in a storm). A roof stops that.
func burn_rate() -> float:
var w: Node = get_parent().get_parent() if get_parent() != null else null
if w == null or w.get("weather") == null:
return 1.0
var rain: float = w.weather.rain_at(global_position)
if rain <= 0.05:
_roofed_cache = -1.0
return 1.0
if _roofed_cache < 0.0:
_roofed_cache = 1.0 if w.build.roofed_at(global_position) else 0.0
return 1.0 if _roofed_cache > 0.5 else 1.0 + 3.0 * rain
func burn(seconds: float) -> void:
if not _lit:
return
fuel -= seconds * burn_rate()
if fuel <= 0.0:
fuel = 0.0
_set_lit(false)
func _process(delta: float) -> void:
if not _lit:
return
_roof_timer -= delta
if _roof_timer <= 0.0:
_roof_timer = 2.0
_roofed_cache = -1.0 # re-check the roof now and then (you may build one)
burn(delta)
if not _lit:
return
_t += delta
var f := _noise.get_noise_1d(_t * 10.0)
_light.light_energy = 2.2 + f * 0.7
# Burns lower in its last minute.
var strength := clampf(fuel / 60.0, 0.35, 1.0)
_light.light_energy = (2.2 + f * 0.7) * strength
_light.position = Vector3(f * 0.05, 0.7 + f * 0.05, 0)
func _set_lit(on: bool) -> void:
_lit = on
if _light != null:
_light.visible = on
for p in _fx:
p.emitting = on
func save_extra() -> Dictionary:
return {"fuel": fuel}
func load_extra(d: Dictionary) -> void:
fuel = float(d.get("fuel", fuel))
_set_lit(fuel > 0.0)
static func _gradient(stops: Array) -> Gradient:
var g := Gradient.new()
g.offsets = PackedFloat32Array(stops.map(func(s): return s[0]))
@@ -61,7 +145,7 @@ static func _soft_dot() -> GradientTexture2D:
t.height = 64
return t
func _particles(label: String, amount: int, lifetime: float, pos: Vector3, vmin: float, vmax: float,
static func _particles(label: String, amount: int, lifetime: float, pos: Vector3, vmin: float, vmax: float,
smin: float, smax: float, ramp: Gradient, additive: bool, quad_size: float, radius: float) -> CPUParticles3D:
var p := CPUParticles3D.new()
p.name = label
@@ -81,7 +165,7 @@ func _particles(label: String, amount: int, lifetime: float, pos: Vector3, vmin:
# Flames shrink as they rise.
var curve := Curve.new()
curve.add_point(Vector2(0.0, 1.0))
curve.add_point(Vector2(1.0, 0.35 if additive else 1.6))
curve.add_point(Vector2(1.0, 0.35))
p.scale_amount_curve = curve
var quad := QuadMesh.new()
quad.size = Vector2.ONE * quad_size

View File

@@ -4,6 +4,12 @@ extends "res://scripts/building/piece.gd"
const SLOTS := 16
var inventory := Inventory.new(SLOTS)
func dismantle_block_reason() -> String:
for s in inventory.slots:
if s != null:
return "Empty the chest first"
return ""
func interact_prompt(_player: Node) -> String:
var used := 0
for s in inventory.slots:

125
scripts/building/door.gd Normal file
View File

@@ -0,0 +1,125 @@
extends "res://scripts/building/build_piece.gd"
## Door: a wall-sized frame (snaps like a wall) with a hinged panel. E opens
## or closes it. The panel is an AnimatableBody3D so it pushes the player
## out of the way rather than trapping them.
##
## An alpha wolf can break a closed wooden door (bash()): after DOOR_HP worth
## of blows the panel gives way and hangs open, and the door shows as broken
## until you repair it (E, two wood). An iron-banded door (items.json
## place.sturdy) can't be broken. The frame stays either way, so whatever it
## holds up keeps standing.
const OPEN_ANGLE := 1.75 ## radians: swings toward -Z, away from where you stood to place it
const DOOR_HP := 100.0
const REPAIR_COST := {"wood": 2}
var open := false
var broken := false
var door_hp := DOOR_HP
var _hinge: Node3D
var _panel: AnimatableBody3D
var _panel_shape: CollisionShape3D
var _tween: Tween
func _visual_mesh() -> Mesh:
return ItemDB.mesh_for(String(def.get("mesh", item_id)) + "_frame") # (wood_door_frame, stone_doorway_frame...)
func _build() -> void:
_hinge = Node3D.new()
_hinge.name = "Hinge"
_hinge.position = Vector3(-0.54, 0.0, 0.0)
add_child(_hinge)
_panel = AnimatableBody3D.new()
_panel.name = "Panel"
# Follow the hinge directly (sync_to_physics would only track the body's
# own motion, not its parent rotating).
_panel.sync_to_physics = false
_panel.collision_layer = 1 | BUILD_LAYER
_panel.set_meta("owner_piece", self)
_hinge.add_child(_panel)
var mi := MeshInstance3D.new()
mi.mesh = ItemDB.mesh_for(String(def.place.get("panel", "wood_door_panel")))
mi.material_override = ItemDB.item_material()
_panel.add_child(mi)
_panel_shape = CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(1.06, 1.86, 0.08)
_panel_shape.shape = box
_panel_shape.position = Vector3(0.54, 0.95, 0.0)
_panel.add_child(_panel_shape)
## Iron-banded: nothing breaks it.
func sturdy() -> bool:
return bool(def.place.get("sturdy", false))
## Shut and in one piece (something trying to get in has to get past it).
func blocks() -> bool:
return not open and not broken
func interact_prompt(_player: Node) -> String:
if broken:
return "Broken door [E] Repair (2 wood)"
return "Door [E] %s" % ("Close" if open else "Open")
func interact(player: Node) -> void:
if broken:
Actions.repair_door(player, self)
else:
Actions.toggle_door(player, self)
func set_open(on: bool, animate: bool = true) -> void:
if broken:
on = true # (it hangs open off one hinge)
open = on
var target := OPEN_ANGLE if on else 0.0
if _tween != null:
_tween.kill()
if animate and is_inside_tree():
_tween = create_tween()
_tween.tween_property(_hinge, "rotation:y", target, 0.45).set_trans(Tween.TRANS_SINE)
else:
_hinge.rotation.y = target
## A blow from something trying to get in. Returns "hit", "held" (iron
## bands), "broke" (this blow broke it) or "" (open or already broken).
func bash(damage: float) -> String:
if not blocks():
return ""
if sturdy():
return "held"
door_hp -= damage
if door_hp > 0.0:
return "hit"
set_broken(true)
return "broke"
## Broken: the panel hangs open, askew, and stops anything. Repaired: shut
## and whole again.
func set_broken(on: bool) -> void:
broken = on
if on:
door_hp = 0.0
set_open(true, false)
_hinge.rotation = Vector3(0.0, OPEN_ANGLE + 0.35, 0.18)
_panel_shape.disabled = true
else:
door_hp = DOOR_HP
_hinge.rotation = Vector3.ZERO
_panel_shape.disabled = false
set_open(false, false)
func save_extra() -> Dictionary:
return {"open": open, "broken": broken}
func load_extra(d: Dictionary) -> void:
set_broken(bool(d.get("broken", false)))
if not broken:
set_open(bool(d.get("open", false)), false)
## A server telling us this door's state: swing it if it only opened or
## closed, snap it if it broke or was mended.
func net_state(d: Dictionary) -> void:
if bool(d.get("broken", false)) != broken:
load_extra(d)
elif not broken:
set_open(bool(d.get("open", false)))

20
scripts/building/forge.gd Normal file
View File

@@ -0,0 +1,20 @@
extends "res://scripts/building/piece.gd"
## Forge: an anvil by a stone hearth. Unlocks "forge" recipes (iron tools,
## nails, a lantern) within 6 m, and repairs iron tools. E opens crafting.
func _build() -> void:
add_to_group("station:forge")
if DisplayServer.get_name() == "headless":
return
var l := OmniLight3D.new()
l.light_color = Color(1.0, 0.5, 0.2)
l.omni_range = 4.0
l.light_energy = 0.8
l.position = Vector3(-0.55, 0.9, 0.2)
add_child(l)
func interact_prompt(_player: Node) -> String:
return "Forge [E] Craft"
func interact(player: Node) -> void:
player.open_inventory(null)

51
scripts/building/grave.gd Normal file
View File

@@ -0,0 +1,51 @@
extends "res://scripts/building/piece.gd"
## Grave: left where you died, holding everything you carried. Open it with E
## to take your things back; it crumbles once empty.
##
## On a server it has an owner (whoever died), and only they can open it
## (player_net.gd asks use_block_reason). Server rules for friends come in M2.6.
var inventory := Inventory.new(40)
var owner_id := ""
var owner_name := ""
func _build() -> void:
inventory.changed.connect(_on_changed)
func _mine() -> bool:
return owner_id.is_empty() or Net.state != "connected" or owner_id == Net.get_identity().player_id
func display_name() -> String:
return "Your grave" if _mine() else "%s's grave" % owner_name
func interact_prompt(_player: Node) -> String:
if not _mine():
return "%s's grave" % owner_name
return "Your grave [E] Recover belongings"
func interact(player: Node) -> void:
if _mine():
player.open_inventory(self)
else:
Actions.notify.emit("That's %s's grave" % owner_name)
## Server: why player `id` can't take from it ("" = they can).
func use_block_reason(id: String) -> String:
if owner_id.is_empty() or owner_id == id:
return ""
return "That's %s's grave" % owner_name
func save_extra() -> Dictionary:
return {"owner_id": owner_id, "owner_name": owner_name} if not owner_id.is_empty() else {}
func load_extra(d: Dictionary) -> void:
owner_id = String(d.get("owner_id", ""))
owner_name = String(d.get("owner_name", ""))
func _on_changed() -> void:
if WorldNet.world != null:
return # online, the server crumbles it (world_net.gd)
for s in inventory.slots:
if s != null:
return
queue_free.call_deferred()

View File

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

View File

@@ -0,0 +1,21 @@
extends "res://scripts/building/campfire.gd"
## Stone hearth: an indoor fire (docs/PROGRESSION_DESIGN.md §3). Everything a
## campfire does (fuel, cooking, keeping wolves off), but it warms a bigger
## room (1.5x the radius), its chimney keeps the rain off so it never burns
## faster in a storm, and its stone doesn't need a floor of its own.
const HEARTH_RADIUS := HEAT_RADIUS * 1.5
func display_name() -> String:
return "Stone Hearth"
func heat_at(pos: Vector3) -> float:
if not _lit:
return 0.0
return 1.0 - smoothstep(2.0, HEARTH_RADIUS, pos.distance_to(global_position))
func burn_rate() -> float:
return 1.0
func interact_prompt(player: Node) -> String:
return super.interact_prompt(player).replace("Campfire", "Stone hearth")

View File

@@ -1,7 +1,8 @@
extends StaticBody3D
## Base for placed pieces (campfire, workbench, chest, later walls/floors).
## Builds the visual from the item's procedural mesh and a box collider from
## the item's footprint. Subclasses add behaviour.
## Base for placed pieces (campfire, workbench, chest, bed, building pieces).
## Builds the visual from the item's procedural mesh and, by default, a box
## collider from the mesh bounds. Subclasses add behaviour or their own
## colliders (_make_collision).
var item_id := ""
var def: Dictionary = {}
@@ -9,11 +10,19 @@ var def: Dictionary = {}
func setup(d: Dictionary) -> void:
def = d
item_id = d.id
add_to_group("piece")
var mi := MeshInstance3D.new()
mi.name = "Mesh"
mi.mesh = ItemDB.mesh_for(item_id)
mi.mesh = _visual_mesh()
mi.material_override = ItemDB.item_material()
add_child(mi)
_make_collision(mi)
_build()
func _visual_mesh() -> Mesh:
return ItemDB.mesh_for(item_id)
func _make_collision(mi: MeshInstance3D) -> void:
var aabb := mi.mesh.get_aabb()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
@@ -21,7 +30,6 @@ func setup(d: Dictionary) -> void:
cs.shape = box
cs.position = aabb.get_center()
add_child(cs)
_build()
func _build() -> void:
pass
@@ -34,3 +42,7 @@ func interact_prompt(_player: Node) -> String:
func interact(_player: Node) -> void:
pass
## Whether X can take this piece back into your inventory (and why not).
func dismantle_block_reason() -> String:
return ""

View File

@@ -0,0 +1,136 @@
extends "res://scripts/building/piece.gd"
## A charcoal kiln or a smelter: E opens it like a chest. Put what it works
## on inside and it turns it into something else over time, on its own,
## while you're away (docs/PROGRESSION_DESIGN.md §2). It glows while it works.
##
## - Kiln: 2 wood -> 1 charcoal, 20 s.
## - Smelter: 1 iron ore + 1 charcoal -> 1 iron bar, 30 s; or 3 iron
## nuggets + 1 charcoal -> 1 iron bar.
##
## The output goes into the same slots, for you to take out. On a server it
## runs there (step() is skipped on players' copies) and the contents reach
## everyone like a chest's. Progress is saved.
const SLOTS := 6
const RECIPES := {
"charcoal_kiln": [{"inputs": {"wood": 2}, "output": "charcoal", "seconds": 20.0}],
"smelter": [
{"inputs": {"iron_ore": 1, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0},
{"inputs": {"iron_nugget": 3, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0},
],
# P4: smoked food keeps for days (raw rots in two).
"smoking_rack": [
{"inputs": {"raw_meat": 2, "wood": 1}, "output": "smoked_meat", "count": 2, "seconds": 60.0},
{"inputs": {"perch": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
{"inputs": {"trout": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
],
}
## What the prompt says it makes, and what it needs when idle.
const MAKES := {"charcoal_kiln": "charcoal", "smelter": "an iron bar", "smoking_rack": "smoked food"}
const NEEDS := {"charcoal_kiln": "put in wood", "smelter": "put in iron ore (or 3 nuggets) and charcoal",
"smoking_rack": "put in raw meat or fish, and wood for the fire"}
var inventory := Inventory.new(SLOTS)
var progress := 0.0 ## seconds into the current batch
var _light: OmniLight3D
var _glow: MeshInstance3D
var _t := 0.0
func _build() -> void:
add_to_group("processor")
inventory.changed.connect(_on_changed)
if DisplayServer.get_name() == "headless":
return
_light = OmniLight3D.new()
_light.light_color = Color(1.0, 0.55, 0.25)
_light.omni_range = 5.0
_light.light_energy = 0.0
_light.position = Vector3(0, 0.6, 0.5)
add_child(_light)
# The fire showing through the mouth.
_glow = MeshInstance3D.new()
var q := QuadMesh.new()
q.size = Vector2(0.36, 0.26)
_glow.mesh = q
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.albedo_color = Color(1.0, 0.45, 0.12)
m.emission_enabled = true
m.emission = Color(1.0, 0.45, 0.12)
m.emission_energy_multiplier = 3.0
_glow.material_override = m
_glow.position = {"smelter": Vector3(0, 0.3, 0.58), "smoking_rack": Vector3(0, 0.12, 0.0)}.get(item_id, Vector3(0, 0.25, 0.72))
if item_id == "smoking_rack":
_glow.rotation.x = -PI * 0.5 # (embers on the ground under the rack)
_glow.visible = false
add_child(_glow)
## The recipe it would work on now, or {} (nothing to do, or no room for
## what it makes).
func current() -> Dictionary:
for r in RECIPES.get(item_id, []):
if inventory.has_all(r.inputs) and inventory.space_for(r.output) > 0:
return r
return {}
func working() -> bool:
return not current().is_empty()
func _process(delta: float) -> void:
if WorldNet.is_client():
_show(working()) # (the server does the work; we just show it)
return
step(delta)
## Works for `secs` (several batches if there's time and stuff for them).
func step(secs: float) -> void:
var r := current()
if r.is_empty():
progress = 0.0
_show(false)
return
progress += secs
while not r.is_empty() and progress >= float(r.seconds):
progress -= float(r.seconds)
for id in r.inputs:
inventory.remove(id, int(r.inputs[id]))
inventory.add(r.output, int(r.get("count", 1)))
r = current()
if r.is_empty():
progress = 0.0
_show(not r.is_empty())
func _show(on: bool) -> void:
if _light == null:
return
_t += get_process_delta_time()
_glow.visible = on
_light.light_energy = (1.2 + 0.25 * sin(_t * 9.0) * sin(_t * 3.1)) if on else 0.0
func _on_changed() -> void:
if current().is_empty():
progress = 0.0
func dismantle_block_reason() -> String:
for s in inventory.slots:
if s != null:
return "Empty it first"
return ""
func interact_prompt(_player: Node) -> String:
var r := current()
var what: String = MAKES.get(item_id, "something")
if r.is_empty():
return "%s (idle: %s) [E] Open" % [display_name(), NEEDS.get(item_id, "put something in")]
if WorldNet.is_client():
return "%s (making %s) [E] Open" % [display_name(), what]
return "%s (making %s, %d s) [E] Open" % [display_name(), what, ceili(float(r.seconds) - progress)]
func interact(player: Node) -> void:
player.open_inventory(self)
func save_extra() -> Dictionary:
return {"progress": progress}
func load_extra(d: Dictionary) -> void:
progress = float(d.get("progress", 0.0))

22
scripts/building/rug.gd Normal file
View File

@@ -0,0 +1,22 @@
extends "res://scripts/building/piece.gd"
## Fur rug: a bearskin on the floor. In a sheltered room it makes you Cozy
## (survival.gd): a little warmer, and Rested twice as fast. Flat, so you
## walk over it (it's only on the interactables layer, for X to take it up).
const COZY_RADIUS := 4.0
func _make_collision(mi: MeshInstance3D) -> void:
var aabb := mi.mesh.get_aabb()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(aabb.size.x, 0.06, aabb.size.z)
cs.shape = box
cs.position = Vector3(aabb.get_center().x, 0.03, aabb.get_center().z)
add_child(cs)
collision_layer = 2
func _build() -> void:
add_to_group("rug")
func interact_prompt(_player: Node) -> String:
return "Fur rug (cozy in a sheltered room)"

View File

@@ -0,0 +1,12 @@
extends "res://scripts/building/piece.gd"
## Stonecutter: a saw bench for dressing stone. Unlocks "stonecutter" recipes
## (cut stone, and the stone building pieces) within 6 m. E opens crafting.
func _build() -> void:
add_to_group("station:stonecutter")
func interact_prompt(_player: Node) -> String:
return "Stonecutter [E] Craft"
func interact(player: Node) -> void:
player.open_inventory(null)

View File

@@ -10,18 +10,31 @@ extends Node
signal notify(text: String) ## short HUD toast ("+4 Wood", "Need an axe")
signal item_gained(id: String, n: int)
## What players did (docs/TUTORIAL_DESIGN.md §3): the tutorial's Guide (and
## later hints and addons) listen to these. Emitted where the game decides
## (single-player, or on a server for each player's character).
signal crafted(player: Node, recipe_id: String, output: String, count: int)
signal harvested(player: Node, node: Node, felled: bool)
signal placed(player: Node, item_id: String, piece: Node)
signal ate(player: Node, item_id: String)
signal killed(player: Node, kind: String)
signal butchered(player: Node, kind: String)
signal caught(player: Node, fish: String)
signal slept(player: Node)
const STATION_RANGE := 6.0
const REACH := 3.2
const FULL_HUNGER := 98.0 ## can't eat above this
func _ready() -> void:
_bind("interact", [KEY_E])
_bind("inventory", [KEY_TAB, KEY_I])
_bind("rotate_piece", [KEY_R])
_bind("dismantle", [KEY_X])
for i in range(8):
_bind("hotbar_%d" % (i + 1), [KEY_1 + i])
_bind_mouse("attack", MOUSE_BUTTON_LEFT)
_bind_mouse("cancel", MOUSE_BUTTON_RIGHT)
_bind_mouse("use_item", MOUSE_BUTTON_RIGHT)
func _bind(action: String, keys: Array) -> void:
if not InputMap.has_action(action):
@@ -51,6 +64,113 @@ func _give(player: Node, id: String, n: int) -> int:
notify.emit("Inventory full")
return got
## Playing on a server: changes to the island go to it (world_net.gd), and
## come back to everyone, us included.
func _online() -> bool:
return WorldNet.is_client()
## Online, the server decides (player_net.gd): every intent is sent there as
## a request, and the server runs this same function on our character there.
## Offline nothing changes: the game decides.
func _ask(what: String, args: Array = []) -> bool:
PlayerNet.ask(what, args)
return true
## A message for one player: on screen in single-player; on a server, to
## that player's game (player_net.gd). For things that happen later than the
## request that caused them (a fish biting, an arrow landing a kill).
func tell(player: Node, text: String) -> void:
if Net.is_server() and player != null and player.get("peer") != null:
PlayerNet.tell(int(player.peer), text)
else:
notify.emit(text)
## A message for everyone (the wolves howling).
func tell_all(text: String) -> void:
if Net.is_server():
PlayerNet.tell_all(text)
else:
notify.emit(text)
# --- wear and repair (P1, docs/PROGRESSION_DESIGN.md §2) ---------------------------
## One use of the tool or weapon in the player's hand: it wears, and breaks
## when it runs out. Things without "durability" never wear.
func use_held(player: Node) -> void:
var slot: int = player.selected
var s = player.inventory.get_slot(slot)
if s == null:
return
var full := ItemDB.max_durability(s.id)
if full <= 0:
return
var left := ItemDB.durability(s) - 1
if left <= 0:
player.inventory.set_slot(slot, null)
tell(player, "Your %s broke" % ItemDB.item_name(s.id).to_lower())
return
var st: Dictionary = s.duplicate()
st.dur = left
player.inventory.set_slot(slot, st)
if left == int(full * 0.1) or left == 5:
tell(player, "Your %s is nearly worn out (repair it at a %s)" % [ItemDB.item_name(s.id).to_lower(),
String(ItemDB.get_item(s.id).get("repair", {}).get("station", "workbench"))])
## What repairing the item in `slot` costs, and where: {"inputs", "station"},
## or {} if it can't be repaired or doesn't need it.
func repair_cost(player: Node, slot: int) -> Dictionary:
var s = player.inventory.get_slot(slot)
if s == null:
return {}
var r: Dictionary = ItemDB.get_item(s.id).get("repair", {})
if r.is_empty() or ItemDB.durability(s) >= ItemDB.max_durability(s.id):
return {}
return r
func can_repair(player: Node, slot: int) -> bool:
var r := repair_cost(player, slot)
return not r.is_empty() and player.inventory.has_all(r.inputs) and station_near(player, String(r.get("station", "")))
## Brings the item in `slot` back to as new, for a little of what it's made of.
func repair(player: Node, slot: int) -> bool:
if _online():
return _ask("repair", [slot])
var r := repair_cost(player, slot)
if r.is_empty():
return false
if not station_near(player, String(r.get("station", ""))):
notify.emit("Needs a %s nearby" % r.station)
return false
if not player.inventory.has_all(r.inputs):
notify.emit("Missing materials")
return false
var s: Dictionary = player.inventory.get_slot(slot).duplicate()
for id in r.inputs:
player.inventory.remove(id, int(r.inputs[id]))
s.erase("dur")
# (Removing materials can't move the tool: it isn't one of them.)
player.inventory.set_slot(slot, s)
notify.emit("Repaired your %s" % ItemDB.item_name(s.id).to_lower())
return true
## The arrows a bow shoots: the first kind in its "ammo" list you have.
func ammo_for(player: Node, bow: Dictionary) -> String:
var kinds: Array = bow.ammo if bow.ammo is Array else [bow.ammo]
for k in kinds:
if player.inventory.count(String(k)) > 0:
return String(k)
return ""
func ammo_count(player: Node, bow: Dictionary) -> int:
var n := 0
for k in (bow.ammo if bow.ammo is Array else [bow.ammo]):
n += player.inventory.count(String(k))
return n
## Online, a creature is known by its net id (creature_net.gd).
static func _cid(c: Node) -> int:
return int(c.get_meta("cid", 0))
func _in_reach(player: Node3D, target: Node3D, extra: float = 0.0) -> bool:
return player.global_position.distance_to(target.global_position) <= REACH + 2.5 + extra
@@ -59,6 +179,8 @@ func station_near(player: Node3D, station: String) -> bool:
if station.is_empty():
return true
for s in player.get_tree().get_nodes_in_group("station:" + station):
if s.has_method("station_active") and not s.station_active():
continue # e.g. a campfire that has gone out
if (s as Node3D).global_position.distance_to(player.global_position) <= STATION_RANGE:
return true
return false
@@ -73,6 +195,8 @@ func can_craft(player: Node, recipe_id: String) -> bool:
return inv.has_all(r.inputs) and station_near(player, r.station)
func craft(player: Node, recipe_id: String) -> bool:
if _online():
return _ask("craft", [recipe_id])
var r: Dictionary = ItemDB.recipes.get(recipe_id, {})
if r.is_empty():
return false
@@ -94,13 +218,26 @@ func craft(player: Node, recipe_id: String) -> bool:
for id in r.inputs:
inv.remove(id, int(r.inputs[id]))
_give(player, r.output, r.count)
crafted.emit(player, recipe_id, String(r.output), int(r.count))
return true
func pickup(player: Node3D, pickup_node: Node3D) -> bool:
if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node):
return false
if _online():
return _ask("pickup", [WorldNet.pickup_key(pickup_node)]) # first come, first served
var id: String = pickup_node.item_id
if _give(player, id, pickup_node.amount) <= 0:
var extra: Dictionary = pickup_node.get("extra") if pickup_node.get("extra") != null else {}
if not extra.is_empty():
var st := extra.duplicate()
st.id = id
st.count = pickup_node.amount
if player.inventory.add_stack(st) > 0:
notify.emit("Inventory full")
return false
item_gained.emit(id, pickup_node.amount)
notify.emit("+%d %s" % [pickup_node.amount, ItemDB.item_name(id)])
elif _give(player, id, pickup_node.amount) <= 0:
return false
pickup_node.collected()
return true
@@ -109,6 +246,8 @@ func pickup(player: Node3D, pickup_node: Node3D) -> bool:
func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
if not is_instance_valid(node) or node.depleted:
return false
if _online():
return _ask("hit", [String(node.uid), hit_point])
if player.global_position.distance_to(hit_point) > REACH + 1.0:
return false
var held: Dictionary = ItemDB.get_item(player.held_item_id())
@@ -116,9 +255,14 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier:
notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
return false
if not player.survival.use_stamina(float(tool.get("stamina", 6.0))): # (the antler pickaxe is lighter)
notify.emit("Too tired")
return false
var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position)
for id in drops:
_give(player, id, int(drops[id]))
use_held(player)
harvested.emit(player, node, bool(node.depleted))
return true
func can_place(player: Node3D, slot: int) -> bool:
@@ -130,6 +274,8 @@ func can_place(player: Node3D, slot: int) -> bool:
func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
if not can_place(player, slot):
return false
if _online():
return _ask("place", [slot, xf])
if player.global_position.distance_to(xf.origin) > 8.0:
return false
var id: String = player.inventory.get_slot(slot).id
@@ -138,12 +284,422 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
notify.emit("Can't place that here")
return false
player.inventory.take_from(slot, 1)
world.spawn_piece(def, xf)
var piece: Node3D = world.spawn_piece(def, xf)
placed.emit(player, id, piece)
return true
## Takes a placed piece back into your inventory. Building pieces that lose
## their support collapse, and come back to you too.
func dismantle(player: Node3D, piece: Node3D) -> bool:
if not is_instance_valid(piece) or not piece.is_in_group("piece") or piece.is_queued_for_deletion():
return false
if _online():
return _ask("dismantle", [WorldNet.nid(piece)])
if player.global_position.distance_to(piece.global_position) > 8.0:
return false
if piece.item_id == "grave":
notify.emit("Take your things out and it will crumble")
return false
var why: String = piece.dismantle_block_reason()
if not why.is_empty():
notify.emit(why)
return false
if player.inventory.space_for(piece.item_id) < 1:
notify.emit("Inventory full")
return false
_give(player, piece.item_id, 1)
piece.get_parent().get_parent().remove_piece(piece, player)
return true
func toggle_door(player: Node3D, door: Node3D) -> bool:
if not is_instance_valid(door) or not _in_reach(player, door, 1.0):
return false
if _online():
return _ask("door", [WorldNet.nid(door)])
door.set_open(not door.open)
return true
## E on the barrow's coffin (barrow.gd): take what's in it. Opening it wakes
## the guards. What doesn't fit goes on the floor by you.
func loot_barrow(player: Node3D, barrow: Node) -> bool:
if barrow == null or not barrow.found or not _in_reach(player, barrow._coffin, 0.5):
return false
if _online():
return _ask("barrow_loot", [])
var got: Dictionary = barrow.loot(player)
if got.is_empty():
return false
for id in got:
var left: int = player.inventory.add(id, int(got[id]))
if left > 0:
barrow.world.drop_item(id, left, Transform3D(Basis(), player.global_position + Vector3(0, 0.3, 0)))
item_gained.emit(id, int(got[id]))
tell(player, "+%d %s" % [int(got[id]), ItemDB.item_name(id)])
return true
## E on the standing stones' altar (standing_stones.gd, P5): set barrow key
## pieces in it, or (all three in, at night) call the Old Stag.
func use_stones(player: Node3D, stones: Node) -> bool:
if stones == null or not stones.found or not _in_reach(player, stones.altar(), 0.5):
return false
if _online():
return _ask("stones", [])
return not stones.use(player).is_empty()
## E on a door an alpha broke: mend it (two wood), shut.
func repair_door(player: Node3D, door: Node3D) -> bool:
if not is_instance_valid(door) or not door.get("broken") or not _in_reach(player, door, 1.0):
return false
if _online():
return _ask("door_repair", [WorldNet.nid(door)])
for id in door.REPAIR_COST:
if player.inventory.count(id) < int(door.REPAIR_COST[id]):
tell(player, "Need %d %s to mend the door" % [int(door.REPAIR_COST[id]), ItemDB.item_name(id).to_lower()])
return false
for id in door.REPAIR_COST:
player.inventory.remove(id, int(door.REPAIR_COST[id]))
door.set_broken(false)
tell(player, "Door mended")
return true
## E on a bed: claim it as your spawn point, or (if it's already yours) sleep.
func use_bed(player: Node3D, bed: Node3D) -> bool:
if not is_instance_valid(bed) or not _in_reach(player, bed, 1.0):
return false
if _online():
return _ask("bed", [WorldNet.nid(bed)])
var world: Node = bed.get_parent().get_parent()
# On a server each player has their own bed (server_player.gd).
var mine: bool = player.claims_bed(bed) if player.has_method("claims_bed") else bed.is_spawn()
if not mine:
if player.has_method("claim_bed"):
player.claim_bed(bed)
else:
world.set_bed_spawn(bed)
notify.emit("Spawn point set")
return true
var why: String = world.sleep_block_reason(bed)
if not why.is_empty():
notify.emit(why)
return false
world.sleep(player)
slept.emit(player)
return true
# --- hunting and combat ---------------------------------------------------------
## Bare hands (or berries, a torch, a stone...) can't hurt an animal: you
## need a weapon, or at least an axe or pickaxe.
const NO_WEAPON := {"damage": 0.0, "reach": 2.0, "stamina": 0.0, "cooldown": 0.45, "armed": false}
const TOOL_AS_WEAPON := 6.0 ## axes and pickaxes are poor weapons
const NEED_WEAPON := "You need a weapon: a club, spear, bow or axe (Tab to craft)"
## Melee stats for what's in the player's hand. "armed" is false when it
## can't hurt an animal.
func melee_stats(player: Node) -> Dictionary:
var held: Dictionary = ItemDB.get_item(player.held_item_id())
var w: Dictionary = held.get("weapon", {})
if float(w.get("damage", 0)) > 0.0:
return {"damage": float(w.damage), "reach": float(w.get("reach", 2.2)),
"stamina": float(w.get("stamina", 8)), "cooldown": float(w.get("cooldown", 0.5)), "armed": true,
"blunt": bool(w.get("blunt", false))}
if held.has("tool"):
return {"damage": TOOL_AS_WEAPON, "reach": 2.3, "stamina": 6.0, "cooldown": 0.5, "armed": true}
return NO_WEAPON
## One melee blow at an animal.
func melee(player: Node3D, target: Node3D) -> bool:
if _online():
if not is_instance_valid(target) or not target.is_in_group("creature"):
return false
if not melee_stats(player).armed:
notify.emit(NEED_WEAPON)
return false
return _ask("melee", [_cid(target)])
if not is_instance_valid(target) or not target.is_in_group("creature"):
return false
var m := melee_stats(player)
if not m.armed:
notify.emit(NEED_WEAPON)
return false
if player.global_position.distance_to(target.global_position) > m.reach + 1.8:
return false
if not player.survival.use_stamina(m.stamina):
notify.emit("Too tired")
return false
# Blunt weapons (the club) crack bone: +50% on skeletons.
var bone := bool(target.def.get("bone", false)) if target.get("def") != null else false
if target.has_method("credit"):
target.credit(player)
target.take_hit(m.damage * (1.5 if bone and m.get("blunt", false) else 1.0), player.global_position)
use_held(player)
if target.is_dead():
notify.emit("Killed a %s" % target.display_name().to_lower())
killed.emit(player, String(target.kind))
return true
## A melee blow at another player (online, with PvP on). Online only.
func hit_player(player: Node3D, peer: int) -> bool:
if _online():
if not melee_stats(player).armed:
notify.emit(NEED_WEAPON)
return false
return _ask("hit_player", [peer])
if not Net.is_server() or not PlayerNet.proxies.has(peer) or player.get("peer") == peer:
return false
var target: Node3D = PlayerNet.proxies[peer]
if not preload("res://scripts/net/rules.gd").pvp():
notify.emit("PvP is off on this server")
return false
if target.survival.dead:
return false
var m := melee_stats(player)
if not m.armed:
notify.emit(NEED_WEAPON)
return false
# (Positions are where the server last heard you both were: a little slack.)
if player.global_position.distance_to(target.global_position) > m.reach + 2.5:
return false
if Time.get_ticks_msec() < int(player.melee_ready_at):
return false
if not player.survival.use_stamina(m.stamina):
notify.emit("Too tired")
return false
player.melee_ready_at = Time.get_ticks_msec() + int(m.cooldown * 1000.0 * 0.8)
target.survival.damage(m.damage, player.global_position, String(player.display))
use_held(player)
notify.emit("Hit %s" % String(target.display))
return true
## An arrow or thrown spear reaching an animal (projectile.gd).
func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3, embed_id: String) -> void:
if not is_instance_valid(target) or not target.is_in_group("creature"):
return
var was_alive: bool = not target.is_dead()
if shooter != null and target.has_method("credit"):
target.credit(shooter) # (the Old Stag remembers everyone who hurt it)
target.take_hit(damage, shooter.global_position if shooter != null else at, embed_id)
if was_alive and target.is_dead():
tell(shooter, "Killed a %s" % target.display_name().to_lower())
killed.emit(shooter, String(target.kind))
## Throws the spear in `slot` from `origin` along `dir`.
func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
if _online():
return _ask("throw", [slot, dir])
var s = player.inventory.get_slot(slot)
if s == null:
return false
var w: Dictionary = ItemDB.get_item(s.id).get("weapon", {})
if not w.has("throw") or player.global_position.distance_to(origin) > 2.5:
return false
if not player.survival.use_stamina(float(w.throw.get("stamina", 12))):
notify.emit("Too tired")
return false
var id: String = s.id
var left := ItemDB.durability(s) - 1
player.inventory.take_from(slot, 1)
var pr: Node3D = world.launch(id, origin, dir.normalized() * float(w.throw.speed) + player.velocity * 0.5,
float(w.throw.damage), player)
if ItemDB.max_durability(id) > 0:
if left <= 0:
pr.breaks = true # (its last throw: it breaks where it lands)
tell(player, "Your %s will break" % ItemDB.item_name(id).to_lower())
else:
pr.extra = {"dur": left}
return true
## Starts drawing the held bow: needs arrows and a little stamina.
func can_draw(player: Node3D) -> bool:
var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {})
if _online():
# (The server checks arrows and spends the stamina when you shoot.)
if bow.is_empty():
return false
if ammo_count(player, bow) <= 0:
notify.emit("No arrows (craft them at a workbench: sticks, flint, feathers)")
return false
return true
if bow.is_empty():
return false
if ammo_count(player, bow) <= 0:
notify.emit("No arrows (craft them at a workbench: sticks, flint, feathers)")
return false
if not player.survival.use_stamina(float(bow.stamina)):
notify.emit("Too tired")
return false
return true
## Looses an arrow. `power` 0..1 is how far the bow was drawn.
func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: Node3D) -> bool:
if _online():
return _ask("shoot", [dir, power])
var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {})
if bow.is_empty() or player.global_position.distance_to(origin) > 2.5:
return false
var ammo := ammo_for(player, bow)
if ammo.is_empty() or not player.inventory.remove(ammo, 1):
return false
power = clampf(power, 0.0, 1.0)
var spd: float = lerpf(float(bow.speed[0]), float(bow.speed[1]), power)
var dmg: float = lerpf(float(bow.damage[0]), float(bow.damage[1]), power)
dmg *= float(ItemDB.get_item(ammo).get("ammo", {}).get("damage_mult", 1.0))
world.launch(ammo, origin, dir.normalized() * spd, dmg, player)
use_held(player)
return true
func butcher(player: Node3D, carcass: Node3D) -> bool:
if _online():
if not is_instance_valid(carcass) or not carcass.is_in_group("carcass"):
return false
return _ask("butcher", [_cid(carcass)])
if not is_instance_valid(carcass) or not carcass.is_in_group("carcass") or not _in_reach(player, carcass, 1.0):
return false
var drops: Dictionary = carcass.butcher_drops()
carcass.remove_from_group("carcass")
butchered.emit(player, String(carcass.kind))
for id in drops:
var got := _give(player, id, int(drops[id]))
if got < int(drops[id]):
# Inventory full: the rest drops on the ground.
carcass.world.drop_item(id, int(drops[id]) - got, Transform3D(Basis(), carcass.global_position + Vector3(0, 0.1, 0)))
carcass.queue_free()
return true
# --- fishing ----------------------------------------------------------------------
## Casts the held rod from `origin` along `dir`.
func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
if _online():
if ItemDB.get_item(player.held_item_id()).get("fishing", {}).is_empty():
return false
return _ask("cast", [dir])
var rod: Dictionary = ItemDB.get_item(player.held_item_id()).get("fishing", {})
if rod.is_empty() or player.global_position.distance_to(origin) > 2.5:
return false
if not player.survival.use_stamina(float(rod.get("stamina", 4))):
notify.emit("Too tired")
return false
var d := dir.normalized()
d.y = maxf(d.y, -0.2) + 0.35 # a lobbed cast, even when looking level
world.start_fishing(player, origin, d.normalized() * float(rod.get("cast_speed", 12.0)))
use_held(player)
return true
## Reels in. With a fish biting you land it; otherwise the line just comes in.
func reel(player: Node3D, world: Node3D) -> bool:
if _online():
return _ask("reel")
var b: Node3D = world.bobber_of(player)
if b == null or not is_instance_valid(b):
return false
var caught := ""
if b.state == "biting":
caught = b.fish
world.end_fishing(player)
if caught.is_empty():
return false
if _give(player, caught, 1) == 0:
world.drop_item(caught, 1, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0)))
notify.emit("Caught a %s" % ItemDB.item_name(caught).to_lower())
emit_signal("caught", player, caught) # (the local `caught` hides the signal's name)
return true
## Puts on the cloak in `slot` (swapping out any cloak you were wearing).
func wear(player: Node3D, slot: int) -> bool:
if _online():
return _ask("wear", [slot])
var s = player.inventory.get_slot(slot)
if s == null or not ItemDB.get_item(s.id).has("wear"):
return false
var id: String = s.id
player.inventory.take_from(slot, 1)
if not player.worn.is_empty():
_give(player, player.worn, 1)
player.worn = id
player.worn_changed.emit()
notify.emit("Wearing %s" % ItemDB.item_name(id).to_lower())
return true
func take_off(player: Node3D) -> bool:
if player.worn.is_empty():
return false
if _online():
return _ask("take_off")
if player.inventory.space_for(player.worn) < 1:
notify.emit("Inventory full")
return false
_give(player, player.worn, 1)
player.worn = ""
player.worn_changed.emit()
return true
func forage(player: Node3D, bush: Node3D) -> bool:
if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
return false
if _online():
return _ask("forage", [String(bush.uid)])
var got: Dictionary = bush.harvest()
for id in got:
_give(player, id, int(got[id]))
return not got.is_empty()
## Eats one of the food in `slot`.
func eat(player: Node3D, slot: int) -> bool:
if _online():
return _ask("eat", [slot])
var s = player.inventory.get_slot(slot)
if s == null:
return false
var food: Dictionary = ItemDB.get_item(s.id).get("food", {})
if food.is_empty():
return false
if player.survival.sick > 0.0:
tell(player, "You feel too sick to eat")
return false
if player.survival.hunger >= FULL_HUNGER:
notify.emit("You're full")
return false
player.inventory.take_from(slot, 1)
var eaten := String(s.id)
player.survival.eat(food)
notify.emit("Ate %s" % ItemDB.item_name(eaten).to_lower())
ate.emit(player, eaten)
# Raw meat and fish can make you ill (P4). Cooked, smoked or salted can't.
if food_rng.randf() < float(food.get("risk", 0.0)):
player.survival.poison()
tell(player, "Your stomach turns: food poisoning. Cook your meat!")
return true
## Rolls for food poisoning (tests seed it).
var food_rng := RandomNumberGenerator.new()
## Feeds a campfire from the player's inventory: wood first, then sticks.
func add_fuel(player: Node3D, fire: Node3D) -> bool:
if not is_instance_valid(fire) or not _in_reach(player, fire):
return false
if _online():
return _ask("fuel", [WorldNet.nid(fire)])
if fire.fuel >= fire.MAX_FUEL - 1.0:
notify.emit("The fire is fully stoked")
return false
for id in ["wood", "bear_fat", "stick", "fiber"]:
if player.inventory.count(id) > 0:
player.inventory.remove(id, 1)
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower())
return true
notify.emit("Nothing to burn (wood or sticks)")
return false
## Moves between two inventories (quick-move / shift-click). The player must
## be next to the container.
func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, container: Node3D = null) -> void:
if container != null and not _in_reach(player, container, 1.0):
return
if _online():
if container != null:
_ask("transfer", [WorldNet.nid(container), slot, from == player.inventory])
return
from.quick_move(slot, to)

52
scripts/core/boot.gd Normal file
View File

@@ -0,0 +1,52 @@
extends Node
## Autoload "Boot", first in the list: one-off start-up housekeeping that has
## to happen before anything reads user://.
##
## The game was called "Valheimlike" until it became "Emberwild". Godot names
## the user-data folder after the project, so the rename moved it from
## .../app_userdata/Valheimlike to .../app_userdata/Emberwild. The first time
## Emberwild starts, this copies saved worlds, your identity (key, name, look)
## and server settings across. It copies rather than moves, so the old folder
## stays as a backup, and it never overwrites anything already in the new
## folder. Test folders (test_*) and logs are skipped.
const OLD_NAME := "Valheimlike"
const MARKER := "migrated_from_valheimlike.txt"
const SKIP := ["logs", "shader_cache", "vulkan", "test_worlds", "test_net"]
var copied := 0
func _enter_tree() -> void:
migrate_user_data()
## Returns the number of files copied (0 if there was nothing to do).
func migrate_user_data() -> int:
var new_dir := OS.get_user_data_dir()
var old_dir := new_dir.get_base_dir().path_join(OLD_NAME)
if new_dir == old_dir or not DirAccess.dir_exists_absolute(old_dir):
return 0
if FileAccess.file_exists(new_dir.path_join(MARKER)):
return 0
copied = 0
_copy_tree(old_dir, new_dir)
var f := FileAccess.open(new_dir.path_join(MARKER), FileAccess.WRITE)
if f != null:
f.store_string("Copied %d files from %s on %s. The old folder was left as it was.\n" % [
copied, old_dir, Time.get_datetime_string_from_system()])
if copied > 0:
print("[boot] moved to the new name: copied %d files from %s" % [copied, old_dir])
return copied
func _copy_tree(from: String, to: String) -> void:
DirAccess.make_dir_recursive_absolute(to)
var da := DirAccess.open(from)
if da == null:
return
for f in da.get_files():
var dst := to.path_join(f)
if not FileAccess.file_exists(dst) and DirAccess.copy_absolute(from.path_join(f), dst) == OK:
copied += 1
for d in da.get_directories():
if d in SKIP or d.begins_with("test_"):
continue
_copy_tree(from.path_join(d), to.path_join(d))

416
scripts/core/guide.gd Normal file
View File

@@ -0,0 +1,416 @@
extends Node
## Autoload "Guide": the tutorial (docs/TUTORIAL_DESIGN.md §3).
##
## Runs the steps in data/tutorial.json in order. Each step has a goal made
## of real game events (Actions' signals: items gained, things crafted,
## trees felled...) plus walking; the Guide counts them from the moment the
## tutorial starts, so a step you've already done ticks as soon as it comes
## up. Nothing is forced and nothing is on a timer.
##
## Goals:
## {"walk": metres} walked that far in all
## {"gained": id, "count": n} picked up / given that many
## {"crafted": recipe_id} (and "count")
## {"felled": "tree" | "rock"} chopped down / broken
## {"placed": item_id}, {"ate": item_id or "any"}, {"killed": kind},
## {"butchered": kind}, {"caught": "any"}, {"slept": true}
## {"continue": true} the player pressed Enter ("Continue")
## {"shelter": true} standing under a roof with walls round you
## {"bed_spawn": true} you've set your spawn at a bed
## {"demo_wolf": true} the tutorial's wolf has been and gone
## {"all": [goal, goal, ...]} every one of them
## {"any": [goal, goal, ...]} one of them
## Markers: {"nearest": "pickup:<id>" | "tree" | "rock" | "bush" | "lake" |
## "creature:<kind>" | "carcass" | "piece:<item id>"}, optionally
## {"unless_have": {id: n}} to hide it once you've got enough.
## Set-up, when a step comes up: {"time": hours} (the clock jumps on),
## {"spawn": {"kind", "count", "distance"}} (calm animals round you),
## {"demo_wolf": true} (a wolf that circles and leaves: tutorial_wolf.gd).
##
## Single-player only (the tutorial is your own world). The panel and the
## in-world marker are ui/guide.gd. Progress is saved with the world.
signal started
signal step_started(index: int, step: Dictionary)
signal step_done(index: int, step: Dictionary)
signal progressed ## a count went up (the panel redraws)
signal finished
const DATA := "res://data/tutorial.json"
var active := false
var steps: Array = []
var chapters: Array = []
var index := 0
var done: Array = [] ## step ids, in the order they ticked
var counters: Dictionary = {} ## "gained:stick" -> 3, "walk" -> 12.5, ...
var world: Node3D
var _last_pos := Vector3.INF
var _marker_t := 0.0
var _marker := Vector3.INF
func _ready() -> void:
_load_data()
var A: Node = get_node("/root/Actions")
A.item_gained.connect(_on_gained)
A.crafted.connect(_on_crafted)
A.harvested.connect(_on_harvested)
A.placed.connect(_on_placed)
A.ate.connect(_on_ate)
A.killed.connect(_on_killed)
A.butchered.connect(_on_butchered)
A.caught.connect(_on_caught)
A.slept.connect(_on_slept)
func _on_gained(id: String, n: int) -> void:
_count("gained:" + id, n)
func _on_crafted(_p: Node, rid: String, _out: String, _n: int) -> void:
_count("crafted:" + rid, 1)
func _on_harvested(_p: Node, node: Node, felled: bool) -> void:
if felled:
_count("felled:" + String(node.kind), 1)
func _on_placed(_p: Node, id: String, _piece: Node) -> void:
_count("placed:" + id, 1)
func _on_ate(_p: Node, id: String) -> void:
_count("ate:" + id, 1)
_count("ate:any", 1)
func _on_killed(_p: Node, kind: String) -> void:
_count("killed:" + kind, 1)
func _on_butchered(_p: Node, kind: String) -> void:
_count("butchered:" + kind, 1)
func _on_caught(_p: Node, fish: String) -> void:
_count("caught:" + fish, 1)
_count("caught:any", 1)
func _on_slept(_p: Node) -> void:
_count("slept:true", 1)
func _load_data() -> void:
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(DATA)) if FileAccess.file_exists(DATA) else null
if d is Dictionary:
steps = d.get("steps", [])
chapters = d.get("chapters", [])
guide_name = String(d.get("guide", {}).get("name", ""))
finish = d.get("finish", {})
## Who's talking you through it (data/tutorial.json "guide": Old Tam, the
## island's hermit).
var guide_name := ""
var finish: Dictionary = {} ## the card at the end: title and text
# --- running it -----------------------------------------------------------------------
func start(w: Node3D, from_saved: Dictionary = {}) -> void:
world = w
active = true
index = 0
done = []
counters = {}
_last_pos = Vector3.INF
if not from_saved.is_empty():
load_data(from_saved)
started.emit()
_enter()
func stop() -> void:
_clear_outline()
active = false
world = null
func current() -> Dictionary:
return steps[index] if active and index < steps.size() else {}
func is_finished() -> bool:
return active and index >= steps.size()
func chapter_title(step: Dictionary) -> String:
for c in chapters:
if c.id == step.get("chapter", ""):
return String(c.title)
return ""
func _enter() -> void:
if index >= steps.size():
finished.emit()
return
_setup(steps[index].get("setup", {}))
step_started.emit(index, steps[index])
_marker_t = 0.0
_check() # (done early? it ticks straight away)
const DemoWolf = preload("res://scripts/creatures/tutorial_wolf.gd")
const HutOutline = preload("res://scripts/ui/hut_outline.gd")
var demo_wolf: Node3D = null
var outline: Node3D = null ## the hut outline, while the hut step is on
func _clear_outline() -> void:
if outline != null and is_instance_valid(outline):
outline.queue_free()
outline = null
if world != null and is_instance_valid(world):
world.build.outline = null
## A step's set-up: the clock, some animals, the demo wolf.
func _setup(su: Dictionary) -> void:
if su.is_empty() or world == null or not is_instance_valid(world):
return
if su.has("time"):
var want: float = float(su.time) / 24.0
if world.day_night.time < want:
world.day_night.time = want
world.day_night.apply(true)
if su.has("spawn"):
var sp: Dictionary = su.spawn
var p: Vector3 = world.player.global_position
var rng := RandomNumberGenerator.new()
rng.seed = int(p.x * 13.0 + p.z * 7.0)
for i in range(int(sp.get("count", 1))):
for _try in range(12):
var a := rng.randf() * TAU
var at := p + Vector3(cos(a), 0, sin(a)) * float(sp.get("distance", 12.0))
if world.terrain.inside_boundary(at.x, at.z, 4.0) and world.terrain.sample_height(at.x, at.z) > world.terrain.water_level + 0.3:
world.creatures.spawn(String(sp.kind), at)
break
if String(su.get("outline", "")) == "hut":
_clear_outline()
# A good spot a few metres from your fire (or from you).
# Search out from the fire in small rings, starting far enough out
# that the hut (3 m half-width) leaves the fire 2 m clear of its walls.
var near: Vector3 = world.player.global_position
var step := 4.0
var min_dist := 0.0
for pc in world.all_pieces():
if pc.item_id == "campfire" and pc.global_position.distance_to(near) < 30.0:
near = pc.global_position
step = 1.5
min_dist = 5.0
var site: Vector3 = world.build.debug_find_site(near, 3.0, step, min_dist)
var yaw: float = world.player.rotation.y
var b := Basis(Vector3.UP, yaw)
var first: Transform3D = world.build._free_xf("floor", site + b * Vector3(-1, 0, -1), yaw, true)
outline = HutOutline.new()
outline.name = "HutOutline"
world.add_child(outline)
outline.setup(world, first)
world.build.outline = outline
if su.get("demo_wolf", false):
if demo_wolf != null and is_instance_valid(demo_wolf):
demo_wolf.queue_free()
demo_wolf = DemoWolf.new()
demo_wolf.setup("wolf", world, world.player.global_position + Vector3(20, 0, 0), 4242)
world.add_child(demo_wolf)
demo_wolf.begin(world.player)
demo_wolf.gone.connect(func(): _count("demo_wolf:true", 1))
## The player read a "Continue" step.
func continue_step() -> void:
_count("continue:true", 1)
## Skips the current step (pause menu, T2).
func skip() -> void:
if not current().is_empty():
_advance()
func _count(key: String, n: float) -> void:
counters[key] = float(counters.get(key, 0.0)) + n
if active:
progressed.emit()
_check()
func _check() -> void:
var s := current()
if s.is_empty():
return
if _met(s.goal):
_advance()
func _advance() -> void:
_clear_outline() # (it was only for the step that's ending)
var s := current()
done.append(String(s.id))
step_done.emit(index, s)
index += 1
# A "continue" only counts for the step it was pressed on.
counters.erase("continue:true")
_enter()
# --- goals -------------------------------------------------------------------------------
## [[label, have, need], ...] for each part of `goal`.
func parts(goal: Dictionary) -> Array:
if goal.has("any"):
var names := []
var met := false
for g in goal.any:
for pp in parts(g):
names.append(String(pp[0]))
met = met or _met(g)
return [[" or ".join(PackedStringArray(names)), 1.0 if met else 0.0, 1.0]]
if goal.has("all"):
var out := []
for g in goal.all:
out.append_array(parts(g))
return out
var need := float(goal.get("count", 1))
for k in ["gained", "crafted", "felled", "placed", "ate", "killed", "butchered", "caught", "slept", "continue",
"shelter", "bed_spawn", "demo_wolf"]:
if goal.has(k):
var key := "%s:%s" % [k, str(goal[k]).to_lower() if goal[k] is bool else str(goal[k])] # (true -> "true")
return [[_label(k, goal), float(counters.get(key, 0.0)), need]]
if goal.has("walk"):
return [["Walk", floorf(float(counters.get("walk", 0.0))), float(goal.walk)]]
return []
func _label(kind: String, goal: Dictionary) -> String:
if goal.has("label"):
return String(goal.label)
match kind:
"gained": return _plural(ItemDB.item_name(String(goal.gained)), int(goal.get("count", 1)))
"crafted":
var r: Dictionary = ItemDB.recipes.get(String(goal.crafted), {})
return ItemDB.item_name(String(r.get("output", goal.crafted)))
"felled": return "Tree" if goal.felled == "tree" else "Boulder"
"continue", "shelter", "bed_spawn", "demo_wolf": return ""
"placed": return ItemDB.item_name(String(goal.placed)) + " placed"
"ate": return "Eaten"
"killed": return String(goal.killed).capitalize()
"butchered": return "Butchered"
"caught": return "A fish"
"slept": return "Slept"
return kind.capitalize()
static func _plural(name: String, n: int) -> String:
if n <= 1 or name.ends_with("s") or name.to_lower() in ["fiber", "fibre", "wood", "stone", "flint", "clay", "salt"]:
return name
return name + "s"
func _met(goal: Dictionary) -> bool:
for p in parts(goal):
if float(p[1]) < float(p[2]):
return false
return true
## "Sticks 2/3 · Stone 1/3" for the current step (empty for one-off goals).
func progress_text() -> String:
var s := current()
if s.is_empty():
return ""
var bits := []
for p in parts(s.goal):
if float(p[2]) > 1.0:
bits.append("%s %d/%d" % [p[0], mini(int(p[1]), int(p[2])), int(p[2])])
elif not String(p[0]).is_empty():
bits.append(("✓ " if float(p[1]) >= 1.0 else "") + String(p[0]))
return " · ".join(PackedStringArray(bits))
# --- walking and the marker ------------------------------------------------------------
func _process(delta: float) -> void:
if not active or world == null or not is_instance_valid(world) or world.player == null:
return
var p: Vector3 = world.player.global_position
if _last_pos != Vector3.INF:
var d := Vector2(p.x - _last_pos.x, p.z - _last_pos.z).length()
if d > 0.01 and d < 5.0: # (not a teleport or a respawn)
_count("walk", d)
_last_pos = p
_marker_t -= delta
if _marker_t <= 0.0:
_marker_t = 0.5
_marker = find_marker()
poll()
## Goals that are states, not events: checked twice a second.
func poll() -> void:
if not active or world == null or not is_instance_valid(world):
return
var pl: Node = world.player
if pl.survival.sheltered and float(counters.get("shelter:true", 0.0)) < 1.0:
_count("shelter:true", 1)
if world.has_bed_spawn and float(counters.get("bed_spawn:true", 0.0)) < 1.0:
_count("bed_spawn:true", 1)
## Called by tests (no frames): moves the walk counter on by `metres`.
func walked(metres: float) -> void:
_count("walk", metres)
func marker_position() -> Vector3:
return _marker
## Where the current step's marker goes: the nearest thing it asks for, or
## INF (no marker, or you've got what it was for).
func find_marker() -> Vector3:
var s := current()
if s.is_empty() or not s.has("marker") or world == null:
return Vector3.INF
var m: Dictionary = s.marker
for id in m.get("unless_have", {}):
if world.player.inventory.count(id) >= int(m.unless_have[id]):
return Vector3.INF
var what := String(m.get("nearest", ""))
var from: Vector3 = world.player.global_position
var best := Vector3.INF
var best_d := INF
var cands: Array = []
if what.begins_with("pickup:"):
var id := what.substr(7)
for pk in world.props.pickups.values():
if is_instance_valid(pk) and not pk.taken and pk.item_id == id:
cands.append(pk)
elif what in ["tree", "rock"]:
for n in world.props.resource_nodes.values():
if is_instance_valid(n) and n.kind == what and not n.depleted:
cands.append(n)
elif what == "bush":
for b in world.props.forage.values():
if is_instance_valid(b) and b.ripe():
cands.append(b)
elif what.begins_with("creature:"):
for c in world.creatures.alive(what.substr(9)):
cands.append(c)
elif what == "carcass":
for c in world.get_tree().get_nodes_in_group("carcass"):
cands.append(c)
elif what.begins_with("piece:"):
for pc in world.all_pieces():
if pc.item_id == what.substr(6):
cands.append(pc)
elif what == "outline":
# Wherever the outline still has pieces to build: its middle.
if outline != null and is_instance_valid(outline):
return outline.center()
return Vector3.INF
elif what == "lake":
# The lake's shore, on your side of it.
var c: Vector2 = world.terrain.lake_center
var dir := Vector2(from.x - c.x, from.z - c.y)
dir = dir.normalized() if dir.length() > 0.1 else Vector2(1, 0)
var r: float = world.terrain.lake_radius * 0.6
while r < world.terrain.lake_radius * 2.0 and world.terrain.sample_height(c.x + dir.x * r, c.y + dir.y * r) < world.terrain.water_level + 0.3:
r += 0.5
var sx: float = c.x + dir.x * r
var sz: float = c.y + dir.y * r
return Vector3(sx, world.terrain.sample_height(sx, sz), sz)
for c in cands:
var d: float = (c as Node3D).global_position.distance_to(from)
if d < best_d:
best_d = d
best = (c as Node3D).global_position
return best
# --- saving --------------------------------------------------------------------------------
func save_data() -> Dictionary:
return {"index": index, "done": done.duplicate(), "counters": counters.duplicate()} if active else {}
func load_data(d: Dictionary) -> void:
index = clampi(int(d.get("index", 0)), 0, steps.size())
done = (d.get("done", []) as Array).duplicate()
counters = (d.get("counters", {}) as Dictionary).duplicate()

137
scripts/core/hints.gd Normal file
View File

@@ -0,0 +1,137 @@
extends Node
## T4 (docs/TUTORIAL_DESIGN.md §5): Old Tam's tips in normal worlds. Each tip
## (data/hints.json) shows once per player, the first time it fits: first
## rain, first night, the wolves' howl, a poor diet, a first death... They
## read only your own state (survival statuses, the weather, the clock, your
## bag) plus two events, so they work the same online. Not in the tutorial
## world (the tutorial covers it) or on a server. A setting turns them off.
## The HUD's hint card (scripts/ui/hint_card.gd) shows them.
signal shown(hint: Dictionary)
const GAP := 16.0 ## seconds between tips, so they don't pile up
const START_QUIET := 4.0 ## nothing in the first seconds of a world
var path := "user://hints.json" ## your settings: on or off, and the tips seen
var enabled := true
var seen: Array = []
var hints: Array = []
var guide_name := ""
var world: Node3D = null
var active := false
var _held: Dictionary = {} ## hint id -> seconds its condition has held
var _events: Dictionary = {} ## "howl" -> true, until the tip shows
var _cooldown := 0.0
func _ready() -> void:
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://data/hints.json"))
if d is Dictionary:
hints = d.get("hints", [])
guide_name = String(d.get("guide", ""))
load_prefs()
func load_prefs() -> void:
enabled = true
seen = []
if not FileAccess.file_exists(path):
return
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if d is Dictionary:
enabled = bool(d.get("enabled", true))
for id in d.get("seen", []):
seen.append(String(id))
func save_prefs() -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify({"enabled": enabled, "seen": seen}, "\t"))
func set_enabled(on: bool) -> void:
enabled = on
save_prefs()
## Every tip can show again (the pause menu's "Show tips again").
func reset_seen() -> void:
seen = []
save_prefs()
var allow_headless := false ## tests: run with no screen (and their own `path`)
func start(w: Node3D) -> void:
if DisplayServer.get_name() == "headless" and not allow_headless:
return # (the other suites' worlds mustn't use up your real tips)
world = w
active = true
_held = {}
_events = {}
_cooldown = START_QUIET
func stop() -> void:
active = false
world = null
## Something that a condition can't read happened: "howl", "died".
func event(id: String) -> void:
if active:
_events[id] = true
func _process(delta: float) -> void:
if active:
step(delta)
func step(dt: float) -> void:
if not active or not enabled or world == null or not is_instance_valid(world) or Guide.active:
return
_cooldown -= dt
var due: Dictionary = {}
for h in hints:
var id := String(h.id)
if seen.has(id):
continue
if _met(String(h.when)):
_held[id] = float(_held.get(id, 0.0)) + dt
if due.is_empty() and _held[id] >= float(h.get("hold", 1.0)):
due = h
else:
_held[id] = 0.0
if not due.is_empty() and _cooldown <= 0.0:
_show(due)
func _show(h: Dictionary) -> void:
seen.append(String(h.id))
_events.erase(String(h.when).trim_prefix("event:"))
save_prefs()
_cooldown = GAP
shown.emit(h)
func _met(when: String) -> bool:
var player: Node = world.player
if player == null or not is_instance_valid(player):
return false
var dead: bool = world.dead_waiting
var kind := when.get_slice(":", 0)
var vals := when.get_slice(":", 1).split("|", false) if when.contains(":") else PackedStringArray()
match kind:
"event":
return not dead and _events.has(vals[0])
"status":
if dead:
return false
for s in player.survival.statuses:
for v in vals:
if String(s).begins_with(v):
return true
return false
"weather":
return not dead and world.weather != null and String(world.weather.state) in vals
"has":
return not dead and player.inventory.count(vals[0]) > 0
"night":
return not dead and world.day_night.is_night()
"stamina_low":
return not dead and player.survival.stamina < 8.0
"wolf_moon":
return not dead and world.day_night.is_night() and world.day_night.wolf_moon_now()
"hard_winter":
return not dead and world.weather != null and world.weather.in_hard_winter()
return false

View File

@@ -31,10 +31,111 @@ static func _stack_max(id: String) -> int:
var db := (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ItemDB")
return db.stack_size(id) if db != null else 50
## A stack can carry "dur": uses left on a tool or weapon that wears out
## (ItemDB "durability"). No "dur" means as new.
## Food that spoils (ItemDB "keeps", in days) carries "rot": the world time
## (seconds since day 1, 00:00) at which it goes rotten. It's stamped when
## the food first goes into an inventory; two stacks that merge share the
## average. world.gd turns rotten stacks into rotten food (spoil()).
static func _norm(stack):
if stack == null or int(stack.count) <= 0:
return null
return {"id": String(stack.id), "count": int(stack.count)}
var out := {"id": String(stack.id), "count": int(stack.count)}
if stack.has("dur"):
out.dur = int(stack.dur)
if stack.has("rot"):
out.rot = float(stack.rot)
return out
## What's special about a stack, for when it's dropped on the ground (and
## kept when it's picked up again): a tool's wear, food's freshness.
static func extra_of(stack: Dictionary) -> Dictionary:
var out := {}
if stack.has("dur"):
out.dur = int(stack.dur)
if stack.has("rot"):
out.rot = float(stack.rot)
return out
# --- spoilage (P4, docs/PROGRESSION_DESIGN.md §5) ---------------------------------
## The world clock (seconds since day 1, 00:00) and how long a day is, kept
## up to date by world.gd. `rot_speed`: 1 normal, 0.5 slow, 0 off (the
## world's or server's "rot" setting).
static var clock := 0.0
static var day_seconds := 1200.0
static var rot_speed := 1.0
const ROTTEN := "rotten_food"
static func _db() -> Node:
return (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ItemDB")
## Days `id` keeps before it rots (0: it doesn't).
static func keeps_days(id: String) -> float:
var db := _db()
return float(db.get_item(id).get("keeps", 0.0)) if db != null else 0.0
## How long `id` keeps, in world seconds, at the current rot speed (0: forever).
static func keeps_for(id: String) -> float:
var d := keeps_days(id)
if d <= 0.0 or rot_speed <= 0.0:
return 0.0
return d * day_seconds / rot_speed
## The "rot" stamp for `id` made right now (-1: it doesn't spoil).
static func fresh_rot(id: String) -> float:
var k := keeps_for(id)
# (Whole seconds, so a stamp comes back from a JSON save exactly as it went in.)
return roundf(clock + k) if k > 0.0 else -1.0
## 0..1: how fresh a stack is (1 = just made or doesn't spoil).
static func freshness(stack) -> float:
if stack == null or not stack.has("rot"):
return 1.0
var k := keeps_for(String(stack.id))
if k <= 0.0:
return 1.0
return clampf((float(stack.rot) - clock) / k, 0.0, 1.0)
## In-game hours until a stack rots (INF: never).
static func hours_left(stack) -> float:
if stack == null or not stack.has("rot") or rot_speed <= 0.0:
return INF
return maxf(float(stack.rot) - clock, 0.0) / day_seconds * 24.0
## Stamps food that has no "rot" yet, and turns food past its time into
## rotten food. Returns how many items went rotten.
func spoil() -> int:
if rot_speed <= 0.0:
return 0
var rotted := 0
var touched := false
for i in range(slots.size()):
var s = slots[i]
if s == null or keeps_days(String(s.id)) <= 0.0:
continue
if not s.has("rot"):
s.rot = fresh_rot(String(s.id))
touched = true
elif clock >= float(s.rot):
rotted += int(s.count)
slots[i] = {"id": ROTTEN, "count": int(s.count)}
touched = true
if touched:
changed.emit()
return rotted
## Adds a whole stack, keeping what's special about it (a worn tool's "dur",
## food's "rot"). Returns how many didn't fit.
func add_stack(stack: Dictionary) -> int:
if not stack.has("dur"):
return add(String(stack.id), int(stack.count), float(stack.get("rot", -1.0)))
for i in range(slots.size()):
if slots[i] == null:
slots[i] = _norm(stack)
changed.emit()
return 0
return int(stack.count)
func count(id: String) -> int:
var n := 0
@@ -55,14 +156,18 @@ func space_for(id: String) -> int:
return room
## Adds up to `n` of `id`, topping up existing stacks first. Returns the
## leftover that didn't fit (0 = everything fitted).
func add(id: String, n: int) -> int:
## leftover that didn't fit (0 = everything fitted). Food gets a fresh "rot"
## stamp, or `rot` if it's coming from somewhere with one of its own.
func add(id: String, n: int, rot: float = -1.0) -> int:
var cap := _stack_max(id)
var r := rot if rot >= 0.0 else fresh_rot(id)
for s in slots:
if n <= 0:
break
if s != null and s.id == id and s.count < cap:
var take := mini(cap - s.count, n)
if r >= 0.0:
s.rot = _mix_rot(s, r, take)
s.count += take
n -= take
for i in range(slots.size()):
@@ -71,10 +176,20 @@ func add(id: String, n: int) -> int:
if slots[i] == null:
var take := mini(cap, n)
slots[i] = {"id": id, "count": take}
if r >= 0.0:
slots[i].rot = r
n -= take
changed.emit()
return n
## The "rot" of stack `s` once `n` more with `rot` are added to it: the
## average, so mixing fresh and old food gives you food in between.
static func _mix_rot(s: Dictionary, rot: float, n: int) -> float:
if rot < 0.0:
return float(s.get("rot", -1.0))
var have := float(s.get("rot", rot))
return roundf((have * int(s.count) + rot * n) / float(int(s.count) + n))
## Removes `n` of `id` (from the last stacks first, so the hotbar keeps its
## items longest). Returns false and changes nothing if there aren't enough.
func remove(id: String, n: int) -> bool:
@@ -117,10 +232,17 @@ func quick_move(i: int, other: Inventory) -> void:
var s = slots[i]
if s == null:
return
var left := other.add(s.id, s.count)
slots[i] = null if left <= 0 else {"id": s.id, "count": left}
var left := other.add_stack(s)
slots[i] = null if left <= 0 else _part(s, left)
changed.emit()
## `n` of stack `s`, with its wear or freshness.
static func _part(s: Dictionary, n: int):
var out := extra_of(s)
out.id = s.id
out.count = n
return _norm(out)
## Clicking slot `i` while holding `cursor` (a stack or null). Returns the new
## cursor stack. Left click: pick up / put down / merge / swap. Right click:
## pick up half, or put down one.
@@ -133,23 +255,25 @@ func click(i: int, cursor, right: bool):
if right and s.count > 1:
var half := int(ceil(s.count / 2.0))
s.count -= half
result = {"id": s.id, "count": half}
result = _part(s, half)
else:
result = s
slots[i] = null
elif s == null:
if right:
slots[i] = {"id": cursor.id, "count": 1}
result = _norm({"id": cursor.id, "count": cursor.count - 1})
if right and cursor.count > 1:
slots[i] = _part(cursor, 1)
result = _part(cursor, cursor.count - 1)
else:
slots[i] = cursor
result = null
elif s.id == cursor.id:
elif s.id == cursor.id and not s.has("dur") and not cursor.has("dur"):
var cap := _stack_max(s.id)
var move: int = 1 if right else cursor.count
move = mini(move, cap - s.count)
if move > 0 and cursor.has("rot"):
s.rot = _mix_rot(s, float(cursor.rot), move)
s.count += move
result = _norm({"id": cursor.id, "count": cursor.count - move})
result = _part(cursor, cursor.count - move)
else:
slots[i] = cursor
result = s

View File

@@ -14,11 +14,14 @@ const ICON_SIZE := 96
var items: Dictionary = {} # id -> definition Dictionary
var recipes: Dictionary = {} # id -> recipe Dictionary (insertion order kept)
var icons: Dictionary = {} # id -> Texture2D
## Fishing by water type (data/fish.json): {"modifiers": {...}, "waters": {type: {...}}}.
var fishing: Dictionary = {"modifiers": {}, "waters": {}}
var _item_mat: StandardMaterial3D
func _ready() -> void:
_load("res://data/items.json", "items", define_item)
_load("res://data/recipes.json", "recipes", define_recipe)
_load_fishing("res://data/fish.json")
if DisplayServer.get_name() != "headless":
_render_icons.call_deferred()
@@ -49,8 +52,54 @@ func define_recipe(r: Dictionary) -> void:
return
r.count = int(r.get("count", 1))
r.station = String(r.get("station", ""))
r.category = String(r.get("category", guess_category(items[r.output])))
if not CATEGORIES.has(r.category):
r.category = "other"
recipes[r.id] = r
## The crafting screen's tabs, in order: id -> label.
const CATEGORIES := {"tools": "Tools", "weapons": "Weapons", "cooking": "Cooking", "building": "Building",
"stations": "Stations", "furniture": "Furniture", "clothing": "Clothing", "other": "Other"}
## For recipes (an addon's, say) that don't say: worked out from what they make.
static func guess_category(item: Dictionary) -> String:
if item.has("food"):
return "cooking"
if item.has("weapon") or item.has("bow"):
return "weapons"
if item.has("tool") or item.has("fishing") or item.has("light"):
return "tools"
if item.has("wear"):
return "clothing"
if item.has("place"):
return "building" if item.place.has("kind") else "furniture"
return "other"
func _load_fishing(path: String) -> void:
var data = JSON.parse_string(FileAccess.get_file_as_string(path))
if typeof(data) != TYPE_DICTIONARY or not data.has("waters"):
push_error("ItemDB: could not parse %s" % path)
return
fishing.modifiers = data.get("modifiers", {})
for water in data.waters:
define_water(water, data.waters[water])
## Registers (or replaces) the fish for one water type. Same shape an addon
## would use to add, say, a "swamp" water with its own catch.
func define_water(water: String, d: Dictionary) -> void:
var fish: Array = []
for f in d.get("fish", []):
if not items.has(f.get("id", "")):
push_error("ItemDB: fish '%s' in water '%s' is not an item" % [f.get("id", "?"), water])
continue
fish.append({"id": f.id, "weight": float(f.get("weight", 1)), "dawn_dusk": float(f.get("dawn_dusk", f.get("weight", 1)))})
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}
## The fishing table for a water type ({} = nothing to catch there).
func water_fishing(water: String) -> Dictionary:
return fishing.waters.get(water, {})
func get_item(id: String) -> Dictionary:
return items.get(id, {})
@@ -60,6 +109,16 @@ func item_name(id: String) -> String:
func stack_size(id: String) -> int:
return int(items.get(id, {}).get("stack", 1))
## How many uses a tool or weapon has when new (0 = it never wears out).
func max_durability(id: String) -> int:
return int(items.get(id, {}).get("durability", 0))
## Uses left on a stack (its max if it's never been used, or it can't wear).
func durability(stack) -> int:
if stack == null:
return 0
return int(stack.get("dur", max_durability(stack.id)))
func mesh_for(id: String) -> ArrayMesh:
return ItemMeshes.build(items.get(id, {}).get("mesh", id))

View File

@@ -40,7 +40,9 @@ func list_worlds() -> Array:
out.sort_custom(func(a, b): return float(a.get("last_played", 0)) > float(b.get("last_played", 0)))
return out
func create_world(world_name: String, seed: int) -> String:
## `options` are the world's own settings, kept in meta.json (P4: "rot",
## how fast food spoils: "normal", "slow" or "off").
func create_world(world_name: String, seed: int, options: Dictionary = {}) -> String:
DirAccess.make_dir_recursive_absolute(root_dir)
var base := world_name.to_lower().replace(" ", "_").validate_filename()
if base.is_empty():
@@ -52,9 +54,28 @@ func create_world(world_name: String, seed: int) -> String:
n += 1
DirAccess.make_dir_recursive_absolute(_dir(id))
var now := Time.get_unix_time_from_system()
_write_json(_dir(id).path_join("meta.json"), {
var meta := {
"name": world_name, "seed": seed, "created": now, "last_played": now,
"play_time": 0.0, "version": VERSION,
}
for k in options:
meta[k] = options[k]
_write_json(_dir(id).path_join("meta.json"), meta)
return id
## The world slot called exactly `id` (a server's world), made with `seed` if
## it doesn't exist yet. An existing world keeps its own seed.
func open_or_create(id: String, seed: int) -> String:
if not read_meta(id).is_empty():
var had := int(read_meta(id).get("seed", seed))
if had != seed:
print("Saves: world '%s' was made with seed %d, so it keeps it (the config says %d)" % [id, had, seed])
return id
DirAccess.make_dir_recursive_absolute(_dir(id))
var now := Time.get_unix_time_from_system()
_write_json(_dir(id).path_join("meta.json"), {
"name": id, "seed": seed, "created": now, "last_played": now,
"play_time": 0.0, "version": VERSION,
})
return id

177
scripts/core/updates.gd Normal file
View File

@@ -0,0 +1,177 @@
extends Node
## The update check (docs/ROADMAP.md, M4 "Updates", first step): is there a
## newer Emberwild than this one? Asked once at start-up, of the feed in
## Project Settings (`emberwild/updates/feed`). The feed is either
## - a release API answer (Gitea, Forgejo or GitHub `releases/latest`): its
## `version.json` asset is fetched next, and its other assets are the
## downloads, by file name; or
## - a `version.json` itself:
## {"version": "0.2.0", "protocol": 13, "notes": "...", "page": "https://...",
## "downloads": {"macos": {"file": "Emberwild-0.2.0-macos.zip", "sha256": "...",
## "size": 61164958, "url": "(optional; else the asset)"},
## "windows": {...}}}
## tools/export.sh writes that file next to the zips, for the release.
##
## This only tells you (title screen, join refusals, the server's log);
## downloading and swapping in the new version is the next step. Off in
## headless runs (tests, bots) unless forced; a dedicated server forces it.
## Your setting (on or off) is in user://updates.json.
signal checked(state: String)
const TIMEOUT := 10.0
var version: String = String(ProjectSettings.get_setting("application/config/version", "0.0.0"))
var feed: String = String(ProjectSettings.get_setting("emberwild/updates/feed", ""))
var prefs_path := "user://updates.json"
var enabled := true
## idle | off | checking | current | available | failed
var state := "idle"
var latest: Dictionary = {} ## the version.json of the newest release
var assets: Dictionary = {} ## file name -> download URL, from the release
var page := "" ## a web page for the release (or the feed's "page")
var error := ""
## Tests: url -> response text, instead of the network.
var fetch_override: Callable
func _ready() -> void:
load_prefs()
func load_prefs() -> void:
enabled = true
if FileAccess.file_exists(prefs_path):
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(prefs_path))
if d is Dictionary:
enabled = bool(d.get("check", true))
func set_enabled(on: bool) -> void:
enabled = on
var f := FileAccess.open(prefs_path, FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify({"check": enabled}, "\t"))
## Asks the feed once. `force`: even headless, even with the setting off (a
## dedicated server, which has its own setting).
func check(force: bool = false) -> void:
if state == "checking":
return
if not force and (not enabled or (DisplayServer.get_name() == "headless" and not fetch_override.is_valid())):
_finish("off")
return
if feed.is_empty():
error = "no update feed set"
_finish("failed")
return
state = "checking"
latest = {}
assets = {}
page = ""
error = ""
var first: Variant = await _get_json(feed)
if not (first is Dictionary):
_finish("failed")
return
var d: Dictionary = first
if not d.has("version") and d.has("assets"):
# A release: its assets by name, then its version.json.
page = String(d.get("html_url", ""))
for a in d.get("assets", []):
if a is Dictionary and a.has("name") and a.has("browser_download_url"):
assets[String(a.name)] = String(a.browser_download_url)
if not assets.has("version.json"):
error = "the latest release has no version.json"
_finish("failed")
return
var v: Variant = await _get_json(assets["version.json"])
if not (v is Dictionary):
_finish("failed")
return
d = v
if not d.has("version"):
error = "the feed has no version"
_finish("failed")
return
latest = d
if page.is_empty():
page = String(d.get("page", ""))
_finish("available" if compare_versions(String(d.version), version) > 0 else "current")
func _finish(s: String) -> void:
state = s
if s == "failed":
print("[updates] couldn't check for updates: ", error)
elif s == "available":
print("[updates] Emberwild %s is out (this is %s)" % [latest_version(), version])
checked.emit(s)
func _get_json(url: String) -> Variant:
var text := ""
if fetch_override.is_valid():
text = String(fetch_override.call(url))
else:
var http := HTTPRequest.new()
http.timeout = TIMEOUT
add_child(http)
var err := http.request(url, ["Accept: application/json", "User-Agent: Emberwild/" + version])
if err != OK:
http.queue_free()
error = "couldn't ask %s (%d)" % [url, err]
return null
var r: Array = await http.request_completed
http.queue_free()
if int(r[0]) != HTTPRequest.RESULT_SUCCESS or int(r[1]) != 200:
error = "%s answered %s" % [url, "HTTP %d" % int(r[1]) if int(r[0]) == HTTPRequest.RESULT_SUCCESS else "nothing (result %d)" % int(r[0])]
return null
text = (r[3] as PackedByteArray).get_string_from_utf8()
var j := JSON.new() # (quietly: a broken feed isn't an engine error)
if j.parse(text) != OK or not (j.data is Dictionary):
error = "%s didn't answer with JSON" % url
return null
return j.data
# --- what we found --------------------------------------------------------------------
func latest_version() -> String:
return String(latest.get("version", ""))
func latest_protocol() -> int:
return int(latest.get("protocol", 0))
## "macos", "windows" or "linux": the key in version.json's downloads.
static func platform_key() -> String:
match OS.get_name():
"macOS":
return "macos"
"Windows":
return "windows"
return "linux"
## This platform's download in the newest release: {file, sha256, size, url}.
func download() -> Dictionary:
var dl: Variant = latest.get("downloads", {}).get(platform_key(), {})
if not (dl is Dictionary) or dl.is_empty():
return {}
var out: Dictionary = (dl as Dictionary).duplicate()
if String(out.get("url", "")).is_empty():
out["url"] = String(assets.get(String(out.get("file", "")), ""))
return out
## Where the Download button goes: this platform's file, else the release page.
func download_url() -> String:
var u := String(download().get("url", ""))
return u if not u.is_empty() else page
## A packaged game (an exported build), as opposed to running from the source.
static func packaged() -> bool:
return OS.has_feature("template")
## -1, 0 or 1. "v0.10.2" > "0.9", "1.0.0-beta" == "1.0.0" (suffixes ignored).
static func compare_versions(a: String, b: String) -> int:
var pa := a.strip_edges().trim_prefix("v").get_slice("-", 0).split(".")
var pb := b.strip_edges().trim_prefix("v").get_slice("-", 0).split(".")
for i in range(maxi(pa.size(), pb.size())):
var x := int(pa[i]) if i < pa.size() else 0
var y := int(pb[i]) if i < pb.size() else 0
if x != y:
return 1 if x > y else -1
return 0

225
scripts/creatures/bear.gd Normal file
View File

@@ -0,0 +1,225 @@
extends "res://scripts/creatures/creature.gd"
## The bear (docs/PROGRESSION_DESIGN.md §4): one per island, living in the
## deep forest by the rim (bear_range.gd finds the spot and brings it back
## after it's killed).
##
## By day it ambles round its patch. From day 3 it sniffs out food left
## unattended: food in a chest, or lying on the ground, with nobody about.
## It walks over, eats every bit of food (nothing else) and wanders off. A
## palisade ring or a closed hut keeps it out: it can't get at food it can't
## walk to, and it doesn't break things.
##
## It won't start a fight, but it won't be crowded: closer than 8 m it rears
## up and warns you, closer than 6 m after that and it charges. Hit it and it
## charges too. Badly hurt, it runs home. At night it sleeps at its home.
const WARN_DIST := 8.0
const CHARGE_DIST := 6.0
const EAT_SECONDS := 8.0
const RAID_COOLDOWN := 40.0 ## after a meal, before it goes looking again
const SNIFF_EVERY := 2.0
const STUCK_GIVE_UP := 15.0 ## no closer for this long: forget that food for a while
var range_: Node ## bear_range.gd
var food: Node3D = null ## what it's going for
var _raid_cd := 0.0
var _sniff := 0.0
var _eat_t := 0.0
var _stuck_t := 0.0
var _last_d := INF
var _skip := {} ## food instance id -> seconds to ignore it
var _goal_d := 0.0
func display_name() -> String:
return def.name
func interact_prompt(p: Node) -> String:
if is_dead():
return "Bear carcass [E] Butcher"
var s := "Bear"
match state:
"rest": s = "Bear (asleep)"
"raid": s = "Bear (following its nose)"
"eat": s = "Bear (eating)"
"warn": s = "Bear (warning you off!)"
"charge": s = "Bear (angry!)"
"flee": s = "Bear (running off)"
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
s += " (you need a weapon)"
return s
func _think(dt: float) -> void:
var pp: Vector3 = player.global_position
var d := global_position.distance_to(pp)
_state_t -= dt
_raid_cd = maxf(_raid_cd - dt, 0.0)
for k in _skip.keys():
_skip[k] = float(_skip[k]) - dt
if float(_skip[k]) <= 0.0:
_skip.erase(k)
if state == "flee":
var away := global_position - _threat
steer(atan2(-away.x, -away.z))
if _state_t <= 0.0:
state = "wander"
return
if _aggro > 0.0:
if player.survival.dead or d > 40.0:
_aggro = 0.0
state = "wander"
else:
state = "charge"
var to := pp - global_position
steer(atan2(-to.x, -to.z))
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player):
_attack_cd = def.attack_cd
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a bear")
return
if not range_.awake():
_go_home_to_sleep()
return
# Too close: rear up, then charge if you don't back off.
if d < WARN_DIST and not player.survival.dead and _can_reach(player):
var to := pp - global_position
steer(atan2(-to.x, -to.z))
if state != "warn":
state = "warn"
_state_t = 2.5
elif _state_t <= 0.0 and d < CHARGE_DIST:
_aggro = 12.0
_attack_cd = 0.3
state = "charge"
return
if state == "warn":
state = "wander"
match state:
"eat":
_eat_t -= dt
if _eat_t <= 0.0:
state = "wander"
_raid_cd = RAID_COOLDOWN
"raid":
_raid_step(dt)
_:
_sniff -= dt
if _sniff <= 0.0 and _raid_cd <= 0.0 and range_.raids_now():
_sniff = SNIFF_EVERY
var f: Node3D = range_.find_food(self, _skip)
if f != null:
food = f
state = "raid"
_last_d = INF
_stuck_t = 0.0
return
_amble(dt)
func _raid_step(dt: float) -> void:
if food == null or not is_instance_valid(food) or not range_.raidable(food):
food = null
state = "wander"
return
var fp: Vector3 = food.global_position
var fd := Vector2(fp.x - global_position.x, fp.z - global_position.z).length()
if fd < 1.9:
range_.raid(self, food)
food = null
state = "eat"
_eat_t = EAT_SECONDS
_goal_d = 0.0
return
_go_to(fp)
if fd > _last_d - 0.05 * dt * 10.0:
_stuck_t += dt
else:
_stuck_t = 0.0
_last_d = minf(_last_d, fd)
if _stuck_t > STUCK_GIVE_UP:
_skip[food.get_instance_id()] = 120.0
food = null
state = "wander"
## Idles and wanders like any animal, but never far from home.
func _amble(dt: float) -> void:
var home: Vector3 = range_.home
if Vector2(home.x - global_position.x, home.z - global_position.z).length() > range_.HOME_RADIUS:
state = "home"
_go_to(home)
return
if state == "home":
state = "wander"
_state_t = rng.randf_range(3.0, 8.0)
if _state_t <= 0.0:
if state == "idle":
state = "wander"
target_yaw = yaw + rng.randf_range(-1.6, 1.6)
_state_t = rng.randf_range(4.0, 9.0)
else:
state = "idle"
_state_t = rng.randf_range(3.0, 7.0)
func _go_home_to_sleep() -> void:
var home: Vector3 = range_.home
if Vector2(home.x - global_position.x, home.z - global_position.z).length() > 2.0:
state = "home"
_go_to(home)
else:
state = "rest"
func _go_to(p: Vector3) -> void:
var to := p - global_position
to.y = 0.0
if to.length() > 0.3:
steer(atan2(-to.x, -to.z))
_goal_d = to.length()
func _desired_speed() -> float:
match state:
"wander": return def.walk
"raid": return 0.0 if _goal_d < 1.0 else 3.2
"home": return 0.0 if _goal_d < 1.0 else 3.0
"charge", "flee": return def.run
return 0.0
## (The chest it's going for isn't in its way: it walks right up to it. Nor
## is the person it's charging: it runs into them, not round them.)
func _blocked(dir: Vector3) -> bool:
if state == "charge" and player != null and global_position.distance_to(player.global_position) < 6.0:
var probe := global_position + dir * (1.4 + speed * 0.15)
var h: float = terrain.sample_height(probe.x, probe.z)
if not terrain.inside_boundary(probe.x, probe.z, 6.0) or h < terrain.water_level + 0.2 or absf(h - global_position.y) > 1.1:
return true
var q := PhysicsRayQueryParameters3D.create(global_position + Vector3(0, 0.45, 0), Vector3(probe.x, h + 0.45, probe.z), 1)
q.exclude = [get_rid(), player.get_rid(), terrain.get_node("TerrainBody").get_rid()]
return not get_world_3d().direct_space_state.intersect_ray(q).is_empty()
if state == "raid" and food != null and is_instance_valid(food) and global_position.distance_to(food.global_position) < 3.5:
var probe := global_position + dir * 1.4
var h: float = terrain.sample_height(probe.x, probe.z)
return h < terrain.water_level + 0.2 or absf(h - global_position.y) > 1.1
return super._blocked(dir)
## Rearing up on its hind legs when it warns you (also on puppets online).
func _animate(dt: float) -> void:
if state == "warn":
_visual.rotation.x = lerpf(_visual.rotation.x, 0.6, minf(dt * 4.0, 1.0))
_visual.position.y = lerpf(_visual.position.y, 0.25, minf(dt * 4.0, 1.0))
return
super._animate(dt)
## No wall, door or rock between the bear and `p`.
func _can_reach(p: Node3D) -> bool:
var from := global_position + Vector3(0, 0.9, 0)
var to: Vector3 = p.global_position + Vector3(0, 1.0, 0)
var q := PhysicsRayQueryParameters3D.create(from, to, 1)
q.exclude = [get_rid(), p.get_rid(), terrain.get_node("TerrainBody").get_rid()]
return get_world_3d().direct_space_state.intersect_ray(q).is_empty()
func _react(from: Vector3) -> void:
if hp < def.hp * 0.25:
_aggro = 0.0
_threat = from
state = "flee"
_state_t = 10.0
return
_aggro = 25.0
_attack_cd = maxf(_attack_cd, 0.4)
state = "charge"

View File

@@ -0,0 +1,215 @@
extends Node3D
## The bear's patch (docs/PROGRESSION_DESIGN.md §4): where the island's one
## bear lives, bringing it back after it's killed, and the food it can get at.
##
## Home is the densest forest near the rim, away from the spawn and the wolf
## den, picked from the seed (the same on every machine). The bear is there
## from day 1; raids start on day 3. Killed, it's back RESPAWN_DAYS later,
## at dawn, when nobody's near its home.
##
## Food it can raid: a chest with food in it, or food left on the ground,
## within SMELL of the bear, with nobody within GUARD of it, and not fenced
## in. Fenced in = every one of 32 level rays from it hits a building piece
## (a palisade ring, the walls of a closed hut; an open door lets it in).
const BearScript = preload("res://scripts/creatures/bear.gd")
const RAID_DAY := 3
const RESPAWN_DAYS := 3
const SMELL := 60.0
const GUARD := 12.0
const HOME_RADIUS := 35.0
const FENCE_RAY := 25.0
const FENCE_RAYS := 32 ## fine enough that an open doorway lets one through
signal raided(target: Node3D, eaten: int)
var world: Node3D
var home := Vector3.ZERO
var found := false
var bear: Node3D = null
var enabled := true
var dead_day := -1 ## the day it was killed (-1: alive or never)
var _rng := RandomNumberGenerator.new()
func setup(w: Node3D) -> void:
world = w
_rng.seed = w.terrain.noise_seed + 881
found = _find_home()
if found:
spawn_bear()
## The densest stand of trees near the rim, at least 60 m from the spawn
## and the den.
func _find_home() -> bool:
var terrain: Node3D = world.terrain
var trees: Array = []
for n in world.props.resource_nodes.values():
if n.kind == "tree":
trees.append((n as Node3D).global_position)
if trees.is_empty():
return false
var spawn: Vector3 = world._spawn
var den_at: Vector3 = world.den.position if world.den != null and world.den.found else Vector3(1e9, 0, 1e9)
var best := -1.0
var r := RandomNumberGenerator.new()
r.seed = _rng.seed
for _i in range(120):
var c: Vector3 = trees[r.randi() % trees.size()]
var rim: float = world.props._near_rim(c.x, c.z)
if rim < 0.1 or rim > 0.9 or not terrain.inside_boundary(c.x, c.z, 10.0):
continue
if Vector2(c.x - spawn.x, c.z - spawn.z).length() < 60.0 or Vector2(c.x - den_at.x, c.z - den_at.z).length() < 60.0:
continue
if world.barrow != null and world.barrow.found and Vector2(c.x - world.barrow.position.x, c.z - world.barrow.position.z).length() < 40.0:
continue
if world.stones != null and world.stones.found and Vector2(c.x - world.stones.position.x, c.z - world.stones.position.z).length() < 42.0:
continue
if terrain.sample_height(c.x, c.z) < terrain.water_level + 1.0:
continue
var near := 0
for t: Vector3 in trees:
if absf(t.x - c.x) < 18.0 and absf(t.z - c.z) < 18.0:
near += 1
var score := near + rim * 6.0
if score > best:
best = score
# Stand just beside the tree, not in its trunk.
home = c + Vector3(1.6, 0, 0)
home.y = terrain.sample_height(home.x, home.z)
return best > 0.0
func spawn_bear() -> Node3D:
var b: AnimatableBody3D = BearScript.new()
b.setup("bear", world, home, _rng.randi())
b.range_ = self
b.state = "rest" if not awake() else "idle"
add_child(b)
b.died.connect(func(_c): dead_day = world.day_night.day)
bear = b
dead_day = -1
return b
func alive() -> bool:
return bear != null and is_instance_valid(bear) and not bear.is_dead()
## Saved with the world: whether it's dead (and since when), and how hurt
## it is. Where it is isn't kept: it goes home over a reload.
func save_data() -> Dictionary:
if not found:
return {}
return {"dead_day": dead_day, "hp": float(bear.hp) if alive() else 0.0}
func load_data(d: Dictionary) -> void:
if not found or d.is_empty():
return
var dd := int(d.get("dead_day", -1))
if dd >= 0:
if bear != null and is_instance_valid(bear):
bear.queue_free()
bear = null
dead_day = dd
return
if not alive():
spawn_bear()
var hp := float(d.get("hp", 0.0))
if hp > 0.0:
bear.hp = minf(hp, float(bear.def.hp))
func awake() -> bool:
var h: float = world.day_night.hours()
return h >= 6.0 and h < 19.5
func raids_now() -> bool:
return world.day_night.day >= RAID_DAY and awake()
func _physics_process(_delta: float) -> void:
if enabled:
step()
## Brings the bear back a few days after it was killed (at dawn, unseen).
func step() -> void:
if not found or alive() or dead_day < 0:
return
var h: float = world.day_night.hours()
if world.day_night.day >= dead_day + RESPAWN_DAYS and h >= 6.0 and h < 9.0:
for p in world.players():
if p.global_position.distance_to(home) < 40.0:
return
spawn_bear()
# --- food --------------------------------------------------------------------------
static func is_food(id: String) -> bool:
return ItemDB.get_item(id).has("food")
## Food that's worth the bear's while: `target` still has food in it.
func has_food(target: Node3D) -> bool:
if target.get("inventory") != null:
for s in target.inventory.slots:
if s != null and is_food(s.id):
return true
return false
return target.get("item_id") != null and is_food(target.item_id) and not target.taken
## Food the bear could go for now: has food, nobody guarding it, not fenced in.
func raidable(target: Node3D) -> bool:
if not is_instance_valid(target) or target.is_queued_for_deletion() or not has_food(target):
return false
for p in world.players():
if p.global_position.distance_to(target.global_position) < GUARD and not p.survival.dead:
return false
return not fenced(target.global_position)
## Walled in on every side (a palisade ring, a closed hut).
func fenced(pos: Vector3) -> bool:
var from := pos + Vector3(0, 0.6, 0)
var space := world.get_world_3d().direct_space_state
for i in range(FENCE_RAYS):
var a := TAU * i / FENCE_RAYS
var q := PhysicsRayQueryParameters3D.create(from, from + Vector3(cos(a), 0, sin(a)) * FENCE_RAY, world.BuildPiece.BUILD_LAYER)
if space.intersect_ray(q).is_empty():
return false
return true
## The nearest food `b` can smell and get at (null: none).
func find_food(b: Node3D, skip: Dictionary = {}) -> Node3D:
var best: Node3D = null
var best_d := SMELL
var cands: Array = []
for p in world.all_pieces():
if p.item_id == "chest":
cands.append(p)
for d in world.all_dropped():
if is_food(d.item_id):
cands.append(d)
for c in cands:
if skip.has(c.get_instance_id()):
continue
var dist: float = c.global_position.distance_to(b.global_position)
if dist < best_d and raidable(c):
best_d = dist
best = c
return best
## The bear eats every bit of food in `target`. Tells anyone nearby.
func raid(b: Node3D, target: Node3D) -> int:
var eaten := 0
var what := ""
if target.get("inventory") != null:
var inv: Inventory = target.inventory
for i in range(inv.size()):
var s = inv.get_slot(i)
if s != null and is_food(s.id):
eaten += int(s.count)
inv.set_slot(i, null)
what = "A bear has broken into your chest and eaten the food!"
else:
eaten = int(target.amount)
world.take_dropped(target)
what = "A bear is eating the food you left out!"
for p in world.players():
if p.global_position.distance_to(target.global_position) < 80.0:
Actions.tell(p, what)
raided.emit(target, eaten)
return eaten

View File

@@ -0,0 +1,493 @@
extends AnimatableBody3D
## A wild animal: rabbit, deer or boar.
##
## Movement is steering over the heightmap, with no navmesh. The animal walks
## toward a heading, probes the ground ahead (water, cliffs, the world edge,
## trees, rocks and buildings) and turns away from anything it can't cross.
##
## States:
## - idle: stands and grazes.
## - wander: ambles in one direction.
## - flee: runs away from a threat. Rabbits zig-zag.
## - charge: a hit boar runs at you and gores you.
## - dead: a carcass. E butchers it for meat and hide, and gives back any
## arrows stuck in it.
##
## step(dt) holds all the logic so tests can drive it directly. Lives on
## physics layer 4 (value 8): hit by weapons and arrows, but the player walks
## through it. A carcass moves to layer 2, like other interactables.
signal died(creature: Node)
const Meshes = preload("res://scripts/creatures/creature_meshes.gd")
const LAYER := 8
## Tuning per kind. Speeds in m/s (the player walks at 5 and sprints at 8).
const KINDS := {
"rabbit": {"name": "Rabbit", "hp": 10.0, "walk": 1.4, "run": 7.0, "turn": 9.0,
"notice": 7.0, "notice_loud": 12.0, "calm": 18.0,
"drops": {"raw_meat": [1, 1]}},
"deer": {"name": "Deer", "hp": 40.0, "walk": 1.3, "run": 9.0, "turn": 4.5,
"notice": 16.0, "notice_loud": 30.0, "calm": 45.0,
"drops": {"raw_meat": [2, 3], "hide": [1, 2]}},
"boar": {"name": "Boar", "hp": 60.0, "walk": 1.2, "run": 6.6, "turn": 5.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
"damage": 14.0, "attack_range": 1.7, "attack_cd": 1.6, "aggro": 18.0,
"drops": {"raw_meat": [3, 4], "hide": [1, 2]}},
# Wolves are driven by wolf.gd + wolf_pack.gd; "notice" is unused.
"wolf": {"name": "Wolf", "hp": 45.0, "walk": 1.6, "run": 8.6, "turn": 6.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 40.0,
"damage": 9.0, "attack_range": 1.8, "attack_cd": 1.2,
"drops": {"raw_meat": [1, 2], "wolf_pelt": [1, 1]}},
# The alpha: leads the pack on a wolf moon (wolf_pack.gd). Three wolves'
# worth of health, bites harder, isn't afraid of fire and breaks down
# wooden doors (wolf.gd).
"alpha": {"name": "Alpha Wolf", "hp": 135.0, "walk": 1.6, "run": 8.2, "turn": 5.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 40.0,
"damage": 16.0, "attack_range": 2.1, "attack_cd": 1.4, "door_damage": 22.0,
"drops": {"raw_meat": [2, 3], "alpha_pelt": [1, 1], "wolf_fang": [1, 2]}},
# Thieves (thieves.gd): crows by day and foxes at dusk and night come for
# food left lying on the ground.
"crow": {"name": "Crow", "hp": 6.0, "walk": 1.0, "run": 9.0, "turn": 8.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
"drops": {"feather": [1, 2]}},
"fox": {"name": "Fox", "hp": 25.0, "walk": 1.5, "run": 7.6, "turn": 7.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
"drops": {"raw_meat": [1, 1], "hide": [1, 1]}},
# The barrow's skeletal guards (guard.gd, barrow.gd). Slow (you can walk
# away from one) but they hit hard; bone, so blunt weapons do +50%.
"guard": {"name": "Barrow Guard", "hp": 80.0, "walk": 1.4, "run": 3.4, "turn": 4.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
"damage": 20.0, "attack_range": 2.0, "attack_cd": 2.0, "bone": true,
"drops": {"iron_nugget": [1, 2]}},
# P5: the Old Stag (old_stag.gd), called by the standing stones, and the
# spirit wolves it summons (they just run at you: the base "charge").
"old_stag": {"name": "Old Stag", "hp": 900.0, "walk": 2.0, "run": 13.0, "turn": 3.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 60.0,
"damage": 30.0, "attack_range": 2.8, "attack_cd": 1.0, "drops": {}},
"spirit_wolf": {"name": "Spirit Wolf", "hp": 40.0, "walk": 1.6, "run": 7.4, "turn": 6.0,
"notice": 0.0, "notice_loud": 0.0, "calm": 60.0,
"damage": 8.0, "attack_range": 1.8, "attack_cd": 1.3, "aggro": 9999.0, "drops": {}},
# One bear per island, in the deep forest by the rim (bear.gd, bear_range.gd).
# Slower than you sprinting, but it hits like a falling tree.
"bear": {"name": "Bear", "hp": 220.0, "walk": 1.3, "run": 7.0, "turn": 3.5,
"notice": 0.0, "notice_loud": 0.0, "calm": 40.0,
"damage": 24.0, "attack_range": 2.3, "attack_cd": 1.8,
"drops": {"raw_meat": [5, 7], "bear_hide": [1, 1], "bear_fat": [2, 3]}},
}
const CARCASS_SECONDS := 300.0
var kind := "deer"
var def: Dictionary
var hp := 40.0
var state := "idle"
var world: Node3D
var terrain: Node3D
var player: Node3D
var rng := RandomNumberGenerator.new()
var embedded := {} ## item id -> count (arrows stuck in it)
var carcass_left := CARCASS_SECONDS
var yaw := 0.0
var target_yaw := 0.0
var speed := 0.0
var _state_t := 0.0
var _threat := Vector3.ZERO
var _aggro := 0.0
var _attack_cd := 0.0
var _zig := 0.0
var _phase := 0.0
var _visual: Node3D
var _legs: Array[Node3D] = []
var _flash := 0.0
var _detour_t := 0.0 ## > 0: following a detour round an obstacle
var _detour_side := 1.0
## Sets the heading, unless the animal is in the middle of a detour.
func steer(y: float) -> void:
if _detour_t <= 0.0:
target_yaw = y
var _mat: StandardMaterial3D
var _hit_mat: StandardMaterial3D
func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
kind = k
def = KINDS[k]
hp = def.hp
world = w
terrain = w.terrain
player = w.player
rng.seed = seed
name = "%s_%d" % [k, absi(seed) % 100000]
collision_layer = LAYER
collision_mask = 0
sync_to_physics = false
add_to_group("creature")
var parts: Dictionary = Meshes.build(k)
_mat = ItemDB.item_material()
_hit_mat = StandardMaterial3D.new()
_hit_mat.vertex_color_use_as_albedo = true
_hit_mat.vertex_color_is_srgb = true
_hit_mat.albedo_color = Color(1.6, 0.5, 0.45)
_visual = Node3D.new()
add_child(_visual)
var body := MeshInstance3D.new()
body.mesh = parts.body
body.material_override = _mat
_visual.add_child(body)
if parts.has("eyes"):
# Eyes that catch the light: you see a wolf's eyes in the dark first.
var eyes := MeshInstance3D.new()
eyes.mesh = parts.eyes
var em := StandardMaterial3D.new()
var eye: Color = {"alpha": Color(1.0, 0.18, 0.1), "guard": Color(0.45, 0.8, 1.0), "old_stag": Color(0.55, 0.95, 1.0),
"spirit_wolf": Color(0.55, 0.95, 1.0)}.get(k, Color(1.0, 0.75, 0.3))
em.albedo_color = eye.lerp(Color.WHITE, 0.2)
em.emission_enabled = true
em.emission = eye
em.emission_energy_multiplier = 2.5
eyes.material_override = em
eyes.set_meta("keep_mat", true)
_visual.add_child(eyes)
for h: Vector3 in parts.hips:
var pivot := Node3D.new()
pivot.position = h
var leg := MeshInstance3D.new()
leg.mesh = parts.leg
leg.material_override = _mat
pivot.add_child(leg)
_visual.add_child(pivot)
_legs.append(pivot)
var cs := CollisionShape3D.new()
var cap := CapsuleShape3D.new()
cap.radius = parts.radius
cap.height = maxf(parts.height, parts.radius * 2.0 + 0.01)
cs.shape = cap
cs.position = Vector3(0, parts.height * 0.5, 0)
if k != "rabbit" and k != "guard": # (rabbits are round, guards stand upright)
# Long bodies: lay the capsule along the spine.
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0, "fox": 0.8, "crow": 0.3, "old_stag": 3.3, "spirit_wolf": 1.25}.get(k, 1.4)
cs.rotation.x = PI * 0.5
cs.position = Vector3(0, parts.height * 0.55, 0)
add_child(cs)
yaw = rng.randf() * TAU
target_yaw = yaw
_state_t = rng.randf_range(1.0, 4.0)
position = Vector3(pos.x, terrain.sample_height(pos.x, pos.z), pos.z) # (spawner sits at the origin)
rotation.y = yaw
func display_name() -> String:
return def.name
func is_dead() -> bool:
return state == "dead"
func interact_prompt(p: Node) -> String:
if is_dead():
return "%s carcass [E] Butcher" % def.name
var s: String = def.name
if kind == "boar":
s = "Boar (angry!)" if _aggro > 0.0 else "Boar (leaves you alone unless you hit it)"
var held: Dictionary = ItemDB.get_item(p.held_item_id())
if not Actions.melee_stats(p).armed and not held.has("bow"):
s += " (you need a weapon: club, spear, bow or axe)"
return s
func interact(p: Node) -> void:
if is_dead():
Actions.butcher(p, self)
func _physics_process(delta: float) -> void:
step(delta)
func step(dt: float) -> void:
if _flash > 0.0:
_flash -= dt
if _flash <= 0.0:
_set_material(_mat)
if puppet:
_puppet_step(dt)
return
if is_dead():
carcass_left -= dt
if carcass_left <= 0.0:
queue_free()
return
_attack_cd = maxf(_attack_cd - dt, 0.0)
_aggro = maxf(_aggro - dt, 0.0)
# Who it's reacting to: the player (or on a server, the nearest one).
var t := _pick_target()
if t != null:
player = t
_think(dt)
elif state in ["flee", "charge"]:
state = "idle" # nobody about
_move(dt)
_animate(dt)
func _pick_target() -> Node3D:
return world.nearest_player(global_position)
# --- on a client playing online: the server's animal, shown here ------------
## A puppet only shows what the server's copy is doing (creature_net.gd):
## no thinking, no damage dealt. It can still be hit (that's sent to the
## server) and butchered.
var puppet := false
var _net_pos := Vector3.ZERO
var _net_yaw := 0.0
func net_update(pos: Vector3, y: float, st: String, spd: float, new_hp: float) -> void:
if not puppet:
return
_net_pos = pos
_net_yaw = y
speed = spd
if new_hp < hp - 0.01 and st != "dead":
_flash = 0.15
_set_material(_hit_mat)
_burst()
hp = new_hp
if st == "dead" and state != "dead":
_burst()
_die(global_position + global_basis.z)
return
if state != "dead":
state = st
func _puppet_step(dt: float) -> void:
if is_dead():
return
var k := 1.0 - exp(-12.0 * dt)
var p := global_position.lerp(_net_pos, k)
if p.distance_to(_net_pos) > 6.0:
p = _net_pos # (a jump: a wolf slipping home to the den)
global_position = p
yaw = lerp_angle(yaw, _net_yaw, k)
rotation.y = yaw
_animate(dt)
## Whether the player is close enough to be noticed. Sprinting, or moving
## fast while swimming or jumping, is loud.
func _notices_player(d: float) -> bool:
if def.notice <= 0.0:
return false
var loud: bool = player.velocity.length() > 6.0
return d < (def.notice_loud if loud else def.notice)
func _think(dt: float) -> void:
var pp: Vector3 = player.global_position
var d := global_position.distance_to(pp)
_state_t -= dt
match state:
"idle", "wander":
if _notices_player(d):
_start_flee(pp)
elif _state_t <= 0.0:
if state == "idle":
state = "wander"
target_yaw = yaw + rng.randf_range(-1.6, 1.6)
_state_t = rng.randf_range(3.0, 8.0)
else:
state = "idle"
_state_t = rng.randf_range(2.0, 6.0)
"flee":
if _notices_player(d):
_threat = pp
var away := global_position - _threat
target_yaw = atan2(-away.x, -away.z) # heading along `away` (forward is -Z)
if kind == "rabbit":
_zig -= dt
if _zig <= 0.0:
_zig = rng.randf_range(0.3, 0.7)
target_yaw += rng.randf_range(-0.9, 0.9)
if d > def.calm and _state_t <= 0.0:
state = "idle"
_state_t = rng.randf_range(2.0, 5.0)
"charge":
var to := pp - global_position
target_yaw = atan2(-to.x, -to.z)
if _aggro <= 0.0 or d > 45.0:
state = "wander"
_state_t = 4.0
elif d < def.attack_range and _attack_cd <= 0.0:
_attack_cd = def.attack_cd
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a " + String(def.name).to_lower())
func _start_flee(from: Vector3) -> void:
state = "flee"
_threat = from
_state_t = 4.0
func _desired_speed() -> float:
match state:
"wander": return def.walk
"flee", "charge": return def.run
return 0.0
func _move(dt: float) -> void:
var want := _desired_speed()
speed = move_toward(speed, want, dt * (def.run * 2.5))
yaw = rotate_toward(yaw, target_yaw, def.turn * dt)
if speed < 0.01:
return
_detour_t = maxf(_detour_t - dt, 0.0)
var dir := Vector3(-sin(yaw), 0, -cos(yaw))
if _blocked(dir):
# Turn away and keep to the new heading for a moment (a detour), so
# an animal heading somewhere slides round an obstacle instead of
# turning straight back into it. Keep turning the same way while
# still blocked.
if _detour_t <= 0.0:
_detour_side = 1.0 if rng.randf() < 0.5 else -1.0
var turn := rng.randf_range(0.8, 1.6) * _detour_side
target_yaw = yaw + turn
yaw += turn * 0.35
_detour_t = rng.randf_range(1.0, 1.8)
speed *= 0.5
if state == "charge":
return
dir = Vector3(-sin(yaw), 0, -cos(yaw))
if _blocked(dir):
return
var p := global_position + dir * speed * dt
p.y = terrain.sample_height(p.x, p.z)
global_position = p
rotation.y = yaw
## Something ahead the animal can't or won't cross.
func _blocked(dir: Vector3) -> bool:
var probe := global_position + dir * (1.4 + speed * 0.15)
if not terrain.inside_boundary(probe.x, probe.z, 6.0):
return true
var h: float = terrain.sample_height(probe.x, probe.z)
if h < terrain.water_level + 0.2:
return true
if absf(h - global_position.y) > 1.1:
return true
# Trees, rocks and buildings at chest height (the terrain isn't on this test).
var from := global_position + Vector3(0, 0.45, 0)
var q := PhysicsRayQueryParameters3D.create(from, Vector3(probe.x, h + 0.45, probe.z), 1)
q.exclude = [get_rid(), terrain.get_node("TerrainBody").get_rid()]
return not get_world_3d().direct_space_state.intersect_ray(q).is_empty()
func _animate(dt: float) -> void:
if state == "rest":
# Lying down: body lowered, legs folded under.
_visual.position.y = lerpf(_visual.position.y, -0.3, minf(dt * 3.0, 1.0))
_visual.rotation.x = lerpf(_visual.rotation.x, 0.0, minf(dt * 4.0, 1.0))
for l in _legs:
l.rotation.x = lerpf(l.rotation.x, 1.35 * (1.0 if l.position.z < 0.0 else -1.0), minf(dt * 3.0, 1.0))
return
var k := speed / maxf(def.run, 0.01)
_phase += dt * (3.0 + speed * 2.2)
var amp := clampf(speed / 2.0, 0.0, 1.0) * 0.6
for i in range(_legs.size()):
# Diagonal pairs move together (a trot); rabbits hop with both
# hind legs at once.
var off := 0.0 if i in [0, 3] else PI
if kind == "rabbit":
off = 0.0 if i < 2 else PI * 0.6
_legs[i].rotation.x = sin(_phase + off) * amp
_visual.position.y = absf(sin(_phase)) * (0.06 if kind == "rabbit" else 0.03) * k
if state == "idle" and kind != "rabbit":
# Grazing: head (whole body) dips a little now and then.
_visual.rotation.x = lerpf(_visual.rotation.x, 0.08 * maxf(sin(_state_t * 1.3), 0.0), dt * 2.0)
else:
_visual.rotation.x = lerpf(_visual.rotation.x, 0.0, dt * 4.0)
# --- combat -------------------------------------------------------------------
## Applies a hit (Actions validates who hit what). `from` = where it came
## from, for the reaction.
func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void:
if puppet:
return # (online the server's copy takes the hit)
if is_dead():
if not embed_id.is_empty():
embedded[embed_id] = embedded.get(embed_id, 0) + 1
return
hp -= damage
if not embed_id.is_empty():
embedded[embed_id] = embedded.get(embed_id, 0) + 1
_flash = 0.15
_set_material(_hit_mat)
_burst()
if hp <= 0.0:
_die(from)
return
_react(from)
## How a living animal responds to being hit (wolves override this).
func _react(from: Vector3) -> void:
if (kind == "boar" and hp > def.hp * 0.3) or kind == "spirit_wolf": # (spirit wolves never run)
state = "charge"
_aggro = def.aggro
_attack_cd = 0.4
else:
_start_flee(from)
_state_t = 6.0
func _die(from: Vector3) -> void:
state = "dead"
speed = 0.0
hp = 0.0
_flash = 0.0
_set_material(_mat)
remove_from_group("creature")
add_to_group("carcass")
collision_layer = 2
for l in _legs:
l.rotation.x = 0.4
if puppet:
_visual.rotation.z = -PI * 0.5
return
# Topple onto its side, away from the blow.
var side := 1.0 if (global_position - from).dot(global_basis.x) > 0.0 else -1.0
if is_inside_tree() and DisplayServer.get_name() != "headless":
var tw := create_tween()
tw.tween_property(_visual, "rotation:z", -side * PI * 0.5, 0.45).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
else:
_visual.rotation.z = -side * PI * 0.5
died.emit(self)
## What butchering gives (rolled once).
func butcher_drops() -> Dictionary:
var out := {}
for id in def.drops:
var r: Array = def.drops[id]
out[id] = rng.randi_range(int(r[0]), int(r[1]))
for id in embedded:
out[id] = out.get(id, 0) + int(embedded[id])
return out
func _set_material(m: Material) -> void:
for c in _visual.get_children():
if c is MeshInstance3D and not c.has_meta("keep_mat"):
c.material_override = m
for g in c.get_children():
if g is MeshInstance3D and not g.has_meta("keep_mat"):
g.material_override = m
func _burst() -> void:
if not is_inside_tree() or DisplayServer.get_name() == "headless":
return
var p := CPUParticles3D.new()
var box := BoxMesh.new()
box.size = Vector3.ONE * 0.05
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.55, 0.08, 0.06)
box.material = m
p.mesh = box
p.amount = 12
p.one_shot = true
p.explosiveness = 0.95
p.lifetime = 0.6
p.spread = 60.0
p.initial_velocity_min = 1.5
p.initial_velocity_max = 3.0
p.gravity = Vector3(0, -10, 0)
world.add_child(p)
p.global_position = global_position + Vector3(0, Meshes.build(kind).height * 0.6, 0)
p.emitting = true
p.finished.connect(p.queue_free)

View File

@@ -0,0 +1,290 @@
extends RefCounted
## Procedural low-poly animals, built from the same helpers as the trees and
## items (flat-shaded, vertex colours, no assets).
##
## Each kind gives a static "body" mesh (torso, head, ears, tail) facing -Z,
## one "leg" mesh hanging from the origin down -Y, and the four hip points
## the legs pivot on. creature.gd animates the legs.
const P = preload("res://scripts/world/proc_mesh.gd")
static var _cache := {}
static func build(kind: String) -> Dictionary:
if _cache.has(kind):
return _cache[kind]
var d: Dictionary
match kind:
"rabbit": d = _rabbit()
"deer": d = _deer()
"wolf": d = _wolf()
"alpha": d = _wolf(true)
"bear": d = _bear()
"fox": d = _scaled(_wolf_parts(Color(0.78, 0.4, 0.15), Color(0.92, 0.88, 0.8), 281), 0.6)
"crow": d = _crow()
"guard": d = _guard()
"old_stag": d = _scaled(_stag(), 2.1)
"spirit_wolf": d = _wolf_parts(Color(0.62, 0.72, 0.78), Color(0.85, 0.92, 0.95), 351)
_: d = _boar()
_cache[kind] = d
return d
static func _rng(seed: int) -> RandomNumberGenerator:
var r := RandomNumberGenerator.new()
r.seed = seed
return r
static func _noise(seed: int, f: float = 1.6) -> FastNoiseLite:
var n := FastNoiseLite.new()
n.seed = seed
n.frequency = f
return n
static func _leg(len: float, r0: float, r1: float, col: Color, hoof: Color, seed: int) -> ArrayMesh:
var st := P._begin()
var r := _rng(seed)
P._cylinder(st, Vector3(0, 0, 0), Vector3(0, -len * 0.55, 0.02), r0, r0 * 0.75, 5, col, r, 0.0)
P._cylinder(st, Vector3(0, -len * 0.55, 0.02), Vector3(0, -len, 0), r0 * 0.75, r1, 5, col, r, 0.0, hoof)
return P._commit(st)
static func _rabbit() -> Dictionary:
var fur := Color(0.55, 0.46, 0.36)
var st := P._begin()
var r := _rng(201)
var n := _noise(201)
P._blob(st, Vector3(0, 0.2, 0.03), Vector3(0.13, 0.13, 0.2), n, 0.15, fur, r, 1)
P._blob(st, Vector3(0, 0.31, -0.17), Vector3(0.085, 0.085, 0.1), n, 0.12, fur, r, 1)
for s in [-1.0, 1.0]:
P._cylinder(st, Vector3(s * 0.035, 0.38, -0.15), Vector3(s * 0.06, 0.56, -0.1), 0.025, 0.018, 4, P._mul(fur, 1.1), r, -1.0, Color(0.85, 0.65, 0.6))
P._blob(st, Vector3(s * 0.05, 0.33, -0.25), Vector3.ONE * 0.012, n, 0.0, Color(0.05, 0.04, 0.04), r, 0)
P._blob(st, Vector3(0, 0.25, 0.23), Vector3.ONE * 0.05, n, 0.2, Color(0.93, 0.92, 0.88), r, 0)
return {"body": P._commit(st), "leg": _leg(0.12, 0.03, 0.025, fur, P._mul(fur, 0.8), 202),
"hips": [Vector3(-0.07, 0.12, -0.1), Vector3(0.07, 0.12, -0.1), Vector3(-0.08, 0.14, 0.12), Vector3(0.08, 0.14, 0.12)],
"radius": 0.18, "height": 0.4}
static func _deer() -> Dictionary:
var coat := Color(0.62, 0.42, 0.25)
var belly := Color(0.85, 0.76, 0.62)
var dark := Color(0.2, 0.15, 0.12)
var st := P._begin()
var r := _rng(211)
var n := _noise(211, 1.2)
P._blob(st, Vector3(0, 1.0, 0), Vector3(0.26, 0.3, 0.62), n, 0.12, coat, r, 1)
P._blob(st, Vector3(0, 0.86, 0.05), Vector3(0.2, 0.14, 0.45), n, 0.1, belly, r, 1)
P._cylinder(st, Vector3(0, 1.1, -0.45), Vector3(0, 1.55, -0.68), 0.13, 0.09, 6, coat, r)
P._blob(st, Vector3(0, 1.6, -0.78), Vector3(0.11, 0.12, 0.2), n, 0.1, coat, r, 1)
P._blob(st, Vector3(0, 1.55, -0.96), Vector3(0.05, 0.05, 0.05), n, 0.0, dark, r, 0)
for s in [-1.0, 1.0]:
P._cylinder(st, Vector3(s * 0.07, 1.7, -0.72), Vector3(s * 0.2, 1.82, -0.66), 0.04, 0.015, 4, coat, r)
P._blob(st, Vector3(s * 0.08, 1.65, -0.86), Vector3.ONE * 0.022, n, 0.0, dark, r, 0)
# Small antlers.
P._cylinder(st, Vector3(s * 0.04, 1.72, -0.76), Vector3(s * 0.12, 2.0, -0.7), 0.018, 0.01, 4, Color(0.8, 0.74, 0.6), r)
P._cylinder(st, Vector3(s * 0.09, 1.88, -0.72), Vector3(s * 0.08, 2.0, -0.86), 0.012, 0.006, 3, Color(0.8, 0.74, 0.6), r)
P._blob(st, Vector3(0, 1.12, 0.62), Vector3(0.06, 0.09, 0.05), n, 0.1, belly, r, 0)
return {"body": P._commit(st), "leg": _leg(0.88, 0.055, 0.035, coat, dark, 212),
"hips": [Vector3(-0.14, 0.9, -0.42), Vector3(0.14, 0.9, -0.42), Vector3(-0.14, 0.92, 0.42), Vector3(0.14, 0.92, 0.42)],
"radius": 0.4, "height": 1.7}
static func _boar() -> Dictionary:
var bristle := Color(0.33, 0.25, 0.2)
var snout := Color(0.55, 0.4, 0.36)
var tusk := Color(0.92, 0.9, 0.82)
var st := P._begin()
var r := _rng(221)
var n := _noise(221, 1.4)
P._blob(st, Vector3(0, 0.62, 0.02), Vector3(0.32, 0.34, 0.6), n, 0.18, bristle, r, 1)
P._blob(st, Vector3(0, 0.8, -0.2), Vector3(0.18, 0.15, 0.3), n, 0.3, P._mul(bristle, 0.75), r, 1) # mane
P._blob(st, Vector3(0, 0.6, -0.62), Vector3(0.2, 0.2, 0.26), n, 0.15, bristle, r, 1)
P._cylinder(st, Vector3(0, 0.55, -0.8), Vector3(0, 0.5, -0.98), 0.1, 0.08, 6, snout, r, -1.0, P._mul(snout, 0.8))
for s in [-1.0, 1.0]:
P._cylinder(st, Vector3(s * 0.08, 0.48, -0.88), Vector3(s * 0.12, 0.62, -0.96), 0.022, 0.006, 4, tusk, r)
P._cylinder(st, Vector3(s * 0.1, 0.76, -0.62), Vector3(s * 0.17, 0.9, -0.55), 0.05, 0.015, 4, bristle, r)
P._blob(st, Vector3(s * 0.11, 0.68, -0.8), Vector3.ONE * 0.02, n, 0.0, Color(0.05, 0.03, 0.03), r, 0)
P._cylinder(st, Vector3(0, 0.7, 0.58), Vector3(0, 0.55, 0.72), 0.02, 0.012, 3, bristle, r)
return {"body": P._commit(st), "leg": _leg(0.36, 0.07, 0.05, bristle, Color(0.15, 0.12, 0.1), 222),
"hips": [Vector3(-0.17, 0.38, -0.36), Vector3(0.17, 0.38, -0.36), Vector3(-0.17, 0.4, 0.38), Vector3(0.17, 0.4, 0.38)],
"radius": 0.42, "height": 0.95}
## Grey wolf: lean body, long snout, pointed ears, bushy tail. "eyes" is a
## separate mesh so it can glow (creature.gd gives it an emissive material).
## `alpha`: a third bigger, near-black with a grizzled ruff and a scarred
## muzzle (wolf moon nights).
static func _wolf(alpha: bool = false) -> Dictionary:
if alpha:
return _scaled(_wolf_parts(Color(0.17, 0.16, 0.17), Color(0.5, 0.48, 0.45), 241), 1.32)
return _wolf_parts(Color(0.42, 0.42, 0.44), Color(0.72, 0.71, 0.68), 231)
## The same parts scaled up `k` times (body, eyes and leg meshes, hips, radius, height).
static func _scaled(d: Dictionary, k: float) -> Dictionary:
var out := d.duplicate()
for key in ["body", "eyes", "leg"]:
if d.has(key):
out[key] = _scale_mesh(d[key], k)
var hips: Array = []
for h: Vector3 in d.hips:
hips.append(h * k)
out.hips = hips
out.radius = float(d.radius) * k
out.height = float(d.height) * k
return out
static func _scale_mesh(m: ArrayMesh, k: float) -> ArrayMesh:
var arrays := m.surface_get_arrays(0)
var v: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
for i in range(v.size()):
v[i] *= k
arrays[Mesh.ARRAY_VERTEX] = v
var out := ArrayMesh.new()
out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return out
static func _wolf_parts(coat: Color, pale: Color, seed: int) -> Dictionary:
var dark := Color(0.16, 0.15, 0.15)
var st := P._begin()
var r := _rng(seed)
var n := _noise(seed, 1.5)
P._blob(st, Vector3(0, 0.7, 0.05), Vector3(0.22, 0.24, 0.55), n, 0.15, coat, r, 1)
P._blob(st, Vector3(0, 0.8, -0.32), Vector3(0.24, 0.26, 0.3), n, 0.25, P._mul(coat, 1.08), r, 1) # ruff
P._blob(st, Vector3(0, 0.6, -0.1), Vector3(0.16, 0.12, 0.35), n, 0.1, pale, r, 1) # belly
P._blob(st, Vector3(0, 0.92, -0.62), Vector3(0.15, 0.14, 0.17), n, 0.12, coat, r, 1) # head
P._cylinder(st, Vector3(0, 0.88, -0.72), Vector3(0, 0.83, -0.95), 0.09, 0.05, 6, pale, r, -1.0, dark) # snout
for s in [-1.0, 1.0]:
P._cylinder(st, Vector3(s * 0.08, 1.02, -0.6), Vector3(s * 0.1, 1.2, -0.58), 0.05, 0.008, 4, P._mul(coat, 0.85), r)
# Bushy tail, hanging down and back.
P._cylinder(st, Vector3(0, 0.78, 0.55), Vector3(0, 0.5, 0.85), 0.07, 0.05, 5, coat, r)
P._blob(st, Vector3(0, 0.45, 0.9), Vector3(0.06, 0.08, 0.06), n, 0.2, pale, r, 0)
var est := P._begin()
for s in [-1.0, 1.0]:
P._blob(est, Vector3(s * 0.065, 0.97, -0.75), Vector3.ONE * 0.022, n, 0.0, Color(1.0, 0.8, 0.3), r, 0)
return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.52, 0.045, 0.03, coat, dark, seed + 1),
"hips": [Vector3(-0.11, 0.52, -0.38), Vector3(0.11, 0.52, -0.38), Vector3(-0.11, 0.54, 0.38), Vector3(0.11, 0.54, 0.38)],
"radius": 0.32, "height": 1.0}
## Brown bear: a heavy humped body, a broad head with a pale muzzle and
## round ears, thick short legs.
static func _bear() -> Dictionary:
var coat := Color(0.36, 0.24, 0.15)
var hump := Color(0.42, 0.29, 0.18)
var muzzle := Color(0.58, 0.45, 0.32)
var dark := Color(0.1, 0.08, 0.07)
var st := P._begin()
var r := _rng(261)
var n := _noise(261, 1.3)
P._blob(st, Vector3(0, 0.95, 0.15), Vector3(0.5, 0.5, 0.85), n, 0.14, coat, r, 1) # body
P._blob(st, Vector3(0, 1.22, -0.35), Vector3(0.44, 0.38, 0.42), n, 0.18, hump, r, 1) # shoulder hump
P._blob(st, Vector3(0, 1.05, -0.95), Vector3(0.3, 0.27, 0.3), n, 0.12, coat, r, 1) # head
P._cylinder(st, Vector3(0, 0.98, -1.12), Vector3(0, 0.92, -1.36), 0.15, 0.1, 6, muzzle, r, -1.0, dark)
for s in [-1.0, 1.0]:
P._blob(st, Vector3(s * 0.2, 1.3, -0.9), Vector3(0.08, 0.08, 0.05), n, 0.1, P._mul(coat, 0.85), r, 0) # ears
P._blob(st, Vector3(0, 0.98, 0.98), Vector3(0.08, 0.08, 0.07), n, 0.2, coat, r, 0) # stub tail
var est := P._begin()
for s in [-1.0, 1.0]:
P._blob(est, Vector3(s * 0.13, 1.12, -1.18), Vector3.ONE * 0.025, n, 0.0, Color(0.9, 0.6, 0.25), r, 0)
return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.72, 0.13, 0.11, coat, dark, 262),
"hips": [Vector3(-0.28, 0.72, -0.5), Vector3(0.28, 0.72, -0.5), Vector3(-0.28, 0.74, 0.6), Vector3(0.28, 0.74, 0.6)],
"radius": 0.62, "height": 1.6}
## Crow: a small black bird facing -Z. The two "legs" are its wings
## (crow.gd swings them out sideways and flaps them).
static func _crow() -> Dictionary:
var black := Color(0.08, 0.08, 0.1)
var sheen := Color(0.14, 0.15, 0.2)
var beak := Color(0.2, 0.18, 0.15)
var st := P._begin()
var r := _rng(291)
var n := _noise(291, 2.0)
P._blob(st, Vector3(0, 0.16, 0), Vector3(0.07, 0.07, 0.14), n, 0.1, black, r, 1)
P._blob(st, Vector3(0, 0.22, -0.13), Vector3(0.05, 0.05, 0.055), n, 0.1, sheen, r, 1)
P._cylinder(st, Vector3(0, 0.215, -0.17), Vector3(0, 0.2, -0.24), 0.016, 0.002, 4, beak, r)
P._cylinder(st, Vector3(0, 0.17, 0.12), Vector3(0, 0.15, 0.26), 0.03, 0.05, 4, black, r)
for s in [-1.0, 1.0]:
P._cylinder(st, Vector3(s * 0.025, 0.1, 0.0), Vector3(s * 0.03, 0.0, 0.02), 0.006, 0.005, 3, beak, r)
for s in [-1.0, 1.0]: # (dull eyes: no glowing "eyes" mesh for a crow)
P._blob(st, Vector3(s * 0.035, 0.235, -0.16), Vector3.ONE * 0.008, n, 0.0, Color(0.3, 0.25, 0.2), r, 0)
var wst := P._begin()
P._blob(wst, Vector3(0, -0.14, 0.02), Vector3(0.012, 0.15, 0.08), n, 0.05, sheen, r, 0)
return {"body": P._commit(st), "leg": P._commit(wst),
"hips": [Vector3(-0.06, 0.19, -0.01), Vector3(0.06, 0.19, -0.01)],
"radius": 0.12, "height": 0.3}
## Barrow guard: an upright skeleton facing -Z, in a rusted helm, an old
## sword in its right hand and a round shield on its left arm. Two legs
## (creature.gd walks them like a pair of a quadruped's).
static func _guard() -> Dictionary:
var bone := Color(0.82, 0.79, 0.68)
var old := Color(0.62, 0.58, 0.48)
var rust := Color(0.42, 0.24, 0.14)
var dark := Color(0.06, 0.06, 0.07)
var wood := Color(0.32, 0.22, 0.14)
var st := P._begin()
var r := _rng(301)
var n := _noise(301, 2.2)
P._blob(st, Vector3(0, 0.95, 0), Vector3(0.17, 0.07, 0.1), n, 0.2, old, r, 0) # pelvis
P._cylinder(st, Vector3(0, 0.97, 0.02), Vector3(0, 1.45, 0.0), 0.035, 0.03, 5, bone, r) # spine
for i in range(4): # ribs
var y := 1.18 + 0.075 * i
P._cylinder(st, Vector3(-0.15 + 0.01 * i, y, -0.02), Vector3(0.15 - 0.01 * i, y, -0.02), 0.018, 0.018, 4, bone, r)
for s in [-1.0, 1.0]:
P._cylinder(st, Vector3(s * (0.15 - 0.01 * i), y, -0.02), Vector3(s * 0.11, y - 0.02, 0.07), 0.015, 0.015, 3, old, r)
P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(0.22, 1.47, 0), 0.025, 0.025, 4, bone, r) # shoulders
P._blob(st, Vector3(0, 1.66, -0.02), Vector3(0.11, 0.13, 0.12), n, 0.12, bone, r, 1) # skull
P._blob(st, Vector3(0, 1.56, -0.08), Vector3(0.07, 0.04, 0.06), n, 0.1, old, r, 0) # jaw
P._cylinder(st, Vector3(0, 1.69, -0.01), Vector3(0, 1.82, -0.01), 0.14, 0.06, 7, rust, r, -1.0, rust) # helm
P._cylinder(st, Vector3(0, 1.66, -0.12), Vector3(0, 1.5, -0.14), 0.018, 0.012, 4, rust, r) # nose guard
for s in [-1.0, 1.0]:
P._blob(st, Vector3(s * 0.045, 1.67, -0.115), Vector3(0.03, 0.025, 0.012), n, 0.0, dark, r, 0) # sockets
# Right arm forward with the sword, left arm with the shield.
P._cylinder(st, Vector3(0.22, 1.47, 0), Vector3(0.28, 1.2, -0.12), 0.025, 0.022, 4, bone, r)
P._cylinder(st, Vector3(0.28, 1.2, -0.12), Vector3(0.27, 1.08, -0.38), 0.022, 0.02, 4, bone, r)
P._cylinder(st, Vector3(0.27, 1.08, -0.4), Vector3(0.29, 1.5, -0.95), 0.03, 0.006, 4, rust, r) # blade
P._cylinder(st, Vector3(0.2, 1.05, -0.38), Vector3(0.34, 1.1, -0.4), 0.02, 0.02, 4, rust, r) # crossguard
P._cylinder(st, Vector3(-0.22, 1.47, 0), Vector3(-0.3, 1.2, -0.05), 0.025, 0.022, 4, bone, r)
P._cylinder(st, Vector3(-0.3, 1.2, -0.05), Vector3(-0.28, 1.05, -0.25), 0.022, 0.02, 4, bone, r)
P._cylinder(st, Vector3(-0.33, 1.12, -0.3), Vector3(-0.31, 1.12, -0.36), 0.26, 0.26, 9, wood, r, -1.0, wood) # shield
P._cylinder(st, Vector3(-0.31, 1.12, -0.36), Vector3(-0.305, 1.12, -0.4), 0.06, 0.04, 6, rust, r, -1.0, rust) # boss
var est := P._begin()
for s in [-1.0, 1.0]:
P._blob(est, Vector3(s * 0.045, 1.67, -0.125), Vector3.ONE * 0.012, n, 0.0, Color(0.5, 0.85, 1.0), r, 0)
var lst := P._begin()
P._cylinder(lst, Vector3(0, 0, 0), Vector3(0, -0.45, 0.02), 0.03, 0.025, 4, bone, r)
P._cylinder(lst, Vector3(0, -0.45, 0.02), Vector3(0, -0.9, 0), 0.025, 0.022, 4, bone, r)
P._blob(lst, Vector3(0, -0.9, -0.05), Vector3(0.04, 0.025, 0.08), n, 0.1, old, r, 0)
return {"body": P._commit(st), "eyes": P._commit(est), "leg": P._commit(lst),
"hips": [Vector3(-0.1, 0.93, 0), Vector3(0.1, 0.93, 0)],
"radius": 0.3, "height": 1.85}
## The Old Stag (before scaling up): a pale, moss-hung spirit deer with a
## huge crown of antlers.
static func _stag() -> Dictionary:
var coat := Color(0.72, 0.74, 0.7)
var moss := Color(0.32, 0.42, 0.24)
var bone := Color(0.9, 0.87, 0.76)
var dark := Color(0.15, 0.16, 0.18)
var st := P._begin()
var r := _rng(341)
var n := _noise(341, 1.2)
P._blob(st, Vector3(0, 1.0, 0), Vector3(0.3, 0.33, 0.66), n, 0.14, coat, r, 1)
P._blob(st, Vector3(0, 1.18, -0.3), Vector3(0.3, 0.28, 0.32), n, 0.3, moss, r, 1) # mossy mane
P._cylinder(st, Vector3(0, 1.15, -0.45), Vector3(0, 1.6, -0.68), 0.15, 0.1, 6, coat, r)
P._blob(st, Vector3(0, 1.66, -0.8), Vector3(0.12, 0.13, 0.22), n, 0.1, coat, r, 1)
P._blob(st, Vector3(0, 1.6, -1.0), Vector3(0.05, 0.05, 0.05), n, 0.0, dark, r, 0)
for s in [-1.0, 1.0]:
P._cylinder(st, Vector3(s * 0.07, 1.74, -0.74), Vector3(s * 0.2, 1.85, -0.7), 0.04, 0.015, 4, coat, r)
# Antlers: a beam with tines, wide and high.
var base := Vector3(s * 0.05, 1.78, -0.78)
var mid := Vector3(s * 0.32, 2.2, -0.7)
var top := Vector3(s * 0.52, 2.6, -0.55)
P._cylinder(st, base, mid, 0.035, 0.028, 5, bone, r)
P._cylinder(st, mid, top, 0.028, 0.012, 5, bone, r)
for t in [[mid, Vector3(s * 0.28, 2.45, -0.95)], [mid, Vector3(s * 0.55, 2.25, -0.85)],
[top, Vector3(s * 0.62, 2.85, -0.7)], [top, Vector3(s * 0.75, 2.6, -0.45)], [base.lerp(mid, 0.3), Vector3(s * 0.12, 2.05, -1.05)]]:
P._cylinder(st, t[0], t[1], 0.02, 0.006, 4, bone, r)
# Moss hanging from the antlers.
P._cylinder(st, mid, mid + Vector3(0, -0.25, 0), 0.03, 0.01, 4, moss, r)
P._blob(st, Vector3(0, 1.1, 0.64), Vector3(0.06, 0.09, 0.05), n, 0.1, coat, r, 0)
var est := P._begin()
for s in [-1.0, 1.0]:
P._blob(est, Vector3(s * 0.085, 1.7, -0.9), Vector3.ONE * 0.025, n, 0.0, Color(0.6, 0.95, 1.0), r, 0)
return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.9, 0.06, 0.04, coat, dark, 342),
"hips": [Vector3(-0.15, 0.9, -0.44), Vector3(0.15, 0.9, -0.44), Vector3(-0.15, 0.92, 0.44), Vector3(0.15, 0.92, 0.44)],
"radius": 0.42, "height": 1.8}

115
scripts/creatures/crow.gd Normal file
View File

@@ -0,0 +1,115 @@
extends "res://scripts/creatures/creature.gd"
## A crow (docs/PROGRESSION_DESIGN.md §4): sent by thieves.gd, by day, for
## food left lying on the ground. It flies in, lands on it and pecks it away
## a bit at a time, and takes off again if anyone comes within SCARE. Then
## it flies off over the trees and is gone. A closed chest keeps food safe.
## An arrow brings it down (feathers).
const SCARE := 7.0
const PECK_EVERY := 3.0
const FLY_SPEED := 9.0
const CRUISE := 7.0 ## height above the ground while flying in
const LEAVE_HEIGHT := 14.0
var thieves: Node ## thieves.gd
var food: Node3D = null
var _goal := Vector3.ZERO
var _peck_anim := 0.0
func display_name() -> String:
return "Crow"
func interact_prompt(p: Node) -> String:
if is_dead():
return "Crow [E] Pluck"
return {"peck": "Crow (pecking at your food!)", "fly": "Crow (circling your food)"}.get(state, "Crow")
func _flying() -> bool:
return state in ["fly", "fly_off"]
func _think(dt: float) -> void:
match state:
"fly":
if not thieves.still_there(food) or thieves.watched(food.global_position, SCARE):
_leave()
return
_goal = food.global_position
var ground: float = terrain.sample_height(global_position.x, global_position.z)
if Vector2(_goal.x - global_position.x, _goal.z - global_position.z).length() < 0.35 and global_position.y - ground < 0.15:
state = "peck"
_state_t = PECK_EVERY
thieves.landed(self, food)
"peck":
if not thieves.still_there(food) or thieves.watched(global_position, SCARE):
_leave()
return
_state_t -= dt
if _state_t <= 0.0:
_state_t = PECK_EVERY
_peck_anim = 0.4
if thieves.peck(self, food): # the last of it
_leave()
"fly_off":
if thieves.out_of_sight(global_position):
thieves.gone(self)
_:
_leave()
func _leave() -> void:
state = "fly_off"
food = null
var p: Node3D = world.nearest_player(global_position)
var away := Vector3(1, 0, 0)
if p != null:
away = global_position - p.global_position
away.y = 0.0
if away.length() < 0.1:
away = Vector3(1, 0, 0)
_goal = global_position + away.normalized() * 120.0
## Straight-line flight (no steering round trees: it flies over them).
func _move(dt: float) -> void:
if not _flying():
speed = 0.0
return
var to := _goal - global_position
to.y = 0.0
var flat := to.length()
var spd := FLY_SPEED
if state == "fly" and flat < 6.0:
spd = maxf(flat * 1.5, 1.2) # glide in to land
var p := global_position
if flat > 0.02:
yaw = rotate_toward(yaw, atan2(-to.x, -to.z), 8.0 * dt)
p += to / flat * minf(spd * dt, flat)
var ground: float = terrain.sample_height(p.x, p.z)
var alt := LEAVE_HEIGHT
if state == "fly":
alt = CRUISE * clampf((flat - 0.3) / 8.0, 0.0, 1.0)
p.y = maxf(move_toward(global_position.y, maxf(ground, terrain.water_level) + alt, 5.0 * dt), ground)
global_position = p
rotation.y = yaw
speed = spd
## Wings out and flapping in the air, folded on the ground; a dip of the
## head when it pecks.
func _animate(dt: float) -> void:
var flying := _flying() or (puppet and speed > 0.5)
_phase += dt * 16.0
for l in _legs:
var s := -1.0 if l.position.x < 0.0 else 1.0
l.rotation.z = s * ((PI * 0.5 + sin(_phase) * 0.7) if flying else 0.2)
_peck_anim = maxf(_peck_anim - dt, 0.0)
if is_dead():
return
_visual.rotation.x = 0.5 * sin(_peck_anim / 0.4 * PI) if state == "peck" else 0.0
func _react(_from: Vector3) -> void:
_leave()
## Brought down out of the air: it lands where it fell.
func _die(from: Vector3) -> void:
super._die(from)
var p := global_position
p.y = terrain.sample_height(p.x, p.z)
global_position = p

187
scripts/creatures/den.gd Normal file
View File

@@ -0,0 +1,187 @@
extends Node3D
## The wolf den: a cave mouth of piled boulders at the foot of the mountain
## rim, opening toward the valley. Built from the same procedural rocks as the
## scenery, so it's part of the island and regenerates from the seed.
##
## The site is chosen deterministically: a flat, clear spot close to the rim,
## away from the sea and the spawn. The rocks' colliders are also on the
## building layer, so standing inside the cave counts as being under a roof
## (it's dry in there, if you dare).
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
const BONE := Color(0.88, 0.85, 0.76)
## [local position, radii] of each boulder. The interior is about 3.6 m
## wide, 4 m deep and 2 m high; +Z is the mouth.
const ROCKS := [
[Vector3(0, 1.2, -3.4), Vector3(3.2, 2.4, 1.6)], # back wall
[Vector3(-2.6, 1.1, -1.4), Vector3(1.4, 2.2, 2.4)], # left wall
[Vector3(-2.4, 0.9, 0.9), Vector3(1.3, 1.8, 1.6)],
[Vector3(2.6, 1.2, -1.5), Vector3(1.5, 2.3, 2.4)], # right wall
[Vector3(2.3, 0.8, 0.9), Vector3(1.2, 1.6, 1.5)],
[Vector3(0, 3.0, -1.2), Vector3(3.4, 0.9, 3.0)], # roof slab
[Vector3(0, 2.0, -5.6), Vector3(4.5, 3.4, 3.0)], # mound behind
[Vector3(-3.6, 1.6, -4.4), Vector3(2.5, 2.6, 2.2)],
[Vector3(3.7, 1.8, -4.3), Vector3(2.6, 2.8, 2.3)],
[Vector3(-2.5, 0.5, 2.6), Vector3(1.0, 0.8, 0.9)], # boulders at the mouth
[Vector3(2.7, 0.45, 2.4), Vector3(0.9, 0.7, 0.8)],
]
## Where the wolves lie (local), facing out of the mouth.
const SLOTS := [Vector3(-0.9, 0, -0.9), Vector3(1.0, 0, -1.3), Vector3(0.0, 0, -2.0), Vector3(-0.2, 0, 0.4)]
const HALF_SIZE := 6.5 ## clear area needed around the site
var terrain: Node3D
var yaw := 0.0 ## node rotation; local +Z points into the valley
var found := false
## Finds the site and builds the den. Returns false (and builds nothing) if
## no spot qualifies on this seed.
func place(world: Node3D, rock_mat: Material) -> bool:
terrain = world.terrain
var site := _find_site(world)
if site == Vector3.INF:
push_warning("[den] no site found for the wolf den on this seed")
return false
found = true
position = site
yaw = atan2(-site.x, -site.z) # mouth toward the centre of the valley
rotation.y = yaw
_clear_area(world.props)
_build(rock_mat)
return true
func _find_site(world: Node3D) -> Vector3:
var rng := RandomNumberGenerator.new()
rng.seed = terrain.noise_seed + 777
var start := rng.randf() * 360.0
var space := world.get_world_3d().direct_space_state
var box := BoxShape3D.new()
box.size = Vector3(HALF_SIZE * 2.0, 6.0, HALF_SIZE * 2.0)
# Pass 1 tries only the foot of the rim wall in each direction (the
# original search, so islands that already had a den keep it in the same
# place). Where the rim is steep all the way round (seed 777, seed 4),
# pass 2 steps further into the valley, a few metres at a time.
for pass_insets in [[0.0], [3.0, 6.0, 9.0, 12.0, 16.0, 20.0, 25.0, 30.0]]:
for step in range(52):
var ang := deg_to_rad(start + step * 7.0)
var dir := Vector2(cos(ang), sin(ang))
var r_foot := _rim_foot(dir)
for inset in pass_insets:
var c := _check_site(world, dir * (r_foot - inset))
if c != Vector3.INF:
return c
return Vector3.INF
## Walks inward from the rim until the den sits inside the walkable area, a
## little way short of the wall. Returns that distance from the centre.
func _rim_foot(dir: Vector2) -> float:
var r: float = terrain.play_radius + 10.0
while r > terrain.play_radius - 40.0:
if terrain.inside_boundary(dir.x * r, dir.y * r, 11.0):
break
r -= 1.0
return r
## The site at `p` (x, z) with its height, or INF if it's no good: near the
## sea or the spawn, too sloped, too low, or in thick woods.
func _check_site(world: Node3D, p: Vector2) -> Vector3:
if terrain.sea_weight(p.x, p.y) > 0.02 or p.length() < 50.0:
return Vector3.INF
var hs: Array[float] = []
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
hs.append(terrain.sample_height(p.x + d.x * 4.0, p.y + d.y * 4.0))
if hs.max() - hs.min() > 2.2 or hs.min() < terrain.water_level + 1.0:
return Vector3.INF
# Trees and boulders in the way are cleared (_clear_area); just avoid
# the thickest woods. Counted by position, because their colliders
# were only just created and the physics space hasn't seen them yet.
var crowd := 0
for uid in world.props.resource_nodes:
var n: Node3D = world.props.resource_nodes[uid]
if Vector2(n.global_position.x - p.x, n.global_position.z - p.y).length() < HALF_SIZE:
crowd += 1
if crowd > 5:
return Vector3.INF
return Vector3(p.x, hs[4], p.y)
## Removes the trees, boulders, bushes and pickups the den is built over.
## Deterministic (the site comes from the seed), and the other scenery keeps
## its save ids because those come from creation order.
func _clear_area(props: Node3D) -> void:
var r := HALF_SIZE
# Also a clearing in front of the mouth, so you can see the cave from the valley.
var b := Basis(Vector3.UP, yaw)
var front: Vector3 = position + b * Vector3(0, 0, 9.0)
for dict in [props.resource_nodes, props.pickups, props.forage]:
for uid in dict.keys():
var n: Node3D = dict[uid]
if not is_instance_valid(n):
continue
var p2 := Vector2(n.global_position.x, n.global_position.z)
if p2.distance_to(Vector2(position.x, position.z)) < r or p2.distance_to(Vector2(front.x, front.z)) < 6.0:
if n.has_method("_set_instance"):
n._set_instance(Transform3D(Basis(), n.global_position + Vector3(0, -1000, 0)))
dict.erase(uid)
n.queue_free()
func _build(rock_mat: Material) -> void:
var body := StaticBody3D.new()
body.name = "Rocks"
body.collision_layer = 1 | 4 # solid, and a roof for shelter checks
add_child(body)
var b := Basis(Vector3.UP, yaw)
for i in range(ROCKS.size()):
var lp: Vector3 = ROCKS[i][0]
var radii: Vector3 = ROCKS[i][1]
# Sit each boulder on the ground under it (the site isn't perfectly flat).
var w: Vector3 = position + b * Vector3(lp.x, 0, lp.z)
var y: float = terrain.sample_height(w.x, w.z) - position.y + lp.y - 0.25
var mesh := ProcMesh.rock(terrain.noise_seed + 3100 + i, false)
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.material_override = rock_mat
var turn := Basis(Vector3.UP, float(i) * 1.7)
mi.transform = Transform3D(turn.scaled(radii), Vector3(lp.x, y, lp.z))
add_child(mi)
var hull := mesh.create_convex_shape(true, true) as ConvexPolygonShape3D
var pts := PackedVector3Array()
for p: Vector3 in hull.points:
pts.append(turn * (p * radii))
hull.points = pts
var cs := CollisionShape3D.new()
cs.shape = hull
cs.position = Vector3(lp.x, y, lp.z)
body.add_child(cs)
# Old bones on the cave floor.
var st := ProcMesh._begin()
var rng := RandomNumberGenerator.new()
rng.seed = terrain.noise_seed + 778
var n := FastNoiseLite.new()
n.seed = 778
for k in range(7):
var c := Vector3(rng.randf_range(-1.4, 1.4), 0.0, rng.randf_range(-2.4, 1.2))
var a := rng.randf() * TAU
var dir := Vector3(cos(a), 0, sin(a)) * rng.randf_range(0.15, 0.3)
ProcMesh._cylinder(st, c - dir + Vector3(0, 0.04, 0), c + dir + Vector3(0, 0.04, 0), 0.025, 0.02, 5, BONE, rng, -1.0, BONE)
ProcMesh._blob(st, c + dir + Vector3(0, 0.05, 0), Vector3.ONE * 0.045, n, 0.2, BONE, rng, 0)
var bones := MeshInstance3D.new()
bones.mesh = ProcMesh._commit(st)
bones.material_override = ItemDB.item_material()
add_child(bones) # (the den's origin is the cave floor)
## World position of resting slot `i`, on the ground.
func slot_pos(i: int) -> Vector3:
var lp: Vector3 = SLOTS[i % SLOTS.size()]
var w: Vector3 = position + Basis(Vector3.UP, yaw) * lp
w.y = terrain.sample_height(w.x, w.z)
return w
## Yaw a wolf uses to face out of the mouth (forward is -Z).
func facing_out_yaw() -> float:
var out := Basis(Vector3.UP, yaw) * Vector3(0, 0, 1)
return atan2(-out.x, -out.z)
func mouth_pos() -> Vector3:
var w: Vector3 = position + Basis(Vector3.UP, yaw) * Vector3(0, 0, 3.5)
w.y = terrain.sample_height(w.x, w.z)
return w

95
scripts/creatures/fox.gd Normal file
View File

@@ -0,0 +1,95 @@
extends "res://scripts/creatures/creature.gd"
## A fox (docs/PROGRESSION_DESIGN.md §4): sent by thieves.gd at dusk and
## night for food left lying on the ground. It slinks up, grabs the whole
## stack in its jaws and runs off with it. Come within WARY while it's
## creeping in and it bolts empty-mouthed. Kill it while it's carrying and
## it drops what it took; let it get away and the food's gone.
const WARY := 10.0
const SNEAK_SPEED := 3.4
const GIVE_UP := 30.0 ## seconds running with it before it's gone for good
var thieves: Node
var food: Node3D = null
var carry: Dictionary = {} ## {id, count, extra} once it has the food
var _escape := Vector3.ZERO
var _goal_d := 0.0
func display_name() -> String:
return "Fox"
func interact_prompt(p: Node) -> String:
if is_dead():
return "Fox carcass [E] Butcher"
if not carry.is_empty():
return "Fox (running off with your %s!)" % ItemDB.item_name(String(carry.id)).to_lower()
return "Fox (sneaking up on your food)" if state == "sneak" else "Fox"
func _think(dt: float) -> void:
var pp: Vector3 = player.global_position
_state_t -= dt
match state:
"sneak":
if not thieves.still_there(food):
_bolt(pp)
return
if thieves.watched(global_position, WARY):
_bolt(pp)
return
var fp: Vector3 = food.global_position
_go_to(fp)
if Vector2(fp.x - global_position.x, fp.z - global_position.z).length() < 0.9:
carry = thieves.grab(self, food)
food = null
state = "carry"
_state_t = GIVE_UP
_escape = _away_point(pp, 60.0)
"carry", "flee":
_go_to(_escape)
if thieves.out_of_sight(global_position) or _state_t <= 0.0 or _goal_d < 1.5:
thieves.gone(self)
_:
_bolt(pp)
func _bolt(from: Vector3) -> void:
state = "flee" if carry.is_empty() else "carry"
food = null
_state_t = GIVE_UP
_escape = _away_point(from, 60.0)
## A point `d` away from `from`, through the fox, kept inside the valley.
func _away_point(from: Vector3, d: float) -> Vector3:
var away := global_position - from
away.y = 0.0
if away.length() < 0.1:
away = Vector3(1, 0, 0)
away = away.normalized()
for turn in [0.0, 0.6, -0.6, 1.2, -1.2, PI]:
var p: Vector3 = global_position + away.rotated(Vector3.UP, turn) * d
if terrain.inside_boundary(p.x, p.z, 8.0) and terrain.sample_height(p.x, p.z) > terrain.water_level + 0.3:
return p
return global_position + away * d * 0.3
func _go_to(p: Vector3) -> void:
var to := p - global_position
to.y = 0.0
if to.length() > 0.3:
steer(atan2(-to.x, -to.z))
_goal_d = to.length()
func _desired_speed() -> float:
match state:
"sneak": return 0.0 if _goal_d < 0.5 else SNEAK_SPEED
"carry", "flee": return def.run
return 0.0
func _react(from: Vector3) -> void:
_bolt(from)
## Killed with the food in its jaws: it drops it.
func _die(from: Vector3) -> void:
super._die(from)
if not carry.is_empty() and not puppet:
var at := global_position + Vector3(0, 0.3, 0)
world.drop_item(String(carry.id), int(carry.count), Transform3D(Basis(), at), carry.get("extra", {}))
carry = {}

110
scripts/creatures/guard.gd Normal file
View File

@@ -0,0 +1,110 @@
extends "res://scripts/creatures/creature.gd"
## A barrow guard (docs/PROGRESSION_DESIGN.md §4): a skeleton standing at its
## post in the barrow (barrow.gd) until something wakes it: someone inside
## the stone ring at night (or any time after day 10), someone opening the
## coffin, or a blow. Then it comes for you. It's slow, so you can walk away
## from it, but it hits hard and it isn't afraid of fire or torches. It
## won't follow you far from the barrow; it goes back to its post and stands
## there again.
var barrow: Node ## barrow.gd
var post := Vector3.ZERO
var post_yaw := 0.0
var _goal_d := 0.0
func display_name() -> String:
return def.name
func interact_prompt(p: Node) -> String:
if is_dead():
return "Bones [E] Search"
var s := "Barrow guard"
match state:
"rest": s = "Barrow guard (still... for now)"
"charge": s = "Barrow guard (coming for you!)"
"home": s = "Barrow guard (going back to its post)"
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
s += " (you need a weapon: a club cracks bone)"
return s
func _think(dt: float) -> void:
var pp: Vector3 = player.global_position
var d := global_position.distance_to(pp)
_state_t -= dt
match state:
"rest":
target_yaw = post_yaw
if barrow.wakes_for(player):
state = "charge"
_attack_cd = 0.8
"charge":
if player.survival.dead or barrow.center().distance_to(pp) > barrow.LEASH or not barrow.alarmed:
state = "home"
return
var to := pp - global_position
steer(atan2(-to.x, -to.z))
_goal_d = d
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player):
_attack_cd = def.attack_cd
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a barrow guard")
"home":
if barrow.wakes_for(player) and barrow.center().distance_to(pp) < barrow.LEASH:
state = "charge"
return
var to := post - global_position
to.y = 0.0
_goal_d = to.length()
if _goal_d < 0.4:
state = "rest"
else:
steer(atan2(-to.x, -to.z))
_:
state = "home"
func _desired_speed() -> float:
match state:
"charge": return 0.0 if _goal_d < def.attack_range * 0.8 else def.run
"home": return 0.0 if _goal_d < 0.4 else def.walk * 1.5
return 0.0
## Nothing solid between its sword and you.
func _can_reach(p: Node3D) -> bool:
var from := global_position + Vector3(0, 1.2, 0)
var to: Vector3 = p.global_position + Vector3(0, 1.0, 0)
var q := PhysicsRayQueryParameters3D.create(from, to, 1)
q.exclude = [get_rid(), p.get_rid(), terrain.get_node("TerrainBody").get_rid()]
return get_world_3d().direct_space_state.intersect_ray(q).is_empty()
## In the barrow, what's ahead is measured from the stone floor (the ground
## under it is lower, and a probe aimed at that would hit the floor slab).
func _blocked(dir: Vector3) -> bool:
var probe := global_position + dir * (1.4 + speed * 0.15)
var f: float = barrow.floor_y_at(probe)
if f == -INF and barrow.floor_y_at(global_position) == -INF:
return super._blocked(dir)
if not terrain.inside_boundary(probe.x, probe.z, 6.0):
return true
var h: float = f if f > -INF else terrain.sample_height(probe.x, probe.z)
if absf(h - global_position.y) > 1.1:
return true
var q := PhysicsRayQueryParameters3D.create(global_position + Vector3(0, 0.45, 0), Vector3(probe.x, h + 0.45, probe.z), 1)
q.exclude = [get_rid(), terrain.get_node("TerrainBody").get_rid()]
return not get_world_3d().direct_space_state.intersect_ray(q).is_empty()
## Inside the barrow it walks on the stone floor, not the ground under it.
func _move(dt: float) -> void:
super._move(dt)
var f: float = barrow.floor_y_at(global_position)
if f > global_position.y:
global_position.y = f
func _react(_from: Vector3) -> void:
barrow.alarm()
state = "charge"
_attack_cd = maxf(_attack_cd, 0.6)
## Standing upright, a blow knocks it to pieces (no toppling sideways).
func _die(from: Vector3) -> void:
super._die(from)
_visual.rotation = Vector3(-PI * 0.5, 0, 0)
_visual.position.y = 0.15

View File

@@ -0,0 +1,210 @@
extends "res://scripts/creatures/creature.gd"
## The Old Stag (docs/PROGRESSION_DESIGN.md §6): the island's one boss, called
## by the standing stones (standing_stones.gd) at night with three barrow key
## pieces. A huge antlered spirit.
##
## The fight goes round a loop: it circles you at a distance, stops and
## lowers its antlers (the warning), then charges in a straight line through
## where you were. Step aside and it thunders past; get caught and it hits for
## 30. After a charge it stands stunned for a moment: hits then do half as
## much again. Three phases by health:
## - 1 (above 2/3): circle, charge, stunned 2.5 s.
## - 2 (from 2/3): it rears and calls three spirit wolves out of the mist,
## then goes on.
## - 3 (from 1/3): two more wolves; quicker warnings and charges, a shorter
## stun, and if you stay close it stamps the ground (15 to everyone within
## 5.5 m).
## It doesn't run, isn't afraid of fire, and goes back into the mist at dawn
## or if everyone leaves the stones (the stones keep their key pieces).
##
## With more people at the stones when it's called it's tougher (60% more
## health for each extra player; standing_stones.gd scale_for()), and it
## remembers everyone who struck it (credit()): all of them are named when
## it falls.
const CIRCLE_R := 14.0
const CHARGE_HIT := 3.0 ## caught within this of its path
const STUN_DAMAGE := 1.5 ## hits while stunned
const STOMP_R := 5.5
const STOMP_DAMAGE := 15.0
const STOMP_CD := 4.0
var stones: Node ## standing_stones.gd
var phase := 1
var max_hp := 900.0 ## (scaled for the group when it's called)
var fighters: Dictionary = {} ## who struck it: player id -> name, in order
var _target := Vector3.ZERO ## where it's charging to
var _dir := Vector3.FORWARD
var _hit_this_charge := false
var _stomp_cd := 0.0
var _circle_a := 0.0
var _goal_d := 0.0
var charges := 0 ## (tests read these)
var charge_hits := 0
func display_name() -> String:
return def.name
func interact_prompt(_p: Node) -> String:
if is_dead():
return "The Old Stag (fading)"
var tell := {"circle": "circling you", "telegraph": "lowering its antlers!", "charge": "CHARGING!",
"stunned": "stunned: strike now!", "summon": "calling its wolves", "stomp": "rearing up!"}
return "The Old Stag %d / %d (%s)" % [ceili(hp), int(max_hp), tell.get(state, "")]
func _pick_target() -> Node3D:
return world.nearest_player(global_position)
func _phase_for(h: float) -> int:
if h > max_hp * 2.0 / 3.0:
return 1
return 2 if h > max_hp / 3.0 else 3
## Tougher for a bigger group: `players` = how many were at the stones.
func scale_for(players: int) -> void:
max_hp = float(def.hp) * (1.0 + 0.6 * float(maxi(players, 1) - 1))
hp = max_hp
## `who` struck it (Actions: a blow, an arrow or a thrown spear).
func credit(who: Node) -> void:
if who == null:
return
var id := String(who.get("player_id")) if who.get("player_id") != null else "you"
if not fighters.has(id):
fighters[id] = String(who.get("display")) if who.get("display") != null and not String(who.get("display")).is_empty() else "you"
## Everyone who struck it, by name, in the order they joined in.
func fighter_names() -> Array:
return fighters.values()
func _times() -> Dictionary:
if phase >= 3:
return {"circle": 2.0, "telegraph": 0.8, "stun": 1.5}
return {"circle": 3.0, "telegraph": 1.2, "stun": 2.5}
func _think(dt: float) -> void:
var pp: Vector3 = player.global_position
_state_t -= dt
_stomp_cd = maxf(_stomp_cd - dt, 0.0)
var want := _phase_for(hp)
if want > phase and state != "charge":
phase = want
state = "summon"
_state_t = 2.0
stones.summon_wolves(self, 3 if phase == 2 else 2)
return
match state:
"summon":
_face(pp)
if _state_t <= 0.0:
_start_circle()
"circle":
_circle_a += dt * 0.5
var c := pp + Vector3(cos(_circle_a), 0, sin(_circle_a)) * CIRCLE_R
_go_to(c)
if phase >= 3 and global_position.distance_to(pp) < STOMP_R and _stomp_cd <= 0.0:
state = "stomp"
_state_t = 0.6
elif _state_t <= 0.0:
state = "telegraph"
_state_t = _times().telegraph
"telegraph":
_face(pp)
if _state_t <= 0.0:
_dir = Vector3(pp.x - global_position.x, 0, pp.z - global_position.z).normalized()
_target = pp + _dir * 7.0
_hit_this_charge = false
charges += 1
state = "charge"
_state_t = 4.0
"charge":
# (Checked every step along the way: anyone near its path is hit.)
if not _hit_this_charge:
for p in world.players():
var d := Vector2(p.global_position.x - global_position.x, p.global_position.z - global_position.z).length()
if d < CHARGE_HIT and not p.survival.dead:
p.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "the Old Stag")
_hit_this_charge = true
charge_hits += 1
var left := Vector2(_target.x - global_position.x, _target.z - global_position.z)
if left.dot(Vector2(_dir.x, _dir.z)) <= 0.0 or _state_t <= 0.0:
state = "stunned"
_state_t = _times().stun
"stunned":
if _state_t <= 0.0:
_start_circle()
"stomp":
_face(pp)
if _state_t <= 0.0:
_stomp_cd = STOMP_CD
for p in world.players():
if p.global_position.distance_to(global_position) < STOMP_R and not p.survival.dead:
p.survival.damage(STOMP_DAMAGE * world.difficulty_mult("damage"), global_position, "the Old Stag")
stones.stomped(self)
_start_circle()
_:
_start_circle()
func _start_circle() -> void:
state = "circle"
_state_t = _times().circle
var away := global_position - player.global_position
_circle_a = atan2(away.z, away.x)
func _face(p: Vector3) -> void:
var to := p - global_position
target_yaw = atan2(-to.x, -to.z)
_goal_d = 0.0
func _go_to(p: Vector3) -> void:
var to := p - global_position
to.y = 0.0
if to.length() > 0.3:
steer(atan2(-to.x, -to.z))
_goal_d = to.length()
func _desired_speed() -> float:
match state:
"circle": return 0.0 if _goal_d < 1.0 else 6.5
"charge": return def.run
return 0.0
## A charge is a straight line: over the ground, through bushes and saplings
## (it only stops for the edge of the valley and the water).
func _move(dt: float) -> void:
if state != "charge":
super._move(dt)
return
speed = move_toward(speed, def.run, dt * def.run * 4.0)
yaw = rotate_toward(yaw, atan2(-_dir.x, -_dir.z), 8.0 * dt)
var p := global_position + _dir * speed * dt
if not terrain.inside_boundary(p.x, p.z, 0.5) or terrain.sample_height(p.x, p.z) < terrain.water_level + 0.2:
state = "stunned" # (pulled up short at the edge)
_state_t = _times().stun
return
p.y = terrain.sample_height(p.x, p.z)
global_position = p
rotation.y = yaw
## Rearing for the summon and the stomp; head down for the warning.
func _animate(dt: float) -> void:
if state in ["summon", "stomp"]:
_visual.rotation.x = lerpf(_visual.rotation.x, 0.5, minf(dt * 5.0, 1.0))
return
if state == "telegraph":
_visual.rotation.x = lerpf(_visual.rotation.x, -0.25, minf(dt * 5.0, 1.0))
return
super._animate(dt)
func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void:
super.take_hit(damage * (STUN_DAMAGE if state == "stunned" and not puppet else 1.0), from, embed_id)
## It doesn't flinch or run; it just keeps coming.
func _react(_from: Vector3) -> void:
pass
func _die(from: Vector3) -> void:
super._die(from)
carcass_left = 4.0 # (it fades back into the mist)
if not puppet and stones != null:
stones.stag_died(self)

View File

@@ -0,0 +1,110 @@
extends Node3D
## A flying arrow or thrown spear.
##
## Each physics step it moves along its velocity (with gravity) and ray-casts
## the segment it covered, so fast arrows can't skip through a thin target.
## - Hits an animal: damage scaled by speed. An arrow sticks in the animal and
## comes back when you butcher it. A spear drops at the animal's feet.
## - Hits anything else: it sticks there and becomes a pickup.
## - After 8 s in the air (or below the world) it drops where it is.
var item_id := "arrow"
var damage := 10.0
var velocity := Vector3.ZERO
var gravity := 9.8
var shooter: Node3D
var world: Node3D
var _life := 8.0
var _start_speed := 1.0
func setup(id: String, w: Node3D, from: Vector3, vel: Vector3, dmg: float, who: Node3D) -> void:
item_id = id
world = w
velocity = vel
damage = dmg
shooter = who
_start_speed = maxf(vel.length(), 0.1)
gravity = 9.8 if ItemDB.get_item(id).has("ammo") else 12.0
var mi := MeshInstance3D.new()
mi.mesh = ItemDB.mesh_for(id)
mi.material_override = ItemDB.item_material()
if ItemDB.get_item(id).get("weapon", {}).has("throw"):
# The spear mesh runs along +Y; turn it to fly point-first along -Z.
mi.transform = Transform3D(Basis(Vector3.RIGHT, -PI * 0.5), Vector3(0, 0, 0.35))
add_child(mi)
position = from
_orient()
func _orient() -> void:
if velocity.length() > 0.01 and is_inside_tree():
look_at(global_position + velocity, Vector3.UP if absf(velocity.normalized().y) < 0.99 else Vector3.RIGHT)
func _ready() -> void:
_orient()
func _physics_process(delta: float) -> void:
step(delta)
func step(dt: float) -> void:
_life -= dt
var from := global_position
velocity.y -= gravity * dt
var to := from + velocity * dt
var ex: Array[RID] = []
if shooter != null and is_instance_valid(shooter) and shooter.has_method("get_rid"):
ex.append(shooter.get_rid())
var mask := 1 | 8
if world.server_mode and preload("res://scripts/net/rules.gd").pvp():
mask |= 16 # other players (server_player.gd); with PvP off arrows pass through them
var q := PhysicsRayQueryParameters3D.create(from, to, mask, ex)
var hit := get_world_3d().direct_space_state.intersect_ray(q)
if not hit.is_empty():
_on_hit(hit)
return
global_position = to
_orient()
if _life <= 0.0 or to.y < world.terrain.min_height - 20.0:
_drop(Transform3D(Basis(), Vector3(to.x, world.terrain.sample_height(to.x, to.z), to.z)))
func _on_hit(hit: Dictionary) -> void:
var c: Object = hit.collider
var dir := velocity.normalized()
if visual:
queue_free()
return
if c != null and c.get("player_id") != null and c.get("survival") != null:
# Another player, on a server with PvP on.
var k := clampf(velocity.length() / _start_speed, 0.4, 1.0)
var by: String = String(shooter.display) if shooter != null and is_instance_valid(shooter) and shooter.get("display") != null else "an arrow"
(c as Node).survival.damage(damage * k, global_position - dir, by)
Actions.tell(shooter, "Hit %s" % String(c.display))
var at: Vector3 = (c as Node3D).global_position + Vector3(0, 0.05, 0)
_drop(Transform3D(Basis(Vector3.UP, randf() * TAU) * Basis(Vector3.FORWARD, PI * 0.5), at))
return
if c != null and (c as Node).is_in_group("creature"):
var k := clampf(velocity.length() / _start_speed, 0.4, 1.0)
Actions.projectile_hit(shooter, c, damage * k, global_position, item_id if ItemDB.get_item(item_id).has("ammo") else "")
if ItemDB.get_item(item_id).get("weapon", {}).has("throw"):
var at: Vector3 = (c as Node3D).global_position + Vector3(0, 0.05, 0)
_drop(Transform3D(Basis(Vector3.UP, randf() * TAU) * Basis(Vector3.FORWARD, PI * 0.5), at))
else:
queue_free()
return
# Stick into whatever it hit, point first, slightly buried.
var at: Vector3 = hit.position - dir * (0.12 if ItemDB.get_item(item_id).has("ammo") else 0.25)
var b := Basis.looking_at(dir, Vector3.UP if absf(dir.y) < 0.99 else Vector3.RIGHT)
if ItemDB.get_item(item_id).get("weapon", {}).has("throw"):
b = b * Basis(Vector3.RIGHT, -PI * 0.5) # mesh +Y along the flight
_drop(Transform3D(b, at))
func _drop(xf: Transform3D) -> void:
if not visual and not breaks:
world.drop_item(item_id, 1, xf, extra)
queue_free()
## Online, our game shows the server's arrows and spears in flight
## (creature_net.gd). They hit nothing: the server's copy does that, and
## anything that lands comes from the server as a dropped item.
var visual := false
var extra: Dictionary = {} ## carried onto the item where it lands (a spear's wear)
var breaks := false ## its last throw: nothing is left where it lands

View File

@@ -0,0 +1,138 @@
extends Node3D
## Keeps a few animals around the player, within a budget per kind.
##
## Animals spawn in a ring 30-70 m out, out of the way, and despawn once
## they're more than 100 m away. Each kind has its own habitat: rabbits in
## meadows, deer at forest edges, boars deep in the woods. Animals aren't
## saved: like Valheim, the wildlife simply carries on around you, and a
## loaded world repopulates within a few seconds.
const CreatureScript = preload("res://scripts/creatures/creature.gd")
const BUDGET := {"rabbit": 6, "deer": 4, "boar": 3}
const RING_MIN := 30.0
const RING_MAX := 70.0
const DESPAWN := 100.0
const RESPAWN_DELAY := 25.0 ## per kind, after one dies or wanders off
const CHECK_EVERY := 2.0
var world: Node3D
var terrain: Node3D
var props: Node3D
var enabled := true
var _rng := RandomNumberGenerator.new()
var _check := 0.0
var _cooldown := {"rabbit": 0.0, "deer": 0.0, "boar": 0.0}
var _serial := 0
func setup(w: Node3D) -> void:
world = w
terrain = w.terrain
props = w.props
_rng.seed = terrain.noise_seed + 901
func alive(kind: String = "") -> Array:
var out := []
for c in get_children():
if c.is_in_group("creature") and (kind.is_empty() or c.kind == kind):
out.append(c)
return out
## Fills the budget straight away (world start), closer in than usual so
## there's something to see, but not on top of the player.
func populate(around: Vector3 = Vector3.INF) -> void:
for kind in BUDGET:
for i in range(BUDGET[kind]):
_spawn_near(kind, 22.0, RING_MAX, around)
## A player joined a server: some wildlife around them straight away, up to
## the island's budget.
func populate_for(pos: Vector3) -> void:
if not enabled:
return
var n: int = world.players().size()
for kind in BUDGET:
var near := alive(kind).filter(func(c): return c.global_position.distance_to(pos) < RING_MAX).size()
for i in range(mini(BUDGET[kind] - near, budget(kind, n) - alive(kind).size())):
_spawn_near(kind, 22.0, RING_MAX, pos)
func _process(delta: float) -> void:
if not enabled:
return
for k in _cooldown:
_cooldown[k] = maxf(_cooldown[k] - delta, 0.0)
_check -= delta
if _check > 0.0:
return
_check = CHECK_EVERY
# Around every player (on a server there can be several, wherever they
# are): an animal goes once it's far from all of them.
var everyone: Array = world.players()
if everyone.is_empty():
return
for c in get_children():
var near := false
for p in everyone:
near = near or c.global_position.distance_to(p.global_position) <= DESPAWN
if not near:
if c.is_in_group("creature"):
_cooldown[c.kind] = RESPAWN_DELAY * 0.3
c.queue_free()
for kind in BUDGET:
if _cooldown[kind] > 0.0:
continue
if alive(kind).size() < winter_budget(kind, everyone.size()):
var who: Node3D = everyone[0] if everyone.size() == 1 else everyone[_rng.randi() % everyone.size()]
if _spawn_near(kind, RING_MIN, RING_MAX, who.global_position) != null:
_cooldown[kind] = RESPAWN_DELAY * 0.25
## How many of a kind the island holds: the budget per player, up to twice
## it however many are on (MULTIPLAYER_PLAN.md §11).
static func budget(kind: String, players: int) -> int:
return BUDGET[kind] * clampi(players, 1, 2)
## In a hard winter (P4) half as many: the animals are holed up.
func winter_budget(kind: String, players: int) -> int:
var b := budget(kind, players)
if world != null and world.weather != null and world.weather.in_hard_winter():
b = maxi(b / 2, 1)
return b
func _on_died(c: Node) -> void:
_cooldown[c.kind] = RESPAWN_DELAY
## Habitat preference, 0..1, at a spot (forest density from the scenery).
func _habitat(kind: String, x: float, z: float) -> float:
var f: float = props._forest(x, z)
match kind:
"rabbit": return 1.0 - smoothstep(0.2, 0.6, f)
"deer": return 1.0 - absf(f - 0.45) * 1.6
_: return smoothstep(0.35, 0.75, f)
func _spawn_near(kind: String, rmin: float, rmax: float, around: Vector3 = Vector3.INF) -> Node3D:
var pp: Vector3 = around if around != Vector3.INF else world.player.global_position
for _try in range(40):
var a := _rng.randf() * TAU
var r := _rng.randf_range(rmin, rmax)
var x := pp.x + cos(a) * r
var z := pp.z + sin(a) * r
if not terrain.inside_boundary(x, z, 10.0):
continue
var h: float = terrain.sample_height(x, z)
if h < terrain.water_level + 0.6 or h > terrain.snow_height - 4.0:
continue
if terrain.sample_normal(x, z).y < 0.85:
continue
if _rng.randf() > 0.15 + _habitat(kind, x, z) * 0.85:
continue
return spawn(kind, Vector3(x, h, z))
return null
func spawn(kind: String, pos: Vector3) -> Node3D:
var c: AnimatableBody3D = CreatureScript.new()
_serial += 1
c.setup(kind, world, pos, _rng.randi() + _serial)
add_child(c)
c.global_position = Vector3(pos.x, terrain.sample_height(pos.x, pos.z), pos.z)
c.died.connect(_on_died)
return c

View File

@@ -0,0 +1,150 @@
extends Node3D
## Thieves (docs/PROGRESSION_DESIGN.md §4): crows by day and foxes at dusk
## and night, after food left lying on the ground.
##
## Food on the ground that nobody has been within UNWATCHED of for
## NOTICE_AFTER seconds draws a thief. It comes from COME_FROM away, out of
## sight of everyone. A crow pecks the stack away one at a time; a fox runs
## off with all of it. Either one gives up if you come near (crow.gd,
## fox.gd). Food in a chest is never touched (that's what chests are for);
## the bear (bear_range.gd) is another matter.
##
## On a server this runs for everyone (thieves are wildlife: creature_net.gd).
const CrowScript = preload("res://scripts/creatures/crow.gd")
const FoxScript = preload("res://scripts/creatures/fox.gd")
const CHECK_EVERY := 5.0
const UNWATCHED := 10.0
const NOTICE_AFTER := 20.0
const MAX_THIEVES := 3
const COME_FROM := 35.0
const OUT_OF_SIGHT := 45.0 ## this far from everyone, a thief that's leaving is gone
signal stolen(thief: Node, id: String, count: int)
var world: Node3D
var enabled := true
var _acc := 0.0
var _unwatched: Dictionary = {} ## pickup instance id -> seconds left alone
var _rng := RandomNumberGenerator.new()
func setup(w: Node3D) -> void:
world = w
_rng.seed = w.terrain.noise_seed + 997
func _physics_process(delta: float) -> void:
if enabled:
step(delta)
## Living thieves, coming or going.
func alive() -> Array:
return get_children().filter(func(c): return is_instance_valid(c) and c.has_method("is_dead") and not c.is_dead() and not c.is_queued_for_deletion())
static func is_food(id: String) -> bool:
return ItemDB.get_item(id).has("food")
func night() -> bool:
var h: float = world.day_night.hours()
return h >= 17.5 or h < 6.5
## Someone (alive) within `r` of `pos`.
func watched(pos: Vector3, r: float) -> bool:
for p in world.players():
if not p.survival.dead and p.global_position.distance_to(pos) < r:
return true
return false
func out_of_sight(pos: Vector3) -> bool:
return not watched(pos, OUT_OF_SIGHT)
func still_there(food: Node3D) -> bool:
return food != null and is_instance_valid(food) and not food.is_queued_for_deletion() and not food.taken and int(food.amount) > 0
func _targeted(food: Node3D) -> bool:
for t in alive():
if t.food == food:
return true
return false
func step(dt: float) -> void:
_acc += dt
if _acc < CHECK_EVERY:
return
var since := _acc
_acc = 0.0
var seen := {}
for d in world.all_dropped():
if not is_food(d.item_id):
continue
var k: int = d.get_instance_id()
seen[k] = true
if watched(d.global_position, UNWATCHED):
_unwatched.erase(k)
continue
_unwatched[k] = float(_unwatched.get(k, 0.0)) + since
if float(_unwatched[k]) >= NOTICE_AFTER and not _targeted(d) and alive().size() < MAX_THIEVES:
send(d)
for k in _unwatched.keys():
if not seen.has(k):
_unwatched.erase(k)
## Sends a thief after `food`: a fox at dusk and night, otherwise a crow.
func send(food: Node3D) -> Node3D:
var fox := night()
var fp: Vector3 = food.global_position
var from := Vector3.INF
for _i in range(12):
var a := _rng.randf() * TAU
var p := fp + Vector3(cos(a), 0, sin(a)) * COME_FROM
var ground: float = world.terrain.sample_height(p.x, p.z)
if world.terrain.inside_boundary(p.x, p.z, 6.0) and (not fox or ground > world.terrain.water_level + 0.3) \
and not watched(p, 20.0):
from = p
break
if from == Vector3.INF:
return null
var t: AnimatableBody3D = (FoxScript if fox else CrowScript).new()
t.setup("fox" if fox else "crow", world, from, _rng.randi())
t.thieves = self
t.food = food
t.state = "sneak" if fox else "fly"
add_child(t)
if not fox:
t.global_position.y = world.terrain.sample_height(from.x, from.z) + t.CRUISE
var to := fp - from
t.yaw = atan2(-to.x, -to.z)
t.rotation.y = t.yaw
return t
## Whoever left `food` there (anyone within 60 m) hears about it, once.
func _tell(food_pos: Vector3, text: String) -> void:
for p in world.players():
if p.global_position.distance_to(food_pos) < 60.0:
Actions.tell(p, text)
func landed(crow: Node, food: Node3D) -> void:
_tell(food.global_position, "A crow is pecking at your %s!" % ItemDB.item_name(food.item_id).to_lower())
## A crow takes one. True when that was the last of it.
func peck(crow: Node, food: Node3D) -> bool:
if not still_there(food):
return true
food.amount = int(food.amount) - 1
stolen.emit(crow, food.item_id, 1)
if int(food.amount) <= 0:
world.take_dropped(food)
return true
return false
## A fox takes the lot. Returns what it's carrying.
func grab(fox: Node, food: Node3D) -> Dictionary:
var c := {"id": String(food.item_id), "count": int(food.amount), "extra": food.extra.duplicate()}
_tell(food.global_position, "A fox has run off with your %s!" % ItemDB.item_name(c.id).to_lower())
world.take_dropped(food)
stolen.emit(fox, c.id, c.count)
return c
## A thief left with what it took (or empty-handed).
func gone(t: Node) -> void:
t.queue_free()

View File

@@ -0,0 +1,79 @@
extends "res://scripts/creatures/creature.gd"
## The tutorial's wolf (docs/TUTORIAL_DESIGN.md §2, step "Wolves"): it comes
## out of the dark, circles at the edge of the light for a while, and slinks
## off. It never attacks. When it's gone, `gone` fires and the step ticks.
signal gone
const RING := 11.0
const CIRCLE_SECONDS := 18.0
const LEAVE_AFTER := 40.0 ## this far from you it's gone
var target: Node3D
var _a := 0.0
var _goal_d := 0.0
var _done := false
func begin(t: Node3D) -> void:
target = t
var away: Vector3 = global_position - t.global_position
_a = atan2(away.z, away.x)
state = "stalk"
_state_t = CIRCLE_SECONDS
func display_name() -> String:
return "Wolf"
func interact_prompt(_p: Node) -> String:
return "Wolf (watching you from the dark)"
func _pick_target() -> Node3D:
return target
func _think(dt: float) -> void:
if target == null or not is_instance_valid(target):
_leave()
return
_state_t -= dt
var tp: Vector3 = target.global_position
match state:
"stalk":
_a += dt * 0.35
_go_to(tp + Vector3(cos(_a), 0, sin(_a)) * RING)
if _state_t <= 0.0:
state = "flee"
_state_t = 15.0
"flee":
var away := global_position - tp
away.y = 0.0
steer(atan2(-away.x, -away.z))
_goal_d = 99.0
if global_position.distance_to(tp) > LEAVE_AFTER or _state_t <= 0.0:
_leave()
_:
state = "stalk"
func _go_to(p: Vector3) -> void:
var to := p - global_position
to.y = 0.0
if to.length() > 0.3:
steer(atan2(-to.x, -to.z))
_goal_d = to.length()
func _desired_speed() -> float:
match state:
"stalk": return 0.0 if _goal_d < 1.0 else (3.0 if _goal_d < 6.0 else 6.5)
"flee": return 7.5
return 0.0
func _leave() -> void:
if _done:
return
_done = true
gone.emit()
queue_free()
## (A swipe makes it run.)
func _react(_from: Vector3) -> void:
state = "flee"
_state_t = 10.0

261
scripts/creatures/wolf.gd Normal file
View File

@@ -0,0 +1,261 @@
extends "res://scripts/creatures/creature.gd"
## A wolf: a member of the pack that lives in the den (wolf_pack.gd decides
## what the pack is doing; this decides what one wolf does about it).
##
## Wary pack hunters rather than berserkers:
## - In the den by day they rest, but they defend it. Come within 14 m and
## they get up and watch you; within 8 m (or hit one) and they attack.
## - At night the pack stalks you. They circle at a distance and take turns
## darting in to bite, one wolf at a time.
## - Fire keeps them back: near a lit campfire they circle wide and never
## close in. A held torch makes them hesitate.
## - They can't bite through walls (a line-of-sight check), so a closed hut
## is safe.
## - A wolf on its own is timid: it keeps its distance and backs off.
## - A badly hurt wolf runs, and comes back another night.
##
## The alpha (kind "alpha", wolf moon nights only) is different: it isn't
## afraid of fire, it doesn't take turns, and if it can't get at you it
## goes for your door. A wooden door gives way after a few blows (door.gd);
## an iron-banded one holds. It doesn't run when hurt.
var pack: Node
var slot := 0
var wounded := false ## ran off hurt: stays away until dawn
func is_alpha() -> bool:
return kind == "alpha"
func display_name() -> String:
return def.name
func interact_prompt(p: Node) -> String:
var nm: String = def.name
if is_dead():
return "%s carcass [E] Butcher" % nm
var s := nm
match state:
"rest": s = nm + " (resting)"
"alert", "stalk": s = nm + " (watching you)"
"lunge", "defend": s = nm + " (attacking!)"
"bash": s = nm + " (breaking a door!)"
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
s += " (you need a weapon)"
return s
func _pick_target() -> Node3D:
return pack.target_for(self)
func _think(dt: float) -> void:
var pp: Vector3 = player.global_position
var d := global_position.distance_to(pp)
_state_t -= dt
if state == "flee":
var away := global_position - _threat
steer(atan2(-away.x, -away.z))
if _state_t <= 0.0 and d > 30.0:
state = "home"
return
if wounded and pack.mode == "hunt":
_go_to(pack.home_slot(self))
state = "home"
return
match pack.mode:
"den":
_think_den(pp, d)
"hunt":
if is_alpha():
_think_alpha(dt, pp, d)
elif _aggro > 0.0 and d < 25.0:
_attack(pp, d, "defend")
elif pack.alive_count() <= 1:
_think_alone(pp, d)
else:
_think_hunt(pp, d)
_:
_think_den(pp, d)
## Daytime (and the howling night before hunts begin): rest at the den,
## defend it if you come too close.
func _think_den(pp: Vector3, d: float) -> void:
var den_d: float = pack.den_pos.distance_to(pp)
if (_aggro > 0.0 or den_d < pack.DEFEND_RADIUS) and d < 25.0:
_attack(pp, d, "defend")
return
if den_d < pack.ALERT_RADIUS:
state = "alert"
var to := pp - global_position
target_yaw = atan2(-to.x, -to.z)
return
var home: Vector3 = pack.home_slot(self)
if Vector2(home.x - global_position.x, home.z - global_position.z).length() > 1.2:
state = "home"
_go_to(home)
else:
state = "rest"
target_yaw = pack.den_yaw + (slot - 1.5) * 0.6
## Night, with the pack: circle, and dart in when it's your turn.
func _think_hunt(pp: Vector3, d: float) -> void:
match state:
"lunge":
# Break off first if you've reached a fire or it's taking too long.
if _state_t <= 0.0 or not pack.may_close_in(player):
state = "stalk"
pack.lunge_done(self)
else:
_attack(pp, d, "lunge")
"retreat":
var away := global_position - pp
steer(atan2(-away.x, -away.z))
if _state_t <= 0.0:
state = "stalk"
_:
state = "stalk"
_go_to(pack.circle_point(self))
if pack.request_lunge(self, player):
state = "lunge"
_state_t = 4.0
# --- the alpha ----------------------------------------------------------------
const BASH_STAND := 1.5 ## stands this far out from a door to hit it
const BASH_REACH := 2.4
const DOOR_SEARCH := 12.0 ## doors within this of the target are worth trying
var _door: Node3D = null ## the door it's working on
var _bash_at := Vector3.ZERO
var _door_look := 0.0
var _entry: Node3D = null ## an open doorway it's going in through
var _entry_out := Vector3.ZERO
var _entry_in := Vector3.ZERO
var _entry_stage := 0 ## 0: to the outside of it, 1: through it
## Straight at you if nothing's in the way (fire or no fire). Otherwise the
## nearest closed door near you, and through it. Otherwise prowl round.
func _think_alpha(dt: float, pp: Vector3, d: float) -> void:
if player.survival.dead:
state = "stalk"
_go_to(pack.circle_point(self))
return
if d < 25.0 and _can_reach_player():
_door = null
_attack(pp, d, "defend")
return
_door_look -= dt
if _door != null and (not is_instance_valid(_door) or _door.is_queued_for_deletion() or not _door.blocks()):
_door = null
if _door == null and _door_look <= 0.0:
_door_look = 1.0
_door = pack.door_for(self, player, DOOR_SEARCH)
if _door != null:
# Stand on the side of the door the alpha is on (the outside).
var n: Vector3 = _door.global_basis.z
var side := 1.0 if (global_position - _door.global_position).dot(n) > 0.0 else -1.0
_bash_at = _door.global_position + n * side * BASH_STAND
if _door == null:
# No shut door left: in through an open (or broken) one, if any.
if _entry == null or not is_instance_valid(_entry) or _entry.blocks():
_entry = pack.door_for(self, player, DOOR_SEARCH, true)
if _entry != null:
var n: Vector3 = _entry.global_basis.z
var side := 1.0 if (global_position - _entry.global_position).dot(n) > 0.0 else -1.0
_entry_out = _entry.global_position + n * side * BASH_STAND
_entry_in = _entry.global_position - n * side * 1.2
_entry_stage = 0
if _entry != null:
state = "bash" # (on its way in: shown the same)
var goal: Vector3 = _entry_out if _entry_stage == 0 else _entry_in
if Vector2(goal.x - global_position.x, goal.z - global_position.z).length() < 0.5:
_entry_stage += 1
if _entry_stage > 1:
_entry = null # inside: next think it can reach you
return
_go_to(goal)
return
state = "stalk"
_go_to(pack.circle_point(self))
return
state = "bash"
var flat := Vector2(_bash_at.x - global_position.x, _bash_at.z - global_position.z).length()
if flat > 0.6:
_go_to(_bash_at)
return
_goal_d = 0.0
var to := _door.global_position - global_position
steer(atan2(-to.x, -to.z))
if _attack_cd <= 0.0 and Vector2(to.x, to.z).length() < BASH_REACH:
_attack_cd = def.attack_cd
pack.bash_door(self, _door, float(def.get("door_damage", def.damage)))
## (Near the door it's battering, the door itself isn't an obstacle.)
func _blocked(dir: Vector3) -> bool:
if state == "bash" and _door != null and global_position.distance_to(_bash_at) < 2.5:
return false
if state == "bash" and _entry != null and is_instance_valid(_entry) and global_position.distance_to(_entry.global_position) < 2.8:
return false # (lined up with the doorway: walking through it)
return super._blocked(dir)
## The last wolf of a pack keeps its distance and won't start a fight.
func _think_alone(pp: Vector3, d: float) -> void:
if d < 10.0:
_threat = pp
state = "flee"
_state_t = 3.0
return
state = "stalk"
_go_to(pp + (global_position - pp).normalized() * 18.0)
## Run at the player and bite when in range with nothing in between.
func _attack(pp: Vector3, d: float, as_state: String) -> void:
state = as_state
var to := pp - global_position
steer(atan2(-to.x, -to.z))
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach_player():
_attack_cd = def.attack_cd
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a " + String(def.name).to_lower())
if as_state == "lunge":
state = "retreat"
_state_t = 1.6
pack.lunge_done(self)
## No wall, door or rock between the wolf's jaws and the player.
func _can_reach_player() -> bool:
var from := global_position + Vector3(0, 0.8, 0)
var to: Vector3 = player.global_position + Vector3(0, 1.0, 0)
var q := PhysicsRayQueryParameters3D.create(from, to, 1)
q.exclude = [get_rid(), player.get_rid(), terrain.get_node("TerrainBody").get_rid()]
return get_world_3d().direct_space_state.intersect_ray(q).is_empty()
func _go_to(p: Vector3) -> void:
var to := p - global_position
to.y = 0.0
if to.length() > 0.3:
steer(atan2(-to.x, -to.z))
_goal_d = to.length()
var _goal_d := 0.0
func _desired_speed() -> float:
match state:
"rest", "alert": return 0.0
"home": return 2.2 if _goal_d < 8.0 else 6.0
"stalk": return 0.0 if _goal_d < 1.0 else (3.4 if _goal_d < 7.0 else 7.5)
"lunge", "defend": return def.run
"bash": return 0.0 if _goal_d < 0.6 else (3.0 if _goal_d < 4.0 else def.run)
"retreat": return 6.0
"flee": return def.run
return 0.0
func _react(from: Vector3) -> void:
pack.alert(from)
if is_alpha():
_aggro = 20.0 # (never runs)
return
if hp < def.hp * 0.4:
wounded = true
_threat = from
state = "flee"
_state_t = 6.0
else:
_aggro = 20.0

View File

@@ -0,0 +1,438 @@
extends Node3D
## The wolf pack: when wolves exist, what they're doing, and who attacks.
##
## Schedule (a grace period so you can build first):
## - Days 1-2: the den is empty (old bones only).
## - Day 3: wolves move in. They rest in the den by day and howl at night,
## but don't hunt yet. A toast warns you.
## - Night 4 onward: the pack hunts. It's 2 wolves to start, 3 from day 6,
## 4 from day 9. At dawn they go back to the den. Wolves you killed are
## replaced at the den during the day, when you aren't watching.
##
## Night is 19:30 to 05:30, the same window the bed allows sleeping in. The
## small hours belong to the previous day's night.
##
## While hunting the pack circles you and lets one wolf at a time dart in
## (every few seconds, slower if you hold a torch). Near a lit campfire it
## circles wide around the fire and never closes in.
##
## Every 7th night is a wolf moon (day_night.gd turns the moon red): an
## alpha comes down from the hills with the pack. It leaves at dawn whether
## or not you killed it, and comes back the next wolf moon (wolf.gd).
const WolfScript = preload("res://scripts/creatures/wolf.gd")
const DayNight = preload("res://scripts/world/day_night.gd")
const DEN_DAY := 3 ## wolves arrive in the den
const HUNT_DAY := 4 ## first night they hunt
const ALERT_RADIUS := 14.0 ## by day: closer than this to the den and they watch you
const DEFEND_RADIUS := 8.0 ## ...and closer than this they attack
const FIRE_SAFE := 8.0 ## within this of a lit campfire, wolves won't close in
const RING := 10.0 ## circling radius around you
const FIRE_RING := 14.0 ## circling radius around your fire
const ATTACK_GAP := 4.5 ## seconds between darts (x2.5 with a torch)
var world: Node3D
var den: Node3D
var den_pos := Vector3.ZERO
var den_yaw := 0.0
var mode := "none" ## none | den | hunt
var members: Array = []
var enabled := true
var _rng := RandomNumberGenerator.new()
var _lunger: Node = null
var _gap := 2.0
var _circle_a := 0.0
var _warned := {} ## day -> true once that night's toast was shown
var _howl_left := 0.0
var _audio: AudioStreamPlayer3D
static var _howl: AudioStreamWAV
var alpha: Node = null ## tonight's alpha (wolf moon)
var _alpha_day := -1 ## the night it came
var _told := {} ## "door id:outcome" -> true: door toasts already shown
## Night `day` is a wolf moon (once the pack hunts at all). The same rule as
## day_night.gd's red moon.
func wolf_moon(day: int) -> bool:
return day >= hunt_day and DayNight.wolf_moon_day(day)
func setup(w: Node3D, d: Node3D) -> void:
world = w
den = d
den_pos = d.position
den_yaw = d.facing_out_yaw()
_rng.seed = w.terrain.noise_seed + 779
if DisplayServer.get_name() != "headless":
_audio = AudioStreamPlayer3D.new()
_audio.stream = howl_stream()
_audio.unit_size = 30.0
_audio.max_distance = 400.0
_audio.volume_db = 2.0
add_child(_audio)
## The "day" a moment belongs to: the hours after midnight count as the
## previous day's night.
func effective_day() -> int:
var h: float = world.day_night.hours()
return world.day_night.day - (1 if h < 5.5 else 0)
func is_night() -> bool:
var h: float = world.day_night.hours()
return h >= 19.5 or h < 5.5
## The pack grows 3 and 6 days after it arrives (2, 3, then 4 wolves), plus
## or minus one for a server's difficulty.
func pack_size(day: int) -> int:
if day < den_day:
return 0
var n := 4
if day < den_day + 3:
n = 2
elif day < den_day + 6:
n = 3
return clampi(n + pack_bonus, 1, 5)
## The schedule (a server sets it from its rules; single-player keeps 3/4).
var den_day := DEN_DAY
var hunt_day := HUNT_DAY
var pack_bonus := 0
func alive() -> Array:
var out := []
for m in members:
if is_instance_valid(m) and not m.is_dead():
out.append(m)
return out
func alive_count() -> int:
return alive().size()
func _physics_process(delta: float) -> void:
if enabled:
step(delta)
## Pack-level logic (the wolves run their own step()).
func step(dt: float) -> void:
var day := effective_day()
var night := is_night()
var want := "none"
if day >= den_day:
want = "hunt" if night and day >= hunt_day else "den"
if want != mode:
_enter(want, day)
if mode == "none":
return
if night:
_warn(day)
# Top up the den by day while you're not looking.
if not night and alive_count() < pack_size(day):
fill_den(false)
_gap = maxf(_gap - dt, 0.0)
for t in _gaps.keys():
_gaps[t] = maxf(float(_gaps[t]) - dt, 0.0)
if not is_instance_valid(t):
_gaps.erase(t)
_circle_a += dt * 0.25
if _lunger != null and (not is_instance_valid(_lunger) or _lunger.is_dead() or _lunger.state != "lunge"):
_lunger = null
# At dawn wolves head home; once one is well away from you (out of sight),
# it slips straight back rather than picking its way across the island.
if mode == "den":
for m in alive():
if m.global_position.distance_to(den_pos) > 6.0 and _far_from_everyone(m.global_position, 45.0) \
and _far_from_everyone(den_pos, 35.0):
m.global_position = den.slot_pos(m.slot)
m.wounded = false
m.state = "rest"
if night and day >= den_day:
_howl_left -= dt
if _howl_left <= 0.0:
_howl_left = _rng.randf_range(50.0, 110.0)
howl()
func _enter(m: String, day: int) -> void:
mode = m
if m == "hunt" and wolf_moon(day) and _alpha_day != day:
spawn_alpha(day)
elif m != "hunt":
_alpha_leaves()
if m == "none":
for w in members:
if is_instance_valid(w):
w.queue_free()
members.clear()
return
if m == "den":
for w in alive():
w.wounded = false
if members.is_empty():
fill_den(true)
## Once per night: a toast on the first howling night and the first hunt.
func _warn(day: int) -> void:
if _warned.has(day):
return
_warned[day] = true
if day == den_day:
Actions.tell_all("Wolves are howling up in the hills. They'll come hunting soon.")
elif day == hunt_day:
Actions.tell_all("The wolves are hunting tonight. Stay near a fire or behind closed walls.")
elif wolf_moon(day):
Actions.tell_all("The moon is red: a wolf moon. Something big is hunting with the pack tonight.")
_howl_left = _rng.randf_range(2.0, 6.0)
## Brings the pack up to size, wolves lying in the den. `force` ignores
## whether you're watching.
func fill_den(force: bool) -> void:
var need: int = pack_size(effective_day()) - alive_count()
if need <= 0:
return
if not force and not _far_from_everyone(den_pos, 35.0):
return
members = alive()
var used := {}
for m in members:
used[m.slot] = true
var i := 0
while need > 0 and i < 4:
if not used.has(i):
var w: AnimatableBody3D = WolfScript.new()
w.setup("wolf", world, den.slot_pos(i), _rng.randi())
w.pack = self
w.slot = i
w.state = "rest"
w.yaw = den_yaw + (i - 1.5) * 0.6
w.rotation.y = w.yaw
add_child(w)
members.append(w)
need -= 1
i += 1
## The alpha comes out of the den's mouth with the pack.
func spawn_alpha(day: int) -> Node:
_alpha_leaves()
_alpha_day = day
var w: AnimatableBody3D = WolfScript.new()
w.setup("alpha", world, den.mouth_pos(), _rng.randi())
w.pack = self
w.slot = 4
w.state = "stalk"
w.yaw = den_yaw
w.rotation.y = w.yaw
add_child(w)
members.append(w)
alpha = w
return w
## At dawn it goes back into the hills (a carcass stays for butchering).
func _alpha_leaves() -> void:
if alpha != null and is_instance_valid(alpha) and not alpha.is_dead():
members.erase(alpha)
alpha.queue_free()
alpha = null
func alpha_alive() -> bool:
return alpha != null and is_instance_valid(alpha) and not alpha.is_dead()
## The door the alpha `w` should go for to get at `target`: the closed door
## nearest `target` (within `r`), a little biased toward ones nearer `w`.
## `open_ones`: the nearest open or broken door instead (a way in).
func door_for(w: Node3D, target: Node3D, r: float, open_ones: bool = false) -> Node3D:
var best: Node3D = null
var best_s := INF
var tp: Vector3 = target.global_position
for p in world.build.build_pieces():
if not p.has_method("bash") or p.blocks() == open_ones:
continue
var d: float = p.global_position.distance_to(tp)
if d > r:
continue
var score: float = d + 0.3 * p.global_position.distance_to(w.global_position)
if score < best_s:
best_s = score
best = p
return best
## The alpha hits `door`. Tells the player it's after (once per door and
## outcome); on a server, every game hears the door broke.
func bash_door(w: Node, door: Node3D, damage: float) -> void:
var res: String = door.bash(damage)
var key := "%d:%s" % [door.get_instance_id(), res]
if not _told.has(key):
_told[key] = true
match res:
"hit": Actions.tell(w.player, "Something is battering at your door!")
"held": Actions.tell(w.player, "Something is battering at your door, but the iron bands hold.")
"broke": Actions.tell(w.player, "The alpha has smashed your door in!")
if res == "broke" and world.server_mode:
WorldNet.piece_state(door)
func home_slot(w: Node) -> Vector3:
return den.slot_pos(w.slot)
func _far_from_everyone(pos: Vector3, r: float) -> bool:
for p in world.players():
if p.global_position.distance_to(pos) < r:
return false
return true
# --- who the pack goes after ----------------------------------------------------
# In single-player that's you. On a server the pack splits between the
# players it's hunting: wolf 1 takes the first, wolf 2 the second, and so on,
# preferring players away from a fire (you can't be got at by a fire, so
# they'd rather go for someone who isn't). Each target gets their own circle
# and their own one-wolf-at-a-time darts.
var _targets: Array = []
var _target_t := 0.0
## Who wolf `w` is after right now (null: nobody about).
func target_for(w: Node) -> Node3D:
if not world.server_mode:
return world.player
if mode != "hunt":
return world.nearest_player((w as Node3D).global_position)
_refresh_targets()
if _targets.is_empty():
return null
var i := alive().find(w)
return _targets[maxi(i, 0) % _targets.size()]
## Players worth hunting, nearest the den first: those away from a fire if
## there are any, otherwise everyone (they'll circle the fire).
func _refresh_targets() -> void:
var now := Time.get_ticks_msec() / 1000.0
if now - _target_t < 1.0 and _targets.all(func(p): return is_instance_valid(p) and not p.survival.dead):
return
_target_t = now
var everyone: Array = world.players()
var open := everyone.filter(func(p): return fire_near(p) == null)
_targets = open if not open.is_empty() else everyone
_targets.sort_custom(func(a, b): return a.global_position.distance_to(den_pos) < b.global_position.distance_to(den_pos))
func _p(p: Node3D) -> Node3D:
return p if p != null else world.player
## The lit campfire `p` is sheltering by, if any.
func fire_near(p: Node3D) -> Node3D:
var pp: Vector3 = _p(p).global_position
for f in get_tree().get_nodes_in_group("station:campfire"):
if f.station_active() and (f as Node3D).global_position.distance_to(pp) < FIRE_SAFE:
return f
return null
## (Single-player name, used by tests.)
func player_fire() -> Node3D:
return fire_near(world.player)
func _torch(p: Node3D = null) -> bool:
var who := _p(p)
return who.get("hands") != null and who.hands.held_heat() > 0.0
## Wolves only close in when you're away from a fire.
func may_close_in(p: Node3D = null) -> bool:
return fire_near(p) == null and not _p(p).survival.dead
## The wolves going after the same player as `w`.
func _hunting_with(w: Node) -> Array:
var t := target_for(w)
return alive().filter(func(m): return target_for(m) == t)
## Where wolf `w` should be while circling.
func circle_point(w: Node) -> Vector3:
var t := _p(target_for(w))
var fire := fire_near(t)
var center: Vector3 = fire.global_position if fire != null else t.global_position
var r := FIRE_RING if fire != null else RING * (1.3 if _torch(t) else 1.0)
var group := _hunting_with(w)
var n := maxi(group.size(), 1)
var a := _circle_a + TAU * float(maxi(group.find(w), 0)) / n
return center + Vector3(cos(a), 0, sin(a)) * r
## Darting in: one wolf at a time per player being hunted.
var _lungers: Dictionary = {} ## target -> wolf
var _gaps: Dictionary = {} ## target -> seconds until the next dart
## A wolf asks for its turn to dart in at `p`.
func request_lunge(w: Node, p: Node3D = null) -> bool:
var t := _p(p)
if not world.server_mode:
if _lunger != null or _gap > 0.0 or not may_close_in(t) or alive_count() < 2:
return false
else:
var cur: Node = _lungers.get(t)
if cur != null and is_instance_valid(cur) and not cur.is_dead() and cur.state == "lunge":
return false
if float(_gaps.get(t, 0.0)) > 0.0 or not may_close_in(t) or alive_count() < 2:
return false
if w.global_position.distance_to(t.global_position) > RING * 1.8:
return false
_lunger = w
_lungers[t] = w
return true
func lunge_done(w: Node) -> void:
if _lunger == w:
_lunger = null
if not world.server_mode:
_gap = ATTACK_GAP * (2.5 if _torch() else 1.0) * _rng.randf_range(0.8, 1.3)
return
var t: Node3D = target_for(w) if w != null and is_instance_valid(w) else null
for k in _lungers.keys():
if _lungers[k] == w:
_lungers.erase(k)
if t != null:
_gaps[t] = ATTACK_GAP * (2.5 if _torch(t) else 1.0) * _rng.randf_range(0.8, 1.3)
## A wolf was hit: the whole pack turns on you.
func alert(_from: Vector3) -> void:
for m in alive():
m._aggro = maxf(m._aggro, 20.0)
func howl() -> void:
var live := alive()
if live.is_empty():
return
if world.server_mode:
CreatureNet.howl((live[0] as Node3D).global_position + Vector3(0, 1, 0)) # everyone hears it
return
Hints.event("howl")
if _audio == null:
return
var from: Node3D = alpha if alpha_alive() else live[0] # (the alpha's howl is deeper)
_audio.global_position = from.global_position + Vector3(0, 1, 0)
_audio.pitch_scale = _rng.randf_range(0.68, 0.76) if alpha_alive() else _rng.randf_range(0.9, 1.1)
_audio.play()
## A synthesised wolf howl: a rising then falling tone with vibrato and a
## breathy edge, ~2.8 s. Built once; no audio files.
static func howl_stream() -> AudioStreamWAV:
if _howl != null:
return _howl
var rate := 22050
var secs := 2.8
var n := int(rate * secs)
var data := PackedByteArray()
data.resize(n * 2)
var phase := 0.0
var rng := RandomNumberGenerator.new()
rng.seed = 5
var lp := 0.0
for i in range(n):
var t := float(i) / rate
var u := t / secs
# Pitch: swoop up, hold, sag at the end.
var f := 330.0 + 260.0 * smoothstep(0.0, 0.25, u) - 140.0 * smoothstep(0.65, 1.0, u)
f += sin(t * TAU * 5.5) * 6.0 * smoothstep(0.2, 0.4, u)
phase += TAU * f / rate
var env := smoothstep(0.0, 0.08, u) * (1.0 - smoothstep(0.75, 1.0, u))
lp += 0.15 * ((rng.randf() * 2.0 - 1.0) - lp)
var s := (sin(phase) * 0.7 + sin(phase * 2.0) * 0.18 + sin(phase * 3.0) * 0.06 + lp * 0.12) * env * 0.6
data.encode_s16(i * 2, int(clampf(s, -1.0, 1.0) * 32000.0))
var wav := AudioStreamWAV.new()
wav.format = AudioStreamWAV.FORMAT_16_BITS
wav.mix_rate = rate
wav.stereo = false
wav.data = data
_howl = wav
return wav

161
scripts/fishing/bobber.gd Normal file
View File

@@ -0,0 +1,161 @@
extends Node3D
## A cast fishing float.
##
## flying -> it arcs out from the rod tip.
## floating -> it landed on deep enough water; a fish bites after a while
## (sooner at dawn and dusk, and in the rain).
## biting -> the float dips; you have BITE_WINDOW seconds to reel in
## (Actions.reel), or the fish gets away and you wait again.
## landed -> it fell on dry ground: nothing will bite. Reel in and try again.
## Walking too far away snaps the line.
##
## A line is drawn from the rod tip each frame (hands.gd gives the tip).
const BITE_WINDOW := 1.5
const LINE_LENGTH := 26.0
var water_type := "" ## "lake", "sea"... (world.water_at)
var table: Dictionary = {} ## that water's fishing table (ItemDB.water_fishing)
var world: Node3D
var player: Node3D
var state := "flying"
var velocity := Vector3.ZERO
var fish := "" ## what's on the line while biting
var wait_left := 0.0
var bite_left := 0.0
var rng := RandomNumberGenerator.new()
var tip := Vector3.ZERO ## rod tip, world space (set by hands.gd)
var _t := 0.0
var _mesh: MeshInstance3D
var _line: MeshInstance3D
var _imesh: ImmediateMesh
func setup(w: Node3D, p: Node3D, from: Vector3, vel: Vector3, seed: int) -> void:
world = w
player = p
velocity = vel
position = from
tip = from
rng.seed = seed
_mesh = MeshInstance3D.new()
_mesh.mesh = ItemDB.mesh_for("bobber")
_mesh.material_override = ItemDB.item_material()
_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(_mesh)
_imesh = ImmediateMesh.new()
_line = MeshInstance3D.new()
_line.mesh = _imesh
_line.top_level = true
_line.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var lm := StandardMaterial3D.new()
lm.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
lm.albedo_color = Color(0.9, 0.9, 0.85, 0.7)
lm.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_line.material_override = lm
add_child(_line)
## Online, our game shows the server's float (creature_net.gd): it doesn't
## run any fishing itself, it's just moved and shown.
var puppet := false
func _physics_process(delta: float) -> void:
if puppet:
_t += delta
_mesh.position.y = (-0.06 + sin(_t * 18.0) * 0.03) if state == "biting" else (sin(_t * 2.2) * 0.012 if state == "floating" else 0.0)
return
step(delta)
func _process(_delta: float) -> void:
_draw_line()
func _dawn_dusk() -> bool:
var h: float = world.day_night.hours()
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.
func _next_wait() -> float:
var mods: Dictionary = ItemDB.fishing.modifiers
var w: Array = table.get("bite_wait", [5.0, 14.0])
var k := 1.0
if _dawn_dusk():
k *= float(mods.get("dawn_dusk_wait", 0.6)) # fish feed at dawn and dusk
if world.weather.precip > 0.3:
k *= float(mods.get("rain_wait", 0.8))
return rng.randf_range(w[0], w[1]) * k
## A weighted pick from this water's fish (dawn/dusk weights when they apply).
func _pick_fish() -> String:
var fish: Array = table.get("fish", [])
var key := "dawn_dusk" if _dawn_dusk() else "weight"
var total := 0.0
for f in fish:
total += float(f[key])
var roll := rng.randf() * total
for f in fish:
roll -= float(f[key])
if roll <= 0.0:
return f.id
return fish[-1].id if not fish.is_empty() else ""
func step(dt: float) -> void:
_t += dt
var terrain: Node3D = world.terrain
if global_position.distance_to(player.global_position) > LINE_LENGTH + 4.0:
Actions.tell(player, "The line snapped")
world.end_fishing(player)
return
match state:
"flying":
velocity.y -= 9.8 * dt
var p := global_position + velocity * dt
var ground: float = terrain.sample_height(p.x, p.z)
var water: Dictionary = world.water_at(p.x, p.z)
if not water.is_empty() and p.y <= float(water.surface):
p.y = water.surface
water_type = water.type
table = ItemDB.water_fishing(water_type)
if table.is_empty() or table.fish.is_empty():
state = "landed"
Actions.tell(player, "Nothing to catch here")
elif float(water.depth) < float(table.min_depth):
state = "landed"
Actions.tell(player, "Too shallow here: cast further out")
else:
state = "floating"
wait_left = _next_wait()
elif p.y <= ground + 0.03:
p.y = ground + 0.03
state = "landed"
Actions.tell(player, "Cast into the water")
global_position = p
"floating":
wait_left -= dt
_mesh.position.y = sin(_t * 2.2) * 0.012
if wait_left <= 0.0:
state = "biting"
fish = _pick_fish()
bite_left = BITE_WINDOW
Actions.tell(player, "Bite! Click to reel in")
"biting":
bite_left -= dt
# The float bobs hard and goes under.
_mesh.position.y = -0.06 + sin(_t * 18.0) * 0.03
if bite_left <= 0.0:
state = "floating"
fish = ""
wait_left = _next_wait() * float(ItemDB.fishing.modifiers.get("missed_wait", 1.3))
_mesh.position.y = 0.0
Actions.tell(player, "It got away")
func _draw_line() -> void:
_imesh.clear_surfaces()
_imesh.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
var a := tip
var b := global_position + Vector3(0, 0.1, 0)
for i in range(13):
var k := i / 12.0
var p := a.lerp(b, k)
p.y -= sin(k * PI) * 0.06 * a.distance_to(b) * (0.3 if state == "biting" else 1.0) # slack
_imesh.surface_add_vertex(p)
_imesh.surface_end()

View File

@@ -0,0 +1,126 @@
extends Node3D
## Fish you can see in the lake, so it's clear there's something to catch.
## Purely visual (the catch comes from bobber.gd).
##
## - A few swim slow loops just under the surface over water at least 0.8 m
## deep, including the shelf near the shore where you'll be standing.
## - Every few seconds one jumps: a leap out of the water and a spreading
## splash ring. That's the cue that tells you where to cast.
const SCALE := 1.6 ## drawn bigger than the item so they read at a distance
var terrain: Node3D
var _fish: Array = [] ## [MeshInstance3D, centre, radius, speed, phase, depth]
var _rng := RandomNumberGenerator.new()
var _jump_left := 3.0
var _jumping := -1 ## index of the fish mid-leap, or -1
var _jump_t := 0.0
var _rings: Array = [] ## [MeshInstance3D, age]
static var _ring_mesh: Mesh
func setup(t: Node3D, count: int = 14) -> void:
terrain = t
_rng.seed = t.noise_seed + 501
var lake: Vector2 = t.lake_center
var tries := 0
while _fish.size() < count and tries < 600:
tries += 1
var a := _rng.randf() * TAU
# Bias toward the outer, shallower ring so some swim near the shore.
var r: float = sqrt(_rng.randf()) * t.lake_radius * 0.7
var c := Vector2(lake.x + cos(a) * r, lake.y + sin(a) * r)
var loop := _rng.randf_range(1.2, 3.0)
var ok := true
for k in range(8):
var q := c + Vector2(cos(k * TAU / 8.0), sin(k * TAU / 8.0)) * loop
if t.sample_height(q.x, q.y) > t.water_level - 0.8:
ok = false
break
if not ok:
continue
var mi := MeshInstance3D.new()
mi.mesh = ItemDB.mesh_for("trout" if _rng.randf() < 0.3 else "perch")
mi.material_override = ItemDB.item_material()
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
mi.scale = Vector3.ONE * SCALE
add_child(mi)
_fish.append([mi, c, loop, _rng.randf_range(0.25, 0.5) * (1.0 if _rng.randf() < 0.5 else -1.0),
_rng.randf() * TAU, _rng.randf_range(0.2, 0.35)])
## Where fish `i` is in its loop at time `t`.
func _swim_pos(i: int, t: float) -> Vector3:
var f: Array = _fish[i]
var a: float = t * f[3] + f[4]
var p: Vector2 = (f[1] as Vector2) + Vector2(cos(a), sin(a)) * float(f[2])
return Vector3(p.x, terrain.water_level - f[5] + sin(t * 1.3 + f[4]) * 0.04, p.y)
func _process(delta: float) -> void:
var t := Time.get_ticks_msec() / 1000.0
for i in range(_fish.size()):
if i == _jumping:
continue
var f: Array = _fish[i]
var mi: MeshInstance3D = f[0]
mi.position = _swim_pos(i, t)
var a: float = t * f[3] + f[4]
# Face along the loop (fish mesh's head is +X), with a little wiggle.
var tangent := Vector2(-sin(a), cos(a)) * signf(f[3])
mi.rotation = Vector3(0, atan2(-tangent.y, tangent.x) + sin(t * 6.0 + f[4]) * 0.15, 0)
_update_jump(delta, t)
_update_rings(delta)
func _update_jump(dt: float, t: float) -> void:
if _fish.is_empty():
return
if _jumping < 0:
_jump_left -= dt
if _jump_left <= 0.0:
_jumping = _rng.randi() % _fish.size()
_jump_t = 0.0
_ring(_swim_pos(_jumping, t))
return
# A 0.8 s arc, half a metre up, nose up then nose down.
_jump_t += dt
var k := _jump_t / 0.8
var mi: MeshInstance3D = _fish[_jumping][0]
var base := _swim_pos(_jumping, t)
mi.position = base + Vector3(0, sin(clampf(k, 0.0, 1.0) * PI) * 0.55 + 0.25, 0)
mi.rotation.z = lerpf(1.0, -1.0, clampf(k, 0.0, 1.0))
if k >= 1.0:
_ring(base)
mi.rotation.z = 0.0
_jumping = -1
_jump_left = _rng.randf_range(3.0, 8.0)
## A splash ring that spreads and fades on the surface.
func _ring(at: Vector3) -> void:
if _ring_mesh == null:
var tm := TorusMesh.new()
tm.inner_radius = 0.42
tm.outer_radius = 0.5
tm.rings = 24
tm.ring_segments = 4
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.albedo_color = Color(0.9, 0.95, 1.0, 0.8)
tm.material = m
_ring_mesh = tm
var mi := MeshInstance3D.new()
mi.mesh = _ring_mesh
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
mi.position = Vector3(at.x, terrain.water_level + 0.02, at.z)
mi.scale = Vector3(0.4, 0.05, 0.4)
add_child(mi)
_rings.append([mi, 0.0])
func _update_rings(dt: float) -> void:
for r in _rings.duplicate():
r[1] += dt
var mi: MeshInstance3D = r[0]
var s: float = 0.4 + r[1] * 1.6
mi.scale = Vector3(s, 0.05, s)
mi.transparency = clampf(r[1] / 1.6, 0.0, 1.0)
if r[1] >= 1.6:
mi.queue_free()
_rings.erase(r)

View File

@@ -18,6 +18,16 @@ const IRON := Color(0.30, 0.30, 0.32)
const ASH := Color(0.18, 0.17, 0.16)
const BINDING := Color(0.75, 0.68, 0.48)
const FIBER := Color(0.45, 0.55, 0.22)
const FIBER_PLANT := Color(0.68, 0.70, 0.36) ## paler than the grass, so it shows
const FIBER_SEED := Color(0.80, 0.68, 0.40)
const BERRY := Color(0.62, 0.08, 0.16)
const MUSH_CAP := Color(0.72, 0.30, 0.18)
const MUSH_ROAST := Color(0.40, 0.24, 0.12)
const MUSH_STEM := Color(0.88, 0.84, 0.74)
const CLOTH := Color(0.60, 0.50, 0.32)
const SOIL := Color(0.30, 0.22, 0.15)
static var _cache := {}
static func build(key: String) -> ArrayMesh:
@@ -35,6 +45,89 @@ static func build(key: String) -> ArrayMesh:
"campfire": m = _campfire()
"workbench": m = _workbench()
"chest": m = _chest()
"fiber": m = _fiber()
"berries": m = _berries()
"mushroom": m = _mushroom(MUSH_CAP)
"roasted_mushroom": m = _mushroom(MUSH_ROAST)
"torch": m = _torch()
"grave": m = _grave()
"wood_floor": m = _floor()
"wood_wall": m = _wall()
"wood_door": m = _door(true)
"wood_door_frame": m = _door(false)
"wood_door_panel": m = _door_panel()
"wood_roof": m = _roof()
"wood_gable": m = _gable()
"bed": m = _bed()
"raw_meat": m = _meat(MEAT_RAW)
"cooked_meat": m = _meat(MEAT_COOKED)
"hide": m = _hide()
"feather": m = _feather()
"spear": m = _spear()
"bow": m = _bow()
"arrow": m = _arrow()
"hide_cloak": m = _cloak()
"wolf_pelt": m = _pelt(WOLF_FUR)
"wolf_cloak": m = _cloak(WOLF_FUR)
"fishing_rod": m = _rod()
"bobber": m = _bobber()
"perch": m = _fish(PERCH, 0.16, false)
"trout": m = _fish(TROUT, 0.24, false)
"grilled_perch": m = _fish(PERCH, 0.16, true)
"grilled_trout": m = _fish(TROUT, 0.24, true)
"fiber_plant": m = _fiber_plant()
"iron_nugget": m = _pebble(61, RUST, Vector3(0.07, 0.05, 0.06), 0.6)
"iron_ore": m = _ore()
"clay": m = _pebble(67, CLAY, Vector3(0.12, 0.07, 0.1), 0.12)
"charcoal": m = _charcoal()
"iron_bar": m = _iron_bar()
"nails": m = _nails()
"iron_axe": m = _iron_axe()
"iron_pickaxe": m = _iron_pickaxe()
"iron_spear": m = _iron_spear()
"iron_arrow": m = _arrow(IRON_BRIGHT)
"lantern": m = _lantern()
"charcoal_kiln": m = _kiln()
"smelter": m = _smelter()
"forge": m = _forge()
"cut_stone": m = _cut_stone()
"stonecutter": m = _stonecutter()
"stone_floor": m = _stone_floor()
"stone_wall": m = _stone_wall()
"stone_pillar": m = _stone_pillar()
"stone_stairs": m = _stairs(true)
"stone_window": m = _stone_window()
"stone_doorway": m = _stone_doorway(true)
"stone_doorway_frame": m = _stone_doorway(false)
"stone_hearth": m = _hearth()
"wood_stairs": m = _stairs(false)
"wood_window": m = _wood_window()
"wood_pillar": m = _wood_pillar()
"palisade": m = _palisade()
"palisade_gate": m = _palisade_gate(true)
"palisade_gate_frame": m = _palisade_gate(false)
"iron_door": m = _iron_door(true)
"iron_door_frame": m = _door(false)
"iron_door_panel": m = _iron_door(false)
"alpha_pelt": m = _pelt(ALPHA_FUR)
"alpha_cloak": m = _cloak(ALPHA_FUR)
"wolf_fang": m = _fang()
"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)
"fur_rug": m = _rug()
"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)
"salt": m = _salt()
"smoking_rack": m = _smoking_rack()
"stag_antler": m = _antler()
"stag_trophy": m = _stag_trophy()
"antler_pickaxe": m = _antler_pickaxe()
"smoked_meat": m = _meat(Color(0.36, 0.17, 0.1))
"salted_meat": m = _meat(Color(0.68, 0.42, 0.38))
"smoked_fish": m = _fish(Color(0.72, 0.5, 0.22), 0.2, true)
"salted_fish": m = _fish(Color(0.75, 0.73, 0.66), 0.2, false)
"berry_bush": m = _berry_bush(true)
"berry_bush_bare": m = _berry_bush(false)
_:
push_warning("item_meshes: no mesh for '%s', using a stone" % key)
m = _pebble(1, Color.MAGENTA, Vector3.ONE * 0.15)
@@ -158,3 +251,862 @@ static func _chest() -> ArrayMesh:
_box(st, Vector3(sx, 0.36, 0), Vector3(0.07, 0.74, 0.76), IRON)
_box(st, Vector3(0, 0.55, 0.37), Vector3(0.12, 0.14, 0.03), IRON)
return P._commit(st)
static func _fiber() -> ArrayMesh:
var st := P._begin()
var r := _rng(41)
for i in range(7):
var a := r.randf_range(-0.25, 0.25)
var b := Vector3(r.randf_range(-0.03, 0.03), 0.02, r.randf_range(-0.03, 0.03))
P._cylinder(st, b + Vector3(-0.22, 0, 0), b + Vector3(0.22, 0.03 + a * 0.1, a), 0.008, 0.005, 3, P._mul(FIBER, r.randf_range(0.8, 1.15)), r)
_box(st, Vector3(0, 0.03, 0), Vector3(0.05, 0.05, 0.08), CLOTH)
return P._commit(st)
## A wild flax clump you pick for fibre: tall pale stalks fanning out of the
## grass, each with a seed head, so it stands out above the meadow.
static func _fiber_plant() -> ArrayMesh:
var st := P._begin()
var r := _rng(53)
for i in range(20):
var a := TAU * i / 20.0 + r.randf_range(-0.2, 0.2)
var lean := r.randf_range(0.12, 0.4)
var h := r.randf_range(1.0, 1.45)
var base := Vector3(cos(a), 0, sin(a)) * r.randf_range(0.03, 0.12)
var top := base + Vector3(cos(a) * lean, h, sin(a) * lean)
var col := P._mul(FIBER_PLANT, r.randf_range(0.85, 1.15))
P._cylinder(st, base, top, 0.022, 0.009, 4, col, r)
P._cylinder(st, top, top + (top - base).normalized() * 0.14, 0.032, 0.006, 4, P._mul(FIBER_SEED, r.randf_range(0.9, 1.1)), r)
return P._commit(st)
static func _berries() -> ArrayMesh:
var st := P._begin()
var r := _rng(43)
var n := FastNoiseLite.new()
n.seed = 43
for i in range(9):
var c := Vector3(r.randf_range(-0.07, 0.07), 0.03 + r.randf_range(0.0, 0.05), r.randf_range(-0.07, 0.07))
P._blob(st, c, Vector3.ONE * r.randf_range(0.025, 0.035), n, 0.1, P._mul(BERRY, r.randf_range(0.8, 1.2)), r, 0)
P._blob(st, Vector3(0, 0.08, 0), Vector3(0.05, 0.012, 0.03), n, 0.2, P.BUSH, r, 0)
return P._commit(st)
static func _mushroom(cap: Color) -> ArrayMesh:
var st := P._begin()
var r := _rng(47)
var n := FastNoiseLite.new()
n.seed = 47
P._cylinder(st, Vector3(0, -0.02, 0), Vector3(0, 0.1, 0), 0.025, 0.02, 6, MUSH_STEM, r, -1.0, MUSH_STEM)
P._blob(st, Vector3(0, 0.11, 0), Vector3(0.075, 0.04, 0.075), n, 0.15, cap, r, 1)
return P._commit(st)
static func _torch() -> ArrayMesh:
var st := P._begin()
var r := _rng(53)
_handle(st, 0.5, r)
P._cylinder(st, Vector3(0, 0.42, 0), Vector3(0, 0.58, 0), 0.045, 0.05, 6, CLOTH, r, -1.0, ASH)
return P._commit(st)
static func _grave() -> ArrayMesh:
var st := P._begin()
var r := _rng(59)
var n := FastNoiseLite.new()
n.seed = 59
P._blob(st, Vector3(0, 0.0, 0), Vector3(0.5, 0.22, 0.8), n, 0.15, SOIL, r, 1)
_box(st, Vector3(0, 0.55, -0.62), Vector3(0.08, 1.0, 0.06), PLANK_DARK)
_box(st, Vector3(0, 0.8, -0.62), Vector3(0.5, 0.08, 0.06), PLANK_DARK)
for i in range(4):
var c := Vector3(r.randf_range(-0.35, 0.35), 0.15, r.randf_range(-0.6, 0.6))
P._blob(st, c, Vector3.ONE * r.randf_range(0.07, 0.12), n, 0.3, STONE, r, 0)
return P._commit(st)
## World berry bush (not an item). `ripe` adds the berries.
static func _berry_bush(ripe: bool) -> ArrayMesh:
var st := P._begin()
var r := _rng(61)
var n := FastNoiseLite.new()
n.seed = 61
n.frequency = 2.0
var leaf := P._mul(P.BUSH, 1.1)
var blobs: Array[Vector3] = []
for i in range(4):
var c := Vector3(r.randf_range(-0.4, 0.4), r.randf_range(0.3, 0.55), r.randf_range(-0.4, 0.4))
var rad := r.randf_range(0.35, 0.5)
P._blob(st, c, Vector3(rad, rad * 0.8, rad), n, 0.3, P._mul(leaf, r.randf_range(0.85, 1.1)), r, 1)
blobs.append(c + Vector3(0, rad * 0.4, 0))
if ripe:
for c in blobs:
for k in range(6):
var d := Vector3(r.randf_range(-1, 1), r.randf_range(0.2, 1), r.randf_range(-1, 1)).normalized() * 0.4
P._blob(st, c + d, Vector3.ONE * 0.045, n, 0.1, BERRY, r, 0)
return P._commit(st)
# --- building pieces ----------------------------------------------------------
# Dimensions must match the colliders in scripts/building/build_piece.gd.
const BEAM := Color(0.40, 0.27, 0.15)
const SHINGLE := Color(0.46, 0.33, 0.20)
const THATCH := Color(0.62, 0.52, 0.30)
const FUR := Color(0.45, 0.30, 0.20)
const LINEN := Color(0.80, 0.76, 0.64)
## Per-board tint so planks don't read as one flat sheet.
static func _board(r: RandomNumberGenerator, base: Color = PLANK) -> Color:
return P._mul(base, r.randf_range(0.82, 1.08))
## 2 x 2 m, top surface at y = 0, 0.2 m deep.
static func _floor() -> ArrayMesh:
var st := P._begin()
var r := _rng(71)
for i in range(5):
_box(st, Vector3(-0.8 + 0.4 * i, -0.05, 0), Vector3(0.385, 0.1, 2.0), _board(r))
for z in [-0.85, 0.0, 0.85]:
_box(st, Vector3(0, -0.15, z), Vector3(2.0, 0.1, 0.22), BEAM)
return P._commit(st)
## Horizontal boards between two posts, 2 x 2 m, 0.2 m thick, bottom centre at origin.
static func _wall_body(st: SurfaceTool, r: RandomNumberGenerator, x0: float, x1: float, y0: float, y1: float) -> void:
var rows := maxi(roundi((y1 - y0) / 0.31), 1)
var h := (y1 - y0) / rows
for i in range(rows):
var y := y0 + h * (i + 0.5)
_box(st, Vector3((x0 + x1) * 0.5, y, 0), Vector3(x1 - x0, h - 0.015, 0.12), _board(r))
static func _wall() -> ArrayMesh:
var st := P._begin()
var r := _rng(73)
_wall_body(st, r, -0.84, 0.84, 0.1, 1.9)
for x in [-0.92, 0.92]:
_box(st, Vector3(x, 1.0, 0), Vector3(0.16, 2.0, 0.2), BEAM)
for y in [0.06, 1.94]:
_box(st, Vector3(0, y, 0), Vector3(1.68, 0.12, 0.2), BEAM)
return P._commit(st)
## Door frame (1.1 x 1.9 m opening); `with_panel` adds the closed door, for the
## inventory icon and the placement ghost.
static func _door(with_panel: bool) -> ArrayMesh:
var st := P._begin()
var r := _rng(79)
for side in [-1.0, 1.0]:
_wall_body(st, r, side * 0.55 if side > 0 else -0.84, 0.84 if side > 0 else -0.55, 0.1, 1.9)
_box(st, Vector3(side * 0.92, 1.0, 0), Vector3(0.16, 2.0, 0.2), BEAM)
_box(st, Vector3(side * 0.6, 0.95, 0), Vector3(0.1, 1.9, 0.2), BEAM) # jamb
_box(st, Vector3(0, 1.95, 0), Vector3(1.68, 0.1, 0.2), BEAM)
_box(st, Vector3(-0.7, 0.06, 0), Vector3(0.28, 0.12, 0.2), BEAM)
_box(st, Vector3(0.7, 0.06, 0), Vector3(0.28, 0.12, 0.2), BEAM)
if with_panel:
_door_panel_into(st, Vector3(-0.54, 0, 0))
return P._commit(st)
## The door leaf, hinge at the origin, spanning +X.
static func _door_panel() -> ArrayMesh:
var st := P._begin()
_door_panel_into(st, Vector3.ZERO)
return P._commit(st)
static func _door_panel_into(st: SurfaceTool, o: Vector3) -> void:
var r := _rng(83)
for i in range(4):
_box(st, o + Vector3(0.135 + 0.27 * i, 0.95, 0), Vector3(0.26, 1.86, 0.07), _board(r))
for y in [0.35, 1.55]:
_box(st, o + Vector3(0.54, y, 0.05), Vector3(0.96, 0.12, 0.04), BEAM)
_box(st, o + Vector3(0.54, 0.95, 0.05), Vector3(0.1, 1.2, 0.04), BEAM, Basis(Vector3.BACK, 0.62))
_box(st, o + Vector3(0.95, 1.0, 0.07), Vector3(0.05, 0.14, 0.05), IRON)
_box(st, o + Vector3(0.95, 1.0, -0.07), Vector3(0.05, 0.14, 0.05), IRON)
## Sloped roof: low edge centre at the origin, rising 1 m over 2 m toward -Z,
## with a short eave hanging past the low edge.
static func _roof() -> ArrayMesh:
var st := P._begin()
var r := _rng(89)
var a := 0.4636476
var b := Basis(Vector3.RIGHT, a)
var n := Vector3(0, cos(a), sin(a))
var along := Vector3(0, sin(a), -cos(a)) # up the slope
var length := sqrt(5.0)
# Boarded deck, then overlapping rows of shingles on top.
_box(st, along * (length * 0.5 - 0.15) + n * 0.03, Vector3(2.0, 0.06, length + 0.3), _board(r, BEAM), b)
var rows := 6
for i in range(rows):
var s := -0.3 + (length + 0.3) * (i + 0.5) / rows
var tilt := Basis(Vector3.RIGHT, a + 0.06)
_box(st, along * s + n * 0.085, Vector3(2.04, 0.05, (length + 0.3) / rows + 0.1), _board(r, SHINGLE), tilt)
return P._commit(st)
## Triangular gable end: 0 m tall at -X, 1 m tall at +X.
static func _gable() -> ArrayMesh:
var st := P._begin()
var r := _rng(97)
for i in range(3):
var y0 := i / 3.0
var x0 := 2.0 * y0 - 1.0 + 0.1
_box(st, Vector3((x0 + 0.92) * 0.5, y0 + 1.0 / 6.0, 0), Vector3(0.92 - x0, 1.0 / 3.0 - 0.015, 0.12), _board(r))
_box(st, Vector3(0.92, 0.5, 0), Vector3(0.16, 1.0, 0.2), BEAM)
_box(st, Vector3(0, 0.06, 0), Vector3(2.0, 0.12, 0.2), BEAM)
return P._commit(st)
## Bed: 1 x 2.1 m frame with a straw mattress, pillow and fur blanket.
static func _bed() -> ArrayMesh:
var st := P._begin()
var r := _rng(101)
for sx in [-0.45, 0.45]:
for sz in [-0.98, 0.98]:
_box(st, Vector3(sx, 0.22, sz), Vector3(0.1, 0.44, 0.1), BEAM)
_box(st, Vector3(sx, 0.28, 0), Vector3(0.08, 0.14, 1.9), _board(r))
for sz in [-0.98, 0.98]:
_box(st, Vector3(0, 0.28, sz), Vector3(0.84, 0.14, 0.08), _board(r))
_box(st, Vector3(0, 0.62, -1.0), Vector3(0.96, 0.4, 0.08), _board(r)) # headboard
_box(st, Vector3(0, 0.38, 0), Vector3(0.86, 0.14, 1.9), THATCH)
_box(st, Vector3(0, 0.5, -0.72), Vector3(0.6, 0.1, 0.32), LINEN)
_box(st, Vector3(0, 0.47, 0.25), Vector3(0.9, 0.08, 1.3), FUR)
return P._commit(st)
# --- hunting gear (M1d) -------------------------------------------------------
const MEAT_RAW := Color(0.72, 0.24, 0.24)
const MEAT_COOKED := Color(0.48, 0.26, 0.12)
const BONE := Color(0.90, 0.87, 0.78)
const HIDE := Color(0.58, 0.42, 0.28)
const FEATHER := Color(0.88, 0.86, 0.80)
const FLETCH := Color(0.70, 0.22, 0.18)
static func _meat(col: Color) -> ArrayMesh:
var st := P._begin()
var r := _rng(107)
var n := FastNoiseLite.new()
n.seed = 107
n.frequency = 2.2
P._blob(st, Vector3(0.03, 0.07, 0), Vector3(0.13, 0.07, 0.1), n, 0.25, col, r, 1)
P._cylinder(st, Vector3(-0.08, 0.07, 0), Vector3(-0.2, 0.08, 0.01), 0.022, 0.018, 5, BONE, r, -1.0, BONE)
P._blob(st, Vector3(-0.21, 0.08, 0.01), Vector3(0.03, 0.03, 0.035), n, 0.1, BONE, r, 0)
return P._commit(st)
static func _hide() -> ArrayMesh:
var st := P._begin()
var r := _rng(109)
# A rough, folded pelt: a few overlapping flattened slabs.
_box(st, Vector3(0, 0.02, 0), Vector3(0.36, 0.03, 0.26), HIDE, Basis(Vector3.UP, 0.2))
_box(st, Vector3(0.05, 0.05, 0.02), Vector3(0.28, 0.03, 0.2), P._mul(HIDE, 0.85), Basis(Vector3.UP, -0.3))
_box(st, Vector3(-0.04, 0.075, -0.02), Vector3(0.2, 0.025, 0.16), _board(r, HIDE), Basis(Vector3.UP, 0.6))
return P._commit(st)
static func _feather() -> ArrayMesh:
var st := P._begin()
var r := _rng(113)
P._cylinder(st, Vector3(-0.14, 0.01, 0), Vector3(0.14, 0.012, 0), 0.004, 0.002, 3, BONE, r)
for side in [-1.0, 1.0]:
var a := Vector3(-0.1, 0.01, 0)
var b := Vector3(0.13, 0.012, 0)
var c := Vector3(0.0, 0.01, side * 0.035)
P._tri(st, a, b, c, Vector3.UP, FEATHER)
P._tri(st, a, b, c, Vector3.DOWN, P._mul(FEATHER, 0.85))
return P._commit(st)
## Long shaft along +Y from the grip, fire-hardened point with a flint tip.
static func _spear() -> ArrayMesh:
var st := P._begin()
var r := _rng(127)
P._cylinder(st, Vector3(0, -0.55, 0), Vector3(0, 0.75, 0), 0.022, 0.02, 6, HANDLE, r, -1.0, HANDLE)
_box(st, Vector3(0, 0.78, 0), Vector3(0.05, 0.06, 0.05), BINDING)
P._cylinder(st, Vector3(0, 0.8, 0), Vector3(0, 0.98, 0), 0.035, 0.004, 4, FLINT, r)
return P._commit(st)
## Bent stave along +Y with a string on the +Z side (held upright).
static func _bow() -> ArrayMesh:
var st := P._begin()
var r := _rng(131)
var pts: Array[Vector3] = []
for i in range(7):
var t := -1.0 + 2.0 * i / 6.0
pts.append(Vector3(0, t * 0.55, -0.1 * (1.0 - t * t)))
for i in range(6):
P._cylinder(st, pts[i], pts[i + 1], 0.02 if absf(i - 2.5) < 1.5 else 0.015, 0.02 if absf(i - 2.5) < 1.5 else 0.013, 5, PLANK_DARK, r, 0.0)
P._cylinder(st, pts[0], pts[6], 0.004, 0.004, 3, LINEN, r)
_box(st, Vector3(0, 0, -0.1), Vector3(0.05, 0.12, 0.05), BINDING)
return P._commit(st)
## Arrow along -Z (its flight direction), tip at the front.
static func _arrow(tip: Color = FLINT) -> ArrayMesh:
var st := P._begin()
var r := _rng(137)
P._cylinder(st, Vector3(0, 0, 0.35), Vector3(0, 0, -0.33), 0.008, 0.008, 4, HANDLE, r, 0.0)
P._cylinder(st, Vector3(0, 0, -0.32), Vector3(0, 0, -0.4), 0.018, 0.002, 4, tip, r, 0.0)
for a in [0.0, TAU / 3.0, TAU * 2.0 / 3.0]:
var d := Vector3(cos(a), sin(a), 0) * 0.03
P._tri(st, Vector3(0, 0, 0.33), Vector3(0, 0, 0.2), d + Vector3(0, 0, 0.3), Vector3(-d.y, d.x, 0), FLETCH)
P._tri(st, Vector3(0, 0, 0.33), Vector3(0, 0, 0.2), d + Vector3(0, 0, 0.3), Vector3(d.y, -d.x, 0), FLETCH)
return P._commit(st)
const WOLF_FUR := Color(0.5, 0.5, 0.52)
# --- iron (P1) ------------------------------------------------------------------
const RUST := Color(0.5, 0.26, 0.14)
const CLAY := Color(0.62, 0.6, 0.55)
const CLAY_FIRED := Color(0.66, 0.42, 0.3)
const CHARCOAL := Color(0.1, 0.09, 0.09)
const IRON_BRIGHT := Color(0.55, 0.56, 0.6)
static func _ore() -> ArrayMesh:
var st := P._begin()
var r := _rng(71)
var n := FastNoiseLite.new()
n.seed = 71
n.frequency = 1.4
P._blob(st, Vector3(0, 0.07, 0), Vector3(0.13, 0.09, 0.11), n, 0.35, STONE, r, 1)
for i in range(4):
var a := TAU * i / 4.0 + 0.4
P._blob(st, Vector3(cos(a) * 0.08, 0.1, sin(a) * 0.07), Vector3(0.05, 0.035, 0.04), n, 0.4, RUST, r, 0)
return P._commit(st)
static func _charcoal() -> ArrayMesh:
var st := P._begin()
var r := _rng(73)
for i in range(4):
var c := Vector3(r.randf_range(-0.07, 0.07), 0.03, r.randf_range(-0.06, 0.06))
_box(st, c, Vector3(0.1, 0.05, 0.06) * r.randf_range(0.8, 1.2), P._mul(CHARCOAL, r.randf_range(0.8, 1.6)), Basis(Vector3.UP, r.randf() * TAU))
return P._commit(st)
static func _iron_bar() -> ArrayMesh:
var st := P._begin()
_box(st, Vector3(0, 0.04, 0), Vector3(0.3, 0.07, 0.1), IRON_BRIGHT)
_box(st, Vector3(0, 0.085, 0), Vector3(0.26, 0.02, 0.07), P._mul(IRON_BRIGHT, 1.15))
return P._commit(st)
static func _nails() -> ArrayMesh:
var st := P._begin()
var r := _rng(79)
for i in range(6):
var a := r.randf() * TAU
var c := Vector3(r.randf_range(-0.06, 0.06), 0.012, r.randf_range(-0.06, 0.06))
var d := Vector3(cos(a), 0, sin(a)) * 0.07
P._cylinder(st, c - d, c + d, 0.006, 0.002, 4, IRON, r)
P._cylinder(st, c - d, c - d * 0.85, 0.013, 0.013, 5, IRON, r)
return P._commit(st)
static func _iron_axe() -> ArrayMesh:
var st := P._begin()
var r := _rng(83)
_handle(st, 0.58, r)
_box(st, Vector3(0.03, 0.5, 0), Vector3(0.1, 0.08, 0.04), IRON)
_box(st, Vector3(0.11, 0.5, 0), Vector3(0.07, 0.15, 0.025), IRON_BRIGHT)
return P._commit(st)
static func _iron_pickaxe() -> ArrayMesh:
var st := P._begin()
var r := _rng(89)
_handle(st, 0.62, r)
_box(st, Vector3(0, 0.57, 0), Vector3(0.06, 0.07, 0.06), IRON)
P._cylinder(st, Vector3(-0.02, 0.58, 0), Vector3(0.25, 0.51, 0), 0.03, 0.004, 4, IRON_BRIGHT, r, -1.0, IRON)
P._cylinder(st, Vector3(0.02, 0.58, 0), Vector3(-0.22, 0.52, 0), 0.03, 0.004, 4, IRON_BRIGHT, r, -1.0, IRON)
return P._commit(st)
static func _iron_spear() -> ArrayMesh:
var st := P._begin()
var r := _rng(97)
P._cylinder(st, Vector3(0, -0.55, 0), Vector3(0, 0.75, 0), 0.022, 0.02, 6, HANDLE, r, -1.0, HANDLE)
_box(st, Vector3(0, 0.77, 0), Vector3(0.04, 0.05, 0.04), IRON)
P._cylinder(st, Vector3(0, 0.79, 0), Vector3(0, 1.02, 0), 0.03, 0.002, 4, IRON_BRIGHT, r)
return P._commit(st)
static func _lantern() -> ArrayMesh:
var st := P._begin()
var r := _rng(101)
# Held by the ring at the top, the light hanging below the hand.
_box(st, Vector3(0, 0.05, 0), Vector3(0.14, 0.02, 0.14), IRON)
_box(st, Vector3(0, 0.3, 0), Vector3(0.16, 0.03, 0.16), IRON)
for sx in [-0.065, 0.065]:
for sz in [-0.065, 0.065]:
_box(st, Vector3(sx, 0.175, sz), Vector3(0.015, 0.24, 0.015), IRON)
_box(st, Vector3(0, 0.17, 0), Vector3(0.11, 0.2, 0.11), Color(1.0, 0.85, 0.5))
P._cylinder(st, Vector3(0, 0.315, 0), Vector3(0, 0.4, 0), 0.04, 0.01, 5, IRON, r)
return P._commit(st)
## A low clay dome with a dark mouth at the front (+Z), and a smoke hole.
static func _kiln() -> ArrayMesh:
var st := P._begin()
var r := _rng(103)
var n := FastNoiseLite.new()
n.seed = 103
n.frequency = 1.2
P._blob(st, Vector3(0, 0.45, 0), Vector3(0.75, 0.55, 0.75), n, 0.08, CLAY_FIRED, r, 1)
_box(st, Vector3(0, 0.25, 0.68), Vector3(0.42, 0.34, 0.12), ASH)
P._cylinder(st, Vector3(0, 0.95, 0), Vector3(0, 1.12, 0), 0.12, 0.1, 6, P._mul(CLAY_FIRED, 0.85), r, -1.0, ASH)
for i in range(8):
var a := TAU * i / 8.0
P._blob(st, Vector3(cos(a) * 0.78, 0.08, sin(a) * 0.78), Vector3(0.13, 0.1, 0.12), n, 0.3, STONE, r, 1)
return P._commit(st)
## A stone base with a tall clay shaft, the hot mouth low at the front (+Z).
static func _smelter() -> ArrayMesh:
var st := P._begin()
var r := _rng(107)
var n := FastNoiseLite.new()
n.seed = 107
n.frequency = 1.5
for i in range(10):
var a := TAU * i / 10.0
P._blob(st, Vector3(cos(a) * 0.5, 0.12, sin(a) * 0.5), Vector3(0.16, 0.14, 0.15), n, 0.3, P._mul(STONE, r.randf_range(0.8, 1.05)), r, 1)
P._cylinder(st, Vector3(0, 0.1, 0), Vector3(0, 1.7, 0), 0.5, 0.26, 8, CLAY_FIRED, r, -1.0, ASH)
_box(st, Vector3(0, 0.3, 0.46), Vector3(0.36, 0.28, 0.14), ASH)
_box(st, Vector3(0.38, 0.12, 0.42), Vector3(0.28, 0.06, 0.14), STONE, Basis(Vector3.UP, 0.5))
return P._commit(st)
## A stone hearth (left) and an anvil on a stump (right).
static func _forge() -> ArrayMesh:
var st := P._begin()
var r := _rng(109)
_box(st, Vector3(-0.55, 0.38, 0), Vector3(0.95, 0.76, 0.95), P._mul(STONE, 0.9))
_box(st, Vector3(-0.55, 0.8, 0), Vector3(0.75, 0.08, 0.75), ASH)
_box(st, Vector3(-0.55, 0.86, 0.05), Vector3(0.4, 0.06, 0.4), Color(0.9, 0.35, 0.1))
P._cylinder(st, Vector3(0.55, 0.0, 0), Vector3(0.55, 0.55, 0), 0.24, 0.22, 7, P.BARK, r, -1.0, PLANK)
_box(st, Vector3(0.55, 0.62, 0), Vector3(0.5, 0.14, 0.2), IRON)
_box(st, Vector3(0.55, 0.72, 0), Vector3(0.62, 0.06, 0.24), IRON_BRIGHT)
P._cylinder(st, Vector3(0.84, 0.72, 0), Vector3(1.0, 0.71, 0), 0.07, 0.01, 5, IRON_BRIGHT, r)
_box(st, Vector3(0.45, 0.8, 0.0), Vector3(0.24, 0.04, 0.05), HANDLE, Basis(Vector3.UP, 0.6))
return P._commit(st)
static func _pelt(col: Color) -> ArrayMesh:
var st := P._begin()
var r := _rng(149)
_box(st, Vector3(0, 0.02, 0), Vector3(0.42, 0.035, 0.24), col, Basis(Vector3.UP, 0.15))
_box(st, Vector3(0.04, 0.05, 0.01), Vector3(0.3, 0.035, 0.18), _board(r, col), Basis(Vector3.UP, -0.25))
_box(st, Vector3(-0.2, 0.03, 0.05), Vector3(0.12, 0.03, 0.05), P._mul(col, 0.8), Basis(Vector3.UP, 0.7)) # tail
return P._commit(st)
static func _cloak(col: Color = HIDE) -> ArrayMesh:
var st := P._begin()
var r := _rng(139)
# Folded cloak: a stack of fur with a bone clasp.
for i in range(3):
_box(st, Vector3(0, 0.03 + i * 0.045, 0), Vector3(0.42 - i * 0.05, 0.04, 0.3 - i * 0.03), _board(r, col), Basis(Vector3.UP, (i - 1) * 0.08))
_box(st, Vector3(0, 0.17, 0.0), Vector3(0.32, 0.05, 0.08), P._mul(FUR if col == HIDE else col, 1.1))
_box(st, Vector3(0.1, 0.2, 0.05), Vector3(0.05, 0.03, 0.03), BONE)
return P._commit(st)
# --- fishing ------------------------------------------------------------------
const PERCH := Color(0.45, 0.52, 0.30)
const TROUT := Color(0.50, 0.55, 0.58)
const GRILLED := Color(0.55, 0.36, 0.18)
const FLOAT_RED := Color(0.85, 0.15, 0.12)
## Rod along +Y from the grip, a tip ring at the top. Rod tip is at
## (0, ROD_TIP, 0) (hands.gd draws the line from there).
const ROD_TIP := 1.15
static func _rod() -> ArrayMesh:
var st := P._begin()
var r := _rng(151)
P._cylinder(st, Vector3(0, -0.15, 0), Vector3(0, ROD_TIP, 0), 0.02, 0.006, 5, HANDLE, r, -1.0, HANDLE)
_box(st, Vector3(0, 0.0, 0), Vector3(0.05, 0.16, 0.05), BINDING)
# A simple wooden reel with line wound on it.
P._cylinder(st, Vector3(-0.05, 0.12, 0.04), Vector3(0.05, 0.12, 0.04), 0.045, 0.045, 7, PLANK_DARK, r, 0.0, LINEN)
return P._commit(st)
static func _bobber() -> ArrayMesh:
var st := P._begin()
var r := _rng(157)
var n := FastNoiseLite.new()
# Big enough to spot 10 m out on the water.
P._blob(st, Vector3(0, 0.03, 0), Vector3(0.08, 0.06, 0.08), n, 0.0, Color(0.95, 0.95, 0.92), r, 1)
P._blob(st, Vector3(0, 0.09, 0), Vector3(0.07, 0.05, 0.07), n, 0.0, FLOAT_RED, r, 1)
P._cylinder(st, Vector3(0, 0.11, 0), Vector3(0, 0.24, 0), 0.01, 0.007, 4, FLOAT_RED, r)
return P._commit(st)
## A fish lying on its side along X, head at +X. `len` is half its length.
static func _fish(col: Color, len: float, grilled: bool) -> ArrayMesh:
var st := P._begin()
var r := _rng(163 + int(len * 100.0))
var n := FastNoiseLite.new()
n.seed = 163
var body := GRILLED.lerp(col, 0.25) if grilled else col
P._blob(st, Vector3(0, len * 0.3, 0), Vector3(len, len * 0.32, len * 0.14), n, 0.05, body, r, 1)
P._blob(st, Vector3(len * 0.1, len * 0.22, 0), Vector3(len * 0.75, len * 0.18, len * 0.12), n, 0.05,
P._mul(body, 1.25) if not grilled else P._mul(body, 0.8), r, 1) # pale belly / char
# Tail fin.
var t0 := Vector3(-len * 0.9, len * 0.3, 0)
var fin := P._mul(body, 0.8)
P._tri(st, t0, Vector3(-len * 1.35, len * 0.65, 0), Vector3(-len * 1.35, -len * 0.05, 0), Vector3(0, 0, 1), fin)
P._tri(st, t0, Vector3(-len * 1.35, len * 0.65, 0), Vector3(-len * 1.35, -len * 0.05, 0), Vector3(0, 0, -1), fin)
# Dorsal fin and an eye.
P._tri(st, Vector3(len * 0.3, len * 0.58, 0), Vector3(-len * 0.3, len * 0.58, 0), Vector3(-len * 0.1, len * 0.85, 0), Vector3(0, 0, 1), fin)
P._tri(st, Vector3(len * 0.3, len * 0.58, 0), Vector3(-len * 0.3, len * 0.58, 0), Vector3(-len * 0.1, len * 0.85, 0), Vector3(0, 0, -1), fin)
if not grilled:
for s in [-1.0, 1.0]:
P._blob(st, Vector3(len * 0.75, len * 0.38, s * len * 0.11), Vector3.ONE * len * 0.06, n, 0.0, Color(0.05, 0.05, 0.05), r, 0)
return P._commit(st)
# --- P2 stone building and wooden upgrades ---------------------------------------
# Dimensions match the colliders in build_piece.gd (pillar, stairs, window,
# palisade) or reuse the wall/floor/door ones.
const MORTAR := Color(0.36, 0.35, 0.33)
const STONE_BLOCK := Color(0.62, 0.61, 0.58)
static func _block_col(r: RandomNumberGenerator) -> Color:
var c := P._mul(STONE_BLOCK, r.randf_range(0.8, 1.08))
return c.lerp(Color(0.6, 0.55, 0.47), r.randf() * 0.25) # some warmer stones
## Courses of staggered blocks filling x0..x1, y0..y1, `depth` thick, with a
## mortar core behind them so the joints read dark.
static func _blocks(st: SurfaceTool, r: RandomNumberGenerator, x0: float, x1: float, y0: float, y1: float,
depth: float = 0.22, course: float = 0.33) -> void:
if x1 - x0 < 0.02 or y1 - y0 < 0.02:
return
_box(st, Vector3((x0 + x1) * 0.5, (y0 + y1) * 0.5, 0), Vector3(x1 - x0, y1 - y0, depth - 0.04), MORTAR)
var rows := maxi(roundi((y1 - y0) / course), 1)
var h := (y1 - y0) / rows
for i in range(rows):
var y := y0 + h * (i + 0.5)
var x := x0
# Stagger: every other course starts with a half block (world-aligned
# so neighbouring walls line up).
var step := 0.5
var next := x0 + (step * 0.5 if i % 2 == 1 else step)
while x < x1 - 0.01:
var e := minf(next, x1)
_box(st, Vector3((x + e) * 0.5, y, 0), Vector3(e - x - 0.025, h - 0.025, depth), _block_col(r))
x = e
next = e + step * r.randf_range(0.8, 1.2)
static func _cut_stone() -> ArrayMesh:
var st := P._begin()
var r := _rng(211)
_box(st, Vector3(0, 0.08, 0), Vector3(0.3, 0.16, 0.2), _block_col(r))
_box(st, Vector3(0.01, 0.161, 0), Vector3(0.26, 0.004, 0.16), P._mul(STONE_BLOCK, 1.1))
return P._commit(st)
## A heavy bench with a block on it and a frame saw: the stonecutter.
static func _stonecutter() -> ArrayMesh:
var st := P._begin()
var r := _rng(213)
_box(st, Vector3(0, 0.72, 0), Vector3(1.6, 0.12, 0.8), PLANK)
for sx in [-0.7, 0.7]:
for sz in [-0.32, 0.32]:
_box(st, Vector3(sx, 0.33, sz), Vector3(0.14, 0.66, 0.14), PLANK_DARK)
_box(st, Vector3(0, 0.2, 0), Vector3(1.4, 0.08, 0.64), PLANK_DARK)
# Rough stone waiting on the shelf, a block being cut on top.
for i in range(3):
_box(st, Vector3(-0.45 + 0.42 * i, 0.32, 0.02), Vector3(0.3, 0.16, 0.24), _block_col(r), Basis(Vector3.UP, r.randf_range(-0.3, 0.3)))
_box(st, Vector3(0.2, 0.92, 0), Vector3(0.6, 0.28, 0.4), _block_col(r))
# Frame saw: two uprights, a cross bar and the iron blade in the cut.
for sx in [-0.2, 0.62]:
_box(st, Vector3(sx, 1.12, 0), Vector3(0.05, 0.5, 0.05), HANDLE)
_box(st, Vector3(0.21, 1.36, 0), Vector3(0.9, 0.04, 0.04), HANDLE)
_box(st, Vector3(0.21, 1.04, 0), Vector3(0.86, 0.06, 0.012), IRON_BRIGHT)
# Chips and dust.
for i in range(5):
_box(st, Vector3(r.randf_range(-0.7, 0.7), 0.79, r.randf_range(-0.3, 0.3)), Vector3(0.05, 0.03, 0.04), STONE)
return P._commit(st)
## 2 x 2 m flagstones, top at y = 0, on a 0.2 m bed.
static func _stone_floor() -> ArrayMesh:
var st := P._begin()
var r := _rng(217)
_box(st, Vector3(0, -0.11, 0), Vector3(2.0, 0.18, 2.0), MORTAR)
for i in range(4):
for j in range(4):
var c := Vector3(-0.75 + 0.5 * i + r.randf_range(-0.02, 0.02), -0.03, -0.75 + 0.5 * j + r.randf_range(-0.02, 0.02))
_box(st, c, Vector3(0.47, 0.06, 0.47) * Vector3(r.randf_range(0.95, 1.0), 1, r.randf_range(0.95, 1.0)), _block_col(r))
return P._commit(st)
static func _stone_wall() -> ArrayMesh:
var st := P._begin()
_blocks(st, _rng(219), -1.0, 1.0, 0.0, 2.0)
return P._commit(st)
## Square column with a base and a capital, 0.4 m.
static func _stone_pillar() -> ArrayMesh:
var st := P._begin()
var r := _rng(223)
_box(st, Vector3(0, 1.0, 0), Vector3(0.32, 2.0, 0.32), MORTAR)
for i in range(6):
var y := 0.2 + (1.6 / 6.0) * (i + 0.5)
_box(st, Vector3(0, y, 0), Vector3(0.36, 1.6 / 6.0 - 0.025, 0.36), _block_col(r), Basis(Vector3.UP, r.randf_range(-0.04, 0.04)))
_box(st, Vector3(0, 0.1, 0), Vector3(0.44, 0.2, 0.44), _block_col(r))
_box(st, Vector3(0, 1.9, 0), Vector3(0.44, 0.2, 0.44), _block_col(r))
return P._commit(st)
static func _wood_pillar() -> ArrayMesh:
var st := P._begin()
var r := _rng(227)
_box(st, Vector3(0, 1.0, 0), Vector3(0.3, 2.0, 0.3), _board(r, BEAM))
_box(st, Vector3(0, 1.94, 0), Vector3(0.4, 0.12, 0.4), BEAM)
_box(st, Vector3(0, 0.06, 0), Vector3(0.38, 0.12, 0.38), BEAM)
return P._commit(st)
## 8 steps climbing 2 m over 4 m toward -Z (the collider is the smooth ramp
## through their noses). Stone is solid underneath; wood is treads on two
## stringers.
static func _stairs(stone: bool) -> ArrayMesh:
var st := P._begin()
var r := _rng(229 if stone else 231)
var a := 0.4636476
for i in range(8):
var top := 0.25 * (i + 1) - 0.06
var zc := -0.25 - 0.5 * i
if stone:
_box(st, Vector3(0, top * 0.5, zc), Vector3(2.0, top, 0.5), MORTAR)
_box(st, Vector3(0, top - 0.05, zc + 0.01), Vector3(1.96, 0.1, 0.5), _block_col(r))
else:
_box(st, Vector3(0, top - 0.04, zc + 0.03), Vector3(1.7, 0.08, 0.46), _board(r))
if not stone:
var length := sqrt(20.0)
for x in [-0.9, 0.9]:
_box(st, Vector3(x, 0.92, -2.0), Vector3(0.12, 0.26, length), BEAM, Basis(Vector3.RIGHT, a))
return P._commit(st)
## Wall with a 1 x 0.7 m opening 1 m up (x -0.5..0.5, y 1.0..1.7).
static func _stone_window() -> ArrayMesh:
var st := P._begin()
var r := _rng(233)
_blocks(st, r, -1.0, 1.0, 0.0, 1.0)
_blocks(st, r, -1.0, -0.5, 1.0, 1.7, 0.22, 0.35)
_blocks(st, r, 0.5, 1.0, 1.0, 1.7, 0.22, 0.35)
_blocks(st, r, -1.0, 1.0, 1.7, 2.0, 0.22, 0.3)
_box(st, Vector3(0, 0.97, 0.03), Vector3(1.2, 0.07, 0.3), _block_col(r)) # sill
_box(st, Vector3(0, 1.75, 0), Vector3(1.24, 0.12, 0.24), _block_col(r)) # lintel
return P._commit(st)
static func _wood_window() -> ArrayMesh:
var st := P._begin()
var r := _rng(237)
_wall_body(st, r, -0.84, 0.84, 0.1, 1.0)
_wall_body(st, r, -0.84, -0.5, 1.0, 1.7)
_wall_body(st, r, 0.5, 0.84, 1.0, 1.7)
_wall_body(st, r, -0.84, 0.84, 1.7, 1.9)
for x in [-0.92, 0.92]:
_box(st, Vector3(x, 1.0, 0), Vector3(0.16, 2.0, 0.2), BEAM)
for y in [0.06, 1.94]:
_box(st, Vector3(0, y, 0), Vector3(1.68, 0.12, 0.2), BEAM)
# Frame, sill and a shutter swung open on each side (outside, +Z).
_box(st, Vector3(0, 0.98, 0.02), Vector3(1.16, 0.06, 0.26), BEAM)
_box(st, Vector3(0, 1.72, 0), Vector3(1.1, 0.06, 0.2), BEAM)
for side in [-1.0, 1.0]:
_box(st, Vector3(side * 0.52, 1.35, 0), Vector3(0.05, 0.72, 0.2), BEAM)
_box(st, Vector3(side * 0.78, 1.35, 0.14), Vector3(0.48, 0.66, 0.04), _board(r, PLANK_DARK))
_box(st, Vector3(0.0, 1.35, 0), Vector3(0.03, 0.7, 0.03), BEAM) # mullion
return P._commit(st)
## Stone arch for a 1.1 x 1.9 m door (the wooden leaf is wood_door_panel).
static func _stone_doorway(with_panel: bool) -> ArrayMesh:
var st := P._begin()
var r := _rng(239)
_blocks(st, r, -1.0, -0.55, 0.0, 2.0)
_blocks(st, r, 0.55, 1.0, 0.0, 2.0)
_box(st, Vector3(0, 1.97, 0), Vector3(1.14, 0.06, 0.2), MORTAR)
# Arch stones over the opening: a keystone and its neighbours.
for i in range(5):
var x := -0.44 + 0.22 * i
var h := 0.16 + (0.08 if i == 2 else 0.0)
_box(st, Vector3(x, 1.9 + h * 0.5 - 0.02, 0), Vector3(0.2, h, 0.24), _block_col(r))
if with_panel:
_door_panel_into(st, Vector3(-0.54, 0, 0))
return P._commit(st)
## Hearth: the fire sits at the origin in a stone firebox (back toward -Z),
## under a hood and chimney.
static func _hearth() -> ArrayMesh:
var st := P._begin()
var r := _rng(241)
_box(st, Vector3(0, 0.06, 0), Vector3(1.6, 0.12, 1.2), _block_col(r))
_box(st, Vector3(0, 0.125, 0.05), Vector3(0.9, 0.01, 0.7), ASH)
# Back and cheeks.
_box(st, Vector3(0, 0.75, -0.48), Vector3(1.6, 1.3, 0.24), MORTAR)
for i in range(4):
_box(st, Vector3(-0.4 + 0.8 * (i % 2) + r.randf_range(-0.05, 0.05), 0.3 + 0.33 * i, -0.36), Vector3(0.7, 0.3, 0.02), _block_col(r))
for sx in [-0.68, 0.68]:
for i in range(4):
_box(st, Vector3(sx, 0.28 + 0.3 * i, -0.05), Vector3(0.26, 0.28, 0.7), _block_col(r))
# Mantel, hood and chimney.
_box(st, Vector3(0, 1.3, 0.05), Vector3(1.7, 0.12, 0.8), _block_col(r))
_box(st, Vector3(0, 1.55, -0.12), Vector3(1.3, 0.38, 0.6), MORTAR)
_blocks(st, r, -0.3, 0.3, 1.74, 2.0, 0.42, 0.26)
# A few logs in the fire.
for i in range(3):
var a := 0.6 * i - 0.6
var d := Vector3(cos(a), 0, sin(a)) * 0.3
P._cylinder(st, Vector3(0, 0.18, 0.05) - d, Vector3(0, 0.18, 0.05) + d, 0.06, 0.06, 5, P.BARK, r, -1.0, PLANK)
return P._commit(st)
## 2 m of sharpened logs, 3 m tall.
static func _palisade() -> ArrayMesh:
var st := P._begin()
var r := _rng(243)
for i in range(5):
_stake(st, r, -0.8 + 0.4 * i, 0.0, 3.0)
for y in [0.7, 2.1]:
_box(st, Vector3(0, y, -0.2), Vector3(2.0, 0.14, 0.08), BEAM)
return P._commit(st)
static func _stake(st: SurfaceTool, r: RandomNumberGenerator, x: float, y0: float, top: float) -> void:
var h := top - r.randf_range(0.0, 0.18)
var rad := r.randf_range(0.16, 0.19)
P._cylinder(st, Vector3(x, y0, 0), Vector3(x, h - 0.35, 0), rad, rad * 0.95, 7, P.BARK, r)
P._cylinder(st, Vector3(x, h - 0.35, 0), Vector3(x, h, 0), rad * 0.95, 0.02, 7, P.WOOD, r)
## Palisade gate: stakes either side of a 1.1 x 1.9 m opening, more above it.
static func _palisade_gate(with_panel: bool) -> ArrayMesh:
var st := P._begin()
var r := _rng(247)
for x in [-0.8, 0.8]:
_stake(st, r, x, 0.0, 3.0)
for x in [-0.36, 0.0, 0.36]:
_stake(st, r, x, 1.95, 3.0)
for x in [-0.62, 0.62]:
_box(st, Vector3(x, 0.98, 0), Vector3(0.12, 1.96, 0.2), BEAM)
_box(st, Vector3(0, 1.98, 0), Vector3(1.4, 0.12, 0.24), BEAM)
if with_panel:
_door_panel_into(st, Vector3(-0.54, 0, 0))
return P._commit(st)
# --- P3 the alpha ------------------------------------------------------------------
const ALPHA_FUR := Color(0.17, 0.16, 0.17)
## Iron-banded door: the wooden leaf with three iron straps and studs.
## `in_frame` = the closed door in a wood frame (icon and ghost); otherwise
## just the leaf, hinge at the origin.
static func _iron_door(in_frame: bool) -> ArrayMesh:
var st := P._begin()
var o := Vector3.ZERO
if in_frame:
var r := _rng(79)
for side in [-1.0, 1.0]:
_wall_body(st, r, side * 0.55 if side > 0 else -0.84, 0.84 if side > 0 else -0.55, 0.1, 1.9)
_box(st, Vector3(side * 0.92, 1.0, 0), Vector3(0.16, 2.0, 0.2), BEAM)
_box(st, Vector3(side * 0.6, 0.95, 0), Vector3(0.1, 1.9, 0.2), BEAM)
_box(st, Vector3(0, 1.95, 0), Vector3(1.68, 0.1, 0.2), BEAM)
o = Vector3(-0.54, 0, 0)
var rr := _rng(251)
for i in range(4):
_box(st, o + Vector3(0.135 + 0.27 * i, 0.95, 0), Vector3(0.26, 1.86, 0.08), _board(rr, PLANK_DARK))
for y in [0.3, 0.95, 1.6]:
for z in [0.05, -0.05]:
_box(st, o + Vector3(0.54, y, z), Vector3(1.04, 0.1, 0.02), IRON)
for i in range(4):
_box(st, o + Vector3(0.14 + 0.27 * i, y, z * 1.4), Vector3(0.03, 0.03, 0.02), IRON_BRIGHT)
_box(st, o + Vector3(0.95, 1.0, 0.08), Vector3(0.06, 0.16, 0.05), IRON_BRIGHT)
_box(st, o + Vector3(0.95, 1.0, -0.08), Vector3(0.06, 0.16, 0.05), IRON_BRIGHT)
return P._commit(st)
static func _fang() -> ArrayMesh:
var st := P._begin()
var r := _rng(253)
P._cylinder(st, Vector3(0, 0.02, 0), Vector3(0.02, 0.02, -0.12), 0.022, 0.003, 6, Color(0.88, 0.82, 0.62), r, -1.0, Color(0.6, 0.45, 0.35))
return P._commit(st)
# --- P3 the bear ---------------------------------------------------------------------
const BEAR_FUR := Color(0.36, 0.24, 0.15)
## A bearskin laid flat: body, four splayed legs and the head toward -Z.
static func _rug() -> ArrayMesh:
var st := P._begin()
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.02, 0.1), Vector3(0.8, 0.03, 1.3), P._mul(BEAR_FUR, 1.12))
for sx in [-1.0, 1.0]:
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(0, 0.05, -0.85), Vector3(0.38, 0.1, 0.34), P._mul(BEAR_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))
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(0, 0.02, 0.92), Vector3(0.12, 0.03, 0.12), BEAR_FUR)
return P._commit(st)
# --- P3 the barrow --------------------------------------------------------------------
## A third of a carved stone disc, with a groove and a pale inlay.
static func _barrow_key() -> ArrayMesh:
var st := P._begin()
var r := _rng(311)
var segs := 5
for i in range(segs):
var a0 := -0.55 + 1.1 * i / segs
var a1 := -0.55 + 1.1 * (i + 1) / segs
var c := Vector3(sin((a0 + a1) * 0.5), 0, cos((a0 + a1) * 0.5)) * 0.09
var w := 2.0 * 0.09 * sin((a1 - a0) * 0.5) + 0.006
_box(st, c + Vector3(0, 0.02, 0), Vector3(w, 0.04, 0.12), P._mul(Color(0.45, 0.46, 0.44), r.randf_range(0.9, 1.05)), Basis(Vector3.UP, (a0 + a1) * 0.5))
_box(st, Vector3(0, 0.042, 0.1), Vector3(0.1, 0.006, 0.02), Color(0.82, 0.8, 0.7))
return P._commit(st)
# --- P4 food keeping ---------------------------------------------------------------------
## A crust of white salt crystals.
static func _salt() -> ArrayMesh:
var st := P._begin()
var r := _rng(331)
for i in range(7):
var c := Vector3(r.randf_range(-0.07, 0.07), 0.02, r.randf_range(-0.06, 0.06))
_box(st, c, Vector3.ONE * r.randf_range(0.025, 0.05), P._mul(Color(0.93, 0.93, 0.9), r.randf_range(0.9, 1.05)),
Basis(Vector3(r.randf(), r.randf(), r.randf()).normalized(), r.randf() * TAU))
return P._commit(st)
## Two forked posts with a crossbar, strips of meat hanging from it, embers
## below. The fire sits at the origin.
static func _smoking_rack() -> ArrayMesh:
var st := P._begin()
var r := _rng(333)
var n := FastNoiseLite.new()
n.seed = 333
for sx in [-0.7, 0.7]:
P._cylinder(st, Vector3(sx, 0.0, 0), Vector3(sx, 1.35, 0), 0.05, 0.04, 6, P.BARK, r, -1.0, PLANK)
P._cylinder(st, Vector3(sx, 1.25, 0), Vector3(sx + 0.08 * signf(sx), 1.45, 0), 0.03, 0.02, 4, P.BARK, r)
P._cylinder(st, Vector3(-0.8, 1.32, 0), Vector3(0.8, 1.32, 0), 0.035, 0.035, 6, P.BARK, r, -1.0, PLANK)
for i in range(5):
var x := -0.48 + 0.24 * i
_box(st, Vector3(x, 1.12, 0), Vector3(0.12, 0.32 + 0.06 * sin(i * 2.1), 0.03), P._mul(Color(0.42, 0.18, 0.12), r.randf_range(0.8, 1.1)))
for i in range(6):
var a := TAU * i / 6.0
P._blob(st, Vector3(cos(a) * 0.32, 0.05, sin(a) * 0.32), Vector3(0.1, 0.07, 0.09), n, 0.3, STONE, r, 1)
P._blob(st, Vector3(0, 0.03, 0), Vector3(0.24, 0.035, 0.24), n, 0.2, ASH, r, 1)
for i in range(3):
var a := 0.9 * i
var d := Vector3(cos(a), 0, sin(a)) * 0.18
P._cylinder(st, Vector3(0, 0.06, 0) - d, Vector3(0, 0.06, 0) + d, 0.035, 0.035, 5, P.BARK, r, -1.0, CHARCOAL)
return P._commit(st)
# --- P5 the Old Stag ---------------------------------------------------------------------
const ANTLER := Color(0.9, 0.87, 0.76)
## One branching antler lying on its side.
static func _antler_into(st: SurfaceTool, r: RandomNumberGenerator, o: Vector3, k: float, up: Vector3 = Vector3.UP) -> void:
var side := up.cross(Vector3.FORWARD).normalized() if absf(up.dot(Vector3.FORWARD)) < 0.9 else Vector3.RIGHT
var a := o
var b := o + up * 0.35 * k + side * 0.12 * k
var c := b + up * 0.3 * k + side * 0.18 * k
P._cylinder(st, a, b, 0.035 * k, 0.028 * k, 5, ANTLER, r)
P._cylinder(st, b, c, 0.028 * k, 0.01 * k, 5, ANTLER, r)
for t in [[a.lerp(b, 0.4), Vector3(0, 0.15, -0.12)], [b, Vector3(0, 0.2, -0.15)], [b.lerp(c, 0.6), Vector3(0, 0.12, 0.14)]]:
P._cylinder(st, t[0], (t[0] as Vector3) + (up * (t[1] as Vector3).y + Vector3(0, 0, (t[1] as Vector3).z)) * k * 1.4, 0.018 * k, 0.005 * k, 4, ANTLER, r)
static func _antler() -> ArrayMesh:
var st := P._begin()
_antler_into(st, _rng(361), Vector3(-0.25, 0.04, 0), 1.0, Vector3(1, 0.12, 0).normalized())
return P._commit(st)
## The skull and antlers on a wooden stand.
static func _stag_trophy() -> ArrayMesh:
var st := P._begin()
var r := _rng(363)
var n := FastNoiseLite.new()
n.seed = 363
_box(st, Vector3(0, 0.08, 0), Vector3(1.2, 0.16, 0.7), P._mul(BEAM, 0.9))
_box(st, Vector3(0, 0.6, -0.15), Vector3(0.16, 0.9, 0.16), BEAM)
P._blob(st, Vector3(0, 1.12, -0.1), Vector3(0.16, 0.16, 0.26), n, 0.1, Color(0.88, 0.85, 0.75), r, 1)
P._blob(st, Vector3(0, 1.08, 0.12), Vector3(0.07, 0.06, 0.08), n, 0.0, Color(0.2, 0.2, 0.2), r, 0)
for s in [-1.0, 1.0]:
_antler_into(st, r, Vector3(s * 0.08, 1.25, -0.12), 1.6, Vector3(s * 0.7, 1.0, 0).normalized())
P._blob(st, Vector3(s * 0.07, 1.16, 0.08), Vector3.ONE * 0.025, n, 0.0, Color(0.55, 0.9, 1.0), r, 0)
return P._commit(st)
static func _antler_pickaxe() -> ArrayMesh:
var st := P._begin()
var r := _rng(365)
_handle(st, 0.62, r)
_box(st, Vector3(0, 0.57, 0), Vector3(0.06, 0.08, 0.06), IRON)
P._cylinder(st, Vector3(-0.02, 0.58, 0), Vector3(0.27, 0.5, 0), 0.032, 0.004, 5, ANTLER, r, -1.0, ANTLER)
P._cylinder(st, Vector3(0.02, 0.58, 0), Vector3(-0.24, 0.53, 0), 0.032, 0.004, 5, ANTLER, r, -1.0, ANTLER)
P._cylinder(st, Vector3(0.1, 0.56, 0), Vector3(0.14, 0.66, 0), 0.012, 0.003, 4, ANTLER, r)
return P._commit(st)

178
scripts/net/avatar.gd Normal file
View File

@@ -0,0 +1,178 @@
extends Node3D
## Another player, as you see them: their body, the item in their hand and a
## name tag, moved smoothly from the server's snapshots.
##
## Snapshots arrive ~20 times a second, some late and some out of order. The
## avatar is drawn INTERP_DELAY behind the newest data and interpolates
## between the two snapshots either side of that moment, so it moves smoothly
## with jitter. If data stops it carries on briefly along its last velocity
## (up to MAX_EXTRAPOLATE), then holds still.
const CharacterMesh = preload("res://scripts/net/character_mesh.gd")
const MAX_EXTRAPOLATE := 0.25
const TAG_FADE := Vector2(25.0, 40.0) ## name tags fade out over this distance range
enum Anim { IDLE, WALK, RUN, SWIM, AIR }
var peer := 0
var info: Dictionary = {}
var anim := Anim.IDLE
var held := ""
var tag: Label3D
var hitbox: AnimatableBody3D
var _buf: Array = [] ## [t, pos, yaw, pitch, anim, held], oldest first
var _body: Node3D
var _legs: Array[Node3D] = []
var _arms: Array[Node3D] = []
var _held_mi: MeshInstance3D
var _phase := 0.0
var _speed := 0.0
var _last_pos := Vector3.INF
func setup(p: int, d: Dictionary) -> void:
peer = p
info = d
name = "Avatar_%d" % p
var parts: Dictionary = CharacterMesh.build(d.get("appearance", {}))
var mat := ItemDB.item_material()
_body = Node3D.new()
add_child(_body)
var mi := MeshInstance3D.new()
mi.mesh = parts.body
mi.material_override = mat
_body.add_child(mi)
for pair in [[parts.hips, parts.leg, _legs], [parts.shoulders, parts.arm, _arms]]:
for pos: Vector3 in pair[0]:
var pivot := Node3D.new()
pivot.position = pos
var limb := MeshInstance3D.new()
limb.mesh = pair[1]
limb.material_override = mat
pivot.add_child(limb)
_body.add_child(pivot)
pair[2].append(pivot)
_held_mi = MeshInstance3D.new()
_held_mi.material_override = mat
_held_mi.position = parts.hand
_held_mi.rotation = Vector3(-PI * 0.5, 0, 0) # item handles run along +Y; point them forward
_held_mi.scale = Vector3.ONE * 0.85
_arms[1].add_child(_held_mi)
# A hitbox for swinging at them when the server has PvP on (hands.gd
# looks for it; the server decides what the hit does). Layer 5 (value
# 16): nothing walks into it.
hitbox = AnimatableBody3D.new()
hitbox.sync_to_physics = false
hitbox.collision_layer = 16
hitbox.collision_mask = 0
hitbox.set_meta("avatar_peer", p)
var cs := CollisionShape3D.new()
var cap := CapsuleShape3D.new()
cap.radius = 0.4
cap.height = 1.8
cs.shape = cap
cs.position = Vector3(0, 0.9, 0)
hitbox.add_child(cs)
add_child(hitbox)
tag = Label3D.new()
tag.text = String(d.get("name", "?"))
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.position = Vector3(0, 2.12, 0)
tag.font_size = 44
tag.outline_size = 10
tag.pixel_size = 0.004
tag.modulate = Color(1, 0.97, 0.88)
tag.outline_modulate = Color(0, 0, 0, 0.8)
tag.no_depth_test = false # walls and hills hide it
tag.double_sided = true
add_child(tag)
## A snapshot for this player at server time `t`.
func push(t: float, pos: Vector3, yaw: float, pitch: float, a: int, h: String) -> void:
if not _buf.is_empty() and t <= _buf[-1][0]:
# Late or duplicate: slot it in order (or drop a duplicate).
for i in range(_buf.size()):
if absf(_buf[i][0] - t) < 0.0001:
return
if _buf[i][0] > t:
_buf.insert(i, [t, pos, yaw, pitch, a, h])
return
_buf.append([t, pos, yaw, pitch, a, h])
if _buf.size() == 1 and is_inside_tree():
global_position = pos # first sighting: appear in place, don't slide in
rotation.y = yaw
## Where to draw the avatar at server time `rt` (and animate it).
func update_at(rt: float, dt: float) -> void:
if _buf.is_empty():
return
# Forget old data, keeping one sample before `rt`.
while _buf.size() > 2 and _buf[1][0] < rt - 1.0:
_buf.pop_front()
var s: Array = _sample(rt)
var p: Vector3 = s[0]
global_position = p
rotation.y = s[1]
anim = s[2]
if s[3] != held:
held = s[3]
_held_mi.mesh = ItemDB.mesh_for(held) if not held.is_empty() and not ItemDB.get_item(held).has("place") else null
_animate(p, dt)
_fade_tag()
## [pos, yaw, anim, held] at time `rt`.
func _sample(rt: float) -> Array:
var first: Array = _buf[0]
if rt <= first[0]:
return [first[1], first[2], first[4], first[5]]
for i in range(_buf.size() - 1):
var a: Array = _buf[i]
var b: Array = _buf[i + 1]
if rt <= b[0]:
var k: float = (rt - a[0]) / maxf(b[0] - a[0], 0.0001)
return [(a[1] as Vector3).lerp(b[1], k), lerp_angle(a[2], b[2], k), b[4] if k > 0.5 else a[4], b[5]]
# Past the newest snapshot: carry on along the last velocity, briefly.
var last: Array = _buf[-1]
if _buf.size() >= 2:
var prev: Array = _buf[-2]
var span: float = maxf(last[0] - prev[0], 0.001)
var vel: Vector3 = ((last[1] as Vector3) - prev[1]) / span
var ahead := minf(rt - last[0], MAX_EXTRAPOLATE)
return [(last[1] as Vector3) + vel * ahead, last[2], last[4], last[5]]
return [last[1], last[2], last[4], last[5]]
func _animate(p: Vector3, dt: float) -> void:
if _last_pos != Vector3.INF and dt > 0.0:
var d := Vector2(p.x - _last_pos.x, p.z - _last_pos.z).length() / dt
_speed = lerpf(_speed, d, minf(dt * 10.0, 1.0))
_last_pos = p
_phase += dt * (2.0 + _speed * 1.6)
var swing := clampf(_speed / 5.0, 0.0, 1.0) * (0.75 if anim == Anim.RUN else 0.55)
var lean := 0.0
match anim:
Anim.SWIM:
lean = 1.2
swing = 0.35
Anim.AIR:
swing = 0.15
Anim.RUN:
lean = 0.12
_body.rotation.x = lerpf(_body.rotation.x, -lean, minf(dt * 6.0, 1.0))
for i in range(2):
var side := 1.0 if i == 0 else -1.0
_legs[i].rotation.x = sin(_phase) * swing * side
_arms[i].rotation.x = -sin(_phase) * swing * side * 0.8
if anim == Anim.SWIM:
_arms[0].rotation.x = -PI * 0.6 + sin(_phase * 1.5) * 0.6
_arms[1].rotation.x = -PI * 0.6 - sin(_phase * 1.5) * 0.6
elif not held.is_empty():
_arms[1].rotation.x = -0.5 + sin(_phase) * swing * 0.3 # hold the item out a little
_body.position.y = absf(sin(_phase)) * 0.03 * clampf(_speed / 5.0, 0.0, 1.0)
func _fade_tag() -> void:
var cam := get_viewport().get_camera_3d() if is_inside_tree() else null
if cam == null:
return
var d := cam.global_position.distance_to(global_position)
tag.modulate.a = 1.0 - smoothstep(TAG_FADE.x, TAG_FADE.y, d)
tag.visible = tag.modulate.a > 0.01

View File

@@ -0,0 +1,69 @@
extends RefCounted
## Procedural low-poly player character, coloured by the player's appearance
## (identity.gd: tunic, hair, beard). Feet at the origin, facing -Z, 1.8 m
## tall to match the player capsule.
##
## build() returns:
## body: torso, belt, head, hair and beard (static)
## arm / leg: one limb each, hanging from its pivot down -Y
## shoulders / hips: the pivot points (left, right)
## hand: where a held item goes, in arm space
const P = preload("res://scripts/world/proc_mesh.gd")
const IM = preload("res://scripts/items/item_meshes.gd")
const Identity = preload("res://scripts/net/identity.gd")
const SKIN := Color(0.86, 0.68, 0.55)
const TROUSERS := Color(0.30, 0.25, 0.20)
const BOOTS := Color(0.20, 0.14, 0.10)
const BELT := Color(0.35, 0.22, 0.12)
const EYES := Color(0.08, 0.07, 0.06)
static var _cache := {}
static func build(appearance: Dictionary) -> Dictionary:
var a := Identity.sanitize_appearance(appearance)
var key := "%d_%d_%s" % [a.tunic, a.hair, a.beard]
if _cache.has(key):
return _cache[key]
var tunic: Color = Identity.TUNICS[a.tunic]
var hair: Color = Identity.HAIR[a.hair]
var r := RandomNumberGenerator.new()
r.seed = 7
var n := FastNoiseLite.new()
n.seed = 7
n.frequency = 1.5
var st := P._begin()
IM._box(st, Vector3(0, 1.2, 0), Vector3(0.42, 0.56, 0.24), tunic) # tunic
IM._box(st, Vector3(0, 0.93, 0), Vector3(0.44, 0.1, 0.26), P._mul(tunic, 0.85)) # skirt hem
IM._box(st, Vector3(0, 1.0, 0), Vector3(0.45, 0.06, 0.27), BELT)
IM._box(st, Vector3(0, 1.0, -0.135), Vector3(0.07, 0.05, 0.02), Color(0.6, 0.55, 0.4)) # buckle
IM._box(st, Vector3(0, 1.5, 0), Vector3(0.12, 0.06, 0.12), SKIN) # neck
P._blob(st, Vector3(0, 1.65, 0), Vector3(0.13, 0.15, 0.14), n, 0.05, SKIN, r, 1) # head
for s in [-1.0, 1.0]:
IM._box(st, Vector3(s * 0.05, 1.67, -0.13), Vector3(0.03, 0.025, 0.02), EYES)
IM._box(st, Vector3(0, 1.62, -0.145), Vector3(0.035, 0.05, 0.03), P._mul(SKIN, 0.92)) # nose
P._blob(st, Vector3(0, 1.72, 0.02), Vector3(0.145, 0.1, 0.15), n, 0.15, hair, r, 1) # hair
IM._box(st, Vector3(0, 1.66, 0.11), Vector3(0.24, 0.14, 0.06), hair) # back of the head
if a.beard:
IM._box(st, Vector3(0, 1.54, -0.115), Vector3(0.2, 0.13, 0.07), hair)
IM._box(st, Vector3(0, 1.47, -0.1), Vector3(0.12, 0.06, 0.05), hair)
var body := P._commit(st)
st = P._begin()
IM._box(st, Vector3(0, -0.2, 0), Vector3(0.12, 0.42, 0.13), tunic)
IM._box(st, Vector3(0, -0.47, 0), Vector3(0.09, 0.12, 0.1), SKIN)
var arm := P._commit(st)
st = P._begin()
IM._box(st, Vector3(0, -0.4, 0), Vector3(0.14, 0.78, 0.15), TROUSERS)
IM._box(st, Vector3(0, -0.85, -0.03), Vector3(0.15, 0.14, 0.22), BOOTS)
var leg := P._commit(st)
var out := {"body": body, "arm": arm, "leg": leg,
"shoulders": [Vector3(-0.27, 1.43, 0), Vector3(0.27, 1.43, 0)],
"hips": [Vector3(-0.1, 0.92, 0), Vector3(0.1, 0.92, 0)],
"hand": Vector3(0, -0.5, -0.02)}
_cache[key] = out
return out

235
scripts/net/chat.gd Normal file
View File

@@ -0,0 +1,235 @@
extends Node
## Autoload "Chat": talking on a server, and its commands
## (docs/MULTIPLAYER_PLAN.md §6).
##
## T opens the chat line. Plain text goes to everyone (global chat). A line
## starting with / is a command; the server console takes the same commands
## without the /, when started with --console (server/run_server.sh does).
##
## Anyone: /help /players /rules /friend <name> /unfriend <name> /friends
## Admins: /kick <name> /ban <name> /unban <name> /admin <name> /save
## /backup /backups /time 08:00 /weather clear|cloudy|rain|storm|snow|blizzard|auto
## /set <rule> <value> /say <message> /release-name <name>
signal message(text: String, kind: String) ## client: show this (kind: chat | server | reply)
const MAX_LEN := 200
const GAP := 0.4 ## seconds between one player's lines
const ADMIN_ONLY := ["kick", "ban", "unban", "admin", "save", "backup", "backups", "time", "weather", "set", "say", "release-name"]
var _last: Dictionary = {} ## peer -> ticks of their last line
var _console: Thread
var heard: Array = [] ## client: everything shown (tests read it)
func _ready() -> void:
if Net.parse_args(OS.get_cmdline_user_args()).has("console"):
Net.state_changed.connect(func(s):
if s == "server" and _console == null:
_console = Thread.new()
_console.start(_read_console))
# --- client ------------------------------------------------------------------------
func say(text: String) -> void:
text = text.strip_edges()
if text.is_empty() or Net.state != "connected":
return
_c2s_say.rpc_id(1, text.substr(0, MAX_LEN))
func _show(text: String, kind: String) -> void:
heard.append(text)
message.emit(text, kind)
@rpc("authority", "call_remote", "reliable")
func _s2c_line(text: String, kind: String) -> void:
_show(text, kind)
# --- server ------------------------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable")
func _c2s_say(text: String) -> void:
var peer := multiplayer.get_remote_sender_id()
if not Net.is_server() or not Net.players.has(peer):
return
text = text.strip_edges().substr(0, MAX_LEN).replace("\n", " ")
if text.is_empty():
return
var who: String = Net.players[peer].name
if text.begins_with("/"):
print("[server] %s: %s" % [who, text])
run(text.substr(1), peer)
return
# Plain chat only: a line every GAP seconds, so nobody can flood it.
var now := Time.get_ticks_msec()
if now - int(_last.get(peer, -100000)) < GAP * 1000.0:
_reply(peer, "(Too fast: that line wasn't sent)")
return
_last[peer] = now
print("[chat] %s: %s" % [who, text])
_all("%s: %s" % [who, text], "chat")
func _all(text: String, kind: String) -> void:
if kind == "server":
print("[server] ", text)
for peer in Net.players:
_s2c_line.rpc_id(peer, text, kind)
## An answer to whoever gave the command (peer 0 = the console).
func _reply(peer: int, text: String) -> void:
if peer == 0:
print(text)
else:
_s2c_line.rpc_id(peer, text, "reply")
func _is_admin(peer: int) -> bool:
return peer == 0 or (Net.players.has(peer) and Net.config.is_admin(String(Net.players[peer].id)))
## The player id a name belongs to (online or reserved), or "".
func _id_of(n: String) -> String:
var peer := Net.peer_by_name(n)
if peer != 0:
return String(Net.players[peer].id)
return Net.config.name_owner(n)
## Runs a command line (without the /) from player `peer`, or the console (0).
func run(line: String, peer: int) -> void:
var parts := line.strip_edges().split(" ", false)
if parts.is_empty():
return
var cmd := parts[0].to_lower()
var rest := " ".join(parts.slice(1))
var arg: String = parts[1] if parts.size() > 1 else ""
if cmd in ADMIN_ONLY and not _is_admin(peer):
_reply(peer, "Only admins can use /%s" % cmd)
return
var me: String = String(Net.players[peer].id) if Net.players.has(peer) else ""
var by: String = String(Net.players[peer].name) if Net.players.has(peer) else "The server"
match cmd:
"help":
_reply(peer, "Commands: /players /rules /friend <name> /unfriend <name> /friends" +
(" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else ""))
"players":
var names: Array[String] = []
for p in Net.players.values():
names.append(String(p.name) + (" (admin)" if Net.config.is_admin(String(p.id)) else ""))
_reply(peer, "%d online: %s" % [names.size(), ", ".join(names)])
"rules":
var c: RefCounted = Net.config
_reply(peer, "PvP %s · raiding %s · locks %s · death: %s · sleep: %s · difficulty %s · wolves after day %d" % [
"on" if bool(c.v("pvp")) else "off", "on" if bool(c.v("raiding")) else "off", c.v("locks"),
c.v("death_penalty"), c.v("sleep"), c.v("difficulty"), int(c.v("wolf_grace_days"))])
"friend", "unfriend":
if me.is_empty():
_reply(peer, "Only players have friends")
return
var fid := _id_of(arg)
if fid.is_empty() or fid == me:
_reply(peer, "No player called '%s' here" % arg)
elif cmd == "friend":
Net.config.add_friend(me, fid)
_reply(peer, "%s can now use your doors, chests and beds" % arg)
var fp := Net.peer_by_name(arg)
if fp != 0:
_reply(fp, "%s added you as a friend: you can use their doors, chests and beds" % by)
else:
_reply(peer, "%s is no longer a friend" % arg if Net.config.remove_friend(me, fid) else "%s wasn't a friend" % arg)
"friends":
if me.is_empty():
return
var names: Array[String] = []
for fid in Net.config.friends.get(me, []):
names.append(Net.config.name_of(String(fid)))
_reply(peer, "Your friends: %s" % (", ".join(names) if not names.is_empty() else "nobody yet (/friend <name>)"))
"kick":
var kp := Net.peer_by_name(arg)
if kp == 0:
_reply(peer, "Nobody called '%s' is online" % arg)
return
Net.kick(kp, "You were kicked by %s" % by)
_all("%s was kicked" % arg, "server")
"ban":
var bid := _id_of(arg)
if bid.is_empty():
_reply(peer, "No player called '%s'" % arg)
return
Net.config.ban(bid)
var bp := Net.peer_by_name(arg)
if bp != 0:
Net.kick(bp, "You were banned by %s" % by)
_all("%s was banned" % arg, "server")
"unban":
var uid: String = Net.config.name_owner(arg)
_reply(peer, "%s can join again" % arg if not uid.is_empty() and Net.config.unban(uid) else "'%s' isn't banned" % arg)
"admin":
var aid := _id_of(arg)
if aid.is_empty():
_reply(peer, "No player called '%s'" % arg)
return
Net.config.add_admin(aid)
_reply(peer, "%s is now an admin" % arg)
var ap := Net.peer_by_name(arg)
if ap != 0:
Net.players[ap].admin = true
_reply(ap, "%s made you an admin (/help for the commands)" % by)
"save":
WorldNet.save()
_reply(peer, "Saved")
"backup":
WorldNet.save()
var b: String = WorldNet.backup("by %s" % by)
_reply(peer, "Backed up as %s (%d kept)" % [b, WorldNet.list_backups().size()] if not b.is_empty() else "Nothing to back up yet")
"backups":
var all: Array = WorldNet.list_backups()
_reply(peer, "%d backups, newest last: %s" % [all.size(), ", ".join(PackedStringArray(all.slice(-5)))] if not all.is_empty() else "No backups yet")
"time":
var hm := arg.split(":")
if hm.size() < 1 or not hm[0].is_valid_int() or int(hm[0]) > 23 or (hm.size() > 1 and not hm[1].is_valid_int()):
_reply(peer, "Use /time HH:MM, e.g. /time 08:00")
return
var w: Node3D = WorldNet.world
w.day_night.time = (int(hm[0]) + (int(hm[1]) if hm.size() > 1 else 0) / 60.0) / 24.0
w.day_night.apply(true)
w.weather.snap()
Net.push_clock()
_all("%s set the time to %02d:%02d" % [by, int(hm[0]), int(hm[1]) if hm.size() > 1 else 0], "server")
"weather":
var states: Array = preload("res://scripts/world/weather.gd").STATES
if arg != "auto" and not arg in states:
_reply(peer, "Use /weather %s or auto" % "|".join(PackedStringArray(states)))
return
Net.set_weather("" if arg == "auto" else arg)
_all("%s set the weather to %s" % [by, arg], "server")
"set":
if parts.size() < 3:
_reply(peer, "Use /set <rule> <value>, e.g. /set pvp true (/rules shows them)")
return
var why: String = Net.config.set_rule(parts[1], " ".join(parts.slice(2)))
if not why.is_empty():
_reply(peer, why)
return
Net.broadcast_rules()
_all("%s set %s to %s" % [by, parts[1], " ".join(parts.slice(2))], "server")
"say":
_all("[Server] %s" % rest, "server")
"release-name":
_reply(peer, "'%s' is free for anyone now" % arg if Net.config.release_name(arg) else "Nobody has the name '%s'" % arg)
_:
_reply(peer, "Unknown command /%s (/help lists them)" % cmd)
# --- server console ------------------------------------------------------------------
## Reads commands typed into the server's terminal (a thread: reading blocks).
func _read_console() -> void:
var empty := 0
while empty < 20:
var line := OS.read_string_from_stdin(1024).strip_edges()
if line.is_empty():
empty += 1
OS.delay_msec(250)
continue
empty = 0
_console_line.call_deferred(line.trim_prefix("/"))
func _console_line(line: String) -> void:
if Net.is_server():
run(line, 0)

265
scripts/net/creature_net.gd Normal file
View File

@@ -0,0 +1,265 @@
extends Node
## Autoload "CreatureNet": the wildlife on a server (M2.5,
## docs/MULTIPLAYER_PLAN.md §5).
##
## The server's world runs the animals and the wolf pack (world.gd in server
## mode), so everyone sees the same rabbit in the same place, and a pack
## hunts whoever it likes. Ten times a second it sends everyone:
## - every animal and wolf, alive or a carcass: id, kind, where, facing,
## what it's doing, its speed and health;
## - every fishing float: whose it is, where, and whether a fish is biting.
## Players' games show these as puppets (creature.gd, bobber.gd `puppet`):
## they animate and can be hit or butchered (Actions sends that to the
## server), but decide nothing. Arrows and spears in flight are sent once
## when they're loosed and flown on each screen for show; only the server's
## copy hits anything. Howls are sent too.
##
## Snapshots are unreliable (a lost one is replaced 0.1 s later). Something
## missing from the snapshots for a second (despawned, butchered) goes.
const Creature = preload("res://scripts/creatures/creature.gd")
const BearScript = preload("res://scripts/creatures/bear.gd")
const CrowScript = preload("res://scripts/creatures/crow.gd")
const GuardScript = preload("res://scripts/creatures/guard.gd")
const StagScript = preload("res://scripts/creatures/old_stag.gd") # (rears and lowers its antlers) # (falls apart in its own way)
const Bobber = preload("res://scripts/fishing/bobber.gd")
const Projectile = preload("res://scripts/creatures/projectile.gd")
const WolfPack = preload("res://scripts/creatures/wolf_pack.gd")
const SEND_INTERVAL := 0.1
const FORGET_AFTER := 1.0
# Server.
var _next_cid := 1
var _by_cid: Dictionary = {} ## cid -> creature (server)
var _send_left := 0.0
# Client.
var world: Node3D
var puppets: Dictionary = {} ## cid -> creature.gd puppet
var floats: Dictionary = {} ## owner peer -> bobber.gd puppet
var _seen: Dictionary = {} ## cid or "f<peer>" -> last time seen (s)
var _root: Node3D
var _howl_player: AudioStreamPlayer3D
var projectiles_seen := 0 ## tests read these
var howls_heard := 0
# --- server ----------------------------------------------------------------------
func _server_world() -> Node3D:
return WorldNet.world if Net.is_server() and WorldNet.world != null and is_instance_valid(WorldNet.world) else null
## The server's creature with id `cid`, alive or a carcass.
func creature(cid: int) -> Node3D:
var c: Node3D = _by_cid.get(cid)
return c if c != null and is_instance_valid(c) and not c.is_queued_for_deletion() else null
func _process(delta: float) -> void:
var w := _server_world()
if w != null:
_server_tick(w, delta)
elif world != null:
_client_tick()
func _server_tick(w: Node3D, delta: float) -> void:
if not w.launched.is_connected(_on_launched):
w.launched.connect(_on_launched)
_send_left -= delta
if _send_left > 0.0:
return
_send_left = SEND_INTERVAL
var all: Array = []
_by_cid.clear()
for group in ["creature", "carcass"]:
for c in w.get_tree().get_nodes_in_group(group):
if not c.has_meta("cid"):
c.set_meta("cid", _next_cid)
_next_cid += 1
_by_cid[int(c.get_meta("cid"))] = c
all.append(c)
var fl: Array = []
for p in w.bobbers:
var b: Node3D = w.bobbers[p]
if is_instance_valid(b) and is_instance_valid(p):
fl.append([int(p.peer), b.global_position, b.state])
if all.is_empty() and fl.is_empty():
return
# Packed tight and split, so no packet goes over the MTU (a lost
# fragment loses the whole unreliable packet).
var i := 0
while i < maxi(all.size(), 1):
var chunk := PackedByteArray()
chunk.resize(mini(PER_PACKET, all.size() - i) * ENTRY)
var o := 0
for c in all.slice(i, i + PER_PACKET):
_encode(chunk, o, c)
o += ENTRY
for peer in WorldNet._ready_peers:
_s2c_life.rpc_id(peer, chunk, fl if i == 0 else [])
i += PER_PACKET
const PER_PACKET := 40
const ENTRY := 23
const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf"]
const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash",
"warn", "raid", "eat", "fly", "fly_off", "peck", "sneak", "carry", "circle", "telegraph", "stunned", "summon", "stomp"]
## One creature in 23 bytes: id, kind, state, position, facing, speed, health.
static func _encode(buf: PackedByteArray, o: int, c: Node3D) -> void:
buf.encode_u32(o, int(c.get_meta("cid")))
buf.encode_u8(o + 4, maxi(KIND_IDS.find(c.kind), 0))
buf.encode_u8(o + 5, maxi(STATE_IDS.find(c.state), 0))
var p: Vector3 = c.global_position
buf.encode_float(o + 6, p.x)
buf.encode_float(o + 10, p.y)
buf.encode_float(o + 14, p.z)
buf.encode_u16(o + 18, int(fposmod(c.yaw, TAU) / TAU * 65535.0))
buf.encode_u8(o + 20, clampi(int(c.speed * 20.0), 0, 255))
buf.encode_u16(o + 21, clampi(roundi(c.hp * 10.0), 0, 65535)) # (tenths: up to 6553, the Old Stag has 900)
static func _decode(buf: PackedByteArray, o: int) -> Array:
return [buf.decode_u32(o), KIND_IDS[mini(buf.decode_u8(o + 4), KIND_IDS.size() - 1)],
Vector3(buf.decode_float(o + 6), buf.decode_float(o + 10), buf.decode_float(o + 14)),
buf.decode_u16(o + 18) / 65535.0 * TAU, STATE_IDS[mini(buf.decode_u8(o + 5), STATE_IDS.size() - 1)],
buf.decode_u8(o + 20) / 20.0, buf.decode_u16(o + 21) / 10.0]
func _on_launched(id: String, origin: Vector3, vel: Vector3, shooter: Node3D) -> void:
var by := int(shooter.peer) if shooter != null and is_instance_valid(shooter) and shooter.get("peer") != null else 0
for peer in WorldNet._ready_peers:
_s2c_launched.rpc_id(peer, id, origin, vel, by)
## The pack howls (wolf_pack.gd): everyone hears it, from where it is.
func howl(at: Vector3) -> void:
for peer in WorldNet._ready_peers:
_s2c_howl.rpc_id(peer, at)
# --- client ----------------------------------------------------------------------
func join_world(w: Node3D) -> void:
world = w
puppets.clear()
floats.clear()
_seen.clear()
projectiles_seen = 0
howls_heard = 0
_root = Node3D.new()
_root.name = "NetCreatures"
w.add_child(_root)
func leave_world() -> void:
if not Net.is_server():
world = null
puppets.clear()
floats.clear()
_root = null
static func _now() -> float:
return Time.get_ticks_msec() / 1000.0
func _alive_world() -> bool:
return world != null and is_instance_valid(world) and _root != null and is_instance_valid(_root)
## Anything the server has stopped sending goes.
func _client_tick() -> void:
if not _alive_world():
return
var now := _now()
for cid in puppets.keys():
if now - float(_seen.get(cid, 0.0)) > FORGET_AFTER:
var c: Variant = puppets[cid]
puppets.erase(cid)
if is_instance_valid(c):
(c as Node).queue_free()
for peer in floats.keys():
if now - float(_seen.get("f%d" % peer, 0.0)) > FORGET_AFTER * 0.6:
_drop_float(peer)
func _drop_float(peer: int) -> void:
var b: Variant = floats.get(peer) # (may already be freed: untyped)
floats.erase(peer)
if is_instance_valid(b):
(b as Node).queue_free()
if peer == Net.local_peer and world != null and is_instance_valid(world):
world.bobber = null
@rpc("authority", "call_remote", "unreliable_ordered")
func _s2c_life(packed: PackedByteArray, fl: Array) -> void:
if not _alive_world():
return
var now := _now()
var list: Array = []
for o in range(0, packed.size() - ENTRY + 1, ENTRY):
list.append(_decode(packed, o))
for e in list:
var cid := int(e[0])
var kind := String(e[1])
if not Creature.KINDS.has(kind):
continue
var got: Variant = puppets.get(cid)
var c: Node3D = got if is_instance_valid(got) else null
if c == null:
# (Kinds with their own look: the bear rears up, a crow flaps.)
var script: Script = {"bear": BearScript, "crow": CrowScript, "guard": GuardScript, "old_stag": StagScript}.get(kind, Creature)
c = script.new()
c.puppet = true
c.setup(kind, world, e[2], cid)
c.set_meta("cid", cid)
c.yaw = float(e[3])
_root.add_child(c)
c.global_position = e[2]
c.rotation.y = c.yaw
puppets[cid] = c
c.net_update(e[2], float(e[3]), String(e[4]), float(e[5]), float(e[6]))
_seen[cid] = now
for f in fl:
_float_update(int(f[0]), f[1], String(f[2]))
_seen["f%d" % int(f[0])] = now
func _float_update(peer: int, pos: Vector3, state: String) -> void:
var got: Variant = floats.get(peer)
var b: Node3D = got if is_instance_valid(got) else null
if b == null:
b = Bobber.new()
b.puppet = true
b.setup(world, world.player, pos, Vector3.ZERO, 0)
_root.add_child(b)
floats[peer] = b
if peer == Net.local_peer:
world.bobber = b # hands.gd: the prompt, the line from our rod
b.global_position = pos
b.state = state
if peer != Net.local_peer:
# Someone else's line: from about where their rod would be.
var av: Node3D = world.net_sync.avatars.get(peer) if world.net_sync != null else null
if av != null and is_instance_valid(av):
b.tip = av.global_position + Vector3(0, 1.5, 0) + (-av.global_basis.z) * 0.8
else:
b.tip = pos + Vector3(0, 0.1, 0)
@rpc("authority", "call_remote", "reliable")
func _s2c_launched(id: String, origin: Vector3, vel: Vector3, by: int) -> void:
if not _alive_world():
return
projectiles_seen += 1
var pr: Node3D = Projectile.new()
pr.visual = true
pr.setup(id, world, origin, vel, 0.0, world.player if by == Net.local_peer else null)
_root.add_child(pr)
@rpc("authority", "call_remote", "reliable")
func _s2c_howl(at: Vector3) -> void:
if not _alive_world():
return
howls_heard += 1
Hints.event("howl")
if DisplayServer.get_name() == "headless":
return
if _howl_player == null or not is_instance_valid(_howl_player):
_howl_player = AudioStreamPlayer3D.new()
_howl_player.stream = WolfPack.howl_stream()
_howl_player.unit_size = 30.0
_howl_player.max_distance = 400.0
_howl_player.volume_db = 2.0
_root.add_child(_howl_player)
_howl_player.global_position = at
_howl_player.pitch_scale = randf_range(0.9, 1.1)
_howl_player.play()

100
scripts/net/identity.gd Normal file
View File

@@ -0,0 +1,100 @@
extends RefCounted
## Who you are on multiplayer servers: a private key, a display name and how
## your character looks. No accounts and no passwords.
##
## - The first time the game runs it generates an RSA-2048 key pair and keeps
## the private key in `<dir>/key.pem`. It never leaves this machine.
## - Your **player id** is a hash of the public key. Servers recognise you by
## it (your reserved name, your admin rights, later your inventory).
## - When you join, the server sends a random challenge and you sign it.
## Nobody can claim your id without your private key.
## - `<dir>/profile.json` holds your name, appearance and last server.
##
## Godot's Crypto has no Ed25519, hence RSA (docs/MULTIPLAYER_PLAN.md §3).
## Limited appearance for v1: a tunic colour, a hair colour, a beard or not.
const TUNICS := [Color(0.55, 0.22, 0.18), Color(0.22, 0.38, 0.58), Color(0.28, 0.46, 0.24),
Color(0.62, 0.52, 0.28), Color(0.42, 0.28, 0.52), Color(0.35, 0.35, 0.36)]
const HAIR := [Color(0.18, 0.12, 0.08), Color(0.45, 0.28, 0.14), Color(0.78, 0.6, 0.32),
Color(0.62, 0.22, 0.1), Color(0.55, 0.55, 0.55), Color(0.1, 0.1, 0.1)]
const DEFAULT_DIR := "user://identity"
var dir := DEFAULT_DIR
var key: CryptoKey
var public_pem := ""
var player_id := ""
var name := ""
var appearance := {"tunic": 0, "hair": 0, "beard": false}
var last_server := ""
## Loads the identity in `d`, creating the key on first use.
static func open(d: String = DEFAULT_DIR) -> RefCounted:
var me = load("res://scripts/net/identity.gd").new()
me.dir = d
DirAccess.make_dir_recursive_absolute(d)
var key_path := d.path_join("key.pem")
me.key = CryptoKey.new()
if not FileAccess.file_exists(key_path) or me.key.load(key_path) != OK:
me.key = Crypto.new().generate_rsa(2048)
var err: int = me.key.save(key_path)
if err != OK:
push_error("[identity] couldn't save the key to %s (%d)" % [key_path, err])
me.public_pem = me.key.save_to_string(true)
me.player_id = id_for(me.public_pem)
var p: Variant = JSON.parse_string(FileAccess.get_file_as_string(d.path_join("profile.json"))) \
if FileAccess.file_exists(d.path_join("profile.json")) else null
if p is Dictionary:
me.name = String(p.get("name", ""))
me.appearance = sanitize_appearance(p.get("appearance", {}))
me.last_server = String(p.get("last_server", ""))
return me
func save_profile() -> void:
var f := FileAccess.open(dir.path_join("profile.json"), FileAccess.WRITE)
if f == null:
push_error("[identity] couldn't write the profile")
return
f.store_string(JSON.stringify({"name": name, "appearance": appearance, "last_server": last_server}, "\t"))
## Signs a server's challenge with the private key.
func sign(nonce: PackedByteArray) -> PackedByteArray:
return Crypto.new().sign(HashingContext.HASH_SHA256, _sha256(nonce), key)
## A short, stable id from a public key (first 20 hex characters of SHA-256).
static func id_for(pem: String) -> String:
return pem.sha256_text().substr(0, 20)
## Whether `sig` is the owner of `pem` signing `nonce`.
static func verify(pem: String, nonce: PackedByteArray, sig: PackedByteArray) -> bool:
var pk := CryptoKey.new()
if pem.length() > 4096 or pk.load_from_string(pem, true) != OK:
return false
return Crypto.new().verify(HashingContext.HASH_SHA256, _sha256(nonce), sig, pk)
static func _sha256(b: PackedByteArray) -> PackedByteArray:
var h := HashingContext.new()
h.start(HashingContext.HASH_SHA256)
h.update(b)
return h.finish()
## Clamps appearance from a file or the network to the allowed choices.
static func sanitize_appearance(d: Variant) -> Dictionary:
var a: Dictionary = d if d is Dictionary else {}
return {
"tunic": clampi(int(a.get("tunic", 0)), 0, TUNICS.size() - 1),
"hair": clampi(int(a.get("hair", 0)), 0, HAIR.size() - 1),
"beard": bool(a.get("beard", false)),
}
## "" if `n` is an allowed display name, otherwise why not.
static func name_problem(n: String) -> String:
if n != n.strip_edges():
return "Names can't start or end with a space"
if n.length() < 3 or n.length() > 16:
return "Names need 3-16 characters"
if n.contains(" "):
return "Names can't have double spaces"
var re := RegEx.create_from_string("^[A-Za-z0-9 _\\-]+$")
if re.search(n) == null:
return "Names can use letters, digits, spaces, _ and -"
return ""

832
scripts/net/net.gd Normal file
View File

@@ -0,0 +1,832 @@
extends Node
## Autoload "Net": the multiplayer layer (docs/MULTIPLAYER_PLAN.md).
##
## M2.1 covers the skeleton: a dedicated server, joining it, the identity
## handshake, name rules and the player list. Gameplay over the network
## follows in M2.2-M2.5; single-player still runs as before.
##
## Server: `godot --headless --path . -- --server [--port 24242]
## [--config user://server/server.json] [--server-name "..."]
## [--max-players 8] [--password ...]`
## Client: Net.join("host:port", password) from the title screen.
##
## Handshake (all reliable ENet over one UDP port):
## client -> server _c2s_hello {protocol, name, appearance, public_key, password}
## server -> client _s2c_challenge nonce (32 random bytes)
## client -> server _c2s_proof signature (nonce signed with the private key)
## server -> client _s2c_welcome {...} or _s2c_rejected reason
## Then the server tells everyone about joins and leaves.
signal state_changed(state: String) ## offline | connecting | connected | server
signal joined ## the server welcomed us
signal join_failed(reason: String)
signal left(reason: String) ## disconnected after joining
signal players_changed
signal player_joined(name: String) ## someone else came in (clients)
signal player_left(name: String)
## Everyone's positions at server time `t`: [[peer, pos, yaw, pitch, anim, held], ...].
signal snapshot(t: float, states: Array)
signal corrected(pos: Vector3) ## the server refused a move: go back here
signal clock(day: int, time: float) ## the server's time of day
const Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 12 ## 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)
const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
const SEND_INTERVAL := 0.05 ## position updates, both ways (20 Hz)
const CLOCK_INTERVAL := 10.0
## Movement checks: sprinting is 8 m/s, so 9.5 plus a little slack for
## packets that arrive bunched together.
const MAX_SPEED := 9.5
const MAX_RISE := 9.0
const MOVE_SLACK := 1.0
const SPAWN_TELEPORT_RADIUS := 25.0 ## a flagged teleport (respawning) must land near the spawn
var state := "offline"
var dedicated := false ## started with --server
var identity: RefCounted ## identity.gd (clients)
var config: RefCounted ## server_config.gd (server)
var server_info: Dictionary = {} ## the server's public settings (clients)
## Everyone on the server: peer id -> {"id", "name", "appearance", "admin"}.
var players: Dictionary = {}
var local_peer := 0
## What the server told us about its world: seed, worldlet, spawn, clock.
var world_info: Dictionary = {}
var last_reason := "" ## why the last join failed or ended (the title shows it)
## Test hooks: simulated network delay on this client, both directions.
var debug_latency_ms := 0.0
var debug_jitter_ms := 0.0
# Server state.
var _world: Node3D ## the shared island (world.gd in server mode)
var _terrain: Node3D ## its terrain, for movement checks
var _spawn := Vector3.ZERO
var _states: Dictionary = {} ## peer -> {"pos", "yaw", "pitch", "anim", "held", "t", "corr_t"}
var server_time := 0.0 ## seconds since the server started
var _send_left := 0.0
var _clock_left := 0.0
# Client delay queues (debug latency): [release_ms, callable].
var _out_q: Array = []
var _in_q: Array = []
## Test hooks (tests/net_bot.gd): sign with the wrong key, or claim another
## protocol version.
var debug_forge := false
var debug_protocol := 0
var debug_skip_name_check := false ## send a bad name anyway: the server must refuse it
var _peer: ENetMultiplayerPeer
var _pending: Dictionary = {} ## server: peer -> {"t": secs, "hello": {}, "nonce": bytes}
var _reject_reason := ""
var _join_left := 0.0
var _args: Dictionary = {}
func _ready() -> void:
_args = parse_args(OS.get_cmdline_user_args())
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(_on_connection_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
if _args.has("server"):
dedicated = true
_start_dedicated.call_deferred()
# `--exit-with <pid>`: quit when that process is gone (test servers and
# bots, so a crashed test never leaves them running).
if _args.has("exit-with"):
var parent := int(_args["exit-with"])
var t := Timer.new()
t.wait_time = 1.0
t.autostart = true
t.timeout.connect(func():
# (OS.is_process_running only works for our own children.)
if OS.get_name() != "Windows" and OS.execute("kill", ["-0", str(parent)]) != 0:
print("[net] parent process %d is gone, quitting" % parent)
stop()
get_tree().quit())
add_child(t)
## "--a=b", "--a b" and bare "--flag" all work.
static func parse_args(args: PackedStringArray) -> Dictionary:
var out := {}
var i := 0
while i < args.size():
var a: String = args[i]
if a.begins_with("--"):
var k := a.trim_prefix("--")
if k.contains("="):
out[k.get_slice("=", 0)] = k.substr(k.find("=") + 1)
elif i + 1 < args.size() and not args[i + 1].begins_with("--"):
out[k] = args[i + 1]
i += 1
else:
out[k] = true
i += 1
return out
## The local identity, loaded on first use (`--identity <dir>` overrides).
func get_identity() -> RefCounted:
if identity == null:
identity = Identity.open(String(_args.get("identity", Identity.DEFAULT_DIR)))
return identity
func _set_state(s: String) -> void:
state = s
state_changed.emit(s)
func is_server() -> bool:
return state == "server"
# --- server -------------------------------------------------------------------
## Why this Godot can't run the server ("" = it can): too old, or the game's
## scripts failed to load (usually a fresh copy that's never been imported).
func startup_problem() -> String:
var v := Engine.get_version_info()
if v.major < 4 or (v.major == 4 and v.minor < 7):
return "This game needs Godot 4.7 or newer, but this is %s. Download 4.7.2 from https://godotengine.org/download" % v.string
var root: Window = (Engine.get_main_loop() as SceneTree).root
for n in ["ItemDB", "Actions", "Saves"]:
var node := root.get_node_or_null(n)
if node == null or node.get_script() == null:
return "The game's scripts didn't load (see the errors above). If this is a fresh copy of the project, " + \
"import it once first: godot --headless --path . --import (server/run_server.sh does this for you)"
return ""
func _start_dedicated() -> void:
var bad := startup_problem()
if not bad.is_empty():
printerr("[server] Not starting: ", bad)
get_tree().quit(1)
return
var err := start_server(String(_args.get("config", ServerConfig.DEFAULT_PATH)), _args)
if not err.is_empty():
printerr("[server] ", err)
get_tree().quit(1)
return
# A real dedicated server (not one a test or Host Game started) says in
# its log when there's a newer version; server.json "check_updates".
if not _args.has("exit-with") and bool(config.v("check_updates")):
Updates.checked.connect(_on_update_checked, CONNECT_ONE_SHOT)
Updates.check(true)
func _on_update_checked(s: String) -> void:
if s == "available":
print("[server] Emberwild %s is out (protocol %d); this server runs %s (protocol %d). %s" % [
Updates.latest_version(), Updates.latest_protocol(), Updates.version, PROTOCOL,
"Players with the new version can't join until it's updated." if Updates.latest_protocol() != PROTOCOL else ""])
## Opens the server. Returns "" on success, otherwise what went wrong.
func start_server(config_path: String, overrides: Dictionary = {}) -> String:
if state != "offline":
return "Already %s" % state
config = ServerConfig.open(config_path)
var map := {"port": "port", "server-name": "server_name", "max-players": "max_players",
"password": "password", "world": "world", "seed": "seed"}
for flag in map:
if overrides.has(flag):
var v: Variant = overrides[flag]
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v)
# The shared island (world_net.gd): the same terrain and scenery every
# client generates from the seed, plus everything players changed.
_world = WorldNet.open_server_world(config)
_terrain = _world.terrain
_spawn = _terrain.find_spawn(Vector2.ZERO)
server_time = 0.0
_peer = ENetMultiplayerPeer.new()
var port := int(config.v("port"))
# A few spare slots so a full server can still say "server is full".
var err := _peer.create_server(port, int(config.v("max_players")) + 4)
if err != OK:
WorldNet.close_server_world()
_world = null
_terrain = null
return "Couldn't open UDP port %d (error %d). Is another server already running on it?" % [port, err]
multiplayer.multiplayer_peer = _peer
multiplayer.server_relay = false # clients only ever talk to the server
players.clear()
_pending.clear()
_set_state("server")
print("[server] '%s' listening on UDP %d (max %d players%s). Config: %s" % [config.v("server_name"), port,
int(config.v("max_players")), ", password set" if not String(config.v("password")).is_empty() else "",
ProjectSettings.globalize_path(config.path)])
return ""
func stop() -> void:
if _peer != null:
_peer.close()
_peer = null
multiplayer.multiplayer_peer = null
players.clear()
_pending.clear()
_states.clear()
server_info.clear()
world_info.clear()
_out_q.clear()
_in_q.clear()
local_peer = 0
if _world != null:
WorldNet.close_server_world() # saves it first
_world = null
_terrain = null
if state != "offline":
_set_state("offline")
func _on_peer_connected(id: int) -> void:
if is_server():
_pending[id] = {"t": 0.0}
func _on_peer_disconnected(id: int) -> void:
if not is_server():
return
_pending.erase(id)
_states.erase(id)
if players.has(id):
var p: Dictionary = players[id]
players.erase(id)
print("[server] %s left (%d online)" % [p.name, players.size()])
_s2c_player_left.rpc(id)
PlayerNet.server_leave(id) # saves their character
WorldNet.peer_left(id)
if _hosted_by(String(p.id)):
_host_left.call_deferred()
players_changed.emit()
func _process(delta: float) -> void:
if is_server():
for id in _pending.keys():
_pending[id].t += delta
if _pending[id].t > HANDSHAKE_TIMEOUT:
_reject(id, "Took too long to say hello")
server_time += delta
_tick_clock(delta)
_send_left -= delta
if _send_left <= 0.0:
_send_left = SEND_INTERVAL
_broadcast_snapshot()
return
_host_tick()
if state == "connecting":
_join_left -= delta
if _join_left <= 0.0:
_fail("No answer from the server")
_run_queue(_out_q)
_run_queue(_in_q)
## The world's clock (it runs while anyone's on, or always if the server's
## set not to pause when empty: world_net.gd pauses the world). Players get
## it every few seconds.
func _tick_clock(delta: float) -> void:
_clock_left -= delta
if _clock_left <= 0.0 and not players.is_empty():
_clock_left = CLOCK_INTERVAL
for peer in players:
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
# --- hosting from the title screen (M2.7) ------------------------------------------
# "Host Game" runs a dedicated server as a separate, windowless copy of the
# game on this machine, then joins it like anyone else (so you're its first
# player, and its admin). Its world and settings live in user://host/. Its
# log goes to user://host/server.log. Leaving asks it to save and stop.
signal host_status(text: String, failed: bool)
var host_config := "user://host/server.json" ## (tests use their own)
var hosting := false
var _host_pid := 0
var _host_io: FileAccess
var _host_log: FileAccess
var _host_out := ""
var _host_port := DEFAULT_PORT
## Settings for a hosted game: name, password, port, a rules preset
## (co-op / rivals / adversarial) and the seed for a new island.
const PRESETS := {
"co-op": {"pvp": "false", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"rivals": {"pvp": "true", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"adversarial": {"pvp": "true", "raiding": "true", "locks": "owner", "death_penalty": "drop_all"},
}
func host(server_name: String, password: String, port: int, preset: String, seed: int) -> void:
if hosting or state != "offline":
return
if _host_pid != 0 and OS.is_process_running(_host_pid):
host_status.emit("The last server is still saving: try again in a moment", true)
return
var c: RefCounted = ServerConfig.open(host_config)
c.set_rule("server_name", server_name)
for k in PRESETS.get(preset, PRESETS["co-op"]):
c.set_rule(k, PRESETS[preset][k])
c.data.password = password
c.data.port = port
var fresh := not FileAccess.file_exists(host_config.get_base_dir().path_join("worlds/server-world/meta.json"))
if fresh:
c.data.seed = seed
c.save()
_host_port = port
var args := PackedStringArray(["--headless"])
if not OS.has_feature("template"):
args.append_array(["--path", ProjectSettings.globalize_path("res://")]) # (running from the editor build)
args.append_array(["--", "--server", "--host-id", String(get_identity().player_id),
"--config", ProjectSettings.globalize_path(host_config), "--exit-with", str(OS.get_process_id())])
var p: Dictionary = OS.execute_with_pipe(OS.get_executable_path(), args, false)
if p.is_empty():
host_status.emit("Couldn't start the server", true)
return
_host_pid = int(p.pid)
_host_io = p.stdio
_host_out = ""
_host_log = FileAccess.open(host_config.get_base_dir().path_join("server.log"), FileAccess.WRITE)
hosting = true
host_status.emit("Starting the server%s..." % (" (building a new island)" if fresh else ""), false)
## Reads what the hosted server prints (so its output never fills the pipe),
## and joins it once it's listening.
func _host_tick() -> void:
if _host_io == null:
return
var chunk := _host_io.get_buffer(8192)
if not chunk.is_empty():
var s := chunk.get_string_from_utf8()
if _host_log != null:
_host_log.store_string(s)
_host_log.flush()
if hosting and state == "offline":
_host_out += s
if _host_out.contains("listening on UDP"):
_host_out = ""
host_status.emit("Joining your server...", false)
join("127.0.0.1:%d" % _host_port, String(ServerConfig.open(host_config).data.password))
elif _host_out.contains("[server] Couldn't open UDP port") or _host_out.contains("Not starting"):
var why := "Port %d is already in use (another server running?)" % _host_port \
if _host_out.contains("Couldn't open") else "The server couldn't start (see user://host/server.log)"
stop_hosting()
host_status.emit(why, true)
if _host_pid != 0 and not OS.is_process_running(_host_pid):
var was_hosting := hosting
_host_pid = 0
# It's gone: whatever it printed last is still in the pipe.
for _i in range(64):
var rest := _host_io.get_buffer(8192)
if rest.is_empty():
break
if _host_log != null:
_host_log.store_string(rest.get_string_from_utf8())
if _host_log != null:
_host_log.flush()
_host_io = null
hosting = false
if was_hosting and state == "offline":
host_status.emit("The server stopped (see user://host/server.log)", true)
## Stops the hosted server: asks it to save and quit, then makes sure. Its
## output is still read (into the log) until it's gone.
func stop_hosting() -> void:
if not hosting:
return
hosting = false
var pid := _host_pid
if pid != 0:
get_tree().create_timer(6.0).timeout.connect(func():
if OS.is_process_running(pid):
OS.kill(pid))
## The addresses friends can use to reach this machine: the local network,
## and Tailscale (100.x) if it's running.
static func my_addresses() -> Array[String]:
var out: Array[String] = []
for a in IP.get_local_addresses():
if a.contains(":") or a.begins_with("127.") or a.begins_with("169.254."):
continue
out.append(a)
out.sort_custom(func(x, y): return (0 if x.begins_with("100.") else 1) < (0 if y.begins_with("100.") else 1))
return out
## A hosted server (started with --host-id): when the player who hosts it
## leaves, it saves and stops (it lives on their computer).
func _hosted_by(id: String) -> bool:
return is_server() and _args.has("host-id") and String(_args["host-id"]) == id
func _host_left() -> void:
print("[server] the host left: saving and stopping")
stop() # (saves the world and everyone's characters)
get_tree().quit()
# --- admin (chat.gd) ---------------------------------------------------------------
signal rules_changed ## clients: the server's rules changed (/set)
## The peer of the player online called `n` (any case), or 0.
func peer_by_name(n: String) -> int:
for peer in players:
if String(players[peer].name).to_lower() == n.strip_edges().to_lower():
return peer
return 0
## Disconnects a player with a reason (their title screen shows it).
func kick(peer: int, reason: String) -> void:
if players.has(peer):
_reject(peer, reason)
## Everyone gets the rules again (after /set) and the weather override.
func broadcast_rules() -> void:
_world.apply_rules()
for peer in players:
_s2c_rules.rpc_id(peer, config.public_info())
func set_weather(state: String) -> void:
_world.weather.force(state)
for peer in players:
_s2c_weather.rpc_id(peer, state)
@rpc("authority", "call_remote", "reliable")
func _s2c_rules(info: Dictionary) -> void:
server_info = info
rules_changed.emit()
@rpc("authority", "call_remote", "reliable")
func _s2c_weather(state: String) -> void:
world_info.weather = state
weather_changed.emit(state)
signal weather_changed(state: String)
## Everyone gets the clock now (after a night's sleep, an admin's /time).
func push_clock() -> void:
_clock_left = CLOCK_INTERVAL
for peer in players:
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
## Where a player is, as far as the server knows.
func player_pos(peer: int) -> Vector3:
return _states[peer].pos if _states.has(peer) else Vector3.INF
func island_spawn() -> Vector3:
return _spawn
## The server moved a player (respawning): their next moves are checked from here.
func teleport(peer: int, pos: Vector3) -> void:
if _states.has(peer):
_states[peer].pos = pos
_states[peer].t = server_time
func _broadcast_snapshot() -> void:
if _states.is_empty():
return
var arr: Array = []
for peer in _states:
var s: Dictionary = _states[peer]
arr.append([peer, s.pos, s.yaw, s.pitch, s.anim, s.held, s.stamp])
for peer in players:
_s2c_snapshot.rpc_id(peer, server_time, arr)
## Where player number `i` starts: around the island's spawn point.
func _spawn_for(i: int) -> Vector3:
if i == 0:
return _spawn
var a := i * 2.4
return _terrain.find_spawn(Vector2(_spawn.x + cos(a) * 3.0, _spawn.z + sin(a) * 3.0))
## A position update from a player. Accepted if it's possible from their last
## accepted one; otherwise they're sent back there.
@rpc("any_peer", "call_remote", "unreliable_ordered")
func _c2s_state(pos: Vector3, yaw: float, pitch: float, anim: int, held: String, teleported: bool, client_t: float) -> void:
var peer := multiplayer.get_remote_sender_id()
if not is_server() or not _states.has(peer):
return
var s: Dictionary = _states[peer]
# When the move happened, in server time: the client's own clock plus a
# smoothed offset. Stamping with arrival time instead would bunch moves
# into some snapshots and leave others empty, and avatars would surge.
var off := server_time - client_t
if s.off == INF or absf(off - s.off) > 1.0:
s.off = off
else:
s.off = lerpf(s.off, off, 0.05)
var stamp: float = client_t + s.off
if stamp <= s.stamp:
return # older than what we have
var why := move_problem(s.pos, pos, server_time - s.t, teleported)
if not why.is_empty():
if server_time - s.corr_t > 0.5:
s.corr_t = server_time
print("[server] moved %s back (%s)" % [players[peer].name, why])
_s2c_correct.rpc_id(peer, s.pos)
return
var from: Vector3 = s.pos
s.pos = pos
s.yaw = wrapf(yaw, -PI, PI)
s.pitch = clampf(pitch, -1.5, 1.5)
s.anim = clampi(anim, 0, 4)
s.held = PlayerNet.held(peer) # (what's really in their hand, not what they say)
PlayerNet.server_moved(peer, from, pos, s.yaw, s.anim, server_time - s.t)
s.t = server_time
s.stamp = stamp
## Why a move from `from` to `to` over `dt` seconds isn't allowed ("" = fine).
func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> String:
if not to.is_finite():
return "bad position"
dt = clampf(dt, SEND_INTERVAL, 1.0)
var flat := Vector2(to.x - from.x, to.z - from.z).length()
if teleported:
if Vector2(to.x - _spawn.x, to.z - _spawn.z).length() > SPAWN_TELEPORT_RADIUS:
return "teleport away from the spawn"
elif flat > MAX_SPEED * dt + MOVE_SLACK:
return "too fast (%.0f m in %.2f s)" % [flat, dt]
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
return "rising too fast"
if not _terrain.inside_boundary(to.x, to.z, -3.0):
return "outside the island"
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
return "underground"
if not teleported and _through_wall(from, to):
return "through a wall"
return ""
## Whether the straight line between two positions crosses a wall, a closed
## door or a gable (at waist height). A genuine crossing hits the wall from
## both sides; someone a wall was built on top of can still step out of it.
func _through_wall(from: Vector3, to: Vector3) -> bool:
if _world == null or from.distance_to(to) < 0.05:
return false
var space: PhysicsDirectSpaceState3D = _world.get_world_3d().direct_space_state
var up := Vector3(0, 0.9, 0)
return _wall_hit(space, from + up, to + up) and _wall_hit(space, to + up, from + up)
func _wall_hit(space: PhysicsDirectSpaceState3D, a: Vector3, b: Vector3) -> bool:
var ex: Array[RID] = []
for _i in range(4):
var rq := PhysicsRayQueryParameters3D.create(a, b, 4, ex) # 4: building pieces
var hit := space.intersect_ray(rq)
if hit.is_empty():
return false
var c: Object = hit.collider
var piece: Node = c as Node
while piece != null and piece.get("kind") == null:
piece = piece.get_parent()
if piece != null and String(piece.kind) in ["wall", "door", "gable", "window", "palisade", "pillar"]:
return true
ex.append(hit.rid) # a floor or roof edge: look past it
return false
func _reject(peer: int, reason: String) -> void:
_pending.erase(peer)
print("[server] rejected peer %d: %s" % [peer, reason])
_s2c_rejected.rpc_id(peer, reason)
# Let the message go out before hanging up.
get_tree().create_timer(0.4).timeout.connect(func():
if _peer != null and is_server():
_peer.disconnect_peer(peer))
@rpc("any_peer", "call_remote", "reliable")
func _c2s_hello(d: Dictionary) -> void:
var peer := multiplayer.get_remote_sender_id()
if not is_server() or not _pending.has(peer) or _pending[peer].has("hello"):
return
if int(d.get("protocol", 0)) != PROTOCOL:
var theirs := int(d.get("protocol", 0))
_reject(peer, "Different game version: this server runs Emberwild %s (protocol %d), you have protocol %d. %s" % [
Updates.version, PROTOCOL, theirs, "Update the game to join." if theirs < PROTOCOL else "The server needs updating."])
return
var pw := String(config.v("password"))
if not pw.is_empty() and String(d.get("password", "")) != pw:
_reject(peer, "Wrong password")
return
var nonce := Crypto.new().generate_random_bytes(32)
_pending[peer].hello = d
_pending[peer].nonce = nonce
_s2c_challenge.rpc_id(peer, nonce)
@rpc("any_peer", "call_remote", "reliable")
func _c2s_proof(sig: PackedByteArray) -> void:
var peer := multiplayer.get_remote_sender_id()
if not is_server() or not _pending.has(peer) or not _pending[peer].has("nonce"):
return
var hello: Dictionary = _pending[peer].hello
var pem := String(hello.get("public_key", ""))
if not Identity.verify(pem, _pending[peer].nonce, sig):
_reject(peer, "Identity check failed")
return
var id := Identity.id_for(pem)
var name := String(hello.get("name", ""))
var why := _admission_problem(id, name)
if not why.is_empty():
_reject(peer, why)
return
_pending.erase(peer)
config.reserve_name(name, id)
if config.data.admins.is_empty():
config.add_admin(id) # first player on a new server (MULTIPLAYER_PLAN.md §12)
print("[server] %s is the first player, so they're now an admin" % name)
var p := {"id": id, "name": name, "appearance": Identity.sanitize_appearance(hello.get("appearance", {})),
"admin": config.is_admin(id)}
# Their character (player_net.gd): back where they left off, or new.
var start: Vector3 = PlayerNet.server_join(peer, id, name)
var yaw: float = PlayerNet.yaw_of(peer)
if start == Vector3.INF:
start = _spawn_for(players.size())
PlayerNet.place_at(peer, start)
_world.creatures.populate_for(start) # some wildlife about them straight away
players[peer] = p
_states[peer] = {"pos": start, "yaw": yaw, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0,
"stamp": server_time, "off": INF}
print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
players.size(), int(config.v("max_players"))])
# (The world's own seed: an existing world keeps the seed it was made with.)
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, "yaw": yaw,
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
"weather": _world.weather.override}
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
for other in players:
if other != peer:
_s2c_player_joined.rpc_id(other, peer, p)
players_changed.emit()
## Why player `id` calling themselves `name` can't come in ("" = they can).
func _admission_problem(id: String, name: String) -> String:
if id in config.data.banned:
return "You're banned from this server"
if not config.data.allowlist.is_empty() and not id in config.data.allowlist:
return "You're not on this server's allowlist"
for other in players.values():
if other.id == id:
return "You're already connected from another game"
var bad := Identity.name_problem(name)
if not bad.is_empty():
return bad
var owner: String = config.name_owner(name)
if not owner.is_empty() and owner != id:
return "The name '%s' is taken on this server: pick another" % name
if players.size() >= int(config.v("max_players")):
return "The server is full (%d/%d)" % [players.size(), int(config.v("max_players"))]
return ""
# --- client -------------------------------------------------------------------
## Splits "host", "host:port" or "[v6]:port". Returns [host, port].
static func parse_address(a: String) -> Array:
a = a.strip_edges()
if a.begins_with("["):
var close := a.find("]")
var host := a.substr(1, close - 1)
var rest := a.substr(close + 1)
return [host, int(rest.trim_prefix(":")) if rest.begins_with(":") else DEFAULT_PORT]
if a.count(":") == 1:
var port := a.get_slice(":", 1)
return [a.get_slice(":", 0), int(port) if port.is_valid_int() else DEFAULT_PORT]
return [a, DEFAULT_PORT]
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
func join(address: String, password: String = "") -> void:
if state != "offline":
stop()
var me := get_identity()
var bad := Identity.name_problem(me.name)
if not bad.is_empty() and not debug_skip_name_check:
join_failed.emit("Set your name first (%s)" % bad.to_lower())
return
var hp := parse_address(address)
if String(hp[0]).is_empty():
join_failed.emit("Enter a server address")
return
_peer = ENetMultiplayerPeer.new()
var err := _peer.create_client(hp[0], hp[1])
if err != OK:
_peer = null
join_failed.emit("Couldn't start connecting (error %d)" % err)
return
multiplayer.multiplayer_peer = _peer
_reject_reason = ""
_pending_password = password
_join_left = JOIN_TIMEOUT
me.last_server = address.strip_edges()
me.save_profile()
_set_state("connecting")
var _pending_password := ""
func leave() -> void:
var was := state
stop_hosting() # (our own server sees us go, and saves and stops)
stop()
if was == "connected":
last_reason = "You left the server"
left.emit(last_reason)
func _on_connected() -> void:
var me := get_identity()
_c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name,
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password})
@rpc("authority", "call_remote", "reliable")
func _s2c_challenge(nonce: PackedByteArray) -> void:
var sig: PackedByteArray = get_identity().sign(nonce)
if debug_forge:
var other := CryptoKey.new()
other = Crypto.new().generate_rsa(2048)
sig = Crypto.new().sign(HashingContext.HASH_SHA256, Identity._sha256(nonce), other)
_c2s_proof.rpc_id(1, sig)
@rpc("authority", "call_remote", "reliable")
func _s2c_welcome(d: Dictionary) -> void:
local_peer = int(d.you)
server_info = d.server
world_info = d.get("world", {})
players.clear()
for k in d.players:
players[int(k)] = d.players[k]
last_reason = ""
_set_state("connected")
joined.emit()
players_changed.emit()
@rpc("authority", "call_remote", "reliable")
func _s2c_rejected(reason: String) -> void:
_reject_reason = reason
@rpc("authority", "call_remote", "reliable")
func _s2c_player_joined(peer: int, p: Dictionary) -> void:
players[peer] = p
players_changed.emit()
player_joined.emit(String(p.get("name", "?")))
@rpc("authority", "call_remote", "reliable")
func _s2c_player_left(peer: int) -> void:
if not players.has(peer):
return
var n := String(players[peer].get("name", "?"))
players.erase(peer)
players_changed.emit()
player_left.emit(n)
@rpc("authority", "call_remote", "unreliable_ordered")
func _s2c_snapshot(t: float, states: Array) -> void:
_delayed(_in_q, func(): snapshot.emit(t, states))
@rpc("authority", "call_remote", "reliable")
func _s2c_correct(pos: Vector3) -> void:
_delayed(_in_q, func(): corrected.emit(pos))
@rpc("authority", "call_remote", "reliable")
func _s2c_clock(day: int, time: float) -> void:
world_info.day = day
world_info.time = time
clock.emit(day, time)
## Sends our position to the server (net_sync.gd calls this ~20 times a second).
func send_state(pos: Vector3, yaw: float, pitch: float, anim: int, held: String, teleported: bool = false) -> void:
if state != "connected":
return
var at := Time.get_ticks_usec() / 1000000.0 # when this happened, by our clock
_delayed(_out_q, func():
if state == "connected":
_c2s_state.rpc_id(1, pos, yaw, pitch, anim, held, teleported, at))
## Runs `f` now, or after the simulated delay when testing with latency.
func _delayed(q: Array, f: Callable) -> void:
if debug_latency_ms <= 0.0:
f.call()
return
var half := debug_latency_ms * 0.5 + randf_range(-debug_jitter_ms, debug_jitter_ms) * 0.5
var at := Time.get_ticks_msec() + maxf(half, 0.0)
# Keep the queue in send order (networks rarely reorder within a stream).
if not q.is_empty() and at < q[-1][0]:
at = q[-1][0]
q.append([at, f])
func _run_queue(q: Array) -> void:
var now := Time.get_ticks_msec()
while not q.is_empty() and q[0][0] <= now:
var f: Callable = q.pop_front()[1]
f.call()
func _fail(reason: String) -> void:
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
stop()
last_reason = reason
join_failed.emit(reason)
func _on_connection_failed() -> void:
_fail("Couldn't reach the server")
func _on_server_disconnected() -> void:
var was := state
var reason := _reject_reason
stop_hosting()
stop()
if was == "connecting":
last_reason = reason if not reason.is_empty() else "The server hung up"
join_failed.emit(last_reason)
else:
last_reason = reason if not reason.is_empty() else "The server closed the connection"
left.emit(last_reason)
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
if _peer != null:
_peer.close()

140
scripts/net/net_sync.gd Normal file
View File

@@ -0,0 +1,140 @@
extends Node
## In a multiplayer world: sends where you are, shows where everyone else is.
## Added by world.gd when the game is connected to a server.
##
## - Your own movement stays instant (you move locally and tell the server).
## The server checks each move; if it refuses one it sends you back to your
## last good position.
## - Other players are avatars (avatar.gd), drawn INTERP_DELAY behind the
## server's clock and interpolated between its snapshots.
## - The time of day follows the server, so everyone shares the same sky
## (weather follows from the seed and the time).
const AvatarScript = preload("res://scripts/net/avatar.gd")
const INTERP_DELAY := 0.1
var world: Node3D
var player: CharacterBody3D
var avatars: Dictionary = {} ## peer -> avatar.gd
var corrections := 0
var _send_left := 0.0
var _offset := INF ## server time minus local time (smoothed)
var _teleport_next := false
var _root: Node3D
func setup(w: Node3D) -> void:
world = w
player = w.player
_root = Node3D.new()
_root.name = "Avatars"
w.add_child(_root)
Net.snapshot.connect(_on_snapshot)
Net.corrected.connect(_on_corrected)
Net.clock.connect(_on_clock)
Net.players_changed.connect(_sync_avatars)
Net.player_joined.connect(func(n): Actions.notify.emit("%s joined" % n))
Net.player_left.connect(func(n): Actions.notify.emit("%s left" % n))
if Net.world_info.has("day"):
_on_clock(int(Net.world_info.day), float(Net.world_info.time))
# An admin's /weather (otherwise the weather follows from seed and clock).
Net.weather_changed.connect(_on_weather)
if not String(Net.world_info.get("weather", "")).is_empty():
_on_weather(String(Net.world_info.weather))
func _on_weather(state: String) -> void:
world.weather.force(state)
func _exit_tree() -> void:
for sig in [[Net.snapshot, _on_snapshot], [Net.corrected, _on_corrected], [Net.clock, _on_clock],
[Net.players_changed, _sync_avatars], [Net.weather_changed, _on_weather]]:
if (sig[0] as Signal).is_connected(sig[1]):
(sig[0] as Signal).disconnect(sig[1])
static func _now() -> float:
return Time.get_ticks_msec() / 1000.0
## Server time we draw other players at.
func render_time() -> float:
return _now() + _offset - INTERP_DELAY if _offset != INF else -INF
## Flags the next update as a legitimate jump (respawning), so the server
## checks it against the spawn instead of against walking speed.
func mark_teleport() -> void:
_teleport_next = true
func _physics_process(delta: float) -> void:
_send_left -= delta
if _send_left > 0.0:
return
_send_left = Net.SEND_INTERVAL
var cam: Camera3D = player.get_node("Camera3D")
Net.send_state(player.global_position, player.rotation.y, cam.rotation.x, anim_state(), player.held_item_id(), _teleport_next)
_teleport_next = false
## What the local player is doing, for other people's view of them.
func anim_state() -> int:
if player.global_position.y < player.water_level - player.FLOAT_DEPTH + 0.1 and not player.is_on_floor():
return AvatarScript.Anim.SWIM
if not player.is_on_floor():
return AvatarScript.Anim.AIR
var flat := Vector2(player.velocity.x, player.velocity.z).length()
if flat > 6.0:
return AvatarScript.Anim.RUN
if flat > 0.5:
return AvatarScript.Anim.WALK
return AvatarScript.Anim.IDLE
func _process(delta: float) -> void:
var rt := render_time()
if rt == -INF:
return
for a in avatars.values():
a.update_at(rt, delta)
func _on_snapshot(t: float, states: Array) -> void:
var sample := t - _now()
# Smooth the clock offset so jitter doesn't make avatars judder. Jump
# straight to it at first, or if it's way off (a long stall).
if _offset == INF or absf(sample - _offset) > 1.0:
_offset = sample
else:
_offset = lerpf(_offset, sample, 0.05)
for s in states:
var peer := int(s[0])
if peer == Net.local_peer:
continue
var a: Node3D = avatars.get(peer)
if a == null:
a = _add_avatar(peer)
if a == null:
continue
a.push(float(s[6]), s[1], s[2], s[3], s[4], s[5]) # stamped with when they moved, not when it was sent
func _add_avatar(peer: int) -> Node3D:
if not Net.players.has(peer):
return null
var a: Node3D = AvatarScript.new()
a.setup(peer, Net.players[peer])
_root.add_child(a)
avatars[peer] = a
return a
func _sync_avatars() -> void:
for peer in avatars.keys():
if not Net.players.has(peer):
avatars[peer].queue_free()
avatars.erase(peer)
func _on_corrected(pos: Vector3) -> void:
corrections += 1
player.global_position = pos
player.velocity = Vector3.ZERO
func _on_clock(day: int, time: float) -> void:
world.day_night.day = day
# Small drift: ease the clock across. A big gap (joining, a stall): jump.
var diff := wrapf(time - world.day_night.time, -0.5, 0.5)
if absf(diff) > 0.01:
world.day_night.time = time
world.day_night.apply(true)
world.weather.snap() # (a night slept through: the weather jumps too)

661
scripts/net/player_net.gd Normal file
View File

@@ -0,0 +1,661 @@
extends Node
## Autoload "PlayerNet": each player's character on a dedicated server (M2.4,
## docs/MULTIPLAYER_PLAN.md §5). Online, the server decides; offline (single
## player) there's no server and the game decides, exactly as before.
##
## - Your game sends what you want to do: `ask("eat", [slot])`,
## `ask("hit", [tree uid, point])`... (Actions does this for every intent
## while online, instead of acting itself).
## - The server runs the same Actions rule on your character there
## (server_player.gd), and sends back your inventory (after every request,
## and whenever it changes) and your stats (4 times a second).
## - Clicks in your own inventory are also done on your screen straight away,
## so dragging items doesn't wait for the network; the server's copy
## replaces it once it has caught up with every click you sent.
## - Characters are saved per player in the server's world:
## worlds/<world>/players/<player id>.json.
signal cursor_changed(stack) ## client: the server says this is on your cursor
const ServerPlayer = preload("res://scripts/net/server_player.gd")
const STATS_INTERVAL := 0.25
# Server.
var proxies: Dictionary = {} ## peer -> server_player.gd
var _acting := 0 ## the peer whose request is being run (for its messages)
var _dirty: Dictionary = {} ## peers whose inventory changed
var _acks: Dictionary = {} ## peer -> last request id handled
var _stats_left := 0.0
var _root: Node3D
var cheats := false ## --debug-cheats (tests only): give items, set stats, die
# Client.
var _seq := 0 ## id of the last request we sent
var world: Node3D
func _ready() -> void:
Actions.notify.connect(_route_notify)
cheats = Net.parse_args(OS.get_cmdline_user_args()).has("debug-cheats")
# --- server --------------------------------------------------------------------
func _route_notify(text: String) -> void:
if Net.is_server() and _acting != 0 and proxies.has(_acting):
_s2c_notify.rpc_id(_acting, text)
func _players_root() -> Node3D:
if _root == null or not is_instance_valid(_root):
_root = Node3D.new()
_root.name = "Players"
WorldNet.world.add_child(_root)
return _root
func _file(id: String) -> String:
return Saves.root_dir.path_join(Saves.current_id).path_join("players").path_join(id + ".json")
## A player came in: their character, from their save if they've been here
## before. Returns where they were (or INF for a new player).
func server_join(peer: int, id: String, display: String) -> Vector3:
var p: Node3D = ServerPlayer.new()
p.name = "P%d" % peer
p.peer = peer
p.player_id = id
p.display = display
_players_root().add_child(p)
p.setup(WorldNet.world)
proxies[peer] = p
_acks[peer] = 0
p.inventory.changed.connect(func(): _dirty[peer] = true; WorldNet.mark_changed())
p.worn_changed.connect(func(): _dirty[peer] = true)
p.survival.died.connect(_on_died.bind(peer))
p.survival.drain_mult = Rules.difficulty("drain")
# A wolf bite or a boar: their screen flashes red, from the right side.
p.survival.hurt.connect(func(amount, from):
if proxies.has(peer):
_s2c_hurt.rpc_id(peer, amount, from))
var at := Vector3.INF
var d := _read(_file(id))
if not d.is_empty():
p.load_data(d)
var t: Node3D = WorldNet.world.terrain
var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF
if pos.is_finite() and t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0:
at = pos
print("[server] %s's character loaded" % display)
if at != Vector3.INF:
p.global_position = at
return at
func place_at(peer: int, pos: Vector3) -> void:
if proxies.has(peer):
proxies[peer].global_position = pos
func yaw_of(peer: int) -> float:
return proxies[peer].rotation.y if proxies.has(peer) else 0.0
func server_leave(peer: int) -> void:
var p: Node3D = proxies.get(peer)
if p == null:
return
if p.survival.dead:
_respawn(peer, false) # left on the death screen: they come back alive
_save(p)
var w: Node3D = WorldNet.world
if w != null and w.bobber_of(p) != null:
w.end_fishing(p)
proxies.erase(peer)
_dirty.erase(peer)
_acks.erase(peer)
p.queue_free()
_maybe_pass_night() # (the last one awake leaving lets the others sleep)
func save_all() -> void:
for p in proxies.values():
_save(p)
func server_reset() -> void:
proxies.clear()
_dirty.clear()
_acks.clear()
_root = null
func _save(p: Node3D) -> void:
var path := _file(p.player_id)
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var tmp := path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
printerr("[server] can't save %s's character" % p.display)
return
f.store_string(JSON.stringify(p.to_data(), "\t"))
f.close()
DirAccess.rename_absolute(tmp, path)
static func _read(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
return d if d is Dictionary else {}
func held(peer: int) -> String:
return proxies[peer].held_item_id() if proxies.has(peer) else ""
## A move the server accepted: the character follows, and pays stamina for
## sprinting and jumping (the same costs as in your game).
func server_moved(peer: int, from: Vector3, to: Vector3, yaw: float, anim: int, dt: float) -> void:
var p: Node3D = proxies.get(peer)
if p == null:
return
var A := preload("res://scripts/net/avatar.gd").Anim
if anim == A.RUN:
p.survival.use_stamina(14.0 * dt, true)
elif anim == A.AIR and p.anim != A.AIR and to.y > from.y + 0.05:
p.survival.use_stamina(8.0)
p.anim = anim
p.velocity = (to - from) / maxf(dt, 0.001)
p.global_position = to
p.rotation.y = yaw
if to.y < p.water_level - 0.6:
p.survival.soak()
if p.in_bed and to.distance_to(p.bed_at) > 1.5:
p.in_bed = false
tell(p.peer, "You got out of bed")
func _process(delta: float) -> void:
if not Net.is_server() or proxies.is_empty():
return
for peer in _dirty.keys():
_push_inv(peer)
_dirty.clear()
_stats_left -= delta
if _stats_left <= 0.0:
_stats_left = STATS_INTERVAL
for peer in proxies:
_push_stats(peer)
func push_all(peer: int) -> void:
_push_inv(peer)
_push_stats(peer)
func _push_inv(peer: int) -> void:
var p: Node3D = proxies.get(peer)
if p != null:
_s2c_inv.rpc_id(peer, p.inventory.to_data(), p.cursor, p.worn, int(_acks.get(peer, 0)))
func _push_stats(peer: int) -> void:
var p: Node3D = proxies.get(peer)
if p == null:
return
var s: Node = p.survival
_s2c_stats.rpc_id(peer, s.to_data(), s.statuses, [s.roofed, s.sheltered, s.heat],
[p.has_bed_spawn, p.bed_spawn])
## Death on the server: everything goes into a grave where they fell (with
## their name on it), and they wake at their bed or the island start.
func _on_died(peer: int) -> void:
var p: Node3D = proxies.get(peer)
var w: Node3D = WorldNet.world
if p == null or w == null:
return
_acting = peer
var pos: Vector3 = p.global_position
pos.y = w.terrain.sample_height(pos.x, pos.z)
if pos.y < w.terrain.water_level:
pos = w.terrain.find_spawn(Vector2(pos.x, pos.z))
if not p.worn.is_empty() and p.inventory.add(p.worn, 1) == 0:
p.worn = ""
if p.cursor != null:
p.inventory.add_stack(p.cursor)
p.cursor = null
var has_items := false
for st in p.inventory.slots:
if st != null:
has_items = true
# What dying costs (the server's death_penalty rule).
var penalty := Rules.death_penalty()
if has_items and penalty == "grave":
var g: Node3D = w.spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, p.rotation.y), pos), {"owner_id": p.player_id, "owner_name": p.display})
g.inventory.load_data(p.inventory.to_data())
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
elif has_items and penalty == "drop_all":
# Everything scattered on the ground where you fell, for anyone.
var rng := RandomNumberGenerator.new()
for st in p.inventory.slots:
if st != null:
var off := Vector3(rng.randf_range(-1.2, 1.2), 0.0, rng.randf_range(-1.2, 1.2))
var at: Vector3 = pos + off
at.y = w.terrain.sample_height(at.x, at.z) + 0.05
w.drop_item(st.id, int(st.count), Transform3D(Basis(Vector3.UP, rng.randf() * TAU), at),
Inventory.extra_of(st))
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
# ("keep": you keep everything.)
# They stay down on the death screen for a few seconds, then ask to respawn.
p.died_at = Time.get_ticks_msec()
_dirty[peer] = true
_s2c_died.rpc_id(peer, p.survival.cause if not p.survival.cause.is_empty() else "You died",
has_items and penalty != "keep", p.bed_piece(w) != null, penalty)
_acting = 0
const RESPAWN_DELAY := 3.0
## Respawn (the death screen's button), once they've been down long enough:
## at their bed if it's still standing, otherwise the island start.
func _respawn(peer: int, tell: bool = true) -> void:
var p: Node3D = proxies.get(peer)
var w: Node3D = WorldNet.world
if p == null or w == null or not p.survival.dead:
return
if tell and Time.get_ticks_msec() - p.died_at < RESPAWN_DELAY * 1000.0 - 100.0:
return # (too soon: the button only unlocks after the delay)
var bed: Node3D = p.bed_piece(w)
var at: Vector3
var yaw := 0.0
if bed != null:
at = bed.global_position + Vector3(0, 0.9, 0)
yaw = bed.global_basis.get_euler().y + PI
else:
if p.has_bed_spawn:
p.has_bed_spawn = false
Actions.notify.emit("Your bed is gone, so you woke at the island start")
at = Net.island_spawn() + Vector3(0, 1.5, 0)
p.survival.reset_after_death()
p.global_position = at
p.rotation.y = yaw
Net.teleport(peer, at)
_dirty[peer] = true
if tell:
_s2c_respawn.rpc_id(peer, at, yaw, "")
_save(p)
# --- server: requests ------------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable")
func _c2s_ask(seq: int, what: String, args: Array) -> void:
var peer := multiplayer.get_remote_sender_id()
var p: Node3D = proxies.get(peer)
if not Net.is_server() or p == null or WorldNet.world == null:
return
_acting = peer
if what == "respawn":
_respawn(peer)
elif not p.survival.dead:
_run(p, what, args)
_acting = 0
_acks[peer] = maxi(int(_acks.get(peer, 0)), seq)
_dirty[peer] = true # (always answer, so the game knows we've caught up)
func _run(p: Node3D, what: String, a: Array) -> void:
var w: Node3D = WorldNet.world
match what:
"select":
p.select(_i(a, 0))
"craft":
Actions.craft(p, _s(a, 0))
"eat":
Actions.eat(p, _i(a, 0))
"wear":
Actions.wear(p, _i(a, 0))
"take_off":
Actions.take_off(p)
"hit":
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
var at: Vector3 = _v(a, 1)
if n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
WorldNet.node_hit(n, at, p.global_position)
"pickup":
var key := _s(a, 0)
var pk: Node3D = WorldNet.find_pickup(key)
if pk != null and not pk.taken and Actions.pickup(p, pk):
WorldNet.taken(key)
"forage":
var b: Node3D = w.props.forage.get(_s(a, 0))
if b != null and Actions.forage(p, b):
WorldNet.foraged(b)
"place":
var xf: Transform3D = a[1] if a.size() > 1 and a[1] is Transform3D else Transform3D()
if xf.origin.is_finite() and xf.origin != Vector3.ZERO:
Actions.place(p, _i(a, 0), xf.orthonormalized(), w)
"dismantle":
var piece := WorldNet.piece(_i(a, 0))
if piece != null and _allowed(Rules.dismantle_problem(p.player_id, piece)):
Actions.dismantle(p, piece)
"door":
var d := WorldNet.piece(_i(a, 0))
if d != null and d.get("open") != null and _allowed(Rules.use_problem(p.player_id, d)) \
and Actions.toggle_door(p, d):
WorldNet.piece_state(d)
"stones":
if w.stones != null:
Actions.use_stones(p, w.stones)
"barrow_loot":
if w.barrow != null:
Actions.loot_barrow(p, w.barrow)
"door_repair":
var d := WorldNet.piece(_i(a, 0))
if d != null and d.get("broken") != null and _allowed(Rules.use_problem(p.player_id, d)) \
and Actions.repair_door(p, d):
WorldNet.piece_state(d)
"fuel":
var f := WorldNet.piece(_i(a, 0))
if f != null and f.get("fuel") != null and Actions.add_fuel(p, f):
WorldNet.piece_state(f)
"bed":
var b := WorldNet.piece(_i(a, 0))
if b != null and b.item_id == "bed" and _allowed(Rules.use_problem(p.player_id, b)):
Actions.use_bed(p, b)
"inv_click":
# Slot `a[1]` of your own inventory (a[0] = 0) or a chest's.
var inv: Inventory = p.inventory
if _i(a, 0) != 0:
var c := _container(p, _i(a, 0))
if c == null:
return
inv = c.inventory
var slot := _i(a, 1)
if slot >= 0 and slot < inv.size():
p.cursor = inv.click(slot, p.cursor, _b(a, 2))
"transfer":
# Shift-click: from your inventory into the chest, or the other way.
var c := _container(p, _i(a, 0))
if c == null:
return
var into_chest := _b(a, 2)
var from: Inventory = p.inventory if into_chest else c.inventory
var to: Inventory = c.inventory if into_chest else p.inventory
var slot := _i(a, 1)
if slot >= 0 and slot < from.size():
Actions.transfer(p, from, slot, to, c)
"close":
# The inventory screen closed: what's on the cursor goes back in
# your bag, or at your feet if it's full.
if p.cursor != null:
var left: int = p.inventory.add_stack(p.cursor)
if left > 0:
w.drop_item(p.cursor.id, left, Transform3D(Basis(), p.global_position + Vector3(0, 0.1, 0)),
Inventory.extra_of(p.cursor))
p.cursor = null
"debug_give":
if cheats and not ItemDB.get_item(_s(a, 0)).is_empty():
p.inventory.add(_s(a, 0), _i(a, 1))
"debug_clear":
if cheats:
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
"debug_stats":
if cheats and a.size() > 0 and a[0] is Dictionary:
for k in a[0]:
if k in ["health", "stamina", "hunger", "warmth"]:
p.survival.set(k, float(a[0][k]))
elif k == "diet" and a[0][k] is Dictionary:
for g in a[0][k]:
if p.survival.diet.has(g):
p.survival.diet[g] = float(a[0][k][g])
"debug_die":
if cheats:
p.survival.damage(10000.0, p.global_position)
"debug_drop":
if cheats and not ItemDB.get_item(_s(a, 0)).is_empty():
w.drop_item(_s(a, 0), maxi(_i(a, 1), 1), Transform3D(Basis(), _v(a, 2)))
# --- hunting and fishing (M2.5) ---
# Arrows, spears and casts start from where the server knows you are
# (your eyes), whatever your game says: only the aim comes from you.
"melee":
var c: Node3D = CreatureNet.creature(_i(a, 0))
if c != null:
Actions.melee(p, c)
"hit_player":
Actions.hit_player(p, _i(a, 0))
"butcher":
var c: Node3D = CreatureNet.creature(_i(a, 0))
if c != null:
Actions.butcher(p, c)
"shoot":
var dir := _dir(a, 0)
var power := clampf(float(a[1]) if a.size() > 1 and (a[1] is float or a[1] is int) else 0.0, 0.0, 1.0)
if dir != Vector3.ZERO and Actions.can_draw(p):
Actions.shoot(p, _eye(p) + dir * 0.5, dir, power, w)
"throw":
var dir := _dir(a, 1)
if dir != Vector3.ZERO:
Actions.throw_spear(p, _i(a, 0), _eye(p) + dir * 0.5 + Vector3(0, -0.05, 0), dir + Vector3(0, 0.06, 0), w)
"cast":
var dir := _dir(a, 0)
if dir != Vector3.ZERO:
Actions.cast_line(p, _eye(p) + dir * 0.6, dir, w)
"reel":
Actions.reel(p, w)
"repair":
var slot := _i(a, 0)
if slot >= 0 and slot < p.inventory.size():
Actions.repair(p, slot)
"debug_process":
if cheats:
for m in w.get_tree().get_nodes_in_group("processor"):
m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0)
"debug_spawn":
if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear", "crow", "fox", "guard", "old_stag", "spirit_wolf"]) and _v(a, 1).is_finite():
w.creatures.spawn(_s(a, 0), _v(a, 1))
"debug_boss":
# Tests: ["hold", secs] stands the Old Stag still (stunned);
# ["hp", n] sets its health.
var s: Node3D = w.stones.stag if w.stones != null and w.stones.fighting() else null
if cheats and s != null:
match _s(a, 0):
"hold":
s.state = "stunned"
s._state_t = float(a[1]) if a.size() > 1 else 30.0
"hp":
s.hp = clampf(float(a[1]) if a.size() > 1 else 1.0, 1.0, s.max_hp)
"debug_time":
if cheats:
w.day_night.day = maxi(_i(a, 0), 1)
w.day_night.time = clampf(float(a[1]) / 24.0, 0.0, 0.999) if a.size() > 1 else w.day_night.time
w.day_night.apply(true)
w.weather.snap()
Net.push_clock()
"debug_tp":
var to := _v(a, 0)
if cheats and to.is_finite():
p.global_position = to
Net.teleport(p.peer, to)
_s2c_respawn.rpc_id(p.peer, to, p.rotation.y, "")
"debug_hut":
if cheats and _v(a, 0).is_finite():
_debug_hut(_v(a, 0))
_:
Actions.notify.emit("Unknown request")
const Creature = preload("res://scripts/creatures/creature.gd")
static func _eye(p: Node3D) -> Vector3:
return p.global_position + Vector3(0, 1.6, 0)
## A unit aim direction from the request, or ZERO.
static func _dir(a: Array, k: int) -> Vector3:
var d := _v(a, k)
return d.normalized() if d.is_finite() and d.length() > 0.01 else Vector3.ZERO
## Test hook: a closed hut (floors, walls, door, roof) with two beds in it.
func _debug_hut(center: Vector3) -> void:
var w: Node3D = WorldNet.world
var out: Dictionary = await w.build.debug_build_hut(center, 0.0)
if out.pieces.is_empty():
return
var top: float = out.pieces[0].global_position.y
for x in [-0.6, 0.6]:
w.spawn_piece(ItemDB.get_item("bed"), Transform3D(Basis(), Vector3(center.x + x, top, center.z - 0.3)))
# --- server: messages, getting hurt, sleeping ----------------------------------------
func tell(peer: int, text: String) -> void:
if proxies.has(peer):
_s2c_notify.rpc_id(peer, text)
func tell_all(text: String) -> void:
for peer in proxies:
_s2c_notify.rpc_id(peer, text)
## The server's sleep rule (server.json "sleep"): how many of the players
## online must be in bed for the night to pass.
func _beds_needed(n: int) -> int:
match String(Net.config.v("sleep")):
"anyone": return 1
"half": return ceili(n / 2.0)
return n # "all_online" (the default)
## Actions.use_bed decided `p` may sleep: they get into bed, and the night
## passes once enough players are in bed.
func go_to_bed(p: Node3D) -> void:
if String(Net.config.v("sleep")) == "off":
tell(p.peer, "Sleeping is off on this server: the nights have to be lived through")
return
p.in_bed = true
p.bed_at = p.global_position
if not _maybe_pass_night():
var n: int = _alive().size()
var asleep: int = _alive().filter(func(q): return q.in_bed).size()
tell(p.peer, "You're in bed (%d of %d asleep). The night passes when %s." % [asleep, n,
"everyone's in bed" if _beds_needed(n) >= n else "%d are" % _beds_needed(n)])
for q in _alive():
if q != p:
tell(q.peer, "%s is in bed (%d of %d asleep)" % [p.display, asleep, n])
func _alive() -> Array:
return proxies.values().filter(func(q): return not q.survival.dead)
## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires,
## regrowth and weather move on; sleepers wake rested.
func _maybe_pass_night() -> bool:
var w: Node3D = WorldNet.world
var live := _alive()
if w == null or live.is_empty():
return false
var asleep := live.filter(func(q): return q.in_bed)
if asleep.size() < _beds_needed(live.size()):
return false
var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length
w.skip_time(secs)
for q in live:
if q.in_bed:
q.survival.after_sleep(secs)
q.in_bed = false
_s2c_slept.rpc_id(q.peer, asleep.has(q))
Net.push_clock()
WorldNet.save()
return true
## The chest or grave `nid`, if this player may use it right now.
func _container(p: Node3D, nid: int) -> Node3D:
var c := WorldNet.piece(nid)
if c == null or c.get("inventory") == null or not Actions._in_reach(p, c, 1.0):
return null
return c if _allowed(Rules.use_problem(p.player_id, c)) else null
const Rules = preload("res://scripts/net/rules.gd")
## Tells the player why not, if there's a reason.
func _allowed(why: String) -> bool:
if why.is_empty():
return true
Actions.notify.emit(why)
return false
static func _i(a: Array, k: int) -> int:
return int(a[k]) if a.size() > k and (a[k] is int or a[k] is float) else -1
static func _s(a: Array, k: int) -> String:
return String(a[k]) if a.size() > k and a[k] is String else ""
static func _b(a: Array, k: int) -> bool:
return a.size() > k and a[k] is bool and a[k]
static func _v(a: Array, k: int) -> Vector3:
return a[k] if a.size() > k and a[k] is Vector3 else Vector3.INF
# --- client ----------------------------------------------------------------------
func is_client() -> bool:
return WorldNet.is_client()
## Our world is online: follow the server's view of us.
func join_world(w: Node3D) -> void:
world = w
_seq = 0
w.player.survival.set_process(false) # the server runs our stats
w.player.selected_changed.connect(_on_selected)
_on_selected()
func leave_world() -> void:
if world != null and is_instance_valid(world) and world.player.selected_changed.is_connected(_on_selected):
world.player.selected_changed.disconnect(_on_selected)
world = null
func _on_selected() -> void:
if world != null:
ask("select", [world.player.selected])
## Sends a request to the server (Actions calls this while online).
func ask(what: String, args: Array = []) -> void:
if Net.state != "connected":
return
_seq += 1
_c2s_ask.rpc_id(1, _seq, what, args)
@rpc("authority", "call_remote", "reliable")
func _s2c_inv(data: Array, cursor: Variant, worn: String, ack: int) -> void:
if world == null or not is_instance_valid(world) or ack < _seq:
return # we've asked for more since: the next copy will include it
var pl: CharacterBody3D = world.player
pl.inventory.load_data(data)
if pl.worn != worn:
pl.worn = worn
pl.worn_changed.emit()
cursor_changed.emit(cursor)
@rpc("authority", "call_remote", "reliable")
func _s2c_stats(stats: Dictionary, statuses: Array, env: Array, bed: Array) -> void:
if world == null or not is_instance_valid(world):
return
var s: Node = world.player.survival
s.load_data(stats)
s.statuses.assign(statuses)
s.roofed = bool(env[0])
s.sheltered = bool(env[1])
s.heat = float(env[2])
world.has_bed_spawn = bool(bed[0])
world.bed_spawn = bed[1]
@rpc("authority", "call_remote", "reliable")
func _s2c_died(cause: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void:
if world != null and is_instance_valid(world):
world.died_online(cause, has_items, to_bed, penalty)
@rpc("authority", "call_remote", "reliable")
func _s2c_respawn(at: Vector3, yaw: float, text: String) -> void:
if world == null or not is_instance_valid(world):
return
world.respawn_from_server(at, yaw)
if not text.is_empty():
Actions.notify.emit(text)
@rpc("authority", "call_remote", "reliable")
func _s2c_notify(text: String) -> void:
Actions.notify.emit(text)
var hurts := 0 ## client: how often the server said we were hurt (tests read it)
@rpc("authority", "call_remote", "reliable")
func _s2c_hurt(amount: float, from: Vector3) -> void:
hurts += 1
if world != null and is_instance_valid(world):
world.player.survival.hurt.emit(amount, from)
## The night passed (we were asleep, or up while the others slept).
@rpc("authority", "call_remote", "reliable")
func _s2c_slept(was_asleep: bool) -> void:
if world == null or not is_instance_valid(world):
return
var text := "You slept until morning" if was_asleep else "The night passed while the others slept"
if world.hud != null and was_asleep:
world.hud.fade(func(): Actions.notify.emit(text))
else:
Actions.notify.emit(text)

79
scripts/net/rules.gd Normal file
View File

@@ -0,0 +1,79 @@
extends RefCounted
## A dedicated server's rules (docs/MULTIPLAYER_PLAN.md §4): who may use and
## take down whose things, whether players can hurt each other, what death
## costs, and how hard the island is. All checked on the server; single
## player has no rules (everything is yours).
##
## Pieces remember who placed them (meta "owner" = player id, "owner_name").
## Pieces with no owner (placed before M2.6, or by the world) are anyone's.
const DIFFICULTY := {
# hunger and diet drain, animal and wolf damage, extra wolves in the pack
"easy": {"drain": 0.75, "damage": 0.7, "pack": -1},
"normal": {"drain": 1.0, "damage": 1.0, "pack": 0},
"hard": {"drain": 1.3, "damage": 1.4, "pack": 1},
}
static func _cfg() -> RefCounted:
var net: Node = (Engine.get_main_loop() as SceneTree).root.get_node_or_null("Net")
return net.config if net != null and net.is_server() else null
static func v(k: String) -> Variant:
var c := _cfg()
return c.v(k) if c != null else null
static func owner_of(piece: Node) -> String:
return String(piece.get_meta("owner", "")) if piece != null else ""
static func owner_name(piece: Node) -> String:
return String(piece.get_meta("owner_name", "someone"))
## Whether player `id` may open, take from or use `piece` (chests, doors,
## beds, graves), going by `locks`.
static func may_use(id: String, piece: Node) -> bool:
var c := _cfg()
var owner := owner_of(piece)
if c == null or owner.is_empty() or owner == id:
return true
match String(c.v("locks")):
"off":
return true
"owner_and_friends":
return c.is_friend(owner, id)
return false # "owner"
## Why `id` can't use `piece` ("" = they can).
static func use_problem(id: String, piece: Node) -> String:
if may_use(id, piece):
return ""
var what: String = piece.display_name().to_lower() if piece.has_method("display_name") else "that"
if piece.item_id == "grave":
return "That's %s's grave" % owner_name(piece)
return "That %s is %s's (locked)" % [what, owner_name(piece)]
## Taking a piece down: your own, anything you're allowed to use (friends,
## or locks off), or anyone's if raiding is on.
static func dismantle_problem(id: String, piece: Node) -> String:
if may_use(id, piece) or bool(v("raiding")):
return ""
return "That's %s's, and raiding is off on this server" % owner_name(piece)
static func pvp() -> bool:
return bool(v("pvp"))
static func death_penalty() -> String:
var d: Variant = v("death_penalty")
return String(d) if d != null else "grave"
static func difficulty(k: String) -> float:
var d: Variant = v("difficulty")
return float(DIFFICULTY.get(String(d) if d != null else "normal", DIFFICULTY.normal)[k])
## How fast food rots: "normal", "slow" (half speed) or "off".
static func rot() -> String:
var r: Variant = v("rot")
return String(r) if r != null else "normal"
static func grace_days() -> int:
var g: Variant = v("wolf_grace_days")
return clampi(int(g), 0, 10) if g != null else 3

View File

@@ -0,0 +1,212 @@
extends RefCounted
## A dedicated server's preferences (`server.json`) and its reserved names
## (`names.json` alongside). Created with defaults on first run and meant to
## be edited by hand; command-line flags override single values for one run.
## See docs/MULTIPLAYER_PLAN.md §4.
const DEFAULT_PATH := "user://server/server.json"
const DEFAULTS := {
"server_name": "Emberwild server",
"motd": "Welcome!",
"port": 24242,
"max_players": 8,
"password": "",
"world": "server-world",
"worldlet": "valley",
"seed": 1337,
"pvp": false,
"raiding": false,
"locks": "owner_and_friends",
"death_penalty": "grave",
"sleep": "all_online",
"pause_when_empty": true,
"day_length_minutes": 20,
"wolf_grace_days": 3,
"difficulty": "normal",
"rot": "normal",
"autosave_minutes": 2,
"backups_kept": 10,
"backup_hours": 6,
"check_updates": true,
"admins": [],
"allowlist": [],
"banned": [],
}
## Settings sent to players when they join (never the password or the lists).
const PUBLIC := ["server_name", "motd", "max_players", "worldlet", "pvp", "raiding", "locks",
"death_penalty", "sleep", "pause_when_empty", "day_length_minutes", "wolf_grace_days", "difficulty", "rot"]
var path := DEFAULT_PATH
var data: Dictionary = {} ## what's in server.json (saved)
var overrides: Dictionary = {} ## command-line flags for this run (never saved)
## Reserved names: lower-case name -> {"name": as typed, "id": player id}.
var names: Dictionary = {}
static func open(p: String = DEFAULT_PATH) -> RefCounted:
var c = load("res://scripts/net/server_config.gd").new()
c.path = p
DirAccess.make_dir_recursive_absolute(p.get_base_dir())
var loaded: Variant = JSON.parse_string(FileAccess.get_file_as_string(p)) if FileAccess.file_exists(p) else null
c.data = DEFAULTS.duplicate(true)
if loaded is Dictionary:
for k in loaded:
c.data[k] = loaded[k]
else:
c.save() # first run: write the defaults so they can be edited
var n: Variant = JSON.parse_string(FileAccess.get_file_as_string(c.names_path())) \
if FileAccess.file_exists(c.names_path()) else null
if n is Dictionary:
c.names = n
c.load_friends()
return c
func names_path() -> String:
return path.get_base_dir().path_join("names.json")
func save() -> void:
_write(path, data)
func save_names() -> void:
_write(names_path(), names)
static func _write(p: String, d: Dictionary) -> void:
var tmp := p + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
push_error("[server] couldn't write %s" % p)
return
f.store_string(JSON.stringify(d, "\t"))
f.close()
DirAccess.rename_absolute(tmp, p)
## A setting's value for this run (a flag beats the file).
func v(k: String) -> Variant:
return overrides.get(k, data.get(k))
func public_info() -> Dictionary:
var out := {}
for k in PUBLIC:
out[k] = v(k)
return out
## The player id holding `n`, or "" if it's free.
func name_owner(n: String) -> String:
return String(names.get(n.to_lower(), {}).get("id", ""))
## Gives `n` to `id`, releasing any other name that id held.
func reserve_name(n: String, id: String) -> void:
for k in names.keys():
if names[k].id == id and k != n.to_lower():
names.erase(k)
names[n.to_lower()] = {"name": n, "id": id}
save_names()
func is_admin(id: String) -> bool:
return id in data.admins
func add_admin(id: String) -> void:
if not id in data.admins:
data.admins.append(id)
save()
func ban(id: String) -> void:
if not id in data.banned:
data.banned.append(id)
save()
func unban(id: String) -> bool:
if not id in data.banned:
return false
data.banned.erase(id)
save()
return true
## A reserved name back up for grabs (the player who had it keeps their
## character, and picks another name next time they join).
func release_name(n: String) -> bool:
if not names.has(n.to_lower()):
return false
names.erase(n.to_lower())
save_names()
return true
## The name a player id goes by here ("" if none).
func name_of(id: String) -> String:
for k in names:
if names[k].id == id:
return String(names[k].name)
return ""
# --- rules that can change while the server runs (/set) --------------------------
## What each can be set to. The others in DEFAULTS are fixed at start-up.
const SETTABLE := {
"pvp": ["false", "true"],
"raiding": ["false", "true"],
"locks": ["off", "owner", "owner_and_friends"],
"death_penalty": ["grave", "keep", "drop_all"],
"sleep": ["all_online", "half", "anyone", "off"],
"pause_when_empty": ["false", "true"],
"difficulty": ["easy", "normal", "hard"],
"rot": ["normal", "slow", "off"],
"wolf_grace_days": "0-10",
"motd": "text",
"server_name": "text",
}
## Changes a rule and saves server.json. Returns "" or what's wrong.
func set_rule(k: String, value: String) -> String:
if not SETTABLE.has(k):
return "Can't set '%s'. These can be set: %s" % [k, ", ".join(PackedStringArray(SETTABLE.keys()))]
var allowed: Variant = SETTABLE[k]
var val: Variant
if allowed is Array:
if not value in allowed:
return "%s can be: %s" % [k, " / ".join(PackedStringArray(allowed))]
val = (value == "true") if value in ["true", "false"] else value
elif allowed == "0-10":
if not value.is_valid_int() or int(value) < 0 or int(value) > 10:
return "%s is a number from 0 to 10" % k
val = int(value)
else:
if value.strip_edges().is_empty() or value.length() > 80:
return "%s needs some text (up to 80 characters)" % k
val = value.strip_edges()
data[k] = val
overrides.erase(k) # (a flag no longer wins over what the admin just set)
save()
return ""
# --- friends (docs/MULTIPLAYER_PLAN.md §4) ----------------------------------------
# Per player: whose doors and chests they share. Stored as owner id -> [ids],
# in friends.json next to server.json.
var friends: Dictionary = {}
func friends_path() -> String:
return path.get_base_dir().path_join("friends.json")
func load_friends() -> void:
var f: Variant = JSON.parse_string(FileAccess.get_file_as_string(friends_path())) \
if FileAccess.file_exists(friends_path()) else null
friends = f if f is Dictionary else {}
## `owner` lets `friend` use their things.
func add_friend(owner: String, friend: String) -> void:
var l: Array = friends.get(owner, [])
if not friend in l:
l.append(friend)
friends[owner] = l
_write(friends_path(), friends)
func remove_friend(owner: String, friend: String) -> bool:
var l: Array = friends.get(owner, [])
if not friend in l:
return false
l.erase(friend)
friends[owner] = l
_write(friends_path(), friends)
return true
func is_friend(owner: String, friend: String) -> bool:
return friend in friends.get(owner, [])

View File

@@ -0,0 +1,110 @@
extends CharacterBody3D
## One player's character on a dedicated server (M2.4): what they carry, what
## they hold, what they wear, their bed, and their survival stats, which run
## here (warmth from the server's fires and buildings, hunger, diet...).
##
## It has the same fields and functions the Actions rules use on the local
## player in single-player (inventory, selected, worn, held_item_id,
## survival...), so the server runs exactly the same rules for everyone.
## It has no body: where it is comes from the player's moves (net.gd).
## Saved as players/<player id>.json in the server's world (player_net.gd).
signal worn_changed
signal selected_changed
const HOTBAR := 8
var peer := 0
var player_id := ""
var display := ""
var inventory := Inventory.new(40)
var selected := 0
var worn := ""
var cursor = null ## the stack held on the inventory screen's cursor
var survival: Node
var hands: Node ## (self: held_heat for a carried torch)
## (A body with no collision, so it has a physics id: wolves look for a clear
## line to it, and arrows skip whoever shot them. `velocity` comes from the
## player's moves.)
var water_level := -INF
var in_bed := false ## got into bed, waiting for the night to pass
var bed_at := Vector3.ZERO
var has_bed_spawn := false
var bed_spawn := Vector3.ZERO
var anim := 0 ## last movement state reported (avatar.gd Anim)
var died_at := 0 ## ticks (ms) when they last died
var melee_ready_at := 0 ## ticks (ms): PvP swings wait for the weapon's cooldown
## Physics layer 5 (value 16): only arrows and thrown spears look for it,
## and only when PvP is on (projectile.gd). Nothing else collides with it.
const PLAYER_LAYER := 16
func setup(world: Node3D) -> void:
hands = self
collision_layer = PLAYER_LAYER
collision_mask = 0
set_physics_process(false)
var cs := CollisionShape3D.new()
var cap := CapsuleShape3D.new()
cap.radius = 0.4
cap.height = 1.8
cs.shape = cap
cs.position = Vector3(0, 0.9, 0)
add_child(cs)
water_level = world.terrain.water_level
survival = preload("res://scripts/player/survival.gd").new()
survival.name = "Survival"
survival.player = self
survival.world = world
add_child(survival)
func held_item_id() -> String:
var s = inventory.get_slot(selected)
return s.id if s != null else ""
func select(i: int) -> void:
selected = wrapi(i, 0, HOTBAR)
selected_changed.emit()
func held_heat() -> float:
return float(ItemDB.get_item(held_item_id()).get("light", {}).get("heat", 0.0))
## Chests and graves call this on interact; on the server there's no screen.
func open_inventory(_container: Node) -> void:
pass
func claims_bed(bed: Node3D) -> bool:
return has_bed_spawn and bed_spawn.distance_to(bed.global_position) < 0.3
func claim_bed(bed: Node3D) -> void:
has_bed_spawn = true
bed_spawn = bed.global_position
## The bed this player respawns at, if it's still standing.
func bed_piece(world: Node3D) -> Node3D:
if not has_bed_spawn:
return null
for p in world.all_pieces():
if p.item_id == "bed" and claims_bed(p):
return p
return null
func to_data() -> Dictionary:
return {"name": display, "pos": [global_position.x, global_position.y, global_position.z], "yaw": rotation.y,
"inventory": inventory.to_data(), "selected": selected, "stats": survival.to_data(), "worn": worn,
"bed": {"has": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]}}
func load_data(d: Dictionary) -> void:
if d.has("inventory"):
inventory.load_data(d.inventory)
selected = int(d.get("selected", 0))
if d.has("stats"):
survival.load_data(d.stats)
worn = String(d.get("worn", ""))
if not worn.is_empty() and ItemDB.get_item(worn).is_empty():
worn = ""
var b: Dictionary = d.get("bed", {})
has_bed_spawn = bool(b.get("has", false))
if b.has("pos"):
bed_spawn = Vector3(b.pos[0], b.pos[1], b.pos[2])
rotation.y = float(d.get("yaw", 0.0))

487
scripts/net/world_net.gd Normal file
View File

@@ -0,0 +1,487 @@
extends Node
## Autoload "WorldNet": the shared world (M2.3, docs/MULTIPLAYER_PLAN.md §5).
##
## A dedicated server runs the real world scene headless, with no player of its
## own (world.gd `server_mode`). It owns everything in the island that changes:
## trees and rocks (damage, felling, regrowth), pickups, berry bushes, dropped
## items, building pieces, chests and graves, fires, doors and the clock. The
## weather follows from the seed and the clock, so it needs no syncing.
##
## Players' requests arrive through PlayerNet (player_net.gd), which runs the
## Actions rules on that player's character on the server. The results show
## up here and go to everyone:
## - pieces placed, removed or collapsed, and items dropped: the server's
## world reports them (world.gd signals), wherever they came from;
## - hits, pickups, berries, doors and fires: PlayerNet tells us;
## - chest and grave contents: whenever they change.
##
## On joining, a client's world asks for a snapshot (_c2s_world) once its
## scenery is up; from then on it gets every change, in order.
const FIRE_SYNC := 10.0 ## seconds between fire fuel updates
const SAVE_AFTER := 10.0 ## seconds after a change before saving
var world: Node3D ## server: its world; client: ours while online
var pieces: Dictionary = {} ## net id -> piece (both sides)
var dropped: Dictionary = {} ## net id -> dropped pickup (both sides)
var _next_nid := 1
var _ready_peers: Dictionary = {} ## server: peers that have their snapshot
var _fire_left := FIRE_SYNC
var _blocker_left := 0.0
var _save_left := INF ## counting down to a save after a change
var _chests_dirty: Dictionary = {}
## Counters the tests read.
var received := 0 ## client: world events applied
func is_server() -> bool:
return Net.is_server() and world != null
func is_client() -> bool:
return Net.state == "connected" and world != null and is_instance_valid(world)
# --- server: the world -----------------------------------------------------------
## Loads (or creates) the server's world from its config and adds it to the
## tree. Saves live next to server.json, in worlds/<world>/.
func open_server_world(config: RefCounted) -> Node3D:
Saves.root_dir = config.path.get_base_dir().path_join("worlds")
var id := String(config.v("world")).validate_filename()
if id.is_empty():
id = "server-world"
Saves.current_id = Saves.open_or_create(id, int(config.v("seed")))
backup("start-up") # (the state it's about to load, before anyone changes it)
_backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0
var w: Node3D = load("res://scenes/world.tscn").instantiate()
w.server_mode = true
w.day_length = float(config.v("day_length_minutes")) * 60.0
w.autosave_seconds = maxf(float(config.v("autosave_minutes")), 0.5) * 60.0
pieces.clear()
dropped.clear()
_ready_peers.clear()
_chests_dirty.clear()
_next_nid = 1
world = w
get_tree().root.add_child(w) # (loads the save: pieces come in through _on_piece_added)
for p in w.all_pieces():
_next_nid = maxi(_next_nid, int(p.get_meta("nid", 0)) + 1)
for p in w.all_pieces():
_register(p)
for d in w.all_dropped():
_register_dropped(d)
w.piece_added.connect(_on_piece_added)
w.pieces_removed.connect(_on_pieces_removed)
w.dropped_added.connect(_on_dropped_added)
w.saved.connect(PlayerNet.save_all)
w.props.regrown.connect(_on_regrown)
var args: Dictionary = Net.parse_args(OS.get_cmdline_user_args())
if args.has("debug-regrow"):
w.props.regrow_scale = float(args["debug-regrow"]) # (tests: things grow back in seconds)
print("[server] world '%s' (seed %d): %d pieces, %d dropped items. Saves: %s" % [Saves.current_id,
w.terrain.noise_seed, pieces.size(), dropped.size(), ProjectSettings.globalize_path(Saves.root_dir)])
return w
func close_server_world() -> void:
if world == null:
return
if Net.is_server() and is_instance_valid(world):
save()
if is_instance_valid(world):
world.queue_free()
world = null
pieces.clear()
dropped.clear()
_ready_peers.clear()
PlayerNet.server_reset()
func save() -> void:
_save_left = INF
if world != null and is_instance_valid(world) and world.server_mode:
world.save_now() # (players too: world.saved)
# --- backups (M2.7) ----------------------------------------------------------------
# A copy of the world (meta, world and every player's file) in
# worlds/<world>/backups/<yyyy-mm-dd_hh-mm-ss>/: when the server starts (the
# state it's about to load), every backup_hours while it runs, and on
# /backup. The newest backups_kept are kept. To restore one, stop the server
# and copy its files back up two folders (docs/HOSTING.md).
var _backup_left := INF
func world_dir() -> String:
return Saves.root_dir.path_join(Saves.current_id)
## Makes a backup now. Returns its folder name ("" if there was nothing to back up).
func backup(reason: String) -> String:
var src := world_dir()
if not FileAccess.file_exists(src.path_join("world.json")):
return ""
var stamp := Time.get_datetime_string_from_system(false, true).replace(":", "-").replace(" ", "_")
var dst := src.path_join("backups").path_join(stamp)
var n := 2
while DirAccess.dir_exists_absolute(dst):
dst = src.path_join("backups").path_join("%s_%d" % [stamp, n])
n += 1
DirAccess.make_dir_recursive_absolute(dst.path_join("players"))
for f in ["meta.json", "world.json"]:
if FileAccess.file_exists(src.path_join(f)):
DirAccess.copy_absolute(src.path_join(f), dst.path_join(f))
var pd := DirAccess.open(src.path_join("players"))
if pd != null:
for f in pd.get_files():
if f.ends_with(".json"):
DirAccess.copy_absolute(src.path_join("players").path_join(f), dst.path_join("players").path_join(f))
_prune_backups()
print("[server] backup (%s): %s" % [reason, ProjectSettings.globalize_path(dst)])
return dst.get_file()
func list_backups() -> Array:
var d := DirAccess.open(world_dir().path_join("backups"))
if d == null:
return []
var out: Array = Array(d.get_directories())
out.sort()
return out
func _prune_backups() -> void:
var keep := maxi(int(Net.config.v("backups_kept")), 1)
var all := list_backups()
while all.size() > keep:
_rm_tree(world_dir().path_join("backups").path_join(String(all.pop_front())))
static func _rm_tree(d: String) -> void:
var da := DirAccess.open(d)
if da == null:
return
for sub in da.get_directories():
_rm_tree(d.path_join(sub))
for f in da.get_files():
da.remove(f)
DirAccess.remove_absolute(d)
## Something changed: save within a few seconds. Ctrl+C kills Godot without
## a chance to save, so this keeps the loss small.
func mark_changed() -> void:
if is_server() and _save_left == INF:
_save_left = SAVE_AFTER
func _new_nid() -> int:
_next_nid += 1
return _next_nid - 1
func _register(p: Node3D) -> void:
if not p.has_meta("nid"):
p.set_meta("nid", _new_nid())
pieces[int(p.get_meta("nid"))] = p
if p.get("inventory") != null:
var nid := int(p.get_meta("nid"))
p.inventory.changed.connect(func(): _chests_dirty[nid] = true; mark_changed())
func _register_dropped(d: Node3D) -> void:
if not d.has_meta("nid"):
d.set_meta("nid", _new_nid())
dropped[int(d.get_meta("nid"))] = d
## A player left: they no longer get events. Save while it's fresh.
func peer_left(peer: int) -> void:
_ready_peers.erase(peer)
save()
func _process(delta: float) -> void:
if not is_server():
return
for nid in _chests_dirty.keys():
_flush_chest(nid)
_chests_dirty.clear()
# Pause the island while nobody's on (fires, regrowth, the clock).
var paused: bool = Net.players.is_empty() and bool(Net.config.v("pause_when_empty"))
world.process_mode = Node.PROCESS_MODE_DISABLED if paused else Node.PROCESS_MODE_INHERIT
if paused:
return
_save_left -= delta
if _save_left <= 0.0:
save()
_backup_left -= delta
if _backup_left <= 0.0:
_backup_left = maxf(float(Net.config.v("backup_hours")), 0.25) * 3600.0
save()
backup("every %s h" % Net.config.v("backup_hours"))
_blocker_left -= delta
if _blocker_left <= 0.0:
_blocker_left = 1.0
var at: Array = []
for peer in Net.players:
at.append(Net.player_pos(peer))
world.props.blockers = at # nothing regrows on top of a player
_fire_left -= delta
if _fire_left <= 0.0:
_fire_left = FIRE_SYNC
var fires: Array = []
for nid in pieces:
var p: Node3D = pieces[nid]
if is_instance_valid(p) and p.get("fuel") != null:
fires.append([nid, p.fuel])
if not fires.is_empty():
_all("_s2c_fires", [fires])
## Sends to every player who has their snapshot.
func _all(method: String, args: Array) -> void:
if method != "_s2c_fires":
mark_changed()
for peer in _ready_peers:
callv("rpc_id", [peer, method] + args)
func piece(nid: int) -> Node3D:
var p: Node3D = pieces.get(nid)
if p == null or not is_instance_valid(p) or p.is_queued_for_deletion():
pieces.erase(nid)
return null
return p
## A pickup by the key players use: its scenery id, or "#<net id>" if it was dropped.
func find_pickup(key: String) -> Node3D:
var pk: Node3D
if key.begins_with("#"):
pk = dropped.get(int(key.substr(1)))
else:
pk = world.props.pickups.get(key) if world != null else null
return pk if pk != null and is_instance_valid(pk) else null
# --- server: telling everyone ----------------------------------------------------
func node_hit(n: Node3D, point: Vector3, from: Vector3) -> void:
_all("_s2c_node", [String(n.uid), n.hp, point, from])
func taken(key: String) -> void:
if key.begins_with("#"):
dropped.erase(int(key.substr(1)))
_all("_s2c_taken", [key])
func foraged(bush: Node3D) -> void:
_all("_s2c_forage", [String(bush.uid), bush.regrow_left])
func piece_state(p: Node3D) -> void:
if p.has_method("save_extra"):
_all("_s2c_piece_state", [int(p.get_meta("nid", 0)), p.save_extra()])
func _on_piece_added(p: Node3D) -> void:
# Whoever's request placed it owns it (graves: whoever died).
var by: Node = PlayerNet.proxies.get(PlayerNet._acting)
if by != null and not p.has_meta("owner"):
p.set_meta("owner", String(by.player_id))
p.set_meta("owner_name", String(by.display))
_register(p)
_all("_s2c_piece_added", [world.piece_entry(p)])
func _on_pieces_removed(gone: Array, fell: Array) -> void:
var g: Array = []
var f: Array = []
for p in gone:
var nid := int(p.get_meta("nid", 0))
pieces.erase(nid)
_chests_dirty.erase(nid)
g.append(nid)
if p in fell:
f.append(nid)
_all("_s2c_pieces_removed", [g, f])
func _on_dropped_added(d: Node3D) -> void:
_register_dropped(d)
_all("_s2c_dropped", [world.dropped_entry(d)])
func _on_regrown(uid: String) -> void:
_all("_s2c_regrown", [uid])
func _flush_chest(nid: int) -> void:
var p := piece(nid)
if p == null:
return
_all("_s2c_chest", [nid, p.inventory.to_data()])
# An emptied grave crumbles.
if p.item_id == "grave" and _empty(p.inventory):
world.remove_piece(p, null)
static func _empty(inv: Inventory) -> bool:
for s in inv.slots:
if s != null:
return false
return true
@rpc("any_peer", "call_remote", "reliable")
func _c2s_world() -> void:
var peer := multiplayer.get_remote_sender_id()
if not is_server() or not Net.players.has(peer):
return
_ready_peers[peer] = true
_s2c_world.rpc_id(peer, world.collect_shared())
PlayerNet.push_all(peer)
# --- client ------------------------------------------------------------------------
## Our world is up: ask for the shared state.
func join_world(w: Node3D) -> void:
world = w
pieces.clear()
dropped.clear()
received = 0
_c2s_world.rpc_id(1)
func leave_world() -> void:
if not Net.is_server():
world = null
pieces.clear()
dropped.clear()
## The key the server knows a pickup by: its scenery id, or "#<net id>".
func pickup_key(pk: Node3D) -> String:
return "#%d" % int(pk.get_meta("nid")) if pk.has_meta("nid") else String(pk.uid)
static func nid(p: Node3D) -> int:
return int(p.get_meta("nid", 0))
# --- server -> client ------------------------------------------------------------
@rpc("authority", "call_remote", "reliable")
func _s2c_world(state: Dictionary) -> void:
if not is_client():
return
world.apply_shared(state)
pieces.clear()
dropped.clear()
for p in world.all_pieces():
if p.has_meta("nid"):
pieces[int(p.get_meta("nid"))] = p
for d in world.all_dropped():
if d.has_meta("nid"):
dropped[int(d.get_meta("nid"))] = d
received += 1
@rpc("authority", "call_remote", "reliable")
func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void:
if not is_client():
return
var n: Node3D = world.props.resource_nodes.get(uid)
if n != null and is_instance_valid(n):
n.apply_remote(hp, point, from)
received += 1
## The standing stones' altar changed (key pieces set, the stag beaten).
func stones_state(d: Dictionary) -> void:
_all("_s2c_stones", [d])
@rpc("authority", "call_remote", "reliable")
func _s2c_stones(d: Dictionary) -> void:
if not is_client() or world.stones == null:
return
world.stones.load_data(d)
received += 1
## Lightning struck a tree (hazards.gd, P4): everyone sees it burn. (It
## falls later through _s2c_node, like any felled tree.)
func struck(n: Node3D) -> void:
_all("_s2c_strike", [String(n.uid)])
@rpc("authority", "call_remote", "reliable")
func _s2c_strike(uid: String) -> void:
if not is_client():
return
var n: Node3D = world.props.resource_nodes.get(uid)
var hz: Node = world.get_node_or_null("Hazards")
if n != null and is_instance_valid(n) and hz != null:
hz.show_strike(n)
received += 1
@rpc("authority", "call_remote", "reliable")
func _s2c_regrown(uid: String) -> void:
if not is_client():
return
var n: Node3D = world.props.pickups.get(uid, world.props.resource_nodes.get(uid))
if n != null and is_instance_valid(n):
world.props.regrow.erase(uid)
n.regrow()
received += 1
@rpc("authority", "call_remote", "reliable")
func _s2c_taken(key: String) -> void:
if not is_client():
return
var pk := find_pickup(key)
if key.begins_with("#"):
dropped.erase(int(key.substr(1)))
if pk != null:
pk.collected()
received += 1
@rpc("authority", "call_remote", "reliable")
func _s2c_forage(uid: String, left: float) -> void:
if not is_client():
return
var bush: Node3D = world.props.forage.get(uid)
if bush != null:
bush.restore(left)
received += 1
@rpc("authority", "call_remote", "reliable")
func _s2c_piece_added(e: Dictionary) -> void:
if not is_client():
return
var p: Node3D = world.add_piece_entry(e)
if p != null:
pieces[int(e.nid)] = p
received += 1
@rpc("authority", "call_remote", "reliable")
func _s2c_pieces_removed(nids: Array, fell: Array) -> void:
if not is_client():
return
var gone: Array = []
for k in nids:
var p: Node3D = pieces.get(int(k))
pieces.erase(int(k))
if p != null and is_instance_valid(p):
gone.append(p)
var collapsed: Array = []
for k in fell:
collapsed.append(int(k))
world.remove_pieces_net(gone, collapsed)
received += 1
@rpc("authority", "call_remote", "reliable")
func _s2c_piece_state(nid_: int, extra: Dictionary) -> void:
if not is_client():
return
var p: Node3D = pieces.get(nid_)
if p == null or not is_instance_valid(p):
return
if p.has_method("net_state"):
p.net_state(extra) # (a door)
elif p.has_method("load_extra"):
p.load_extra(extra)
received += 1
@rpc("authority", "call_remote", "reliable")
func _s2c_fires(fires: Array) -> void:
if not is_client():
return
for f in fires:
var p: Node3D = pieces.get(int(f[0]))
if p != null and is_instance_valid(p) and p.has_method("load_extra"):
p.load_extra({"fuel": float(f[1])})
@rpc("authority", "call_remote", "reliable")
func _s2c_chest(nid_: int, data: Array) -> void:
if not is_client():
return
var p: Node3D = pieces.get(nid_)
if p != null and is_instance_valid(p) and p.get("inventory") != null:
p.inventory.load_data(data)
received += 1
@rpc("authority", "call_remote", "reliable")
func _s2c_dropped(e: Dictionary) -> void:
if not is_client():
return
var d: Node3D = world.add_dropped_entry(e)
if d != null:
dropped[int(e.nid)] = d
received += 1

View File

@@ -10,6 +10,9 @@ extends Node3D
signal prompt_changed(text: String)
const Campfire = preload("res://scripts/building/campfire.gd")
const BuildPiece = preload("res://scripts/building/build_piece.gd")
const ItemMeshesScript = preload("res://scripts/items/item_meshes.gd")
const REACH := 3.2
const PLACE_REACH := 6.0
const SWING_TIME := 0.5
@@ -25,13 +28,21 @@ var _ghost_bad_mat: StandardMaterial3D
var _ghost_yaw := 0.0
var _place_xf := Transform3D()
var _place_ok := false
var _place_reason := ""
var _rest_default := Transform3D()
var _rest_bow := Transform3D()
var _drawing := false
var _draw_t := 0.0
var _prompt := ""
var _held_shown := "~"
var _torch_light: OmniLight3D
var _torch_flame: CPUParticles3D
var _t := 0.0
func _ready() -> void:
_ray = RayCast3D.new()
_ray.target_position = Vector3(0, 0, -REACH)
_ray.collision_mask = 1 | 2
_ray.collision_mask = 1 | 2 | 8 # world, interactables, animals
_ray.collide_with_areas = true
_ray.add_exception(player)
add_child(_ray)
@@ -41,6 +52,8 @@ func _ready() -> void:
_view.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_view_rest = Transform3D(Basis.from_euler(Vector3(deg_to_rad(-25), deg_to_rad(-10), deg_to_rad(-20))), Vector3(0.36, -0.42, -0.62))
_view.transform = _view_rest
_rest_default = _view_rest
_rest_bow = Transform3D(Basis.from_euler(Vector3(deg_to_rad(-8), deg_to_rad(80), deg_to_rad(-12))), Vector3(0.3, -0.3, -0.6))
add_child(_view)
_ghost_ok_mat = _ghost_material(Color(0.3, 1.0, 0.4, 0.45))
@@ -51,6 +64,22 @@ func _ready() -> void:
_ghost.top_level = true
add_child(_ghost)
_torch_light = OmniLight3D.new()
_torch_light.light_color = Color(1.0, 0.65, 0.35)
_torch_light.omni_attenuation = 1.3
_torch_light.shadow_enabled = true
_torch_light.position = Vector3(0, 0.62, 0)
_torch_light.visible = false
_view.add_child(_torch_light)
_torch_flame = Campfire._particles(
"TorchFlame", 14, 0.4, Vector3(0, 0.58, 0), 0.2, 0.5, 0.25, 0.5,
Campfire._gradient([
[0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.2, Color(1.0, 0.6, 0.2, 0.8)], [1.0, Color(0.6, 0.1, 0.02, 0.0)]]),
true, 0.12, 0.03)
_torch_flame.local_coords = false
_torch_flame.emitting = false
_view.add_child(_torch_flame)
player.inventory.changed.connect(_refresh_held)
player.selected_changed.connect(_refresh_held)
_refresh_held()
@@ -73,6 +102,9 @@ func _refresh_held() -> void:
_held_shown = id
var def := _held_def()
_view.visible = not id.is_empty() and not def.has("place")
# A bow is held side-on, so you see its curve and string.
_view_rest = _rest_bow if def.has("bow") else _rest_default
_view.transform = _view_rest
if _view.visible:
_view.mesh = ItemDB.mesh_for(id)
var s := 0.85 if def.category in ["tool", "weapon"] else 0.6
@@ -82,6 +114,17 @@ func _refresh_held() -> void:
_ghost_yaw = 0.0
else:
_ghost.visible = false
# Torch: a flickering light at the head of the held torch.
var light: Dictionary = def.get("light", {})
_torch_light.visible = not light.is_empty()
_torch_flame.emitting = not light.is_empty()
if not light.is_empty():
_torch_light.omni_range = float(light.get("range", 8.0))
_torch_light.light_energy = float(light.get("energy", 1.5))
## 0..1 warmth from what's held (a torch).
func held_heat() -> float:
return float(_held_def().get("light", {}).get("heat", 0.0))
func _set_prompt(t: String) -> void:
if t != _prompt:
@@ -92,6 +135,8 @@ func _target() -> Object:
if not _ray.is_colliding():
return null
var c := _ray.get_collider()
if c != null and c.has_meta("owner_piece"):
c = c.get_meta("owner_piece") # a door's moving panel
if c == null or not c.has_method("interact_prompt"):
return null
if c.get("depleted") == true:
@@ -107,11 +152,60 @@ func _physics_process(delta: float) -> void:
var def := _held_def()
if def.has("place"):
_update_ghost(def)
_set_prompt("[LMB] Place %s [R] Rotate" % def.name)
var why := "" if _place_ok or _place_reason.is_empty() else " (%s)" % _place_reason
_set_prompt("[LMB] Place %s [R] Rotate%s" % [def.name, why])
return
_ghost.visible = false
if _torch_light.visible:
_t += delta
_torch_light.light_energy = float(def.get("light", {}).get("energy", 1.5)) * (0.85 + 0.15 * sin(_t * 17.0) * sin(_t * 5.3))
var t := _target()
_set_prompt(t.interact_prompt(player) if t != null else "")
if t != null:
var p: String = t.interact_prompt(player)
if t.is_in_group("piece") and t.item_id != "grave":
p += " [X] Dismantle"
# Online: someone else's piece says whose it is (the server decides
# whether you may use it, by its rules).
var owner := String(t.get_meta("owner", ""))
if not owner.is_empty() and Net.state == "connected" and owner != Net.get_identity().player_id:
p = "%s's · %s" % [String(t.get_meta("owner_name", "someone")), p]
_set_prompt(p)
elif def.has("food"):
_set_prompt("[RMB] Eat %s" % def.name)
elif def.has("wear"):
_set_prompt("[RMB] Wear %s" % def.name)
elif def.has("bow"):
var n: int = Actions.ammo_count(player, def.bow)
_set_prompt("Drawing %d%%" % roundi(draw_power() * 100.0) if _drawing else "[hold LMB] Draw (%d arrows)" % n)
elif def.get("weapon", {}).has("throw"):
_set_prompt("[LMB] Thrust [RMB] Throw")
elif def.has("fishing"):
var b: Node3D = player.get_parent().bobber
if b == null or not is_instance_valid(b):
_set_prompt("[LMB] Cast into the lake")
else:
match b.state:
"biting": _set_prompt("Bite! [LMB] Reel in now")
"floating": _set_prompt("Waiting for a bite... [LMB] Reel in")
_: _set_prompt("[LMB] Reel in")
else:
_set_prompt("")
# The line runs from the rod tip; switching away from the rod reels in.
var bob: Node3D = player.get_parent().bobber
if bob != null and is_instance_valid(bob):
if def.has("fishing"):
bob.tip = rod_tip()
else:
player.get_parent().end_fishing()
if _drawing:
if not def.has("bow"):
_drawing = false # switched away mid-draw
_view.transform = _view_rest
else:
_draw_t += delta
var p := draw_power()
# Pull the bow back toward you as it's drawn.
_view.transform = Transform3D(_view_rest.basis, _view_rest.origin.lerp(Vector3(0.08, -0.22, -0.5), p))
func _update_ghost(def: Dictionary) -> void:
var cam := get_parent() as Camera3D
@@ -124,8 +218,16 @@ func _update_ghost(def: Dictionary) -> void:
_place_ok = false
return
var yaw := player.rotation.y + _ghost_yaw # piece's front (+Z) faces the player
_place_xf = Transform3D(Basis(Vector3.UP, yaw), hit.position)
_place_ok = player.get_parent().placement_valid(def, _place_xf)
if BuildPiece.is_build(def):
# Building pieces snap to what's already built (build_system.gd).
var res: Dictionary = player.get_parent().build.placement(def, hit, yaw)
_place_xf = res.xf
_place_ok = res.ok
_place_reason = res.reason
else:
_place_xf = Transform3D(Basis(Vector3.UP, yaw), hit.position)
_place_ok = player.get_parent().placement_valid(def, _place_xf)
_place_reason = "" if _place_ok else "blocked or too steep"
_ghost.global_transform = _place_xf
_ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat
_ghost.visible = true
@@ -142,8 +244,24 @@ func _unhandled_input(event: InputEvent) -> void:
player.select(player.selected - 1)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
player.select(player.selected + 1)
if event.is_action_pressed("rotate_piece"):
_ghost_yaw += deg_to_rad(22.5)
if event.is_action_released("attack") and _drawing:
_release_bow()
return
if event.is_action_pressed("use_item") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
var d := _held_def()
if d.has("food"):
Actions.eat(player, player.selected)
elif d.has("wear"):
Actions.wear(player, player.selected)
elif d.get("weapon", {}).has("throw"):
_throw()
elif event.is_action_pressed("rotate_piece"):
# Building pieces turn in quarter turns (they snap to a grid).
_ghost_yaw += deg_to_rad(90.0 if BuildPiece.is_build(_held_def()) else 22.5)
elif event.is_action_pressed("dismantle"):
var t := _target()
if t != null and t.is_in_group("piece"):
Actions.dismantle(player, t)
elif event.is_action_pressed("interact"):
var t := _target()
if t != null and t.has_method("interact"):
@@ -154,14 +272,73 @@ func _unhandled_input(event: InputEvent) -> void:
Actions.place(player, player.selected, _place_xf, player.get_parent())
else:
Actions.notify.emit("Can't place that here")
elif _held_def().has("bow"):
_start_draw()
elif _held_def().has("fishing"):
_fish_click()
else:
swing()
# --- fishing ----------------------------------------------------------------------
func _fish_click() -> void:
var w: Node3D = player.get_parent()
if w.bobber != null and is_instance_valid(w.bobber):
Actions.reel(player, w)
return
if _swing_cd > 0.0:
return
var a := _aim()
if Actions.cast_line(player, rod_tip(), a[1], w):
_swing_cd = 0.6
var tw := create_tween()
var back := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(40)), _view_rest.origin)
tw.tween_property(_view, "transform", back, 0.12)
tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE)
## World position of the held rod's tip (where the line starts).
func rod_tip() -> Vector3:
return _view.global_transform * Vector3(0, ItemMeshesScript.ROD_TIP, 0)
# --- bow and spear --------------------------------------------------------------
func _aim() -> Array:
var cam := get_parent() as Camera3D
var fwd := -cam.global_basis.z
return [cam.global_position + fwd * 0.5, fwd]
func _start_draw() -> void:
if _drawing or _swing_cd > 0.0 or not Actions.can_draw(player):
return
_drawing = true
_draw_t = 0.0
func draw_power() -> float:
var bow: Dictionary = _held_def().get("bow", {})
return clampf(_draw_t / float(bow.get("draw", 1.0)), 0.0, 1.0) if _drawing else 0.0
func _release_bow() -> void:
var p := draw_power()
_drawing = false
_view.transform = _view_rest
if p < 0.15:
return # barely drawn: let the string down, nothing fired
var a := _aim()
Actions.shoot(player, a[0], a[1], p, player.get_parent())
_swing_cd = 0.3
func _throw() -> void:
if _swing_cd > 0.0:
return
var a := _aim()
if Actions.throw_spear(player, player.selected, a[0] + Vector3(0, -0.05, 0), (a[1] as Vector3) + Vector3(0, 0.06, 0), player.get_parent()):
_swing_cd = 0.6
## Swings the held item (or fist). The hit is applied at the peak of the swing.
func swing() -> void:
if _swing_cd > 0.0:
return
_swing_cd = SWING_TIME
_swing_cd = float(Actions.melee_stats(player).cooldown)
var tw := create_tween()
var wind := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(35)), _view_rest.origin + Vector3(0, 0.08, 0.05))
var strike := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(-70)), _view_rest.origin + Vector3(-0.05, -0.05, -0.15))
@@ -171,9 +348,42 @@ func swing() -> void:
tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE)
func _apply_hit() -> void:
# Animals first: a short box sweep in front of the camera, so you don't
# need pixel-perfect aim at a running rabbit.
var animal := melee_target()
if animal != null and animal.has_meta("avatar_peer"):
Actions.hit_player(player, int(animal.get_meta("avatar_peer")))
return
if animal != null:
Actions.melee(player, animal)
return
_ray.force_raycast_update()
if not _ray.is_colliding():
return
var c := _ray.get_collider()
if c != null and c.is_in_group("harvestable"):
Actions.harvest_hit(player, c, _ray.get_collision_point())
## The nearest living animal inside the melee sweep, or null.
func melee_target() -> Node3D:
var cam := get_parent() as Camera3D
var reach: float = Actions.melee_stats(player).reach
# Tall enough (down to your feet when looking level) to catch a rabbit.
var box := BoxShape3D.new()
box.size = Vector3(0.9, 2.2, reach)
var q := PhysicsShapeQueryParameters3D.new()
q.shape = box
q.transform = Transform3D(cam.global_basis, cam.global_position - cam.global_basis.z * reach * 0.5 - cam.global_basis.y * 0.5)
# Online with PvP on, other players' avatars too (their hitbox, layer 16).
var pvp: bool = Net.state == "connected" and bool(Net.server_info.get("pvp", false))
q.collision_mask = 8 | (16 if pvp else 0)
var best: Node3D = null
var best_d := INF
for h in cam.get_world_3d().direct_space_state.intersect_shape(q, 8):
var c: Node3D = h.collider
if c.is_in_group("creature") or c.has_meta("avatar_peer"):
var d := c.global_position.distance_to(cam.global_position)
if d < best_d:
best_d = d
best = c
return best

View File

@@ -26,6 +26,9 @@ var inventory := Inventory.new(40) ## slots 0-7 are the hotbar
var selected := 0
var ui_open := false ## set by the inventory screen; freezes look/move
var hands: Node3D
var survival: Node ## health / stamina / hunger / warmth (survival.gd)
var worn := "" ## item id of the cloak you're wearing ("" = none)
signal worn_changed
var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)
@@ -36,6 +39,11 @@ func _ready() -> void:
hands.name = "Hands"
hands.player = self
camera.add_child(hands)
survival = preload("res://scripts/player/survival.gd").new()
survival.name = "Survival"
survival.player = self
survival.world = get_parent()
add_child(survival)
func held_item_id() -> String:
var s = inventory.get_slot(selected)
@@ -70,17 +78,21 @@ func _physics_process(delta: float) -> void:
elif not is_on_floor():
velocity.y -= _gravity * delta
if global_position.y < water_level - 0.6:
survival.soak() # waist-deep or more: you're wet
var jump := Input.is_action_just_pressed("jump") and not ui_open
if jump and is_on_floor():
velocity.y = jump_velocity
elif jump and global_position.y > float_y - 0.4 and global_position.y < float_y + 0.3:
# Swim-jump: kick up out of the water to climb onto a bank.
var can_swim_jump := global_position.y > float_y - 0.4 and global_position.y < float_y + 0.3
if jump and (is_on_floor() or can_swim_jump) and survival.use_stamina(8.0):
# (A swim-jump kicks you up out of the water onto a bank.)
velocity.y = jump_velocity
var input_dir := Vector2.ZERO if ui_open else Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
var current_speed := sprint_speed if Input.is_key_pressed(KEY_SHIFT) else speed
var sprinting: bool = Input.is_key_pressed(KEY_SHIFT) and direction.length() > 0.1 and not ui_open \
and survival.use_stamina(14.0 * delta, true)
var current_speed := sprint_speed if sprinting else speed
if swimming:
current_speed = swim_speed
if direction:

318
scripts/player/survival.gd Normal file
View File

@@ -0,0 +1,318 @@
extends Node
## Survival stats: health, stamina, hunger (satiety) and warmth, each 0..100.
##
## - Hunger drains over about a day and a quarter, faster when you're cold.
## - Warmth moves toward a target: air temperature (day/night, altitude),
## plus heat from fires and a held torch, minus being wet.
## - Health drains while starving or freezing, and regenerates when fed and warm.
## - Stamina pays for sprinting, jumping and swinging, and regenerates after a
## short pause.
##
## step(dt) holds all the rules, so tests can fast-forward time. In M2 this runs
## on the server.
signal changed
signal died
const MAX := 100.0
const HUNGER_DRAIN := 100.0 / 1500.0 ## full to empty in ~1.25 in-game days
const STAMINA_REGEN := 24.0
const STAMINA_DELAY := 1.0
const WET_SECONDS := 90.0
const REST_AFTER := 20.0 ## seconds sheltered by a fire before you're Rested
const REST_SECONDS := 300.0 ## Rested: stamina regenerates 50% faster
var health := MAX
var stamina := MAX
var hunger := MAX ## satiety: 100 = full, 0 = starving
var warmth := 70.0
var wet := 0.0 ## seconds of "Wet" left
var heat := 0.0 ## 0..1 from nearby fires etc (last step)
var statuses: Array[String] = []
var dead := false
var roofed := false ## a roof overhead (rain can't reach you)
var sheltered := false ## roof and walls (the weather barely reaches you)
var rested := 0.0 ## seconds of Rested left
var _shelter_left := 0.0
var _rest_build := 0.0
## Food buffs: id -> {name, left (s), stamina_regen, health_regen (multipliers)}.
var buffs: Dictionary = {}
var sick := 0.0 ## seconds of food poisoning left (P4): slow stamina, can't eat
const SICK_SECONDS := 60.0
var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit
var cause := "" ## what last hurt you, as the death screen says it ("You froze")
var drain_mult := 1.0 ## hunger and diet drain (a server's difficulty, rules.gd)
signal hurt(amount: float, from: Vector3)
const HURT_NO_REGEN := 6.0
## Mixed diet: each food group (meat, fish, greens) has a level that runs
## down over about a day and a half. Food from a group tops it up.
## - One group low: you don't heal naturally ("Poor diet").
## - Two or more low: Malnourished. Health slowly drains, stamina recovers
## slower. Living on berries alone will kill you.
## - All three kept up: Balanced diet, healing x1.5.
const DIET_GROUPS := ["meat", "fish", "plants"]
const DIET_NAMES := {"meat": "meat", "fish": "fish", "plants": "greens"}
const DIET_DRAIN := 100.0 / 1800.0
const DIET_LOW := 15.0
const DIET_GOOD := 40.0
const DIET_START := 60.0
const MALNOURISHED_DRAIN := 0.12
var diet := {"meat": DIET_START, "fish": DIET_START, "plants": DIET_START}
## The groups that are running low right now.
func diet_lacking() -> Array:
var out := []
for g in DIET_GROUPS:
if float(diet[g]) < DIET_LOW:
out.append(g)
return out
func balanced_diet() -> bool:
for g in DIET_GROUPS:
if float(diet[g]) < DIET_GOOD:
return false
return true
var stone_room := false ## sheltered by mostly stone walls (P2): a little warmer
const STONE_ROOM_WARMTH := 6.0
var cozy := false ## sheltered with a fur rug near (P3, the bear)
const COZY_WARMTH := 4.0
func _rug_near(p: Vector3) -> bool:
for r in world.get_tree().get_nodes_in_group("rug"):
if (r as Node3D).global_position.distance_to(p) < r.COZY_RADIUS:
return true
return false
var player: Node3D ## the local player, or a server_player.gd on a server
var world: Node3D
var _stamina_wait := 0.0
func _process(delta: float) -> void:
# (Online, the server runs our stats and sends them: player_net.gd.)
if world != null and not world.menu_mode and not world.is_online():
step(delta)
func step(dt: float) -> void:
if dead:
return
var p := player.global_position
heat = world.heat_at(p, player)
_shelter_left -= dt
if _shelter_left <= 0.0:
_shelter_left = 0.25
roofed = world.build.roofed_at(p)
sheltered = roofed and world.build.enclosure_at(p) >= 0.74
stone_room = sheltered and world.build.stone_walls_at(p) >= 0.5
cozy = sheltered and _rug_near(p)
# Rain soaks you unless there's a roof over your head.
if not roofed and world.weather.rain_at(p) > 0.25:
wet = WET_SECONDS
# Resting: a while by a fire with a roof and walls around you (half the
# time on a fur rug).
if sheltered and heat > 0.25:
_rest_build += dt * (2.0 if cozy else 1.0)
if _rest_build >= REST_AFTER:
rested = maxf(rested, REST_SECONDS)
else:
_rest_build = 0.0
rested = maxf(rested - dt, 0.0)
var wear: Dictionary = ItemDB.get_item(player.worn).get("wear", {}) if not player.worn.is_empty() else {}
var target: float = world.air_warmth(p, sheltered) + heat * 70.0 + float(wear.get("warmth", 0.0))
if stone_room:
target += STONE_ROOM_WARMTH
if cozy:
target += COZY_WARMTH
if wet > 0.0:
target -= 22.0 * float(wear.get("wet_chill", 1.0))
wet = maxf(wet - dt * (3.0 if heat > 0.3 else 1.0), 0.0) # fires dry you off
target = clampf(target, 0.0, MAX)
# Warm up faster than you cool down: getting to a fire should feel good.
# (Out in a blizzard you lose it twice as fast.)
var cool := 1.6 * (2.0 if world.weather.blizzard_chill(sheltered) > 0.0 else 1.0)
warmth = move_toward(warmth, target, (7.0 if target > warmth else cool) * dt)
hunger = maxf(hunger - HUNGER_DRAIN * drain_mult * dt * (1.5 if warmth < 30.0 else 1.0), 0.0)
if _stamina_wait > 0.0:
_stamina_wait -= dt
else:
var malnourished := diet_lacking().size() >= 2
stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * buff_mult("stamina_regen")
* (0.5 if sick > 0.0 else 1.0)
* (0.7 if malnourished else 1.0) * dt, MAX)
for g in DIET_GROUPS:
diet[g] = maxf(float(diet[g]) - DIET_DRAIN * drain_mult * dt, 0.0)
var lacking := diet_lacking()
for id in buffs.keys():
buffs[id].left -= dt
if buffs[id].left <= 0.0:
buffs.erase(id)
_hurt_cd = maxf(_hurt_cd - dt, 0.0)
sick = maxf(sick - dt, 0.0)
var dmg := 0.0
if hunger <= 0.0:
dmg += 0.6
if warmth < 12.0:
dmg += 0.5
if lacking.size() >= 2:
dmg += MALNOURISHED_DRAIN
if dmg > 0.0:
health = maxf(health - dmg * dt, 0.0)
if hunger <= 0.0:
cause = "You starved"
elif warmth < 12.0:
cause = "You froze to death"
else:
cause = "Malnourished: you needed %s" % " and ".join(PackedStringArray(lacking.map(func(g): return DIET_NAMES[g])))
elif hunger > 40.0 and warmth > 35.0 and _hurt_cd <= 0.0 and lacking.is_empty():
var diet_mult := 1.5 if balanced_diet() else 1.0
health = minf(health + 0.5 * buff_mult("health_regen") * diet_mult * dt, MAX)
_update_statuses()
changed.emit()
if health <= 0.0:
dead = true
died.emit()
func _update_statuses() -> void:
statuses.clear()
if heat > 0.25:
statuses.append("Warm")
if warmth < 12.0:
statuses.append("Freezing")
elif warmth < 30.0:
statuses.append("Cold")
if hunger <= 0.0:
statuses.append("Starving")
elif hunger < 25.0:
statuses.append("Hungry")
if wet > 0.0:
statuses.append("Wet")
var lacking := diet_lacking()
if not lacking.is_empty():
var need: Array[String] = []
for g in lacking:
need.append(DIET_NAMES[g])
statuses.append("%s (need %s)" % ["Malnourished" if lacking.size() >= 2 else "Poor diet", ", ".join(need)])
elif balanced_diet():
statuses.append("Balanced diet")
if sick > 0.0:
statuses.append("Food poisoning")
if sheltered:
statuses.append("Sheltered")
if cozy:
statuses.append("Cozy")
if rested > 0.0:
statuses.append("Rested")
for id in buffs:
statuses.append("%s %d:%02d" % [buffs[id].name, int(buffs[id].left) / 60, int(buffs[id].left) % 60])
## Spends stamina for an action. Returns false (and spends nothing) if you're
## too tired. Continuous costs (sprinting) pass `partial = true`.
func use_stamina(amount: float, partial: bool = false) -> bool:
if stamina <= 0.0 or (not partial and stamina < amount * 0.5):
return false
stamina = maxf(stamina - amount, 0.0)
_stamina_wait = STAMINA_DELAY
return true
func eat(food: Dictionary) -> void:
hunger = minf(hunger + float(food.get("hunger", 0)), MAX)
# Raw meat can hurt a little, but never kills you.
health = clampf(health + float(food.get("health", 0)), minf(health, 1.0), MAX)
if food.has("buff"):
add_buff(food.buff)
var d: Dictionary = food.get("diet", {})
for g in d:
if diet.has(g):
diet[g] = minf(float(diet[g]) + float(d[g]), MAX)
_update_statuses()
changed.emit()
func soak() -> void:
wet = WET_SECONDS
## Something you ate disagreed with you (raw meat, raw fish): stamina comes
## back at half speed and you can't keep anything down for a minute.
func poison() -> void:
sick = SICK_SECONDS
_update_statuses()
changed.emit()
## After a night's sleep: a little hungrier, dry, warm and rested.
func after_sleep(seconds: float) -> void:
hunger = maxf(hunger - HUNGER_DRAIN * seconds * 0.3, 0.0)
wet = 0.0
warmth = maxf(warmth, 60.0)
stamina = MAX
rested = REST_SECONDS * 2.0
_update_statuses()
changed.emit()
func reset_after_death() -> void:
dead = false
health = 50.0
stamina = MAX
hunger = 50.0
warmth = 60.0
wet = 0.0
buffs.clear()
cause = ""
# Don't respawn straight back into malnutrition.
for g in DIET_GROUPS:
diet[g] = maxf(float(diet[g]), 30.0)
changed.emit()
## Takes a hit (a boar, later wolves). Pauses natural healing briefly.
## `by` is what did it ("a wolf"), for the death screen.
func damage(amount: float, from: Vector3, by: String = "") -> void:
if dead:
return
cause = "Killed by %s" % by if not by.is_empty() else "You were killed"
health = maxf(health - amount, 0.0)
_hurt_cd = HURT_NO_REGEN
hurt.emit(amount, from)
changed.emit()
if health <= 0.0:
dead = true
died.emit()
## Multiplier from food buffs for one effect (1.0 = none). Buffs don't stack:
## the strongest applies.
func buff_mult(key: String) -> float:
var m := 1.0
for id in buffs:
m = maxf(m, float(buffs[id].get(key, 1.0)))
return m
func add_buff(b: Dictionary) -> void:
var id: String = b.get("id", "buff")
buffs[id] = {"name": b.get("name", id), "left": float(b.get("seconds", 300.0)),
"stamina_regen": float(b.get("stamina_regen", 1.0)), "health_regen": float(b.get("health_regen", 1.0))}
_update_statuses()
func to_data() -> Dictionary:
return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet, "rested": rested,
"buffs": buffs.duplicate(true), "diet": diet.duplicate(), "sick": sick}
func load_data(d: Dictionary) -> void:
health = float(d.get("health", health))
stamina = float(d.get("stamina", stamina))
hunger = float(d.get("hunger", hunger))
warmth = float(d.get("warmth", warmth))
wet = float(d.get("wet", 0.0))
rested = float(d.get("rested", 0.0))
sick = float(d.get("sick", 0.0))
buffs = (d.get("buffs", {}) as Dictionary).duplicate(true)
# Older saves have no diet: start it where a new world does.
var sd: Dictionary = d.get("diet", {})
for g in DIET_GROUPS:
diet[g] = float(sd.get(g, DIET_START))
dead = false
changed.emit()

View File

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

231
scripts/ui/guide.gd Normal file
View File

@@ -0,0 +1,231 @@
extends Control
## The tutorial's guide panel and marker (docs/TUTORIAL_DESIGN.md §1): what to
## do now, top right under the clock, with a progress count; a tick when a
## step's done; a pale glowing post where to go, and an arrow with the
## distance when it's off-screen. H hides the panel. Drawn from the Guide
## autoload; hidden when no tutorial is running.
const UITheme = preload("res://scripts/ui/theme.gd")
var player: Node3D
var _box: PanelContainer
var _chapter: Label
var _title: Label
var _text: Label
var _progress: Label
var _tick: Label
var _arrow: Label
var _marker: Node3D
var _hidden := false
func setup(p: Node3D) -> void:
player = p
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
_box = UITheme.panel()
_box.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_box.grow_horizontal = Control.GROW_DIRECTION_BEGIN
_box.offset_right = -22
_box.offset_top = 48
_box.custom_minimum_size = Vector2(340, 0)
_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_box)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 4)
v.custom_minimum_size = Vector2(320, 0)
_box.add_child(v)
_chapter = UITheme.label("", 13, UITheme.TEXT_DIM)
v.add_child(_chapter)
_title = UITheme.label("", 19)
v.add_child(_title)
_text = UITheme.label("", 15, UITheme.TEXT_DIM)
_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_text.custom_minimum_size = Vector2(320, 0)
v.add_child(_text)
_progress = UITheme.label("", 15, Color(0.95, 0.82, 0.5))
v.add_child(_progress)
_tick = UITheme.label("", 22, Color(0.55, 0.9, 0.45))
_tick.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP)
_tick.offset_top = 90
_tick.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_tick.modulate.a = 0.0
add_child(_tick)
_arrow = UITheme.label("", 20, Color(0.75, 0.95, 1.0))
_arrow.visible = false
add_child(_arrow)
Guide.step_started.connect(func(_i, _s): _redraw())
Guide.progressed.connect(_redraw)
Guide.step_done.connect(_on_done)
Guide.started.connect(_redraw)
Guide.finished.connect(_on_finished)
_redraw()
func _unhandled_input(event: InputEvent) -> void:
if not Guide.active or not (event is InputEventKey) or not event.pressed or event.echo:
return
if event.keycode == KEY_H:
_hidden = not _hidden
_redraw()
elif event.keycode in [KEY_ENTER, KEY_KP_ENTER] and Guide.current().get("goal", {}).has("continue"):
Guide.continue_step()
get_viewport().set_input_as_handled()
func _redraw() -> void:
var s: Dictionary = Guide.current()
visible = Guide.active
_box.visible = Guide.active and not _hidden and not s.is_empty()
if s.is_empty():
return
_chapter.text = "%s · step %d of %d (H hides)" % [Guide.chapter_title(s), Guide.index + 1, Guide.steps.size()]
_title.text = String(s.title)
# The hermit's words, in quotes, with his name.
_text.text = ("%s: \"%s\"" % [Guide.guide_name, String(s.text)]) if not Guide.guide_name.is_empty() else String(s.text)
_progress.text = Guide.progress_text()
func _on_done(_i: int, s: Dictionary) -> void:
_tick.text = "✓ " + String(s.title)
var tw := create_tween()
_tick.modulate.a = 1.0
tw.tween_interval(1.4)
tw.tween_property(_tick, "modulate:a", 0.0, 0.6)
## The end: Old Tam's summary, and Start your own world / Keep exploring.
var card: PanelContainer
func _on_finished() -> void:
_box.visible = false
if card != null and is_instance_valid(card):
return
card = UITheme.panel()
card.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
card.grow_horizontal = Control.GROW_DIRECTION_BOTH
card.grow_vertical = Control.GROW_DIRECTION_BOTH
card.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(card)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 10)
v.custom_minimum_size = Vector2(520, 0)
card.add_child(v)
var f: Dictionary = Guide.finish
v.add_child(UITheme.label(String(f.get("title", "Tutorial done")), 26))
var t := UITheme.label("%s: \"%s\"" % [Guide.guide_name, String(f.get("text", ""))] if not Guide.guide_name.is_empty() else String(f.get("text", "")), 16, UITheme.TEXT_DIM)
t.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
t.custom_minimum_size = Vector2(520, 0)
v.add_child(t)
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10)
v.add_child(h)
for b in [["Start your own world", _on_new_world], ["Keep exploring", _on_keep_exploring]]:
var btn := Button.new()
btn.text = b[0]
btn.focus_mode = Control.FOCUS_NONE
btn.custom_minimum_size = Vector2(240, 44)
btn.add_theme_font_size_override("font_size", 18)
btn.pressed.connect(b[1])
h.add_child(btn)
if player != null and player.get("ui_open") != null:
player.ui_open = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func _on_keep_exploring() -> void:
if card != null:
card.queue_free()
card = null
if player != null and player.get("ui_open") != null:
player.ui_open = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
## Saves the tutorial world and goes to the title screen's New World page.
func _on_new_world() -> void:
if Guide.world != null and is_instance_valid(Guide.world):
Guide.world.save_now()
get_tree().paused = false
Saves.current_id = ""
get_tree().auto_accept_quit = true
var TS: Script = load("res://scripts/ui/title_screen.gd")
TS.start_page = "new"
get_tree().change_scene_to_file("res://scenes/title.tscn")
func _process(_delta: float) -> void:
_update_marker()
## The glowing post at the target, and the arrow at the screen edge.
func _update_marker() -> void:
var at: Vector3 = Guide.marker_position() if Guide.active else Vector3.INF
if _marker != null and (not is_instance_valid(_marker) or _marker.get_parent() != Guide.world):
if is_instance_valid(_marker):
_marker.queue_free()
_marker = null # (its world went: a reload, or back to the title)
if _marker == null and Guide.active and Guide.world != null:
_marker = make_marker()
Guide.world.add_child(_marker)
if _marker == null:
_arrow.visible = false
return
_marker.visible = at != Vector3.INF
if at == Vector3.INF:
_arrow.visible = false
return
_marker.global_position = at
var cam := get_viewport().get_camera_3d()
if cam == null or _hidden:
_arrow.visible = false
return
var vis := not cam.is_position_behind(at)
var sp := cam.unproject_position(at + Vector3(0, 1.0, 0))
var r := get_viewport_rect()
if vis and r.grow(-40).has_point(sp):
_arrow.visible = false
return
# Off-screen: an arrow at the edge, pointing the way, with the distance.
var c := r.size * 0.5
var dir := (sp - c) * (-1.0 if not vis else 1.0)
if dir.length() < 1.0:
dir = Vector2(0, -1)
dir = dir.normalized()
var edge := c + dir * (minf(c.x, c.y) - 70.0)
edge = edge.clamp(Vector2(40, 40), r.size - Vector2(140, 60))
var arrows := ["→", "↘", "↓", "↙", "←", "↖", "↑", "↗"]
var k := wrapi(roundi(atan2(dir.y, dir.x) / (PI / 4.0)), 0, 8)
var who: Node3D = Guide.world.player if Guide.world != null else player
_arrow.text = "%s %d m" % [arrows[k], roundi(who.global_position.distance_to(at))]
_arrow.position = edge
_arrow.visible = true
## A pale, glowing post with a ring at its foot.
static func make_marker() -> Node3D:
var root := Node3D.new()
root.name = "GuideMarker"
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(0.55, 0.85, 1.0, 0.35)
m.cull_mode = BaseMaterial3D.CULL_DISABLED
var beam := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = 0.06
cyl.bottom_radius = 0.14
cyl.height = 5.0
cyl.material = m
beam.mesh = cyl
beam.position = Vector3(0, 2.6, 0)
beam.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
root.add_child(beam)
var ring := MeshInstance3D.new()
var torus := TorusMesh.new()
torus.inner_radius = 0.55
torus.outer_radius = 0.7
torus.material = m
ring.mesh = torus
ring.position = Vector3(0, 0.05, 0)
ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
root.add_child(ring)
if DisplayServer.get_name() != "headless":
var l := OmniLight3D.new()
l.light_color = Color(0.55, 0.85, 1.0)
l.light_energy = 0.6
l.omni_range = 4.0
l.position = Vector3(0, 1.0, 0)
root.add_child(l)
return root

63
scripts/ui/hint_card.gd Normal file
View File

@@ -0,0 +1,63 @@
extends Control
## Old Tam's tips in normal worlds (scripts/core/hints.gd): a card top right,
## under the clock, for 14 seconds. H puts it away early.
const UITheme = preload("res://scripts/ui/theme.gd")
const SHOW_FOR := 14.0
var _box: PanelContainer
var _title: Label
var _text: Label
var _tween: Tween
func setup() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
_box = UITheme.panel()
_box.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_box.grow_horizontal = Control.GROW_DIRECTION_BEGIN
_box.offset_right = -22
_box.offset_top = 48
_box.custom_minimum_size = Vector2(340, 0)
_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
_box.modulate.a = 0.0
_box.visible = false
add_child(_box)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 4)
v.custom_minimum_size = Vector2(320, 0)
_box.add_child(v)
v.add_child(UITheme.label("%s's tip (H hides · tips can be turned off in the menu)" % Hints.guide_name if not Hints.guide_name.is_empty() else "Tip (H hides)", 13, UITheme.TEXT_DIM))
_title = UITheme.label("", 19)
v.add_child(_title)
_text = UITheme.label("", 15, UITheme.TEXT_DIM)
_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_text.custom_minimum_size = Vector2(320, 0)
v.add_child(_text)
Hints.shown.connect(show_hint)
func show_hint(h: Dictionary) -> void:
_title.text = String(h.title)
_text.text = ("\"%s\"" % String(h.text)) if not Hints.guide_name.is_empty() else String(h.text)
_box.visible = true
if _tween != null:
_tween.kill()
_tween = create_tween()
_tween.tween_property(_box, "modulate:a", 1.0, 0.4)
_tween.tween_interval(SHOW_FOR)
_tween.tween_property(_box, "modulate:a", 0.0, 0.8)
_tween.tween_callback(func(): _box.visible = false)
func is_showing() -> bool:
return _box.visible
func hide_hint() -> void:
if _tween != null:
_tween.kill()
_box.visible = false
_box.modulate.a = 0.0
func _unhandled_input(event: InputEvent) -> void:
if _box.visible and event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_H:
hide_hint()
get_viewport().set_input_as_handled()

View File

@@ -15,6 +15,17 @@ var _hotbar: Array = []
var _toasts: VBoxContainer
var _screen: Control
var _menu: Control
var _bars: Dictionary = {}
var _diet_bars: Dictionary = {}
var _status: Label
var _clock: Label
var _hurt: ColorRect
## A red flash at the screen edge when something hits you.
func _on_hurt(amount: float, _from: Vector3) -> void:
_hurt.color.a = clampf(0.15 + amount / 60.0, 0.15, 0.45)
var tw := _hurt.create_tween()
tw.tween_property(_hurt, "color:a", 0.0, 0.6)
func bind(p: CharacterBody3D) -> void:
player = p
@@ -74,6 +85,8 @@ func bind(p: CharacterBody3D) -> void:
_toasts.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(_toasts)
_build_stats(root)
_screen = InventoryScreen.new()
root.add_child(_screen)
_screen.bind(player)
@@ -88,17 +101,280 @@ func bind(p: CharacterBody3D) -> void:
player.selected_changed.connect(_refresh)
player.inventory_requested.connect(_on_inventory_requested)
player.hands.prompt_changed.connect(func(t): _prompt.text = t)
_hurt = ColorRect.new()
_hurt.color = Color(0.7, 0.0, 0.0, 0.0)
_hurt.set_anchors_preset(Control.PRESET_FULL_RECT)
_hurt.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(_hurt)
root.move_child(_hurt, 0)
player.survival.hurt.connect(_on_hurt)
Actions.notify.connect(toast)
_build_chat(root)
ItemDB.icons_ready.connect(_refresh)
_refresh()
## Bars bottom-left (health, stamina, food, warmth), status tags above them,
## clock top-right.
func _build_stats(root: Control) -> void:
var box := VBoxContainer.new()
box.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
box.grow_vertical = Control.GROW_DIRECTION_BEGIN
box.offset_left = 22
box.offset_bottom = -22
box.add_theme_constant_override("separation", 5)
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(box)
_status = UITheme.label("", 15, UITheme.TEXT)
box.add_child(_status)
for spec in [["health", "Health", Color(0.85, 0.22, 0.2)], ["stamina", "Stamina", Color(0.9, 0.78, 0.25)],
["hunger", "Food", Color(0.85, 0.5, 0.2)], ["warmth", "Warmth", Color(0.95, 0.55, 0.3)]]:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
var name_l := UITheme.label(spec[1], 14, UITheme.TEXT_DIM)
name_l.custom_minimum_size = Vector2(62, 0)
row.add_child(name_l)
var bar := ProgressBar.new()
bar.custom_minimum_size = Vector2(190, 14)
bar.show_percentage = false
bar.max_value = 100.0
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_theme_stylebox_override("background", UITheme.box(Color(0, 0, 0, 0.55), UITheme.SLOT_BORDER, 1, 4, 0))
bar.add_theme_stylebox_override("fill", UITheme.box(spec[2], spec[2].lightened(0.2), 1, 4, 0))
row.add_child(bar)
box.add_child(row)
_bars[spec[0]] = bar
# Diet: three short bars (meat, fish, greens), so a gap shows early.
var drow := HBoxContainer.new()
drow.add_theme_constant_override("separation", 6)
drow.mouse_filter = Control.MOUSE_FILTER_IGNORE
var dl := UITheme.label("Diet", 14, UITheme.TEXT_DIM)
dl.custom_minimum_size = Vector2(64, 0)
drow.add_child(dl)
for spec in [["meat", "Meat", Color(0.75, 0.3, 0.25)], ["fish", "Fish", Color(0.35, 0.6, 0.85)], ["plants", "Greens", Color(0.45, 0.75, 0.3)]]:
var cell := VBoxContainer.new()
cell.add_theme_constant_override("separation", 0)
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
var bar := ProgressBar.new()
bar.custom_minimum_size = Vector2(58, 8)
bar.show_percentage = false
bar.max_value = 100.0
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_theme_stylebox_override("background", UITheme.box(Color(0, 0, 0, 0.55), UITheme.SLOT_BORDER, 1, 3, 0))
bar.add_theme_stylebox_override("fill", UITheme.box(spec[2], spec[2].lightened(0.2), 1, 3, 0))
cell.add_child(bar)
cell.add_child(UITheme.label(spec[1], 11, UITheme.TEXT_DIM))
drow.add_child(cell)
_diet_bars[spec[0]] = bar
box.add_child(drow)
_clock = UITheme.label("", 18)
_clock.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
_clock.grow_horizontal = Control.GROW_DIRECTION_BEGIN
_clock.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_clock.offset_right = -22
_clock.offset_top = 14
root.add_child(_clock)
# The tutorial's guide panel, under the clock (hidden unless one's running).
var guide := preload("res://scripts/ui/guide.gd").new()
guide.name = "Guide"
root.add_child(guide)
guide.setup(player)
# Old Tam's tips in normal worlds (T4), in the same place.
var tips := preload("res://scripts/ui/hint_card.gd").new()
tips.name = "HintCard"
root.add_child(tips)
tips.setup()
func _process(_d: float) -> void:
_fade_chat()
var s = player.survival
for k in _bars:
_bars[k].value = s.get(k)
for g in _diet_bars:
_diet_bars[g].value = s.diet[g]
# A group running low blinks, so you notice before it hurts.
_diet_bars[g].modulate.a = 0.45 + 0.55 * absf(sin(Time.get_ticks_msec() / 300.0)) if s.diet[g] < s.DIET_LOW else 1.0
# Warmth bar shifts from icy blue (cold) to orange (warm).
var w: float = s.warmth / 100.0
var wc := Color(0.35, 0.6, 0.95).lerp(Color(0.95, 0.55, 0.3), w)
(_bars["warmth"].get_theme_stylebox("fill") as StyleBoxFlat).bg_color = wc
_status.text = " ".join(s.statuses)
var dn = player.get_parent().day_night
if dn != null:
var wx = player.get_parent().weather
_clock.text = dn.clock_text() + (" " + wx.label() if wx != null else "")
## Fades to black, runs `mid` (e.g. skipping the night), and fades back in.
func fade(mid: Callable) -> void:
var black := ColorRect.new()
black.color = Color(0, 0, 0, 0)
black.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
black.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(black)
player.ui_open = true
var tw := black.create_tween()
tw.tween_property(black, "color:a", 1.0, 0.8)
tw.tween_callback(mid)
tw.tween_interval(0.7)
tw.tween_property(black, "color:a", 0.0, 1.0)
tw.tween_callback(func():
player.ui_open = false
black.queue_free())
var _death: Control
# --- chat (online: T to talk) ----------------------------------------------------------
const CHAT_LINES := 8
const CHAT_FADE := 14.0 ## seconds a line stays up (all show while typing)
var _chat_log: VBoxContainer
var _chat_input: LineEdit
func _build_chat(root: Control) -> void:
_chat_log = VBoxContainer.new()
_chat_log.position = Vector2(16, 64)
_chat_log.custom_minimum_size = Vector2(520, 0)
_chat_log.add_theme_constant_override("separation", 2)
_chat_log.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(_chat_log)
_chat_input = LineEdit.new()
_chat_input.position = Vector2(16, 64 + CHAT_LINES * 24 + 6)
_chat_input.custom_minimum_size = Vector2(520, 34)
_chat_input.placeholder_text = "Say something (Enter), or /help for commands. Esc to cancel"
_chat_input.max_length = 200
_chat_input.visible = false
_chat_input.text_submitted.connect(func(t):
Chat.say(t)
close_chat())
root.add_child(_chat_input)
Chat.message.connect(chat_line)
func chat_line(text: String, kind: String) -> void:
var col := UITheme.TEXT
if kind == "server":
col = Color(0.95, 0.82, 0.45)
elif kind == "reply":
col = UITheme.TEXT_DIM
var l := UITheme.label(text, 15, col)
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
l.custom_minimum_size = Vector2(520, 0)
l.set_meta("t", Time.get_ticks_msec())
_chat_log.add_child(l)
while _chat_log.get_child_count() > CHAT_LINES:
var old := _chat_log.get_child(0)
_chat_log.remove_child(old)
old.queue_free()
func open_chat() -> void:
_chat_input.text = ""
_chat_input.visible = true
player.ui_open = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_chat_input.grab_focus()
func close_chat() -> void:
_chat_input.visible = false
_chat_input.release_focus()
player.ui_open = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _fade_chat() -> void:
if _chat_log == null:
return
var typing := _chat_input.visible
var now := Time.get_ticks_msec()
for l in _chat_log.get_children():
var age := (now - int(l.get_meta("t", now))) / 1000.0
l.modulate.a = 1.0 if typing else clampf((CHAT_FADE - age) / 2.0, 0.0, 1.0)
## The death screen: the world fades to dark red, it says what killed you
## and where your things are, and after `delay` seconds Respawn (or Enter)
## calls `respawn`.
func show_death(cause: String, note: String, delay: float, respawn: Callable) -> void:
hide_death()
player.ui_open = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
var bg := ColorRect.new()
bg.color = Color(0.22, 0.02, 0.02, 0.0)
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(bg)
_death = bg
var col := VBoxContainer.new()
col.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
col.grow_horizontal = Control.GROW_DIRECTION_BOTH
col.grow_vertical = Control.GROW_DIRECTION_BOTH
col.alignment = BoxContainer.ALIGNMENT_CENTER
col.add_theme_constant_override("separation", 14)
col.modulate.a = 0.0
bg.add_child(col)
var title := UITheme.label("You died", 56)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(title)
var why := UITheme.label(cause if not cause.is_empty() else "You died", 22)
why.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(why)
var where := UITheme.label(note, 16, UITheme.TEXT_DIM)
where.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(where)
var btn := Button.new()
btn.custom_minimum_size = Vector2(220, 46)
btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
btn.disabled = true
btn.text = "Respawn (%d)" % ceili(delay)
col.add_child(btn)
btn.pressed.connect(func():
hide_death()
respawn.call())
var tw := bg.create_tween()
tw.tween_property(bg, "color:a", 0.72, 0.9)
tw.parallel().tween_property(col, "modulate:a", 1.0, 0.9)
# Count down, then let them go.
for k in range(1, ceili(delay)):
var left := ceili(delay) - k
get_tree().create_timer(float(k)).timeout.connect(func():
if is_instance_valid(btn) and btn.disabled:
btn.text = "Respawn (%d)" % left)
get_tree().create_timer(delay).timeout.connect(func():
if is_instance_valid(btn):
btn.disabled = false
btn.text = "Respawn"
btn.grab_focus())
func is_dead_screen() -> bool:
return _death != null and is_instance_valid(_death)
func hide_death() -> void:
if is_dead_screen():
_death.queue_free()
_death = null
player.ui_open = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
## Closes the inventory / menu (e.g. on death).
func close_screens() -> void:
if _screen.is_open():
_screen.close()
func _on_inventory_requested(container: Node) -> void:
if not _screen.is_open():
_screen.open(container)
## ESC: close the inventory if it's open, otherwise toggle the pause menu.
func _input(event: InputEvent) -> void:
if _chat_input != null and _chat_input.visible:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
close_chat()
get_viewport().set_input_as_handled()
return # (typing: keys go to the chat line)
if event is InputEventKey and event.pressed and not event.echo and event.physical_keycode == KEY_T \
and Net.state == "connected" and not player.ui_open and not is_dead_screen():
open_chat()
get_viewport().set_input_as_handled()
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
if is_dead_screen():
return # (respawn first)
if _screen.is_open():
_screen.close()
elif _menu.is_open():

74
scripts/ui/hut_outline.gd Normal file
View File

@@ -0,0 +1,74 @@
extends Node3D
## The tutorial's hut outline (docs/TUTORIAL_DESIGN.md §2, step 17): a pale,
## see-through 4 x 4 m hut on a good spot near your fire, the same 20 pieces
## the game's own snapping makes (floors, walls and a door, roofs, gables).
## Each piece of the outline fades out once a real piece of that kind stands
## in its place. It's a suggestion: you can build anywhere.
## [item, offset from the first floor, yaw] (from build_system.gd
## debug_build_hut at yaw 0: the pieces snap to a fixed grid).
const LAYOUT := [
["wood_floor", Vector3(0, 0, 0), 0.0], ["wood_floor", Vector3(2, 0, 0), 0.0],
["wood_floor", Vector3(0, 0, 2), 0.0], ["wood_floor", Vector3(2, 0, 2), 0.0],
["wood_wall", Vector3(0, 0, -1), 0.0], ["wood_wall", Vector3(0, 0, 3), 0.0],
["wood_wall", Vector3(2, 0, -1), 0.0], ["wood_door", Vector3(2, 0, 3), 0.0],
["wood_wall", Vector3(3, 0, 0), PI * 0.5], ["wood_wall", Vector3(-1, 0, 0), PI * 0.5],
["wood_wall", Vector3(3, 0, 2), PI * 0.5], ["wood_wall", Vector3(-1, 0, 2), PI * 0.5],
["wood_roof", Vector3(0, 2, 3), 0.0], ["wood_roof", Vector3(0, 2, -1), PI],
["wood_roof", Vector3(2, 2, 3), 0.0], ["wood_roof", Vector3(2, 2, -1), PI],
["wood_gable", Vector3(-1, 2, 2), PI * 0.5], ["wood_gable", Vector3(-1, 2, 0), -PI * 0.5],
["wood_gable", Vector3(3, 2, 2), PI * 0.5], ["wood_gable", Vector3(3, 2, 0), -PI * 0.5],
]
const MATCH := 0.35 ## a real piece this close to a slot fills it
var world: Node3D
var origin := Transform3D() ## the first floor's place
var slots: Array = [] ## [item, Transform3D, MeshInstance3D]
var _check := 0.0
## Lays the outline out with its first floor at `xf` (from build_system's
## free placement, so it sits level on the ground).
func setup(w: Node3D, xf: Transform3D) -> void:
world = w
origin = xf
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.albedo_color = Color(0.6, 0.88, 1.0, 0.22)
m.cull_mode = BaseMaterial3D.CULL_DISABLED
m.no_depth_test = false
for e in LAYOUT:
var t := Transform3D(xf.basis * Basis(Vector3.UP, float(e[2])), xf * (e[1] as Vector3))
var mi := MeshInstance3D.new()
mi.mesh = ItemDB.mesh_for(String(e[0]))
mi.material_override = m
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(mi)
mi.global_transform = t
slots.append([String(e[0]), t, mi])
## The middle of the hut (the guide's marker points here).
func center() -> Vector3:
return origin * Vector3(1, 0, 1)
func _process(delta: float) -> void:
_check -= delta
if _check <= 0.0:
_check = 0.5
refresh()
## Hides each slot a real piece of its kind now fills. Returns how many are
## left to build.
func refresh() -> int:
var left := 0
var built: Array = world.build.build_pieces() if world != null else []
for s in slots:
var filled := false
for p in built:
if p.item_id == s[0] and (p as Node3D).global_position.distance_to((s[1] as Transform3D).origin) < MATCH:
filled = true
break
(s[2] as MeshInstance3D).visible = not filled
if not filled:
left += 1
return left

View File

@@ -21,6 +21,8 @@ var _chest_grid: GridContainer
var _chest_title: Label
var _recipe_list: VBoxContainer
var _station_label: Label
var _worn_label: Label
var _worn_btn: Button
var _cursor = null ## stack held on the mouse
var _cursor_view: Control
var _cursor_icon: TextureRect
@@ -73,6 +75,20 @@ func bind(p: CharacterBody3D) -> void:
pass
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))
var wr := HBoxContainer.new()
wr.add_theme_constant_override("separation", 10)
_worn_label = UITheme.label("", 15)
wr.add_child(_worn_label)
_worn_btn = Button.new()
_worn_btn.text = "Take off"
_worn_btn.focus_mode = Control.FOCUS_NONE
_worn_btn.pressed.connect(func():
Actions.take_off(player)
_refresh())
wr.add_child(_worn_btn)
pv.add_child(wr)
player.worn_changed.connect(_refresh)
PlayerNet.cursor_changed.connect(_on_server_cursor)
pp.add_child(pv)
row.add_child(pp)
@@ -84,8 +100,14 @@ func bind(p: CharacterBody3D) -> void:
crv.add_child(UITheme.label("Crafting", 20))
_station_label = UITheme.label("", 13, UITheme.TEXT_DIM)
crv.add_child(_station_label)
# Categories: Tools, Weapons, Cooking... (ItemDB.CATEGORIES). Two rows
# of tabs, so they all fit.
_tabs = HFlowContainer.new()
_tabs.add_theme_constant_override("h_separation", 4)
_tabs.add_theme_constant_override("v_separation", 4)
crv.add_child(_tabs)
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(320, 380)
scroll.custom_minimum_size = Vector2(320, 330)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_recipe_list = VBoxContainer.new()
_recipe_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
@@ -123,16 +145,22 @@ func open(chest: Node) -> void:
visible = true
player.ui_open = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_pick_tab()
_rebuild_chest()
_refresh()
func close() -> void:
if _cursor != null:
var left: int = player.inventory.add(_cursor.id, _cursor.count)
if left > 0 and container != null:
left = container.inventory.add(_cursor.id, left)
if WorldNet.is_client():
PlayerNet.ask("close") # the server puts the cursor back in our bag
_cursor = null
if container != null and container.inventory.changed.is_connected(_refresh):
elif _cursor != null:
var left: int = player.inventory.add_stack(_cursor)
if left > 0 and container != null and is_instance_valid(container):
var rest: Dictionary = _cursor.duplicate()
rest.count = left
left = container.inventory.add_stack(rest)
_cursor = null
if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh):
container.inventory.changed.disconnect(_refresh)
container = null
visible = false
@@ -147,12 +175,17 @@ func _input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
elif event is InputEventMouseMotion:
_cursor_view.global_position = event.position
# Walking away from a chest closes it.
if container != null and player.global_position.distance_to(container.global_position) > 5.0:
close()
func _process(_d: float) -> void:
if visible and container != null and player.global_position.distance_to(container.global_position) > 5.0:
if not visible or container == null:
return
# Containers can vanish while open (an emptied grave crumbles).
if not is_instance_valid(container) or container.is_queued_for_deletion():
container = null
_rebuild_chest()
return
if player.global_position.distance_to(container.global_position) > 5.0:
close()
func _rebuild_chest() -> void:
@@ -169,22 +202,36 @@ func _rebuild_chest() -> void:
_chest_grid.add_child(s)
_chest_slots.append(s)
## Online the server does every click (player_net.gd). Clicks in our own
## bag also happen here straight away, so dragging doesn't wait on the
## network; the server's copy replaces ours once it's caught up.
func _on_player_slot(i: int, right: bool, shift: bool) -> void:
if shift and container != null:
Actions.transfer(player, player.inventory, i, container.inventory, container)
else:
_cursor = player.inventory.click(i, _cursor, right)
if WorldNet.is_client():
PlayerNet.ask("inv_click", [0, i, right])
_refresh()
func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
if not Actions._in_reach(player, container, 1.0):
if container == null or not is_instance_valid(container) or not Actions._in_reach(player, container, 1.0):
return
if shift:
Actions.transfer(player, container.inventory, i, player.inventory, container)
elif WorldNet.is_client():
# A shared chest (someone else may be in it too): only the server's
# answer counts.
PlayerNet.ask("inv_click", [WorldNet.nid(container), i, right])
else:
_cursor = container.inventory.click(i, _cursor, right)
_refresh()
## Online: what the server says we're holding.
func _on_server_cursor(stack) -> void:
_cursor = stack if visible else null
_refresh()
func _refresh() -> void:
if player == null:
return
@@ -198,6 +245,8 @@ func _refresh() -> void:
if _cursor != null:
_cursor_icon.texture = ItemDB.icon(_cursor.id)
_cursor_count.text = str(_cursor.count) if _cursor.count > 1 else ""
_worn_label.text = "Wearing: %s" % (ItemDB.item_name(player.worn) if not player.worn.is_empty() else "nothing")
_worn_btn.visible = not player.worn.is_empty()
_rebuild_recipes()
func _rebuild_recipes() -> void:
@@ -206,12 +255,127 @@ func _rebuild_recipes() -> void:
for c in _recipe_list.get_children():
c.queue_free()
var stations: Array[String] = []
for st in ["workbench", "campfire"]:
for st in ["workbench", "campfire", "forge", "stonecutter"]:
if Actions.station_near(player, st):
stations.append(st)
_station_label.text = "Nearby: " + (", ".join(stations) if not stations.is_empty() else "nothing (hand crafting only)")
_rebuild_tabs()
if category == "repair":
for slot in _worn_slots():
_recipe_list.add_child(_repair_row(slot))
return
for rid in ItemDB.recipes:
_recipe_list.add_child(_recipe_row(ItemDB.recipes[rid]))
var r: Dictionary = ItemDB.recipes[rid]
if r.category == category:
_recipe_list.add_child(_recipe_row(r))
## Slots holding a tool or weapon that's been used.
func _worn_slots() -> Array:
var out: Array = []
for i in range(player.inventory.size()):
if not Actions.repair_cost(player, i).is_empty():
out.append(i)
return out
func _repair_row(slot: int) -> Control:
var s = player.inventory.get_slot(slot)
var cost: Dictionary = Actions.repair_cost(player, slot)
var ok := Actions.can_repair(player, slot)
var row := PanelContainer.new()
row.add_theme_stylebox_override("panel", UITheme.box(UITheme.SLOT_BG, UITheme.SELECTED if ok else UITheme.SLOT_BORDER, 1, 5, 6))
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 8)
row.add_child(h)
var icon := TextureRect.new()
icon.texture = ItemDB.icon(s.id)
icon.custom_minimum_size = Vector2(44, 44)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
h.add_child(icon)
var v := VBoxContainer.new()
v.size_flags_horizontal = Control.SIZE_EXPAND_FILL
v.add_child(UITheme.label("%s (%d/%d)" % [ItemDB.item_name(s.id), ItemDB.durability(s), ItemDB.max_durability(s.id)], 16,
UITheme.TEXT if ok else UITheme.TEXT_DIM))
var req := RichTextLabel.new()
req.bbcode_enabled = true
req.fit_content = true
req.scroll_active = false
req.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
req.add_theme_font_size_override("normal_font_size", 13)
req.custom_minimum_size = Vector2(170, 0)
var parts: Array[String] = []
for id in cost.inputs:
var need := int(cost.inputs[id])
var have: int = player.inventory.count(id)
parts.append("[color=#%s]%s %d/%d[/color]" % [(UITheme.OK if have >= need else UITheme.BAD).to_html(false), ItemDB.item_name(id), have, need])
var st := String(cost.get("station", ""))
if not st.is_empty():
parts.append("[color=#%s]@ %s[/color]" % [(UITheme.OK if Actions.station_near(player, st) else UITheme.BAD).to_html(false), st])
req.text = " ".join(parts)
v.add_child(req)
h.add_child(v)
var b := Button.new()
b.text = "Repair"
b.disabled = not ok
b.focus_mode = Control.FOCUS_NONE
b.pressed.connect(func():
Actions.repair(player, slot)
_refresh())
h.add_child(b)
return row
var _tabs: HFlowContainer
var category := "tools" ## the tab showing
var _picked := false ## chose a tab since opening (don't jump about)
## Tabs for the categories that have recipes, each with how many of its
## recipes you could make right now.
func _rebuild_tabs() -> void:
for c in _tabs.get_children():
c.queue_free()
var count := {}
var ready := {}
for rid in ItemDB.recipes:
var r: Dictionary = ItemDB.recipes[rid]
count[r.category] = int(count.get(r.category, 0)) + 1
if Actions.can_craft(player, rid):
ready[r.category] = int(ready.get(r.category, 0)) + 1
# Repair: shown once something you carry is worn.
var worn := _worn_slots()
if not worn.is_empty():
count["repair"] = worn.size()
ready["repair"] = worn.filter(func(i): return Actions.can_repair(player, i)).size()
if not count.has(category):
category = count.keys()[0] if not count.is_empty() else "tools"
for cat in ItemDB.CATEGORIES.keys() + ["repair"]:
if not count.has(cat):
continue
var b := Button.new()
var n := int(ready.get(cat, 0))
b.text = String(ItemDB.CATEGORIES.get(cat, "Repair")) + (" (%d)" % n if n > 0 else "")
b.toggle_mode = true
b.button_pressed = cat == category
b.focus_mode = Control.FOCUS_NONE
b.add_theme_font_size_override("font_size", 13)
if n > 0 and cat != category:
b.add_theme_color_override("font_color", UITheme.OK)
b.pressed.connect(func():
category = cat
_picked = true
_rebuild_recipes())
_tabs.add_child(b)
## On opening: Cooking if you're at a lit fire with something to cook,
## otherwise the tab you were on last.
func _pick_tab() -> void:
_picked = false
if not Actions.station_near(player, "campfire"):
return
for rid in ItemDB.recipes:
var r: Dictionary = ItemDB.recipes[rid]
if r.category == "cooking" and Actions.can_craft(player, rid):
category = "cooking"
return
func _recipe_row(r: Dictionary) -> Control:
var ok := Actions.can_craft(player, r.id)
@@ -235,7 +399,7 @@ func _recipe_row(r: Dictionary) -> Control:
req.bbcode_enabled = true
req.fit_content = true
req.scroll_active = false
req.autowrap_mode = TextServer.AUTOWRAP_OFF
req.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART # (long lists wrap rather than widen the panel)
req.add_theme_font_size_override("normal_font_size", 13)
var parts: Array[String] = []
for id in r.inputs:
@@ -247,7 +411,7 @@ func _recipe_row(r: Dictionary) -> Control:
var col := UITheme.OK if Actions.station_near(player, r.station) else UITheme.BAD
parts.append("[color=#%s]@ %s[/color]" % [col.to_html(false), r.station])
req.text = " ".join(parts)
req.custom_minimum_size = Vector2(200, 0)
req.custom_minimum_size = Vector2(170, 0)
v.add_child(req)
h.add_child(v)
var b := Button.new()
@@ -258,6 +422,10 @@ func _recipe_row(r: Dictionary) -> Control:
h.add_child(b)
return row
## Shift-click crafts up to five (handy for walls and floors).
func _craft(recipe_id: String) -> void:
Actions.craft(player, recipe_id)
var n := 5 if Input.is_key_pressed(KEY_SHIFT) else 1
for i in range(n):
if not Actions.can_craft(player, recipe_id) or not Actions.craft(player, recipe_id):
break
_refresh()

View File

@@ -7,8 +7,13 @@ const UITheme = preload("res://scripts/ui/theme.gd")
const CONTROLS := [
["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"],
["Mouse", "Look"], ["E", "Interact / pick up"], ["Left click", "Use tool / place"],
["R", "Rotate piece"], ["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting"],
["Mouse", "Look"], ["E", "Interact / pick up / add fuel"], ["Left click", "Use tool / place"],
["Right click (holding food)", "Eat"], ["Right click (spear / cloak)", "Throw / wear"],
["Hold left click (bow)", "Draw, release to shoot"],
["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"],
["E on a bed", "Set spawn point / sleep (night, under a roof)"],
["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"],
["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"],
["Esc", "This menu (game autosaves every 2 min)"],
]
@@ -18,6 +23,8 @@ var _main: VBoxContainer
var _controls: VBoxContainer
var _quit_btn: Button
var _quit_armed := false
var _online := false ## on a server: no pausing, no saving here
var _players_label: Label
func bind(p: CharacterBody3D) -> void:
player = p
@@ -73,8 +80,49 @@ func bind(p: CharacterBody3D) -> void:
sens_row.add_child(sens)
_main.add_child(sens_row)
# Old Tam's tips in normal worlds (T4): on or off, or all of them again.
if not Guide.active:
var tips_row := HBoxContainer.new()
var tips := CheckButton.new()
tips.text = "Old Tam's tips"
tips.focus_mode = Control.FOCUS_NONE
tips.button_pressed = Hints.enabled
tips.add_theme_font_size_override("font_size", 18)
tips.size_flags_horizontal = Control.SIZE_EXPAND_FILL
tips.toggled.connect(Hints.set_enabled)
tips_row.add_child(tips)
var again := Button.new()
again.text = "Show again"
again.focus_mode = Control.FOCUS_NONE
again.add_theme_font_size_override("font_size", 15)
again.tooltip_text = "Every tip shows once more, when it next fits"
again.pressed.connect(func(): Hints.reset_seen(); again.text = "Done")
tips_row.add_child(again)
_main.add_child(tips_row)
var upd := CheckButton.new()
upd.text = "Check for updates at start (Emberwild %s)" % Updates.version
upd.focus_mode = Control.FOCUS_NONE
upd.button_pressed = Updates.enabled
upd.add_theme_font_size_override("font_size", 16)
upd.toggled.connect(Updates.set_enabled)
_main.add_child(upd)
# The tutorial: skip a step, or leave it (saved, to carry on later).
if Guide.active:
_main.add_child(_button("Skip this tutorial step", func(): Guide.skip(); close()))
_main.add_child(_button("Leave the tutorial (carry on later)", _on_quit_to_title))
var saved: bool = Saves.has_world()
if saved:
_online = Net.state == "connected"
if _online:
_players_label = UITheme.label("", 15)
_players_label.autowrap_mode = TextServer.AUTOWRAP_WORD
_players_label.custom_minimum_size = Vector2(340, 0)
_main.add_child(_players_label)
_main.add_child(_button("Disconnect", _on_disconnect))
_quit_btn = _button("Disconnect & Quit to Desktop", _on_quit)
elif saved:
_main.add_child(_button("Save & Quit to Title", _on_quit_to_title))
_quit_btn = _button("Save & Quit to Desktop", _on_quit)
else:
@@ -82,6 +130,8 @@ func bind(p: CharacterBody3D) -> void:
_main.add_child(_quit_btn)
var note_text := "Autosaves every 2 minutes. World: %s" % Saves.read_meta(Saves.current_id).get("name", "?") if saved \
else "Not playing a saved world: progress won't be kept."
if _online:
note_text = "The game keeps running while this menu is open."
var note := UITheme.label(note_text, 13, UITheme.TEXT_DIM)
note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_main.add_child(note)
@@ -120,8 +170,14 @@ func open() -> void:
_show_controls(false)
_disarm_quit()
player.ui_open = true
get_tree().paused = true
get_tree().paused = not _online # a shared world can't be paused by one player
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
if _online:
var names: Array[String] = []
for p in Net.players.values():
names.append(String(p.name) + (" (admin)" if p.get("admin", false) else ""))
names.sort()
_players_label.text = "%s · %d online\n%s" % [Net.server_info.get("server_name", "Server"), names.size(), ", ".join(names)]
func close() -> void:
visible = false
@@ -143,7 +199,16 @@ func _show_controls(on: bool) -> void:
func _on_fullscreen(on: bool) -> void:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if on else DisplayServer.WINDOW_MODE_WINDOWED)
## Leaves the server (world.gd sees Net.left and goes back to the title).
func _on_disconnect() -> void:
close()
Net.leave()
func _on_quit() -> void:
if _online:
Net.leave()
get_tree().quit()
return
if Saves.has_world():
player.get_parent().save_now()
get_tree().quit()
@@ -164,4 +229,6 @@ func _on_quit_to_title() -> void:
func _disarm_quit() -> void:
_quit_armed = false
if _online:
return
_quit_btn.text = "Save & Quit to Desktop" if Saves.has_world() else "Quit to Desktop"

View File

@@ -13,6 +13,8 @@ var _key: Label
var _style: StyleBoxFlat
var _selected_style: StyleBoxFlat
var _stack = null
var _wear: ColorRect
var _wear_fill: ColorRect
func _init(i: int = 0, size_px: int = 64, key_label: String = "") -> void:
index = i
@@ -44,17 +46,62 @@ func _init(i: int = 0, size_px: int = 64, key_label: String = "") -> void:
_key.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_key)
_wear = ColorRect.new()
_wear.color = Color(0, 0, 0, 0.6)
_wear.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
_wear.offset_left = 6
_wear.offset_right = -6
_wear.offset_top = -9
_wear.offset_bottom = -5
_wear.mouse_filter = Control.MOUSE_FILTER_IGNORE
_wear.visible = false
add_child(_wear)
_wear_fill = ColorRect.new()
_wear_fill.anchor_bottom = 1.0
_wear_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
_wear.add_child(_wear_fill)
func set_stack(stack) -> void:
_stack = stack
if stack == null:
_icon.texture = null
_count.text = ""
tooltip_text = ""
_wear.visible = false
return
_icon.texture = ItemDB.icon(stack.id)
_count.text = str(stack.count) if stack.count > 1 else ""
var def := ItemDB.get_item(stack.id)
tooltip_text = "%s\n%s" % [def.get("name", stack.id), def.get("description", "")]
# Wear: a bar along the bottom once a tool or weapon has been used.
var full := ItemDB.max_durability(stack.id)
var left := ItemDB.durability(stack)
if full > 0 and left < full:
var k := float(left) / full
_wear.visible = true
_wear_fill.anchor_right = k
_wear_fill.color = Color(0.85, 0.25, 0.2) if k < 0.2 else (Color(0.9, 0.75, 0.25) if k < 0.5 else Color(0.45, 0.8, 0.35))
tooltip_text += "\n%d of %d uses left" % [left, full]
elif stack.has("rot") and Inventory.keeps_for(String(stack.id)) > 0.0:
# Food: a bar that runs down green to brown as it goes off.
var k := Inventory.freshness(stack)
_wear.visible = true
_wear_fill.anchor_right = maxf(k, 0.04)
_wear_fill.color = Color(0.45, 0.8, 0.35).lerp(Color(0.5, 0.33, 0.15), 1.0 - k)
tooltip_text += "\n" + freshness_text(stack)
else:
_wear.visible = false
## "Fresh", "Going off (about 9 hours left)", "Nearly rotten (2 hours left)".
static func freshness_text(stack) -> String:
var k := Inventory.freshness(stack)
var h := Inventory.hours_left(stack)
var left := ("%d hours left" % roundi(h)) if h < 48.0 else ("%d days left" % roundi(h / 24.0))
if k > 0.6:
return "Fresh (%s)" % left
if k > 0.2:
return "Going off (%s)" % left
return "Nearly rotten (%s)" % left
func set_selected(on: bool) -> void:
add_theme_stylebox_override("panel", _selected_style if on else _style)

View File

@@ -8,10 +8,38 @@ const WORLD_SCENE := "res://scenes/world.tscn"
var _pages: Dictionary = {} ## name -> Control
var _name_edit: LineEdit
var _seed_edit: LineEdit
var _rot_pick: OptionButton
## The page to open on (the tutorial's "Start your own world" sends you to "new").
static var start_page := ""
## [setting, what the New World page calls it]
const ROT_CHOICES := [["normal", "Normally (raw meat lasts 2 days)"], ["slow", "Slowly (twice as long)"], ["off", "Never"]]
var _list: VBoxContainer
var _armed_delete := ""
const Identity = preload("res://scripts/net/identity.gd")
var _after_profile := "main" ## page to return to after saving the profile
var _prof_name: LineEdit
var _prof_msg: Label
var _prof_tag: Label
var _prof_beard: Button
var _prof_tunic: Array = []
var _prof_hair: Array = []
var _prof_fig: Dictionary = {}
var _prof_app: Dictionary = {}
var _join_addr: LineEdit
var _join_pw: LineEdit
var _join_msg: Label
var _join_btn: Button
var _lobby_title: Label
var _lobby_motd: Label
var _lobby_rules: Label
var _lobby_count: Label
var _lobby_list: VBoxContainer
var _lobby_note: Label
func _ready() -> void:
if Net.dedicated:
return # a headless server has no menu (and doesn't need the backdrop world)
Saves.current_id = ""
get_tree().auto_accept_quit = true
get_tree().paused = false
@@ -52,20 +80,49 @@ func _ready() -> void:
col.offset_right = 520
col.add_theme_constant_override("separation", 14)
root.add_child(col)
col.add_child(UITheme.label("VALHEIMLIKE", 54))
col.add_child(UITheme.label("EMBERWILD", 54))
col.add_child(UITheme.label("Survive the island. Build a home.", 18, UITheme.TEXT_DIM))
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 24)
col.add_child(spacer)
_build_update_banner(root)
_pages["main"] = _main_page()
_pages["new"] = _new_page()
_pages["load"] = _load_page()
_pages["profile"] = _profile_page()
_pages["join"] = _join_page()
_pages["lobby"] = _lobby_page()
_pages["host"] = _host_page()
for p in _pages.values():
col.add_child(p)
_show("main")
Net.host_status.connect(_on_host_status)
Net.joined.connect(_enter_server_world)
Net.join_failed.connect(_on_join_failed)
Net.left.connect(_on_left)
Net.players_changed.connect(func():
if _pages["lobby"].visible:
_fill_lobby())
_show(start_page if start_page in _pages else "main")
start_page = ""
# Back from a server (left, kicked, or it went away): say why.
if Net.state == "offline" and not Net.last_reason.is_empty():
_show("join")
_join_msg.text = Net.last_reason
Net.last_reason = ""
_maybe_capture()
## The server let us in: show who's there for a moment, then load the island.
func _enter_server_world() -> void:
_show("lobby")
_lobby_note.text = "Loading the island..."
for _i in range(3):
await get_tree().process_frame
if Net.state != "connected":
return
Saves.current_id = "" # a server's world isn't saved locally
get_tree().change_scene_to_file(WORLD_SCENE)
## Dev hook, same as world.gd: `-- --shot=/abs/prefix` saves the title pages.
func _maybe_capture() -> void:
var prefix := ""
@@ -76,7 +133,18 @@ func _maybe_capture() -> void:
return
await get_tree().create_timer(1.0).timeout
print("[title] mouse mode: %s" % ["visible", "hidden", "captured", "confined"][Input.mouse_mode])
for page in ["main", "new", "load"]:
var me: RefCounted = Net.get_identity()
if me.name.is_empty():
me.name = "Paul" # in memory only, for the pictures
for page in ["main", "new", "load", "profile", "join", "host", "lobby"]:
if page == "lobby":
# Sample data: the lobby normally needs a live server.
Net.server_info = {"server_name": "Paul's island", "motd": "Welcome! Be nice to the wolves.",
"max_players": 8, "pvp": false, "raiding": false, "difficulty": "normal"}
Net.players = {1: {"name": "Paul", "admin": true, "appearance": {"tunic": 1}},
2: {"name": "Freya", "admin": false, "appearance": {"tunic": 2}},
3: {"name": "Old Bjorn", "admin": false, "appearance": {"tunic": 3}}}
Net.local_peer = 1
_show(page)
await get_tree().create_timer(4.0 if page == "main" else 0.5).timeout
await RenderingServer.frame_post_draw
@@ -86,17 +154,41 @@ func _maybe_capture() -> void:
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible:
_show("main")
if _pages["lobby"].visible:
Net.leave()
elif Net.state == "connecting" or Net.hosting:
Net.stop_hosting()
Net.stop()
_join_msg.text = ""
_show("main")
else:
_show("main")
func _show(page: String) -> void:
# Joining (or hosting) needs a name: send people to their profile first.
if page in ["join", "host"] and Identity.name_problem(Net.get_identity().name) != "":
_after_profile = page
page = "profile"
if page == "host":
_fill_host()
for k in _pages:
_pages[k].visible = k == page
_armed_delete = ""
if page == "profile":
_fill_profile()
elif page == "join":
_join_shown_at = Time.get_ticks_msec()
_join_addr.text = Net.get_identity().last_server
_join_btn.disabled = false
_join_addr.grab_focus()
elif page == "lobby":
_fill_lobby()
if page == "load":
_fill_list()
elif page == "new":
_name_edit.text = _unique_name()
_seed_edit.text = ""
_rot_pick.select(0)
_name_edit.grab_focus()
_name_edit.select_all()
@@ -114,16 +206,365 @@ func _main_page() -> Control:
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 10)
var worlds := Saves.list_worlds()
if not worlds.is_empty():
if worlds.is_empty():
v.add_child(UITheme.label("New here? Try the tutorial (about 20 minutes).", 15, UITheme.TEXT_DIM))
else:
var w: Dictionary = worlds[0]
v.add_child(_button("Continue: %s" % w.name, _play.bind(w.id)))
var tut := tutorial_world()
v.add_child(_button("Tutorial" if tut.is_empty() else "Tutorial (carry on)", _play_tutorial))
v.add_child(_button("New World", _show.bind("new")))
var lb := _button("Load World", _show.bind("load"))
lb.disabled = worlds.is_empty()
v.add_child(lb)
v.add_child(_button("Host Game", _show.bind("host")))
v.add_child(_button("Join Game", _show.bind("join")))
var n: String = Net.get_identity().name
v.add_child(_button("Profile: %s" % n if not n.is_empty() else "Set Up Your Profile",
func(): _after_profile = "main"; _show("profile")))
v.add_child(_button("Quit", get_tree().quit))
return v
# --- multiplayer: profile, join, lobby ----------------------------------------
func _profile_page() -> Control:
var p := UITheme.panel()
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 22)
p.add_child(h)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 8)
h.add_child(v)
v.add_child(UITheme.label("Your profile", 24))
v.add_child(UITheme.label("Name (shown over your character to other players)", 15, UITheme.TEXT_DIM))
_prof_name = LineEdit.new()
_prof_name.custom_minimum_size = Vector2(300, 40)
_prof_name.max_length = 16
_prof_name.text_changed.connect(func(_t): _update_profile_preview())
_prof_name.text_submitted.connect(func(_t): _save_profile())
v.add_child(_prof_name)
v.add_child(UITheme.label("Tunic", 15, UITheme.TEXT_DIM))
v.add_child(_swatch_row(Identity.TUNICS, "tunic", _prof_tunic))
v.add_child(UITheme.label("Hair", 15, UITheme.TEXT_DIM))
v.add_child(_swatch_row(Identity.HAIR, "hair", _prof_hair))
_prof_beard = Button.new()
_prof_beard.toggle_mode = true
_prof_beard.focus_mode = Control.FOCUS_NONE
_prof_beard.custom_minimum_size = Vector2(140, 34)
_prof_beard.add_theme_font_size_override("font_size", 16)
_prof_beard.toggled.connect(func(on): _prof_app.beard = on; _update_profile_preview())
v.add_child(_prof_beard)
_prof_msg = UITheme.label("", 14, UITheme.TEXT_DIM)
v.add_child(_prof_msg)
var b := HBoxContainer.new()
b.add_theme_constant_override("separation", 10)
b.add_child(_button("Save", _save_profile, 140))
b.add_child(_button("Back", func(): _show("main"), 120))
v.add_child(b)
# A little figure: hair, face, beard, tunic.
var fig := VBoxContainer.new()
fig.add_theme_constant_override("separation", 0)
fig.alignment = BoxContainer.ALIGNMENT_CENTER
_prof_tag = UITheme.label("", 15)
_prof_tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
fig.add_child(_prof_tag)
_prof_fig = {}
for part in [["hair", Vector2(44, 14)], ["face", Vector2(40, 30)], ["beard", Vector2(34, 12)], ["tunic", Vector2(64, 60)]]:
var c := CenterContainer.new()
var r := ColorRect.new()
r.custom_minimum_size = part[1]
c.add_child(r)
fig.add_child(c)
_prof_fig[part[0]] = r
_prof_fig.face.color = Color(0.86, 0.68, 0.55)
h.add_child(fig)
return p
func _swatch_row(colors: Array, key: String, store: Array) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 6)
for i in range(colors.size()):
var s := Button.new()
s.custom_minimum_size = Vector2(36, 30)
s.focus_mode = Control.FOCUS_NONE
s.pressed.connect(func(): _prof_app[key] = i; _update_profile_preview())
row.add_child(s)
store.append(s)
return row
func _fill_profile() -> void:
var me: RefCounted = Net.get_identity()
_prof_name.text = me.name
_prof_app = me.appearance.duplicate()
_prof_beard.set_pressed_no_signal(bool(_prof_app.beard))
_prof_msg.text = "Pick a name to join servers." if me.name.is_empty() else ""
_update_profile_preview()
_prof_name.grab_focus()
func _update_profile_preview() -> void:
for pair in [[_prof_tunic, Identity.TUNICS, "tunic"], [_prof_hair, Identity.HAIR, "hair"]]:
for i in range(pair[0].size()):
var sel: bool = int(_prof_app[pair[2]]) == i
var st := UITheme.box(pair[1][i], UITheme.SELECTED if sel else UITheme.SLOT_BORDER, 3 if sel else 1, 4, 0)
for state in ["normal", "hover", "pressed"]:
pair[0][i].add_theme_stylebox_override(state, st)
_prof_beard.text = "Beard: %s" % ("yes" if _prof_app.beard else "no")
var bst := UITheme.box(UITheme.SLOT_BG, UITheme.SELECTED if _prof_app.beard else UITheme.SLOT_BORDER, 2 if _prof_app.beard else 1, 4, 6)
for state in ["normal", "hover", "pressed", "hover_pressed"]:
_prof_beard.add_theme_stylebox_override(state, bst)
var hair: Color = Identity.HAIR[int(_prof_app.hair)]
_prof_fig.hair.color = hair
_prof_fig.beard.color = hair if _prof_app.beard else Color(0, 0, 0, 0)
_prof_fig.tunic.color = Identity.TUNICS[int(_prof_app.tunic)]
var n := _prof_name.text
_prof_tag.text = n if not n.is_empty() else "your name"
var bad := Identity.name_problem(n)
_prof_msg.text = bad if not n.is_empty() else "Pick a name to join servers."
_prof_msg.add_theme_color_override("font_color", UITheme.BAD if not bad.is_empty() and not n.is_empty() else UITheme.TEXT_DIM)
func _save_profile() -> void:
var bad := Identity.name_problem(_prof_name.text)
if not bad.is_empty():
_update_profile_preview()
return
var me: RefCounted = Net.get_identity()
me.name = _prof_name.text
me.appearance = Identity.sanitize_appearance(_prof_app)
me.save_profile()
_rebuild_main()
_show(_after_profile)
var _join_shown_at := -100000
# --- hosting (M2.7) -------------------------------------------------------------
var _host_name: LineEdit
var _host_pw: LineEdit
var _host_port: LineEdit
var _host_seed: LineEdit
var _host_seed_label: Label
var _host_preset: OptionButton
var _host_msg: Label
var _host_btn: Button
const PRESET_IDS := ["co-op", "rivals", "adversarial"]
const PRESET_NAMES := ["Co-op: no PvP, bases safe, friends share", "Friendly rivals: PvP on, bases safe",
"Adversarial: PvP, raiding, locks, drop everything on death"]
func _host_page() -> Control:
var p := UITheme.panel()
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 7)
p.add_child(v)
v.add_child(UITheme.label("Host a game", 24))
v.add_child(UITheme.label("Runs a server on this computer and puts you in it. Friends join with your address.", 13, UITheme.TEXT_DIM))
v.add_child(UITheme.label("Server name", 15, UITheme.TEXT_DIM))
_host_name = LineEdit.new()
_host_name.custom_minimum_size = Vector2(420, 38)
_host_name.max_length = 40
v.add_child(_host_name)
v.add_child(UITheme.label("Rules", 15, UITheme.TEXT_DIM))
_host_preset = OptionButton.new()
for n in PRESET_NAMES:
_host_preset.add_item(n)
_host_preset.custom_minimum_size = Vector2(420, 36)
v.add_child(_host_preset)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var c1 := VBoxContainer.new()
c1.add_child(UITheme.label("Password (optional)", 15, UITheme.TEXT_DIM))
_host_pw = LineEdit.new()
_host_pw.secret = true
_host_pw.custom_minimum_size = Vector2(200, 38)
c1.add_child(_host_pw)
row.add_child(c1)
var c2 := VBoxContainer.new()
c2.add_child(UITheme.label("Port (UDP)", 15, UITheme.TEXT_DIM))
_host_port = LineEdit.new()
_host_port.custom_minimum_size = Vector2(100, 38)
c2.add_child(_host_port)
row.add_child(c2)
var c3 := VBoxContainer.new()
_host_seed_label = UITheme.label("Seed", 15, UITheme.TEXT_DIM)
c3.add_child(_host_seed_label)
_host_seed = LineEdit.new()
_host_seed.placeholder_text = "random"
_host_seed.custom_minimum_size = Vector2(100, 38)
c3.add_child(_host_seed)
row.add_child(c3)
v.add_child(row)
_host_msg = UITheme.label("", 14, UITheme.TEXT_DIM)
_host_msg.autowrap_mode = TextServer.AUTOWRAP_WORD
_host_msg.custom_minimum_size = Vector2(420, 0)
v.add_child(_host_msg)
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10)
_host_btn = _button("Start", _start_host, 140)
h.add_child(_host_btn)
h.add_child(_button("Back", func():
Net.stop_hosting()
Net.stop()
_show("main"), 120))
v.add_child(h)
return p
func _fill_host() -> void:
var c: RefCounted = load("res://scripts/net/server_config.gd").open(Net.host_config)
var n: String = c.data.server_name
_host_name.text = "%s's island" % Net.get_identity().name if n == "Emberwild server" else n
_host_port.text = str(int(c.data.port))
_host_pw.text = String(c.data.password)
var preset := "co-op"
if bool(c.data.pvp):
preset = "adversarial" if bool(c.data.raiding) else "rivals"
_host_preset.select(PRESET_IDS.find(preset))
var meta_path: String = Net.host_config.get_base_dir().path_join("worlds/server-world/meta.json")
var existing: bool = FileAccess.file_exists(meta_path)
_host_seed.editable = not existing
_host_seed_label.text = "Seed" if not existing else "Seed (kept)"
_host_seed.text = "" if not existing else str(int(c.data.seed))
_host_msg.text = "Your hosted island carries on where you left it." if existing else "A new island will be made."
_host_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
_host_btn.disabled = false
func _start_host() -> void:
var port_t := _host_port.text.strip_edges()
if not port_t.is_valid_int() or int(port_t) < 1024 or int(port_t) > 65535:
_on_host_status("The port is a number from 1024 to 65535 (24242 is the usual one)", true)
return
var seed_t := _host_seed.text.strip_edges()
var seed := int(seed_t) if seed_t.is_valid_int() else (seed_t.hash() if not seed_t.is_empty() else randi() % 1000000)
var name := _host_name.text.strip_edges()
if name.is_empty():
name = "%s's island" % Net.get_identity().name
_host_btn.disabled = true
Net.host(name, _host_pw.text, int(port_t), PRESET_IDS[maxi(_host_preset.selected, 0)], seed)
func _on_host_status(text: String, failed: bool) -> void:
if not _pages["host"].visible:
return
_host_msg.text = text
_host_msg.add_theme_color_override("font_color", UITheme.BAD if failed else UITheme.TEXT_DIM)
if failed:
_host_btn.disabled = false
func _join_page() -> Control:
var p := UITheme.panel()
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 8)
p.add_child(v)
v.add_child(UITheme.label("Join a server", 24))
v.add_child(UITheme.label("Address (host, or host:port; the default port is %d)" % Net.DEFAULT_PORT, 15, UITheme.TEXT_DIM))
_join_addr = LineEdit.new()
_join_addr.placeholder_text = "e.g. 100.101.102.103 or homeserver:24242"
_join_addr.custom_minimum_size = Vector2(400, 40)
_join_addr.text_submitted.connect(func(_t): _connect())
v.add_child(_join_addr)
v.add_child(UITheme.label("Password (if the server has one)", 15, UITheme.TEXT_DIM))
_join_pw = LineEdit.new()
_join_pw.secret = true
_join_pw.custom_minimum_size = Vector2(400, 40)
_join_pw.text_submitted.connect(func(_t): _connect())
v.add_child(_join_pw)
v.add_child(UITheme.label("On Tailscale, use the server's 100.x address or its machine name.", 13, UITheme.TEXT_DIM))
_join_msg = UITheme.label("", 15, UITheme.TEXT_DIM)
_join_msg.autowrap_mode = TextServer.AUTOWRAP_WORD
_join_msg.custom_minimum_size = Vector2(400, 0)
v.add_child(_join_msg)
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10)
_join_btn = _button("Connect", _connect, 160)
h.add_child(_join_btn)
h.add_child(_button("Back", func():
Net.stop()
_join_msg.text = ""
_show("main"), 120))
v.add_child(h)
return p
func _connect() -> void:
# (A click or Enter carried over from the page before can't connect.)
if Time.get_ticks_msec() - _join_shown_at < 400:
return
if _join_addr.text.strip_edges().is_empty():
_join_msg.text = "Enter the server's address"
_join_msg.add_theme_color_override("font_color", UITheme.BAD)
return
_join_msg.text = "Connecting to %s..." % _join_addr.text.strip_edges()
_join_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
_join_btn.disabled = true
Net.join(_join_addr.text, _join_pw.text)
func _on_join_failed(reason: String) -> void:
if _pages["host"].visible:
_on_host_status(reason, true)
return
_join_btn.disabled = false
_join_msg.text = reason + version_advice(reason)
_join_msg.add_theme_color_override("font_color", UITheme.BAD)
func _on_left(reason: String) -> void:
_show("join")
_join_msg.text = reason
_join_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
func _lobby_page() -> Control:
var p := UITheme.panel()
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 8)
p.add_child(v)
_lobby_title = UITheme.label("", 24)
v.add_child(_lobby_title)
_lobby_motd = UITheme.label("", 15, UITheme.TEXT_DIM)
_lobby_motd.autowrap_mode = TextServer.AUTOWRAP_WORD
_lobby_motd.custom_minimum_size = Vector2(420, 0)
v.add_child(_lobby_motd)
_lobby_rules = UITheme.label("", 14, UITheme.TEXT_DIM)
v.add_child(_lobby_rules)
_lobby_count = UITheme.label("", 17)
v.add_child(_lobby_count)
_lobby_list = VBoxContainer.new()
_lobby_list.add_theme_constant_override("separation", 4)
v.add_child(_lobby_list)
_lobby_note = UITheme.label("", 13, UITheme.TEXT_DIM)
_lobby_note.autowrap_mode = TextServer.AUTOWRAP_WORD
_lobby_note.custom_minimum_size = Vector2(420, 0)
v.add_child(_lobby_note)
v.add_child(_button("Disconnect", Net.leave, 180))
return p
func _fill_lobby() -> void:
var s: Dictionary = Net.server_info
_lobby_title.text = String(s.get("server_name", "Server"))
_lobby_motd.text = String(s.get("motd", ""))
var mode := "Co-op"
if s.get("pvp", false):
mode = "Adversarial" if s.get("raiding", false) else "Friendly rivals"
_lobby_rules.text = "%s · PvP %s · raiding %s · %s difficulty" % [mode, "on" if s.get("pvp", false) else "off",
"on" if s.get("raiding", false) else "off", s.get("difficulty", "normal")]
_lobby_count.text = "Players (%d/%d)" % [Net.players.size(), int(s.get("max_players", 0))]
for c in _lobby_list.get_children():
c.queue_free()
var peers := Net.players.keys()
peers.sort_custom(func(a, b): return String(Net.players[a].name).to_lower() < String(Net.players[b].name).to_lower())
for peer in peers:
var pl: Dictionary = Net.players[peer]
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var sw := ColorRect.new()
sw.custom_minimum_size = Vector2(16, 16)
sw.color = Identity.TUNICS[int(pl.get("appearance", {}).get("tunic", 0))]
var c := CenterContainer.new()
c.add_child(sw)
row.add_child(c)
var tags := []
if peer == Net.local_peer:
tags.append("you")
if pl.get("admin", false):
tags.append("admin")
row.add_child(UITheme.label(String(pl.name) + (" (%s)" % ", ".join(tags) if not tags.is_empty() else ""), 16,
UITheme.SELECTED if peer == Net.local_peer else UITheme.TEXT))
_lobby_list.add_child(row)
func _new_page() -> Control:
var p := UITheme.panel()
var v := VBoxContainer.new()
@@ -142,6 +583,12 @@ func _new_page() -> Control:
_seed_edit.custom_minimum_size = Vector2(360, 40)
_seed_edit.text_submitted.connect(func(_t): _create())
v.add_child(_seed_edit)
v.add_child(UITheme.label("Food spoils", 15, UITheme.TEXT_DIM))
_rot_pick = OptionButton.new()
for t in ROT_CHOICES:
_rot_pick.add_item(t[1])
_rot_pick.custom_minimum_size = Vector2(360, 36)
v.add_child(_rot_pick)
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10)
h.add_child(_button("Create & Play", _create, 170))
@@ -231,8 +678,94 @@ func _create() -> void:
world_name = _unique_name()
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())
_play(Saves.create_world(world_name, seed))
var rot: String = ROT_CHOICES[maxi(_rot_pick.selected, 0)][0]
_play(Saves.create_world(world_name, seed, {"rot": rot}))
## The tutorial (docs/TUTORIAL_DESIGN.md): its own world on the original
## island (seed 1337), made the first time and carried on after that.
const TUTORIAL_SEED := 1337
static func tutorial_world() -> Dictionary:
for w in Saves.list_worlds():
if bool(w.get("tutorial", false)):
return w
return {}
func _play_tutorial() -> void:
var w := tutorial_world()
_play(String(w.id) if not w.is_empty() else Saves.create_world("Tutorial", TUTORIAL_SEED, {"tutorial": true}))
func _play(id: String) -> void:
Saves.current_id = id
get_tree().change_scene_to_file(WORLD_SCENE)
# --- updates (scripts/core/updates.gd) ------------------------------------------------
## Asked once per run of the game, not every time you come back to the title.
static var _update_asked := false
var update_banner: PanelContainer
var _update_text: Label
var _update_btn: Button
var version_label: Label
## The version, bottom left; and a card bottom right when a newer one is out.
func _build_update_banner(root: Control) -> void:
version_label = UITheme.label("Emberwild %s" % Updates.version, 13, UITheme.TEXT_DIM)
version_label.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
version_label.offset_left = 16
version_label.offset_top = -30
root.add_child(version_label)
update_banner = UITheme.panel()
update_banner.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
update_banner.grow_horizontal = Control.GROW_DIRECTION_BEGIN
update_banner.grow_vertical = Control.GROW_DIRECTION_BEGIN
update_banner.offset_right = -24
update_banner.offset_bottom = -24
update_banner.visible = false
root.add_child(update_banner)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 8)
v.custom_minimum_size = Vector2(360, 0)
update_banner.add_child(v)
_update_text = UITheme.label("", 16)
_update_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_update_text.custom_minimum_size = Vector2(360, 0)
v.add_child(_update_text)
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 8)
v.add_child(h)
_update_btn = _button("Download", _on_update_download, 170)
h.add_child(_update_btn)
h.add_child(_button("Not now", func(): update_banner.visible = false, 120))
Updates.checked.connect(_on_update_checked)
if Updates.state == "available":
_on_update_checked("available")
elif not _update_asked:
_update_asked = true
Updates.check()
func _on_update_checked(s: String) -> void:
if s != "available" or update_banner == null:
return
var notes := String(Updates.latest.get("notes", ""))
var how := "" if Updates.packaged() else "\nRunning from the source: git pull in the game folder, then restart."
_update_text.text = "Emberwild %s is out (you have %s).%s%s" % [Updates.latest_version(), Updates.version,
("\n" + notes) if not notes.is_empty() else "", how]
_update_btn.text = "Download" if Updates.packaged() and not String(Updates.download().get("url", "")).is_empty() else "Release page"
_update_btn.disabled = Updates.download_url().is_empty()
update_banner.visible = true
func _on_update_download() -> void:
var u := Updates.download_url()
if not u.is_empty():
OS.shell_open(u)
## Extra words for a "Different game version" refusal: what to do about it.
static func version_advice(reason: String) -> String:
if not reason.begins_with("Different game version"):
return ""
if Updates.state == "available":
return "\nEmberwild %s is out: Download is on the title page." % Updates.latest_version()
if reason.ends_with("Update the game to join."):
return "\nThere's no newer release yet: the server may be running a newer copy from the source."
return ""

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

@@ -0,0 +1,451 @@
extends Node3D
## The barrow (docs/PROGRESSION_DESIGN.md §4): an ancient burial mound in a
## ring of standing stones, out on open ground. Scenery, like the wolf den:
## built from the seed on every machine, so it's the same for everyone.
##
## A stone-lined passage leads under the mound into a chamber with a stone
## coffin at the back. Three skeletal guards (guard.gd) stand inside. They
## wake when someone's inside the stone ring at night (or at any time after
## day WAKE_DAY), when the coffin is opened, or when one of them is hit.
## The coffin holds iron and a barrow key piece (three open the standing
## stones on the rim, P5). It fills again RESTOCK_DAYS after it was emptied,
## and fallen guards are back at their posts then too.
##
## Guards and loot run on the server (or in single-player): start().
## Saved with the world: when the coffin was emptied and which guards fell.
##
## Local space: +Z is the passage's mouth (facing the centre of the valley),
## the origin is the chamber floor.
const IM = preload("res://scripts/items/item_meshes.gd")
const P = preload("res://scripts/world/proc_mesh.gd")
const GuardScript = preload("res://scripts/creatures/guard.gd")
const MOUND_R := 6.5
const MOUND_H := 3.6
const RING_R := 10.0 ## the standing stones
const CLEAR_R := 11.5
const LEASH := 22.0 ## guards don't follow you further than this from the centre
const WAKE_DAY := 10 ## after this, they wake by day too
const RESTOCK_DAYS := 5
const PASSAGE_END := 6.4 ## the mouth, local z
const POSTS := [[Vector3(-1.4, 0, -0.4), 0.0], [Vector3(1.4, 0, -0.4), 0.0], [Vector3(0.0, 0, 1.2), 0.0]]
const LOOT := {"iron_bar": [1, 3], "iron_nugget": [3, 6], "barrow_key": [1, 1]}
const STONE := Color(0.5, 0.5, 0.47)
const MOSS := Color(0.32, 0.38, 0.22)
const TURF := Color(0.3, 0.4, 0.18)
const EARTH := Color(0.36, 0.3, 0.2)
var world: Node3D
var terrain: Node3D
var found := false
var yaw := 0.0
var alarmed := false ## guards stirred (the coffin was opened, one was hit)
var loot_day := -1 ## the day it was emptied (-1: never)
var fallen: Array = [] ## post indices whose guard is dead
var guards: Array = []
var running := false ## this machine runs the guards (server / single-player)
var _rng := RandomNumberGenerator.new()
var _mouth_drop := 0.0 ## how far the ground at the mouth is below the floor
# --- placing it -----------------------------------------------------------------------
func place(w: Node3D) -> bool:
world = w
terrain = w.terrain
var site := _find_site()
if site == Vector3.INF:
push_warning("[barrow] no site for the barrow on this seed")
return false
found = true
position = site
yaw = atan2(-site.x, -site.z) # mouth toward the middle of the valley
rotation.y = yaw
var mouth: Vector3 = position + Basis(Vector3.UP, yaw) * Vector3(0, 0, PASSAGE_END)
_mouth_drop = maxf(position.y - terrain.sample_height(mouth.x, mouth.z), 0.0)
_clear_area(w.props)
w.props.clear_bushes(position, CLEAR_R)
_build(w.props._rock_mat)
w.props.static_cuts.append([Transform3D(Basis(Vector3.UP, yaw), position), MOUND_R + 0.5, MOUND_R + 1.5])
return true
## Open, fairly flat ground away from the spawn, the den and the sea. Where
## nothing qualifies (a lumpy island), later passes accept rougher ground
## nearer the spawn and the den; the first pass is the original search, so
## islands that had a site keep it.
func _find_site() -> Vector3:
for pass_ in [[1.2, 55.0, 70.0], [2.0, 45.0, 50.0], [3.0, 40.0, 40.0]]:
var c: Vector3 = _search(float(pass_[0]), float(pass_[1]), float(pass_[2]))
if c != Vector3.INF:
return c
return Vector3.INF
func _search(max_spread: float, from_spawn: float, from_den: float) -> Vector3:
_rng.seed = terrain.noise_seed + 555
var spawn: Vector3 = world._spawn
var den_at: Vector3 = world.den.position if world.den != null and world.den.found else Vector3(1e9, 0, 1e9)
var best := Vector3.INF
var best_score := INF
for _i in range(600):
var a := _rng.randf() * TAU
var rr := _rng.randf_range(40.0, terrain.play_radius - 20.0)
var c := Vector2(cos(a), sin(a)) * rr
if not terrain.inside_boundary(c.x, c.y, CLEAR_R + 4.0) or terrain.sea_weight(c.x, c.y) > 0.02:
continue
if c.distance_to(Vector2(spawn.x, spawn.z)) < from_spawn or c.distance_to(Vector2(den_at.x, den_at.z)) < from_den:
continue
var hs: Array[float] = []
for k in range(8):
var b := TAU * k / 8.0
for rad in [MOUND_R * 0.5, PASSAGE_END + 0.5]:
hs.append(terrain.sample_height(c.x + cos(b) * rad, c.y + sin(b) * rad))
var h0: float = terrain.sample_height(c.x, c.y)
hs.append(h0)
if hs.min() < terrain.water_level + 1.0:
continue
var spread: float = hs.max() - hs.min()
if spread > max_spread:
continue
var trees := 0
for n in world.props.resource_nodes.values():
if Vector2(n.global_position.x - c.x, n.global_position.z - c.y).length() < CLEAR_R:
trees += 1
var score: float = spread * 4.0 + trees
if score < best_score:
best_score = score
# Floor level: the highest ground inside the chamber, so the
# terrain doesn't come up through the floor.
var top := h0
for d in [Vector2(-2, -2), Vector2(2, -2), Vector2(-2, 2), Vector2(2, 2)]:
top = maxf(top, terrain.sample_height(c.x + d.x, c.y + d.y))
best = Vector3(c.x, top + 0.03, c.y)
return best
func _clear_area(props: Node3D) -> void:
for dict in [props.resource_nodes, props.pickups, props.forage]:
for uid in dict.keys():
var n: Node3D = dict[uid]
if not is_instance_valid(n):
continue
if Vector2(n.global_position.x - position.x, n.global_position.z - position.z).length() < CLEAR_R:
if n.has_method("_set_instance"):
n._set_instance(Transform3D(Basis(), n.global_position + Vector3(0, -1000, 0)))
dict.erase(uid)
n.queue_free()
# --- building it -----------------------------------------------------------------------
var _body: StaticBody3D
var _st: SurfaceTool
var _stone_rng := RandomNumberGenerator.new()
## A dressed stone block: drawn and solid.
func _block(c: Vector3, s: Vector3, basis: Basis = Basis()) -> void:
var col := P._mul(STONE, _stone_rng.randf_range(0.8, 1.05)).lerp(MOSS, _stone_rng.randf() * 0.35)
IM._box(_st, c, s, col, basis)
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = s
cs.shape = box
cs.transform = Transform3D(basis, c)
_body.add_child(cs)
func _build(rock_mat: Material) -> void:
_stone_rng.seed = terrain.noise_seed + 556
_body = StaticBody3D.new()
_body.name = "Stones"
_body.collision_layer = 1 | 4 # solid, and a roof for shelter checks
add_child(_body)
_st = P._begin()
# Chamber: 4 x 4 m inside, 2.4 m high, a slab roof.
_block(Vector3(0, -0.15, 0), Vector3(4.8, 0.36, 4.8)) # floor (top at 0.03)
_block(Vector3(0, 1.2, -2.2), Vector3(4.8, 2.4, 0.4)) # back
for s in [-1.0, 1.0]:
_block(Vector3(s * 2.2, 1.2, 0), Vector3(0.4, 2.4, 4.0)) # sides
_block(Vector3(s * 1.4, 1.2, 2.2), Vector3(1.6, 2.4, 0.4)) # front, either side of the passage
_block(Vector3(0, 2.6, 0), Vector3(4.8, 0.4, 4.8)) # roof
_block(Vector3(0, 2.15, 2.2), Vector3(1.2, 0.5, 0.4)) # over the passage's inner end
# Passage: 1.2 m wide, 1.95 m high, from the chamber to the mouth. Its
# floor slopes down to the ground at the mouth.
var plen := PASSAGE_END - 2.4
var mid := 2.4 + plen * 0.5
var tilt := atan2(_mouth_drop, plen)
_block(Vector3(0, -0.15 - _mouth_drop * 0.5, mid), Vector3(1.6, 0.36, sqrt(plen * plen + _mouth_drop * _mouth_drop)), Basis(Vector3.RIGHT, tilt))
for s in [-1.0, 1.0]:
_block(Vector3(s * 0.8, 1.0 - _mouth_drop * 0.5, mid), Vector3(0.4, 2.0 + _mouth_drop, plen))
_block(Vector3(0, 2.1, mid), Vector3(2.0, 0.3, plen))
# The mouth: two tall jambs and a lintel.
for s in [-1.0, 1.0]:
_block(Vector3(s * 0.95, 1.1 - _mouth_drop * 0.5, PASSAGE_END + 0.1), Vector3(0.5, 2.6 + _mouth_drop, 0.5))
_block(Vector3(0, 2.45, PASSAGE_END + 0.1), Vector3(2.6, 0.45, 0.6))
# The coffin at the back: a stone chest with a lid.
_coffin = Coffin.new()
_coffin.barrow = self
_coffin.collision_layer = 1
_coffin.position = Vector3(0, 0, -1.45) # (its origin is what reach is measured to)
add_child(_coffin)
var ccs := CollisionShape3D.new()
var cbox := BoxShape3D.new()
cbox.size = Vector3(1.9, 0.85, 0.8)
ccs.shape = cbox
ccs.position = Vector3(0, 0.45, 0)
_coffin.add_child(ccs)
IM._box(_st, Vector3(0, 0.4, -1.45), Vector3(1.8, 0.75, 0.75), P._mul(STONE, 0.85))
IM._box(_st, Vector3(0, 0.82, -1.45), Vector3(1.95, 0.12, 0.85), STONE)
IM._box(_st, Vector3(0, 0.9, -1.45), Vector3(0.9, 0.05, 0.3), MOSS)
var mi := MeshInstance3D.new()
mi.mesh = P._commit(_st)
mi.material_override = ItemDB.item_material()
add_child(mi)
_build_mound()
_build_ring(rock_mat)
## The grass mound over the chamber: a dome that meets the ground at its
## edge, open where the passage goes in. Its surface is solid (you can climb
## onto it); inside it you see the stone.
func _build_mound() -> void:
var st := P._begin()
var faces := PackedVector3Array()
var rings := 9
var segs := 36
var nz := FastNoiseLite.new()
nz.seed = terrain.noise_seed + 557
var b := Basis(Vector3.UP, yaw)
var pt := func(i: int, j: int) -> Vector3:
var rr: float = MOUND_R * float(i) / rings
var a: float = TAU * float(j) / segs
var x: float = sin(a) * rr
var z: float = cos(a) * rr
var w: Vector3 = position + b * Vector3(x, 0, z)
var ground: float = terrain.sample_height(w.x, w.z) - position.y
var k: float = rr / MOUND_R
return Vector3(x, lerpf(MOUND_H, ground - 0.3, k * k) + nz.get_noise_2d(x * 3.0, z * 3.0) * 0.12 * (1.0 - k), z)
for i in range(rings):
for j in range(segs):
var q := [pt.call(i, j), pt.call(i + 1, j), pt.call(i + 1, j + 1), pt.call(i, j + 1)]
for tri in [[q[0], q[1], q[2]], [q[0], q[2], q[3]]]:
var c: Vector3 = (tri[0] + tri[1] + tri[2]) / 3.0
if absf(c.x) < 1.05 and c.z > 2.0:
continue # the passage
if (tri[0] as Vector3).distance_to(tri[1]) < 0.001 or (tri[1] as Vector3).distance_to(tri[2]) < 0.001:
continue # (degenerate, at the top)
var col := TURF.lerp(EARTH, clampf(0.4 - c.y * 0.12, 0.0, 0.5))
P._tri(st, tri[0], tri[1], tri[2], c - Vector3(0, -3, 0), P._mul(col, 0.9 + 0.2 * nz.get_noise_2d(c.x * 7.0, c.z * 7.0)))
var nrm: Vector3 = ((tri[1] as Vector3) - tri[0]).cross((tri[2] as Vector3) - tri[0])
if nrm.dot(c - Vector3(0, -3, 0)) < 0.0:
faces.append_array(PackedVector3Array([tri[0], tri[2], tri[1]]))
else:
faces.append_array(PackedVector3Array([tri[0], tri[1], tri[2]]))
var mi := MeshInstance3D.new()
mi.name = "Mound"
mi.mesh = P._commit(st)
mi.material_override = ItemDB.item_material()
add_child(mi)
var shape := ConcavePolygonShape3D.new()
shape.set_faces(faces)
shape.backface_collision = true
var cs := CollisionShape3D.new()
cs.shape = shape
var body := StaticBody3D.new()
body.name = "MoundBody"
body.collision_layer = 1 | 4
body.add_child(cs)
add_child(body)
## Standing stones round the mound, with a gap in front of the mouth.
func _build_ring(rock_mat: Material) -> void:
var body := StaticBody3D.new()
body.name = "Ring"
body.collision_layer = 1
add_child(body)
var n := 11
for i in range(n):
var a := TAU * (i + 0.5) / n
if absf(wrapf(a, -PI, PI)) < 0.3:
continue # (the mouth faces +Z: a = 0)
var lp := Vector3(sin(a) * RING_R, 0, cos(a) * RING_R)
var w: Vector3 = position + Basis(Vector3.UP, yaw) * lp
var radii := Vector3(0.55, 1.5 + 0.35 * sin(i * 2.3), 0.4)
var y: float = terrain.sample_height(w.x, w.z) - position.y + radii.y * 0.8 - 0.3
var mesh := P.rock(terrain.noise_seed + 5200 + i, false)
var turn := Basis(Vector3.UP, a + 0.2 * sin(i * 1.7)) * Basis(Vector3.RIGHT, 0.05 * sin(i * 3.1))
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.material_override = rock_mat
mi.transform = Transform3D(turn.scaled(radii), Vector3(lp.x, y, lp.z))
add_child(mi)
var hull := mesh.create_convex_shape(true, true) as ConvexPolygonShape3D
var pts := PackedVector3Array()
for p: Vector3 in hull.points:
pts.append(turn * (p * radii))
hull.points = pts
var cs := CollisionShape3D.new()
cs.shape = hull
cs.position = Vector3(lp.x, y, lp.z)
body.add_child(cs)
# --- where things are --------------------------------------------------------------------
func center() -> Vector3:
return position
func to_world(lp: Vector3) -> Vector3:
return position + Basis(Vector3.UP, yaw) * lp
func to_local_pos(p: Vector3) -> Vector3:
return Basis(Vector3.UP, yaw).inverse() * (p - position)
## Inside the chamber or passage.
func inside(p: Vector3) -> bool:
var l := to_local_pos(p)
return (absf(l.x) < 2.0 and absf(l.z) < 2.0 or absf(l.x) < 0.6 and l.z > 1.9 and l.z < PASSAGE_END + 0.3) and l.y > -1.0 and l.y < 2.6
## The stone floor's height at `p` (world), or -INF outside.
func floor_y_at(p: Vector3) -> float:
var l := to_local_pos(p)
if absf(l.x) < 2.0 and absf(l.z) < 2.0:
return position.y + 0.03
if absf(l.x) < 0.6 and l.z >= 2.0 and l.z < PASSAGE_END:
return position.y + 0.03 - _mouth_drop * (l.z - 2.4) / (PASSAGE_END - 2.4) * float(l.z > 2.4)
return -INF
func mouth() -> Vector3:
var m := to_world(Vector3(0, 0, PASSAGE_END + 1.2))
m.y = terrain.sample_height(m.x, m.z)
return m
# --- guards ---------------------------------------------------------------------------
func is_night() -> bool:
var h: float = world.day_night.hours()
return h >= 19.5 or h < 5.5
## Whether being where `p` is wakes the guards right now.
func wakes_for(p: Node3D) -> bool:
if alarmed:
return true
if p.survival.dead:
return false
var near := Vector2(p.global_position.x - position.x, p.global_position.z - position.z).length() < RING_R
if near and (is_night() or world.day_night.day > WAKE_DAY):
alarmed = true
return alarmed
func alarm() -> void:
alarmed = true
func guards_alive() -> Array:
return guards.filter(func(g): return is_instance_valid(g) and not g.is_dead())
## Starts the guards (on the machine that runs creatures).
func start() -> void:
if not found:
return
running = true
_spawn_guards()
func _spawn_guards() -> void:
for g in guards:
if is_instance_valid(g) and not g.is_dead():
g.queue_free()
guards.clear()
for i in range(POSTS.size()):
if fallen.has(i):
continue
var g: AnimatableBody3D = GuardScript.new()
var post: Vector3 = to_world(POSTS[i][0])
g.setup("guard", world, post, terrain.noise_seed + 600 + i)
g.barrow = self
g.post = Vector3(post.x, position.y + 0.03, post.z)
# Facing the mouth (+Z local); a creature's forward is -Z, so turn by PI.
g.post_yaw = yaw + PI + float(POSTS[i][1])
g.state = "rest"
g.yaw = g.post_yaw
g.rotation.y = g.yaw
g.set_meta("post", i)
g.died.connect(_on_guard_died)
g.top_level = true # (world space: the barrow is turned, and a creature's facing is its own rotation)
add_child(g)
g.global_position = g.post
guards.append(g)
func _on_guard_died(g: Node) -> void:
var i := int(g.get_meta("post", -1))
if i >= 0 and not fallen.has(i):
fallen.append(i)
func _physics_process(_delta: float) -> void:
if running:
step()
func step() -> void:
# Everyone's gone: the guards settle (they walk back to their posts).
if alarmed:
var anyone := false
for p in world.players():
if not p.survival.dead and p.global_position.distance_to(position) < LEASH:
anyone = true
if not anyone:
alarmed = false
# The coffin fills again (and the fallen are back) a few days after it
# was emptied, at dawn, with nobody about.
if loot_day >= 0 and world.day_night.day >= loot_day + RESTOCK_DAYS:
var h: float = world.day_night.hours()
if h >= 6.0 and h < 10.0:
for p in world.players():
if p.global_position.distance_to(position) < 30.0:
return
loot_day = -1
fallen.clear()
alarmed = false
_spawn_guards()
# --- the coffin ---------------------------------------------------------------------------
var _coffin: StaticBody3D
func stocked() -> bool:
return loot_day < 0
func days_to_restock() -> int:
return maxi(loot_day + RESTOCK_DAYS - int(world.day_night.day), 1)
## Opens the coffin for `player` (Actions.loot_barrow). Wakes the guards.
## Returns what was taken ({} if it was empty).
func loot(player: Node3D) -> Dictionary:
alarm()
if not stocked():
Actions.tell(player, "The coffin is empty. Come back in %d days." % days_to_restock())
return {}
var r := RandomNumberGenerator.new()
r.seed = terrain.noise_seed * 31 + int(world.day_night.day)
var got := {}
for id in LOOT:
got[id] = r.randi_range(int(LOOT[id][0]), int(LOOT[id][1]))
loot_day = int(world.day_night.day)
Actions.tell(player, "You lift the coffin lid... and something stirs behind you!")
return got
func coffin_prompt() -> String:
return "Stone coffin [E] Open"
func save_data() -> Dictionary:
return {"loot_day": loot_day, "fallen": fallen.duplicate()} if found else {}
func load_data(d: Dictionary) -> void:
if not found or d.is_empty():
return
loot_day = int(d.get("loot_day", -1))
fallen = []
for i in d.get("fallen", []):
fallen.append(int(i))
if running:
_spawn_guards()
class Coffin extends StaticBody3D:
var barrow: Node
func interact_prompt(_p: Node) -> String:
return barrow.coffin_prompt()
func interact(p: Node) -> void:
Actions.loot_barrow(p, barrow)

154
scripts/world/day_night.gd Normal file
View File

@@ -0,0 +1,154 @@
extends Node
## Day/night cycle. Moves the sun along its arc and drives sky colours, sun and
## moon light, ambient light and fog from the time of day.
##
## Time is a fraction of a day (0 = midnight, 0.25 = 06:00 sunrise,
## 0.5 = noon, 0.75 = 18:00 sunset). One day lasts `day_length` real seconds
## (20 min). Saved with the world (see world.gd collect_save).
signal new_day(day: int)
@export var day_length := 1200.0
@export var sun_tilt_deg := 35.0 ## arc leans toward +Z, so noon sun sits at 55°
const DAY_BIAS := 0.2
var day := 1
var time := 8.0 / 24.0
var frozen := false ## title-screen backdrop: hold a fixed time
var sun: DirectionalLight3D
var moon: DirectionalLight3D
var env: Environment
var sky_mat: ShaderMaterial
var daylight := 1.0 ## 0 at night, 1 in full day (smooth)
var sun_height := 1.0 ## sin of sun elevation (-1..1)
var _sky_timer := 0.0
# Palette: [zenith, horizon, fog]
const DAY_COLS := [Color(0.24, 0.44, 0.72), Color(0.72, 0.79, 0.85), Color(0.70, 0.77, 0.85)]
const DUSK_COLS := [Color(0.22, 0.27, 0.48), Color(0.98, 0.62, 0.38), Color(0.82, 0.62, 0.50)]
const NIGHT_COLS := [Color(0.02, 0.03, 0.07), Color(0.07, 0.09, 0.16), Color(0.07, 0.09, 0.14)]
const OVERCAST_COLS := [Color(0.40, 0.44, 0.49), Color(0.58, 0.61, 0.64), Color(0.56, 0.59, 0.62)]
const BASE_FOG := 0.0035 ## default_env.tres fog_density
var weather: Node ## weather.gd, set by world.gd
var _flash_shown := false
func setup(world_sun: DirectionalLight3D, environment: Environment) -> void:
sun = world_sun
env = environment
sky_mat = env.sky.sky_material as ShaderMaterial
moon = DirectionalLight3D.new()
moon.name = "Moon"
moon.light_color = Color(0.55, 0.65, 1.0)
moon.shadow_enabled = false
moon.directional_shadow_max_distance = 120.0
moon.sky_mode = DirectionalLight3D.SKY_MODE_LIGHT_ONLY # keep the sky shader's LIGHT0 = sun
sun.add_sibling(moon)
apply(true)
func hours() -> float:
return time * 24.0
func clock_text() -> String:
var h := hours()
return "Day %d %02d:%02d" % [day, int(h), int(fmod(h, 1.0) * 60.0)]
func is_night() -> bool:
return daylight < 0.3
## Every 7th night is a wolf moon: the moon turns red and an alpha hunts with
## the pack (wolf_pack.gd). Worked out from the day alone, so every game on a
## server agrees without being told.
static func wolf_moon_day(d: int) -> bool:
return d >= 7 and d % 7 == 0
## Tonight's moon is a wolf moon (the small hours count as the night before).
func wolf_moon_now() -> bool:
var h := hours()
return (h >= 19.5 or h < 5.5) and wolf_moon_day(day - (1 if h < 5.5 else 0))
func advance(seconds: float) -> void:
time += seconds / day_length
while time >= 1.0:
time -= 1.0
day += 1
new_day.emit(day)
apply(false)
func _process(delta: float) -> void:
if not frozen:
advance(delta)
## Updates lights every call; the sky material (which triggers a radiance
## rebuild) only a few times a second unless `force`.
func apply(force: bool) -> void:
if sun == null:
return
var a := (time - 0.25) * TAU
var t := deg_to_rad(sun_tilt_deg)
# +DAY_BIAS lifts the whole arc: sunrise ~05:00, sunset ~19:00, so days
# are longer than nights and dusk lingers.
var to_sun := Vector3(cos(a), sin(a) * cos(t) + DAY_BIAS, sin(a) * sin(t)).normalized()
sun_height = to_sun.y
daylight = smoothstep(-0.2, 0.25, to_sun.y)
var low := 1.0 - smoothstep(0.05, 0.45, absf(to_sun.y)) # 1 near the horizon
# Weather (weather.gd) dims the sun, thickens fog and greys the sky.
var w_light := 1.0
var w_fog := 1.0
var w_clouds := 0.3
var w_flash := 0.0
if weather != null:
w_light = weather.light
w_fog = weather.fog
w_clouds = weather.clouds
w_flash = weather.flash
var overcast := smoothstep(0.5, 1.0, w_clouds)
sun.global_basis = Basis.looking_at(-to_sun if to_sun.y > -0.2 else Vector3.DOWN, Vector3.FORWARD)
sun.light_energy = 1.8 * smoothstep(-0.03, 0.15, to_sun.y) * w_light
sun.light_color = Color(1.0, 0.95, 0.83).lerp(Color(1.0, 0.58, 0.32), low)
sun.visible = sun.light_energy > 0.01
sun.shadow_enabled = sun.visible
var moon_dir := Vector3(-to_sun.x, absf(to_sun.y) * 0.8 + 0.25, -to_sun.z * 0.5).normalized()
moon.global_basis = Basis.looking_at(-moon_dir, Vector3.FORWARD)
var red := wolf_moon_now()
moon.light_color = Color(1.0, 0.38, 0.3) if red else Color(0.55, 0.65, 1.0)
moon.light_energy = (0.5 if red else 0.38) * (1.0 - daylight) * (1.0 - 0.6 * overcast)
moon.visible = moon.light_energy > 0.01
moon.shadow_enabled = moon.visible and not sun.visible
env.ambient_light_energy = lerpf(0.3, 1.0, daylight) * lerpf(1.0, 0.85, overcast) + w_flash * 3.0
env.fog_light_energy = lerpf(0.25, 1.0, daylight) * lerpf(1.0, 0.7, overcast) + w_flash * 2.0
env.volumetric_fog_density = lerpf(0.0025, 0.0045, daylight) * lerpf(1.0, 2.0, overcast)
env.fog_density = BASE_FOG * w_fog
_sky_timer -= get_process_delta_time()
if not force and _sky_timer > 0.0 and w_flash < 0.05 and not _flash_shown:
return
_flash_shown = w_flash >= 0.05
_sky_timer = 0.25
var cols: Array = []
for i in range(3):
var dayc: Color = DAY_COLS[i].lerp(DUSK_COLS[i], low * daylight * (1.0 - overcast * 0.7))
dayc = dayc.lerp(OVERCAST_COLS[i], overcast * 0.85)
var night: Color = NIGHT_COLS[i]
if red:
night = night.lerp(Color(0.2, 0.04, 0.04), 0.5) # a blood-red night sky
cols.append(night.lerp(dayc, daylight))
env.fog_light_color = cols[2]
if sky_mat != null:
sky_mat.set_shader_parameter("zenith_col", cols[0])
sky_mat.set_shader_parameter("horizon_col", cols[1])
sky_mat.set_shader_parameter("energy", lerpf(0.35, 1.0, daylight) * lerpf(1.0, 0.72, overcast) + w_flash * 2.5)
sky_mat.set_shader_parameter("star_strength", (1.0 - smoothstep(0.0, 0.35, daylight)) * (1.0 - overcast))
sky_mat.set_shader_parameter("cloud_cover", lerpf(0.35, 1.05, w_clouds))
var lit := Color(0.16, 0.18, 0.24).lerp(Color(1, 1, 1), daylight)
sky_mat.set_shader_parameter("cloud_lit", lit.lerp(lit * Color(0.55, 0.57, 0.6), overcast))
sky_mat.set_shader_parameter("sun_tint", Color(1.0, 0.86, 0.62).lerp(Color(1.0, 0.55, 0.3), low))
## 0..100 "how warm the air is" before fires, shelter and clothes.
func air_warmth() -> float:
return lerpf(22.0, 68.0, daylight)

View File

@@ -0,0 +1 @@
uid://2vp5n05qr6yn

59
scripts/world/forage.gd Normal file
View File

@@ -0,0 +1,59 @@
extends Area3D
## A berry bush you can forage with E: berries plus some fiber. It's then bare
## until it regrows (`regrow_seconds` of play time). Walk-through, on
## collision layer 2 like pickups. Regrow timers are saved (props.forage).
const ItemMeshes = preload("res://scripts/items/item_meshes.gd")
var uid := ""
var regrow_seconds := 600.0
var regrow_left := 0.0 ## 0 = ripe
var rng := RandomNumberGenerator.new()
var _mesh: MeshInstance3D
func setup(material: Material) -> void:
collision_layer = 2
collision_mask = 0
monitoring = false
_mesh = MeshInstance3D.new()
_mesh.material_override = material
add_child(_mesh)
var cs := CollisionShape3D.new()
var sph := SphereShape3D.new()
sph.radius = 0.7
cs.shape = sph
cs.position = Vector3(0, 0.5, 0)
add_child(cs)
_refresh()
func ripe() -> bool:
return regrow_left <= 0.0
func _refresh() -> void:
_mesh.mesh = ItemMeshes.build("berry_bush" if ripe() else "berry_bush_bare")
func _process(delta: float) -> void:
if regrow_left > 0.0:
regrow_left = maxf(regrow_left - delta, 0.0)
if regrow_left <= 0.0:
_refresh()
func interact_prompt(_player: Node) -> String:
if ripe():
return "Berry bush [E] Pick berries"
return "Berry bush (regrowing, %d min)" % ceili(regrow_left / 60.0)
func interact(player: Node) -> void:
Actions.forage(player, self)
## Called by Actions.forage. Returns what was picked.
func harvest() -> Dictionary:
if not ripe():
return {}
regrow_left = regrow_seconds
_refresh()
return {"berries": rng.randi_range(2, 4), "fiber": rng.randi_range(1, 2)}
func restore(left: float) -> void:
regrow_left = left
_refresh()

View File

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

288
scripts/world/hazards.gd Normal file
View File

@@ -0,0 +1,288 @@
extends Node3D
## Weather hazards (P4, docs/PROGRESSION_DESIGN.md §5) that need more than
## the weather model:
##
## - Hard winters (weather.gd hard_winter()): a warning the day before and
## on the day; the lake's edge freezes over (walkable ice you can't fish
## through), on every machine since it comes from the shared clock.
## - Lightning: in a storm, every minute or so a bolt hits a tree near
## someone, preferring tall trees near the rim. It burns for a while (a
## fire you can warm yourself by), then falls, leaving charcoal. Decided:
## trees only for now; buildings are never struck.
##
## Strikes and warnings happen where the game decides (single-player, a
## server); players' games are told (world_net.gd _s2c_strike).
const Weather = preload("res://scripts/world/weather.gd")
const STRIKE_MIN := 50.0
const STRIKE_MAX := 110.0
const STRIKE_RANGE := 90.0 ## trees this near someone can be struck
const BURN_SECONDS := 40.0
const CHARCOAL := 2
const ICE_IN := 0.62 ## the ice: from this x lake radius out to the shore
const ICE_OUT := 1.7
var world: Node3D
var deciding := true ## false on a player's game online
var strikes := 0
var _strike_left := 30.0
var _rng := RandomNumberGenerator.new()
var _burning: Dictionary = {} ## resource node -> [fire node, seconds left]
var _ice: MeshInstance3D
var _ice_body: StaticBody3D
var _warned := {} ## day -> true
func setup(w: Node3D, decides: bool) -> void:
world = w
deciding = decides
_rng.seed = w.terrain.noise_seed + 1201
_build_ice()
w.day_night.new_day.connect(_on_new_day)
func _physics_process(delta: float) -> void:
step(delta)
func step(dt: float) -> void:
_set_ice(world.weather.in_hard_winter())
_snow_left -= dt
if _snow_left <= 0.0:
_snow_left = 2.0
_update_roof_snow()
for n in _burning.keys():
var e: Array = _burning[n]
e[1] = float(e[1]) - dt
if float(e[1]) <= 0.0 or not is_instance_valid(n):
_burn_out(n)
if not deciding:
return
var w: Node = world.weather
if w.state == "storm" and w.precip > 0.7:
_strike_left -= dt
if _strike_left <= 0.0:
_strike_left = _rng.randf_range(STRIKE_MIN, STRIKE_MAX)
var t := pick_tree()
if t != null:
strike(t)
# --- lightning -------------------------------------------------------------------------
## A standing tree near someone, the taller and nearer the rim the likelier.
func pick_tree() -> Node3D:
var best: Node3D = null
var best_s := -INF
for n in world.props.resource_nodes.values():
if n.kind != "tree" or n.depleted or _burning.has(n):
continue
var near := false
for p in world.players():
if (n as Node3D).global_position.distance_to(p.global_position) < STRIKE_RANGE:
near = true
break
if not near:
continue
var pos: Vector3 = n.global_position
var s: float = world.props._near_rim(pos.x, pos.z) * 2.0 + n.base_xf.basis.get_scale().y + _rng.randf() * 1.5
if s > best_s:
best_s = s
best = n
return best
## Lightning hits tree `n`: a flash, a crack, and it's on fire.
func strike(n: Node3D) -> void:
strikes += 1
show_strike(n)
if world.server_mode:
WorldNet.struck(n)
for p in world.players():
if p.global_position.distance_to(n.global_position) < 60.0:
Actions.tell(p, "Lightning struck a tree! It's burning.")
## What everyone sees: the flash, and the tree alight.
func show_strike(n: Node3D) -> void:
world.weather.flash = 1.0
if _burning.has(n):
return
var fire := TreeFire.new()
fire.name = "TreeFire"
add_child(fire)
fire.global_position = n.global_position
fire.build(DisplayServer.get_name() != "headless")
_burning[n] = [fire, BURN_SECONDS]
func burning(n: Node3D) -> bool:
return _burning.has(n)
## Burnt through: the tree falls (where the game decides; players' games
## see it fall via world_net), leaving charcoal at its foot.
func _burn_out(n: Node3D) -> void:
var e: Array = _burning[n]
_burning.erase(n)
if is_instance_valid(e[0]):
(e[0] as Node).queue_free()
if not deciding or not is_instance_valid(n) or n.depleted:
return
var top: Vector3 = n.global_position + Vector3(0, 2.0, 0)
var from: Vector3 = n.global_position + Vector3(1, 0, 0)
n._show_hit(0.0, top, from)
if world.server_mode:
WorldNet.node_hit(n, top, from)
world.drop_item("charcoal", CHARCOAL, Transform3D(Basis(), n.global_position + Vector3(0.8, 0.3, 0.4)))
## A burning tree's fire: flames up the trunk, a light, and warmth.
class TreeFire extends Node3D:
const RADIUS := 6.0
func build(visual: bool) -> void:
add_to_group("heat")
if not visual:
return
var light := OmniLight3D.new()
light.light_color = Color(1.0, 0.55, 0.25)
light.light_energy = 3.0
light.omni_range = 14.0
light.position = Vector3(0, 3.0, 0)
add_child(light)
# The campfire's soft additive flames, bigger and up the trunk.
var CF: Script = load("res://scripts/building/campfire.gd")
var ramp: Gradient = CF._gradient([[0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.15, Color(1.0, 0.62, 0.2, 0.8)],
[0.6, Color(0.95, 0.3, 0.06, 0.5)], [1.0, Color(0.5, 0.08, 0.02, 0.0)]])
for i in range(3):
var p: CPUParticles3D = CF._particles("Flames%d" % i, 30, 1.0, Vector3(0, 1.2 + 1.6 * i, 0), 0.8, 2.0,
0.6, 1.2, ramp, true, 0.9, 0.7 - 0.15 * i)
add_child(p)
p.emitting = true
func heat_at(pos: Vector3) -> float:
return 1.0 - smoothstep(2.0, RADIUS, pos.distance_to(global_position))
# --- hard winters ---------------------------------------------------------------------
func _on_new_day(day: int) -> void:
if not deciding or _warned.has(day):
return
_warned[day] = true
if Weather.hard_winter(day + 1) and not Weather.hard_winter(day):
Actions.tell_all("The air has a bite to it. A hard winter is coming tomorrow: three bitter days. Stock up on food and firewood.")
elif Weather.hard_winter(day) and not Weather.hard_winter(day - 1):
Actions.tell_all("A hard winter has set in. The lake's edge has frozen, and the animals are holed up.")
elif Weather.hard_winter(day - 1) and not Weather.hard_winter(day):
Actions.tell_all("The hard winter is over.")
## A ring of ice round the lake, at the water line: it shows (and you can
## walk on it) wherever the water is, from the shore out to ICE_IN of the
## lake's radius. Off except in a hard winter.
func _build_ice() -> void:
var t: Node3D = world.terrain
var c: Vector2 = t.lake_center
var r0: float = t.lake_radius * ICE_IN
var r1: float = t.lake_radius * ICE_OUT
var y: float = t.water_level + 0.04
var segs := 64
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var faces := PackedVector3Array()
var nz := FastNoiseLite.new()
nz.seed = t.noise_seed + 1202
for i in range(segs):
var a0 := TAU * i / segs
var a1 := TAU * (i + 1) / segs
var k0 := 1.0 + 0.12 * nz.get_noise_1d(i * 7.0)
var k1 := 1.0 + 0.12 * nz.get_noise_1d((i + 1) * 7.0)
var p := [Vector3(c.x + cos(a0) * r0 * k0, y, c.y + sin(a0) * r0 * k0), Vector3(c.x + cos(a0) * r1, y, c.y + sin(a0) * r1),
Vector3(c.x + cos(a1) * r1, y, c.y + sin(a1) * r1), Vector3(c.x + cos(a1) * r0 * k1, y, c.y + sin(a1) * r0 * k1)]
for tri in [[p[0], p[2], p[1]], [p[0], p[3], p[2]]]:
for v in tri:
st.set_normal(Vector3.UP)
st.add_vertex(v)
faces.append(v)
_ice = MeshInstance3D.new()
_ice.name = "LakeIce"
_ice.mesh = st.commit()
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.86, 0.93, 0.98, 0.96)
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.roughness = 0.35
m.metallic_specular = 0.8
_ice.material_override = m
_ice.visible = false
add_child(_ice)
_ice_body = StaticBody3D.new()
_ice_body.name = "IceBody"
var cs := CollisionShape3D.new()
var shape := ConcavePolygonShape3D.new()
shape.set_faces(faces)
shape.backface_collision = true
cs.shape = shape
_ice_body.add_child(cs)
_ice_body.collision_layer = 0
add_child(_ice_body)
func _set_ice(on: bool) -> void:
if _ice.visible == on:
return
_ice.visible = on
_ice_body.collision_layer = 1 if on else 0
# --- snow on roofs ----------------------------------------------------------------------
var _snow_left := 0.0
var _snow_mat: StandardMaterial3D
var _snow_mesh: BoxMesh
## Snow piles up on roofs as it settles (weather.gd snow_cover), and melts
## off with it. Just a look: a white layer on each roof piece, thicker the
## more has settled. (Visual only, so not on a headless server.)
func _update_roof_snow() -> void:
if DisplayServer.get_name() == "headless":
return
var cover: float = world.weather.snow_cover
if _snow_mesh == null:
var BP: Script = load("res://scripts/building/build_piece.gd")
_snow_mesh = BoxMesh.new()
_snow_mesh.size = Vector3(2.04, 0.12, sqrt(5.0) + 0.25)
_snow_mat = StandardMaterial3D.new()
_snow_mat.albedo_color = Color(0.93, 0.95, 0.98)
_snow_mat.roughness = 0.9
_snow_mesh.material = _snow_mat
_snow_angle = BP.ROOF_ANGLE
_snow_t = BP.ROOF_T
for p in world.build.build_pieces():
if p.kind != "roof":
continue
var s: MeshInstance3D = p.get_node_or_null("Snow")
if cover < 0.2:
if s != null:
s.visible = false
continue
if s == null:
s = MeshInstance3D.new()
s.name = "Snow"
s.mesh = _snow_mesh
p.add_child(s)
var k := clampf((cover - 0.2) / 0.8, 0.0, 1.0)
var n := Vector3(0, cos(_snow_angle), sin(_snow_angle))
var along := Vector3(0, sin(_snow_angle), -cos(_snow_angle))
s.visible = true
s.transform = Transform3D(Basis(Vector3.RIGHT, _snow_angle).scaled(Vector3(1.0, maxf(k, 0.15), 1.0)),
along * (sqrt(5.0) * 0.5 - 0.1) + n * (_snow_t + 0.11 + 0.06 * k))
var _snow_angle := 0.4636476
var _snow_t := 0.12
## How many roofs have snow on them right now (tests).
func snowy_roofs() -> int:
var n := 0
for p in world.build.build_pieces():
var s: Node = p.get_node_or_null("Snow")
if s != null and s.visible:
n += 1
return n
func lake_frozen() -> bool:
return _ice.visible
## Frozen over at (x, z): ice, not open water.
func iced(x: float, z: float) -> bool:
if not lake_frozen():
return false
var c: Vector2 = world.terrain.lake_center
return Vector2(x - c.x, z - c.y).length() > world.terrain.lake_radius * ICE_IN * 0.9

View File

@@ -25,11 +25,32 @@ func setup(id: String, mesh: Mesh, material: Material, radius: float) -> void:
add_child(cs)
func interact_prompt(_player: Node) -> String:
return "[E] Pick up %s" % ItemDB.item_name(item_id)
if item_id == "fiber" and not dropped:
return "Wild flax [E] Pick %d %s" % [amount, ItemDB.item_name(item_id).to_lower()]
return "[E] Pick up %s%s" % [ItemDB.item_name(item_id), " x%d" % amount if amount > 1 else ""]
func interact(player: Node) -> void:
Actions.pickup(player, self)
signal taken_now(pickup: Node)
var dropped := false ## dropped by the player (arrows, spears): saved, never regrows
var extra: Dictionary = {} ## kept when it's picked up (a worn spear's "dur")
## Picked up: hidden (not freed) so it can reappear later (props.gd regrowth).
## Dropped items just go.
func collected() -> void:
if taken:
return
taken = true
queue_free()
if dropped:
queue_free()
return
visible = false
collision_layer = 0
taken_now.emit(self)
func regrow() -> void:
taken = false
visible = true
collision_layer = 2

View File

@@ -13,6 +13,8 @@ extends Node3D
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
const ResourceNodeScript = preload("res://scripts/world/resource_node.gd")
const PickupScript = preload("res://scripts/world/pickup.gd")
const ForageScript = preload("res://scripts/world/forage.gd")
const ItemMeshes = preload("res://scripts/items/item_meshes.gd")
const CELL := 48.0
const GRASS_TILE := 12.0
@@ -40,6 +42,19 @@ var _resources: Node3D ## harvestable trees/boulders (each its own body)
## creation order gives the same id to the same tree on every load.
var resource_nodes: Dictionary = {} ## uid -> resource node
var pickups: Dictionary = {} ## uid -> pickup
var forage: Dictionary = {} ## uid -> berry bush (forage.gd)
## Regrowth: uid -> seconds until a felled tree, mined boulder or picked-up
## item comes back. Saved with the world. Bushes keep their own timers.
var regrow: Dictionary = {}
## Off on a client playing online: the server says when things grow back.
var regrow_enabled := true
## Other places nothing may regrow on top of (a server's players).
var blockers: Array = []
var regrow_scale := 1.0 ## test hook: a server started with --debug-regrow <k>
signal regrown(uid: String)
const REGROW_SECONDS := {"tree": 2400.0, "rock": 3600.0, "pickup": 900.0}
const REGROW_RETRY := 120.0 ## blocked (player or a building nearby): try again later
var _regrow_tick := 0.0
var _bark_mat: StandardMaterial3D
var _leaf_mat: ShaderMaterial
@@ -99,12 +114,86 @@ func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void:
func _process(delta: float) -> void:
if _player == null:
return
_regrow_tick += delta
if _regrow_tick >= 1.0:
skip_time(_regrow_tick)
_regrow_tick = 0.0
_grass_timer -= delta
if _grass_timer > 0.0:
return
_grass_timer = 0.2
_update_grass(grass_tiles_per_update)
# --- regrowth ---------------------------------------------------------------
func _regrow_kind(uid: String) -> String:
if pickups.has(uid):
return "pickup"
return resource_nodes[uid].kind
func _on_depleted(node: Node) -> void:
regrow[node.uid] = REGROW_SECONDS[node.kind] * regrow_scale
func _on_taken(pk: Node) -> void:
regrow[pk.uid] = REGROW_SECONDS["pickup"] * regrow_scale
## Advances regrowth timers by `secs` (every second while playing, or in one
## go when you sleep).
func skip_time(secs: float) -> void:
for uid in regrow.keys() if regrow_enabled else []:
regrow[uid] -= secs
if regrow[uid] <= 0.0:
_try_regrow(uid)
for uid in forage:
var f = forage[uid]
if secs > 1.5 and f.regrow_left > 0.0:
f._process(secs)
func _try_regrow(uid: String) -> void:
var node: Node3D = pickups.get(uid, resource_nodes.get(uid))
if node == null:
regrow.erase(uid)
return
var r := 0.5 if pickups.has(uid) else 1.4
var blocked := _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0
for b in blockers:
blocked = blocked or (b as Vector3).distance_to(node.global_position) < r + 3.0
if blocked or _built_over(node.global_position, r):
regrow[uid] = REGROW_RETRY * regrow_scale
return
regrow.erase(uid)
node.regrow()
regrown.emit(uid)
## Something built (or being built) where a tree or rock would come back.
func _built_over(pos: Vector3, r: float) -> bool:
var sph := SphereShape3D.new()
sph.radius = r
var q := PhysicsShapeQueryParameters3D.new()
q.shape = sph
q.transform = Transform3D(Basis(), pos + Vector3(0, r, 0))
q.collision_mask = 4 # building pieces (build_piece.gd BUILD_LAYER)
if not get_world_3d().direct_space_state.intersect_shape(q, 1).is_empty():
return true
# Chests, fires and beds aren't on the building layer.
for p in get_tree().get_nodes_in_group("piece"):
if (p as Node3D).global_position.distance_to(pos) < r + 0.8:
return true
return false
## From a save. Anything gone without a saved timer (older saves) starts a
## fresh one.
func restore_regrow(saved: Dictionary) -> void:
for uid in saved:
if pickups.has(uid) or resource_nodes.has(uid):
regrow[uid] = float(saved[uid])
for uid in resource_nodes:
if resource_nodes[uid].depleted and not regrow.has(uid):
regrow[uid] = REGROW_SECONDS[resource_nodes[uid].kind] * regrow_scale
for uid in pickups:
if pickups[uid].taken and not regrow.has(uid):
regrow[uid] = REGROW_SECONDS["pickup"] * regrow_scale
# --- materials --------------------------------------------------------------
func _make_materials() -> void:
@@ -203,6 +292,7 @@ func _resource_node(kind: String, xf: Transform3D) -> StaticBody3D:
node.drops_final = {"stone": [3, 5], "flint": [1, 2]}
node.chip_color = Color(0.55, 0.54, 0.52)
node.hp = node.max_hp
node.depleted_now.connect(_on_depleted)
_resources.add_child(node)
node.global_position = xf.origin
return node
@@ -388,6 +478,19 @@ func _scatter_rocks() -> void:
_bind_nodes(pending, _flush(groups, meshes, true, "Rocks"))
## 0..1: how close (x, z) is to the foot of the mountain rim, where scree
## and iron collect. 1 within 6 m of the edge of the walkable area, 0 from
## 30 m in.
func _near_rim(x: float, z: float) -> float:
if not terrain.inside_boundary(x, z, 6.0):
return 1.0
if terrain.inside_boundary(x, z, 30.0):
return 0.0
for m in [24.0, 18.0, 12.0]: # (inside at margin m = at least m metres from the edge)
if terrain.inside_boundary(x, z, m):
return 1.0 - (m - 6.0) / 24.0
return 1.0
## Places one rock. Passing `pending` makes it a mineable boulder (if it's
## inside the walkable area); otherwise it just gets plain collision.
func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h: float, n: Vector3,
@@ -403,6 +506,12 @@ func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h:
shape.points = pts
if pending != null and terrain.inside_boundary(p.x, p.y, 0.0):
var node := _resource_node("rock", Transform3D(b, pos))
# Rust-streaked boulders with iron ore in them: mostly near the
# rim, a few anywhere. (From the position, so it's the same every
# time, without disturbing the scatter's random numbers.)
var roll := float(absi(hash(Vector2i(int(p.x * 10.0), int(p.y * 10.0)))) % 1000) / 1000.0
if roll < 0.18 + _near_rim(p.x, p.y) * 0.6:
node.make_iron(hulls[vi])
var cs := CollisionShape3D.new()
cs.shape = shape
cs.basis = b.orthonormalized()
@@ -436,6 +545,25 @@ func _scatter_bushes() -> void:
_group(groups, p.x, p.y, vi, Transform3D(_ground_basis(n, 0.5, yaw, s), Vector3(p.x, h - 0.1, p.y)))
_flush(groups, meshes, true, "Bushes")
## Hides the decorative bushes within `r` of `center` (the barrow's and the
## standing stones' clearings: they're placed after the undergrowth).
## Deterministic, like the rest of the scenery.
func clear_bushes(center: Vector3, r: float) -> int:
var root := get_node_or_null("Bushes")
if root == null:
return 0
var n := 0
for mmi in root.get_children():
if not mmi is MultiMeshInstance3D:
continue
var mm: MultiMesh = mmi.multimesh
for i in range(mm.instance_count):
var xf: Transform3D = mmi.global_transform * mm.get_instance_transform(i)
if Vector2(xf.origin.x - center.x, xf.origin.z - center.z).length() < r:
mm.set_instance_transform(i, Transform3D(xf.basis, xf.origin + Vector3(0, -1000, 0)))
n += 1
return n
func _place_logs() -> void:
var root := Node3D.new()
root.name = "Logs"
@@ -500,10 +628,14 @@ func _scatter_pickups() -> void:
add_child(root)
var rng := RandomNumberGenerator.new()
rng.seed = world_seed + 6
var want := {"stick": 260, "stone": 170, "flint": 80}
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25}
# Order matters: new kinds go at the END so existing saves keep their ids.
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150,
"iron_nugget": 70, "clay": 60, "salt": 40}
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45,
"iron_nugget": 0.25, "clay": 0.3, "salt": 0.25}
for id: String in want:
var mesh := ItemDB.mesh_for(id)
# Fibre grows as a plant (wild flax) you pick, 2 fibre a clump.
var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else ItemDB.mesh_for(id)
var placed := 0
var tries := 0
while placed < want[id] and tries < 20000:
@@ -525,18 +657,83 @@ func _scatter_pickups() -> void:
"flint":
var shore := 1.0 - smoothstep(terrain.water_level + 0.5, terrain.water_level + 4.0, h)
chance = 0.06 + shore * 0.9 + (1.0 - n.y) * 2.0
"mushroom":
chance = _forest(x, z) * 0.7 # deep forest only
"feather":
chance = 0.1 + (1.0 - _forest(x, z)) * 0.5 # open meadows
"fiber":
# Meadows, and thickest along the lake shore.
var wet := 1.0 - smoothstep(terrain.water_level + 0.5, terrain.water_level + 3.0, h)
chance = 0.05 + (1.0 - _forest(x, z)) * 0.45 + wet * 0.6
"iron_nugget":
# In the scree at the foot of the mountains (and on rocky slopes).
chance = 0.02 + _near_rim(x, z) * 0.9 + (1.0 - n.y) * 1.5
"clay":
# The lake shore only.
var lake: Vector2 = terrain.lake_center
var by_lake: bool = Vector2(x - lake.x, z - lake.y).length() < terrain.lake_radius * 1.6
var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h)
chance = shore * 0.95 if by_lake else 0.0
"salt":
# Crusts on the cliff tops above the sea (P4).
chance = 0.95 if terrain.sea_weight(x, z) > 0.6 and _near_rim(x, z) > 0.35 else 0.0
# A few right by the spawn so the first craft is quick.
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0:
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt"]:
chance += 0.5
if keep > chance:
continue
var pk: Area3D = PickupScript.new()
pk.setup(id, mesh, ItemDB.item_material(), radius[id])
if id == "fiber":
pk.amount = 2
pk.uid = "p%d" % pickups.size()
pickups[pk.uid] = pk
pk.taken_now.connect(_on_taken)
root.add_child(pk)
pk.global_transform = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h - 0.02, z))
placed += 1
_scatter_berry_bushes(root)
## Berry bushes along forest edges and in meadows, plus a couple near spawn.
func _scatter_berry_bushes(root: Node3D) -> void:
var rng := RandomNumberGenerator.new()
rng.seed = world_seed + 7
var placed := 0
var tries := 0
while placed < 70 and tries < 8000:
tries += 1
var x := rng.randf_range(-terrain.play_radius, terrain.play_radius)
var z := rng.randf_range(-terrain.play_radius, terrain.play_radius)
var yaw := rng.randf() * TAU
var keep := rng.randf()
if not terrain.inside_boundary(x, z, 4.0):
continue
var h: float = terrain.sample_height(x, z)
var n: Vector3 = terrain.sample_normal(x, z)
if h < terrain.water_level + 0.6 or n.y < 0.85:
continue
var f := _forest(x, z)
var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5
var near_spawn := Vector2(x - _spawn.x, z - _spawn.z).length()
if near_spawn < 6.0:
continue
if near_spawn < 25.0:
chance += 0.4
if keep > chance:
continue
var bush: Area3D = ForageScript.new()
bush.uid = "b%d" % forage.size()
bush.rng.seed = hash(bush.uid) ^ world_seed
bush.setup(_leaf_mat_static())
root.add_child(bush)
bush.global_transform = Transform3D(_ground_basis(n, 0.5, yaw, rng.randf_range(0.9, 1.2)), Vector3(x, h - 0.05, z))
forage[bush.uid] = bush
placed += 1
## Berry-bush meshes carry their own vertex colours (including red berries),
## so they use the plain item material rather than the wind foliage shader.
func _leaf_mat_static() -> Material:
return ItemDB.item_material()
# --- grass streaming --------------------------------------------------------
@@ -580,6 +777,12 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
var flower_xf: Array = []
var flower_col: Array = []
var steps := int(GRASS_TILE / grass_spacing)
# Floors that overlap this tile (grass doesn't grow through them).
var cuts: Array = []
var tile := Rect2(ox, oz, GRASS_TILE, GRASS_TILE).grow(2.0)
for c in _grass_cuts:
if tile.has_point(Vector2(c[1].x, c[1].z)):
cuts.append(c)
for iz in range(steps):
for ix in range(steps):
var x := ox + (ix + rng.randf()) * grass_spacing
@@ -594,6 +797,8 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
var n: Vector3 = terrain.sample_normal(x, z)
if n.y < 0.72:
continue
if not cuts.is_empty() and _under_cut(cuts, x, z):
continue
var meadow := _meadow_noise.get_noise_2d(x, z)
var dens := smoothstep(-0.45, 0.1, meadow) * (1.0 - smoothstep(0.85, 0.72, n.y) * 0.7)
if keep > dens:
@@ -619,6 +824,43 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
_grass_root.add_child(node)
return node
## Floor footprints to keep clear of grass: [world->local transform, centre,
## half width]. Set by world.gd whenever floors are built or removed; only the
## grass tiles around old and new floors are rebuilt.
var _grass_cuts: Array = []
## Permanent cuts (the wolf den floor): [transform, half width x, half depth z].
var static_cuts: Array = []
func set_grass_cuts(xforms: Array, half: float) -> void:
var old := _grass_cuts
_grass_cuts = []
for xf: Transform3D in xforms:
_grass_cuts.append([xf.affine_inverse(), xf.origin, half, half])
for c in static_cuts:
var xf: Transform3D = c[0]
_grass_cuts.append([xf.affine_inverse(), xf.origin, float(c[1]), float(c[2])])
var touched := {}
for c in old + _grass_cuts:
var p: Vector3 = c[1]
for dz in [-3.0, 0.0, 3.0]:
for dx in [-3.0, 0.0, 3.0]:
touched[Vector2i(floori((p.x + dx) / GRASS_TILE), floori((p.z + dz) / GRASS_TILE))] = true
for k in touched:
if _grass_tiles.has(k):
var node = _grass_tiles[k]
if node != null and is_instance_valid(node):
node.queue_free()
_grass_tiles.erase(k)
if _player != null:
_update_grass(touched.size())
static func _under_cut(cuts: Array, x: float, z: float) -> bool:
for c in cuts:
var l: Vector3 = (c[0] as Transform3D) * Vector3(x, (c[1] as Vector3).y, z)
if absf(l.x) < c[2] and absf(l.z) < c[3]:
return true
return false
func _fade(gi: GeometryInstance3D, dist: float) -> void:
gi.visibility_range_end = dist
gi.visibility_range_end_margin = 10.0

View File

@@ -29,10 +29,57 @@ var rng := RandomNumberGenerator.new()
var _shake := 0.0
var _shake_axis := Vector3.RIGHT
var _stump: MeshInstance3D
var _orig_shapes: Array = [] ## [CollisionShape3D, shape, position] before the stump
## Emitted when a player fells or mines this (not when a save restores it).
signal depleted_now(node: Node)
func display_name() -> String:
if is_iron:
return "Rusty boulder (iron ore)"
return "Tree" if kind == "tree" else "Boulder"
# --- rusty boulders (iron ore) ---------------------------------------------------
var is_iron := false
var ore_vis: Node3D ## the rust streaks, following the boulder's instance
static var _patch_mesh: ArrayMesh
## Turns this boulder into a rusty one: iron ore when it breaks, and red-brown
## streaks on its surface (`hull` = the rock mesh's points, unscaled).
func make_iron(hull: PackedVector3Array) -> void:
is_iron = true
drops_final = {"stone": [2, 4], "iron_ore": [2, 4]}
chip_color = Color(0.55, 0.3, 0.18)
if DisplayServer.get_name() == "headless":
return
if _patch_mesh == null:
var n := FastNoiseLite.new()
n.seed = 5
n.frequency = 2.0
var st := ProcMesh._begin()
ProcMesh._blob(st, Vector3.ZERO, Vector3(0.13, 0.1, 0.018), n, 0.25, Color(0.48, 0.24, 0.12), RandomNumberGenerator.new(), 0)
_patch_mesh = ProcMesh._commit(st)
ore_vis = Node3D.new()
ore_vis.top_level = true
add_child(ore_vis)
# Streaks on the upper surface, spread out.
var up: Array = []
for q: Vector3 in hull:
if q.y > 0.15:
up.append(q)
up.sort_custom(func(a, b): return a.x + a.z * 0.37 < b.x + b.z * 0.37)
var mat := ItemDB.item_material()
for i in range(mini(6, up.size())):
var q: Vector3 = up[int(float(i) / 6.0 * up.size())]
var mi := MeshInstance3D.new()
mi.mesh = _patch_mesh
mi.material_override = mat
mi.transform = Transform3D(Basis.looking_at(-q.normalized(), Vector3.UP if absf(q.normalized().y) < 0.95 else Vector3.RIGHT), q * 0.97)
ore_vis.add_child(mi)
ore_vis.global_transform = base_xf
func interact_prompt(player: Node) -> String:
var tool: Dictionary = ItemDB.get_item(player.held_item_id()).get("tool", {})
var verb := "Chop" if kind == "tree" else "Mine"
@@ -51,15 +98,26 @@ func interact_prompt(player: Node) -> String:
## Applies one hit. Returns the items earned by this hit.
func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
var out := {}
hp -= damage
for id in drops_per_hit:
out[id] = drops_per_hit[id]
_burst(point, chip_color, 14)
if hp <= 0.0:
depleted = true
if hp - damage <= 0.0:
for id in drops_final:
var r: Array = drops_final[id]
out[id] = out.get(id, 0) + rng.randi_range(int(r[0]), int(r[1]))
_show_hit(hp - damage, point, from)
return out
## A hit someone else's game reported, via the server (world_net.gd): the
## same chips, shake and fall, with no drops.
func apply_remote(new_hp: float, point: Vector3, from: Vector3) -> void:
if not depleted:
_show_hit(new_hp, point, from)
func _show_hit(new_hp: float, point: Vector3, from: Vector3) -> void:
hp = new_hp
_burst(point, chip_color, 14)
if hp <= 0.0:
depleted = true
_deplete(from)
else:
_shake = 1.0
@@ -68,7 +126,6 @@ func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
_shake_axis = away.normalized().cross(Vector3.UP) if away.length() > 0.01 else Vector3.RIGHT
if kind == "rock":
_set_instance(_rock_xf())
return out
func _process(delta: float) -> void:
if _shake <= 0.0 or depleted:
@@ -86,6 +143,8 @@ func _rock_xf() -> Transform3D:
func _set_instance(xf: Transform3D) -> void:
if mmi != null and index >= 0:
mmi.multimesh.set_instance_transform(index, xf)
if ore_vis != null:
ore_vis.global_transform = xf # (shrinks, shakes, hides and regrows with it)
## Restores saved damage when a world loads. hp <= 0 = already felled/mined
## (no animation, no drops).
@@ -107,7 +166,36 @@ func _deplete(from: Vector3, animate: bool = true) -> void:
else:
if animate:
_burst(global_position + Vector3(0, 0.8, 0), chip_color, 40)
queue_free()
# Kept (hidden, no collision) so it can regrow later.
set_process(false)
remove_from_group("harvestable")
if animate:
depleted_now.emit(self)
## Brings a felled tree or mined boulder back, growing in over a few seconds.
func regrow() -> void:
if not depleted:
return
depleted = false
hp = max_hp
if _stump != null:
_stump.queue_free()
_stump = null
for e in _orig_shapes:
e[0].shape = e[1]
e[0].position = e[2]
_orig_shapes.clear()
if has_meta("is_stump"):
remove_meta("is_stump")
collision_layer = 1
add_to_group("harvestable")
set_process(true)
if is_inside_tree() and DisplayServer.get_name() != "headless":
var tw := create_tween()
tw.tween_method(func(k: float): _set_instance(Transform3D(base_xf.basis.scaled_local(Vector3.ONE * k), base_xf.origin)),
0.15, 1.0, 4.0).set_trans(Tween.TRANS_SINE)
else:
_set_instance(base_xf)
func _fell(from: Vector3, animate: bool) -> void:
var world := get_parent()
@@ -139,9 +227,11 @@ func _make_stump(world: Node) -> void:
stump.material_override = stump_material
world.add_child(stump)
stump.global_position = base_xf.origin
_stump = stump
# Keep collision as a short stump.
for c in get_children():
if c is CollisionShape3D and c.shape is CapsuleShape3D:
_orig_shapes.append([c, c.shape, c.position])
var cyl := CylinderShape3D.new()
cyl.radius = (c.shape as CapsuleShape3D).radius
cyl.height = 0.6

View File

@@ -0,0 +1,393 @@
extends Node3D
## The standing stones (docs/PROGRESSION_DESIGN.md §6): a ring of tall stones
## at the foot of the rim with an altar in the middle, built from the seed
## like the den and the barrow. The altar has three hollows for the barrow
## key pieces. With all three in, at night (19:30 to 05:30), E on the altar
## calls the Old Stag (old_stag.gd).
##
## - Lose (everyone leaves, dies, or dawn comes) and the stag goes back into
## the mist; the key pieces stay in the altar for another night.
## - Win and it drops its antler (for the antler pickaxe) and a trophy; the
## key pieces are used up, and the world remembers who beat it.
##
## The fight runs where the game decides (single-player, a server). Saved
## with the world: the key pieces in the altar and whether it's been beaten.
## Players online are sent the altar's state (world_net.gd _s2c_stones).
const IM = preload("res://scripts/items/item_meshes.gd")
const P = preload("res://scripts/world/proc_mesh.gd")
const StagScript = preload("res://scripts/creatures/old_stag.gd")
const CreatureScript = preload("res://scripts/creatures/creature.gd")
const RING_R := 7.5
const STONES := 7
const CLEAR_R := 10.0
const KEYS := 3
const LEASH := 45.0 ## nobody this near for LEAVE_AFTER: the stag goes
const LEAVE_AFTER := 5.0
var world: Node3D
var terrain: Node3D
var found := false
var yaw := 0.0
var keys := 0 ## key pieces in the altar
var beaten := false
var beaten_by := ""
var beaten_day := -1
var fighters: Array = [] ## (on a server) the names of everyone who fought it
var stag: Node3D = null
var wolves: Array = []
var deciding := true ## false on a player's game online
var stomps := 0
var _alone := 0.0
var _rng := RandomNumberGenerator.new()
var _key_meshes: Array = []
var _glow: OmniLight3D
var _altar: StaticBody3D
func place(w: Node3D) -> bool:
world = w
terrain = w.terrain
var site := _find_site()
if site == Vector3.INF:
push_warning("[stones] no site for the standing stones on this seed")
return false
found = true
position = site
yaw = atan2(-site.x, -site.z) # the altar faces the valley
rotation.y = yaw
_clear_area(w.props)
w.props.clear_bushes(position, CLEAR_R)
_build(w.props._rock_mat)
w.props.static_cuts.append([Transform3D(Basis(Vector3.UP, yaw), position), RING_R, RING_R])
return true
## At the foot of the rim, fairly flat, well away from the spawn, the den,
## the barrow and the bear's home. Rougher ground is accepted on later
## passes, so every island has one.
func _find_site() -> Vector3:
_rng.seed = terrain.noise_seed + 1501
var start := _rng.randf() * 360.0
var spawn: Vector3 = world._spawn
var avoid: Array = [[Vector2(spawn.x, spawn.z), 60.0]]
if world.den != null and world.den.found:
avoid.append([Vector2(world.den.position.x, world.den.position.z), 60.0])
if world.barrow != null and world.barrow.found:
avoid.append([Vector2(world.barrow.position.x, world.barrow.position.z), 50.0])
for pass_ in [[1.6, [6.0, 10.0, 14.0]], [2.6, [6.0, 10.0, 14.0, 18.0, 24.0, 30.0]], [4.0, [8.0, 16.0, 24.0, 32.0, 40.0]]]:
for step in range(60):
var ang := deg_to_rad(start + step * 6.0)
var dir := Vector2(cos(ang), sin(ang))
var r: float = terrain.play_radius + 10.0
while r > terrain.play_radius - 50.0 and not terrain.inside_boundary(dir.x * r, dir.y * r, CLEAR_R + 2.0):
r -= 1.0
for inset: float in pass_[1]:
var c: Vector2 = dir * (r - inset)
if not terrain.inside_boundary(c.x, c.y, CLEAR_R + 1.0) or terrain.sea_weight(c.x, c.y) > 0.02:
continue
var far := true
for a in avoid:
if c.distance_to(a[0]) < float(a[1]):
far = false
if not far:
continue
var hs: Array[float] = []
for k in range(8):
var b := TAU * k / 8.0
hs.append(terrain.sample_height(c.x + cos(b) * RING_R, c.y + sin(b) * RING_R))
var h0: float = terrain.sample_height(c.x, c.y)
hs.append(h0)
if hs.max() - hs.min() > float(pass_[0]) or hs.min() < terrain.water_level + 1.0:
continue
return Vector3(c.x, h0, c.y)
return Vector3.INF
func _clear_area(props: Node3D) -> void:
for dict in [props.resource_nodes, props.pickups, props.forage]:
for uid in dict.keys():
var n: Node3D = dict[uid]
if not is_instance_valid(n):
continue
if Vector2(n.global_position.x - position.x, n.global_position.z - position.z).length() < CLEAR_R:
if n.has_method("_set_instance"):
n._set_instance(Transform3D(Basis(), n.global_position + Vector3(0, -1000, 0)))
dict.erase(uid)
n.queue_free()
func _build(rock_mat: Material) -> void:
var body := StaticBody3D.new()
body.name = "Stones"
body.collision_layer = 1
add_child(body)
for i in range(STONES):
var a := TAU * i / STONES
var lp := Vector3(sin(a) * RING_R, 0, cos(a) * RING_R)
var w: Vector3 = position + Basis(Vector3.UP, yaw) * lp
# Tall, narrow slabs (the rock mesh is a unit blob: stretch it up).
var radii := Vector3(0.55, 3.8 + 0.6 * sin(i * 2.7), 0.38)
var y: float = terrain.sample_height(w.x, w.z) - position.y + radii.y * 0.45 - 0.3
var mesh := P.rock(terrain.noise_seed + 6100 + i, false)
var turn := Basis(Vector3.UP, a) * Basis(Vector3.RIGHT, 0.04 * sin(i * 1.9))
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.material_override = rock_mat
mi.transform = Transform3D(turn.scaled(radii), Vector3(lp.x, y, lp.z))
add_child(mi)
var hull := mesh.create_convex_shape(true, true) as ConvexPolygonShape3D
var pts := PackedVector3Array()
for p: Vector3 in hull.points:
pts.append(turn * (p * radii))
hull.points = pts
var cs := CollisionShape3D.new()
cs.shape = hull
cs.position = Vector3(lp.x, y, lp.z)
body.add_child(cs)
# The altar: a low slab on two blocks, with three hollows on top.
_altar = Altar.new()
_altar.stones = self
_altar.name = "Altar"
_altar.collision_layer = 1
add_child(_altar)
var st := P._begin()
var stone := Color(0.46, 0.46, 0.44)
var r := RandomNumberGenerator.new()
r.seed = terrain.noise_seed + 1502
for sx in [-0.55, 0.55]:
IM._box(st, Vector3(sx, 0.3, 0), Vector3(0.4, 0.6, 0.7), P._mul(stone, r.randf_range(0.85, 1.0)))
IM._box(st, Vector3(0, 0.7, 0), Vector3(1.8, 0.2, 1.0), stone)
for i in range(KEYS):
IM._box(st, Vector3(-0.5 + 0.5 * i, 0.805, 0), Vector3(0.24, 0.01, 0.24), Color(0.16, 0.16, 0.16))
var mi := MeshInstance3D.new()
mi.mesh = P._commit(st)
mi.material_override = ItemDB.item_material()
_altar.add_child(mi)
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(1.8, 0.8, 1.0)
cs.shape = box
cs.position = Vector3(0, 0.4, 0)
_altar.add_child(cs)
for i in range(KEYS):
var k := MeshInstance3D.new()
k.mesh = ItemDB.mesh_for("barrow_key")
k.material_override = ItemDB.item_material()
k.position = Vector3(-0.5 + 0.5 * i, 0.8, 0.08)
k.rotation.y = PI
k.visible = false
_altar.add_child(k)
_key_meshes.append(k)
if DisplayServer.get_name() != "headless":
_glow = OmniLight3D.new()
_glow.light_color = Color(0.55, 0.9, 1.0)
_glow.omni_range = 9.0
_glow.light_energy = 0.0
_glow.position = Vector3(0, 1.6, 0)
add_child(_glow)
_show()
## The key pieces in the altar, and a pale glow once all three are in.
func _show() -> void:
for i in range(_key_meshes.size()):
_key_meshes[i].visible = i < keys
if _glow != null:
_glow.light_energy = 1.8 if keys >= KEYS else 0.3 * keys
func center() -> Vector3:
return position
func altar() -> Node3D:
return _altar
func is_night() -> bool:
var h: float = world.day_night.hours()
return h >= 19.5 or h < 5.5
func fighting() -> bool:
return stag != null and is_instance_valid(stag) and not stag.is_dead()
# --- the altar --------------------------------------------------------------------------
func altar_prompt() -> String:
if fighting():
return "The altar (the Old Stag is here!)"
if keys < KEYS:
return "Stone altar: %d of %d hollows filled [E] Set a barrow key piece" % [keys, KEYS]
if is_night():
return "Stone altar, humming [E] Call the Old Stag"
return "Stone altar, humming (come back at night)"
## E on the altar (Actions.use_stones): set key pieces in it, or call the
## stag. Returns what happened: "set", "summoned", "" (nothing).
func use(player: Node3D) -> String:
if fighting():
return ""
if keys < KEYS:
var have: int = player.inventory.count("barrow_key")
if have <= 0:
Actions.tell(player, "Three hollows in the stone (%d filled). Barrow key pieces would fit them." % keys)
return ""
var n := mini(have, KEYS - keys)
player.inventory.remove("barrow_key", n)
keys += n
_changed()
if keys < KEYS:
Actions.tell(player, "You set a key piece in the altar (%d of %d)." % [keys, KEYS])
else:
Actions.tell(player, "The last key piece clicks into place. The stones begin to hum%s." % ("" if is_night() else ": come back at night"))
return "set"
if not is_night():
Actions.tell(player, "The stones hum, waiting for the dark.")
return ""
summon(player)
return "summoned"
## Calls the Old Stag, on the far side of the ring from `player`.
func summon(player: Node3D) -> Node3D:
var away := position - player.global_position
away.y = 0.0
away = away.normalized() if away.length() > 0.1 else Vector3(1, 0, 0)
var at := _inside_point(away, RING_R + 6.0)
var s: AnimatableBody3D = StagScript.new()
s.setup("old_stag", world, at, _rng.randi())
s.stones = self
s.state = "summon"
s._state_t = 2.5
var to: Vector3 = player.global_position - at
s.yaw = atan2(-to.x, -to.z)
s.rotation.y = s.yaw
s.top_level = true # (in world space: the stones are moved and turned, a creature steers in world space)
add_child(s)
s.scale_for(group_size())
stag = s
_alone = 0.0
world.weather.flash = 0.8
Actions.tell_all("The standing stones wake... the Old Stag has come!")
return s
## How many (living) players are at the stones: the stag's toughened for
## them, and calls a wolf more for each.
func group_size() -> int:
var n := 0
for p in world.players():
if not p.survival.dead and p.global_position.distance_to(position) < LEASH:
n += 1
return maxi(n, 1)
## The stag calls `n` spirit wolves out of the mist round the ring (and one
## more for each extra player, up to 4 more).
func summon_wolves(s: Node3D, n: int) -> void:
n += mini(group_size() - 1, 4)
for i in range(n):
var a := _rng.randf() * TAU
var at := _inside_point(Vector3(cos(a), 0, sin(a)), RING_R + 4.0)
var w: AnimatableBody3D = CreatureScript.new()
w.setup("spirit_wolf", world, at, _rng.randi())
w.state = "charge"
w._aggro = 9999.0
w.top_level = true
add_child(w)
wolves.append(w)
var who: Node = world.nearest_player(s.global_position)
if who != null:
Actions.tell(who, "The Old Stag bellows, and spirit wolves come out of the mist!")
## A point `r` from the centre roughly along `dir`, turned until it's inside
## the valley with room to move (the stones are at the foot of the rim).
func _inside_point(dir: Vector3, r: float) -> Vector3:
for turn in [0.0, 0.5, -0.5, 1.0, -1.0, 1.6, -1.6, 2.2, -2.2, PI]:
var p: Vector3 = position + dir.rotated(Vector3.UP, turn) * r
if terrain.inside_boundary(p.x, p.z, 4.0) and terrain.sample_height(p.x, p.z) > terrain.water_level + 0.3:
return p
return position + dir * 3.0
func stomped(_s: Node3D) -> void:
stomps += 1
world.weather.flash = maxf(world.weather.flash, 0.3)
func wolves_alive() -> Array:
return wolves.filter(func(w): return is_instance_valid(w) and not w.is_dead())
func _physics_process(_delta: float) -> void:
if deciding:
step(_delta)
## Sends the stag (and its wolves) back into the mist when everyone's gone
## or dead, or at dawn.
func step(dt: float) -> void:
if not fighting():
return
var anyone := false
for p in world.players():
if not p.survival.dead and p.global_position.distance_to(position) < LEASH:
anyone = true
_alone = 0.0 if anyone else _alone + dt
if _alone >= LEAVE_AFTER or not is_night():
fade("dawn" if not is_night() else "gone")
func fade(why: String) -> void:
if stag != null and is_instance_valid(stag):
stag.queue_free()
stag = null
_clear_wolves()
Actions.tell_all("The Old Stag fades into the mist%s. The key pieces are still in the altar." % (" with the dawn" if why == "dawn" else ""))
func _clear_wolves() -> void:
for w in wolves:
if is_instance_valid(w):
w.queue_free()
wolves.clear()
## Beaten: its antler and a trophy where it fell, the key pieces used up,
## and the world remembers.
func stag_died(s: Node3D) -> void:
var at: Vector3 = s.global_position + Vector3(0, 0.4, 0)
world.drop_item("stag_antler", 1, Transform3D(Basis(), at))
world.drop_item("stag_trophy", 1, Transform3D(Basis(), at + Vector3(1.2, 0, 0.6)))
_clear_wolves()
keys = 0
var names: Array = s.fighter_names()
beaten = true
beaten_by = join_names(names) if world.server_mode else "you"
fighters = names.duplicate() if world.server_mode else []
beaten_day = int(world.day_night.day)
stag = null
_changed()
if world.server_mode:
Actions.tell_all("The Old Stag has fallen! Beaten by %s on day %d." % [beaten_by if not names.is_empty() else "the island", beaten_day])
else:
Actions.tell_all("The Old Stag has fallen! Its antler lies where it fell.")
## "Ana", "Ana and Bob", "Ana, Bob and Cara".
static func join_names(names: Array) -> String:
if names.is_empty():
return ""
if names.size() == 1:
return String(names[0])
return ", ".join(PackedStringArray(names.slice(0, names.size() - 1))) + " and " + String(names.back())
func _changed() -> void:
_show()
if world != null and world.server_mode:
WorldNet.stones_state(save_data())
func save_data() -> Dictionary:
if not found:
return {}
return {"keys": keys, "beaten": beaten, "beaten_by": beaten_by, "beaten_day": beaten_day, "fighters": fighters.duplicate()}
func load_data(d: Dictionary) -> void:
if not found or d.is_empty():
return
keys = clampi(int(d.get("keys", 0)), 0, KEYS)
beaten = bool(d.get("beaten", false))
beaten_by = String(d.get("beaten_by", ""))
beaten_day = int(d.get("beaten_day", -1))
fighters = (d.get("fighters", []) as Array).duplicate()
_show()
class Altar extends StaticBody3D:
var stones: Node
func interact_prompt(_p: Node) -> String:
return stones.altar_prompt()
func interact(p: Node) -> void:
Actions.use_stones(p, stones)

View File

@@ -69,9 +69,13 @@ var _heights := PackedFloat32Array()
var _normals := PackedVector3Array()
func _ready() -> void:
# A saved world brings its own seed; otherwise keep the exported default.
# On a server, its seed; in a saved world, that world's seed; otherwise
# keep the exported default.
var net := get_node_or_null("/root/Net")
var saves := get_node_or_null("/root/Saves")
if saves != null and saves.has_world():
if net != null and net.state == "connected" and net.world_info.has("seed"):
noise_seed = int(net.world_info.seed)
elif saves != null and saves.has_world():
noise_seed = saves.world_seed()
_build()

233
scripts/world/weather.gd Normal file
View File

@@ -0,0 +1,233 @@
extends Node
## Weather model: what the sky is doing right now, and what that means for
## warmth, wetness and light. Visuals and sound live in weather_fx.gd.
##
## Deterministic: the weather at any moment is a function of the world seed and
## the absolute world time (day + time of day), so saves don't store it and in
## M2 every client can compute the same weather as the server.
##
## A slow 1D noise "front" value picks the state: high = clear, low = rain,
## lowest = storm. About one day in four is a cold day, when rain falls as snow
## (and a storm becomes a blizzard). Above the snowline it always snows.
## Outputs ease toward the state's targets over about a minute, so fronts
## roll in rather than switching.
##
## P4 (docs/PROGRESSION_DESIGN.md §5): every 12 days a hard winter, three
## bitter cold days in a row (hard_winter()), colder still, with more snow and
## blizzards. A blizzard cuts visibility right down and its wind strips the
## warmth from anyone out in it (blizzard_chill(); a shelter keeps it out).
signal state_changed(state: String)
const STATES := ["clear", "cloudy", "rain", "storm", "snow", "blizzard"]
## Thresholds on the front value (see _state_for). Tuned with
## tests/test_weather_building.gd so that over many days it's roughly
## clear 40%, cloudy 30%, rain 20%, storm 10%.
var cut_cloudy := 0.07
var cut_rain := -0.19
var cut_storm := -0.42
## Per-state targets: clouds 0..1, precip 0..1, wind 0..1, light (sun/ambient
## multiplier), fog (density multiplier), warmth (added to air warmth).
const TARGETS := {
"clear": {"clouds": 0.30, "precip": 0.0, "wind": 0.25, "light": 1.00, "fog": 1.0, "warmth": 0.0},
"cloudy": {"clouds": 0.72, "precip": 0.0, "wind": 0.45, "light": 0.72, "fog": 1.4, "warmth": -3.0},
"rain": {"clouds": 0.92, "precip": 0.6, "wind": 0.60, "light": 0.50, "fog": 2.2, "warmth": -6.0},
"storm": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.30, "fog": 3.0, "warmth": -12.0},
"snow": {"clouds": 0.90, "precip": 0.6, "wind": 0.35, "light": 0.68, "fog": 2.6, "warmth": -14.0},
"blizzard": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.40, "fog": 7.0, "warmth": -24.0},
}
const COLD_DAY_WARMTH := -8.0
const BLEND_SECONDS := 50.0 ## time for outputs to cross from one state to another
const CALM_START := 600.0 ## a new world's first 10 minutes are always clear
const COLD_DAY_CHANCE := 0.25
const HARD_WINTER_EVERY := 12 ## days 12-14, 24-26, ...
const HARD_WINTER_DAYS := 3
const HARD_WINTER_WARMTH := -8.0 ## on top of a cold day's
const BLIZZARD_CHILL := 18.0 ## more warmth lost out in a blizzard's wind (a hard winter's freezes you by day)
## In a hard winter the weather leans wet (more snow and blizzards).
const HARD_WINTER_FRONT := -0.18
var world_seed := 1337
var day_night: Node
var snowline := 32.0 ## above this height precipitation is snow
var frozen := false ## title-screen backdrop: hold clear weather
var override := "" ## force a state (tests, screenshots); "" = natural
var state := "clear"
var cold_day := false
# Smoothed outputs.
var clouds := 0.3
var precip := 0.0
var wind := 0.25
var light := 1.0
var fog := 1.0
var warmth := 0.0
var ground_wet := 0.0 ## 0..1 how soaked the ground looks
var snow_cover := 0.0 ## 0..1 how much snow has settled
var flash := 0.0 ## lightning brightness, decays fast
var lightning_count := 0
var _noise := FastNoiseLite.new()
var _strike_left := 8.0
var _rng := RandomNumberGenerator.new()
func setup(seed: int, dn: Node) -> void:
world_seed = seed
day_night = dn
_noise.seed = seed + 401
_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
_noise.frequency = 1.0 / 900.0 # a wet spell lasts about 6 minutes
_noise.fractal_type = FastNoiseLite.FRACTAL_FBM
_noise.fractal_octaves = 2
_rng.seed = seed + 402
snap()
## Seconds since the world began (day 1, 00:00).
func world_seconds() -> float:
return (float(day_night.day - 1) + float(day_night.time)) * float(day_night.day_length)
## Day `day` is one of a hard winter's three bitter days.
static func hard_winter(day: int) -> bool:
return day >= HARD_WINTER_EVERY and (day - HARD_WINTER_EVERY) % HARD_WINTER_EVERY < HARD_WINTER_DAYS
## The first day of the next hard winter on or after `day`.
static func next_hard_winter(day: int) -> int:
var d := maxi(day, HARD_WINTER_EVERY)
while not hard_winter(d):
d += 1
return d
func is_cold_day(day: int) -> bool:
if day <= 1:
return false
if hard_winter(day):
return true
return float(absi(hash(Vector2i(world_seed, day))) % 10000) / 10000.0 < COLD_DAY_CHANCE
## The raw front value at an absolute time (higher = fairer).
func front_at(t: float) -> float:
var v := _noise.get_noise_1d(t)
if t < CALM_START:
v = maxf(v, 0.3)
return v
func _state_for(v: float, cold: bool) -> String:
if v > cut_cloudy:
return "clear"
if v > cut_rain:
return "cloudy"
if v > cut_storm:
return "snow" if cold else "rain"
return "blizzard" if cold else "storm"
## The state at an absolute time, ignoring smoothing and overrides.
func state_at(t: float) -> String:
var day := int(floor(t / float(day_night.day_length))) + 1
return _state_for(front_at(t) + (HARD_WINTER_FRONT if hard_winter(day) else 0.0), is_cold_day(day))
func force(s: String) -> void:
override = s
snap()
## Jump every output straight to the current state's targets (on load, or
## when forcing a state).
func snap() -> void:
_pick_state()
var tg: Dictionary = TARGETS[state]
clouds = tg.clouds
precip = tg.precip
wind = tg.wind
light = tg.light
fog = tg.fog
warmth = tg.warmth + _cold_extra()
ground_wet = 1.0 if state in ["rain", "storm"] else 0.0
snow_cover = 0.7 if state in ["snow", "blizzard"] else (0.25 if cold_day else 0.0)
func _pick_state() -> void:
if day_night == null:
return
cold_day = is_cold_day(day_night.day)
var s := override
if frozen and s.is_empty():
s = "clear"
if s.is_empty():
s = _state_for(front_at(world_seconds()) + (HARD_WINTER_FRONT if hard_winter(day_night.day) else 0.0), cold_day)
if s != state:
state = s
state_changed.emit(state)
func _process(delta: float) -> void:
step(delta)
func step(dt: float) -> void:
_pick_state()
var tg: Dictionary = TARGETS[state]
var k := dt / BLEND_SECONDS
clouds = move_toward(clouds, tg.clouds, k)
precip = move_toward(precip, tg.precip, k * 1.2)
wind = move_toward(wind, tg.wind, k)
light = move_toward(light, tg.light, k * 0.8)
fog = move_toward(fog, tg.fog, k * 3.0)
warmth = move_toward(warmth, tg.warmth + _cold_extra(), k * 20.0)
# The ground soaks in a couple of minutes of rain and dries over ten.
var raining := precip > 0.2 and not snowing_globally()
ground_wet = move_toward(ground_wet, 1.0 if raining else 0.0, dt / (120.0 if raining else 600.0))
# Snow settles over a few minutes and only melts on warm days.
var snowing := precip > 0.2 and snowing_globally()
if snowing:
snow_cover = move_toward(snow_cover, 1.0, dt * precip / 180.0)
elif not cold_day:
snow_cover = move_toward(snow_cover, 0.0, dt / 480.0)
flash = maxf(flash - dt * 6.0, 0.0)
if state == "storm" and precip > 0.7:
_strike_left -= dt
if _strike_left <= 0.0:
_strike_left = _rng.randf_range(7.0, 22.0)
flash = 1.0
lightning_count += 1
## Extra cold from the day: a cold day, and a hard winter on top.
func _cold_extra() -> float:
if not cold_day:
return 0.0
return COLD_DAY_WARMTH + (HARD_WINTER_WARMTH if day_night != null and hard_winter(day_night.day) else 0.0)
func in_hard_winter() -> bool:
return day_night != null and hard_winter(day_night.day)
## Extra warmth lost by someone out in a blizzard's wind (0 when sheltered or
## when it isn't blowing).
func blizzard_chill(sheltered: bool) -> float:
if sheltered or state != "blizzard":
return 0.0
return BLIZZARD_CHILL * clampf(precip, 0.0, 1.0)
func snowing_globally() -> bool:
return state in ["snow", "blizzard"]
## Whether precipitation at `pos` falls as snow (cold day or high up).
func snows_at(pos: Vector3) -> bool:
return snowing_globally() or pos.y > snowline
## 0..1 rain intensity at `pos` (0 when it's snow there).
func rain_at(pos: Vector3) -> float:
return 0.0 if snows_at(pos) else precip
## Air warmth change from the weather. Altitude snow makes the peaks colder
## still when precipitation is falling up there.
func warmth_at(pos: Vector3) -> float:
var w := warmth
if precip > 0.2 and pos.y > snowline and not snowing_globally():
w -= 10.0 * precip
return w
func label() -> String:
var names := {"clear": "Clear", "cloudy": "Cloudy", "rain": "Rain", "storm": "Storm",
"snow": "Snow", "blizzard": "Blizzard"}
var s: String = names[state]
if cold_day and state in ["clear", "cloudy"]:
s += ", cold"
if in_hard_winter():
s += " (hard winter)"
return s

217
scripts/world/weather_fx.gd Normal file
View File

@@ -0,0 +1,217 @@
extends Node3D
## Weather visuals and sound, driven by weather.gd (the model).
##
## - Rain and snow are GPU particles emitted above the camera in world space.
## A heightfield collider that follows the camera stops them on roofs and on
## the ground, so it doesn't rain indoors.
## - Ground wetness, settled snow and wind strength are global shader uniforms
## (weather_wet, weather_snow, weather_wind) read by the terrain, grass,
## foliage and rock shaders.
## - Sound is synthesised: filtered noise for rain and wind, a decaying
## low rumble for thunder. No audio assets, nothing to license.
##
## Client-side only: in M2 the server runs weather.gd and never this.
const Campfire = preload("res://scripts/building/campfire.gd")
var weather: Node
var player: Node3D ## for shelter muffling (survival.roofed)
var _rain: GPUParticles3D
var _snow: GPUParticles3D
var _rain_pm: ParticleProcessMaterial
var _snow_pm: ParticleProcessMaterial
var _field: GPUParticlesCollisionHeightField3D
# Audio
const MIX_RATE := 22050.0
var _audio: AudioStreamPlayer
var _playback: AudioStreamGeneratorPlayback
var _rng := RandomNumberGenerator.new()
var _rain_lp := 0.0
var _rain_hp := 0.0
var _brown := 0.0
var _brown2 := 0.0
var _gust_t := 0.0
var _thunder_env := 0.0
var _thunder_delay := -1.0
var _seen_strikes := 0
var _rain_amp := 0.0
var _wind_amp := 0.0
var _muffle := 0.0
func setup(w: Node, p: Node3D) -> void:
weather = w
player = p
_seen_strikes = weather.lightning_count
_rain_pm = ParticleProcessMaterial.new()
_rain = _make_particles(7000, 1.5, _rain_pm, _streak_mesh())
_rain_pm.emission_box_extents = Vector3(20, 1, 20)
_rain_pm.initial_velocity_min = 13.0
_rain_pm.initial_velocity_max = 16.0
_rain_pm.gravity = Vector3(0, -6, 0)
_rain_pm.particle_flag_align_y = true
_rain_pm.spread = 2.0
_snow_pm = ParticleProcessMaterial.new()
_snow = _make_particles(5000, 9.0, _snow_pm, _flake_mesh())
_snow_pm.emission_box_extents = Vector3(18, 1, 18)
_snow_pm.initial_velocity_min = 0.8
_snow_pm.initial_velocity_max = 1.4
_snow_pm.gravity = Vector3(0, -0.6, 0)
_snow_pm.spread = 15.0
_snow_pm.turbulence_enabled = true
_snow_pm.turbulence_noise_strength = 1.2
_snow_pm.turbulence_noise_scale = 4.0
_snow_pm.turbulence_influence_min = 0.03
_snow_pm.turbulence_influence_max = 0.08
_field = GPUParticlesCollisionHeightField3D.new()
_field.size = Vector3(48, 48, 48)
_field.resolution = GPUParticlesCollisionHeightField3D.RESOLUTION_512
_field.update_mode = GPUParticlesCollisionHeightField3D.UPDATE_MODE_ALWAYS
_field.follow_camera_enabled = true
add_child(_field)
if DisplayServer.get_name() != "headless":
var gen := AudioStreamGenerator.new()
gen.mix_rate = MIX_RATE
gen.buffer_length = 0.2
_audio = AudioStreamPlayer.new()
_audio.stream = gen
_audio.volume_db = -4.0
add_child(_audio)
_audio.play()
_playback = _audio.get_stream_playback()
func _make_particles(amount: int, life: float, pm: ParticleProcessMaterial, mesh: Mesh) -> GPUParticles3D:
var p := GPUParticles3D.new()
p.amount = amount
p.lifetime = life
p.preprocess = life
p.local_coords = false
p.visibility_aabb = AABB(Vector3(-30, -40, -30), Vector3(60, 60, 60))
p.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
p.amount_ratio = 0.0
p.emitting = false
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
pm.direction = Vector3.DOWN
pm.collision_mode = ParticleProcessMaterial.COLLISION_HIDE_ON_CONTACT
pm.collision_use_scale = false
p.collision_base_size = 0.05
p.process_material = pm
p.draw_pass_1 = mesh
add_child(p)
return p
## Two crossed thin quads along +Y, so a streak reads from any side once the
## particle is aligned to its velocity.
static func _streak_mesh() -> ArrayMesh:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var w := 0.012
var h := 0.32
for axis in [Vector3.RIGHT, Vector3.BACK]:
var a: Vector3 = axis * w
var quad := [-a + Vector3(0, -h, 0), a + Vector3(0, -h, 0), a + Vector3(0, h, 0), -a + Vector3(0, h, 0)]
for i in [0, 1, 2, 0, 2, 3]:
st.set_uv(Vector2(0.5, 1.0 if quad[i].y < 0 else 0.0))
st.add_vertex(quad[i])
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.cull_mode = BaseMaterial3D.CULL_DISABLED
m.albedo_color = Color(0.72, 0.78, 0.88, 0.22)
m.disable_receive_shadows = true
st.set_material(m)
return st.commit()
static func _flake_mesh() -> QuadMesh:
var q := QuadMesh.new()
q.size = Vector2.ONE * 0.07
var m := StandardMaterial3D.new()
m.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.albedo_texture = Campfire._soft_dot()
m.albedo_color = Color(1, 1, 1, 0.9)
m.emission_enabled = true
m.emission = Color(0.25, 0.27, 0.3) # stays visible against a dark sky
m.disable_receive_shadows = true
q.material = m
return q
func _process(delta: float) -> void:
if weather == null:
return
var cam := get_viewport().get_camera_3d()
if cam == null:
return
var cp := cam.global_position
var fwd := -cam.global_basis.z
fwd.y = 0.0
var snow: bool = weather.snows_at(cp)
var amount: float = weather.precip if weather.precip > 0.05 else 0.0
var wind_v: Vector3 = Vector3(0.8, 0.0, 0.3) * float(weather.wind)
# Emit a little ahead of the camera, so the view is full and the area
# behind doesn't waste particles.
_rain.global_position = cp + fwd.normalized() * 5.0 + Vector3(0, 13, 0)
_snow.global_position = cp + fwd.normalized() * 3.0 + Vector3(0, 9, 0)
_set_amount(_rain, 0.0 if snow else amount)
_set_amount(_snow, amount if snow else 0.0)
_rain_pm.direction = (Vector3.DOWN + wind_v * 0.25).normalized()
_snow_pm.gravity = Vector3(0, -0.6, 0) + wind_v * 1.5
_field.visible = amount > 0.0
RenderingServer.global_shader_parameter_set("weather_wet", weather.ground_wet)
RenderingServer.global_shader_parameter_set("weather_snow", weather.snow_cover)
RenderingServer.global_shader_parameter_set("weather_wind", lerpf(0.7, 3.0, weather.wind))
var roofed: bool = player != null and player.survival.roofed
var sheltered: bool = player != null and player.survival.sheltered
_muffle = move_toward(_muffle, 1.0 if roofed else 0.0, delta * 3.0)
_rain_amp = weather.rain_at(cp) * 0.30 * (1.0 - 0.45 * _muffle)
_wind_amp = weather.wind * weather.wind * 0.22 * (0.35 if sheltered else 1.0)
if weather.lightning_count != _seen_strikes:
_seen_strikes = weather.lightning_count
_thunder_delay = _rng.randf_range(0.5, 2.5)
if _thunder_delay >= 0.0:
_thunder_delay -= delta
if _thunder_delay < 0.0:
_thunder_env = 1.0
_fill_audio()
func _set_amount(p: GPUParticles3D, a: float) -> void:
p.amount_ratio = a
p.emitting = a > 0.0
## Synthesises as many audio frames as the generator can take.
func _fill_audio() -> void:
if _playback == null:
return
var n := _playback.get_frames_available()
if n <= 0:
return
var rain_cut := lerpf(0.55, 0.12, _muffle)
var buf := PackedVector2Array()
buf.resize(n)
var thunder_decay := exp(-1.0 / (MIX_RATE * 0.9))
for i in range(n):
var white := _rng.randf() * 2.0 - 1.0
# Rain: band-limited hiss (low-pass, minus a slower low-pass).
_rain_lp += rain_cut * (white - _rain_lp)
_rain_hp += 0.02 * (_rain_lp - _rain_hp)
var rain := (_rain_lp - _rain_hp) * _rain_amp
# Wind: brown noise, swelling in slow gusts.
_brown = clampf(_brown * 0.998 + white * 0.03, -1.0, 1.0)
_gust_t += 1.0 / MIX_RATE
var gust := 0.6 + 0.4 * sin(_gust_t * 0.7) * sin(_gust_t * 0.23 + 1.0)
var wind_s := _brown * _wind_amp * gust * 2.0
# Thunder: a heavier brown rumble with a long decay.
var thunder := 0.0
if _thunder_env > 0.001:
_brown2 = clampf(_brown2 * 0.9995 + white * 0.02, -1.0, 1.0)
thunder = _brown2 * _thunder_env * 2.2
_thunder_env *= thunder_decay
var s := clampf(rain + wind_s + thunder, -1.0, 1.0)
buf[i] = Vector2(s, s)
_playback.push_buffer(buf)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Emberwild dedicated server as a launchd agent (macOS): starts when you log
in, and restarts if it stops.
1. Edit the two paths below (the game folder, and your user name in the log
path) if yours differ.
2. Copy it: cp server/com.emberwild.server.plist ~/Library/LaunchAgents/
3. Start it: launchctl load -w ~/Library/LaunchAgents/com.emberwild.server.plist
Stop it: launchctl unload ~/Library/LaunchAgents/com.emberwild.server.plist
Log: tail -f ~/Library/Logs/emberwild-server.log
server.json and the world are in
~/Library/Application Support/Godot/app_userdata/Emberwild/server/
For admin commands, join the game and use chat (/help), or edit server.json
and stop/start it. (For a console, run server/run_server.sh in Terminal
instead.)
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.emberwild.server</string>
<key>ProgramArguments</key>
<array>
<string>/Users/home/valheimlike/server/run_server.sh</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/home/valheimlike</string>
<key>EnvironmentVariables</key>
<dict>
<!-- launchd's PATH is minimal: point at Godot directly. -->
<key>GODOT</key>
<string>/Applications/Godot.app/Contents/MacOS/Godot</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>15</integer>
<key>StandardOutPath</key>
<string>/Users/home/Library/Logs/emberwild-server.log</string>
<key>StandardErrorPath</key>
<string>/Users/home/Library/Logs/emberwild-server.log</string>
</dict>
</plist>

View File

@@ -0,0 +1,36 @@
# Emberwild dedicated server as a systemd service (Linux).
#
# 1. Put the game where it'll live, e.g. /opt/emberwild (a git clone), and
# install Godot 4.7+ as /usr/local/bin/godot.
# 2. Make a user for it: sudo useradd -r -m -d /var/lib/emberwild emberwild
# 3. Copy this file: sudo cp server/emberwild-server.service /etc/systemd/system/
# and edit User, WorkingDirectory and GODOT below if yours differ.
# 4. Start it, and on boot: sudo systemctl enable --now emberwild-server
# Logs: journalctl -u emberwild-server -f
# Stop / restart: sudo systemctl stop|restart emberwild-server
#
# server.json and the world live in the user's Godot data folder:
# /var/lib/emberwild/.local/share/godot/app_userdata/Emberwild/server/
# Admin commands: join the game and use chat (/help), or edit server.json and
# restart. (The console needs a terminal, so it isn't used here.)
[Unit]
Description=Emberwild dedicated server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=emberwild
WorkingDirectory=/opt/emberwild
Environment=GODOT=/usr/local/bin/godot
# run_server.sh checks the Godot version and imports a fresh copy first.
ExecStart=/opt/emberwild/server/run_server.sh
Restart=on-failure
RestartSec=10
# The world saves within 10 s of any change, so a stop loses at most that.
KillSignal=SIGINT
TimeoutStopSec=20
[Install]
WantedBy=multi-user.target

16
server/run_server.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# Starts a dedicated Emberwild server (no window, no local player).
#
# server/run_server.sh defaults from server.json
# server/run_server.sh --port 24242 --server-name "Paul's island" --password secret
#
# Any flags are passed to the server (see docs/MULTIPLAYER_PLAN.md §2).
# Set GODOT=/path/to/godot to use a particular Godot. Stop it with Ctrl+C.
#
# Before starting it checks that Godot is 4.7 or newer, and imports a fresh
# copy of the project once (tools/godot_env.sh).
cd "$(dirname "$0")/.." || exit 1
. tools/godot_env.sh
exec "$GODOT" --headless --path . -- --server --console "$@"

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