Compare commits
35 Commits
m1b-surviv
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab72f2c05 | ||
|
|
a8eaf0d12e | ||
|
|
bd7d10a829 | ||
|
|
73d27f1dcb | ||
|
|
dbcc6f6e59 | ||
|
|
f93ebe1bea | ||
|
|
5b5dd3d609 | ||
|
|
e03411ffdb | ||
|
|
3389ec9a21 | ||
|
|
657c3140fe | ||
|
|
9528f91e47 | ||
|
|
96aac29606 | ||
|
|
fc58c8d0e1 | ||
|
|
411bfef34a | ||
|
|
24c280d885 | ||
|
|
ab1f417c32 | ||
|
|
2ab79b68c6 | ||
|
|
47fc116839 | ||
|
|
a211fe4c63 | ||
|
|
26be17b05f | ||
|
|
35a656e996 | ||
|
|
8e8f10eb96 | ||
|
|
c87b241b29 | ||
|
|
80786ab796 | ||
|
|
0c18b6d49e | ||
|
|
74cbf96458 | ||
|
|
d489bb57ed | ||
|
|
ae2592822e | ||
|
|
d0f473e57b | ||
|
|
f62f71658f | ||
|
|
89683e78fd | ||
|
|
4630ea5d44 | ||
|
|
f174221268 | ||
|
|
67bf85523d | ||
|
|
fed96ef118 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,7 +3,7 @@
|
|||||||
.import/
|
.import/
|
||||||
*.import
|
*.import
|
||||||
export.cfg
|
export.cfg
|
||||||
export_presets.cfg
|
# (export_presets.cfg is kept: signing passwords live in .godot/export_credentials.cfg)
|
||||||
|
|
||||||
# Exported builds
|
# Exported builds
|
||||||
/build/
|
/build/
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -34,6 +34,25 @@ godot --path .
|
|||||||
godot --headless --path . -- --server
|
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.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
```
|
```
|
||||||
project.godot Godot project definition
|
project.godot Godot project definition
|
||||||
|
|||||||
29
data/fish.json
Normal file
29
data/fish.json
Normal 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
38
data/hints.json
Normal 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."}
|
||||||
|
]
|
||||||
|
}
|
||||||
956
data/items.json
956
data/items.json
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
|||||||
"output": "stone_axe",
|
"output": "stone_axe",
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"station": "",
|
"station": "",
|
||||||
|
"category": "tools",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"stick": 2,
|
"stick": 2,
|
||||||
"flint": 2
|
"flint": 2
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
"output": "stone_pickaxe",
|
"output": "stone_pickaxe",
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"station": "",
|
"station": "",
|
||||||
|
"category": "tools",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"stick": 2,
|
"stick": 2,
|
||||||
"stone": 3,
|
"stone": 3,
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
"output": "campfire",
|
"output": "campfire",
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"station": "",
|
"station": "",
|
||||||
|
"category": "stations",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"stone": 5,
|
"stone": 5,
|
||||||
"stick": 4
|
"stick": 4
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
"output": "workbench",
|
"output": "workbench",
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"station": "",
|
"station": "",
|
||||||
|
"category": "stations",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"wood": 10,
|
"wood": 10,
|
||||||
"flint": 2
|
"flint": 2
|
||||||
@@ -46,6 +50,7 @@
|
|||||||
"output": "club",
|
"output": "club",
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"station": "",
|
"station": "",
|
||||||
|
"category": "weapons",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"wood": 4
|
"wood": 4
|
||||||
}
|
}
|
||||||
@@ -55,6 +60,7 @@
|
|||||||
"output": "chest",
|
"output": "chest",
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"station": "workbench",
|
"station": "workbench",
|
||||||
|
"category": "furniture",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"wood": 10,
|
"wood": 10,
|
||||||
"stick": 4
|
"stick": 4
|
||||||
@@ -65,6 +71,7 @@
|
|||||||
"output": "torch",
|
"output": "torch",
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"station": "",
|
"station": "",
|
||||||
|
"category": "tools",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"stick": 1,
|
"stick": 1,
|
||||||
"fiber": 2
|
"fiber": 2
|
||||||
@@ -75,9 +82,513 @@
|
|||||||
"output": "roasted_mushroom",
|
"output": "roasted_mushroom",
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"station": "campfire",
|
"station": "campfire",
|
||||||
|
"category": "cooking",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"mushroom": 1
|
"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
356
data/tutorial.json
Normal 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
137
docs/HOSTING.md
Normal 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
406
docs/MULTIPLAYER_PLAN.md
Normal 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
318
docs/PROGRESSION_DESIGN.md
Normal 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.
|
||||||
@@ -29,17 +29,44 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d).
|
|||||||
- [x] Crafting (recipe: wood → tools / building pieces) *(M1a)*
|
- [x] Crafting (recipe: wood → tools / building pieces) *(M1a)*
|
||||||
- [x] Pause menu, title screen, world slots, save/load, autosave *(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] Health / stamina / hunger / warmth, day-night cycle, fire fuel, cooking, foraging, torches, death + grave *(M1b)*
|
||||||
- [ ] Weather (rain, storm, snow), wet/shelter *(M1c)*
|
- [x] Weather (rain, storm, snow), wet/shelter *(M1c)*
|
||||||
- [ ] Snap-together building: place a wall/floor, resource cost, basic support rule
|
- [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.
|
- **Done when:** gather → craft → build a tiny shelter, single-player.
|
||||||
|
|
||||||
## M2 — Multiplayer · ~2–4 months
|
## M2 — Multiplayer · ~2–4 months
|
||||||
**Goal:** self-hosted, authoritative, 2–8 players.
|
**Goal:** self-hosted, authoritative, 2–8 players.
|
||||||
- [ ] Refactor state to server-authoritative (single-player becomes loopback server)
|
Detailed plan in `docs/MULTIPLAYER_PLAN.md` (hosting on UDP 24242 via Tailscale
|
||||||
- [ ] Headless dedicated server mode (`--headless -- --server`)
|
or port forwarding, player identity and names, server rules for co-op/PvP, and
|
||||||
- [ ] ENet transport, client prediction for local movement, interpolation for others
|
phases M2.0-M2.7).
|
||||||
- [ ] Replicate resource nodes, inventory, building, health
|
- [x] Server-authoritative online (single-player stays as it is, with no server) *(M2.4)*
|
||||||
- [ ] Persistence: world save/load on the server
|
- [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.
|
- **Done when:** a friend joins your self-hosted server and you build together.
|
||||||
|
|
||||||
## M3 — Addon SDK + sandbox · ~2–3 months
|
## M3 — Addon SDK + sandbox · ~2–3 months
|
||||||
@@ -60,7 +87,62 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d).
|
|||||||
- PvP / raiding rules, building damage
|
- PvP / raiding rules, building damage
|
||||||
- Better UI, settings, controls
|
- Better UI, settings, controls
|
||||||
- Audio, music, weather
|
- 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
|
## Honest overall estimate
|
||||||
A genuinely playable **self-hosted survival game with a working sandboxed addon
|
A genuinely playable **self-hosted survival game with a working sandboxed addon
|
||||||
|
|||||||
@@ -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
|
in it except **Godot itself** (which you install once per machine). No assets to
|
||||||
download — the M0 world is generated in code.
|
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#
|
- **macOS / Windows / Linux:** download the *standard* build (NOT the .NET/C#
|
||||||
build — this project is pure GDScript) from https://godotengine.org/download
|
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
|
- Use **4.7.x or newer 4.x**. The project is on `4.7.2.stable`. **Older
|
||||||
older editor will warn that the project is from a newer version).
|
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,
|
- 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
|
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
|
## Tests
|
||||||
|
Run everything (fails on any failed check or script error):
|
||||||
```bash
|
```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
|
# Boundary: tries to walk/jump out of the valley in 24 directions
|
||||||
godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd
|
godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd
|
||||||
# Terrain: no deep pits / floating water at the boundary, across 8 seeds
|
# Terrain: no deep pits / floating water at the boundary, across 8 seeds
|
||||||
@@ -23,16 +72,26 @@ godot --headless --path . --script res://tests/test_terrain_edges.gd
|
|||||||
godot --headless --path . --script res://tests/test_survival_core.gd
|
godot --headless --path . --script res://tests/test_survival_core.gd
|
||||||
# Save/load round trip + world slots (uses user://test_worlds, not your saves)
|
# Save/load round trip + world slots (uses user://test_worlds, not your saves)
|
||||||
godot --headless --path . --script res://tests/test_save_load.gd
|
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
|
# Survival stats: hunger, warmth, fire fuel, cooking, foraging, death + grave
|
||||||
godot --headless --path . --script res://tests/test_survival_stats.gd
|
godot --headless --path . --script res://tests/test_survival_stats.gd
|
||||||
```
|
```
|
||||||
|
|
||||||
## Save files
|
## Save files
|
||||||
Worlds are per machine, under
|
Worlds are per machine, under
|
||||||
`~/Library/Application Support/Godot/app_userdata/Valheimlike/worlds/` on
|
`~/Library/Application Support/Godot/app_userdata/Emberwild/worlds/` on
|
||||||
macOS (`%APPDATA%\Godot\app_userdata\Valheimlike\worlds\` on Windows). Copy a
|
macOS (`%APPDATA%\Godot\app_userdata\Emberwild\worlds\` on Windows). Copy a
|
||||||
world's folder across to move it to another machine.
|
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
|
## 2. Get the project onto the machine
|
||||||
You have the folder (from the zip, or a git remote). Then:
|
You have the folder (from the zip, or a git remote). Then:
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ Item fields: `id`, `name`, `description`, `stack`, `category`
|
|||||||
| Animals *(M1d)* | weapon | raw meat, hide, bone |
|
| Animals *(M1d)* | weapon | raw meat, hide, bone |
|
||||||
|
|
||||||
Tool **tiers** gate harder resources later (e.g. iron ore needs a tier-2
|
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
|
### 2.4 Crafting & stations
|
||||||
Recipes list inputs, an output and an optional **station** that must be within
|
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
|
### 2.5 Building
|
||||||
- **Placement mode**: holding a placeable shows a ghost preview (green = valid,
|
- **Placement mode**: holding a placeable shows a ghost preview (green = valid,
|
||||||
red = blocked). R rotates it, left-click places it.
|
red = blocked). R rotates it, left-click places it.
|
||||||
- M1a pieces: campfire, workbench, chest.
|
- M1a pieces: campfire, workbench, chest. M1c adds a bed.
|
||||||
- *(M1c)* Snap-together pieces: wood floor, wall, door, roof, stairs, with
|
- *(built in M1c)* Snap-together wood pieces, crafted at a workbench
|
||||||
snap points and Valheim-style **structural support** (stability falls off
|
(shift-click Craft makes 5): floor (2 wood), wall (2), door (4), roof
|
||||||
with distance from the ground; unsupported pieces collapse).
|
(2 wood + 1 fiber), gable (1). Everything is 2 m on a side. A roof rises
|
||||||
- **Shelter detection**: you count as sheltered when rays upward hit a roof and
|
1 m over 2 m, so two roofs from opposite walls of a 4 m hut meet at a ridge,
|
||||||
most sideways rays hit walls. That drives "dry" and "rested" status.
|
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)*
|
### 2.6 Survival stats *(M1b)*
|
||||||
| Stat | Drains from | Restored by | At zero |
|
| Stat | Drains from | Restored by | At zero |
|
||||||
@@ -103,24 +131,141 @@ Built in M1b (`scripts/player/survival.gd`), current numbers:
|
|||||||
- Day/night cycle: 20 real minutes per day by default. The sun, sky, fog and
|
- 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
|
ambient light follow time of day. Nights are dark enough that a campfire or
|
||||||
torch matters.
|
torch matters.
|
||||||
- Weather is a seeded state machine: clear → cloudy → rain → storm, plus
|
- *(built in M1c)* Weather is a function of the world seed and the absolute
|
||||||
**snow** on cold days and always near the snowline. Each state sets the
|
world time (`weather.gd`), so it isn't saved and every M2 client can
|
||||||
cloud cover, fog, wind (grass/trees already read wind), particles and
|
compute the same weather. A slow noise "front" picks the state. Measured
|
||||||
sound, plus a warmth modifier. Transitions blend over about a minute.
|
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)*
|
### 2.8 Creatures & hunting *(built in M1d)*
|
||||||
- **Rabbit**: skittish, fast, gives a little meat.
|
| Animal | HP | Run speed | Notices you | Behaviour | Butchering gives |
|
||||||
- **Deer**: flees on sight or sound, gives meat and hide.
|
|---|---|---|---|---|---|
|
||||||
- **Boar**: neutral; charges if hit; gives meat and hide.
|
| Rabbit | 10 | 7 m/s, zig-zags | 7 m (12 m if you sprint) | flees | 1 raw meat |
|
||||||
- *(later)* **Wolf**: night predator, hunts in packs.
|
| Deer | 40 | 9 m/s | 16 m (30 m if you sprint) | flees, outruns you | 2-3 meat, 1-2 hide |
|
||||||
- Simple state machines (idle / wander / flee / attack) on navigation-free
|
| 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 |
|
||||||
steering over the heightmap. Spawned around the player within budget.
|
|
||||||
|
|
||||||
### 2.9 Weapons & combat *(M1d)*
|
- **Wolf** *(built)*: 45 HP, 8.6 m/s, bites for 9 (every 1.2 s at most).
|
||||||
- Club (hand), wooden spear (thrust; can be thrown), bow + arrows (workbench;
|
Butchering gives 1-2 meat and a wolf pelt. See 2.8a.
|
||||||
needs feathers and flint).
|
- `scripts/creatures/creature.gd` runs each animal's state machine (idle,
|
||||||
- A melee swing is a short shape cast in front of the camera. It costs stamina
|
wander, flee, charge, dead). Steering is over the heightmap with no navmesh:
|
||||||
and has a cooldown. Damage numbers come from item data.
|
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)*
|
### 2.10 Saving & worlds *(built)*
|
||||||
- **One continuous world per save slot.** The title screen offers Continue
|
- **One continuous world per save slot.** The title screen offers Continue
|
||||||
@@ -130,14 +275,16 @@ Built in M1b (`scripts/player/survival.gd`), current numbers:
|
|||||||
- **Autosave** every 2 minutes, on Save & Quit (to the title or desktop) and
|
- **Autosave** every 2 minutes, on Save & Quit (to the title or desktop) and
|
||||||
when the window is closed.
|
when the window is closed.
|
||||||
- Saves live in `user://worlds/<id>/` (on macOS:
|
- 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
|
`meta.json` + `world.json`. Writes are atomic, and the previous save is
|
||||||
kept as `world.json.bak`.
|
kept as `world.json.bak`.
|
||||||
- A save stores only what the player changed: player position and look,
|
- A save stores only what the player changed: player position and look,
|
||||||
inventory, hotbar slot; placed pieces and chest contents; tree and boulder
|
inventory, hotbar slot; placed pieces and chest contents; tree and boulder
|
||||||
damage, stumps, mined rocks; collected pickups. Everything else regenerates
|
damage, stumps, mined rocks; collected pickups. Everything else regenerates
|
||||||
from the seed. Since M1b it also stores stats, time of day, bush regrowth
|
from the seed. Since M1b it also stores stats, time of day, bush regrowth
|
||||||
timers, campfire fuel and graves. *(Later: weather.)*
|
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 *(built in M1b)*
|
### 2.11 Death *(built in M1b)*
|
||||||
- On death you respawn at your bed (or the island spawn) and leave a **grave
|
- On death you respawn at your bed (or the island spawn) and leave a **grave
|
||||||
@@ -163,8 +310,8 @@ Built in M1b (`scripts/player/survival.gd`), current numbers:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **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 |
|
| **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** *(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 |
|
| **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** | weather (rain, storm, snow) + wet/shelter, snap building (floor/wall/roof/door) with support, bed spawn point, resource regrowth | you build a hut and wait out a storm |
|
| **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** | rabbit/deer/boar AI, club/spear/bow, hides, cooked food buffs | you hunt a deer and eat its meat |
|
| **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)
|
## 5. Open decisions (current defaults in bold)
|
||||||
- Thirst stat? **No**, to keep the stats readable (Valheim has none).
|
- Thirst stat? **No**, to keep the stats readable (Valheim has none).
|
||||||
|
|||||||
237
docs/TUTORIAL_DESIGN.md
Normal file
237
docs/TUTORIAL_DESIGN.md
Normal 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
166
docs/WORLDLETS_DESIGN.md
Normal 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
101
export_presets.cfg
Normal 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
12
play.sh
Executable 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 . "$@"
|
||||||
@@ -14,17 +14,27 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
|||||||
|
|
||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="Valheimlike"
|
config/name="Emberwild"
|
||||||
config/description="Low-poly survival game — Valheim look, Rust depth, self-hosted, moddable."
|
config/description="Low-poly survival game — Valheim look, Rust depth, self-hosted, moddable."
|
||||||
run/main_scene="res://scenes/title.tscn"
|
run/main_scene="res://scenes/title.tscn"
|
||||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
|
config/version="0.1.0"
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
|
Boot="*res://scripts/core/boot.gd"
|
||||||
ItemDB="*res://scripts/core/item_db.gd"
|
ItemDB="*res://scripts/core/item_db.gd"
|
||||||
Actions="*res://scripts/core/actions.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"
|
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]
|
[display]
|
||||||
|
|
||||||
@@ -64,8 +74,29 @@ jump={
|
|||||||
|
|
||||||
common/physics_ticks_per_second=30
|
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/valheimlike/releases/latest"
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
|
textures/vram_compression/import_etc2_astc=true
|
||||||
|
|
||||||
lights_and_shadows/directional_shadow/size=8192
|
lights_and_shadows/directional_shadow/size=8192
|
||||||
lights_and_shadows/directional_shadow/soft_shadow_filter_quality=3
|
lights_and_shadows/directional_shadow/soft_shadow_filter_quality=3
|
||||||
anti_aliasing/quality/msaa_3d=2
|
anti_aliasing/quality/msaa_3d=2
|
||||||
|
|||||||
@@ -43,5 +43,5 @@ offset_left = 16.0
|
|||||||
offset_top = 12.0
|
offset_top = 12.0
|
||||||
offset_right = 600.0
|
offset_right = 600.0
|
||||||
offset_bottom = 60.0
|
offset_bottom = 60.0
|
||||||
text = "WASD move · Shift sprint · Space jump · E interact · LMB use/place · RMB eat · 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")
|
label_settings = SubResource("Label_1")
|
||||||
|
|||||||
18
scripts/building/bed.gd
Normal file
18
scripts/building/bed.gd
Normal 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)
|
||||||
177
scripts/building/build_piece.gd
Normal file
177
scripts/building/build_piece.gd
Normal 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()
|
||||||
435
scripts/building/build_system.gd
Normal file
435
scripts/building/build_system.gd
Normal 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
|
||||||
@@ -15,6 +15,8 @@ var _fx: Array[CPUParticles3D] = []
|
|||||||
var _t := 0.0
|
var _t := 0.0
|
||||||
var _noise := FastNoiseLite.new()
|
var _noise := FastNoiseLite.new()
|
||||||
var _lit := true
|
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:
|
func _build() -> void:
|
||||||
add_to_group("station:campfire")
|
add_to_group("station:campfire")
|
||||||
@@ -61,22 +63,47 @@ func add_fuel(seconds: float) -> void:
|
|||||||
_set_lit(true)
|
_set_lit(true)
|
||||||
|
|
||||||
func interact_prompt(player: Node) -> String:
|
func interact_prompt(player: Node) -> String:
|
||||||
var has_fuel: bool = player.inventory.count("wood") + player.inventory.count("stick") > 0
|
var has_fuel: bool = player.inventory.count("wood") + player.inventory.count("stick") + player.inventory.count("bear_fat") > 0
|
||||||
if _lit:
|
if _lit:
|
||||||
var left := "%d:%02d" % [int(fuel) / 60, int(fuel) % 60]
|
var left := "%d:%02d" % [int(fuel) / 60, int(fuel) % 60]
|
||||||
return "Campfire (%s left)%s" % [left, " [E] Add fuel" if has_fuel else " (add wood or sticks)"]
|
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)")
|
return "Campfire (out)%s" % (" [E] Add fuel to light" if has_fuel else " (needs wood or sticks)")
|
||||||
|
|
||||||
func interact(player: Node) -> void:
|
func interact(player: Node) -> void:
|
||||||
Actions.add_fuel(player, self)
|
Actions.add_fuel(player, self)
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
## 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:
|
if not _lit:
|
||||||
return
|
return
|
||||||
fuel -= delta
|
fuel -= seconds * burn_rate()
|
||||||
if fuel <= 0.0:
|
if fuel <= 0.0:
|
||||||
fuel = 0.0
|
fuel = 0.0
|
||||||
_set_lit(false)
|
_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
|
return
|
||||||
_t += delta
|
_t += delta
|
||||||
var f := _noise.get_noise_1d(_t * 10.0)
|
var f := _noise.get_noise_1d(_t * 10.0)
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ extends "res://scripts/building/piece.gd"
|
|||||||
const SLOTS := 16
|
const SLOTS := 16
|
||||||
var inventory := Inventory.new(SLOTS)
|
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:
|
func interact_prompt(_player: Node) -> String:
|
||||||
var used := 0
|
var used := 0
|
||||||
for s in inventory.slots:
|
for s in inventory.slots:
|
||||||
|
|||||||
125
scripts/building/door.gd
Normal file
125
scripts/building/door.gd
Normal 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
20
scripts/building/forge.gd
Normal 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)
|
||||||
@@ -1,22 +1,50 @@
|
|||||||
extends "res://scripts/building/piece.gd"
|
extends "res://scripts/building/piece.gd"
|
||||||
## Grave: left where you died, holding everything you carried. Open it with E
|
## Grave: left where you died, holding everything you carried. Open it with E
|
||||||
## to take your things back; it crumbles once empty.
|
## 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 inventory := Inventory.new(40)
|
||||||
|
var owner_id := ""
|
||||||
|
var owner_name := ""
|
||||||
|
|
||||||
func _build() -> void:
|
func _build() -> void:
|
||||||
inventory.changed.connect(_on_changed)
|
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:
|
func display_name() -> String:
|
||||||
return "Your grave"
|
return "Your grave" if _mine() else "%s's grave" % owner_name
|
||||||
|
|
||||||
func interact_prompt(_player: Node) -> String:
|
func interact_prompt(_player: Node) -> String:
|
||||||
|
if not _mine():
|
||||||
|
return "%s's grave" % owner_name
|
||||||
return "Your grave [E] Recover belongings"
|
return "Your grave [E] Recover belongings"
|
||||||
|
|
||||||
func interact(player: Node) -> void:
|
func interact(player: Node) -> void:
|
||||||
player.open_inventory(self)
|
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:
|
func _on_changed() -> void:
|
||||||
|
if WorldNet.world != null:
|
||||||
|
return # online, the server crumbles it (world_net.gd)
|
||||||
for s in inventory.slots:
|
for s in inventory.slots:
|
||||||
if s != null:
|
if s != null:
|
||||||
return
|
return
|
||||||
|
|||||||
21
scripts/building/hearth.gd
Normal file
21
scripts/building/hearth.gd
Normal 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")
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
extends StaticBody3D
|
extends StaticBody3D
|
||||||
## Base for placed pieces (campfire, workbench, chest, later walls/floors).
|
## Base for placed pieces (campfire, workbench, chest, bed, building pieces).
|
||||||
## Builds the visual from the item's procedural mesh and a box collider from
|
## Builds the visual from the item's procedural mesh and, by default, a box
|
||||||
## the item's footprint. Subclasses add behaviour.
|
## collider from the mesh bounds. Subclasses add behaviour or their own
|
||||||
|
## colliders (_make_collision).
|
||||||
|
|
||||||
var item_id := ""
|
var item_id := ""
|
||||||
var def: Dictionary = {}
|
var def: Dictionary = {}
|
||||||
@@ -9,11 +10,19 @@ var def: Dictionary = {}
|
|||||||
func setup(d: Dictionary) -> void:
|
func setup(d: Dictionary) -> void:
|
||||||
def = d
|
def = d
|
||||||
item_id = d.id
|
item_id = d.id
|
||||||
|
add_to_group("piece")
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
mi.name = "Mesh"
|
mi.name = "Mesh"
|
||||||
mi.mesh = ItemDB.mesh_for(item_id)
|
mi.mesh = _visual_mesh()
|
||||||
mi.material_override = ItemDB.item_material()
|
mi.material_override = ItemDB.item_material()
|
||||||
add_child(mi)
|
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 aabb := mi.mesh.get_aabb()
|
||||||
var cs := CollisionShape3D.new()
|
var cs := CollisionShape3D.new()
|
||||||
var box := BoxShape3D.new()
|
var box := BoxShape3D.new()
|
||||||
@@ -21,7 +30,6 @@ func setup(d: Dictionary) -> void:
|
|||||||
cs.shape = box
|
cs.shape = box
|
||||||
cs.position = aabb.get_center()
|
cs.position = aabb.get_center()
|
||||||
add_child(cs)
|
add_child(cs)
|
||||||
_build()
|
|
||||||
|
|
||||||
func _build() -> void:
|
func _build() -> void:
|
||||||
pass
|
pass
|
||||||
@@ -34,3 +42,7 @@ func interact_prompt(_player: Node) -> String:
|
|||||||
|
|
||||||
func interact(_player: Node) -> void:
|
func interact(_player: Node) -> void:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
## Whether X can take this piece back into your inventory (and why not).
|
||||||
|
func dismantle_block_reason() -> String:
|
||||||
|
return ""
|
||||||
|
|||||||
136
scripts/building/processor.gd
Normal file
136
scripts/building/processor.gd
Normal 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
22
scripts/building/rug.gd
Normal 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)"
|
||||||
12
scripts/building/stonecutter.gd
Normal file
12
scripts/building/stonecutter.gd
Normal 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)
|
||||||
@@ -10,6 +10,17 @@ extends Node
|
|||||||
|
|
||||||
signal notify(text: String) ## short HUD toast ("+4 Wood", "Need an axe")
|
signal notify(text: String) ## short HUD toast ("+4 Wood", "Need an axe")
|
||||||
signal item_gained(id: String, n: int)
|
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 STATION_RANGE := 6.0
|
||||||
const REACH := 3.2
|
const REACH := 3.2
|
||||||
@@ -19,6 +30,7 @@ func _ready() -> void:
|
|||||||
_bind("interact", [KEY_E])
|
_bind("interact", [KEY_E])
|
||||||
_bind("inventory", [KEY_TAB, KEY_I])
|
_bind("inventory", [KEY_TAB, KEY_I])
|
||||||
_bind("rotate_piece", [KEY_R])
|
_bind("rotate_piece", [KEY_R])
|
||||||
|
_bind("dismantle", [KEY_X])
|
||||||
for i in range(8):
|
for i in range(8):
|
||||||
_bind("hotbar_%d" % (i + 1), [KEY_1 + i])
|
_bind("hotbar_%d" % (i + 1), [KEY_1 + i])
|
||||||
_bind_mouse("attack", MOUSE_BUTTON_LEFT)
|
_bind_mouse("attack", MOUSE_BUTTON_LEFT)
|
||||||
@@ -52,6 +64,113 @@ func _give(player: Node, id: String, n: int) -> int:
|
|||||||
notify.emit("Inventory full")
|
notify.emit("Inventory full")
|
||||||
return got
|
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:
|
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
|
return player.global_position.distance_to(target.global_position) <= REACH + 2.5 + extra
|
||||||
|
|
||||||
@@ -76,6 +195,8 @@ func can_craft(player: Node, recipe_id: String) -> bool:
|
|||||||
return inv.has_all(r.inputs) and station_near(player, r.station)
|
return inv.has_all(r.inputs) and station_near(player, r.station)
|
||||||
|
|
||||||
func craft(player: Node, recipe_id: String) -> bool:
|
func craft(player: Node, recipe_id: String) -> bool:
|
||||||
|
if _online():
|
||||||
|
return _ask("craft", [recipe_id])
|
||||||
var r: Dictionary = ItemDB.recipes.get(recipe_id, {})
|
var r: Dictionary = ItemDB.recipes.get(recipe_id, {})
|
||||||
if r.is_empty():
|
if r.is_empty():
|
||||||
return false
|
return false
|
||||||
@@ -97,13 +218,26 @@ func craft(player: Node, recipe_id: String) -> bool:
|
|||||||
for id in r.inputs:
|
for id in r.inputs:
|
||||||
inv.remove(id, int(r.inputs[id]))
|
inv.remove(id, int(r.inputs[id]))
|
||||||
_give(player, r.output, r.count)
|
_give(player, r.output, r.count)
|
||||||
|
crafted.emit(player, recipe_id, String(r.output), int(r.count))
|
||||||
return true
|
return true
|
||||||
|
|
||||||
func pickup(player: Node3D, pickup_node: Node3D) -> bool:
|
func pickup(player: Node3D, pickup_node: Node3D) -> bool:
|
||||||
if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node):
|
if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node):
|
||||||
return false
|
return false
|
||||||
|
if _online():
|
||||||
|
return _ask("pickup", [WorldNet.pickup_key(pickup_node)]) # first come, first served
|
||||||
var id: String = pickup_node.item_id
|
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
|
return false
|
||||||
pickup_node.collected()
|
pickup_node.collected()
|
||||||
return true
|
return true
|
||||||
@@ -112,6 +246,8 @@ func pickup(player: Node3D, pickup_node: Node3D) -> bool:
|
|||||||
func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
|
func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
|
||||||
if not is_instance_valid(node) or node.depleted:
|
if not is_instance_valid(node) or node.depleted:
|
||||||
return false
|
return false
|
||||||
|
if _online():
|
||||||
|
return _ask("hit", [String(node.uid), hit_point])
|
||||||
if player.global_position.distance_to(hit_point) > REACH + 1.0:
|
if player.global_position.distance_to(hit_point) > REACH + 1.0:
|
||||||
return false
|
return false
|
||||||
var held: Dictionary = ItemDB.get_item(player.held_item_id())
|
var held: Dictionary = ItemDB.get_item(player.held_item_id())
|
||||||
@@ -119,12 +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:
|
if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier:
|
||||||
notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
|
notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
|
||||||
return false
|
return false
|
||||||
if not player.survival.use_stamina(6.0):
|
if not player.survival.use_stamina(float(tool.get("stamina", 6.0))): # (the antler pickaxe is lighter)
|
||||||
notify.emit("Too tired")
|
notify.emit("Too tired")
|
||||||
return false
|
return false
|
||||||
var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position)
|
var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position)
|
||||||
for id in drops:
|
for id in drops:
|
||||||
_give(player, id, int(drops[id]))
|
_give(player, id, int(drops[id]))
|
||||||
|
use_held(player)
|
||||||
|
harvested.emit(player, node, bool(node.depleted))
|
||||||
return true
|
return true
|
||||||
|
|
||||||
func can_place(player: Node3D, slot: int) -> bool:
|
func can_place(player: Node3D, slot: int) -> bool:
|
||||||
@@ -136,6 +274,8 @@ func can_place(player: Node3D, slot: int) -> bool:
|
|||||||
func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
|
func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
|
||||||
if not can_place(player, slot):
|
if not can_place(player, slot):
|
||||||
return false
|
return false
|
||||||
|
if _online():
|
||||||
|
return _ask("place", [slot, xf])
|
||||||
if player.global_position.distance_to(xf.origin) > 8.0:
|
if player.global_position.distance_to(xf.origin) > 8.0:
|
||||||
return false
|
return false
|
||||||
var id: String = player.inventory.get_slot(slot).id
|
var id: String = player.inventory.get_slot(slot).id
|
||||||
@@ -144,12 +284,361 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
|
|||||||
notify.emit("Can't place that here")
|
notify.emit("Can't place that here")
|
||||||
return false
|
return false
|
||||||
player.inventory.take_from(slot, 1)
|
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
|
return true
|
||||||
|
|
||||||
func forage(player: Node3D, bush: Node3D) -> bool:
|
func forage(player: Node3D, bush: Node3D) -> bool:
|
||||||
if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
|
if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
|
||||||
return false
|
return false
|
||||||
|
if _online():
|
||||||
|
return _ask("forage", [String(bush.uid)])
|
||||||
var got: Dictionary = bush.harvest()
|
var got: Dictionary = bush.harvest()
|
||||||
for id in got:
|
for id in got:
|
||||||
_give(player, id, int(got[id]))
|
_give(player, id, int(got[id]))
|
||||||
@@ -157,29 +646,45 @@ func forage(player: Node3D, bush: Node3D) -> bool:
|
|||||||
|
|
||||||
## Eats one of the food in `slot`.
|
## Eats one of the food in `slot`.
|
||||||
func eat(player: Node3D, slot: int) -> bool:
|
func eat(player: Node3D, slot: int) -> bool:
|
||||||
|
if _online():
|
||||||
|
return _ask("eat", [slot])
|
||||||
var s = player.inventory.get_slot(slot)
|
var s = player.inventory.get_slot(slot)
|
||||||
if s == null:
|
if s == null:
|
||||||
return false
|
return false
|
||||||
var food: Dictionary = ItemDB.get_item(s.id).get("food", {})
|
var food: Dictionary = ItemDB.get_item(s.id).get("food", {})
|
||||||
if food.is_empty():
|
if food.is_empty():
|
||||||
return false
|
return false
|
||||||
|
if player.survival.sick > 0.0:
|
||||||
|
tell(player, "You feel too sick to eat")
|
||||||
|
return false
|
||||||
if player.survival.hunger >= FULL_HUNGER:
|
if player.survival.hunger >= FULL_HUNGER:
|
||||||
notify.emit("You're full")
|
notify.emit("You're full")
|
||||||
return false
|
return false
|
||||||
player.inventory.take_from(slot, 1)
|
player.inventory.take_from(slot, 1)
|
||||||
|
var eaten := String(s.id)
|
||||||
player.survival.eat(food)
|
player.survival.eat(food)
|
||||||
notify.emit("Ate %s" % ItemDB.item_name(s.id).to_lower())
|
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
|
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.
|
## Feeds a campfire from the player's inventory: wood first, then sticks.
|
||||||
func add_fuel(player: Node3D, fire: Node3D) -> bool:
|
func add_fuel(player: Node3D, fire: Node3D) -> bool:
|
||||||
if not is_instance_valid(fire) or not _in_reach(player, fire):
|
if not is_instance_valid(fire) or not _in_reach(player, fire):
|
||||||
return false
|
return false
|
||||||
|
if _online():
|
||||||
|
return _ask("fuel", [WorldNet.nid(fire)])
|
||||||
if fire.fuel >= fire.MAX_FUEL - 1.0:
|
if fire.fuel >= fire.MAX_FUEL - 1.0:
|
||||||
notify.emit("The fire is fully stoked")
|
notify.emit("The fire is fully stoked")
|
||||||
return false
|
return false
|
||||||
for id in ["wood", "stick", "fiber"]:
|
for id in ["wood", "bear_fat", "stick", "fiber"]:
|
||||||
if player.inventory.count(id) > 0:
|
if player.inventory.count(id) > 0:
|
||||||
player.inventory.remove(id, 1)
|
player.inventory.remove(id, 1)
|
||||||
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
|
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
|
||||||
@@ -193,4 +698,8 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool:
|
|||||||
func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, container: Node3D = null) -> void:
|
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):
|
if container != null and not _in_reach(player, container, 1.0):
|
||||||
return
|
return
|
||||||
|
if _online():
|
||||||
|
if container != null:
|
||||||
|
_ask("transfer", [WorldNet.nid(container), slot, from == player.inventory])
|
||||||
|
return
|
||||||
from.quick_move(slot, to)
|
from.quick_move(slot, to)
|
||||||
|
|||||||
52
scripts/core/boot.gd
Normal file
52
scripts/core/boot.gd
Normal 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
416
scripts/core/guide.gd
Normal 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
137
scripts/core/hints.gd
Normal 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
|
||||||
@@ -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")
|
var db := (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ItemDB")
|
||||||
return db.stack_size(id) if db != null else 50
|
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):
|
static func _norm(stack):
|
||||||
if stack == null or int(stack.count) <= 0:
|
if stack == null or int(stack.count) <= 0:
|
||||||
return null
|
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:
|
func count(id: String) -> int:
|
||||||
var n := 0
|
var n := 0
|
||||||
@@ -55,14 +156,18 @@ func space_for(id: String) -> int:
|
|||||||
return room
|
return room
|
||||||
|
|
||||||
## Adds up to `n` of `id`, topping up existing stacks first. Returns the
|
## Adds up to `n` of `id`, topping up existing stacks first. Returns the
|
||||||
## leftover that didn't fit (0 = everything fitted).
|
## leftover that didn't fit (0 = everything fitted). Food gets a fresh "rot"
|
||||||
func add(id: String, n: int) -> int:
|
## 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 cap := _stack_max(id)
|
||||||
|
var r := rot if rot >= 0.0 else fresh_rot(id)
|
||||||
for s in slots:
|
for s in slots:
|
||||||
if n <= 0:
|
if n <= 0:
|
||||||
break
|
break
|
||||||
if s != null and s.id == id and s.count < cap:
|
if s != null and s.id == id and s.count < cap:
|
||||||
var take := mini(cap - s.count, n)
|
var take := mini(cap - s.count, n)
|
||||||
|
if r >= 0.0:
|
||||||
|
s.rot = _mix_rot(s, r, take)
|
||||||
s.count += take
|
s.count += take
|
||||||
n -= take
|
n -= take
|
||||||
for i in range(slots.size()):
|
for i in range(slots.size()):
|
||||||
@@ -71,10 +176,20 @@ func add(id: String, n: int) -> int:
|
|||||||
if slots[i] == null:
|
if slots[i] == null:
|
||||||
var take := mini(cap, n)
|
var take := mini(cap, n)
|
||||||
slots[i] = {"id": id, "count": take}
|
slots[i] = {"id": id, "count": take}
|
||||||
|
if r >= 0.0:
|
||||||
|
slots[i].rot = r
|
||||||
n -= take
|
n -= take
|
||||||
changed.emit()
|
changed.emit()
|
||||||
return n
|
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
|
## 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.
|
## items longest). Returns false and changes nothing if there aren't enough.
|
||||||
func remove(id: String, n: int) -> bool:
|
func remove(id: String, n: int) -> bool:
|
||||||
@@ -117,10 +232,17 @@ func quick_move(i: int, other: Inventory) -> void:
|
|||||||
var s = slots[i]
|
var s = slots[i]
|
||||||
if s == null:
|
if s == null:
|
||||||
return
|
return
|
||||||
var left := other.add(s.id, s.count)
|
var left := other.add_stack(s)
|
||||||
slots[i] = null if left <= 0 else {"id": s.id, "count": left}
|
slots[i] = null if left <= 0 else _part(s, left)
|
||||||
changed.emit()
|
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
|
## 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:
|
## cursor stack. Left click: pick up / put down / merge / swap. Right click:
|
||||||
## pick up half, or put down one.
|
## pick up half, or put down one.
|
||||||
@@ -133,23 +255,25 @@ func click(i: int, cursor, right: bool):
|
|||||||
if right and s.count > 1:
|
if right and s.count > 1:
|
||||||
var half := int(ceil(s.count / 2.0))
|
var half := int(ceil(s.count / 2.0))
|
||||||
s.count -= half
|
s.count -= half
|
||||||
result = {"id": s.id, "count": half}
|
result = _part(s, half)
|
||||||
else:
|
else:
|
||||||
result = s
|
result = s
|
||||||
slots[i] = null
|
slots[i] = null
|
||||||
elif s == null:
|
elif s == null:
|
||||||
if right:
|
if right and cursor.count > 1:
|
||||||
slots[i] = {"id": cursor.id, "count": 1}
|
slots[i] = _part(cursor, 1)
|
||||||
result = _norm({"id": cursor.id, "count": cursor.count - 1})
|
result = _part(cursor, cursor.count - 1)
|
||||||
else:
|
else:
|
||||||
slots[i] = cursor
|
slots[i] = cursor
|
||||||
result = null
|
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 cap := _stack_max(s.id)
|
||||||
var move: int = 1 if right else cursor.count
|
var move: int = 1 if right else cursor.count
|
||||||
move = mini(move, cap - s.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
|
s.count += move
|
||||||
result = _norm({"id": cursor.id, "count": cursor.count - move})
|
result = _part(cursor, cursor.count - move)
|
||||||
else:
|
else:
|
||||||
slots[i] = cursor
|
slots[i] = cursor
|
||||||
result = s
|
result = s
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ const ICON_SIZE := 96
|
|||||||
var items: Dictionary = {} # id -> definition Dictionary
|
var items: Dictionary = {} # id -> definition Dictionary
|
||||||
var recipes: Dictionary = {} # id -> recipe Dictionary (insertion order kept)
|
var recipes: Dictionary = {} # id -> recipe Dictionary (insertion order kept)
|
||||||
var icons: Dictionary = {} # id -> Texture2D
|
var icons: Dictionary = {} # id -> Texture2D
|
||||||
|
## Fishing by water type (data/fish.json): {"modifiers": {...}, "waters": {type: {...}}}.
|
||||||
|
var fishing: Dictionary = {"modifiers": {}, "waters": {}}
|
||||||
var _item_mat: StandardMaterial3D
|
var _item_mat: StandardMaterial3D
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_load("res://data/items.json", "items", define_item)
|
_load("res://data/items.json", "items", define_item)
|
||||||
_load("res://data/recipes.json", "recipes", define_recipe)
|
_load("res://data/recipes.json", "recipes", define_recipe)
|
||||||
|
_load_fishing("res://data/fish.json")
|
||||||
if DisplayServer.get_name() != "headless":
|
if DisplayServer.get_name() != "headless":
|
||||||
_render_icons.call_deferred()
|
_render_icons.call_deferred()
|
||||||
|
|
||||||
@@ -49,8 +52,54 @@ func define_recipe(r: Dictionary) -> void:
|
|||||||
return
|
return
|
||||||
r.count = int(r.get("count", 1))
|
r.count = int(r.get("count", 1))
|
||||||
r.station = String(r.get("station", ""))
|
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
|
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:
|
func get_item(id: String) -> Dictionary:
|
||||||
return items.get(id, {})
|
return items.get(id, {})
|
||||||
|
|
||||||
@@ -60,6 +109,16 @@ func item_name(id: String) -> String:
|
|||||||
func stack_size(id: String) -> int:
|
func stack_size(id: String) -> int:
|
||||||
return int(items.get(id, {}).get("stack", 1))
|
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:
|
func mesh_for(id: String) -> ArrayMesh:
|
||||||
return ItemMeshes.build(items.get(id, {}).get("mesh", id))
|
return ItemMeshes.build(items.get(id, {}).get("mesh", id))
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
out.sort_custom(func(a, b): return float(a.get("last_played", 0)) > float(b.get("last_played", 0)))
|
||||||
return out
|
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)
|
DirAccess.make_dir_recursive_absolute(root_dir)
|
||||||
var base := world_name.to_lower().replace(" ", "_").validate_filename()
|
var base := world_name.to_lower().replace(" ", "_").validate_filename()
|
||||||
if base.is_empty():
|
if base.is_empty():
|
||||||
@@ -52,9 +54,28 @@ func create_world(world_name: String, seed: int) -> String:
|
|||||||
n += 1
|
n += 1
|
||||||
DirAccess.make_dir_recursive_absolute(_dir(id))
|
DirAccess.make_dir_recursive_absolute(_dir(id))
|
||||||
var now := Time.get_unix_time_from_system()
|
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,
|
"name": world_name, "seed": seed, "created": now, "last_played": now,
|
||||||
"play_time": 0.0, "version": VERSION,
|
"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
|
return id
|
||||||
|
|
||||||
|
|||||||
177
scripts/core/updates.gd
Normal file
177
scripts/core/updates.gd
Normal 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
225
scripts/creatures/bear.gd
Normal 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"
|
||||||
215
scripts/creatures/bear_range.gd
Normal file
215
scripts/creatures/bear_range.gd
Normal 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
|
||||||
493
scripts/creatures/creature.gd
Normal file
493
scripts/creatures/creature.gd
Normal 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)
|
||||||
290
scripts/creatures/creature_meshes.gd
Normal file
290
scripts/creatures/creature_meshes.gd
Normal 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
115
scripts/creatures/crow.gd
Normal 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
187
scripts/creatures/den.gd
Normal 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
95
scripts/creatures/fox.gd
Normal 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
110
scripts/creatures/guard.gd
Normal 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
|
||||||
210
scripts/creatures/old_stag.gd
Normal file
210
scripts/creatures/old_stag.gd
Normal 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)
|
||||||
110
scripts/creatures/projectile.gd
Normal file
110
scripts/creatures/projectile.gd
Normal 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
|
||||||
138
scripts/creatures/spawner.gd
Normal file
138
scripts/creatures/spawner.gd
Normal 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
|
||||||
150
scripts/creatures/thieves.gd
Normal file
150
scripts/creatures/thieves.gd
Normal 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()
|
||||||
79
scripts/creatures/tutorial_wolf.gd
Normal file
79
scripts/creatures/tutorial_wolf.gd
Normal 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
261
scripts/creatures/wolf.gd
Normal 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
|
||||||
438
scripts/creatures/wolf_pack.gd
Normal file
438
scripts/creatures/wolf_pack.gd
Normal 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
161
scripts/fishing/bobber.gd
Normal 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()
|
||||||
126
scripts/fishing/lake_fish.gd
Normal file
126
scripts/fishing/lake_fish.gd
Normal 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)
|
||||||
@@ -19,6 +19,8 @@ const ASH := Color(0.18, 0.17, 0.16)
|
|||||||
const BINDING := Color(0.75, 0.68, 0.48)
|
const BINDING := Color(0.75, 0.68, 0.48)
|
||||||
|
|
||||||
const FIBER := Color(0.45, 0.55, 0.22)
|
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 BERRY := Color(0.62, 0.08, 0.16)
|
||||||
const MUSH_CAP := Color(0.72, 0.30, 0.18)
|
const MUSH_CAP := Color(0.72, 0.30, 0.18)
|
||||||
const MUSH_ROAST := Color(0.40, 0.24, 0.12)
|
const MUSH_ROAST := Color(0.40, 0.24, 0.12)
|
||||||
@@ -49,6 +51,81 @@ static func build(key: String) -> ArrayMesh:
|
|||||||
"roasted_mushroom": m = _mushroom(MUSH_ROAST)
|
"roasted_mushroom": m = _mushroom(MUSH_ROAST)
|
||||||
"torch": m = _torch()
|
"torch": m = _torch()
|
||||||
"grave": m = _grave()
|
"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": m = _berry_bush(true)
|
||||||
"berry_bush_bare": m = _berry_bush(false)
|
"berry_bush_bare": m = _berry_bush(false)
|
||||||
_:
|
_:
|
||||||
@@ -185,6 +262,22 @@ static func _fiber() -> ArrayMesh:
|
|||||||
_box(st, Vector3(0, 0.03, 0), Vector3(0.05, 0.05, 0.08), CLOTH)
|
_box(st, Vector3(0, 0.03, 0), Vector3(0.05, 0.05, 0.08), CLOTH)
|
||||||
return P._commit(st)
|
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:
|
static func _berries() -> ArrayMesh:
|
||||||
var st := P._begin()
|
var st := P._begin()
|
||||||
var r := _rng(43)
|
var r := _rng(43)
|
||||||
@@ -245,3 +338,775 @@ static func _berry_bush(ripe: bool) -> ArrayMesh:
|
|||||||
var d := Vector3(r.randf_range(-1, 1), r.randf_range(0.2, 1), r.randf_range(-1, 1)).normalized() * 0.4
|
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)
|
P._blob(st, c + d, Vector3.ONE * 0.045, n, 0.1, BERRY, r, 0)
|
||||||
return P._commit(st)
|
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
178
scripts/net/avatar.gd
Normal 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
|
||||||
69
scripts/net/character_mesh.gd
Normal file
69
scripts/net/character_mesh.gd
Normal 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
235
scripts/net/chat.gd
Normal 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
265
scripts/net/creature_net.gd
Normal 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
100
scripts/net/identity.gd
Normal 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
832
scripts/net/net.gd
Normal 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
140
scripts/net/net_sync.gd
Normal 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
661
scripts/net/player_net.gd
Normal 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
79
scripts/net/rules.gd
Normal 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
|
||||||
212
scripts/net/server_config.gd
Normal file
212
scripts/net/server_config.gd
Normal 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, [])
|
||||||
110
scripts/net/server_player.gd
Normal file
110
scripts/net/server_player.gd
Normal 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
487
scripts/net/world_net.gd
Normal 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
|
||||||
@@ -11,6 +11,8 @@ extends Node3D
|
|||||||
signal prompt_changed(text: String)
|
signal prompt_changed(text: String)
|
||||||
|
|
||||||
const Campfire = preload("res://scripts/building/campfire.gd")
|
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 REACH := 3.2
|
||||||
const PLACE_REACH := 6.0
|
const PLACE_REACH := 6.0
|
||||||
const SWING_TIME := 0.5
|
const SWING_TIME := 0.5
|
||||||
@@ -26,6 +28,11 @@ var _ghost_bad_mat: StandardMaterial3D
|
|||||||
var _ghost_yaw := 0.0
|
var _ghost_yaw := 0.0
|
||||||
var _place_xf := Transform3D()
|
var _place_xf := Transform3D()
|
||||||
var _place_ok := false
|
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 _prompt := ""
|
||||||
var _held_shown := "~"
|
var _held_shown := "~"
|
||||||
var _torch_light: OmniLight3D
|
var _torch_light: OmniLight3D
|
||||||
@@ -35,7 +42,7 @@ var _t := 0.0
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_ray = RayCast3D.new()
|
_ray = RayCast3D.new()
|
||||||
_ray.target_position = Vector3(0, 0, -REACH)
|
_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.collide_with_areas = true
|
||||||
_ray.add_exception(player)
|
_ray.add_exception(player)
|
||||||
add_child(_ray)
|
add_child(_ray)
|
||||||
@@ -45,6 +52,8 @@ func _ready() -> void:
|
|||||||
_view.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
_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_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
|
_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)
|
add_child(_view)
|
||||||
|
|
||||||
_ghost_ok_mat = _ghost_material(Color(0.3, 1.0, 0.4, 0.45))
|
_ghost_ok_mat = _ghost_material(Color(0.3, 1.0, 0.4, 0.45))
|
||||||
@@ -93,6 +102,9 @@ func _refresh_held() -> void:
|
|||||||
_held_shown = id
|
_held_shown = id
|
||||||
var def := _held_def()
|
var def := _held_def()
|
||||||
_view.visible = not id.is_empty() and not def.has("place")
|
_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:
|
if _view.visible:
|
||||||
_view.mesh = ItemDB.mesh_for(id)
|
_view.mesh = ItemDB.mesh_for(id)
|
||||||
var s := 0.85 if def.category in ["tool", "weapon"] else 0.6
|
var s := 0.85 if def.category in ["tool", "weapon"] else 0.6
|
||||||
@@ -123,6 +135,8 @@ func _target() -> Object:
|
|||||||
if not _ray.is_colliding():
|
if not _ray.is_colliding():
|
||||||
return null
|
return null
|
||||||
var c := _ray.get_collider()
|
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"):
|
if c == null or not c.has_method("interact_prompt"):
|
||||||
return null
|
return null
|
||||||
if c.get("depleted") == true:
|
if c.get("depleted") == true:
|
||||||
@@ -138,7 +152,8 @@ func _physics_process(delta: float) -> void:
|
|||||||
var def := _held_def()
|
var def := _held_def()
|
||||||
if def.has("place"):
|
if def.has("place"):
|
||||||
_update_ghost(def)
|
_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
|
return
|
||||||
_ghost.visible = false
|
_ghost.visible = false
|
||||||
if _torch_light.visible:
|
if _torch_light.visible:
|
||||||
@@ -146,11 +161,51 @@ func _physics_process(delta: float) -> void:
|
|||||||
_torch_light.light_energy = float(def.get("light", {}).get("energy", 1.5)) * (0.85 + 0.15 * sin(_t * 17.0) * sin(_t * 5.3))
|
_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()
|
var t := _target()
|
||||||
if t != null:
|
if t != null:
|
||||||
_set_prompt(t.interact_prompt(player))
|
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"):
|
elif def.has("food"):
|
||||||
_set_prompt("[RMB] Eat %s" % def.name)
|
_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:
|
else:
|
||||||
_set_prompt("")
|
_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:
|
func _update_ghost(def: Dictionary) -> void:
|
||||||
var cam := get_parent() as Camera3D
|
var cam := get_parent() as Camera3D
|
||||||
@@ -163,8 +218,16 @@ func _update_ghost(def: Dictionary) -> void:
|
|||||||
_place_ok = false
|
_place_ok = false
|
||||||
return
|
return
|
||||||
var yaw := player.rotation.y + _ghost_yaw # piece's front (+Z) faces the player
|
var yaw := player.rotation.y + _ghost_yaw # piece's front (+Z) faces the player
|
||||||
_place_xf = Transform3D(Basis(Vector3.UP, yaw), hit.position)
|
if BuildPiece.is_build(def):
|
||||||
_place_ok = player.get_parent().placement_valid(def, _place_xf)
|
# 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.global_transform = _place_xf
|
||||||
_ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat
|
_ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat
|
||||||
_ghost.visible = true
|
_ghost.visible = true
|
||||||
@@ -181,11 +244,24 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
player.select(player.selected - 1)
|
player.select(player.selected - 1)
|
||||||
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||||
player.select(player.selected + 1)
|
player.select(player.selected + 1)
|
||||||
|
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:
|
if event.is_action_pressed("use_item") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||||
if _held_def().has("food"):
|
var d := _held_def()
|
||||||
|
if d.has("food"):
|
||||||
Actions.eat(player, player.selected)
|
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"):
|
elif event.is_action_pressed("rotate_piece"):
|
||||||
_ghost_yaw += deg_to_rad(22.5)
|
# 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"):
|
elif event.is_action_pressed("interact"):
|
||||||
var t := _target()
|
var t := _target()
|
||||||
if t != null and t.has_method("interact"):
|
if t != null and t.has_method("interact"):
|
||||||
@@ -196,14 +272,73 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
Actions.place(player, player.selected, _place_xf, player.get_parent())
|
Actions.place(player, player.selected, _place_xf, player.get_parent())
|
||||||
else:
|
else:
|
||||||
Actions.notify.emit("Can't place that here")
|
Actions.notify.emit("Can't place that here")
|
||||||
|
elif _held_def().has("bow"):
|
||||||
|
_start_draw()
|
||||||
|
elif _held_def().has("fishing"):
|
||||||
|
_fish_click()
|
||||||
else:
|
else:
|
||||||
swing()
|
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.
|
## Swings the held item (or fist). The hit is applied at the peak of the swing.
|
||||||
func swing() -> void:
|
func swing() -> void:
|
||||||
if _swing_cd > 0.0:
|
if _swing_cd > 0.0:
|
||||||
return
|
return
|
||||||
_swing_cd = SWING_TIME
|
_swing_cd = float(Actions.melee_stats(player).cooldown)
|
||||||
var tw := create_tween()
|
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 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))
|
var strike := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(-70)), _view_rest.origin + Vector3(-0.05, -0.05, -0.15))
|
||||||
@@ -213,9 +348,42 @@ func swing() -> void:
|
|||||||
tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE)
|
tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE)
|
||||||
|
|
||||||
func _apply_hit() -> void:
|
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()
|
_ray.force_raycast_update()
|
||||||
if not _ray.is_colliding():
|
if not _ray.is_colliding():
|
||||||
return
|
return
|
||||||
var c := _ray.get_collider()
|
var c := _ray.get_collider()
|
||||||
if c != null and c.is_in_group("harvestable"):
|
if c != null and c.is_in_group("harvestable"):
|
||||||
Actions.harvest_hit(player, c, _ray.get_collision_point())
|
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
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ var selected := 0
|
|||||||
var ui_open := false ## set by the inventory screen; freezes look/move
|
var ui_open := false ## set by the inventory screen; freezes look/move
|
||||||
var hands: Node3D
|
var hands: Node3D
|
||||||
var survival: Node ## health / stamina / hunger / warmth (survival.gd)
|
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)
|
var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const HUNGER_DRAIN := 100.0 / 1500.0 ## full to empty in ~1.25 in-game days
|
|||||||
const STAMINA_REGEN := 24.0
|
const STAMINA_REGEN := 24.0
|
||||||
const STAMINA_DELAY := 1.0
|
const STAMINA_DELAY := 1.0
|
||||||
const WET_SECONDS := 90.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 health := MAX
|
||||||
var stamina := MAX
|
var stamina := MAX
|
||||||
@@ -28,44 +30,149 @@ var wet := 0.0 ## seconds of "Wet" left
|
|||||||
var heat := 0.0 ## 0..1 from nearby fires etc (last step)
|
var heat := 0.0 ## 0..1 from nearby fires etc (last step)
|
||||||
var statuses: Array[String] = []
|
var statuses: Array[String] = []
|
||||||
var dead := false
|
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)
|
||||||
|
|
||||||
var player: CharacterBody3D
|
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 world: Node3D
|
||||||
var _stamina_wait := 0.0
|
var _stamina_wait := 0.0
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if world != null and not world.menu_mode:
|
# (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)
|
step(delta)
|
||||||
|
|
||||||
func step(dt: float) -> void:
|
func step(dt: float) -> void:
|
||||||
if dead:
|
if dead:
|
||||||
return
|
return
|
||||||
var p := player.global_position
|
var p := player.global_position
|
||||||
heat = world.heat_at(p)
|
heat = world.heat_at(p, player)
|
||||||
var target: float = world.air_warmth(p) + heat * 70.0
|
_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:
|
if wet > 0.0:
|
||||||
target -= 22.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
|
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)
|
target = clampf(target, 0.0, MAX)
|
||||||
# Warm up faster than you cool down: getting to a fire should feel good.
|
# Warm up faster than you cool down: getting to a fire should feel good.
|
||||||
warmth = move_toward(warmth, target, (7.0 if target > warmth else 1.6) * dt)
|
# (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 * dt * (1.5 if warmth < 30.0 else 1.0), 0.0)
|
hunger = maxf(hunger - HUNGER_DRAIN * drain_mult * dt * (1.5 if warmth < 30.0 else 1.0), 0.0)
|
||||||
|
|
||||||
if _stamina_wait > 0.0:
|
if _stamina_wait > 0.0:
|
||||||
_stamina_wait -= dt
|
_stamina_wait -= dt
|
||||||
else:
|
else:
|
||||||
stamina = minf(stamina + STAMINA_REGEN * dt, MAX)
|
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
|
var dmg := 0.0
|
||||||
if hunger <= 0.0:
|
if hunger <= 0.0:
|
||||||
dmg += 0.6
|
dmg += 0.6
|
||||||
if warmth < 12.0:
|
if warmth < 12.0:
|
||||||
dmg += 0.5
|
dmg += 0.5
|
||||||
|
if lacking.size() >= 2:
|
||||||
|
dmg += MALNOURISHED_DRAIN
|
||||||
if dmg > 0.0:
|
if dmg > 0.0:
|
||||||
health = maxf(health - dmg * dt, 0.0)
|
health = maxf(health - dmg * dt, 0.0)
|
||||||
elif hunger > 40.0 and warmth > 35.0:
|
if hunger <= 0.0:
|
||||||
health = minf(health + 0.5 * dt, MAX)
|
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()
|
_update_statuses()
|
||||||
changed.emit()
|
changed.emit()
|
||||||
@@ -87,6 +194,24 @@ func _update_statuses() -> void:
|
|||||||
statuses.append("Hungry")
|
statuses.append("Hungry")
|
||||||
if wet > 0.0:
|
if wet > 0.0:
|
||||||
statuses.append("Wet")
|
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
|
## Spends stamina for an action. Returns false (and spends nothing) if you're
|
||||||
## too tired. Continuous costs (sprinting) pass `partial = true`.
|
## too tired. Continuous costs (sprinting) pass `partial = true`.
|
||||||
@@ -99,12 +224,37 @@ func use_stamina(amount: float, partial: bool = false) -> bool:
|
|||||||
|
|
||||||
func eat(food: Dictionary) -> void:
|
func eat(food: Dictionary) -> void:
|
||||||
hunger = minf(hunger + float(food.get("hunger", 0)), MAX)
|
hunger = minf(hunger + float(food.get("hunger", 0)), MAX)
|
||||||
health = minf(health + float(food.get("health", 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()
|
changed.emit()
|
||||||
|
|
||||||
func soak() -> void:
|
func soak() -> void:
|
||||||
wet = WET_SECONDS
|
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:
|
func reset_after_death() -> void:
|
||||||
dead = false
|
dead = false
|
||||||
health = 50.0
|
health = 50.0
|
||||||
@@ -112,10 +262,44 @@ func reset_after_death() -> void:
|
|||||||
hunger = 50.0
|
hunger = 50.0
|
||||||
warmth = 60.0
|
warmth = 60.0
|
||||||
wet = 0.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()
|
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:
|
func to_data() -> Dictionary:
|
||||||
return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet}
|
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:
|
func load_data(d: Dictionary) -> void:
|
||||||
health = float(d.get("health", health))
|
health = float(d.get("health", health))
|
||||||
@@ -123,5 +307,12 @@ func load_data(d: Dictionary) -> void:
|
|||||||
hunger = float(d.get("hunger", hunger))
|
hunger = float(d.get("hunger", hunger))
|
||||||
warmth = float(d.get("warmth", warmth))
|
warmth = float(d.get("warmth", warmth))
|
||||||
wet = float(d.get("wet", 0.0))
|
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
|
dead = false
|
||||||
changed.emit()
|
changed.emit()
|
||||||
|
|||||||
231
scripts/ui/guide.gd
Normal file
231
scripts/ui/guide.gd
Normal 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
63
scripts/ui/hint_card.gd
Normal 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()
|
||||||
@@ -16,8 +16,16 @@ var _toasts: VBoxContainer
|
|||||||
var _screen: Control
|
var _screen: Control
|
||||||
var _menu: Control
|
var _menu: Control
|
||||||
var _bars: Dictionary = {}
|
var _bars: Dictionary = {}
|
||||||
|
var _diet_bars: Dictionary = {}
|
||||||
var _status: Label
|
var _status: Label
|
||||||
var _clock: 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:
|
func bind(p: CharacterBody3D) -> void:
|
||||||
player = p
|
player = p
|
||||||
@@ -93,7 +101,15 @@ func bind(p: CharacterBody3D) -> void:
|
|||||||
player.selected_changed.connect(_refresh)
|
player.selected_changed.connect(_refresh)
|
||||||
player.inventory_requested.connect(_on_inventory_requested)
|
player.inventory_requested.connect(_on_inventory_requested)
|
||||||
player.hands.prompt_changed.connect(func(t): _prompt.text = t)
|
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)
|
Actions.notify.connect(toast)
|
||||||
|
_build_chat(root)
|
||||||
ItemDB.icons_ready.connect(_refresh)
|
ItemDB.icons_ready.connect(_refresh)
|
||||||
_refresh()
|
_refresh()
|
||||||
|
|
||||||
@@ -128,6 +144,29 @@ func _build_stats(root: Control) -> void:
|
|||||||
row.add_child(bar)
|
row.add_child(bar)
|
||||||
box.add_child(row)
|
box.add_child(row)
|
||||||
_bars[spec[0]] = bar
|
_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 = UITheme.label("", 18)
|
||||||
_clock.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
_clock.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||||||
_clock.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
_clock.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||||
@@ -135,11 +174,26 @@ func _build_stats(root: Control) -> void:
|
|||||||
_clock.offset_right = -22
|
_clock.offset_right = -22
|
||||||
_clock.offset_top = 14
|
_clock.offset_top = 14
|
||||||
root.add_child(_clock)
|
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:
|
func _process(_d: float) -> void:
|
||||||
|
_fade_chat()
|
||||||
var s = player.survival
|
var s = player.survival
|
||||||
for k in _bars:
|
for k in _bars:
|
||||||
_bars[k].value = s.get(k)
|
_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).
|
# Warmth bar shifts from icy blue (cold) to orange (warm).
|
||||||
var w: float = s.warmth / 100.0
|
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)
|
var wc := Color(0.35, 0.6, 0.95).lerp(Color(0.95, 0.55, 0.3), w)
|
||||||
@@ -147,7 +201,155 @@ func _process(_d: float) -> void:
|
|||||||
_status.text = " ".join(s.statuses)
|
_status.text = " ".join(s.statuses)
|
||||||
var dn = player.get_parent().day_night
|
var dn = player.get_parent().day_night
|
||||||
if dn != null:
|
if dn != null:
|
||||||
_clock.text = dn.clock_text()
|
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).
|
## Closes the inventory / menu (e.g. on death).
|
||||||
func close_screens() -> void:
|
func close_screens() -> void:
|
||||||
@@ -160,7 +362,19 @@ func _on_inventory_requested(container: Node) -> void:
|
|||||||
|
|
||||||
## ESC: close the inventory if it's open, otherwise toggle the pause menu.
|
## ESC: close the inventory if it's open, otherwise toggle the pause menu.
|
||||||
func _input(event: InputEvent) -> void:
|
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 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():
|
if _screen.is_open():
|
||||||
_screen.close()
|
_screen.close()
|
||||||
elif _menu.is_open():
|
elif _menu.is_open():
|
||||||
|
|||||||
74
scripts/ui/hut_outline.gd
Normal file
74
scripts/ui/hut_outline.gd
Normal 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
|
||||||
@@ -21,6 +21,8 @@ var _chest_grid: GridContainer
|
|||||||
var _chest_title: Label
|
var _chest_title: Label
|
||||||
var _recipe_list: VBoxContainer
|
var _recipe_list: VBoxContainer
|
||||||
var _station_label: Label
|
var _station_label: Label
|
||||||
|
var _worn_label: Label
|
||||||
|
var _worn_btn: Button
|
||||||
var _cursor = null ## stack held on the mouse
|
var _cursor = null ## stack held on the mouse
|
||||||
var _cursor_view: Control
|
var _cursor_view: Control
|
||||||
var _cursor_icon: TextureRect
|
var _cursor_icon: TextureRect
|
||||||
@@ -73,6 +75,20 @@ func bind(p: CharacterBody3D) -> void:
|
|||||||
pass
|
pass
|
||||||
pv.add_child(grid)
|
pv.add_child(grid)
|
||||||
pv.add_child(UITheme.label("Row 1 is your hotbar. Shift-click moves stacks to/from a chest.", 13, UITheme.TEXT_DIM))
|
pv.add_child(UITheme.label("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)
|
pp.add_child(pv)
|
||||||
row.add_child(pp)
|
row.add_child(pp)
|
||||||
|
|
||||||
@@ -84,8 +100,14 @@ func bind(p: CharacterBody3D) -> void:
|
|||||||
crv.add_child(UITheme.label("Crafting", 20))
|
crv.add_child(UITheme.label("Crafting", 20))
|
||||||
_station_label = UITheme.label("", 13, UITheme.TEXT_DIM)
|
_station_label = UITheme.label("", 13, UITheme.TEXT_DIM)
|
||||||
crv.add_child(_station_label)
|
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()
|
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
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
_recipe_list = VBoxContainer.new()
|
_recipe_list = VBoxContainer.new()
|
||||||
_recipe_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
_recipe_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
@@ -123,14 +145,20 @@ func open(chest: Node) -> void:
|
|||||||
visible = true
|
visible = true
|
||||||
player.ui_open = true
|
player.ui_open = true
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
_pick_tab()
|
||||||
_rebuild_chest()
|
_rebuild_chest()
|
||||||
_refresh()
|
_refresh()
|
||||||
|
|
||||||
func close() -> void:
|
func close() -> void:
|
||||||
if _cursor != null:
|
if WorldNet.is_client():
|
||||||
var left: int = player.inventory.add(_cursor.id, _cursor.count)
|
PlayerNet.ask("close") # the server puts the cursor back in our bag
|
||||||
if left > 0 and container != null:
|
_cursor = null
|
||||||
left = container.inventory.add(_cursor.id, left)
|
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
|
_cursor = null
|
||||||
if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh):
|
if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh):
|
||||||
container.inventory.changed.disconnect(_refresh)
|
container.inventory.changed.disconnect(_refresh)
|
||||||
@@ -174,11 +202,16 @@ func _rebuild_chest() -> void:
|
|||||||
_chest_grid.add_child(s)
|
_chest_grid.add_child(s)
|
||||||
_chest_slots.append(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:
|
func _on_player_slot(i: int, right: bool, shift: bool) -> void:
|
||||||
if shift and container != null:
|
if shift and container != null:
|
||||||
Actions.transfer(player, player.inventory, i, container.inventory, container)
|
Actions.transfer(player, player.inventory, i, container.inventory, container)
|
||||||
else:
|
else:
|
||||||
_cursor = player.inventory.click(i, _cursor, right)
|
_cursor = player.inventory.click(i, _cursor, right)
|
||||||
|
if WorldNet.is_client():
|
||||||
|
PlayerNet.ask("inv_click", [0, i, right])
|
||||||
_refresh()
|
_refresh()
|
||||||
|
|
||||||
func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
|
func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
|
||||||
@@ -186,10 +219,19 @@ func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
|
|||||||
return
|
return
|
||||||
if shift:
|
if shift:
|
||||||
Actions.transfer(player, container.inventory, i, player.inventory, container)
|
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:
|
else:
|
||||||
_cursor = container.inventory.click(i, _cursor, right)
|
_cursor = container.inventory.click(i, _cursor, right)
|
||||||
_refresh()
|
_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:
|
func _refresh() -> void:
|
||||||
if player == null:
|
if player == null:
|
||||||
return
|
return
|
||||||
@@ -203,6 +245,8 @@ func _refresh() -> void:
|
|||||||
if _cursor != null:
|
if _cursor != null:
|
||||||
_cursor_icon.texture = ItemDB.icon(_cursor.id)
|
_cursor_icon.texture = ItemDB.icon(_cursor.id)
|
||||||
_cursor_count.text = str(_cursor.count) if _cursor.count > 1 else ""
|
_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()
|
_rebuild_recipes()
|
||||||
|
|
||||||
func _rebuild_recipes() -> void:
|
func _rebuild_recipes() -> void:
|
||||||
@@ -211,12 +255,127 @@ func _rebuild_recipes() -> void:
|
|||||||
for c in _recipe_list.get_children():
|
for c in _recipe_list.get_children():
|
||||||
c.queue_free()
|
c.queue_free()
|
||||||
var stations: Array[String] = []
|
var stations: Array[String] = []
|
||||||
for st in ["workbench", "campfire"]:
|
for st in ["workbench", "campfire", "forge", "stonecutter"]:
|
||||||
if Actions.station_near(player, st):
|
if Actions.station_near(player, st):
|
||||||
stations.append(st)
|
stations.append(st)
|
||||||
_station_label.text = "Nearby: " + (", ".join(stations) if not stations.is_empty() else "nothing (hand crafting only)")
|
_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:
|
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:
|
func _recipe_row(r: Dictionary) -> Control:
|
||||||
var ok := Actions.can_craft(player, r.id)
|
var ok := Actions.can_craft(player, r.id)
|
||||||
@@ -240,7 +399,7 @@ func _recipe_row(r: Dictionary) -> Control:
|
|||||||
req.bbcode_enabled = true
|
req.bbcode_enabled = true
|
||||||
req.fit_content = true
|
req.fit_content = true
|
||||||
req.scroll_active = false
|
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)
|
req.add_theme_font_size_override("normal_font_size", 13)
|
||||||
var parts: Array[String] = []
|
var parts: Array[String] = []
|
||||||
for id in r.inputs:
|
for id in r.inputs:
|
||||||
@@ -252,7 +411,7 @@ func _recipe_row(r: Dictionary) -> Control:
|
|||||||
var col := UITheme.OK if Actions.station_near(player, r.station) else UITheme.BAD
|
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])
|
parts.append("[color=#%s]@ %s[/color]" % [col.to_html(false), r.station])
|
||||||
req.text = " ".join(parts)
|
req.text = " ".join(parts)
|
||||||
req.custom_minimum_size = Vector2(200, 0)
|
req.custom_minimum_size = Vector2(170, 0)
|
||||||
v.add_child(req)
|
v.add_child(req)
|
||||||
h.add_child(v)
|
h.add_child(v)
|
||||||
var b := Button.new()
|
var b := Button.new()
|
||||||
@@ -263,6 +422,10 @@ func _recipe_row(r: Dictionary) -> Control:
|
|||||||
h.add_child(b)
|
h.add_child(b)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
## Shift-click crafts up to five (handy for walls and floors).
|
||||||
func _craft(recipe_id: String) -> void:
|
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()
|
_refresh()
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ const UITheme = preload("res://scripts/ui/theme.gd")
|
|||||||
const CONTROLS := [
|
const CONTROLS := [
|
||||||
["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"],
|
["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"],
|
||||||
["Mouse", "Look"], ["E", "Interact / pick up / add fuel"], ["Left click", "Use tool / place"],
|
["Mouse", "Look"], ["E", "Interact / pick up / add fuel"], ["Left click", "Use tool / place"],
|
||||||
["Right click (holding food)", "Eat"],
|
["Right click (holding food)", "Eat"], ["Right click (spear / cloak)", "Throw / wear"],
|
||||||
["R", "Rotate piece"], ["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting"],
|
["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"],
|
["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"],
|
||||||
["Esc", "This menu (game autosaves every 2 min)"],
|
["Esc", "This menu (game autosaves every 2 min)"],
|
||||||
]
|
]
|
||||||
@@ -19,6 +23,8 @@ var _main: VBoxContainer
|
|||||||
var _controls: VBoxContainer
|
var _controls: VBoxContainer
|
||||||
var _quit_btn: Button
|
var _quit_btn: Button
|
||||||
var _quit_armed := false
|
var _quit_armed := false
|
||||||
|
var _online := false ## on a server: no pausing, no saving here
|
||||||
|
var _players_label: Label
|
||||||
|
|
||||||
func bind(p: CharacterBody3D) -> void:
|
func bind(p: CharacterBody3D) -> void:
|
||||||
player = p
|
player = p
|
||||||
@@ -74,8 +80,49 @@ func bind(p: CharacterBody3D) -> void:
|
|||||||
sens_row.add_child(sens)
|
sens_row.add_child(sens)
|
||||||
_main.add_child(sens_row)
|
_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()
|
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))
|
_main.add_child(_button("Save & Quit to Title", _on_quit_to_title))
|
||||||
_quit_btn = _button("Save & Quit to Desktop", _on_quit)
|
_quit_btn = _button("Save & Quit to Desktop", _on_quit)
|
||||||
else:
|
else:
|
||||||
@@ -83,6 +130,8 @@ func bind(p: CharacterBody3D) -> void:
|
|||||||
_main.add_child(_quit_btn)
|
_main.add_child(_quit_btn)
|
||||||
var note_text := "Autosaves every 2 minutes. World: %s" % Saves.read_meta(Saves.current_id).get("name", "?") if saved \
|
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."
|
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)
|
var note := UITheme.label(note_text, 13, UITheme.TEXT_DIM)
|
||||||
note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
_main.add_child(note)
|
_main.add_child(note)
|
||||||
@@ -121,8 +170,14 @@ func open() -> void:
|
|||||||
_show_controls(false)
|
_show_controls(false)
|
||||||
_disarm_quit()
|
_disarm_quit()
|
||||||
player.ui_open = true
|
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
|
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:
|
func close() -> void:
|
||||||
visible = false
|
visible = false
|
||||||
@@ -144,7 +199,16 @@ func _show_controls(on: bool) -> void:
|
|||||||
func _on_fullscreen(on: bool) -> void:
|
func _on_fullscreen(on: bool) -> void:
|
||||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if on else DisplayServer.WINDOW_MODE_WINDOWED)
|
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if on else DisplayServer.WINDOW_MODE_WINDOWED)
|
||||||
|
|
||||||
|
## 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:
|
func _on_quit() -> void:
|
||||||
|
if _online:
|
||||||
|
Net.leave()
|
||||||
|
get_tree().quit()
|
||||||
|
return
|
||||||
if Saves.has_world():
|
if Saves.has_world():
|
||||||
player.get_parent().save_now()
|
player.get_parent().save_now()
|
||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
@@ -165,4 +229,6 @@ func _on_quit_to_title() -> void:
|
|||||||
|
|
||||||
func _disarm_quit() -> void:
|
func _disarm_quit() -> void:
|
||||||
_quit_armed = false
|
_quit_armed = false
|
||||||
|
if _online:
|
||||||
|
return
|
||||||
_quit_btn.text = "Save & Quit to Desktop" if Saves.has_world() else "Quit to Desktop"
|
_quit_btn.text = "Save & Quit to Desktop" if Saves.has_world() else "Quit to Desktop"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ var _key: Label
|
|||||||
var _style: StyleBoxFlat
|
var _style: StyleBoxFlat
|
||||||
var _selected_style: StyleBoxFlat
|
var _selected_style: StyleBoxFlat
|
||||||
var _stack = null
|
var _stack = null
|
||||||
|
var _wear: ColorRect
|
||||||
|
var _wear_fill: ColorRect
|
||||||
|
|
||||||
func _init(i: int = 0, size_px: int = 64, key_label: String = "") -> void:
|
func _init(i: int = 0, size_px: int = 64, key_label: String = "") -> void:
|
||||||
index = i
|
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
|
_key.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
add_child(_key)
|
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:
|
func set_stack(stack) -> void:
|
||||||
_stack = stack
|
_stack = stack
|
||||||
if stack == null:
|
if stack == null:
|
||||||
_icon.texture = null
|
_icon.texture = null
|
||||||
_count.text = ""
|
_count.text = ""
|
||||||
tooltip_text = ""
|
tooltip_text = ""
|
||||||
|
_wear.visible = false
|
||||||
return
|
return
|
||||||
_icon.texture = ItemDB.icon(stack.id)
|
_icon.texture = ItemDB.icon(stack.id)
|
||||||
_count.text = str(stack.count) if stack.count > 1 else ""
|
_count.text = str(stack.count) if stack.count > 1 else ""
|
||||||
var def := ItemDB.get_item(stack.id)
|
var def := ItemDB.get_item(stack.id)
|
||||||
tooltip_text = "%s\n%s" % [def.get("name", stack.id), def.get("description", "")]
|
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:
|
func set_selected(on: bool) -> void:
|
||||||
add_theme_stylebox_override("panel", _selected_style if on else _style)
|
add_theme_stylebox_override("panel", _selected_style if on else _style)
|
||||||
|
|||||||
@@ -8,10 +8,38 @@ const WORLD_SCENE := "res://scenes/world.tscn"
|
|||||||
var _pages: Dictionary = {} ## name -> Control
|
var _pages: Dictionary = {} ## name -> Control
|
||||||
var _name_edit: LineEdit
|
var _name_edit: LineEdit
|
||||||
var _seed_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 _list: VBoxContainer
|
||||||
var _armed_delete := ""
|
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:
|
func _ready() -> void:
|
||||||
|
if Net.dedicated:
|
||||||
|
return # a headless server has no menu (and doesn't need the backdrop world)
|
||||||
Saves.current_id = ""
|
Saves.current_id = ""
|
||||||
get_tree().auto_accept_quit = true
|
get_tree().auto_accept_quit = true
|
||||||
get_tree().paused = false
|
get_tree().paused = false
|
||||||
@@ -52,20 +80,49 @@ func _ready() -> void:
|
|||||||
col.offset_right = 520
|
col.offset_right = 520
|
||||||
col.add_theme_constant_override("separation", 14)
|
col.add_theme_constant_override("separation", 14)
|
||||||
root.add_child(col)
|
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))
|
col.add_child(UITheme.label("Survive the island. Build a home.", 18, UITheme.TEXT_DIM))
|
||||||
var spacer := Control.new()
|
var spacer := Control.new()
|
||||||
spacer.custom_minimum_size = Vector2(0, 24)
|
spacer.custom_minimum_size = Vector2(0, 24)
|
||||||
col.add_child(spacer)
|
col.add_child(spacer)
|
||||||
|
_build_update_banner(root)
|
||||||
|
|
||||||
_pages["main"] = _main_page()
|
_pages["main"] = _main_page()
|
||||||
_pages["new"] = _new_page()
|
_pages["new"] = _new_page()
|
||||||
_pages["load"] = _load_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():
|
for p in _pages.values():
|
||||||
col.add_child(p)
|
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()
|
_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.
|
## Dev hook, same as world.gd: `-- --shot=/abs/prefix` saves the title pages.
|
||||||
func _maybe_capture() -> void:
|
func _maybe_capture() -> void:
|
||||||
var prefix := ""
|
var prefix := ""
|
||||||
@@ -76,7 +133,18 @@ func _maybe_capture() -> void:
|
|||||||
return
|
return
|
||||||
await get_tree().create_timer(1.0).timeout
|
await get_tree().create_timer(1.0).timeout
|
||||||
print("[title] mouse mode: %s" % ["visible", "hidden", "captured", "confined"][Input.mouse_mode])
|
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)
|
_show(page)
|
||||||
await get_tree().create_timer(4.0 if page == "main" else 0.5).timeout
|
await get_tree().create_timer(4.0 if page == "main" else 0.5).timeout
|
||||||
await RenderingServer.frame_post_draw
|
await RenderingServer.frame_post_draw
|
||||||
@@ -86,17 +154,41 @@ func _maybe_capture() -> void:
|
|||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible:
|
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:
|
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:
|
for k in _pages:
|
||||||
_pages[k].visible = k == page
|
_pages[k].visible = k == page
|
||||||
_armed_delete = ""
|
_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":
|
if page == "load":
|
||||||
_fill_list()
|
_fill_list()
|
||||||
elif page == "new":
|
elif page == "new":
|
||||||
_name_edit.text = _unique_name()
|
_name_edit.text = _unique_name()
|
||||||
_seed_edit.text = ""
|
_seed_edit.text = ""
|
||||||
|
_rot_pick.select(0)
|
||||||
_name_edit.grab_focus()
|
_name_edit.grab_focus()
|
||||||
_name_edit.select_all()
|
_name_edit.select_all()
|
||||||
|
|
||||||
@@ -114,16 +206,365 @@ func _main_page() -> Control:
|
|||||||
var v := VBoxContainer.new()
|
var v := VBoxContainer.new()
|
||||||
v.add_theme_constant_override("separation", 10)
|
v.add_theme_constant_override("separation", 10)
|
||||||
var worlds := Saves.list_worlds()
|
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]
|
var w: Dictionary = worlds[0]
|
||||||
v.add_child(_button("Continue: %s" % w.name, _play.bind(w.id)))
|
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")))
|
v.add_child(_button("New World", _show.bind("new")))
|
||||||
var lb := _button("Load World", _show.bind("load"))
|
var lb := _button("Load World", _show.bind("load"))
|
||||||
lb.disabled = worlds.is_empty()
|
lb.disabled = worlds.is_empty()
|
||||||
v.add_child(lb)
|
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))
|
v.add_child(_button("Quit", get_tree().quit))
|
||||||
return v
|
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:
|
func _new_page() -> Control:
|
||||||
var p := UITheme.panel()
|
var p := UITheme.panel()
|
||||||
var v := VBoxContainer.new()
|
var v := VBoxContainer.new()
|
||||||
@@ -142,6 +583,12 @@ func _new_page() -> Control:
|
|||||||
_seed_edit.custom_minimum_size = Vector2(360, 40)
|
_seed_edit.custom_minimum_size = Vector2(360, 40)
|
||||||
_seed_edit.text_submitted.connect(func(_t): _create())
|
_seed_edit.text_submitted.connect(func(_t): _create())
|
||||||
v.add_child(_seed_edit)
|
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()
|
var h := HBoxContainer.new()
|
||||||
h.add_theme_constant_override("separation", 10)
|
h.add_theme_constant_override("separation", 10)
|
||||||
h.add_child(_button("Create & Play", _create, 170))
|
h.add_child(_button("Create & Play", _create, 170))
|
||||||
@@ -231,8 +678,94 @@ func _create() -> void:
|
|||||||
world_name = _unique_name()
|
world_name = _unique_name()
|
||||||
var seed_text := _seed_edit.text.strip_edges()
|
var seed_text := _seed_edit.text.strip_edges()
|
||||||
var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi())
|
var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi())
|
||||||
_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:
|
func _play(id: String) -> void:
|
||||||
Saves.current_id = id
|
Saves.current_id = id
|
||||||
get_tree().change_scene_to_file(WORLD_SCENE)
|
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
451
scripts/world/barrow.gd
Normal 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)
|
||||||
@@ -28,6 +28,11 @@ var _sky_timer := 0.0
|
|||||||
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 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 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 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:
|
func setup(world_sun: DirectionalLight3D, environment: Environment) -> void:
|
||||||
sun = world_sun
|
sun = world_sun
|
||||||
@@ -52,6 +57,17 @@ func clock_text() -> String:
|
|||||||
func is_night() -> bool:
|
func is_night() -> bool:
|
||||||
return daylight < 0.3
|
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:
|
func advance(seconds: float) -> void:
|
||||||
time += seconds / day_length
|
time += seconds / day_length
|
||||||
while time >= 1.0:
|
while time >= 1.0:
|
||||||
@@ -78,37 +94,59 @@ func apply(force: bool) -> void:
|
|||||||
daylight = smoothstep(-0.2, 0.25, 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
|
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.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)
|
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.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.visible = sun.light_energy > 0.01
|
||||||
sun.shadow_enabled = sun.visible
|
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()
|
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)
|
moon.global_basis = Basis.looking_at(-moon_dir, Vector3.FORWARD)
|
||||||
moon.light_energy = 0.38 * (1.0 - daylight)
|
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.visible = moon.light_energy > 0.01
|
||||||
moon.shadow_enabled = moon.visible and not sun.visible
|
moon.shadow_enabled = moon.visible and not sun.visible
|
||||||
|
|
||||||
env.ambient_light_energy = lerpf(0.3, 1.0, daylight)
|
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)
|
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)
|
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()
|
_sky_timer -= get_process_delta_time()
|
||||||
if not force and _sky_timer > 0.0:
|
if not force and _sky_timer > 0.0 and w_flash < 0.05 and not _flash_shown:
|
||||||
return
|
return
|
||||||
|
_flash_shown = w_flash >= 0.05
|
||||||
_sky_timer = 0.25
|
_sky_timer = 0.25
|
||||||
var cols: Array = []
|
var cols: Array = []
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
var dayc: Color = DAY_COLS[i].lerp(DUSK_COLS[i], low * daylight)
|
var dayc: Color = DAY_COLS[i].lerp(DUSK_COLS[i], low * daylight * (1.0 - overcast * 0.7))
|
||||||
cols.append(NIGHT_COLS[i].lerp(dayc, daylight))
|
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]
|
env.fog_light_color = cols[2]
|
||||||
if sky_mat != null:
|
if sky_mat != null:
|
||||||
sky_mat.set_shader_parameter("zenith_col", cols[0])
|
sky_mat.set_shader_parameter("zenith_col", cols[0])
|
||||||
sky_mat.set_shader_parameter("horizon_col", cols[1])
|
sky_mat.set_shader_parameter("horizon_col", cols[1])
|
||||||
sky_mat.set_shader_parameter("energy", lerpf(0.35, 1.0, daylight))
|
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))
|
sky_mat.set_shader_parameter("star_strength", (1.0 - smoothstep(0.0, 0.35, daylight)) * (1.0 - overcast))
|
||||||
sky_mat.set_shader_parameter("cloud_lit", Color(0.16, 0.18, 0.24).lerp(Color(1, 1, 1), daylight))
|
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))
|
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.
|
## 0..100 "how warm the air is" before fires, shelter and clothes.
|
||||||
|
|||||||
288
scripts/world/hazards.gd
Normal file
288
scripts/world/hazards.gd
Normal 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
|
||||||
@@ -25,11 +25,32 @@ func setup(id: String, mesh: Mesh, material: Material, radius: float) -> void:
|
|||||||
add_child(cs)
|
add_child(cs)
|
||||||
|
|
||||||
func interact_prompt(_player: Node) -> String:
|
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:
|
func interact(player: Node) -> void:
|
||||||
Actions.pickup(player, self)
|
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:
|
func collected() -> void:
|
||||||
|
if taken:
|
||||||
|
return
|
||||||
taken = true
|
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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
|
|||||||
const ResourceNodeScript = preload("res://scripts/world/resource_node.gd")
|
const ResourceNodeScript = preload("res://scripts/world/resource_node.gd")
|
||||||
const PickupScript = preload("res://scripts/world/pickup.gd")
|
const PickupScript = preload("res://scripts/world/pickup.gd")
|
||||||
const ForageScript = preload("res://scripts/world/forage.gd")
|
const ForageScript = preload("res://scripts/world/forage.gd")
|
||||||
|
const ItemMeshes = preload("res://scripts/items/item_meshes.gd")
|
||||||
|
|
||||||
const CELL := 48.0
|
const CELL := 48.0
|
||||||
const GRASS_TILE := 12.0
|
const GRASS_TILE := 12.0
|
||||||
@@ -42,6 +43,18 @@ var _resources: Node3D ## harvestable trees/boulders (each its own body)
|
|||||||
var resource_nodes: Dictionary = {} ## uid -> resource node
|
var resource_nodes: Dictionary = {} ## uid -> resource node
|
||||||
var pickups: Dictionary = {} ## uid -> pickup
|
var pickups: Dictionary = {} ## uid -> pickup
|
||||||
var forage: Dictionary = {} ## uid -> berry bush (forage.gd)
|
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 _bark_mat: StandardMaterial3D
|
||||||
var _leaf_mat: ShaderMaterial
|
var _leaf_mat: ShaderMaterial
|
||||||
@@ -101,12 +114,86 @@ func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void:
|
|||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if _player == null:
|
if _player == null:
|
||||||
return
|
return
|
||||||
|
_regrow_tick += delta
|
||||||
|
if _regrow_tick >= 1.0:
|
||||||
|
skip_time(_regrow_tick)
|
||||||
|
_regrow_tick = 0.0
|
||||||
_grass_timer -= delta
|
_grass_timer -= delta
|
||||||
if _grass_timer > 0.0:
|
if _grass_timer > 0.0:
|
||||||
return
|
return
|
||||||
_grass_timer = 0.2
|
_grass_timer = 0.2
|
||||||
_update_grass(grass_tiles_per_update)
|
_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 --------------------------------------------------------------
|
# --- materials --------------------------------------------------------------
|
||||||
|
|
||||||
func _make_materials() -> void:
|
func _make_materials() -> void:
|
||||||
@@ -205,6 +292,7 @@ func _resource_node(kind: String, xf: Transform3D) -> StaticBody3D:
|
|||||||
node.drops_final = {"stone": [3, 5], "flint": [1, 2]}
|
node.drops_final = {"stone": [3, 5], "flint": [1, 2]}
|
||||||
node.chip_color = Color(0.55, 0.54, 0.52)
|
node.chip_color = Color(0.55, 0.54, 0.52)
|
||||||
node.hp = node.max_hp
|
node.hp = node.max_hp
|
||||||
|
node.depleted_now.connect(_on_depleted)
|
||||||
_resources.add_child(node)
|
_resources.add_child(node)
|
||||||
node.global_position = xf.origin
|
node.global_position = xf.origin
|
||||||
return node
|
return node
|
||||||
@@ -390,6 +478,19 @@ func _scatter_rocks() -> void:
|
|||||||
|
|
||||||
_bind_nodes(pending, _flush(groups, meshes, true, "Rocks"))
|
_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
|
## Places one rock. Passing `pending` makes it a mineable boulder (if it's
|
||||||
## inside the walkable area); otherwise it just gets plain collision.
|
## 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,
|
func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h: float, n: Vector3,
|
||||||
@@ -405,6 +506,12 @@ func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h:
|
|||||||
shape.points = pts
|
shape.points = pts
|
||||||
if pending != null and terrain.inside_boundary(p.x, p.y, 0.0):
|
if pending != null and terrain.inside_boundary(p.x, p.y, 0.0):
|
||||||
var node := _resource_node("rock", Transform3D(b, pos))
|
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()
|
var cs := CollisionShape3D.new()
|
||||||
cs.shape = shape
|
cs.shape = shape
|
||||||
cs.basis = b.orthonormalized()
|
cs.basis = b.orthonormalized()
|
||||||
@@ -438,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)))
|
_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")
|
_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:
|
func _place_logs() -> void:
|
||||||
var root := Node3D.new()
|
var root := Node3D.new()
|
||||||
root.name = "Logs"
|
root.name = "Logs"
|
||||||
@@ -503,10 +629,13 @@ func _scatter_pickups() -> void:
|
|||||||
var rng := RandomNumberGenerator.new()
|
var rng := RandomNumberGenerator.new()
|
||||||
rng.seed = world_seed + 6
|
rng.seed = world_seed + 6
|
||||||
# Order matters: new kinds go at the END so existing saves keep their ids.
|
# Order matters: new kinds go at the END so existing saves keep their ids.
|
||||||
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110}
|
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150,
|
||||||
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25}
|
"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:
|
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 placed := 0
|
||||||
var tries := 0
|
var tries := 0
|
||||||
while placed < want[id] and tries < 20000:
|
while placed < want[id] and tries < 20000:
|
||||||
@@ -530,15 +659,36 @@ func _scatter_pickups() -> void:
|
|||||||
chance = 0.06 + shore * 0.9 + (1.0 - n.y) * 2.0
|
chance = 0.06 + shore * 0.9 + (1.0 - n.y) * 2.0
|
||||||
"mushroom":
|
"mushroom":
|
||||||
chance = _forest(x, z) * 0.7 # deep forest only
|
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.
|
# 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
|
chance += 0.5
|
||||||
if keep > chance:
|
if keep > chance:
|
||||||
continue
|
continue
|
||||||
var pk: Area3D = PickupScript.new()
|
var pk: Area3D = PickupScript.new()
|
||||||
pk.setup(id, mesh, ItemDB.item_material(), radius[id])
|
pk.setup(id, mesh, ItemDB.item_material(), radius[id])
|
||||||
|
if id == "fiber":
|
||||||
|
pk.amount = 2
|
||||||
pk.uid = "p%d" % pickups.size()
|
pk.uid = "p%d" % pickups.size()
|
||||||
pickups[pk.uid] = pk
|
pickups[pk.uid] = pk
|
||||||
|
pk.taken_now.connect(_on_taken)
|
||||||
root.add_child(pk)
|
root.add_child(pk)
|
||||||
pk.global_transform = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h - 0.02, z))
|
pk.global_transform = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h - 0.02, z))
|
||||||
placed += 1
|
placed += 1
|
||||||
@@ -627,6 +777,12 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
|
|||||||
var flower_xf: Array = []
|
var flower_xf: Array = []
|
||||||
var flower_col: Array = []
|
var flower_col: Array = []
|
||||||
var steps := int(GRASS_TILE / grass_spacing)
|
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 iz in range(steps):
|
||||||
for ix in range(steps):
|
for ix in range(steps):
|
||||||
var x := ox + (ix + rng.randf()) * grass_spacing
|
var x := ox + (ix + rng.randf()) * grass_spacing
|
||||||
@@ -641,6 +797,8 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
|
|||||||
var n: Vector3 = terrain.sample_normal(x, z)
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
if n.y < 0.72:
|
if n.y < 0.72:
|
||||||
continue
|
continue
|
||||||
|
if not cuts.is_empty() and _under_cut(cuts, x, z):
|
||||||
|
continue
|
||||||
var meadow := _meadow_noise.get_noise_2d(x, z)
|
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)
|
var dens := smoothstep(-0.45, 0.1, meadow) * (1.0 - smoothstep(0.85, 0.72, n.y) * 0.7)
|
||||||
if keep > dens:
|
if keep > dens:
|
||||||
@@ -666,6 +824,43 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
|
|||||||
_grass_root.add_child(node)
|
_grass_root.add_child(node)
|
||||||
return 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:
|
func _fade(gi: GeometryInstance3D, dist: float) -> void:
|
||||||
gi.visibility_range_end = dist
|
gi.visibility_range_end = dist
|
||||||
gi.visibility_range_end_margin = 10.0
|
gi.visibility_range_end_margin = 10.0
|
||||||
|
|||||||
@@ -29,10 +29,57 @@ var rng := RandomNumberGenerator.new()
|
|||||||
|
|
||||||
var _shake := 0.0
|
var _shake := 0.0
|
||||||
var _shake_axis := Vector3.RIGHT
|
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:
|
func display_name() -> String:
|
||||||
|
if is_iron:
|
||||||
|
return "Rusty boulder (iron ore)"
|
||||||
return "Tree" if kind == "tree" else "Boulder"
|
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:
|
func interact_prompt(player: Node) -> String:
|
||||||
var tool: Dictionary = ItemDB.get_item(player.held_item_id()).get("tool", {})
|
var tool: Dictionary = ItemDB.get_item(player.held_item_id()).get("tool", {})
|
||||||
var verb := "Chop" if kind == "tree" else "Mine"
|
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.
|
## Applies one hit. Returns the items earned by this hit.
|
||||||
func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
|
func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
|
||||||
var out := {}
|
var out := {}
|
||||||
hp -= damage
|
|
||||||
for id in drops_per_hit:
|
for id in drops_per_hit:
|
||||||
out[id] = drops_per_hit[id]
|
out[id] = drops_per_hit[id]
|
||||||
_burst(point, chip_color, 14)
|
if hp - damage <= 0.0:
|
||||||
if hp <= 0.0:
|
|
||||||
depleted = true
|
|
||||||
for id in drops_final:
|
for id in drops_final:
|
||||||
var r: Array = drops_final[id]
|
var r: Array = drops_final[id]
|
||||||
out[id] = out.get(id, 0) + rng.randi_range(int(r[0]), int(r[1]))
|
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)
|
_deplete(from)
|
||||||
else:
|
else:
|
||||||
_shake = 1.0
|
_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
|
_shake_axis = away.normalized().cross(Vector3.UP) if away.length() > 0.01 else Vector3.RIGHT
|
||||||
if kind == "rock":
|
if kind == "rock":
|
||||||
_set_instance(_rock_xf())
|
_set_instance(_rock_xf())
|
||||||
return out
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if _shake <= 0.0 or depleted:
|
if _shake <= 0.0 or depleted:
|
||||||
@@ -86,6 +143,8 @@ func _rock_xf() -> Transform3D:
|
|||||||
func _set_instance(xf: Transform3D) -> void:
|
func _set_instance(xf: Transform3D) -> void:
|
||||||
if mmi != null and index >= 0:
|
if mmi != null and index >= 0:
|
||||||
mmi.multimesh.set_instance_transform(index, xf)
|
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
|
## Restores saved damage when a world loads. hp <= 0 = already felled/mined
|
||||||
## (no animation, no drops).
|
## (no animation, no drops).
|
||||||
@@ -107,7 +166,36 @@ func _deplete(from: Vector3, animate: bool = true) -> void:
|
|||||||
else:
|
else:
|
||||||
if animate:
|
if animate:
|
||||||
_burst(global_position + Vector3(0, 0.8, 0), chip_color, 40)
|
_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:
|
func _fell(from: Vector3, animate: bool) -> void:
|
||||||
var world := get_parent()
|
var world := get_parent()
|
||||||
@@ -139,9 +227,11 @@ func _make_stump(world: Node) -> void:
|
|||||||
stump.material_override = stump_material
|
stump.material_override = stump_material
|
||||||
world.add_child(stump)
|
world.add_child(stump)
|
||||||
stump.global_position = base_xf.origin
|
stump.global_position = base_xf.origin
|
||||||
|
_stump = stump
|
||||||
# Keep collision as a short stump.
|
# Keep collision as a short stump.
|
||||||
for c in get_children():
|
for c in get_children():
|
||||||
if c is CollisionShape3D and c.shape is CapsuleShape3D:
|
if c is CollisionShape3D and c.shape is CapsuleShape3D:
|
||||||
|
_orig_shapes.append([c, c.shape, c.position])
|
||||||
var cyl := CylinderShape3D.new()
|
var cyl := CylinderShape3D.new()
|
||||||
cyl.radius = (c.shape as CapsuleShape3D).radius
|
cyl.radius = (c.shape as CapsuleShape3D).radius
|
||||||
cyl.height = 0.6
|
cyl.height = 0.6
|
||||||
|
|||||||
393
scripts/world/standing_stones.gd
Normal file
393
scripts/world/standing_stones.gd
Normal 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)
|
||||||
@@ -69,9 +69,13 @@ var _heights := PackedFloat32Array()
|
|||||||
var _normals := PackedVector3Array()
|
var _normals := PackedVector3Array()
|
||||||
|
|
||||||
func _ready() -> void:
|
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")
|
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()
|
noise_seed = saves.world_seed()
|
||||||
_build()
|
_build()
|
||||||
|
|
||||||
|
|||||||
233
scripts/world/weather.gd
Normal file
233
scripts/world/weather.gd
Normal 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
217
scripts/world/weather_fx.gd
Normal 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
50
server/com.emberwild.server.plist
Normal file
50
server/com.emberwild.server.plist
Normal 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>
|
||||||
36
server/emberwild-server.service
Normal file
36
server/emberwild-server.service
Normal 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
16
server/run_server.sh
Executable 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 "$@"
|
||||||
@@ -5,6 +5,22 @@
|
|||||||
|
|
||||||
uniform sampler2D noise_tex : filter_linear_mipmap, repeat_enable;
|
uniform sampler2D noise_tex : filter_linear_mipmap, repeat_enable;
|
||||||
|
|
||||||
|
// Set every frame by weather_fx.gd (declared in project.godot [shader_globals]).
|
||||||
|
global uniform float weather_wet; // 0..1 rain-soaked ground
|
||||||
|
global uniform float weather_snow; // 0..1 settled snow
|
||||||
|
global uniform float weather_wind; // wind multiplier, ~1 on a calm day
|
||||||
|
|
||||||
|
// Wet surfaces: darker and glossier.
|
||||||
|
vec3 wet_albedo(vec3 c, float wet) {
|
||||||
|
return c * (1.0 - 0.35 * wet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settled snow on upward-facing surfaces, patchy where it's thin.
|
||||||
|
float snow_mask(float up, float patch) {
|
||||||
|
float s = weather_snow * 1.25 - (1.0 - patch) * 0.45;
|
||||||
|
return clamp(s, 0.0, 1.0) * smoothstep(0.55, 0.85, up);
|
||||||
|
}
|
||||||
|
|
||||||
float nz(vec2 p) {
|
float nz(vec2 p) {
|
||||||
return texture(noise_tex, p).r;
|
return texture(noise_tex, p).r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ void vertex() {
|
|||||||
float phase = dot(inst_pos.xz, vec2(0.13, 0.17));
|
float phase = dot(inst_pos.xz, vec2(0.13, 0.17));
|
||||||
float gust = nz(inst_pos.xz * 0.012 + vec2(TIME * 0.02, TIME * 0.013));
|
float gust = nz(inst_pos.xz * 0.012 + vec2(TIME * 0.02, TIME * 0.013));
|
||||||
vec3 sway = vec3(sin(TIME * 0.9 + phase), 0.0, cos(TIME * 0.7 + phase * 1.3) * 0.6);
|
vec3 sway = vec3(sin(TIME * 0.9 + phase), 0.0, cos(TIME * 0.7 + phase * 1.3) * 0.6);
|
||||||
sway *= wind_strength * h * (0.4 + gust);
|
float wk = min(weather_wind, 2.2); // storms bend crowns, but not into the ground
|
||||||
sway += vec3(sin(TIME * 3.7 + dot(VERTEX, vec3(3.1, 1.7, 2.3)) + phase)) * flutter * clamp(VERTEX.y, 0.0, 1.0);
|
sway *= wind_strength * wk * h * (0.4 + gust);
|
||||||
|
sway += vec3(sin(TIME * 3.7 * (0.7 + 0.3 * wk) + dot(VERTEX, vec3(3.1, 1.7, 2.3)) + phase)) * flutter * wk * clamp(VERTEX.y, 0.0, 1.0);
|
||||||
VERTEX += (inverse(MODEL_MATRIX) * vec4(sway, 0.0)).xyz;
|
VERTEX += (inverse(MODEL_MATRIX) * vec4(sway, 0.0)).xyz;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,10 @@ void fragment() {
|
|||||||
float v = nz(inst_pos.xz * 0.045);
|
float v = nz(inst_pos.xz * 0.045);
|
||||||
vec3 tint = mix(vec3(0.88, 1.0, 0.85), vec3(1.12, 1.04, 0.78), v);
|
vec3 tint = mix(vec3(0.88, 1.0, 0.85), vec3(1.12, 1.04, 0.78), v);
|
||||||
vec3 col = to_linear(COLOR.rgb) * tint;
|
vec3 col = to_linear(COLOR.rgb) * tint;
|
||||||
|
// Snow on the upper faces of the crowns.
|
||||||
|
float up = (INV_VIEW_MATRIX * vec4(NORMAL, 0.0)).y;
|
||||||
|
col = mix(col, vec3(0.86, 0.89, 0.93), snow_mask(up, v) * 0.9);
|
||||||
|
col = wet_albedo(col, weather_wet * 0.5);
|
||||||
ALBEDO = col;
|
ALBEDO = col;
|
||||||
ROUGHNESS = 0.85;
|
ROUGHNESS = 0.85;
|
||||||
SPECULAR = 0.3;
|
SPECULAR = 0.3;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ void vertex() {
|
|||||||
vec2 wd = normalize(wind_dir);
|
vec2 wd = normalize(wind_dir);
|
||||||
float gust = nz(wp.xz * 0.015 - TIME * 0.05 * wd);
|
float gust = nz(wp.xz * 0.015 - TIME * 0.05 * wd);
|
||||||
float sway = sin(TIME * 1.8 + wp.x * 0.35 + wp.z * 0.25) * 0.35 + gust * 1.3 - 0.4;
|
float sway = sin(TIME * 1.8 + wp.x * 0.35 + wp.z * 0.25) * 0.35 + gust * 1.3 - 0.4;
|
||||||
float k = wind_strength * hfac * hfac;
|
float k = wind_strength * weather_wind * hfac * hfac;
|
||||||
vec3 off = vec3(wd.x * sway * k, -abs(sway) * k * 0.3, wd.y * sway * k);
|
vec3 off = vec3(wd.x * sway * k, -abs(sway) * k * 0.3, wd.y * sway * k);
|
||||||
VERTEX += (inverse(MODEL_MATRIX) * vec4(off, 0.0)).xyz;
|
VERTEX += (inverse(MODEL_MATRIX) * vec4(off, 0.0)).xyz;
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,10 @@ void fragment() {
|
|||||||
base *= 0.82 + 0.36 * nz(inst_pos.xz * 0.17);
|
base *= 0.82 + 0.36 * nz(inst_pos.xz * 0.17);
|
||||||
vec3 col = base * mix(0.55, 1.0, hfac); // darker at the root
|
vec3 col = base * mix(0.55, 1.0, hfac); // darker at the root
|
||||||
col = mix(col, col * tip_tint * 1.3, smoothstep(0.6, 1.0, hfac) * 0.3);
|
col = mix(col, col * tip_tint * 1.3, smoothstep(0.6, 1.0, hfac) * 0.3);
|
||||||
|
// Snow weighs down on the blades from the root up; rain darkens them.
|
||||||
|
float snowy = clamp(weather_snow * 1.3 - hfac * 0.5 + (nz(inst_pos.xz * 0.17) - 0.5) * 0.4, 0.0, 0.85);
|
||||||
|
col = mix(col, vec3(0.62, 0.65, 0.7), snowy); // greyer than ground snow: blades are lit as if flat
|
||||||
|
col = wet_albedo(col, weather_wet * 0.6 * (1.0 - snowy));
|
||||||
ALBEDO = col;
|
ALBEDO = col;
|
||||||
ROUGHNESS = 0.9;
|
ROUGHNESS = 0.9;
|
||||||
SPECULAR = 0.2;
|
SPECULAR = 0.2;
|
||||||
|
|||||||
@@ -33,8 +33,13 @@ void fragment() {
|
|||||||
float snow = smoothstep(snow_height - 3.0, snow_height + 3.0, w_pos.y + (a - 0.5) * 10.0);
|
float snow = smoothstep(snow_height - 3.0, snow_height + 3.0, w_pos.y + (a - 0.5) * 10.0);
|
||||||
snow *= smoothstep(0.35, 0.7, w_nrm.y);
|
snow *= smoothstep(0.35, 0.7, w_nrm.y);
|
||||||
col = mix(col, snow_col, snow);
|
col = mix(col, snow_col, snow);
|
||||||
|
float wsnow = snow_mask(w_nrm.y, a);
|
||||||
|
col = mix(col, snow_col, wsnow);
|
||||||
|
snow = max(snow, wsnow);
|
||||||
|
float wet = weather_wet * (1.0 - snow);
|
||||||
|
col = wet_albedo(col, wet);
|
||||||
|
|
||||||
ALBEDO = col;
|
ALBEDO = col;
|
||||||
ROUGHNESS = mix(mix(0.8, 0.95, moss), 0.6, snow);
|
ROUGHNESS = mix(mix(mix(0.8, 0.95, moss), 0.6, snow), 0.3, wet * 0.8);
|
||||||
SPECULAR = 0.35;
|
SPECULAR = 0.35;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,16 @@ void fragment() {
|
|||||||
snow_m *= 1.0 - smoothstep(0.5, 0.7, slope);
|
snow_m *= 1.0 - smoothstep(0.5, 0.7, slope);
|
||||||
col = mix(col, snow_col * (0.92 + 0.08 * fine), snow_m);
|
col = mix(col, snow_col * (0.92 + 0.08 * fine), snow_m);
|
||||||
|
|
||||||
|
// Weather: fresh snow settles on flat ground above the waterline; rain
|
||||||
|
// darkens and glosses everything else.
|
||||||
|
float wsnow = snow_mask(w_nrm.y, fine * 0.6 + mid * 0.4) * step(water_level + 0.1, w_pos.y);
|
||||||
|
col = mix(col, snow_col * (0.9 + 0.1 * fine), wsnow);
|
||||||
|
snow_m = max(snow_m, wsnow);
|
||||||
|
float wet = weather_wet * (1.0 - snow_m);
|
||||||
|
col = wet_albedo(col, wet);
|
||||||
|
|
||||||
ALBEDO = col;
|
ALBEDO = col;
|
||||||
ROUGHNESS = mix(mix(0.95, 0.82, rock_m), 0.6, snow_m);
|
ROUGHNESS = mix(mix(mix(0.95, 0.82, rock_m), 0.6, snow_m), 0.4, wet * 0.8);
|
||||||
SPECULAR = 0.3;
|
SPECULAR = 0.3;
|
||||||
NORMAL_MAP = texture(normal_tex, w_pos.xz * 0.35).rgb;
|
NORMAL_MAP = texture(normal_tex, w_pos.xz * 0.35).rgb;
|
||||||
NORMAL_MAP_DEPTH = mix(0.5, 1.4, rock_m) * (1.0 - snow_m * 0.7);
|
NORMAL_MAP_DEPTH = mix(0.5, 1.4, rock_m) * (1.0 - snow_m * 0.7);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user