M1c: weather, snap building, shelter, beds and regrowth
- Seeded weather (clear/cloudy/rain/storm, snow and blizzards on cold days and above the snowline), lightning, synthesised rain/wind/thunder sound - Rain/snow particles stopped by roofs; wet ground, settled snow and wind via global shader uniforms - Snap-together wood floor, wall, door, roof and gable with structural support, collapse and X to dismantle - Shelter detection: dry under a roof, warmer and Rested inside with a fire; rain burns an uncovered campfire faster - Bed sets the spawn point and lets you sleep through the night - Trees, boulders and ground pickups regrow; timers saved - Grass cleared under floors; shorter HUD hint - New test_weather_building suite (62 checks) and tests/run_all.sh, which also fails on script errors
This commit is contained in:
@@ -191,6 +191,81 @@
|
|||||||
1.6
|
1.6
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_floor",
|
||||||
|
"name": "Wood Floor",
|
||||||
|
"category": "building",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "2 x 2 m. Snaps to other pieces. Sits level on the ground.",
|
||||||
|
"mesh": "wood_floor",
|
||||||
|
"place": {
|
||||||
|
"piece": "build",
|
||||||
|
"kind": "floor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_wall",
|
||||||
|
"name": "Wood Wall",
|
||||||
|
"category": "building",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "2 x 2 m. Snaps to floor edges, other walls and roofs.",
|
||||||
|
"mesh": "wood_wall",
|
||||||
|
"place": {
|
||||||
|
"piece": "build",
|
||||||
|
"kind": "wall"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_door",
|
||||||
|
"name": "Wood Door",
|
||||||
|
"category": "building",
|
||||||
|
"stack": 10,
|
||||||
|
"description": "A wall with a door in it. E opens and closes it.",
|
||||||
|
"mesh": "wood_door",
|
||||||
|
"place": {
|
||||||
|
"piece": "door",
|
||||||
|
"kind": "door"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_roof",
|
||||||
|
"name": "Wood Roof",
|
||||||
|
"category": "building",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Rises 1 m over 2 m. Two from opposite walls of a 4 m hut meet at the ridge.",
|
||||||
|
"mesh": "wood_roof",
|
||||||
|
"place": {
|
||||||
|
"piece": "build",
|
||||||
|
"kind": "roof"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_gable",
|
||||||
|
"name": "Wood Gable",
|
||||||
|
"category": "building",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Triangle that closes the end of a roof. R to flip it.",
|
||||||
|
"mesh": "wood_gable",
|
||||||
|
"place": {
|
||||||
|
"piece": "build",
|
||||||
|
"kind": "gable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bed",
|
||||||
|
"name": "Bed",
|
||||||
|
"category": "placeable",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "Sets your spawn point. Sleep through the night under a roof.",
|
||||||
|
"mesh": "bed",
|
||||||
|
"place": {
|
||||||
|
"piece": "bed",
|
||||||
|
"footprint": [
|
||||||
|
1.0,
|
||||||
|
2.1
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,62 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"mushroom": 1
|
"mushroom": 1
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_floor",
|
||||||
|
"output": "wood_floor",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_wall",
|
||||||
|
"output": "wood_wall",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_door",
|
||||||
|
"output": "wood_door",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_roof",
|
||||||
|
"output": "wood_roof",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 2,
|
||||||
|
"fiber": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wood_gable",
|
||||||
|
"output": "wood_gable",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bed",
|
||||||
|
"output": "bed",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 8,
|
||||||
|
"fiber": 6
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ 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)*
|
||||||
|
- [ ] Creatures, hunting, weapons *(M1d)*
|
||||||
- **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
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ download — the M0 world is generated in code.
|
|||||||
`/Applications`.
|
`/Applications`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
Run everything (fails on any failed check or script error):
|
||||||
```bash
|
```bash
|
||||||
|
tests/run_all.sh
|
||||||
|
```
|
||||||
|
Or one at a time:
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -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,10 +131,26 @@ 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 *(M1d)*
|
||||||
- **Rabbit**: skittish, fast, gives a little meat.
|
- **Rabbit**: skittish, fast, gives a little meat.
|
||||||
@@ -137,7 +181,9 @@ Built in M1b (`scripts/player/survival.gd`), current numbers:
|
|||||||
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,7 +209,7 @@ 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** | rabbit/deer/boar AI, club/spear/bow, hides, cooked food buffs | you hunt a deer and eat its meat |
|
||||||
|
|
||||||
## 5. Open decisions (current defaults in bold)
|
## 5. Open decisions (current defaults in bold)
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
lights_and_shadows/directional_shadow/size=8192
|
lights_and_shadows/directional_shadow/size=8192
|
||||||
|
|||||||
@@ -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)
|
||||||
135
scripts/building/build_piece.gd
Normal file
135
scripts/building/build_piece.gd
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
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),
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
|
||||||
|
func _make_collision(_mi: MeshInstance3D) -> void:
|
||||||
|
kind = def.place.kind
|
||||||
|
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()
|
||||||
368
scripts/building/build_system.gd
Normal file
368
scripts/building/build_system.gd
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
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 _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
|
||||||
|
|
||||||
|
static func _loss(from_center: Vector3, to_center: Vector3) -> float:
|
||||||
|
var d := to_center - from_center
|
||||||
|
var horiz := Vector2(d.x, d.z).length()
|
||||||
|
return VERTICAL_LOSS if absf(d.y) >= horiz * 0.99 else HORIZONTAL_LOSS
|
||||||
|
|
||||||
|
## Support a piece of `kind` would have at `xf`, given the current structure.
|
||||||
|
func support_for(kind: String, xf: Transform3D, exclude: Node = null) -> 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):
|
||||||
|
var nc: Vector3 = n.global_transform * BuildPiece.kinds()[n.kind].center
|
||||||
|
best = maxf(best, n.support * (1.0 - _loss(nc, center)))
|
||||||
|
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]:
|
||||||
|
best = maxf(best, n.support * (1.0 - _loss(centers[n], centers[p])))
|
||||||
|
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"
|
||||||
|
if support_for(kind, xf, exclude) < MIN_SUPPORT:
|
||||||
|
return "Not enough support"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
## 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 := []
|
||||||
|
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]
|
||||||
|
_:
|
||||||
|
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
|
||||||
|
|
||||||
|
## 0..1 fraction of 8 horizontal directions blocked by walls within 8 m.
|
||||||
|
func enclosure_at(pos: Vector3) -> float:
|
||||||
|
var from := pos + Vector3(0, 1.2, 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
|
||||||
|
|
||||||
|
# --- 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.
|
||||||
|
## Returns {"placed": n, "failed": [...], "pieces": [...]}.
|
||||||
|
func debug_build_hut(center: Vector3, yaw: float) -> Dictionary:
|
||||||
|
var out := {"placed": 0, "failed": [], "pieces": []}
|
||||||
|
# Floors: the first sits on the ground, the rest snap to it.
|
||||||
|
var f0: Node3D = await _hut_place(out, center, yaw, "wood_floor", 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, "wood_floor", 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, "wood_wall", Vector3(x, top, -1.9), 0.0, f0) # north
|
||||||
|
await _hut_place(out, center, yaw, "wood_door" if x > 0 else "wood_wall", Vector3(x, top, 1.9), 0.0, f0) # south
|
||||||
|
for z in [-1.0, 1.0]:
|
||||||
|
await _hut_place(out, center, yaw, "wood_wall", Vector3(1.9, top, z), q, f0) # east
|
||||||
|
await _hut_place(out, center, yaw, "wood_wall", 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) -> 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()]
|
||||||
|
for ring in range(0, 12):
|
||||||
|
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 * 4.0
|
||||||
|
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")
|
||||||
@@ -64,19 +66,44 @@ 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") > 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:
|
||||||
|
|||||||
61
scripts/building/door.gd
Normal file
61
scripts/building/door.gd
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
extends "res://scripts/building/build_piece.gd"
|
||||||
|
## Wooden 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.
|
||||||
|
|
||||||
|
const OPEN_ANGLE := 1.75 ## radians: swings toward -Z, away from where you stood to place it
|
||||||
|
|
||||||
|
var open := false
|
||||||
|
var _hinge: Node3D
|
||||||
|
var _panel: AnimatableBody3D
|
||||||
|
var _tween: Tween
|
||||||
|
|
||||||
|
func _visual_mesh() -> Mesh:
|
||||||
|
return ItemDB.mesh_for("wood_door_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("wood_door_panel")
|
||||||
|
mi.material_override = ItemDB.item_material()
|
||||||
|
_panel.add_child(mi)
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var box := BoxShape3D.new()
|
||||||
|
box.size = Vector3(1.06, 1.86, 0.08)
|
||||||
|
cs.shape = box
|
||||||
|
cs.position = Vector3(0.54, 0.95, 0.0)
|
||||||
|
_panel.add_child(cs)
|
||||||
|
|
||||||
|
func interact_prompt(_player: Node) -> String:
|
||||||
|
return "Door [E] %s" % ("Close" if open else "Open")
|
||||||
|
|
||||||
|
func interact(player: Node) -> void:
|
||||||
|
Actions.toggle_door(player, self)
|
||||||
|
|
||||||
|
func set_open(on: bool, animate: bool = true) -> void:
|
||||||
|
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
|
||||||
|
|
||||||
|
func save_extra() -> Dictionary:
|
||||||
|
return {"open": open}
|
||||||
|
|
||||||
|
func load_extra(d: Dictionary) -> void:
|
||||||
|
set_open(bool(d.get("open", false)), false)
|
||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -19,6 +19,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)
|
||||||
@@ -147,6 +148,49 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
|
|||||||
world.spawn_piece(def, xf)
|
world.spawn_piece(def, xf)
|
||||||
return true
|
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 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
|
||||||
|
door.set_open(not door.open)
|
||||||
|
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
|
||||||
|
var world: Node = bed.get_parent().get_parent()
|
||||||
|
if not bed.is_spawn():
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ 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()
|
||||||
"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)
|
||||||
_:
|
_:
|
||||||
@@ -245,3 +253,123 @@ 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)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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 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 +27,7 @@ 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 _prompt := ""
|
var _prompt := ""
|
||||||
var _held_shown := "~"
|
var _held_shown := "~"
|
||||||
var _torch_light: OmniLight3D
|
var _torch_light: OmniLight3D
|
||||||
@@ -123,6 +125,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 +142,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,7 +151,10 @@ 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"
|
||||||
|
_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)
|
||||||
else:
|
else:
|
||||||
@@ -163,8 +171,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
|
||||||
@@ -185,7 +201,12 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
if _held_def().has("food"):
|
if _held_def().has("food"):
|
||||||
Actions.eat(player, player.selected)
|
Actions.eat(player, player.selected)
|
||||||
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"):
|
||||||
|
|||||||
@@ -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,6 +30,11 @@ 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
|
||||||
|
|
||||||
var player: CharacterBody3D
|
var player: CharacterBody3D
|
||||||
var world: Node3D
|
var world: Node3D
|
||||||
@@ -42,7 +49,23 @@ func step(dt: float) -> void:
|
|||||||
return
|
return
|
||||||
var p := player.global_position
|
var p := player.global_position
|
||||||
heat = world.heat_at(p)
|
heat = world.heat_at(p)
|
||||||
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
|
||||||
|
# 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.
|
||||||
|
if sheltered and heat > 0.25:
|
||||||
|
_rest_build += dt
|
||||||
|
if _rest_build >= REST_AFTER:
|
||||||
|
rested = maxf(rested, REST_SECONDS)
|
||||||
|
else:
|
||||||
|
_rest_build = 0.0
|
||||||
|
rested = maxf(rested - dt, 0.0)
|
||||||
|
var target: float = world.air_warmth(p, sheltered) + heat * 70.0
|
||||||
if wet > 0.0:
|
if wet > 0.0:
|
||||||
target -= 22.0
|
target -= 22.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
|
||||||
@@ -55,7 +78,7 @@ func step(dt: float) -> void:
|
|||||||
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)
|
stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * dt, MAX)
|
||||||
|
|
||||||
var dmg := 0.0
|
var dmg := 0.0
|
||||||
if hunger <= 0.0:
|
if hunger <= 0.0:
|
||||||
@@ -87,6 +110,10 @@ func _update_statuses() -> void:
|
|||||||
statuses.append("Hungry")
|
statuses.append("Hungry")
|
||||||
if wet > 0.0:
|
if wet > 0.0:
|
||||||
statuses.append("Wet")
|
statuses.append("Wet")
|
||||||
|
if sheltered:
|
||||||
|
statuses.append("Sheltered")
|
||||||
|
if rested > 0.0:
|
||||||
|
statuses.append("Rested")
|
||||||
|
|
||||||
## 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`.
|
||||||
@@ -105,6 +132,16 @@ func eat(food: Dictionary) -> void:
|
|||||||
func soak() -> void:
|
func soak() -> void:
|
||||||
wet = WET_SECONDS
|
wet = WET_SECONDS
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -115,7 +152,7 @@ func reset_after_death() -> void:
|
|||||||
changed.emit()
|
changed.emit()
|
||||||
|
|
||||||
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}
|
||||||
|
|
||||||
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 +160,6 @@ 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))
|
||||||
dead = false
|
dead = false
|
||||||
changed.emit()
|
changed.emit()
|
||||||
|
|||||||
@@ -147,7 +147,25 @@ 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())
|
||||||
|
|
||||||
## Closes the inventory / menu (e.g. on death).
|
## Closes the inventory / menu (e.g. on death).
|
||||||
func close_screens() -> void:
|
func close_screens() -> void:
|
||||||
|
|||||||
@@ -263,6 +263,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()
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ 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"],
|
||||||
["R", "Rotate piece"], ["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting"],
|
["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)"],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -78,37 +83,54 @@ 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)
|
moon.light_energy = 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))
|
||||||
|
dayc = dayc.lerp(OVERCAST_COLS[i], overcast * 0.85)
|
||||||
cols.append(NIGHT_COLS[i].lerp(dayc, daylight))
|
cols.append(NIGHT_COLS[i].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.
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ func interact_prompt(_player: Node) -> String:
|
|||||||
func interact(player: Node) -> void:
|
func interact(player: Node) -> void:
|
||||||
Actions.pickup(player, self)
|
Actions.pickup(player, self)
|
||||||
|
|
||||||
|
signal taken_now(pickup: Node)
|
||||||
|
|
||||||
|
## Picked up: hidden (not freed) so it can reappear later (props.gd regrowth).
|
||||||
func collected() -> void:
|
func collected() -> void:
|
||||||
|
if taken:
|
||||||
|
return
|
||||||
taken = true
|
taken = true
|
||||||
queue_free()
|
visible = false
|
||||||
|
collision_layer = 0
|
||||||
|
taken_now.emit(self)
|
||||||
|
|
||||||
|
func regrow() -> void:
|
||||||
|
taken = false
|
||||||
|
visible = true
|
||||||
|
collision_layer = 2
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ 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 = {}
|
||||||
|
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 +107,83 @@ 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]
|
||||||
|
|
||||||
|
func _on_taken(pk: Node) -> void:
|
||||||
|
regrow[pk.uid] = REGROW_SECONDS["pickup"]
|
||||||
|
|
||||||
|
## 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():
|
||||||
|
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
|
||||||
|
if _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0 \
|
||||||
|
or _built_over(node.global_position, r):
|
||||||
|
regrow[uid] = REGROW_RETRY
|
||||||
|
return
|
||||||
|
regrow.erase(uid)
|
||||||
|
node.regrow()
|
||||||
|
|
||||||
|
## 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]
|
||||||
|
for uid in pickups:
|
||||||
|
if pickups[uid].taken and not regrow.has(uid):
|
||||||
|
regrow[uid] = REGROW_SECONDS["pickup"]
|
||||||
|
|
||||||
# --- materials --------------------------------------------------------------
|
# --- materials --------------------------------------------------------------
|
||||||
|
|
||||||
func _make_materials() -> void:
|
func _make_materials() -> void:
|
||||||
@@ -205,6 +282,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
|
||||||
@@ -539,6 +617,7 @@ func _scatter_pickups() -> void:
|
|||||||
pk.setup(id, mesh, ItemDB.item_material(), radius[id])
|
pk.setup(id, mesh, ItemDB.item_material(), radius[id])
|
||||||
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 +706,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 +726,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 +753,38 @@ 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 = []
|
||||||
|
|
||||||
|
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])
|
||||||
|
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[2]:
|
||||||
|
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,6 +29,11 @@ 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:
|
||||||
return "Tree" if kind == "tree" else "Boulder"
|
return "Tree" if kind == "tree" else "Boulder"
|
||||||
@@ -107,7 +112,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 +173,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
|
||||||
|
|||||||
191
scripts/world/weather.gd
Normal file
191
scripts/world/weather.gd
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
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": 0.90, "light": 0.45, "fog": 4.5, "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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
func is_cold_day(day: int) -> bool:
|
||||||
|
if day <= 1:
|
||||||
|
return false
|
||||||
|
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), 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_DAY_WARMTH if cold_day else 0.0)
|
||||||
|
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()), 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_DAY_WARMTH if cold_day else 0.0), 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
|
||||||
|
|
||||||
|
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"
|
||||||
|
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)
|
||||||
@@ -21,7 +21,11 @@ const PIECES := {
|
|||||||
"workbench": preload("res://scripts/building/workbench.gd"),
|
"workbench": preload("res://scripts/building/workbench.gd"),
|
||||||
"chest": preload("res://scripts/building/chest.gd"),
|
"chest": preload("res://scripts/building/chest.gd"),
|
||||||
"grave": preload("res://scripts/building/grave.gd"),
|
"grave": preload("res://scripts/building/grave.gd"),
|
||||||
|
"build": preload("res://scripts/building/build_piece.gd"),
|
||||||
|
"door": preload("res://scripts/building/door.gd"),
|
||||||
|
"bed": preload("res://scripts/building/bed.gd"),
|
||||||
}
|
}
|
||||||
|
const BuildPiece = preload("res://scripts/building/build_piece.gd")
|
||||||
|
|
||||||
const AUTOSAVE_SECONDS := 120.0
|
const AUTOSAVE_SECONDS := 120.0
|
||||||
|
|
||||||
@@ -38,6 +42,12 @@ var _autosave_left := AUTOSAVE_SECONDS
|
|||||||
var _menu_cam: Camera3D
|
var _menu_cam: Camera3D
|
||||||
var _menu_t := 0.0
|
var _menu_t := 0.0
|
||||||
var day_night: Node ## day_night.gd: time of day, sun, sky
|
var day_night: Node ## day_night.gd: time of day, sun, sky
|
||||||
|
var weather: Node ## weather.gd: the weather model
|
||||||
|
var weather_fx: Node3D ## weather_fx.gd: rain/snow particles, sound
|
||||||
|
var build: Node ## build_system.gd: snapping, support, shelter
|
||||||
|
var has_bed_spawn := false
|
||||||
|
var bed_spawn := Vector3.ZERO ## position of the bed you respawn at
|
||||||
|
var _loading := false ## applying a save: defer support recompute
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_pieces = Node3D.new()
|
_pieces = Node3D.new()
|
||||||
@@ -47,6 +57,22 @@ func _ready() -> void:
|
|||||||
day_night.name = "DayNight"
|
day_night.name = "DayNight"
|
||||||
add_child(day_night)
|
add_child(day_night)
|
||||||
day_night.setup(sun, $WorldEnvironment.environment)
|
day_night.setup(sun, $WorldEnvironment.environment)
|
||||||
|
weather = preload("res://scripts/world/weather.gd").new()
|
||||||
|
weather.name = "Weather"
|
||||||
|
add_child(weather)
|
||||||
|
weather.snowline = terrain.snow_height - 8.0
|
||||||
|
weather.frozen = menu_mode
|
||||||
|
weather.setup(terrain.noise_seed, day_night)
|
||||||
|
day_night.weather = weather
|
||||||
|
build = preload("res://scripts/building/build_system.gd").new()
|
||||||
|
build.name = "Build"
|
||||||
|
add_child(build)
|
||||||
|
build.setup(self)
|
||||||
|
if not menu_mode and DisplayServer.get_name() != "headless":
|
||||||
|
weather_fx = preload("res://scripts/world/weather_fx.gd").new()
|
||||||
|
weather_fx.name = "WeatherFX"
|
||||||
|
add_child(weather_fx)
|
||||||
|
weather_fx.setup(weather, player)
|
||||||
if menu_mode:
|
if menu_mode:
|
||||||
day_night.time = 9.5 / 24.0
|
day_night.time = 9.5 / 24.0
|
||||||
day_night.frozen = true
|
day_night.frozen = true
|
||||||
@@ -75,10 +101,17 @@ func _ready() -> void:
|
|||||||
|
|
||||||
# --- survival environment ---------------------------------------------------
|
# --- survival environment ---------------------------------------------------
|
||||||
|
|
||||||
## 0..100 air warmth at `pos` before fires: time of day, minus altitude.
|
## 0..100 air warmth at `pos` before fires: time of day, minus altitude,
|
||||||
func air_warmth(pos: Vector3) -> float:
|
## plus the weather. A proper shelter keeps out most of the weather and adds a
|
||||||
|
## little warmth of its own.
|
||||||
|
func air_warmth(pos: Vector3, sheltered: bool = false) -> float:
|
||||||
var w: float = day_night.air_warmth()
|
var w: float = day_night.air_warmth()
|
||||||
w -= maxf(pos.y - 12.0, 0.0) * 0.9
|
w -= maxf(pos.y - 12.0, 0.0) * 0.9
|
||||||
|
var ww: float = weather.warmth_at(pos)
|
||||||
|
if sheltered:
|
||||||
|
w += ww * 0.25 + 8.0
|
||||||
|
else:
|
||||||
|
w += ww
|
||||||
return w
|
return w
|
||||||
|
|
||||||
## 0..1 heat from fires and a held torch.
|
## 0..1 heat from fires and a held torch.
|
||||||
@@ -108,10 +141,71 @@ func _on_player_died() -> void:
|
|||||||
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
|
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
|
||||||
if hud != null:
|
if hud != null:
|
||||||
hud.close_screens()
|
hud.close_screens()
|
||||||
_respawn()
|
var bed := spawn_bed()
|
||||||
|
if bed != null:
|
||||||
|
_respawn_at_bed(bed)
|
||||||
|
else:
|
||||||
|
_respawn()
|
||||||
|
if has_bed_spawn:
|
||||||
|
has_bed_spawn = false
|
||||||
|
Actions.notify.emit("Your bed is gone, so you woke at the island start")
|
||||||
player.survival.reset_after_death()
|
player.survival.reset_after_death()
|
||||||
Actions.notify.emit("You died. Your belongings are in your grave." if has_items else "You died.")
|
Actions.notify.emit("You died. Your belongings are in your grave." if has_items else "You died.")
|
||||||
|
|
||||||
|
# --- beds and sleeping ------------------------------------------------------
|
||||||
|
|
||||||
|
## The bed you respawn at, if it still exists.
|
||||||
|
func spawn_bed() -> Node3D:
|
||||||
|
if not has_bed_spawn:
|
||||||
|
return null
|
||||||
|
for p in _pieces.get_children():
|
||||||
|
if p.item_id == "bed" and not p.is_queued_for_deletion() and p.global_position.distance_to(bed_spawn) < 0.3:
|
||||||
|
return p
|
||||||
|
return null
|
||||||
|
|
||||||
|
func set_bed_spawn(bed: Node3D) -> void:
|
||||||
|
has_bed_spawn = true
|
||||||
|
bed_spawn = bed.global_position
|
||||||
|
|
||||||
|
func _respawn_at_bed(bed: Node3D) -> void:
|
||||||
|
player.global_position = bed.global_position + Vector3(0, 0.9, 0)
|
||||||
|
player.velocity = Vector3.ZERO
|
||||||
|
player.rotation = Vector3(0.0, bed.global_basis.get_euler().y + PI, 0.0)
|
||||||
|
player.get_node("Camera3D").rotation.x = 0.0
|
||||||
|
|
||||||
|
## Why you can't sleep in `bed` right now ("" = you can).
|
||||||
|
func sleep_block_reason(bed: Node3D) -> String:
|
||||||
|
var h: float = day_night.hours()
|
||||||
|
if h > 5.5 and h < 19.5:
|
||||||
|
return "You can only sleep at night (after 19:30)"
|
||||||
|
if not build.sheltered_at(bed.global_position):
|
||||||
|
return "Your bed needs a roof and walls around it"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
## Sleeps until 06:00: the clock, fires, regrowth and weather all move on.
|
||||||
|
func sleep(p: Node3D) -> void:
|
||||||
|
var secs: float = fposmod(6.0 - day_night.hours(), 24.0) / 24.0 * day_night.day_length
|
||||||
|
if hud != null:
|
||||||
|
hud.fade(func(): _finish_sleep(p, secs))
|
||||||
|
else:
|
||||||
|
_finish_sleep(p, secs)
|
||||||
|
|
||||||
|
func _finish_sleep(p: Node3D, secs: float) -> void:
|
||||||
|
skip_time(secs)
|
||||||
|
p.survival.after_sleep(secs)
|
||||||
|
save_now()
|
||||||
|
Actions.notify.emit("You slept until morning")
|
||||||
|
|
||||||
|
## Fast-forwards the world (not the player) by `secs`.
|
||||||
|
func skip_time(secs: float) -> void:
|
||||||
|
day_night.advance(secs)
|
||||||
|
day_night.apply(true)
|
||||||
|
weather.snap()
|
||||||
|
props.skip_time(secs)
|
||||||
|
for f in get_tree().get_nodes_in_group("heat"):
|
||||||
|
if f.has_method("burn"):
|
||||||
|
f.burn(secs)
|
||||||
|
|
||||||
func _on_terrain_ready() -> void:
|
func _on_terrain_ready() -> void:
|
||||||
var t0 := Time.get_ticks_msec()
|
var t0 := Time.get_ticks_msec()
|
||||||
_spawn = terrain.find_spawn(Vector2.ZERO)
|
_spawn = terrain.find_spawn(Vector2.ZERO)
|
||||||
@@ -217,7 +311,9 @@ func collect_save() -> Dictionary:
|
|||||||
"stats": player.survival.to_data(),
|
"stats": player.survival.to_data(),
|
||||||
},
|
},
|
||||||
"time": {"day": day_night.day, "time": day_night.time},
|
"time": {"day": day_night.day, "time": day_night.time},
|
||||||
|
"spawn": {"bed": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]},
|
||||||
"forage_regrow": regrow,
|
"forage_regrow": regrow,
|
||||||
|
"regrow": props.regrow.duplicate(),
|
||||||
"harvested": harvested,
|
"harvested": harvested,
|
||||||
"pickups_taken": taken,
|
"pickups_taken": taken,
|
||||||
"pieces": pieces,
|
"pieces": pieces,
|
||||||
@@ -250,6 +346,14 @@ func apply_save(s: Dictionary) -> void:
|
|||||||
for uid in s.get("pickups_taken", []):
|
for uid in s.get("pickups_taken", []):
|
||||||
if props.pickups.has(uid) and is_instance_valid(props.pickups[uid]):
|
if props.pickups.has(uid) and is_instance_valid(props.pickups[uid]):
|
||||||
props.pickups[uid].collected()
|
props.pickups[uid].collected()
|
||||||
|
# Regrowth timers (older saves have none: everything gone starts a fresh timer).
|
||||||
|
props.restore_regrow(s.get("regrow", {}))
|
||||||
|
var sp: Dictionary = s.get("spawn", {})
|
||||||
|
has_bed_spawn = bool(sp.get("bed", false))
|
||||||
|
if sp.has("pos"):
|
||||||
|
bed_spawn = Vector3(sp.pos[0], sp.pos[1], sp.pos[2])
|
||||||
|
weather.snap()
|
||||||
|
_loading = true
|
||||||
for e in s.get("pieces", []):
|
for e in s.get("pieces", []):
|
||||||
var def: Dictionary = ItemDB.get_item(e.get("item", ""))
|
var def: Dictionary = ItemDB.get_item(e.get("item", ""))
|
||||||
if def.is_empty() or not def.has("place"):
|
if def.is_empty() or not def.has("place"):
|
||||||
@@ -259,6 +363,9 @@ func apply_save(s: Dictionary) -> void:
|
|||||||
piece.inventory.load_data(e.inventory)
|
piece.inventory.load_data(e.inventory)
|
||||||
if e.has("extra") and piece.has_method("load_extra"):
|
if e.has("extra") and piece.has_method("load_extra"):
|
||||||
piece.load_extra(e.extra)
|
piece.load_extra(e.extra)
|
||||||
|
_loading = false
|
||||||
|
build.recompute() # same terrain, same pieces: nothing collapses on load
|
||||||
|
_update_grass_cuts()
|
||||||
|
|
||||||
func _respawn() -> void:
|
func _respawn() -> void:
|
||||||
player.global_position = _spawn + Vector3(0.0, 1.5, 0.0)
|
player.global_position = _spawn + Vector3(0.0, 1.5, 0.0)
|
||||||
@@ -277,8 +384,11 @@ func _physics_process(_delta: float) -> void:
|
|||||||
# --- building ---------------------------------------------------------------
|
# --- building ---------------------------------------------------------------
|
||||||
|
|
||||||
## Whether a piece can go at `xf`: inside the valley, on dry fairly flat
|
## Whether a piece can go at `xf`: inside the valley, on dry fairly flat
|
||||||
## ground, and not overlapping trees, rocks or other pieces.
|
## ground (or a floor), and not overlapping trees, rocks or other pieces.
|
||||||
|
## Building pieces have their own rules (build_system.gd).
|
||||||
func placement_valid(def: Dictionary, xf: Transform3D) -> bool:
|
func placement_valid(def: Dictionary, xf: Transform3D) -> bool:
|
||||||
|
if BuildPiece.is_build(def):
|
||||||
|
return build.check(def, xf).is_empty()
|
||||||
var p := xf.origin
|
var p := xf.origin
|
||||||
if not terrain.inside_boundary(p.x, p.z, 1.5):
|
if not terrain.inside_boundary(p.x, p.z, 1.5):
|
||||||
return false
|
return false
|
||||||
@@ -287,10 +397,12 @@ func placement_valid(def: Dictionary, xf: Transform3D) -> bool:
|
|||||||
var fp: Array = def.place.get("footprint", [1.0, 1.0])
|
var fp: Array = def.place.get("footprint", [1.0, 1.0])
|
||||||
var hx := float(fp[0]) * 0.5
|
var hx := float(fp[0]) * 0.5
|
||||||
var hz := float(fp[1]) * 0.5
|
var hz := float(fp[1]) * 0.5
|
||||||
# Ground under the corners must be within reach of the base.
|
# Something solid (ground or a floor) under each corner, close to the base.
|
||||||
|
var space := get_world_3d().direct_space_state
|
||||||
for c in [Vector3(-hx, 0, -hz), Vector3(hx, 0, -hz), Vector3(-hx, 0, hz), Vector3(hx, 0, hz)]:
|
for c in [Vector3(-hx, 0, -hz), Vector3(hx, 0, -hz), Vector3(-hx, 0, hz), Vector3(hx, 0, hz)]:
|
||||||
var w: Vector3 = xf * c
|
var w: Vector3 = xf * c
|
||||||
if absf(terrain.sample_height(w.x, w.z) - p.y) > 0.45:
|
var rq := PhysicsRayQueryParameters3D.create(w + Vector3(0, 0.45, 0), w - Vector3(0, 0.45, 0), 1, [player.get_rid()])
|
||||||
|
if space.intersect_ray(rq).is_empty():
|
||||||
return false
|
return false
|
||||||
# Overlap test against everything solid except the ground itself.
|
# Overlap test against everything solid except the ground itself.
|
||||||
var box := BoxShape3D.new()
|
var box := BoxShape3D.new()
|
||||||
@@ -308,8 +420,75 @@ func spawn_piece(def: Dictionary, xf: Transform3D) -> Node3D:
|
|||||||
piece.setup(def)
|
piece.setup(def)
|
||||||
_pieces.add_child(piece)
|
_pieces.add_child(piece)
|
||||||
piece.global_transform = xf
|
piece.global_transform = xf
|
||||||
|
if piece.is_in_group("build") and not _loading:
|
||||||
|
build.recompute()
|
||||||
|
if piece.kind == "floor":
|
||||||
|
_update_grass_cuts()
|
||||||
return piece
|
return piece
|
||||||
|
|
||||||
|
## Grass doesn't grow up through floors.
|
||||||
|
func _update_grass_cuts() -> void:
|
||||||
|
var xfs: Array = []
|
||||||
|
for p in _pieces.get_children():
|
||||||
|
if p.is_in_group("build") and p.kind == "floor" and not p.is_queued_for_deletion():
|
||||||
|
xfs.append(p.global_transform)
|
||||||
|
props.set_grass_cuts(xfs, 1.02)
|
||||||
|
|
||||||
|
## Takes a piece out of the world (dismantled). Anything that loses its
|
||||||
|
## support collapses, and its item goes back to `refund_to` if there's room.
|
||||||
|
## Returns how many pieces collapsed.
|
||||||
|
func remove_piece(piece: Node3D, refund_to: Node3D = null) -> int:
|
||||||
|
if piece.item_id == "bed" and spawn_bed() == piece:
|
||||||
|
has_bed_spawn = false
|
||||||
|
Actions.notify.emit("Spawn point cleared")
|
||||||
|
var was_build := piece.is_in_group("build")
|
||||||
|
_pieces.remove_child(piece)
|
||||||
|
piece.queue_free()
|
||||||
|
if not was_build:
|
||||||
|
return 0
|
||||||
|
var weak: Array = build.recompute()
|
||||||
|
for w in weak:
|
||||||
|
if refund_to != null:
|
||||||
|
Actions._give(refund_to, w.item_id, 1)
|
||||||
|
_collapse_fx(w)
|
||||||
|
_pieces.remove_child(w)
|
||||||
|
w.queue_free()
|
||||||
|
if not weak.is_empty():
|
||||||
|
build.recompute()
|
||||||
|
Actions.notify.emit("%d piece%s collapsed" % [weak.size(), "" if weak.size() == 1 else "s"])
|
||||||
|
_settle_loose_pieces()
|
||||||
|
_update_grass_cuts()
|
||||||
|
return weak.size()
|
||||||
|
|
||||||
|
## A collapsing piece: a copy of its mesh tumbles down and sinks away.
|
||||||
|
func _collapse_fx(piece: Node3D) -> void:
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
return
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
mi.mesh = piece.get_node("Mesh").mesh
|
||||||
|
mi.material_override = ItemDB.item_material()
|
||||||
|
add_child(mi)
|
||||||
|
mi.global_transform = piece.global_transform
|
||||||
|
var tw := mi.create_tween().set_parallel(true)
|
||||||
|
tw.tween_property(mi, "global_position:y", mi.global_position.y - 4.0, 1.2).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
|
||||||
|
tw.tween_property(mi, "rotation:x", mi.rotation.x + randf_range(-0.8, 0.8), 1.2)
|
||||||
|
tw.chain().tween_callback(mi.queue_free)
|
||||||
|
|
||||||
|
## Chests, fires and beds left hanging after a collapse drop to whatever is
|
||||||
|
## below them.
|
||||||
|
func _settle_loose_pieces() -> void:
|
||||||
|
var space := get_world_3d().direct_space_state
|
||||||
|
for p in _pieces.get_children():
|
||||||
|
if p.is_in_group("build") or p.is_queued_for_deletion():
|
||||||
|
continue
|
||||||
|
var at: Vector3 = p.global_position
|
||||||
|
var rq := PhysicsRayQueryParameters3D.create(at + Vector3(0, 0.3, 0), at - Vector3(0, 0.5, 0), 1, [p.get_rid()])
|
||||||
|
if not space.intersect_ray(rq).is_empty():
|
||||||
|
continue
|
||||||
|
rq = PhysicsRayQueryParameters3D.create(at, at - Vector3(0, 80, 0), 1, [p.get_rid(), player.get_rid()])
|
||||||
|
var hit := space.intersect_ray(rq)
|
||||||
|
p.global_position.y = hit.position.y if not hit.is_empty() else terrain.sample_height(at.x, at.z)
|
||||||
|
|
||||||
# --- dev screenshot hook ----------------------------------------------------
|
# --- dev screenshot hook ----------------------------------------------------
|
||||||
|
|
||||||
func _maybe_capture() -> void:
|
func _maybe_capture() -> void:
|
||||||
@@ -415,3 +594,65 @@ func _capture_survival(prefix: String, cam: Camera3D) -> void:
|
|||||||
get_viewport().get_texture().get_image().save_png("%s_menu.png" % prefix)
|
get_viewport().get_texture().get_image().save_png("%s_menu.png" % prefix)
|
||||||
print("[world] saved %s_menu.png" % prefix)
|
print("[world] saved %s_menu.png" % prefix)
|
||||||
hud._menu.close()
|
hud._menu.close()
|
||||||
|
await _capture_weather(prefix, cam)
|
||||||
|
|
||||||
|
## Weather views: a snapped-together hut in clear weather, rain, a storm and
|
||||||
|
## snow, and the view out of its open door in the rain.
|
||||||
|
func _capture_weather(prefix: String, cam: Camera3D) -> void:
|
||||||
|
_respawn()
|
||||||
|
player.set_physics_process(false)
|
||||||
|
var yaw: float = player.rotation.y
|
||||||
|
var hb := Basis(Vector3.UP, yaw)
|
||||||
|
var site: Vector3 = build.debug_find_site(player.global_position - player.global_basis.z * 11.0, 3.0)
|
||||||
|
var hut: Dictionary = await build.debug_build_hut(site, yaw)
|
||||||
|
print("[world] demo hut: %d pieces, failed %s" % [hut.placed, hut.failed])
|
||||||
|
var top: float = hut.pieces[0].global_position.y
|
||||||
|
spawn_piece(ItemDB.get_item("bed"), Transform3D(hb, site + hb * Vector3(1.0, top - site.y, -0.6)))
|
||||||
|
var fire_at: Vector3 = site + hb * Vector3(-0.6, 0, 4.5)
|
||||||
|
fire_at.y = terrain.sample_height(fire_at.x, fire_at.z)
|
||||||
|
var fire: Node = spawn_piece(ItemDB.get_item("campfire"), Transform3D(hb, fire_at))
|
||||||
|
fire.fuel = fire.MAX_FUEL
|
||||||
|
var door: Node = null
|
||||||
|
for p in hut.pieces:
|
||||||
|
if p.item_id == "wood_door":
|
||||||
|
door = p
|
||||||
|
player.select(4) # wood: nothing lit in hand
|
||||||
|
var outside_eye: Vector3 = site + hb * Vector3(4.2, 0, 8.5)
|
||||||
|
outside_eye.y = terrain.sample_height(outside_eye.x, outside_eye.z) + 1.7
|
||||||
|
var look: Vector3 = site + Vector3(0, 1.4, 0)
|
||||||
|
day_night.time = 10.5 / 24.0
|
||||||
|
for shot in [["hut_clear", "clear", 10.5], ["hut_rain", "rain", 10.5], ["hut_storm", "storm", 16.0], ["hut_snow", "snow", 11.0]]:
|
||||||
|
weather.force(shot[1])
|
||||||
|
if shot[1] == "snow":
|
||||||
|
weather.snow_cover = 1.0
|
||||||
|
weather.ground_wet = 0.0
|
||||||
|
day_night.time = float(shot[2]) / 24.0
|
||||||
|
day_night.apply(true)
|
||||||
|
_aim_camera(cam, outside_eye, look)
|
||||||
|
await get_tree().create_timer(2.0).timeout
|
||||||
|
if shot[1] == "storm":
|
||||||
|
weather.flash = 0.8
|
||||||
|
day_night.apply(true)
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
get_viewport().get_texture().get_image().save_png("%s_%s.png" % [prefix, shot[0]])
|
||||||
|
print("[world] saved %s_%s.png" % [prefix, shot[0]])
|
||||||
|
# From inside, out through the open door, in the rain.
|
||||||
|
weather.force("rain")
|
||||||
|
weather.snow_cover = 0.0
|
||||||
|
day_night.time = 17.5 / 24.0
|
||||||
|
day_night.apply(true)
|
||||||
|
door.set_open(true, false)
|
||||||
|
var in_eye: Vector3 = site + hb * Vector3(0.6, top - site.y + 1.6, -1.2)
|
||||||
|
_aim_camera(cam, in_eye, site + hb * Vector3(1.2, top - site.y + 1.2, 6.0))
|
||||||
|
await get_tree().create_timer(2.0).timeout
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
get_viewport().get_texture().get_image().save_png("%s_hut_inside.png" % prefix)
|
||||||
|
print("[world] saved %s_hut_inside.png" % prefix)
|
||||||
|
weather.force("")
|
||||||
|
|
||||||
|
func _aim_camera(cam: Camera3D, eye: Vector3, at: Vector3) -> void:
|
||||||
|
var dir := at - eye
|
||||||
|
player.global_position = eye - Vector3(0.0, cam.position.y, 0.0)
|
||||||
|
player.rotation = Vector3(0.0, atan2(-dir.x, -dir.z), 0.0)
|
||||||
|
cam.rotation.x = atan2(dir.y, Vector2(dir.x, dir.z).length())
|
||||||
|
props._update_grass(100000)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
21
tests/run_all.sh
Executable file
21
tests/run_all.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs every headless test suite. Fails if any suite fails OR prints a script
|
||||||
|
# error (a script that fails to compile can otherwise let a suite "pass").
|
||||||
|
# Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH)
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
status=0
|
||||||
|
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_terrain_edges test_boundary; do
|
||||||
|
extra=""
|
||||||
|
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
||||||
|
out=$(godot --headless --path . $extra --script "res://tests/$t.gd" 2>&1)
|
||||||
|
code=$?
|
||||||
|
result=$(printf '%s\n' "$out" | grep "RESULT:" | tail -n 1)
|
||||||
|
if [ $code -ne 0 ] || printf '%s\n' "$out" | grep -q "SCRIPT ERROR"; then
|
||||||
|
echo "FAIL $t $result"
|
||||||
|
printf '%s\n' "$out" | grep -E "FAIL|SCRIPT ERROR|at: " | head -n 20
|
||||||
|
status=1
|
||||||
|
else
|
||||||
|
echo "ok $t $result"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $status
|
||||||
@@ -61,6 +61,7 @@ func _body(world: Node3D, id: String) -> void:
|
|||||||
var terrain: Node3D = world.get_node("Terrain")
|
var terrain: Node3D = world.get_node("Terrain")
|
||||||
player.set_physics_process(false) # keep the player put while we fast-forward
|
player.set_physics_process(false) # keep the player put while we fast-forward
|
||||||
sv.set_process(false)
|
sv.set_process(false)
|
||||||
|
world.weather.force("clear") # weather has its own test (test_weather_building)
|
||||||
|
|
||||||
print("day / night")
|
print("day / night")
|
||||||
check(dn.day == 1 and absf(dn.hours() - 8.0) < 0.1, "new world starts on day 1 at 08:00 (%s)" % dn.clock_text())
|
check(dn.day == 1 and absf(dn.hours() - 8.0) < 0.1, "new world starts on day 1 at 08:00 (%s)" % dn.clock_text())
|
||||||
|
|||||||
382
tests/test_weather_building.gd
Normal file
382
tests/test_weather_building.gd
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## M1c: weather, shelter, snap building + support + dismantling, doors, beds
|
||||||
|
## and sleeping, resource regrowth, and saving all of it.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_weather_building.gd
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var A: Node
|
||||||
|
var S: Node
|
||||||
|
var DB: Node
|
||||||
|
|
||||||
|
func check(cond: bool, what: String) -> void:
|
||||||
|
print((" ok " if cond else " FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
_fails += 1
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _spawn_world() -> Node3D:
|
||||||
|
var w: Node3D = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(w)
|
||||||
|
for _i in range(5):
|
||||||
|
await physics_frame
|
||||||
|
return w
|
||||||
|
|
||||||
|
func _settle() -> void:
|
||||||
|
for _i in range(3):
|
||||||
|
await physics_frame
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
A = root.get_node("Actions")
|
||||||
|
S = root.get_node("Saves")
|
||||||
|
DB = root.get_node("ItemDB")
|
||||||
|
S.root_dir = "user://test_worlds"
|
||||||
|
for w in S.list_worlds():
|
||||||
|
S.delete_world(w.id)
|
||||||
|
var id: String = S.create_world("Huts", 1337)
|
||||||
|
S.current_id = id
|
||||||
|
_weather_model()
|
||||||
|
var world: Node3D = await _spawn_world()
|
||||||
|
await _body(world)
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
S.delete_world(id)
|
||||||
|
DirAccess.remove_absolute(S.root_dir)
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
## The weather model on its own: deterministic and with sensible odds.
|
||||||
|
func _weather_model() -> void:
|
||||||
|
print("weather model")
|
||||||
|
var dn: Node = load("res://scripts/world/day_night.gd").new()
|
||||||
|
var wa: Node = load("res://scripts/world/weather.gd").new()
|
||||||
|
var wb: Node = load("res://scripts/world/weather.gd").new()
|
||||||
|
wa.setup(1337, dn)
|
||||||
|
wb.setup(1337, dn)
|
||||||
|
var same := true
|
||||||
|
var counts := {}
|
||||||
|
var cold_days := 0
|
||||||
|
var snow_on_warm_day := false
|
||||||
|
var days := 60
|
||||||
|
var t := 0.0
|
||||||
|
while t < days * 1200.0:
|
||||||
|
var s: String = wa.state_at(t)
|
||||||
|
if s != wb.state_at(t):
|
||||||
|
same = false
|
||||||
|
counts[s] = counts.get(s, 0) + 1
|
||||||
|
var day := int(t / 1200.0) + 1
|
||||||
|
if s in ["snow", "blizzard"] and not wa.is_cold_day(day):
|
||||||
|
snow_on_warm_day = true
|
||||||
|
t += 20.0
|
||||||
|
for d in range(1, days + 1):
|
||||||
|
if wa.is_cold_day(d):
|
||||||
|
cold_days += 1
|
||||||
|
var total := float(days * 60)
|
||||||
|
var frac := func(names: Array) -> float:
|
||||||
|
var n := 0
|
||||||
|
for k in names:
|
||||||
|
n += counts.get(k, 0)
|
||||||
|
return n / total
|
||||||
|
check(same, "same seed and time give the same weather")
|
||||||
|
check(frac.call(["clear"]) > 0.3 and frac.call(["clear"]) < 0.5, "clear about 40%% of the time (%.0f%%)" % (frac.call(["clear"]) * 100))
|
||||||
|
var wet: float = frac.call(["rain", "storm", "snow", "blizzard"])
|
||||||
|
check(wet > 0.2 and wet < 0.4, "rain or snow about 30%% of the time (%.0f%%)" % (wet * 100))
|
||||||
|
var hard: float = frac.call(["storm", "blizzard"])
|
||||||
|
check(hard > 0.04 and hard < 0.16, "storms about 10%% of the time (%.0f%%)" % (hard * 100))
|
||||||
|
check(cold_days >= 8 and cold_days <= 24, "about a quarter of days are cold (%d of %d)" % [cold_days, days])
|
||||||
|
check(not snow_on_warm_day, "it only snows in the valley on cold days")
|
||||||
|
check(wa.state_at(0.0) == "clear" and wa.state_at(500.0) == "clear", "a new world starts with clear weather")
|
||||||
|
var other: Node = load("res://scripts/world/weather.gd").new()
|
||||||
|
other.setup(99, dn)
|
||||||
|
var differs := false
|
||||||
|
t = 0.0
|
||||||
|
while t < 20 * 1200.0 and not differs:
|
||||||
|
differs = other.state_at(t) != wa.state_at(t)
|
||||||
|
t += 60.0
|
||||||
|
check(differs, "a different seed gives different weather")
|
||||||
|
check(wa.snows_at(Vector3(0, wa.snowline + 1.0, 0)), "above the snowline it snows")
|
||||||
|
for n in [dn, wa, wb, other]:
|
||||||
|
n.free()
|
||||||
|
|
||||||
|
func _body(world: Node3D) -> void:
|
||||||
|
var player: CharacterBody3D = world.get_node("Player")
|
||||||
|
var sv: Node = player.survival
|
||||||
|
var dn: Node = world.day_night
|
||||||
|
var wx: Node = world.weather
|
||||||
|
var bs: Node = world.build
|
||||||
|
var inv: Inventory = player.inventory
|
||||||
|
var terrain: Node3D = world.get_node("Terrain")
|
||||||
|
var props: Node3D = world.get_node("Props")
|
||||||
|
player.set_physics_process(false)
|
||||||
|
sv.set_process(false)
|
||||||
|
wx.set_process(false)
|
||||||
|
wx.force("clear")
|
||||||
|
|
||||||
|
# A flat-ish spot a little way from the spawn, facing the same way.
|
||||||
|
var fwd := -player.global_basis.z
|
||||||
|
var site: Vector3 = bs.debug_find_site(player.global_position + fwd * 12.0, 3.0)
|
||||||
|
var yaw: float = player.rotation.y
|
||||||
|
|
||||||
|
print("snapping")
|
||||||
|
var floor_def: Dictionary = DB.get_item("wood_floor")
|
||||||
|
var tb: Node = terrain.get_node("TerrainBody")
|
||||||
|
var r0: Dictionary = bs.placement(floor_def, {"position": site, "normal": Vector3.UP, "collider": tb}, yaw)
|
||||||
|
check(r0.ok, "a floor can go on open ground (%s)" % r0.reason)
|
||||||
|
var f0: Node3D = world.spawn_piece(floor_def, r0.xf)
|
||||||
|
await _settle()
|
||||||
|
check(f0.grounded and f0.support == 1.0, "a floor on the ground is fully supported")
|
||||||
|
var right: Vector3 = f0.global_basis.x
|
||||||
|
var aim := f0.global_position + right * 1.6
|
||||||
|
var r1: Dictionary = bs.placement(floor_def, {"position": aim, "normal": Vector3.UP, "collider": f0}, yaw + 0.2)
|
||||||
|
var off: Vector3 = r1.xf.origin - f0.global_position
|
||||||
|
check(r1.ok and r1.snapped and off.distance_to(right * 2.0) < 0.01, "the next floor snaps exactly 2 m along (%s)" % off)
|
||||||
|
check(absf(r1.xf.basis.get_euler().y - f0.global_basis.get_euler().y) < 0.01, "and lines up with the first despite a skewed aim")
|
||||||
|
var wall_def: Dictionary = DB.get_item("wood_wall")
|
||||||
|
var aimw := f0.global_position - f0.global_basis.z * 0.9
|
||||||
|
var rw: Dictionary = bs.placement(wall_def, {"position": aimw, "normal": Vector3.UP, "collider": f0}, yaw)
|
||||||
|
var edge := f0.global_position - f0.global_basis.z * 1.0
|
||||||
|
check(rw.ok and rw.xf.origin.distance_to(edge) < 0.01, "a wall snaps onto the floor's edge")
|
||||||
|
var air := site + Vector3(0, 6, 0)
|
||||||
|
var ra: Dictionary = bs.placement(floor_def, {"position": air, "normal": Vector3.UP, "collider": null}, yaw)
|
||||||
|
check(not ra.ok and ra.reason == "Not enough support", "a floor in mid-air has no support (%s)" % ra.reason)
|
||||||
|
var rblock: Dictionary = bs.placement(floor_def, {"position": f0.global_position, "normal": Vector3.UP, "collider": f0}, yaw)
|
||||||
|
check(not rblock.ok or rblock.xf.origin.distance_to(f0.global_position) > 1.0, "can't place a floor on top of another one")
|
||||||
|
world.remove_piece(f0)
|
||||||
|
await _settle()
|
||||||
|
|
||||||
|
print("hut")
|
||||||
|
var hut: Dictionary = await bs.debug_build_hut(site, yaw)
|
||||||
|
await _settle()
|
||||||
|
check(hut.placed == 20 and hut.failed.is_empty(), "built a 20-piece hut by snapping (%d placed, failed: %s, blocker %s)" % [hut.placed, hut.failed, bs.last_blocker])
|
||||||
|
var weakest := 1.0
|
||||||
|
for p in hut.pieces:
|
||||||
|
weakest = minf(weakest, p.support)
|
||||||
|
check(weakest >= bs.MIN_SUPPORT, "every hut piece is supported (weakest %.2f)" % weakest)
|
||||||
|
var inside: Vector3 = hut.pieces[0].global_position + hut.pieces[0].global_basis * Vector3(1, 0.05, 1)
|
||||||
|
var outside: Vector3 = site + fwd * -6.0
|
||||||
|
outside.y = terrain.sample_height(outside.x, outside.z)
|
||||||
|
check(bs.roofed_at(inside) and bs.enclosure_at(inside) > 0.99 and bs.sheltered_at(inside), "inside the hut is sheltered")
|
||||||
|
check(not bs.roofed_at(outside) and not bs.sheltered_at(outside), "outside is not")
|
||||||
|
|
||||||
|
print("rain and shelter")
|
||||||
|
dn.time = 0.5
|
||||||
|
dn.apply(true)
|
||||||
|
wx.force("rain")
|
||||||
|
player.global_position = outside
|
||||||
|
sv.wet = 0.0
|
||||||
|
sv._shelter_left = 0.0
|
||||||
|
sv.step(0.5)
|
||||||
|
check(sv.wet > 0.0 and "Wet" in sv.statuses, "rain soaks you outside")
|
||||||
|
player.global_position = inside
|
||||||
|
sv.wet = 0.0
|
||||||
|
sv._shelter_left = 0.0
|
||||||
|
sv.step(0.5)
|
||||||
|
check(sv.wet == 0.0 and sv.sheltered and "Sheltered" in sv.statuses, "inside the hut you stay dry")
|
||||||
|
check(world.air_warmth(inside, true) > world.air_warmth(outside, false) + 8.0, "the hut keeps out the weather's chill")
|
||||||
|
var fire_out: Node = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), outside + right * 3.0))
|
||||||
|
var fire_in: Node = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), inside + right * 0.2))
|
||||||
|
check(fire_out.burn_rate() > 2.0 and fire_in.burn_rate() == 1.0, "rain eats an open fire's fuel, not one under a roof (%.1fx)" % fire_out.burn_rate())
|
||||||
|
sv._rest_build = 0.0
|
||||||
|
for _i in range(25):
|
||||||
|
sv.step(1.0)
|
||||||
|
check(sv.rested > 0.0 and "Rested" in sv.statuses, "resting by a fire in the hut makes you Rested")
|
||||||
|
world.remove_piece(fire_out)
|
||||||
|
wx.force("storm")
|
||||||
|
wx.step(30.0)
|
||||||
|
check(wx.lightning_count > 0, "storms bring lightning")
|
||||||
|
wx.force("snow")
|
||||||
|
player.global_position = outside
|
||||||
|
sv.wet = 0.0
|
||||||
|
sv._shelter_left = 0.0
|
||||||
|
sv.step(0.5)
|
||||||
|
check(sv.wet == 0.0, "snow makes you cold, not wet")
|
||||||
|
check(world.air_warmth(outside) < world.air_warmth(inside, true) - 10.0, "snow is colder outside than in the hut")
|
||||||
|
wx.force("clear")
|
||||||
|
|
||||||
|
print("door")
|
||||||
|
var door: Node = null
|
||||||
|
for p in hut.pieces:
|
||||||
|
if p.item_id == "wood_door":
|
||||||
|
door = p
|
||||||
|
player.global_position = door.global_position + door.global_basis.z * 1.5
|
||||||
|
var panel_ray := func() -> bool:
|
||||||
|
var from: Vector3 = door.global_position + door.global_basis.z * 0.6 + Vector3(0, 1.0, 0)
|
||||||
|
var q := PhysicsRayQueryParameters3D.create(from, from - door.global_basis.z * 1.2, 1)
|
||||||
|
return not world.get_world_3d().direct_space_state.intersect_ray(q).is_empty()
|
||||||
|
check(panel_ray.call(), "a closed door blocks the doorway")
|
||||||
|
check(A.toggle_door(player, door) and door.open, "E opens the door")
|
||||||
|
door.set_open(true, false)
|
||||||
|
await _settle()
|
||||||
|
check(not panel_ray.call(), "an open door lets you through")
|
||||||
|
door.set_open(false, false)
|
||||||
|
await _settle()
|
||||||
|
|
||||||
|
print("support and collapse")
|
||||||
|
# A lone wall on a floor, with floors stacked off its top as an overhang:
|
||||||
|
# they reach out a few pieces, then run out of support.
|
||||||
|
var s2: Vector3 = bs.debug_find_site(site + right * 16.0, 6.0) - fwd * 4.0
|
||||||
|
s2.y = terrain.sample_height(s2.x, s2.z)
|
||||||
|
var rg: Dictionary = bs.placement(floor_def, {"position": s2, "normal": Vector3.UP, "collider": tb}, yaw)
|
||||||
|
var g: Node3D = world.spawn_piece(floor_def, rg.xf)
|
||||||
|
await physics_frame
|
||||||
|
var rtw: Dictionary = bs.placement(wall_def, {"position": g.global_position - g.global_basis.z * 0.9, "normal": Vector3.UP, "collider": g}, yaw)
|
||||||
|
check(rg.ok and rtw.ok, "a test wall on its own floor (%s %s)" % [rg.reason, rtw.reason])
|
||||||
|
var tw: Node3D = world.spawn_piece(wall_def, rtw.xf)
|
||||||
|
await physics_frame
|
||||||
|
var out_dir: Vector3 = -tw.global_basis.z
|
||||||
|
var chain: Array = []
|
||||||
|
var top: Vector3 = tw.global_position + Vector3(0, 2, 0)
|
||||||
|
var stopped := -1
|
||||||
|
var stop_reason := ""
|
||||||
|
for i in range(8):
|
||||||
|
var rc: Dictionary = bs.placement(floor_def, {"position": top + out_dir * (1.0 + 2.0 * i), "normal": Vector3.UP, "collider": tw}, yaw)
|
||||||
|
if not rc.ok:
|
||||||
|
stopped = i
|
||||||
|
stop_reason = rc.reason
|
||||||
|
break
|
||||||
|
chain.append(world.spawn_piece(floor_def, rc.xf))
|
||||||
|
await physics_frame
|
||||||
|
check(stopped >= 2 and stopped <= 5 and stop_reason == "Not enough support", "an overhang reaches %d floors before it runs out of support (%s %s)" % [stopped, stop_reason, bs.last_blocker])
|
||||||
|
if chain.size() >= 2:
|
||||||
|
check(chain[-1].support < chain[0].support, "support falls off along the overhang (%.2f -> %.2f)" % [chain[0].support, chain[-1].support])
|
||||||
|
inv.load_data(Inventory.new(inv.size()).to_data())
|
||||||
|
player.global_position = tw.global_position + out_dir * -1.5
|
||||||
|
var n_chain := chain.size()
|
||||||
|
check(A.dismantle(player, tw), "X dismantles a wall")
|
||||||
|
await _settle()
|
||||||
|
var gone := 0
|
||||||
|
for p in chain:
|
||||||
|
if not is_instance_valid(p) or p.get_parent() == null:
|
||||||
|
gone += 1
|
||||||
|
check(gone == n_chain and n_chain > 0, "the overhang it held up collapses (%d of %d)" % [gone, n_chain])
|
||||||
|
check(inv.count("wood_wall") == 1 and inv.count("wood_floor") == n_chain, "dismantled and collapsed pieces come back to you")
|
||||||
|
world.remove_piece(g)
|
||||||
|
|
||||||
|
# Taking one wall out of the hut: the roof is tied in by the other walls,
|
||||||
|
# the ridge and the gables, so nothing falls.
|
||||||
|
var hut_wall: Node3D = hut.pieces[4] # north wall, west half (see debug_build_hut)
|
||||||
|
var hut_floor: Node3D = hut.pieces[0]
|
||||||
|
player.global_position = inside
|
||||||
|
check(hut_wall.item_id == "wood_wall" and A.dismantle(player, hut_wall), "dismantled one hut wall")
|
||||||
|
await _settle()
|
||||||
|
var roofs_left := 0
|
||||||
|
for p in world.get_node("Pieces").get_children():
|
||||||
|
if p.item_id == "wood_roof":
|
||||||
|
roofs_left += 1
|
||||||
|
check(roofs_left == 4, "the roof still stands (%d of 4 roof pieces)" % roofs_left)
|
||||||
|
_hold(player, "wood_wall")
|
||||||
|
var aimb: Vector3 = hut_floor.global_position - hut_floor.global_basis.z * 0.9
|
||||||
|
var rb: Dictionary = bs.placement(wall_def, {"position": aimb, "normal": Vector3.UP, "collider": hut_floor}, yaw)
|
||||||
|
check(rb.ok and A.place(player, 0, rb.xf, world), "and it goes back where it was (%s)" % rb.reason)
|
||||||
|
await _settle()
|
||||||
|
check(bs.sheltered_at(inside), "the hut is whole again")
|
||||||
|
world.remove_piece(fire_in)
|
||||||
|
var chest: Node = world.spawn_piece(DB.get_item("chest"), Transform3D(Basis(Vector3.UP, yaw), inside - right * 1.0))
|
||||||
|
chest.inventory.add("stone", 3)
|
||||||
|
check(not A.dismantle(player, chest), "can't dismantle a chest with things in it")
|
||||||
|
|
||||||
|
print("bed and sleep")
|
||||||
|
inv.add("bed", 1)
|
||||||
|
_hold(player, "bed")
|
||||||
|
var bed_xf := Transform3D(Basis(Vector3.UP, yaw), inside + right * 0.9 - Vector3(0, 0.05, 0))
|
||||||
|
check(world.placement_valid(DB.get_item("bed"), bed_xf), "a bed fits on the hut floor")
|
||||||
|
A.place(player, 0, bed_xf, world)
|
||||||
|
var bed: Node = null
|
||||||
|
for p in world.get_node("Pieces").get_children():
|
||||||
|
if p.item_id == "bed":
|
||||||
|
bed = p
|
||||||
|
player.global_position = bed.global_position + right * 1.0
|
||||||
|
check(A.use_bed(player, bed) and world.has_bed_spawn and bed.is_spawn(), "E on a bed sets your spawn point")
|
||||||
|
dn.time = 14.0 / 24.0
|
||||||
|
check(not A.use_bed(player, bed), "can't sleep in the afternoon")
|
||||||
|
dn.time = 22.0 / 24.0
|
||||||
|
var day0: int = dn.day
|
||||||
|
var hunger0: float = sv.hunger
|
||||||
|
check(A.use_bed(player, bed), "can sleep at 22:00 in a sheltered bed")
|
||||||
|
check(dn.day == day0 + 1 and absf(dn.hours() - 6.0) < 0.05, "woke at 06:00 the next day (%s)" % dn.clock_text())
|
||||||
|
check(sv.rested > 0.0 and sv.hunger < hunger0 and sv.hunger > hunger0 - 15.0, "rested, and a little hungrier (%.0f -> %.0f)" % [hunger0, sv.hunger])
|
||||||
|
sv.hunger = 0.0
|
||||||
|
sv.health = 0.3
|
||||||
|
player.global_position = outside
|
||||||
|
sv.step(2.0)
|
||||||
|
check(player.global_position.distance_to(bed.global_position) < 2.0, "after dying you wake at your bed")
|
||||||
|
|
||||||
|
print("regrowth")
|
||||||
|
var tree: Node = null
|
||||||
|
for uid in props.resource_nodes:
|
||||||
|
var n = props.resource_nodes[uid]
|
||||||
|
if n.kind == "tree" and not n.depleted and n.global_position.distance_to(player.global_position) > 15.0:
|
||||||
|
tree = n
|
||||||
|
break
|
||||||
|
inv.add("stone_axe", 1)
|
||||||
|
_hold(player, "stone_axe")
|
||||||
|
player.global_position = tree.global_position + Vector3(1.5, 0, 0)
|
||||||
|
var hits := 0
|
||||||
|
while not tree.depleted and hits < 20:
|
||||||
|
A.harvest_hit(player, tree, tree.global_position + Vector3(0, 1, 0))
|
||||||
|
sv.stamina = 100.0
|
||||||
|
hits += 1
|
||||||
|
check(tree.depleted and props.regrow.has(tree.uid), "a felled tree starts a regrowth timer (%.0f s)" % props.regrow.get(tree.uid, 0.0))
|
||||||
|
var pk: Node = null
|
||||||
|
for uid in props.pickups:
|
||||||
|
if not props.pickups[uid].taken:
|
||||||
|
pk = props.pickups[uid]
|
||||||
|
break
|
||||||
|
player.global_position = pk.global_position + Vector3(0.5, 0, 0)
|
||||||
|
A.pickup(player, pk)
|
||||||
|
check(pk.taken and props.regrow.has(pk.uid), "a picked-up item starts one too")
|
||||||
|
props.skip_time(1000.0)
|
||||||
|
check(pk.taken and props.regrow.has(pk.uid), "with you standing on the spot, the item waits")
|
||||||
|
player.global_position = outside + Vector3(0, 0, 40)
|
||||||
|
props.skip_time(2500.0)
|
||||||
|
check(not tree.depleted and tree.is_in_group("harvestable") and tree.collision_layer == 1, "the tree grows back after two days")
|
||||||
|
check(not pk.taken and pk.visible, "the item comes back")
|
||||||
|
|
||||||
|
print("save + load")
|
||||||
|
tree.restore(0.0)
|
||||||
|
props.regrow[tree.uid] = 777.0
|
||||||
|
var tree_uid: String = tree.uid
|
||||||
|
door.set_open(true, false)
|
||||||
|
var n_pieces := world.get_node("Pieces").get_child_count()
|
||||||
|
var bed_pos: Vector3 = bed.global_position
|
||||||
|
world.save_now()
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
world = await _spawn_world()
|
||||||
|
player = world.get_node("Player")
|
||||||
|
props = world.get_node("Props")
|
||||||
|
await _settle()
|
||||||
|
check(world.get_node("Pieces").get_child_count() == n_pieces, "all %d pieces restored" % n_pieces)
|
||||||
|
var weakest2 := 1.0
|
||||||
|
var door2: Node = null
|
||||||
|
for p in world.get_node("Pieces").get_children():
|
||||||
|
if p.is_in_group("build"):
|
||||||
|
weakest2 = minf(weakest2, p.support)
|
||||||
|
if p.item_id == "wood_door":
|
||||||
|
door2 = p
|
||||||
|
check(weakest2 >= world.build.MIN_SUPPORT, "nothing collapses on load (weakest %.2f)" % weakest2)
|
||||||
|
check(door2 != null and door2.open, "the open door is still open")
|
||||||
|
check(world.has_bed_spawn and world.bed_spawn.distance_to(bed_pos) < 0.05, "spawn point restored")
|
||||||
|
check(absf(float(props.regrow.get(tree_uid, 0.0)) - 777.0) < 2.0, "regrowth timers restored")
|
||||||
|
check(world.build.sheltered_at(inside), "the restored hut still shelters you")
|
||||||
|
# Older saves had no regrowth timers: anything gone starts a fresh one.
|
||||||
|
var state: Dictionary = S.load_state(S.current_id)
|
||||||
|
state.erase("regrow")
|
||||||
|
props.regrow.clear()
|
||||||
|
props.restore_regrow(state.get("regrow", {}))
|
||||||
|
check(props.regrow.has(tree_uid) and props.regrow[tree_uid] == props.REGROW_SECONDS["tree"], "old saves: gone things get a full timer")
|
||||||
|
world.queue_free()
|
||||||
|
_finished = true
|
||||||
|
|
||||||
|
func _hold(player: Node, id: String) -> void:
|
||||||
|
var inv: Inventory = player.inventory
|
||||||
|
for i in range(inv.size()):
|
||||||
|
var s = inv.get_slot(i)
|
||||||
|
if s != null and s.id == id:
|
||||||
|
var tmp = inv.get_slot(0)
|
||||||
|
inv.set_slot(0, s)
|
||||||
|
inv.set_slot(i, tmp)
|
||||||
|
break
|
||||||
|
player.select(0)
|
||||||
Reference in New Issue
Block a user