M1b: survival stats, day/night, fire fuel, cooking, foraging, torches, death + grave
- Health, stamina, hunger, warmth and wet with HUD bars, status tags and clock - 20-minute day/night cycle with moon, stars and lit clouds - Campfire fuel (2 min start, 15 min max), heat radius, cooking needs a lit fire - Berry bushes (regrow 10 min), mushrooms, roasted mushroom, torch - Stamina costs for sprint, jump and harvest hits - Death respawns at island start and leaves a grave bag - Saves include stats, time of day, fire fuel, regrowth timers and graves - New test_survival_stats suite; tests fail if a script error aborts them
This commit is contained in:
220
data/items.json
220
data/items.json
@@ -1,32 +1,196 @@
|
|||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
{ "id": "stick", "name": "Stick", "category": "resource", "stack": 50,
|
{
|
||||||
"description": "A sturdy branch. Handles, kindling, fuel.", "mesh": "stick", "fuel": 20 },
|
"id": "stick",
|
||||||
{ "id": "stone", "name": "Stone", "category": "resource", "stack": 50,
|
"name": "Stick",
|
||||||
"description": "A fist-sized stone.", "mesh": "stone" },
|
"category": "resource",
|
||||||
{ "id": "flint", "name": "Flint", "category": "resource", "stack": 50,
|
"stack": 50,
|
||||||
"description": "Sharp-edged stone. Knaps into blades and axe heads.", "mesh": "flint" },
|
"description": "A sturdy branch. Handles, kindling, fuel.",
|
||||||
{ "id": "wood", "name": "Wood", "category": "resource", "stack": 50,
|
"mesh": "stick",
|
||||||
"description": "Split timber from a felled tree.", "mesh": "wood", "fuel": 60 },
|
"fuel": 20
|
||||||
|
},
|
||||||
{ "id": "stone_axe", "name": "Stone Axe", "category": "tool", "stack": 1,
|
{
|
||||||
"description": "Chops trees. Hold it and click on a tree.", "mesh": "stone_axe",
|
"id": "stone",
|
||||||
"tool": { "kind": "axe", "tier": 1, "damage": 1.0 } },
|
"name": "Stone",
|
||||||
{ "id": "stone_pickaxe", "name": "Stone Pickaxe", "category": "tool", "stack": 1,
|
"category": "resource",
|
||||||
"description": "Breaks boulders into stone and flint.", "mesh": "stone_pickaxe",
|
"stack": 50,
|
||||||
"tool": { "kind": "pickaxe", "tier": 1, "damage": 1.0 } },
|
"description": "A fist-sized stone.",
|
||||||
{ "id": "club", "name": "Wooden Club", "category": "weapon", "stack": 1,
|
"mesh": "stone"
|
||||||
"description": "Crude, heavy, effective. (Creatures arrive in M1d.)", "mesh": "club",
|
},
|
||||||
"weapon": { "damage": 12, "reach": 2.2, "stamina": 8 } },
|
{
|
||||||
|
"id": "flint",
|
||||||
{ "id": "campfire", "name": "Campfire", "category": "placeable", "stack": 5,
|
"name": "Flint",
|
||||||
"description": "Light and warmth. Place it, then keep it burning.", "mesh": "campfire",
|
"category": "resource",
|
||||||
"place": { "piece": "campfire", "footprint": [1.4, 1.4] } },
|
"stack": 50,
|
||||||
{ "id": "workbench", "name": "Workbench", "category": "placeable", "stack": 1,
|
"description": "Sharp-edged stone. Knaps into blades and axe heads.",
|
||||||
"description": "Crafting station for better gear and storage.", "mesh": "workbench",
|
"mesh": "flint"
|
||||||
"place": { "piece": "workbench", "footprint": [2.0, 1.1] } },
|
},
|
||||||
{ "id": "chest", "name": "Wooden Chest", "category": "placeable", "stack": 5,
|
{
|
||||||
"description": "Stores 16 stacks.", "mesh": "chest",
|
"id": "wood",
|
||||||
"place": { "piece": "chest", "footprint": [1.2, 0.8] } }
|
"name": "Wood",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "Split timber from a felled tree.",
|
||||||
|
"mesh": "wood",
|
||||||
|
"fuel": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stone_axe",
|
||||||
|
"name": "Stone Axe",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "Chops trees. Hold it and click on a tree.",
|
||||||
|
"mesh": "stone_axe",
|
||||||
|
"tool": {
|
||||||
|
"kind": "axe",
|
||||||
|
"tier": 1,
|
||||||
|
"damage": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stone_pickaxe",
|
||||||
|
"name": "Stone Pickaxe",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "Breaks boulders into stone and flint.",
|
||||||
|
"mesh": "stone_pickaxe",
|
||||||
|
"tool": {
|
||||||
|
"kind": "pickaxe",
|
||||||
|
"tier": 1,
|
||||||
|
"damage": 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "club",
|
||||||
|
"name": "Wooden Club",
|
||||||
|
"category": "weapon",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "Crude, heavy, effective. (Creatures arrive in M1d.)",
|
||||||
|
"mesh": "club",
|
||||||
|
"weapon": {
|
||||||
|
"damage": 12,
|
||||||
|
"reach": 2.2,
|
||||||
|
"stamina": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "campfire",
|
||||||
|
"name": "Campfire",
|
||||||
|
"category": "placeable",
|
||||||
|
"stack": 5,
|
||||||
|
"description": "Light and warmth. Place it, then keep it burning.",
|
||||||
|
"mesh": "campfire",
|
||||||
|
"place": {
|
||||||
|
"piece": "campfire",
|
||||||
|
"footprint": [
|
||||||
|
1.4,
|
||||||
|
1.4
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "workbench",
|
||||||
|
"name": "Workbench",
|
||||||
|
"category": "placeable",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "Crafting station for better gear and storage.",
|
||||||
|
"mesh": "workbench",
|
||||||
|
"place": {
|
||||||
|
"piece": "workbench",
|
||||||
|
"footprint": [
|
||||||
|
2.0,
|
||||||
|
1.1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chest",
|
||||||
|
"name": "Wooden Chest",
|
||||||
|
"category": "placeable",
|
||||||
|
"stack": 5,
|
||||||
|
"description": "Stores 16 stacks.",
|
||||||
|
"mesh": "chest",
|
||||||
|
"place": {
|
||||||
|
"piece": "chest",
|
||||||
|
"footprint": [
|
||||||
|
1.2,
|
||||||
|
0.8
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fiber",
|
||||||
|
"name": "Plant Fiber",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "Tough stems from bushes. Twine, torch wrapping.",
|
||||||
|
"mesh": "fiber",
|
||||||
|
"fuel": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "berries",
|
||||||
|
"name": "Berries",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "A handful of wild berries. Right-click to eat.",
|
||||||
|
"mesh": "berries",
|
||||||
|
"food": {
|
||||||
|
"hunger": 9,
|
||||||
|
"health": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mushroom",
|
||||||
|
"name": "Mushroom",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Edible raw, much better roasted at a campfire.",
|
||||||
|
"mesh": "mushroom",
|
||||||
|
"food": {
|
||||||
|
"hunger": 6,
|
||||||
|
"health": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "roasted_mushroom",
|
||||||
|
"name": "Roasted Mushroom",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 30,
|
||||||
|
"description": "Smoky and filling. Right-click to eat.",
|
||||||
|
"mesh": "roasted_mushroom",
|
||||||
|
"food": {
|
||||||
|
"hunger": 24,
|
||||||
|
"health": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "torch",
|
||||||
|
"name": "Torch",
|
||||||
|
"category": "tool",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "Hold it for light and a little warmth.",
|
||||||
|
"mesh": "torch",
|
||||||
|
"light": {
|
||||||
|
"range": 10.0,
|
||||||
|
"energy": 1.6,
|
||||||
|
"heat": 0.3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grave",
|
||||||
|
"name": "Grave",
|
||||||
|
"category": "placeable",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "Where you fell. Your belongings are inside.",
|
||||||
|
"mesh": "grave",
|
||||||
|
"hidden": true,
|
||||||
|
"place": {
|
||||||
|
"piece": "grave",
|
||||||
|
"footprint": [
|
||||||
|
1.0,
|
||||||
|
1.6
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,83 @@
|
|||||||
{
|
{
|
||||||
"recipes": [
|
"recipes": [
|
||||||
{ "id": "stone_axe", "output": "stone_axe", "count": 1, "station": "",
|
{
|
||||||
"inputs": { "stick": 2, "flint": 2 } },
|
"id": "stone_axe",
|
||||||
{ "id": "stone_pickaxe", "output": "stone_pickaxe", "count": 1, "station": "",
|
"output": "stone_axe",
|
||||||
"inputs": { "stick": 2, "stone": 3, "flint": 1 } },
|
"count": 1,
|
||||||
{ "id": "campfire", "output": "campfire", "count": 1, "station": "",
|
"station": "",
|
||||||
"inputs": { "stone": 5, "stick": 4 } },
|
"inputs": {
|
||||||
{ "id": "workbench", "output": "workbench", "count": 1, "station": "",
|
"stick": 2,
|
||||||
"inputs": { "wood": 10, "flint": 2 } },
|
"flint": 2
|
||||||
{ "id": "club", "output": "club", "count": 1, "station": "",
|
}
|
||||||
"inputs": { "wood": 4 } },
|
},
|
||||||
{ "id": "chest", "output": "chest", "count": 1, "station": "workbench",
|
{
|
||||||
"inputs": { "wood": 10, "stick": 4 } }
|
"id": "stone_pickaxe",
|
||||||
|
"output": "stone_pickaxe",
|
||||||
|
"count": 1,
|
||||||
|
"station": "",
|
||||||
|
"inputs": {
|
||||||
|
"stick": 2,
|
||||||
|
"stone": 3,
|
||||||
|
"flint": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "campfire",
|
||||||
|
"output": "campfire",
|
||||||
|
"count": 1,
|
||||||
|
"station": "",
|
||||||
|
"inputs": {
|
||||||
|
"stone": 5,
|
||||||
|
"stick": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "workbench",
|
||||||
|
"output": "workbench",
|
||||||
|
"count": 1,
|
||||||
|
"station": "",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 10,
|
||||||
|
"flint": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "club",
|
||||||
|
"output": "club",
|
||||||
|
"count": 1,
|
||||||
|
"station": "",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chest",
|
||||||
|
"output": "chest",
|
||||||
|
"count": 1,
|
||||||
|
"station": "workbench",
|
||||||
|
"inputs": {
|
||||||
|
"wood": 10,
|
||||||
|
"stick": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "torch",
|
||||||
|
"output": "torch",
|
||||||
|
"count": 1,
|
||||||
|
"station": "",
|
||||||
|
"inputs": {
|
||||||
|
"stick": 1,
|
||||||
|
"fiber": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "roasted_mushroom",
|
||||||
|
"output": "roasted_mushroom",
|
||||||
|
"count": 1,
|
||||||
|
"station": "campfire",
|
||||||
|
"inputs": {
|
||||||
|
"mushroom": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ actually run and see. Timelines assume solo dev + heavy AI assistance, steady
|
|||||||
|
|
||||||
## M1 — Survival loop (single-player) · ~3–5 weeks
|
## M1 — Survival loop (single-player) · ~3–5 weeks
|
||||||
**Goal:** the core game verbs.
|
**Goal:** the core game verbs.
|
||||||
- [ ] Resource nodes: chop tree → wood, mine rock → stone
|
Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d).
|
||||||
- [ ] Inventory + simple hotbar UI
|
- [x] Resource nodes: chop tree → wood, mine rock → stone *(M1a)*
|
||||||
- [ ] Crafting (recipe: wood → tools / building pieces)
|
- [x] Inventory + simple hotbar UI, chests *(M1a)*
|
||||||
- [ ] Health / stamina, day-night cycle
|
- [x] Crafting (recipe: wood → tools / building pieces) *(M1a)*
|
||||||
|
- [x] Pause menu, title screen, world slots, save/load, autosave *(M1a)*
|
||||||
|
- [x] Health / stamina / hunger / warmth, day-night cycle, fire fuel, cooking, foraging, torches, death + grave *(M1b)*
|
||||||
|
- [ ] Weather (rain, storm, snow), wet/shelter *(M1c)*
|
||||||
- [ ] Snap-together building: place a wall/floor, resource cost, basic support rule
|
- [ ] Snap-together building: place a wall/floor, resource cost, basic support rule
|
||||||
- **Done when:** gather → craft → build a tiny shelter, single-player.
|
- **Done when:** gather → craft → build a tiny shelter, single-player.
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ godot --headless --path . --script res://tests/test_terrain_edges.gd
|
|||||||
godot --headless --path . --script res://tests/test_survival_core.gd
|
godot --headless --path . --script res://tests/test_survival_core.gd
|
||||||
# Save/load round trip + world slots (uses user://test_worlds, not your saves)
|
# Save/load round trip + world slots (uses user://test_worlds, not your saves)
|
||||||
godot --headless --path . --script res://tests/test_save_load.gd
|
godot --headless --path . --script res://tests/test_save_load.gd
|
||||||
|
# Survival stats: hunger, warmth, fire fuel, cooking, foraging, death + grave
|
||||||
|
godot --headless --path . --script res://tests/test_survival_stats.gd
|
||||||
```
|
```
|
||||||
|
|
||||||
## Save files
|
## Save files
|
||||||
|
|||||||
@@ -84,6 +84,21 @@ Status effects: **Wet** (rain or swimming: warmth drains faster, lasts after
|
|||||||
you're out), **Cold** / **Freezing**, **Warm** (near a fire), **Sheltered**,
|
you're out), **Cold** / **Freezing**, **Warm** (near a fire), **Sheltered**,
|
||||||
**Rested** (sat by a fire under a roof: stamina regen buff).
|
**Rested** (sat by a fire under a roof: stamina regen buff).
|
||||||
|
|
||||||
|
Built in M1b (`scripts/player/survival.gd`), current numbers:
|
||||||
|
- Hunger: full to empty in 1500 s (about 1.25 days), 1.5x faster below 30
|
||||||
|
warmth. Eating is refused at 98+. Berries +9, mushroom +6, roasted
|
||||||
|
mushroom +24.
|
||||||
|
- Warmth eases toward a target: air warmth (22 at night to 68 at noon, -0.9 per metre above 12 m) plus
|
||||||
|
up to +70 from a fire or a held torch. Warms at 7/s, cools at 1.6/s.
|
||||||
|
- Wet: 90 s after leaving deep water, -22 to the warmth target, dries 3x
|
||||||
|
faster by a fire.
|
||||||
|
- Stamina: regen 24/s after a 1 s pause. Sprint 14/s, jump 8, harvest hit 6.
|
||||||
|
- Health: -0.6/s starving, -0.5/s below 12 warmth, +0.5/s when fed (>40)
|
||||||
|
and warm (>35).
|
||||||
|
- Campfire: starts with 2 min of fuel, holds 15 min, +fuel with wood (E),
|
||||||
|
heat radius 7 m. Out of fuel = no heat, no cooking.
|
||||||
|
- Berry bushes regrow after 10 min. Torch = 1 stick + 2 fiber.
|
||||||
|
|
||||||
### 2.7 Time & weather *(M1b / M1c)*
|
### 2.7 Time & weather *(M1b / M1c)*
|
||||||
- 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
|
||||||
@@ -121,9 +136,10 @@ you're out), **Cold** / **Freezing**, **Warm** (near a fire), **Sheltered**,
|
|||||||
- A save stores only what the player changed: player position and look,
|
- A save stores only what the player changed: player position and look,
|
||||||
inventory, hotbar slot; placed pieces and chest contents; tree and boulder
|
inventory, hotbar slot; placed pieces and chest contents; tree and boulder
|
||||||
damage, stumps, mined rocks; collected pickups. Everything else regenerates
|
damage, stumps, mined rocks; collected pickups. Everything else regenerates
|
||||||
from the seed. *(Later: stats, time of day, weather, regrowth timers.)*
|
from the seed. Since M1b it also stores stats, time of day, bush regrowth
|
||||||
|
timers, campfire fuel and graves. *(Later: weather.)*
|
||||||
|
|
||||||
### 2.11 Death *(M1c)*
|
### 2.11 Death *(built in M1b)*
|
||||||
- On death you respawn at your bed (or the island spawn) and leave a **grave
|
- On death you respawn at your bed (or the island spawn) and leave a **grave
|
||||||
bag** with your inventory where you died (Valheim-style: punishing but
|
bag** with your inventory where you died (Valheim-style: punishing but
|
||||||
recoverable). The world carries on.
|
recoverable). The world carries on.
|
||||||
@@ -146,8 +162,8 @@ you're out), **Cold** / **Freezing**, **Warm** (near a fire), **Sheltered**,
|
|||||||
| Phase | Delivers | Playable "done when" |
|
| Phase | Delivers | Playable "done when" |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **M1a** *(done)* | items/recipes data, inventory + hotbar, pickups, chop/mine with tools, crafting UI, campfire / workbench / chest placement, chest storage, pause menu, **title screen + world slots + save/load + autosave** (pulled forward from M1c) | you craft an axe, fell a tree, build a fire, stash wood in a chest, quit and continue |
|
| **M1a** *(done)* | items/recipes data, inventory + hotbar, pickups, chop/mine with tools, crafting UI, campfire / workbench / chest placement, chest storage, pause menu, **title screen + world slots + save/load + autosave** (pulled forward from M1c) | you craft an axe, fell a tree, build a fire, stash wood in a chest, quit and continue |
|
||||||
| **M1b** | hunger/warmth/stamina/health HUD, day/night cycle, campfire warmth + fuel, cooking, bushes (fiber, berries), torches | you survive your first night by the fire |
|
| **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, death + grave bag, resource regrowth | you build a hut and wait out a storm |
|
| **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 |
|
||||||
| **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)
|
||||||
|
|||||||
@@ -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 · R rotate · 1-8 hotbar · Tab inventory & crafting · ESC menu"
|
text = "WASD move · Shift sprint · Space jump · E interact · LMB use/place · RMB eat · R rotate · 1-8 hotbar · Tab inventory & crafting · ESC menu"
|
||||||
label_settings = SubResource("Label_1")
|
label_settings = SubResource("Label_1")
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
extends "res://scripts/building/piece.gd"
|
extends "res://scripts/building/piece.gd"
|
||||||
## Campfire: flickering light, flames, embers and smoke. A "campfire" station
|
## Campfire: light, warmth and cooking, while it has fuel.
|
||||||
## (cooking arrives in M1b along with fuel and warmth).
|
##
|
||||||
|
## Starts with a little kindling. E adds wood (or sticks) from your inventory;
|
||||||
|
## it burns down in play time and goes out at zero. While lit it is a
|
||||||
|
## "campfire" station (cooking) and a heat source (warmth within ~7 m).
|
||||||
|
|
||||||
|
const MAX_FUEL := 900.0 ## 15 min
|
||||||
|
const START_FUEL := 120.0
|
||||||
|
const HEAT_RADIUS := 7.0
|
||||||
|
|
||||||
|
var fuel := START_FUEL
|
||||||
var _light: OmniLight3D
|
var _light: OmniLight3D
|
||||||
|
var _fx: Array[CPUParticles3D] = []
|
||||||
var _t := 0.0
|
var _t := 0.0
|
||||||
var _noise := FastNoiseLite.new()
|
var _noise := FastNoiseLite.new()
|
||||||
|
var _lit := true
|
||||||
|
|
||||||
func _build() -> void:
|
func _build() -> void:
|
||||||
add_to_group("station:campfire")
|
add_to_group("station:campfire")
|
||||||
|
add_to_group("heat")
|
||||||
_noise.frequency = 2.5
|
_noise.frequency = 2.5
|
||||||
_light = OmniLight3D.new()
|
_light = OmniLight3D.new()
|
||||||
_light.light_color = Color(1.0, 0.62, 0.3)
|
_light.light_color = Color(1.0, 0.62, 0.3)
|
||||||
@@ -18,30 +29,76 @@ func _build() -> void:
|
|||||||
_light.position = Vector3(0, 0.7, 0)
|
_light.position = Vector3(0, 0.7, 0)
|
||||||
add_child(_light)
|
add_child(_light)
|
||||||
|
|
||||||
add_child(_particles("Flames", 26, 0.55, Vector3(0, 0.12, 0), 0.5, 1.0, 0.5, 0.9, _gradient([
|
_fx.append(_particles("Flames", 26, 0.55, Vector3(0, 0.12, 0), 0.5, 1.0, 0.5, 0.9, _gradient([
|
||||||
[0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.15, Color(1.0, 0.62, 0.2, 0.75)],
|
[0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.15, Color(1.0, 0.62, 0.2, 0.75)],
|
||||||
[0.6, Color(0.95, 0.3, 0.06, 0.45)], [1.0, Color(0.5, 0.08, 0.02, 0.0)]]), true, 0.32, 0.12))
|
[0.6, Color(0.95, 0.3, 0.06, 0.45)], [1.0, Color(0.5, 0.08, 0.02, 0.0)]]), true, 0.32, 0.12))
|
||||||
add_child(_particles("Embers", 10, 2.0, Vector3(0, 0.3, 0), 0.8, 2.0, 0.5, 1.0, _gradient([
|
_fx.append(_particles("Embers", 10, 2.0, Vector3(0, 0.3, 0), 0.8, 2.0, 0.5, 1.0, _gradient([
|
||||||
[0.0, Color(1.0, 0.75, 0.35, 1.0)], [1.0, Color(1.0, 0.3, 0.05, 0.0)]]), true, 0.04, 0.15))
|
[0.0, Color(1.0, 0.75, 0.35, 1.0)], [1.0, Color(1.0, 0.3, 0.05, 0.0)]]), true, 0.04, 0.15))
|
||||||
var smoke := _particles("Smoke", 10, 5.0, Vector3(0, 1.0, 0), 0.6, 1.1, 0.6, 1.4, _gradient([
|
for p in _fx:
|
||||||
[0.0, Color(0.55, 0.54, 0.52, 0.0)], [0.25, Color(0.58, 0.57, 0.55, 0.07)],
|
add_child(p)
|
||||||
[1.0, Color(0.7, 0.7, 0.7, 0.0)]]), false, 0.9, 0.15)
|
# TODO: smoke. Alpha-blended unshaded particles rendered as an opaque dark
|
||||||
smoke.spread = 25.0
|
# ball whatever their ramp colour (additive flames are fine). Needs a small
|
||||||
smoke.gravity = Vector3(0.35, 0.25, 0.1) # drifts with the wind
|
# particle shader.
|
||||||
# TODO(M1b): smoke renders as an opaque dark ball whatever its ramp colour
|
_set_lit(fuel > 0.0)
|
||||||
# (alpha-blended unshaded particles; additive flames are fine). Disabled
|
|
||||||
# until that's fixed, likely by moving to a small particle shader.
|
|
||||||
smoke.queue_free()
|
|
||||||
|
|
||||||
func display_name() -> String:
|
func display_name() -> String:
|
||||||
return "Campfire"
|
return "Campfire"
|
||||||
|
|
||||||
|
func is_lit() -> bool:
|
||||||
|
return _lit
|
||||||
|
|
||||||
|
func station_active() -> bool:
|
||||||
|
return _lit
|
||||||
|
|
||||||
|
## 0..1 warmth this fire gives at `pos`.
|
||||||
|
func heat_at(pos: Vector3) -> float:
|
||||||
|
if not _lit:
|
||||||
|
return 0.0
|
||||||
|
return 1.0 - smoothstep(1.5, HEAT_RADIUS, pos.distance_to(global_position))
|
||||||
|
|
||||||
|
func add_fuel(seconds: float) -> void:
|
||||||
|
fuel = minf(fuel + seconds, MAX_FUEL)
|
||||||
|
_set_lit(true)
|
||||||
|
|
||||||
|
func interact_prompt(player: Node) -> String:
|
||||||
|
var has_fuel: bool = player.inventory.count("wood") + player.inventory.count("stick") > 0
|
||||||
|
if _lit:
|
||||||
|
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)"]
|
||||||
|
return "Campfire (out)%s" % (" [E] Add fuel to light" if has_fuel else " (needs wood or sticks)")
|
||||||
|
|
||||||
|
func interact(player: Node) -> void:
|
||||||
|
Actions.add_fuel(player, self)
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
|
if not _lit:
|
||||||
|
return
|
||||||
|
fuel -= delta
|
||||||
|
if fuel <= 0.0:
|
||||||
|
fuel = 0.0
|
||||||
|
_set_lit(false)
|
||||||
|
return
|
||||||
_t += delta
|
_t += delta
|
||||||
var f := _noise.get_noise_1d(_t * 10.0)
|
var f := _noise.get_noise_1d(_t * 10.0)
|
||||||
_light.light_energy = 2.2 + f * 0.7
|
# Burns lower in its last minute.
|
||||||
|
var strength := clampf(fuel / 60.0, 0.35, 1.0)
|
||||||
|
_light.light_energy = (2.2 + f * 0.7) * strength
|
||||||
_light.position = Vector3(f * 0.05, 0.7 + f * 0.05, 0)
|
_light.position = Vector3(f * 0.05, 0.7 + f * 0.05, 0)
|
||||||
|
|
||||||
|
func _set_lit(on: bool) -> void:
|
||||||
|
_lit = on
|
||||||
|
if _light != null:
|
||||||
|
_light.visible = on
|
||||||
|
for p in _fx:
|
||||||
|
p.emitting = on
|
||||||
|
|
||||||
|
func save_extra() -> Dictionary:
|
||||||
|
return {"fuel": fuel}
|
||||||
|
|
||||||
|
func load_extra(d: Dictionary) -> void:
|
||||||
|
fuel = float(d.get("fuel", fuel))
|
||||||
|
_set_lit(fuel > 0.0)
|
||||||
|
|
||||||
static func _gradient(stops: Array) -> Gradient:
|
static func _gradient(stops: Array) -> Gradient:
|
||||||
var g := Gradient.new()
|
var g := Gradient.new()
|
||||||
g.offsets = PackedFloat32Array(stops.map(func(s): return s[0]))
|
g.offsets = PackedFloat32Array(stops.map(func(s): return s[0]))
|
||||||
@@ -61,7 +118,7 @@ static func _soft_dot() -> GradientTexture2D:
|
|||||||
t.height = 64
|
t.height = 64
|
||||||
return t
|
return t
|
||||||
|
|
||||||
func _particles(label: String, amount: int, lifetime: float, pos: Vector3, vmin: float, vmax: float,
|
static func _particles(label: String, amount: int, lifetime: float, pos: Vector3, vmin: float, vmax: float,
|
||||||
smin: float, smax: float, ramp: Gradient, additive: bool, quad_size: float, radius: float) -> CPUParticles3D:
|
smin: float, smax: float, ramp: Gradient, additive: bool, quad_size: float, radius: float) -> CPUParticles3D:
|
||||||
var p := CPUParticles3D.new()
|
var p := CPUParticles3D.new()
|
||||||
p.name = label
|
p.name = label
|
||||||
@@ -81,7 +138,7 @@ func _particles(label: String, amount: int, lifetime: float, pos: Vector3, vmin:
|
|||||||
# Flames shrink as they rise.
|
# Flames shrink as they rise.
|
||||||
var curve := Curve.new()
|
var curve := Curve.new()
|
||||||
curve.add_point(Vector2(0.0, 1.0))
|
curve.add_point(Vector2(0.0, 1.0))
|
||||||
curve.add_point(Vector2(1.0, 0.35 if additive else 1.6))
|
curve.add_point(Vector2(1.0, 0.35))
|
||||||
p.scale_amount_curve = curve
|
p.scale_amount_curve = curve
|
||||||
var quad := QuadMesh.new()
|
var quad := QuadMesh.new()
|
||||||
quad.size = Vector2.ONE * quad_size
|
quad.size = Vector2.ONE * quad_size
|
||||||
|
|||||||
23
scripts/building/grave.gd
Normal file
23
scripts/building/grave.gd
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
extends "res://scripts/building/piece.gd"
|
||||||
|
## Grave: left where you died, holding everything you carried. Open it with E
|
||||||
|
## to take your things back; it crumbles once empty.
|
||||||
|
|
||||||
|
var inventory := Inventory.new(40)
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
inventory.changed.connect(_on_changed)
|
||||||
|
|
||||||
|
func display_name() -> String:
|
||||||
|
return "Your grave"
|
||||||
|
|
||||||
|
func interact_prompt(_player: Node) -> String:
|
||||||
|
return "Your grave [E] Recover belongings"
|
||||||
|
|
||||||
|
func interact(player: Node) -> void:
|
||||||
|
player.open_inventory(self)
|
||||||
|
|
||||||
|
func _on_changed() -> void:
|
||||||
|
for s in inventory.slots:
|
||||||
|
if s != null:
|
||||||
|
return
|
||||||
|
queue_free.call_deferred()
|
||||||
1
scripts/building/grave.gd.uid
Normal file
1
scripts/building/grave.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ok0js1e4pb4s
|
||||||
@@ -13,6 +13,7 @@ signal item_gained(id: String, n: int)
|
|||||||
|
|
||||||
const STATION_RANGE := 6.0
|
const STATION_RANGE := 6.0
|
||||||
const REACH := 3.2
|
const REACH := 3.2
|
||||||
|
const FULL_HUNGER := 98.0 ## can't eat above this
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_bind("interact", [KEY_E])
|
_bind("interact", [KEY_E])
|
||||||
@@ -21,7 +22,7 @@ func _ready() -> void:
|
|||||||
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)
|
||||||
_bind_mouse("cancel", MOUSE_BUTTON_RIGHT)
|
_bind_mouse("use_item", MOUSE_BUTTON_RIGHT)
|
||||||
|
|
||||||
func _bind(action: String, keys: Array) -> void:
|
func _bind(action: String, keys: Array) -> void:
|
||||||
if not InputMap.has_action(action):
|
if not InputMap.has_action(action):
|
||||||
@@ -59,6 +60,8 @@ func station_near(player: Node3D, station: String) -> bool:
|
|||||||
if station.is_empty():
|
if station.is_empty():
|
||||||
return true
|
return true
|
||||||
for s in player.get_tree().get_nodes_in_group("station:" + station):
|
for s in player.get_tree().get_nodes_in_group("station:" + station):
|
||||||
|
if s.has_method("station_active") and not s.station_active():
|
||||||
|
continue # e.g. a campfire that has gone out
|
||||||
if (s as Node3D).global_position.distance_to(player.global_position) <= STATION_RANGE:
|
if (s as Node3D).global_position.distance_to(player.global_position) <= STATION_RANGE:
|
||||||
return true
|
return true
|
||||||
return false
|
return false
|
||||||
@@ -116,6 +119,9 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
|
|||||||
if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier:
|
if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier:
|
||||||
notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
|
notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind))
|
||||||
return false
|
return false
|
||||||
|
if not player.survival.use_stamina(6.0):
|
||||||
|
notify.emit("Too tired")
|
||||||
|
return false
|
||||||
var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position)
|
var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position)
|
||||||
for id in drops:
|
for id in drops:
|
||||||
_give(player, id, int(drops[id]))
|
_give(player, id, int(drops[id]))
|
||||||
@@ -141,6 +147,47 @@ 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
|
||||||
|
|
||||||
|
func forage(player: Node3D, bush: Node3D) -> bool:
|
||||||
|
if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
|
||||||
|
return false
|
||||||
|
var got: Dictionary = bush.harvest()
|
||||||
|
for id in got:
|
||||||
|
_give(player, id, int(got[id]))
|
||||||
|
return not got.is_empty()
|
||||||
|
|
||||||
|
## Eats one of the food in `slot`.
|
||||||
|
func eat(player: Node3D, slot: int) -> bool:
|
||||||
|
var s = player.inventory.get_slot(slot)
|
||||||
|
if s == null:
|
||||||
|
return false
|
||||||
|
var food: Dictionary = ItemDB.get_item(s.id).get("food", {})
|
||||||
|
if food.is_empty():
|
||||||
|
return false
|
||||||
|
if player.survival.hunger >= FULL_HUNGER:
|
||||||
|
notify.emit("You're full")
|
||||||
|
return false
|
||||||
|
player.inventory.take_from(slot, 1)
|
||||||
|
player.survival.eat(food)
|
||||||
|
notify.emit("Ate %s" % ItemDB.item_name(s.id).to_lower())
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
## Feeds a campfire from the player's inventory: wood first, then sticks.
|
||||||
|
func add_fuel(player: Node3D, fire: Node3D) -> bool:
|
||||||
|
if not is_instance_valid(fire) or not _in_reach(player, fire):
|
||||||
|
return false
|
||||||
|
if fire.fuel >= fire.MAX_FUEL - 1.0:
|
||||||
|
notify.emit("The fire is fully stoked")
|
||||||
|
return false
|
||||||
|
for id in ["wood", "stick", "fiber"]:
|
||||||
|
if player.inventory.count(id) > 0:
|
||||||
|
player.inventory.remove(id, 1)
|
||||||
|
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
|
||||||
|
notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower())
|
||||||
|
return true
|
||||||
|
notify.emit("Nothing to burn (wood or sticks)")
|
||||||
|
return false
|
||||||
|
|
||||||
## Moves between two inventories (quick-move / shift-click). The player must
|
## Moves between two inventories (quick-move / shift-click). The player must
|
||||||
## be next to the container.
|
## be next to the container.
|
||||||
func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, container: Node3D = null) -> void:
|
func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, container: Node3D = null) -> void:
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ const IRON := Color(0.30, 0.30, 0.32)
|
|||||||
const ASH := Color(0.18, 0.17, 0.16)
|
const ASH := Color(0.18, 0.17, 0.16)
|
||||||
const BINDING := Color(0.75, 0.68, 0.48)
|
const BINDING := Color(0.75, 0.68, 0.48)
|
||||||
|
|
||||||
|
const FIBER := Color(0.45, 0.55, 0.22)
|
||||||
|
const BERRY := Color(0.62, 0.08, 0.16)
|
||||||
|
const MUSH_CAP := Color(0.72, 0.30, 0.18)
|
||||||
|
const MUSH_ROAST := Color(0.40, 0.24, 0.12)
|
||||||
|
const MUSH_STEM := Color(0.88, 0.84, 0.74)
|
||||||
|
const CLOTH := Color(0.60, 0.50, 0.32)
|
||||||
|
const SOIL := Color(0.30, 0.22, 0.15)
|
||||||
|
|
||||||
static var _cache := {}
|
static var _cache := {}
|
||||||
|
|
||||||
static func build(key: String) -> ArrayMesh:
|
static func build(key: String) -> ArrayMesh:
|
||||||
@@ -35,6 +43,14 @@ static func build(key: String) -> ArrayMesh:
|
|||||||
"campfire": m = _campfire()
|
"campfire": m = _campfire()
|
||||||
"workbench": m = _workbench()
|
"workbench": m = _workbench()
|
||||||
"chest": m = _chest()
|
"chest": m = _chest()
|
||||||
|
"fiber": m = _fiber()
|
||||||
|
"berries": m = _berries()
|
||||||
|
"mushroom": m = _mushroom(MUSH_CAP)
|
||||||
|
"roasted_mushroom": m = _mushroom(MUSH_ROAST)
|
||||||
|
"torch": m = _torch()
|
||||||
|
"grave": m = _grave()
|
||||||
|
"berry_bush": m = _berry_bush(true)
|
||||||
|
"berry_bush_bare": m = _berry_bush(false)
|
||||||
_:
|
_:
|
||||||
push_warning("item_meshes: no mesh for '%s', using a stone" % key)
|
push_warning("item_meshes: no mesh for '%s', using a stone" % key)
|
||||||
m = _pebble(1, Color.MAGENTA, Vector3.ONE * 0.15)
|
m = _pebble(1, Color.MAGENTA, Vector3.ONE * 0.15)
|
||||||
@@ -158,3 +174,74 @@ static func _chest() -> ArrayMesh:
|
|||||||
_box(st, Vector3(sx, 0.36, 0), Vector3(0.07, 0.74, 0.76), IRON)
|
_box(st, Vector3(sx, 0.36, 0), Vector3(0.07, 0.74, 0.76), IRON)
|
||||||
_box(st, Vector3(0, 0.55, 0.37), Vector3(0.12, 0.14, 0.03), IRON)
|
_box(st, Vector3(0, 0.55, 0.37), Vector3(0.12, 0.14, 0.03), IRON)
|
||||||
return P._commit(st)
|
return P._commit(st)
|
||||||
|
|
||||||
|
static func _fiber() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(41)
|
||||||
|
for i in range(7):
|
||||||
|
var a := r.randf_range(-0.25, 0.25)
|
||||||
|
var b := Vector3(r.randf_range(-0.03, 0.03), 0.02, r.randf_range(-0.03, 0.03))
|
||||||
|
P._cylinder(st, b + Vector3(-0.22, 0, 0), b + Vector3(0.22, 0.03 + a * 0.1, a), 0.008, 0.005, 3, P._mul(FIBER, r.randf_range(0.8, 1.15)), r)
|
||||||
|
_box(st, Vector3(0, 0.03, 0), Vector3(0.05, 0.05, 0.08), CLOTH)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
static func _berries() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(43)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 43
|
||||||
|
for i in range(9):
|
||||||
|
var c := Vector3(r.randf_range(-0.07, 0.07), 0.03 + r.randf_range(0.0, 0.05), r.randf_range(-0.07, 0.07))
|
||||||
|
P._blob(st, c, Vector3.ONE * r.randf_range(0.025, 0.035), n, 0.1, P._mul(BERRY, r.randf_range(0.8, 1.2)), r, 0)
|
||||||
|
P._blob(st, Vector3(0, 0.08, 0), Vector3(0.05, 0.012, 0.03), n, 0.2, P.BUSH, r, 0)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
static func _mushroom(cap: Color) -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(47)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 47
|
||||||
|
P._cylinder(st, Vector3(0, -0.02, 0), Vector3(0, 0.1, 0), 0.025, 0.02, 6, MUSH_STEM, r, -1.0, MUSH_STEM)
|
||||||
|
P._blob(st, Vector3(0, 0.11, 0), Vector3(0.075, 0.04, 0.075), n, 0.15, cap, r, 1)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
static func _torch() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(53)
|
||||||
|
_handle(st, 0.5, r)
|
||||||
|
P._cylinder(st, Vector3(0, 0.42, 0), Vector3(0, 0.58, 0), 0.045, 0.05, 6, CLOTH, r, -1.0, ASH)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
static func _grave() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(59)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 59
|
||||||
|
P._blob(st, Vector3(0, 0.0, 0), Vector3(0.5, 0.22, 0.8), n, 0.15, SOIL, r, 1)
|
||||||
|
_box(st, Vector3(0, 0.55, -0.62), Vector3(0.08, 1.0, 0.06), PLANK_DARK)
|
||||||
|
_box(st, Vector3(0, 0.8, -0.62), Vector3(0.5, 0.08, 0.06), PLANK_DARK)
|
||||||
|
for i in range(4):
|
||||||
|
var c := Vector3(r.randf_range(-0.35, 0.35), 0.15, r.randf_range(-0.6, 0.6))
|
||||||
|
P._blob(st, c, Vector3.ONE * r.randf_range(0.07, 0.12), n, 0.3, STONE, r, 0)
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## World berry bush (not an item). `ripe` adds the berries.
|
||||||
|
static func _berry_bush(ripe: bool) -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(61)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 61
|
||||||
|
n.frequency = 2.0
|
||||||
|
var leaf := P._mul(P.BUSH, 1.1)
|
||||||
|
var blobs: Array[Vector3] = []
|
||||||
|
for i in range(4):
|
||||||
|
var c := Vector3(r.randf_range(-0.4, 0.4), r.randf_range(0.3, 0.55), r.randf_range(-0.4, 0.4))
|
||||||
|
var rad := r.randf_range(0.35, 0.5)
|
||||||
|
P._blob(st, c, Vector3(rad, rad * 0.8, rad), n, 0.3, P._mul(leaf, r.randf_range(0.85, 1.1)), r, 1)
|
||||||
|
blobs.append(c + Vector3(0, rad * 0.4, 0))
|
||||||
|
if ripe:
|
||||||
|
for c in blobs:
|
||||||
|
for k in range(6):
|
||||||
|
var d := Vector3(r.randf_range(-1, 1), r.randf_range(0.2, 1), r.randf_range(-1, 1)).normalized() * 0.4
|
||||||
|
P._blob(st, c + d, Vector3.ONE * 0.045, n, 0.1, BERRY, r, 0)
|
||||||
|
return P._commit(st)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ extends Node3D
|
|||||||
|
|
||||||
signal prompt_changed(text: String)
|
signal prompt_changed(text: String)
|
||||||
|
|
||||||
|
const Campfire = preload("res://scripts/building/campfire.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
|
||||||
@@ -27,6 +28,9 @@ var _place_xf := Transform3D()
|
|||||||
var _place_ok := false
|
var _place_ok := false
|
||||||
var _prompt := ""
|
var _prompt := ""
|
||||||
var _held_shown := "~"
|
var _held_shown := "~"
|
||||||
|
var _torch_light: OmniLight3D
|
||||||
|
var _torch_flame: CPUParticles3D
|
||||||
|
var _t := 0.0
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_ray = RayCast3D.new()
|
_ray = RayCast3D.new()
|
||||||
@@ -51,6 +55,22 @@ func _ready() -> void:
|
|||||||
_ghost.top_level = true
|
_ghost.top_level = true
|
||||||
add_child(_ghost)
|
add_child(_ghost)
|
||||||
|
|
||||||
|
_torch_light = OmniLight3D.new()
|
||||||
|
_torch_light.light_color = Color(1.0, 0.65, 0.35)
|
||||||
|
_torch_light.omni_attenuation = 1.3
|
||||||
|
_torch_light.shadow_enabled = true
|
||||||
|
_torch_light.position = Vector3(0, 0.62, 0)
|
||||||
|
_torch_light.visible = false
|
||||||
|
_view.add_child(_torch_light)
|
||||||
|
_torch_flame = Campfire._particles(
|
||||||
|
"TorchFlame", 14, 0.4, Vector3(0, 0.58, 0), 0.2, 0.5, 0.25, 0.5,
|
||||||
|
Campfire._gradient([
|
||||||
|
[0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.2, Color(1.0, 0.6, 0.2, 0.8)], [1.0, Color(0.6, 0.1, 0.02, 0.0)]]),
|
||||||
|
true, 0.12, 0.03)
|
||||||
|
_torch_flame.local_coords = false
|
||||||
|
_torch_flame.emitting = false
|
||||||
|
_view.add_child(_torch_flame)
|
||||||
|
|
||||||
player.inventory.changed.connect(_refresh_held)
|
player.inventory.changed.connect(_refresh_held)
|
||||||
player.selected_changed.connect(_refresh_held)
|
player.selected_changed.connect(_refresh_held)
|
||||||
_refresh_held()
|
_refresh_held()
|
||||||
@@ -82,6 +102,17 @@ func _refresh_held() -> void:
|
|||||||
_ghost_yaw = 0.0
|
_ghost_yaw = 0.0
|
||||||
else:
|
else:
|
||||||
_ghost.visible = false
|
_ghost.visible = false
|
||||||
|
# Torch: a flickering light at the head of the held torch.
|
||||||
|
var light: Dictionary = def.get("light", {})
|
||||||
|
_torch_light.visible = not light.is_empty()
|
||||||
|
_torch_flame.emitting = not light.is_empty()
|
||||||
|
if not light.is_empty():
|
||||||
|
_torch_light.omni_range = float(light.get("range", 8.0))
|
||||||
|
_torch_light.light_energy = float(light.get("energy", 1.5))
|
||||||
|
|
||||||
|
## 0..1 warmth from what's held (a torch).
|
||||||
|
func held_heat() -> float:
|
||||||
|
return float(_held_def().get("light", {}).get("heat", 0.0))
|
||||||
|
|
||||||
func _set_prompt(t: String) -> void:
|
func _set_prompt(t: String) -> void:
|
||||||
if t != _prompt:
|
if t != _prompt:
|
||||||
@@ -110,8 +141,16 @@ func _physics_process(delta: float) -> void:
|
|||||||
_set_prompt("[LMB] Place %s [R] Rotate" % def.name)
|
_set_prompt("[LMB] Place %s [R] Rotate" % def.name)
|
||||||
return
|
return
|
||||||
_ghost.visible = false
|
_ghost.visible = false
|
||||||
|
if _torch_light.visible:
|
||||||
|
_t += delta
|
||||||
|
_torch_light.light_energy = float(def.get("light", {}).get("energy", 1.5)) * (0.85 + 0.15 * sin(_t * 17.0) * sin(_t * 5.3))
|
||||||
var t := _target()
|
var t := _target()
|
||||||
_set_prompt(t.interact_prompt(player) if t != null else "")
|
if t != null:
|
||||||
|
_set_prompt(t.interact_prompt(player))
|
||||||
|
elif def.has("food"):
|
||||||
|
_set_prompt("[RMB] Eat %s" % def.name)
|
||||||
|
else:
|
||||||
|
_set_prompt("")
|
||||||
|
|
||||||
func _update_ghost(def: Dictionary) -> void:
|
func _update_ghost(def: Dictionary) -> void:
|
||||||
var cam := get_parent() as Camera3D
|
var cam := get_parent() as Camera3D
|
||||||
@@ -142,7 +181,10 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
player.select(player.selected - 1)
|
player.select(player.selected - 1)
|
||||||
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||||
player.select(player.selected + 1)
|
player.select(player.selected + 1)
|
||||||
if event.is_action_pressed("rotate_piece"):
|
if event.is_action_pressed("use_item") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||||
|
if _held_def().has("food"):
|
||||||
|
Actions.eat(player, player.selected)
|
||||||
|
elif event.is_action_pressed("rotate_piece"):
|
||||||
_ghost_yaw += deg_to_rad(22.5)
|
_ghost_yaw += deg_to_rad(22.5)
|
||||||
elif event.is_action_pressed("interact"):
|
elif event.is_action_pressed("interact"):
|
||||||
var t := _target()
|
var t := _target()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ var inventory := Inventory.new(40) ## slots 0-7 are the hotbar
|
|||||||
var selected := 0
|
var selected := 0
|
||||||
var ui_open := false ## set by the inventory screen; freezes look/move
|
var ui_open := false ## set by the inventory screen; freezes look/move
|
||||||
var hands: Node3D
|
var hands: Node3D
|
||||||
|
var survival: Node ## health / stamina / hunger / warmth (survival.gd)
|
||||||
|
|
||||||
var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)
|
var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)
|
||||||
|
|
||||||
@@ -36,6 +37,11 @@ func _ready() -> void:
|
|||||||
hands.name = "Hands"
|
hands.name = "Hands"
|
||||||
hands.player = self
|
hands.player = self
|
||||||
camera.add_child(hands)
|
camera.add_child(hands)
|
||||||
|
survival = preload("res://scripts/player/survival.gd").new()
|
||||||
|
survival.name = "Survival"
|
||||||
|
survival.player = self
|
||||||
|
survival.world = get_parent()
|
||||||
|
add_child(survival)
|
||||||
|
|
||||||
func held_item_id() -> String:
|
func held_item_id() -> String:
|
||||||
var s = inventory.get_slot(selected)
|
var s = inventory.get_slot(selected)
|
||||||
@@ -70,17 +76,21 @@ func _physics_process(delta: float) -> void:
|
|||||||
elif not is_on_floor():
|
elif not is_on_floor():
|
||||||
velocity.y -= _gravity * delta
|
velocity.y -= _gravity * delta
|
||||||
|
|
||||||
|
if global_position.y < water_level - 0.6:
|
||||||
|
survival.soak() # waist-deep or more: you're wet
|
||||||
|
|
||||||
var jump := Input.is_action_just_pressed("jump") and not ui_open
|
var jump := Input.is_action_just_pressed("jump") and not ui_open
|
||||||
if jump and is_on_floor():
|
var can_swim_jump := global_position.y > float_y - 0.4 and global_position.y < float_y + 0.3
|
||||||
velocity.y = jump_velocity
|
if jump and (is_on_floor() or can_swim_jump) and survival.use_stamina(8.0):
|
||||||
elif jump and global_position.y > float_y - 0.4 and global_position.y < float_y + 0.3:
|
# (A swim-jump kicks you up out of the water onto a bank.)
|
||||||
# Swim-jump: kick up out of the water to climb onto a bank.
|
|
||||||
velocity.y = jump_velocity
|
velocity.y = jump_velocity
|
||||||
|
|
||||||
var input_dir := Vector2.ZERO if ui_open else Input.get_vector("move_left", "move_right", "move_forward", "move_back")
|
var input_dir := Vector2.ZERO if ui_open else Input.get_vector("move_left", "move_right", "move_forward", "move_back")
|
||||||
var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
|
var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
|
||||||
|
|
||||||
var current_speed := sprint_speed if Input.is_key_pressed(KEY_SHIFT) else speed
|
var sprinting: bool = Input.is_key_pressed(KEY_SHIFT) and direction.length() > 0.1 and not ui_open \
|
||||||
|
and survival.use_stamina(14.0 * delta, true)
|
||||||
|
var current_speed := sprint_speed if sprinting else speed
|
||||||
if swimming:
|
if swimming:
|
||||||
current_speed = swim_speed
|
current_speed = swim_speed
|
||||||
if direction:
|
if direction:
|
||||||
|
|||||||
127
scripts/player/survival.gd
Normal file
127
scripts/player/survival.gd
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
extends Node
|
||||||
|
## Survival stats: health, stamina, hunger (satiety) and warmth, each 0..100.
|
||||||
|
##
|
||||||
|
## - Hunger drains over about a day and a quarter, faster when you're cold.
|
||||||
|
## - Warmth moves toward a target: air temperature (day/night, altitude),
|
||||||
|
## plus heat from fires and a held torch, minus being wet.
|
||||||
|
## - Health drains while starving or freezing, and regenerates when fed and warm.
|
||||||
|
## - Stamina pays for sprinting, jumping and swinging, and regenerates after a
|
||||||
|
## short pause.
|
||||||
|
##
|
||||||
|
## step(dt) holds all the rules, so tests can fast-forward time. In M2 this runs
|
||||||
|
## on the server.
|
||||||
|
|
||||||
|
signal changed
|
||||||
|
signal died
|
||||||
|
|
||||||
|
const MAX := 100.0
|
||||||
|
const HUNGER_DRAIN := 100.0 / 1500.0 ## full to empty in ~1.25 in-game days
|
||||||
|
const STAMINA_REGEN := 24.0
|
||||||
|
const STAMINA_DELAY := 1.0
|
||||||
|
const WET_SECONDS := 90.0
|
||||||
|
|
||||||
|
var health := MAX
|
||||||
|
var stamina := MAX
|
||||||
|
var hunger := MAX ## satiety: 100 = full, 0 = starving
|
||||||
|
var warmth := 70.0
|
||||||
|
var wet := 0.0 ## seconds of "Wet" left
|
||||||
|
var heat := 0.0 ## 0..1 from nearby fires etc (last step)
|
||||||
|
var statuses: Array[String] = []
|
||||||
|
var dead := false
|
||||||
|
|
||||||
|
var player: CharacterBody3D
|
||||||
|
var world: Node3D
|
||||||
|
var _stamina_wait := 0.0
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if world != null and not world.menu_mode:
|
||||||
|
step(delta)
|
||||||
|
|
||||||
|
func step(dt: float) -> void:
|
||||||
|
if dead:
|
||||||
|
return
|
||||||
|
var p := player.global_position
|
||||||
|
heat = world.heat_at(p)
|
||||||
|
var target: float = world.air_warmth(p) + heat * 70.0
|
||||||
|
if wet > 0.0:
|
||||||
|
target -= 22.0
|
||||||
|
wet = maxf(wet - dt * (3.0 if heat > 0.3 else 1.0), 0.0) # fires dry you off
|
||||||
|
target = clampf(target, 0.0, MAX)
|
||||||
|
# Warm up faster than you cool down: getting to a fire should feel good.
|
||||||
|
warmth = move_toward(warmth, target, (7.0 if target > warmth else 1.6) * dt)
|
||||||
|
|
||||||
|
hunger = maxf(hunger - HUNGER_DRAIN * dt * (1.5 if warmth < 30.0 else 1.0), 0.0)
|
||||||
|
|
||||||
|
if _stamina_wait > 0.0:
|
||||||
|
_stamina_wait -= dt
|
||||||
|
else:
|
||||||
|
stamina = minf(stamina + STAMINA_REGEN * dt, MAX)
|
||||||
|
|
||||||
|
var dmg := 0.0
|
||||||
|
if hunger <= 0.0:
|
||||||
|
dmg += 0.6
|
||||||
|
if warmth < 12.0:
|
||||||
|
dmg += 0.5
|
||||||
|
if dmg > 0.0:
|
||||||
|
health = maxf(health - dmg * dt, 0.0)
|
||||||
|
elif hunger > 40.0 and warmth > 35.0:
|
||||||
|
health = minf(health + 0.5 * dt, MAX)
|
||||||
|
|
||||||
|
_update_statuses()
|
||||||
|
changed.emit()
|
||||||
|
if health <= 0.0:
|
||||||
|
dead = true
|
||||||
|
died.emit()
|
||||||
|
|
||||||
|
func _update_statuses() -> void:
|
||||||
|
statuses.clear()
|
||||||
|
if heat > 0.25:
|
||||||
|
statuses.append("Warm")
|
||||||
|
if warmth < 12.0:
|
||||||
|
statuses.append("Freezing")
|
||||||
|
elif warmth < 30.0:
|
||||||
|
statuses.append("Cold")
|
||||||
|
if hunger <= 0.0:
|
||||||
|
statuses.append("Starving")
|
||||||
|
elif hunger < 25.0:
|
||||||
|
statuses.append("Hungry")
|
||||||
|
if wet > 0.0:
|
||||||
|
statuses.append("Wet")
|
||||||
|
|
||||||
|
## Spends stamina for an action. Returns false (and spends nothing) if you're
|
||||||
|
## too tired. Continuous costs (sprinting) pass `partial = true`.
|
||||||
|
func use_stamina(amount: float, partial: bool = false) -> bool:
|
||||||
|
if stamina <= 0.0 or (not partial and stamina < amount * 0.5):
|
||||||
|
return false
|
||||||
|
stamina = maxf(stamina - amount, 0.0)
|
||||||
|
_stamina_wait = STAMINA_DELAY
|
||||||
|
return true
|
||||||
|
|
||||||
|
func eat(food: Dictionary) -> void:
|
||||||
|
hunger = minf(hunger + float(food.get("hunger", 0)), MAX)
|
||||||
|
health = minf(health + float(food.get("health", 0)), MAX)
|
||||||
|
changed.emit()
|
||||||
|
|
||||||
|
func soak() -> void:
|
||||||
|
wet = WET_SECONDS
|
||||||
|
|
||||||
|
func reset_after_death() -> void:
|
||||||
|
dead = false
|
||||||
|
health = 50.0
|
||||||
|
stamina = MAX
|
||||||
|
hunger = 50.0
|
||||||
|
warmth = 60.0
|
||||||
|
wet = 0.0
|
||||||
|
changed.emit()
|
||||||
|
|
||||||
|
func to_data() -> Dictionary:
|
||||||
|
return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet}
|
||||||
|
|
||||||
|
func load_data(d: Dictionary) -> void:
|
||||||
|
health = float(d.get("health", health))
|
||||||
|
stamina = float(d.get("stamina", stamina))
|
||||||
|
hunger = float(d.get("hunger", hunger))
|
||||||
|
warmth = float(d.get("warmth", warmth))
|
||||||
|
wet = float(d.get("wet", 0.0))
|
||||||
|
dead = false
|
||||||
|
changed.emit()
|
||||||
1
scripts/player/survival.gd.uid
Normal file
1
scripts/player/survival.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ibwy562x1kqp
|
||||||
@@ -15,6 +15,9 @@ var _hotbar: Array = []
|
|||||||
var _toasts: VBoxContainer
|
var _toasts: VBoxContainer
|
||||||
var _screen: Control
|
var _screen: Control
|
||||||
var _menu: Control
|
var _menu: Control
|
||||||
|
var _bars: Dictionary = {}
|
||||||
|
var _status: Label
|
||||||
|
var _clock: Label
|
||||||
|
|
||||||
func bind(p: CharacterBody3D) -> void:
|
func bind(p: CharacterBody3D) -> void:
|
||||||
player = p
|
player = p
|
||||||
@@ -74,6 +77,8 @@ func bind(p: CharacterBody3D) -> void:
|
|||||||
_toasts.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
_toasts.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
root.add_child(_toasts)
|
root.add_child(_toasts)
|
||||||
|
|
||||||
|
_build_stats(root)
|
||||||
|
|
||||||
_screen = InventoryScreen.new()
|
_screen = InventoryScreen.new()
|
||||||
root.add_child(_screen)
|
root.add_child(_screen)
|
||||||
_screen.bind(player)
|
_screen.bind(player)
|
||||||
@@ -92,6 +97,63 @@ func bind(p: CharacterBody3D) -> void:
|
|||||||
ItemDB.icons_ready.connect(_refresh)
|
ItemDB.icons_ready.connect(_refresh)
|
||||||
_refresh()
|
_refresh()
|
||||||
|
|
||||||
|
## Bars bottom-left (health, stamina, food, warmth), status tags above them,
|
||||||
|
## clock top-right.
|
||||||
|
func _build_stats(root: Control) -> void:
|
||||||
|
var box := VBoxContainer.new()
|
||||||
|
box.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
|
||||||
|
box.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||||
|
box.offset_left = 22
|
||||||
|
box.offset_bottom = -22
|
||||||
|
box.add_theme_constant_override("separation", 5)
|
||||||
|
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
root.add_child(box)
|
||||||
|
_status = UITheme.label("", 15, UITheme.TEXT)
|
||||||
|
box.add_child(_status)
|
||||||
|
for spec in [["health", "Health", Color(0.85, 0.22, 0.2)], ["stamina", "Stamina", Color(0.9, 0.78, 0.25)],
|
||||||
|
["hunger", "Food", Color(0.85, 0.5, 0.2)], ["warmth", "Warmth", Color(0.95, 0.55, 0.3)]]:
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 8)
|
||||||
|
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
var name_l := UITheme.label(spec[1], 14, UITheme.TEXT_DIM)
|
||||||
|
name_l.custom_minimum_size = Vector2(62, 0)
|
||||||
|
row.add_child(name_l)
|
||||||
|
var bar := ProgressBar.new()
|
||||||
|
bar.custom_minimum_size = Vector2(190, 14)
|
||||||
|
bar.show_percentage = false
|
||||||
|
bar.max_value = 100.0
|
||||||
|
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
bar.add_theme_stylebox_override("background", UITheme.box(Color(0, 0, 0, 0.55), UITheme.SLOT_BORDER, 1, 4, 0))
|
||||||
|
bar.add_theme_stylebox_override("fill", UITheme.box(spec[2], spec[2].lightened(0.2), 1, 4, 0))
|
||||||
|
row.add_child(bar)
|
||||||
|
box.add_child(row)
|
||||||
|
_bars[spec[0]] = bar
|
||||||
|
_clock = UITheme.label("", 18)
|
||||||
|
_clock.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
|
||||||
|
_clock.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||||
|
_clock.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||||
|
_clock.offset_right = -22
|
||||||
|
_clock.offset_top = 14
|
||||||
|
root.add_child(_clock)
|
||||||
|
|
||||||
|
func _process(_d: float) -> void:
|
||||||
|
var s = player.survival
|
||||||
|
for k in _bars:
|
||||||
|
_bars[k].value = s.get(k)
|
||||||
|
# Warmth bar shifts from icy blue (cold) to orange (warm).
|
||||||
|
var w: float = s.warmth / 100.0
|
||||||
|
var wc := Color(0.35, 0.6, 0.95).lerp(Color(0.95, 0.55, 0.3), w)
|
||||||
|
(_bars["warmth"].get_theme_stylebox("fill") as StyleBoxFlat).bg_color = wc
|
||||||
|
_status.text = " ".join(s.statuses)
|
||||||
|
var dn = player.get_parent().day_night
|
||||||
|
if dn != null:
|
||||||
|
_clock.text = dn.clock_text()
|
||||||
|
|
||||||
|
## Closes the inventory / menu (e.g. on death).
|
||||||
|
func close_screens() -> void:
|
||||||
|
if _screen.is_open():
|
||||||
|
_screen.close()
|
||||||
|
|
||||||
func _on_inventory_requested(container: Node) -> void:
|
func _on_inventory_requested(container: Node) -> void:
|
||||||
if not _screen.is_open():
|
if not _screen.is_open():
|
||||||
_screen.open(container)
|
_screen.open(container)
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func close() -> void:
|
|||||||
if left > 0 and container != null:
|
if left > 0 and container != null:
|
||||||
left = container.inventory.add(_cursor.id, left)
|
left = container.inventory.add(_cursor.id, left)
|
||||||
_cursor = null
|
_cursor = null
|
||||||
if container != null and container.inventory.changed.is_connected(_refresh):
|
if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh):
|
||||||
container.inventory.changed.disconnect(_refresh)
|
container.inventory.changed.disconnect(_refresh)
|
||||||
container = null
|
container = null
|
||||||
visible = false
|
visible = false
|
||||||
@@ -147,12 +147,17 @@ func _input(event: InputEvent) -> void:
|
|||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
elif event is InputEventMouseMotion:
|
elif event is InputEventMouseMotion:
|
||||||
_cursor_view.global_position = event.position
|
_cursor_view.global_position = event.position
|
||||||
# Walking away from a chest closes it.
|
|
||||||
if container != null and player.global_position.distance_to(container.global_position) > 5.0:
|
|
||||||
close()
|
|
||||||
|
|
||||||
func _process(_d: float) -> void:
|
func _process(_d: float) -> void:
|
||||||
if visible and container != null and player.global_position.distance_to(container.global_position) > 5.0:
|
if not visible or container == null:
|
||||||
|
return
|
||||||
|
# Containers can vanish while open (an emptied grave crumbles).
|
||||||
|
if not is_instance_valid(container) or container.is_queued_for_deletion():
|
||||||
|
container = null
|
||||||
|
_rebuild_chest()
|
||||||
|
return
|
||||||
|
if player.global_position.distance_to(container.global_position) > 5.0:
|
||||||
close()
|
close()
|
||||||
|
|
||||||
func _rebuild_chest() -> void:
|
func _rebuild_chest() -> void:
|
||||||
@@ -177,7 +182,7 @@ func _on_player_slot(i: int, right: bool, shift: bool) -> void:
|
|||||||
_refresh()
|
_refresh()
|
||||||
|
|
||||||
func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
|
func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
|
||||||
if not Actions._in_reach(player, container, 1.0):
|
if container == null or not is_instance_valid(container) or not Actions._in_reach(player, container, 1.0):
|
||||||
return
|
return
|
||||||
if shift:
|
if shift:
|
||||||
Actions.transfer(player, container.inventory, i, player.inventory, container)
|
Actions.transfer(player, container.inventory, i, player.inventory, container)
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const UITheme = preload("res://scripts/ui/theme.gd")
|
|||||||
|
|
||||||
const CONTROLS := [
|
const CONTROLS := [
|
||||||
["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"],
|
["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"],
|
||||||
["Mouse", "Look"], ["E", "Interact / pick up"], ["Left click", "Use tool / place"],
|
["Mouse", "Look"], ["E", "Interact / pick up / add fuel"], ["Left click", "Use tool / place"],
|
||||||
|
["Right click (holding food)", "Eat"],
|
||||||
["R", "Rotate piece"], ["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting"],
|
["R", "Rotate piece"], ["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting"],
|
||||||
["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)"],
|
||||||
|
|||||||
116
scripts/world/day_night.gd
Normal file
116
scripts/world/day_night.gd
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
extends Node
|
||||||
|
## Day/night cycle. Moves the sun along its arc and drives sky colours, sun and
|
||||||
|
## moon light, ambient light and fog from the time of day.
|
||||||
|
##
|
||||||
|
## Time is a fraction of a day (0 = midnight, 0.25 = 06:00 sunrise,
|
||||||
|
## 0.5 = noon, 0.75 = 18:00 sunset). One day lasts `day_length` real seconds
|
||||||
|
## (20 min). Saved with the world (see world.gd collect_save).
|
||||||
|
|
||||||
|
signal new_day(day: int)
|
||||||
|
|
||||||
|
@export var day_length := 1200.0
|
||||||
|
@export var sun_tilt_deg := 35.0 ## arc leans toward +Z, so noon sun sits at 55°
|
||||||
|
const DAY_BIAS := 0.2
|
||||||
|
|
||||||
|
var day := 1
|
||||||
|
var time := 8.0 / 24.0
|
||||||
|
var frozen := false ## title-screen backdrop: hold a fixed time
|
||||||
|
|
||||||
|
var sun: DirectionalLight3D
|
||||||
|
var moon: DirectionalLight3D
|
||||||
|
var env: Environment
|
||||||
|
var sky_mat: ShaderMaterial
|
||||||
|
var daylight := 1.0 ## 0 at night, 1 in full day (smooth)
|
||||||
|
var sun_height := 1.0 ## sin of sun elevation (-1..1)
|
||||||
|
var _sky_timer := 0.0
|
||||||
|
|
||||||
|
# Palette: [zenith, horizon, fog]
|
||||||
|
const DAY_COLS := [Color(0.24, 0.44, 0.72), Color(0.72, 0.79, 0.85), Color(0.70, 0.77, 0.85)]
|
||||||
|
const DUSK_COLS := [Color(0.22, 0.27, 0.48), Color(0.98, 0.62, 0.38), Color(0.82, 0.62, 0.50)]
|
||||||
|
const NIGHT_COLS := [Color(0.02, 0.03, 0.07), Color(0.07, 0.09, 0.16), Color(0.07, 0.09, 0.14)]
|
||||||
|
|
||||||
|
func setup(world_sun: DirectionalLight3D, environment: Environment) -> void:
|
||||||
|
sun = world_sun
|
||||||
|
env = environment
|
||||||
|
sky_mat = env.sky.sky_material as ShaderMaterial
|
||||||
|
moon = DirectionalLight3D.new()
|
||||||
|
moon.name = "Moon"
|
||||||
|
moon.light_color = Color(0.55, 0.65, 1.0)
|
||||||
|
moon.shadow_enabled = false
|
||||||
|
moon.directional_shadow_max_distance = 120.0
|
||||||
|
moon.sky_mode = DirectionalLight3D.SKY_MODE_LIGHT_ONLY # keep the sky shader's LIGHT0 = sun
|
||||||
|
sun.add_sibling(moon)
|
||||||
|
apply(true)
|
||||||
|
|
||||||
|
func hours() -> float:
|
||||||
|
return time * 24.0
|
||||||
|
|
||||||
|
func clock_text() -> String:
|
||||||
|
var h := hours()
|
||||||
|
return "Day %d %02d:%02d" % [day, int(h), int(fmod(h, 1.0) * 60.0)]
|
||||||
|
|
||||||
|
func is_night() -> bool:
|
||||||
|
return daylight < 0.3
|
||||||
|
|
||||||
|
func advance(seconds: float) -> void:
|
||||||
|
time += seconds / day_length
|
||||||
|
while time >= 1.0:
|
||||||
|
time -= 1.0
|
||||||
|
day += 1
|
||||||
|
new_day.emit(day)
|
||||||
|
apply(false)
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not frozen:
|
||||||
|
advance(delta)
|
||||||
|
|
||||||
|
## Updates lights every call; the sky material (which triggers a radiance
|
||||||
|
## rebuild) only a few times a second unless `force`.
|
||||||
|
func apply(force: bool) -> void:
|
||||||
|
if sun == null:
|
||||||
|
return
|
||||||
|
var a := (time - 0.25) * TAU
|
||||||
|
var t := deg_to_rad(sun_tilt_deg)
|
||||||
|
# +DAY_BIAS lifts the whole arc: sunrise ~05:00, sunset ~19:00, so days
|
||||||
|
# are longer than nights and dusk lingers.
|
||||||
|
var to_sun := Vector3(cos(a), sin(a) * cos(t) + DAY_BIAS, sin(a) * sin(t)).normalized()
|
||||||
|
sun_height = to_sun.y
|
||||||
|
daylight = smoothstep(-0.2, 0.25, to_sun.y)
|
||||||
|
var low := 1.0 - smoothstep(0.05, 0.45, absf(to_sun.y)) # 1 near the horizon
|
||||||
|
|
||||||
|
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_color = Color(1.0, 0.95, 0.83).lerp(Color(1.0, 0.58, 0.32), low)
|
||||||
|
sun.visible = sun.light_energy > 0.01
|
||||||
|
sun.shadow_enabled = sun.visible
|
||||||
|
|
||||||
|
var moon_dir := Vector3(-to_sun.x, absf(to_sun.y) * 0.8 + 0.25, -to_sun.z * 0.5).normalized()
|
||||||
|
moon.global_basis = Basis.looking_at(-moon_dir, Vector3.FORWARD)
|
||||||
|
moon.light_energy = 0.38 * (1.0 - daylight)
|
||||||
|
moon.visible = moon.light_energy > 0.01
|
||||||
|
moon.shadow_enabled = moon.visible and not sun.visible
|
||||||
|
|
||||||
|
env.ambient_light_energy = lerpf(0.3, 1.0, daylight)
|
||||||
|
env.fog_light_energy = lerpf(0.25, 1.0, daylight)
|
||||||
|
env.volumetric_fog_density = lerpf(0.0025, 0.0045, daylight)
|
||||||
|
|
||||||
|
_sky_timer -= get_process_delta_time()
|
||||||
|
if not force and _sky_timer > 0.0:
|
||||||
|
return
|
||||||
|
_sky_timer = 0.25
|
||||||
|
var cols: Array = []
|
||||||
|
for i in range(3):
|
||||||
|
var dayc: Color = DAY_COLS[i].lerp(DUSK_COLS[i], low * daylight)
|
||||||
|
cols.append(NIGHT_COLS[i].lerp(dayc, daylight))
|
||||||
|
env.fog_light_color = cols[2]
|
||||||
|
if sky_mat != null:
|
||||||
|
sky_mat.set_shader_parameter("zenith_col", cols[0])
|
||||||
|
sky_mat.set_shader_parameter("horizon_col", cols[1])
|
||||||
|
sky_mat.set_shader_parameter("energy", lerpf(0.35, 1.0, daylight))
|
||||||
|
sky_mat.set_shader_parameter("star_strength", 1.0 - smoothstep(0.0, 0.35, daylight))
|
||||||
|
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("sun_tint", Color(1.0, 0.86, 0.62).lerp(Color(1.0, 0.55, 0.3), low))
|
||||||
|
|
||||||
|
## 0..100 "how warm the air is" before fires, shelter and clothes.
|
||||||
|
func air_warmth() -> float:
|
||||||
|
return lerpf(22.0, 68.0, daylight)
|
||||||
1
scripts/world/day_night.gd.uid
Normal file
1
scripts/world/day_night.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://2vp5n05qr6yn
|
||||||
59
scripts/world/forage.gd
Normal file
59
scripts/world/forage.gd
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
extends Area3D
|
||||||
|
## A berry bush you can forage with E: berries plus some fiber. It's then bare
|
||||||
|
## until it regrows (`regrow_seconds` of play time). Walk-through, on
|
||||||
|
## collision layer 2 like pickups. Regrow timers are saved (props.forage).
|
||||||
|
|
||||||
|
const ItemMeshes = preload("res://scripts/items/item_meshes.gd")
|
||||||
|
|
||||||
|
var uid := ""
|
||||||
|
var regrow_seconds := 600.0
|
||||||
|
var regrow_left := 0.0 ## 0 = ripe
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
var _mesh: MeshInstance3D
|
||||||
|
|
||||||
|
func setup(material: Material) -> void:
|
||||||
|
collision_layer = 2
|
||||||
|
collision_mask = 0
|
||||||
|
monitoring = false
|
||||||
|
_mesh = MeshInstance3D.new()
|
||||||
|
_mesh.material_override = material
|
||||||
|
add_child(_mesh)
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var sph := SphereShape3D.new()
|
||||||
|
sph.radius = 0.7
|
||||||
|
cs.shape = sph
|
||||||
|
cs.position = Vector3(0, 0.5, 0)
|
||||||
|
add_child(cs)
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
func ripe() -> bool:
|
||||||
|
return regrow_left <= 0.0
|
||||||
|
|
||||||
|
func _refresh() -> void:
|
||||||
|
_mesh.mesh = ItemMeshes.build("berry_bush" if ripe() else "berry_bush_bare")
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if regrow_left > 0.0:
|
||||||
|
regrow_left = maxf(regrow_left - delta, 0.0)
|
||||||
|
if regrow_left <= 0.0:
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
func interact_prompt(_player: Node) -> String:
|
||||||
|
if ripe():
|
||||||
|
return "Berry bush [E] Pick berries"
|
||||||
|
return "Berry bush (regrowing, %d min)" % ceili(regrow_left / 60.0)
|
||||||
|
|
||||||
|
func interact(player: Node) -> void:
|
||||||
|
Actions.forage(player, self)
|
||||||
|
|
||||||
|
## Called by Actions.forage. Returns what was picked.
|
||||||
|
func harvest() -> Dictionary:
|
||||||
|
if not ripe():
|
||||||
|
return {}
|
||||||
|
regrow_left = regrow_seconds
|
||||||
|
_refresh()
|
||||||
|
return {"berries": rng.randi_range(2, 4), "fiber": rng.randi_range(1, 2)}
|
||||||
|
|
||||||
|
func restore(left: float) -> void:
|
||||||
|
regrow_left = left
|
||||||
|
_refresh()
|
||||||
1
scripts/world/forage.gd.uid
Normal file
1
scripts/world/forage.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ceb8l5850ldj4
|
||||||
@@ -13,6 +13,7 @@ extends Node3D
|
|||||||
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
|
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
|
||||||
const ResourceNodeScript = preload("res://scripts/world/resource_node.gd")
|
const ResourceNodeScript = preload("res://scripts/world/resource_node.gd")
|
||||||
const PickupScript = preload("res://scripts/world/pickup.gd")
|
const PickupScript = preload("res://scripts/world/pickup.gd")
|
||||||
|
const ForageScript = preload("res://scripts/world/forage.gd")
|
||||||
|
|
||||||
const CELL := 48.0
|
const CELL := 48.0
|
||||||
const GRASS_TILE := 12.0
|
const GRASS_TILE := 12.0
|
||||||
@@ -40,6 +41,7 @@ var _resources: Node3D ## harvestable trees/boulders (each its own body)
|
|||||||
## creation order gives the same id to the same tree on every load.
|
## creation order gives the same id to the same tree on every load.
|
||||||
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 _bark_mat: StandardMaterial3D
|
var _bark_mat: StandardMaterial3D
|
||||||
var _leaf_mat: ShaderMaterial
|
var _leaf_mat: ShaderMaterial
|
||||||
@@ -500,8 +502,9 @@ func _scatter_pickups() -> void:
|
|||||||
add_child(root)
|
add_child(root)
|
||||||
var rng := RandomNumberGenerator.new()
|
var rng := RandomNumberGenerator.new()
|
||||||
rng.seed = world_seed + 6
|
rng.seed = world_seed + 6
|
||||||
var want := {"stick": 260, "stone": 170, "flint": 80}
|
# Order matters: new kinds go at the END so existing saves keep their ids.
|
||||||
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25}
|
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110}
|
||||||
|
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25}
|
||||||
for id: String in want:
|
for id: String in want:
|
||||||
var mesh := ItemDB.mesh_for(id)
|
var mesh := ItemDB.mesh_for(id)
|
||||||
var placed := 0
|
var placed := 0
|
||||||
@@ -525,6 +528,8 @@ func _scatter_pickups() -> void:
|
|||||||
"flint":
|
"flint":
|
||||||
var shore := 1.0 - smoothstep(terrain.water_level + 0.5, terrain.water_level + 4.0, h)
|
var shore := 1.0 - smoothstep(terrain.water_level + 0.5, terrain.water_level + 4.0, h)
|
||||||
chance = 0.06 + shore * 0.9 + (1.0 - n.y) * 2.0
|
chance = 0.06 + shore * 0.9 + (1.0 - n.y) * 2.0
|
||||||
|
"mushroom":
|
||||||
|
chance = _forest(x, z) * 0.7 # deep forest only
|
||||||
# A few right by the spawn so the first craft is quick.
|
# A few right by the spawn so the first craft is quick.
|
||||||
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0:
|
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0:
|
||||||
chance += 0.5
|
chance += 0.5
|
||||||
@@ -537,6 +542,48 @@ func _scatter_pickups() -> void:
|
|||||||
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
|
||||||
|
_scatter_berry_bushes(root)
|
||||||
|
|
||||||
|
## Berry bushes along forest edges and in meadows, plus a couple near spawn.
|
||||||
|
func _scatter_berry_bushes(root: Node3D) -> void:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = world_seed + 7
|
||||||
|
var placed := 0
|
||||||
|
var tries := 0
|
||||||
|
while placed < 70 and tries < 8000:
|
||||||
|
tries += 1
|
||||||
|
var x := rng.randf_range(-terrain.play_radius, terrain.play_radius)
|
||||||
|
var z := rng.randf_range(-terrain.play_radius, terrain.play_radius)
|
||||||
|
var yaw := rng.randf() * TAU
|
||||||
|
var keep := rng.randf()
|
||||||
|
if not terrain.inside_boundary(x, z, 4.0):
|
||||||
|
continue
|
||||||
|
var h: float = terrain.sample_height(x, z)
|
||||||
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
|
if h < terrain.water_level + 0.6 or n.y < 0.85:
|
||||||
|
continue
|
||||||
|
var f := _forest(x, z)
|
||||||
|
var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5
|
||||||
|
var near_spawn := Vector2(x - _spawn.x, z - _spawn.z).length()
|
||||||
|
if near_spawn < 6.0:
|
||||||
|
continue
|
||||||
|
if near_spawn < 25.0:
|
||||||
|
chance += 0.4
|
||||||
|
if keep > chance:
|
||||||
|
continue
|
||||||
|
var bush: Area3D = ForageScript.new()
|
||||||
|
bush.uid = "b%d" % forage.size()
|
||||||
|
bush.rng.seed = hash(bush.uid) ^ world_seed
|
||||||
|
bush.setup(_leaf_mat_static())
|
||||||
|
root.add_child(bush)
|
||||||
|
bush.global_transform = Transform3D(_ground_basis(n, 0.5, yaw, rng.randf_range(0.9, 1.2)), Vector3(x, h - 0.05, z))
|
||||||
|
forage[bush.uid] = bush
|
||||||
|
placed += 1
|
||||||
|
|
||||||
|
## Berry-bush meshes carry their own vertex colours (including red berries),
|
||||||
|
## so they use the plain item material rather than the wind foliage shader.
|
||||||
|
func _leaf_mat_static() -> Material:
|
||||||
|
return ItemDB.item_material()
|
||||||
|
|
||||||
# --- grass streaming --------------------------------------------------------
|
# --- grass streaming --------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ extends Node3D
|
|||||||
## Dev hook: `godot --path . -- --shot=/abs/prefix` renders a few fixed views to
|
## Dev hook: `godot --path . -- --shot=/abs/prefix` renders a few fixed views to
|
||||||
## <prefix>_<view>.png and quits. Handy for checking visual changes.
|
## <prefix>_<view>.png and quits. Handy for checking visual changes.
|
||||||
|
|
||||||
@export var sun_elevation_deg: float = 34.0
|
|
||||||
@export var sun_azimuth_deg: float = 40.0 ## 0 = behind the spawn view, + = to its right
|
|
||||||
|
|
||||||
@onready var terrain: Node3D = $Terrain
|
@onready var terrain: Node3D = $Terrain
|
||||||
@onready var props: Node3D = $Props
|
@onready var props: Node3D = $Props
|
||||||
@@ -22,6 +20,7 @@ const PIECES := {
|
|||||||
"campfire": preload("res://scripts/building/campfire.gd"),
|
"campfire": preload("res://scripts/building/campfire.gd"),
|
||||||
"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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTOSAVE_SECONDS := 120.0
|
const AUTOSAVE_SECONDS := 120.0
|
||||||
@@ -38,12 +37,20 @@ var _session_time := 0.0 ## seconds played since the last save
|
|||||||
var _autosave_left := AUTOSAVE_SECONDS
|
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
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_pieces = Node3D.new()
|
_pieces = Node3D.new()
|
||||||
_pieces.name = "Pieces"
|
_pieces.name = "Pieces"
|
||||||
add_child(_pieces)
|
add_child(_pieces)
|
||||||
|
day_night = preload("res://scripts/world/day_night.gd").new()
|
||||||
|
day_night.name = "DayNight"
|
||||||
|
add_child(day_night)
|
||||||
|
day_night.setup(sun, $WorldEnvironment.environment)
|
||||||
if menu_mode:
|
if menu_mode:
|
||||||
|
day_night.time = 9.5 / 24.0
|
||||||
|
day_night.frozen = true
|
||||||
|
day_night.apply(true)
|
||||||
player.process_mode = Node.PROCESS_MODE_DISABLED
|
player.process_mode = Node.PROCESS_MODE_DISABLED
|
||||||
player.get_node("Camera3D").current = false
|
player.get_node("Camera3D").current = false
|
||||||
_menu_cam = Camera3D.new()
|
_menu_cam = Camera3D.new()
|
||||||
@@ -54,10 +61,11 @@ func _ready() -> void:
|
|||||||
hud = preload("res://scripts/ui/hud.gd").new()
|
hud = preload("res://scripts/ui/hud.gd").new()
|
||||||
add_child(hud)
|
add_child(hud)
|
||||||
hud.bind(player)
|
hud.bind(player)
|
||||||
|
if not menu_mode:
|
||||||
|
player.survival.died.connect(_on_player_died)
|
||||||
if Saves.has_world() and not menu_mode:
|
if Saves.has_world() and not menu_mode:
|
||||||
# Save on window close instead of quitting straight away.
|
# Save on window close instead of quitting straight away.
|
||||||
get_tree().auto_accept_quit = false
|
get_tree().auto_accept_quit = false
|
||||||
_aim_sun()
|
|
||||||
player.set_physics_process(false)
|
player.set_physics_process(false)
|
||||||
set_physics_process(false)
|
set_physics_process(false)
|
||||||
if terrain.built:
|
if terrain.built:
|
||||||
@@ -65,11 +73,44 @@ func _ready() -> void:
|
|||||||
else:
|
else:
|
||||||
terrain.ready_built.connect(_on_terrain_ready)
|
terrain.ready_built.connect(_on_terrain_ready)
|
||||||
|
|
||||||
func _aim_sun() -> void:
|
# --- survival environment ---------------------------------------------------
|
||||||
var el := deg_to_rad(sun_elevation_deg)
|
|
||||||
var az := deg_to_rad(sun_azimuth_deg)
|
## 0..100 air warmth at `pos` before fires: time of day, minus altitude.
|
||||||
var to_sun := Vector3(sin(az) * cos(el), sin(el), cos(az) * cos(el))
|
func air_warmth(pos: Vector3) -> float:
|
||||||
sun.global_basis = Basis.looking_at(-to_sun, Vector3.UP)
|
var w: float = day_night.air_warmth()
|
||||||
|
w -= maxf(pos.y - 12.0, 0.0) * 0.9
|
||||||
|
return w
|
||||||
|
|
||||||
|
## 0..1 heat from fires and a held torch.
|
||||||
|
func heat_at(pos: Vector3) -> float:
|
||||||
|
var h := 0.0
|
||||||
|
for n in get_tree().get_nodes_in_group("heat"):
|
||||||
|
h = maxf(h, n.heat_at(pos))
|
||||||
|
if player.hands != null:
|
||||||
|
h = maxf(h, player.hands.held_heat())
|
||||||
|
return h
|
||||||
|
|
||||||
|
## Death: everything you carried goes into a grave where you fell, and you
|
||||||
|
## wake up at the island start. The world carries on.
|
||||||
|
func _on_player_died() -> void:
|
||||||
|
var pos := player.global_position
|
||||||
|
pos.y = terrain.sample_height(pos.x, pos.z)
|
||||||
|
if pos.y < terrain.water_level:
|
||||||
|
# Drowned or died in the lake: put the grave on the nearest dry shore.
|
||||||
|
pos = terrain.find_spawn(Vector2(pos.x, pos.z))
|
||||||
|
var has_items := false
|
||||||
|
for st in player.inventory.slots:
|
||||||
|
if st != null:
|
||||||
|
has_items = true
|
||||||
|
if has_items:
|
||||||
|
var grave := spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, player.rotation.y), pos))
|
||||||
|
grave.inventory.load_data(player.inventory.to_data())
|
||||||
|
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
|
||||||
|
if hud != null:
|
||||||
|
hud.close_screens()
|
||||||
|
_respawn()
|
||||||
|
player.survival.reset_after_death()
|
||||||
|
Actions.notify.emit("You died. Your belongings are in your grave." if has_items else "You died.")
|
||||||
|
|
||||||
func _on_terrain_ready() -> void:
|
func _on_terrain_ready() -> void:
|
||||||
var t0 := Time.get_ticks_msec()
|
var t0 := Time.get_ticks_msec()
|
||||||
@@ -146,6 +187,11 @@ func collect_save() -> Dictionary:
|
|||||||
harvested[uid] = 0.0
|
harvested[uid] = 0.0
|
||||||
elif n.hp < n.max_hp:
|
elif n.hp < n.max_hp:
|
||||||
harvested[uid] = n.hp
|
harvested[uid] = n.hp
|
||||||
|
var regrow := {}
|
||||||
|
for uid in props.forage:
|
||||||
|
var f = props.forage[uid]
|
||||||
|
if is_instance_valid(f) and f.regrow_left > 0.0:
|
||||||
|
regrow[uid] = f.regrow_left
|
||||||
var taken: Array = []
|
var taken: Array = []
|
||||||
for uid in props.pickups:
|
for uid in props.pickups:
|
||||||
var pk = props.pickups[uid]
|
var pk = props.pickups[uid]
|
||||||
@@ -158,6 +204,8 @@ func collect_save() -> Dictionary:
|
|||||||
var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)}
|
var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)}
|
||||||
if piece.get("inventory") != null:
|
if piece.get("inventory") != null:
|
||||||
entry["inventory"] = piece.inventory.to_data()
|
entry["inventory"] = piece.inventory.to_data()
|
||||||
|
if piece.has_method("save_extra"):
|
||||||
|
entry["extra"] = piece.save_extra()
|
||||||
pieces.append(entry)
|
pieces.append(entry)
|
||||||
return {
|
return {
|
||||||
"player": {
|
"player": {
|
||||||
@@ -166,7 +214,10 @@ func collect_save() -> Dictionary:
|
|||||||
"pitch": cam.rotation.x,
|
"pitch": cam.rotation.x,
|
||||||
"inventory": player.inventory.to_data(),
|
"inventory": player.inventory.to_data(),
|
||||||
"selected": player.selected,
|
"selected": player.selected,
|
||||||
|
"stats": player.survival.to_data(),
|
||||||
},
|
},
|
||||||
|
"time": {"day": day_night.day, "time": day_night.time},
|
||||||
|
"forage_regrow": regrow,
|
||||||
"harvested": harvested,
|
"harvested": harvested,
|
||||||
"pickups_taken": taken,
|
"pickups_taken": taken,
|
||||||
"pieces": pieces,
|
"pieces": pieces,
|
||||||
@@ -181,6 +232,17 @@ func apply_save(s: Dictionary) -> void:
|
|||||||
if p.has("inventory"):
|
if p.has("inventory"):
|
||||||
player.inventory.load_data(p.inventory)
|
player.inventory.load_data(p.inventory)
|
||||||
player.select(int(p.get("selected", 0)))
|
player.select(int(p.get("selected", 0)))
|
||||||
|
if p.has("stats"):
|
||||||
|
player.survival.load_data(p.stats)
|
||||||
|
var t: Dictionary = s.get("time", {})
|
||||||
|
if not t.is_empty():
|
||||||
|
day_night.day = int(t.get("day", 1))
|
||||||
|
day_night.time = float(t.get("time", day_night.time))
|
||||||
|
day_night.apply(true)
|
||||||
|
var regrow: Dictionary = s.get("forage_regrow", {})
|
||||||
|
for uid in regrow:
|
||||||
|
if props.forage.has(uid):
|
||||||
|
props.forage[uid].restore(float(regrow[uid]))
|
||||||
var harvested: Dictionary = s.get("harvested", {})
|
var harvested: Dictionary = s.get("harvested", {})
|
||||||
for uid in harvested:
|
for uid in harvested:
|
||||||
if props.resource_nodes.has(uid):
|
if props.resource_nodes.has(uid):
|
||||||
@@ -195,6 +257,8 @@ func apply_save(s: Dictionary) -> void:
|
|||||||
var piece := spawn_piece(def, _array_to_xf(e.xf))
|
var piece := spawn_piece(def, _array_to_xf(e.xf))
|
||||||
if e.has("inventory") and piece.get("inventory") != null:
|
if e.has("inventory") and piece.get("inventory") != null:
|
||||||
piece.inventory.load_data(e.inventory)
|
piece.inventory.load_data(e.inventory)
|
||||||
|
if e.has("extra") and piece.has_method("load_extra"):
|
||||||
|
piece.load_extra(e.extra)
|
||||||
|
|
||||||
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)
|
||||||
@@ -322,6 +386,19 @@ func _capture_survival(prefix: String, cam: Camera3D) -> void:
|
|||||||
get_viewport().get_texture().get_image().save_png("%s_camp.png" % prefix)
|
get_viewport().get_texture().get_image().save_png("%s_camp.png" % prefix)
|
||||||
print("[world] saved %s_camp.png" % prefix)
|
print("[world] saved %s_camp.png" % prefix)
|
||||||
|
|
||||||
|
# Same camp at dusk and at night, torch in hand.
|
||||||
|
inv.set_slot(1, {"id": "torch", "count": 1})
|
||||||
|
player.select(1)
|
||||||
|
for shot in [["dusk", 18.3], ["night", 22.5]]:
|
||||||
|
day_night.time = float(shot[1]) / 24.0
|
||||||
|
day_night.apply(true)
|
||||||
|
await get_tree().create_timer(1.5).timeout
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
get_viewport().get_texture().get_image().save_png("%s_camp_%s.png" % [prefix, shot[0]])
|
||||||
|
print("[world] saved %s_camp_%s.png" % [prefix, shot[0]])
|
||||||
|
day_night.time = 9.0 / 24.0
|
||||||
|
day_night.apply(true)
|
||||||
|
|
||||||
chest.inventory.add("wood", 40)
|
chest.inventory.add("wood", 40)
|
||||||
chest.inventory.add("stone", 12)
|
chest.inventory.add("stone", 12)
|
||||||
player.global_position = chest.global_position + fwd * -2.0
|
player.global_position = chest.global_position + fwd * -2.0
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
shader_type sky;
|
shader_type sky;
|
||||||
|
|
||||||
// Gradient sky + sun disc/halo + a static cloud layer projected onto a dome.
|
// Gradient sky + sun disc/halo + a static cloud layer projected onto a dome,
|
||||||
// Deliberately no TIME: a time-varying sky forces Godot to rebuild the ambient
|
// plus stars at night. Colours and energy are driven by day_night.gd, a few
|
||||||
// radiance map every frame.
|
// times a second rather than every frame. Deliberately no TIME: a time-varying
|
||||||
|
// sky forces Godot to rebuild the ambient radiance map every frame.
|
||||||
|
|
||||||
uniform vec3 zenith_col : source_color = vec3(0.24, 0.44, 0.72);
|
uniform vec3 zenith_col : source_color = vec3(0.24, 0.44, 0.72);
|
||||||
uniform vec3 horizon_col : source_color = vec3(0.72, 0.79, 0.85);
|
uniform vec3 horizon_col : source_color = vec3(0.72, 0.79, 0.85);
|
||||||
uniform vec3 ground_col : source_color = vec3(0.30, 0.33, 0.32);
|
uniform vec3 ground_col : source_color = vec3(0.30, 0.33, 0.32);
|
||||||
uniform vec3 sun_tint : source_color = vec3(1.0, 0.86, 0.62);
|
uniform vec3 sun_tint : source_color = vec3(1.0, 0.86, 0.62);
|
||||||
|
uniform vec3 cloud_lit : source_color = vec3(1.0, 1.0, 1.0);
|
||||||
uniform sampler2D cloud_tex : filter_linear_mipmap, repeat_enable;
|
uniform sampler2D cloud_tex : filter_linear_mipmap, repeat_enable;
|
||||||
uniform float cloud_cover = 0.5;
|
uniform float cloud_cover = 0.5;
|
||||||
uniform float energy = 1.0;
|
uniform float energy = 1.0;
|
||||||
|
uniform float star_strength = 0.0;
|
||||||
|
|
||||||
|
float hash(vec3 p) {
|
||||||
|
p = fract(p * 0.3183099 + 0.1);
|
||||||
|
p *= 17.0;
|
||||||
|
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
|
||||||
|
}
|
||||||
|
|
||||||
void sky() {
|
void sky() {
|
||||||
vec3 d = EYEDIR;
|
vec3 d = EYEDIR;
|
||||||
@@ -23,19 +32,28 @@ void sky() {
|
|||||||
sd = max(dot(d, LIGHT0_DIRECTION), 0.0);
|
sd = max(dot(d, LIGHT0_DIRECTION), 0.0);
|
||||||
col += sun_tint * pow(sd, 6.0) * 0.35 * (1.0 - smoothstep(0.0, 0.5, h));
|
col += sun_tint * pow(sd, 6.0) * 0.35 * (1.0 - smoothstep(0.0, 0.5, h));
|
||||||
col += sun_tint * pow(sd, 64.0) * 0.6;
|
col += sun_tint * pow(sd, 64.0) * 0.6;
|
||||||
col += LIGHT0_COLOR * smoothstep(0.9994, 0.9997, sd) * 20.0;
|
col += LIGHT0_COLOR * smoothstep(0.9994, 0.9997, sd) * 20.0 * LIGHT0_ENERGY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
float cov = 0.0;
|
||||||
if (h > 0.0) {
|
if (h > 0.0) {
|
||||||
vec2 uv = d.xz / (h + 0.12);
|
vec2 uv = d.xz / (h + 0.12);
|
||||||
float c = texture(cloud_tex, uv * 0.08).r * 0.65 + texture(cloud_tex, uv * 0.23 + 0.31).r * 0.35;
|
float c = texture(cloud_tex, uv * 0.08).r * 0.65 + texture(cloud_tex, uv * 0.23 + 0.31).r * 0.35;
|
||||||
float cov = smoothstep(1.0 - cloud_cover, 1.0 - cloud_cover + 0.22, c);
|
cov = smoothstep(1.0 - cloud_cover, 1.0 - cloud_cover + 0.22, c);
|
||||||
cov *= smoothstep(0.0, 0.18, h);
|
cov *= smoothstep(0.0, 0.18, h);
|
||||||
float shade = smoothstep(0.35, 1.0, c);
|
float shade = smoothstep(0.35, 1.0, c);
|
||||||
vec3 cloud = mix(vec3(1.0), vec3(0.62, 0.66, 0.72), shade * 0.8);
|
vec3 cloud = mix(cloud_lit, cloud_lit * vec3(0.62, 0.66, 0.72), shade * 0.8);
|
||||||
cloud += sun_tint * pow(sd, 4.0) * 0.5;
|
cloud += sun_tint * pow(sd, 4.0) * 0.5;
|
||||||
col = mix(col, cloud, cov * 0.9);
|
col = mix(col, cloud, cov * 0.9);
|
||||||
}
|
}
|
||||||
|
col *= energy;
|
||||||
|
|
||||||
COLOR = col * energy;
|
// Stars: a sparse hash over direction cells, hidden by clouds and haze.
|
||||||
|
if (star_strength > 0.0 && h > 0.0) {
|
||||||
|
float s = hash(floor(d * 260.0));
|
||||||
|
float star = step(0.9972, s) * (0.5 + 0.5 * fract(s * 91.7));
|
||||||
|
col += vec3(0.9, 0.93, 1.0) * star * star_strength * smoothstep(0.02, 0.25, h) * (1.0 - cov) * 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
COLOR = col;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ extends SceneTree
|
|||||||
## Run: godot --headless --path . --script res://tests/test_save_load.gd
|
## Run: godot --headless --path . --script res://tests/test_save_load.gd
|
||||||
|
|
||||||
var _fails := 0
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
var A: Node # Actions
|
var A: Node # Actions
|
||||||
var S: Node # Saves
|
var S: Node # Saves
|
||||||
var DB: Node # ItemDB
|
var DB: Node # ItemDB
|
||||||
@@ -114,9 +115,11 @@ func _run() -> void:
|
|||||||
check(chest2 != null and chest2.inventory.count("flint") == 7 and chest2.inventory.count("wood") == 12, "chest rebuilt with its contents")
|
check(chest2 != null and chest2.inventory.count("flint") == 7 and chest2.inventory.count("wood") == 12, "chest rebuilt with its contents")
|
||||||
check(S.read_meta(id).play_time >= 0.0, "meta updated")
|
check(S.read_meta(id).play_time >= 0.0, "meta updated")
|
||||||
|
|
||||||
|
_finished = true
|
||||||
world.queue_free()
|
world.queue_free()
|
||||||
S.delete_world(id)
|
S.delete_world(id)
|
||||||
DirAccess.remove_absolute(S.root_dir)
|
DirAccess.remove_absolute(S.root_dir)
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
quit(1 if _fails > 0 else 0)
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ extends SceneTree
|
|||||||
## Run: godot --headless --path . --script res://tests/test_survival_core.gd
|
## Run: godot --headless --path . --script res://tests/test_survival_core.gd
|
||||||
|
|
||||||
var _fails := 0
|
var _fails := 0
|
||||||
|
var _finished := false ## set at the very end; a script error aborts before it
|
||||||
var _actions: Node
|
var _actions: Node
|
||||||
var _db: Node
|
var _db: Node
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ func _run() -> void:
|
|||||||
for _i in range(5):
|
for _i in range(5):
|
||||||
await physics_frame
|
await physics_frame
|
||||||
await _test_world(world)
|
await _test_world(world)
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
quit(1 if _fails > 0 else 0)
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
@@ -134,6 +136,7 @@ func _test_world(world: Node3D) -> void:
|
|||||||
var moving: int = inv.get_slot(wood_slot).count
|
var moving: int = inv.get_slot(wood_slot).count
|
||||||
_actions.transfer(player, inv, wood_slot, chest.inventory, chest)
|
_actions.transfer(player, inv, wood_slot, chest.inventory, chest)
|
||||||
check(chest.inventory.count("wood") == moving, "shift-click stored %d wood in the chest" % moving)
|
check(chest.inventory.count("wood") == moving, "shift-click stored %d wood in the chest" % moving)
|
||||||
|
_finished = true
|
||||||
|
|
||||||
func ItemDB_get(_p, id: String) -> Dictionary:
|
func ItemDB_get(_p, id: String) -> Dictionary:
|
||||||
return _db.get_item(id)
|
return _db.get_item(id)
|
||||||
|
|||||||
189
tests/test_survival_stats.gd
Normal file
189
tests/test_survival_stats.gd
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## M1b: day/night, hunger, warmth, stamina, campfire fuel + heat + cooking,
|
||||||
|
## berry bushes, eating, death + grave, and saving all of it.
|
||||||
|
##
|
||||||
|
## Fast-forwards time by calling survival.step() / day_night.advance() / the
|
||||||
|
## fire's _process() directly, so the whole test runs in seconds.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_survival_stats.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
|
||||||
|
|
||||||
|
## Advances the simulation `seconds` in 1 s steps (stats + clock + fires).
|
||||||
|
func _ff(world: Node3D, seconds: float) -> void:
|
||||||
|
var player: Node = world.get_node("Player")
|
||||||
|
for _i in range(int(seconds)):
|
||||||
|
world.day_night.advance(1.0)
|
||||||
|
for f in world.get_tree().get_nodes_in_group("heat"):
|
||||||
|
f._process(1.0)
|
||||||
|
player.survival.step(1.0)
|
||||||
|
|
||||||
|
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("Stats", 1337)
|
||||||
|
S.current_id = id
|
||||||
|
var world: Node3D = await _spawn_world()
|
||||||
|
await _body(world, id)
|
||||||
|
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)
|
||||||
|
|
||||||
|
func _body(world: Node3D, id: String) -> void:
|
||||||
|
var player: CharacterBody3D = world.get_node("Player")
|
||||||
|
var sv: Node = player.survival
|
||||||
|
var dn: Node = world.day_night
|
||||||
|
var inv: Inventory = player.inventory
|
||||||
|
var terrain: Node3D = world.get_node("Terrain")
|
||||||
|
player.set_physics_process(false) # keep the player put while we fast-forward
|
||||||
|
sv.set_process(false)
|
||||||
|
|
||||||
|
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())
|
||||||
|
dn.time = 0.5; dn.apply(true)
|
||||||
|
check(dn.daylight > 0.9, "noon is bright (daylight %.2f)" % dn.daylight)
|
||||||
|
dn.time = 0.0; dn.apply(true)
|
||||||
|
check(dn.daylight < 0.05, "midnight is dark (daylight %.2f)" % dn.daylight)
|
||||||
|
dn.time = 0.99
|
||||||
|
dn.advance(dn.day_length * 0.02)
|
||||||
|
check(dn.day == 2, "clock rolls over to day 2")
|
||||||
|
|
||||||
|
print("hunger and eating")
|
||||||
|
dn.time = 0.5; dn.apply(true)
|
||||||
|
var h0: float = sv.hunger
|
||||||
|
_ff(world, 300)
|
||||||
|
check(sv.hunger < h0 - 15.0, "hunger drains over 5 min (%.0f -> %.0f)" % [h0, sv.hunger])
|
||||||
|
inv.add("berries", 3)
|
||||||
|
_hold(player, "berries")
|
||||||
|
var h1: float = sv.hunger
|
||||||
|
check(A.eat(player, 0) and sv.hunger > h1 and inv.count("berries") == 2, "eating berries restores food")
|
||||||
|
|
||||||
|
print("cold and fire")
|
||||||
|
dn.time = 21.0 / 24.0; dn.apply(true) # night (fast-forward below stays before dawn)
|
||||||
|
sv.warmth = 60.0
|
||||||
|
_ff(world, 240)
|
||||||
|
check(sv.warmth < 30.0 and "Cold" in sv.statuses, "a night in the open gets cold (warmth %.0f, %s)" % [sv.warmth, sv.statuses])
|
||||||
|
var spot := player.global_position + Vector3(2.0, 0, 0)
|
||||||
|
spot.y = terrain.sample_height(spot.x, spot.z)
|
||||||
|
var fire: Node = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), spot))
|
||||||
|
_ff(world, 30)
|
||||||
|
check(sv.warmth > 55.0 and "Warm" in sv.statuses, "sitting by the fire warms you (warmth %.0f)" % sv.warmth)
|
||||||
|
|
||||||
|
print("fuel and cooking")
|
||||||
|
inv.add("mushroom", 2)
|
||||||
|
check(A.can_craft(player, "roasted_mushroom"), "can roast a mushroom at a lit fire")
|
||||||
|
fire._process(fire.fuel + 1.0)
|
||||||
|
check(not fire.is_lit() and fire.heat_at(player.global_position) == 0.0, "the fire burns out when its fuel runs out")
|
||||||
|
check(not A.can_craft(player, "roasted_mushroom"), "an unlit fire can't cook")
|
||||||
|
inv.add("wood", 2)
|
||||||
|
check(A.add_fuel(player, fire) and fire.is_lit() and inv.count("wood") == 1, "adding wood relights it")
|
||||||
|
check(A.craft(player, "roasted_mushroom") and inv.count("roasted_mushroom") == 1, "roasted a mushroom")
|
||||||
|
|
||||||
|
print("stamina")
|
||||||
|
sv.stamina = 100.0
|
||||||
|
var swings := 0
|
||||||
|
while sv.use_stamina(6.0) and swings < 100:
|
||||||
|
swings += 1
|
||||||
|
check(swings >= 14 and swings < 30, "about 16 swings empty your stamina (%d)" % swings)
|
||||||
|
_ff(world, 6)
|
||||||
|
check(sv.stamina > 90.0, "stamina regenerates after a pause")
|
||||||
|
|
||||||
|
print("berry bushes")
|
||||||
|
var bush: Node3D = world.get_node("Props").forage.values()[0]
|
||||||
|
player.global_position = bush.global_position + Vector3(1.0, 0.3, 0)
|
||||||
|
var b0 := inv.count("berries")
|
||||||
|
check(A.forage(player, bush) and inv.count("berries") > b0 and inv.count("fiber") >= 1, "foraged berries and fiber")
|
||||||
|
check(not A.forage(player, bush), "a picked bush is bare")
|
||||||
|
bush._process(bush.regrow_seconds + 1.0)
|
||||||
|
check(bush.ripe(), "it regrows")
|
||||||
|
inv.add("stick", 1)
|
||||||
|
inv.add("fiber", 2)
|
||||||
|
check(A.craft(player, "torch") and inv.count("torch") == 1, "torch crafted from stick + fiber")
|
||||||
|
_hold(player, "torch")
|
||||||
|
check(player.hands.held_heat() > 0.0, "a held torch gives a little warmth")
|
||||||
|
|
||||||
|
print("save + load")
|
||||||
|
sv.hunger = 42.0
|
||||||
|
sv.warmth = 33.0
|
||||||
|
fire.fuel = 321.0
|
||||||
|
A.forage(player, bush)
|
||||||
|
var regrow_left: float = bush.regrow_left
|
||||||
|
dn.day = 3; dn.time = 0.75
|
||||||
|
world.save_now()
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
world = await _spawn_world()
|
||||||
|
player = world.get_node("Player")
|
||||||
|
sv = player.survival
|
||||||
|
check(absf(sv.hunger - 42.0) < 0.5 and absf(sv.warmth - 33.0) < 0.5, "stats restored (food %.0f, warmth %.0f)" % [sv.hunger, sv.warmth])
|
||||||
|
check(world.day_night.day == 3 and absf(world.day_night.hours() - 18.0) < 0.2, "time of day restored (%s)" % world.day_night.clock_text())
|
||||||
|
var fire2: Node = null
|
||||||
|
for p in world.get_node("Pieces").get_children():
|
||||||
|
if p.item_id == "campfire":
|
||||||
|
fire2 = p
|
||||||
|
check(fire2 != null and absf(fire2.fuel - 321.0) < 2.0 and fire2.is_lit(), "campfire fuel restored")
|
||||||
|
var bush2: Node = world.get_node("Props").forage[bush.uid] if is_instance_valid(bush) else world.get_node("Props").forage.values()[0]
|
||||||
|
check(absf(bush2.regrow_left - regrow_left) < 2.0, "bush regrow timer restored")
|
||||||
|
|
||||||
|
print("death and grave")
|
||||||
|
player.set_physics_process(false)
|
||||||
|
sv.set_process(false)
|
||||||
|
player.inventory.add("wood", 9)
|
||||||
|
player.inventory.add("stone_axe", 1)
|
||||||
|
var death_pos := player.global_position
|
||||||
|
sv.hunger = 0.0
|
||||||
|
sv.health = 0.5
|
||||||
|
sv.step(2.0)
|
||||||
|
check(sv.health == 50.0 and not sv.dead, "you respawn with half health")
|
||||||
|
check(player.inventory.count("wood") == 0, "your inventory went into the grave")
|
||||||
|
var grave: Node = null
|
||||||
|
for p in world.get_node("Pieces").get_children():
|
||||||
|
if p.item_id == "grave":
|
||||||
|
grave = p
|
||||||
|
check(grave != null and grave.inventory.count("wood") >= 9 and grave.inventory.count("stone_axe") == 1, "the grave holds your things")
|
||||||
|
check(grave != null and grave.global_position.distance_to(death_pos) < 15.0, "grave is where you fell")
|
||||||
|
player.global_position = grave.global_position + Vector3(1, 0.5, 0)
|
||||||
|
for i in range(grave.inventory.size()):
|
||||||
|
if grave.inventory.get_slot(i) != null:
|
||||||
|
A.transfer(player, grave.inventory, i, player.inventory, grave)
|
||||||
|
await process_frame
|
||||||
|
check(player.inventory.count("stone_axe") == 1, "recovered your belongings")
|
||||||
|
check(not is_instance_valid(grave) or grave.is_queued_for_deletion(), "the empty grave crumbles")
|
||||||
|
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