P4 hazards: food spoilage with rot speed setting, smoking rack and salt, food poisoning, harsher blizzards, hard winters with lake ice, lightning on trees, snow on roofs (protocol 11)
This commit is contained in:
131
data/items.json
131
data/items.json
@@ -163,7 +163,8 @@
|
|||||||
"diet": {
|
"diet": {
|
||||||
"plants": 14
|
"plants": 14
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"keeps": 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mushroom",
|
"id": "mushroom",
|
||||||
@@ -178,7 +179,8 @@
|
|||||||
"diet": {
|
"diet": {
|
||||||
"plants": 10
|
"plants": 10
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"keeps": 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "roasted_mushroom",
|
"id": "roasted_mushroom",
|
||||||
@@ -193,7 +195,8 @@
|
|||||||
"diet": {
|
"diet": {
|
||||||
"plants": 30
|
"plants": 30
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"keeps": 6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "torch",
|
"id": "torch",
|
||||||
@@ -311,8 +314,10 @@
|
|||||||
"health": -4,
|
"health": -4,
|
||||||
"diet": {
|
"diet": {
|
||||||
"meat": 8
|
"meat": 8
|
||||||
}
|
},
|
||||||
}
|
"risk": 0.35
|
||||||
|
},
|
||||||
|
"keeps": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cooked_meat",
|
"id": "cooked_meat",
|
||||||
@@ -334,7 +339,8 @@
|
|||||||
"stamina_regen": 1.3,
|
"stamina_regen": 1.3,
|
||||||
"health_regen": 2.0
|
"health_regen": 2.0
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"keeps": 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "hide",
|
"id": "hide",
|
||||||
@@ -483,8 +489,10 @@
|
|||||||
"health": -2,
|
"health": -2,
|
||||||
"diet": {
|
"diet": {
|
||||||
"fish": 8
|
"fish": 8
|
||||||
}
|
},
|
||||||
}
|
"risk": 0.2
|
||||||
|
},
|
||||||
|
"keeps": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "trout",
|
"id": "trout",
|
||||||
@@ -498,8 +506,10 @@
|
|||||||
"health": -2,
|
"health": -2,
|
||||||
"diet": {
|
"diet": {
|
||||||
"fish": 12
|
"fish": 12
|
||||||
}
|
},
|
||||||
}
|
"risk": 0.2
|
||||||
|
},
|
||||||
|
"keeps": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "grilled_perch",
|
"id": "grilled_perch",
|
||||||
@@ -514,7 +524,8 @@
|
|||||||
"diet": {
|
"diet": {
|
||||||
"fish": 30
|
"fish": 30
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"keeps": 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "grilled_trout",
|
"id": "grilled_trout",
|
||||||
@@ -529,7 +540,8 @@
|
|||||||
"diet": {
|
"diet": {
|
||||||
"fish": 45
|
"fish": 45
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"keeps": 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "iron_nugget",
|
"id": "iron_nugget",
|
||||||
@@ -978,6 +990,101 @@
|
|||||||
"stack": 3,
|
"stack": 3,
|
||||||
"description": "A carved stone token from a barrow coffin. One of three: the standing stones on the rim are said to wake for them.",
|
"description": "A carved stone token from a barrow coffin. One of three: the standing stones on the rim are said to wake for them.",
|
||||||
"mesh": "barrow_key"
|
"mesh": "barrow_key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rotten_food",
|
||||||
|
"name": "Rotten Food",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "It's gone off. Good for nothing: throw it out.",
|
||||||
|
"mesh": "rotten_food"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "salt",
|
||||||
|
"name": "Salt",
|
||||||
|
"category": "resource",
|
||||||
|
"stack": 50,
|
||||||
|
"description": "Crusted on the rocks above the sea cliffs. Cures meat and fish so they keep for weeks.",
|
||||||
|
"mesh": "salt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "smoking_rack",
|
||||||
|
"name": "Smoking Rack",
|
||||||
|
"category": "placeable",
|
||||||
|
"stack": 1,
|
||||||
|
"description": "Hang raw meat or fish over a smoky fire (E): with a piece of wood it smokes them, and smoked food keeps for days.",
|
||||||
|
"mesh": "smoking_rack",
|
||||||
|
"place": {
|
||||||
|
"piece": "smoker",
|
||||||
|
"footprint": [
|
||||||
|
1.6,
|
||||||
|
1.2
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "smoked_meat",
|
||||||
|
"name": "Smoked Meat",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Dark and chewy. Keeps 12 days.",
|
||||||
|
"mesh": "smoked_meat",
|
||||||
|
"keeps": 12,
|
||||||
|
"food": {
|
||||||
|
"hunger": 28,
|
||||||
|
"health": 10,
|
||||||
|
"diet": {
|
||||||
|
"meat": 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "smoked_fish",
|
||||||
|
"name": "Smoked Fish",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Golden and salty-sweet. Keeps 12 days.",
|
||||||
|
"mesh": "smoked_fish",
|
||||||
|
"keeps": 12,
|
||||||
|
"food": {
|
||||||
|
"hunger": 20,
|
||||||
|
"health": 8,
|
||||||
|
"diet": {
|
||||||
|
"fish": 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "salted_meat",
|
||||||
|
"name": "Salted Meat",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Cured in salt. Keeps 20 days. Safe to eat as it is.",
|
||||||
|
"mesh": "salted_meat",
|
||||||
|
"keeps": 20,
|
||||||
|
"food": {
|
||||||
|
"hunger": 22,
|
||||||
|
"health": 6,
|
||||||
|
"diet": {
|
||||||
|
"meat": 26
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "salted_fish",
|
||||||
|
"name": "Salted Fish",
|
||||||
|
"category": "food",
|
||||||
|
"stack": 20,
|
||||||
|
"description": "Cured in salt. Keeps 20 days. Safe to eat as it is.",
|
||||||
|
"mesh": "salted_fish",
|
||||||
|
"keeps": 20,
|
||||||
|
"food": {
|
||||||
|
"hunger": 16,
|
||||||
|
"health": 5,
|
||||||
|
"diet": {
|
||||||
|
"fish": 26
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -532,6 +532,51 @@
|
|||||||
"bear_hide": 1,
|
"bear_hide": 1,
|
||||||
"fiber": 2
|
"fiber": 2
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "smoking_rack",
|
||||||
|
"output": "smoking_rack",
|
||||||
|
"count": 1,
|
||||||
|
"station": "",
|
||||||
|
"category": "stations",
|
||||||
|
"inputs": {
|
||||||
|
"stick": 6,
|
||||||
|
"wood": 4,
|
||||||
|
"fiber": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "salted_meat",
|
||||||
|
"output": "salted_meat",
|
||||||
|
"count": 2,
|
||||||
|
"station": "",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"raw_meat": 2,
|
||||||
|
"salt": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "salted_fish",
|
||||||
|
"output": "salted_fish",
|
||||||
|
"count": 2,
|
||||||
|
"station": "",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"perch": 2,
|
||||||
|
"salt": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "salted_trout",
|
||||||
|
"output": "salted_fish",
|
||||||
|
"count": 2,
|
||||||
|
"station": "",
|
||||||
|
"category": "cooking",
|
||||||
|
"inputs": {
|
||||||
|
"trout": 1,
|
||||||
|
"salt": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Progression and challenges design
|
# Progression and challenges design
|
||||||
|
|
||||||
Status: **P1 (iron), P2 (stone) and P3 (adversaries) built**; P4-P5 are
|
Status: **P1 (iron), P2 (stone), P3 (adversaries) and P4 (hazards)
|
||||||
proposals.
|
built**; P5 is a proposal.
|
||||||
|
|
||||||
*As built in P1:*
|
*As built in P1:*
|
||||||
- Iron nuggets (70, in the scree under the rim), clay (60, on the lake
|
- Iron nuggets (70, in the scree under the rim), clay (60, on the lake
|
||||||
@@ -95,6 +95,34 @@ proposals.
|
|||||||
- Not yet: iron scrap (bars and nuggets instead); the standing stones and
|
- Not yet: iron scrap (bars and nuggets instead); the standing stones and
|
||||||
the Old Stag (P5).
|
the Old Stag (P5).
|
||||||
|
|
||||||
|
*As built in P4 (hazards):*
|
||||||
|
- Spoilage: food carries a "rots at" world time on its stack (stamped when
|
||||||
|
it first goes into a bag or chest; merged stacks average it), so it rots
|
||||||
|
in bags, chests, graves, racks and on the ground, and through sleep and
|
||||||
|
saves. Raw meat and fish 2 days, berries 3, mushrooms and cooked food 4,
|
||||||
|
roasted mushrooms 6, smoked 12, salted 20. Rotten food is junk; food
|
||||||
|
rotting on the ground just goes. A bar on the slot runs green to brown;
|
||||||
|
the tooltip says Fresh / Going off / Nearly rotten, with hours left.
|
||||||
|
- Rot speed: normal, slow (half) or off. Chosen on the New World page
|
||||||
|
(meta.json "rot"); on a server, "rot" in server.json or /set rot.
|
||||||
|
- Smoking rack (by hand: 6 sticks, 4 wood, 4 fibre): 2 raw meat + a wood,
|
||||||
|
or 2 perch (or 1 trout) + a wood, make 2 smoked, a minute a batch.
|
||||||
|
- Salt: 40 crusts on the cliff tops above the sea. 2 raw meat (or 2 perch,
|
||||||
|
or 1 trout) + a salt: 2 salted, by hand.
|
||||||
|
- Food poisoning: raw meat 35%, raw fish 20%. A minute of half-speed
|
||||||
|
stamina and no eating. Cooked, smoked and salted food are safe.
|
||||||
|
- Blizzards: fog x7; out in one, an extra 18 warmth lost on top of the
|
||||||
|
cold, and you cool twice as fast. A hard-winter blizzard freezes you by
|
||||||
|
day without a coat; a hide cloak keeps you out of Freezing; a hearth room
|
||||||
|
stays Warm. Snow piles on roofs (a look only).
|
||||||
|
- Hard winters: days 12-14, 24-26, ... (every 12 days): always cold, 8
|
||||||
|
colder than a cold day, snowier, half the animals, the lake's edge frozen
|
||||||
|
(walkable ice, no fishing through it). Warned the day before.
|
||||||
|
- Lightning (decided: trees only): in a storm, a strike every 50-110 s on a
|
||||||
|
tree within 90 m of someone, the taller and nearer the rim the likelier.
|
||||||
|
It burns 40 s (you can warm yourself by it), falls, and leaves 2 charcoal.
|
||||||
|
- Network protocol 11.
|
||||||
|
|
||||||
Today the island runs out of goals after a few days: once you have stone
|
Today the island runs out of goals after a few days: once you have stone
|
||||||
tools, a hut, a bow and a fire, the only threats are wolves at night, the
|
tools, a hut, a bow and a fire, the only threats are wolves at night, the
|
||||||
cold and your diet. This plan adds a **second tier of materials** (iron and
|
cold and your diet. This plan adds a **second tier of materials** (iron and
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d).
|
|||||||
- [x] The wolf moon and the alpha: breaks wooden doors, iron-banded doors hold, alpha pelt and cloak *(P3, part 1)*
|
- [x] The wolf moon and the alpha: breaks wooden doors, iron-banded doors hold, alpha pelt and cloak *(P3, part 1)*
|
||||||
- [x] The bear: raids unattended food, palisades and closed huts keep it out, bear hide rug, bear fat *(P3, part 2)*
|
- [x] The bear: raids unattended food, palisades and closed huts keep it out, bear hide rug, bear fat *(P3, part 2)*
|
||||||
- [x] Crows and foxes steal food left out; the barrow, its guards, the coffin and key pieces *(P3, part 3)*
|
- [x] Crows and foxes steal food left out; the barrow, its guards, the coffin and key pieces *(P3, part 3)*
|
||||||
|
- [x] Hazards: food spoilage (rot speed setting), smoking rack and salt, food poisoning, harsher blizzards, hard winters, lightning on trees *(P4)*
|
||||||
- [ ] **Iron, stone building and new adversaries**: iron nuggets and ore,
|
- [ ] **Iron, stone building and new adversaries**: iron nuggets and ore,
|
||||||
smelting and a forge, iron tools and durability, cut-stone building,
|
smelting and a forge, iron tools and durability, cut-stone building,
|
||||||
an alpha wolf, a bear, a barrow with guards, spoilage and blizzards
|
an alpha wolf, a bear, a barrow with guards, spoilage and blizzards
|
||||||
|
|||||||
@@ -18,7 +18,17 @@ const RECIPES := {
|
|||||||
{"inputs": {"iron_ore": 1, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0},
|
{"inputs": {"iron_ore": 1, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0},
|
||||||
{"inputs": {"iron_nugget": 3, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0},
|
{"inputs": {"iron_nugget": 3, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0},
|
||||||
],
|
],
|
||||||
|
# P4: smoked food keeps for days (raw rots in two).
|
||||||
|
"smoking_rack": [
|
||||||
|
{"inputs": {"raw_meat": 2, "wood": 1}, "output": "smoked_meat", "count": 2, "seconds": 60.0},
|
||||||
|
{"inputs": {"perch": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
|
||||||
|
{"inputs": {"trout": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
## What the prompt says it makes, and what it needs when idle.
|
||||||
|
const MAKES := {"charcoal_kiln": "charcoal", "smelter": "an iron bar", "smoking_rack": "smoked food"}
|
||||||
|
const NEEDS := {"charcoal_kiln": "put in wood", "smelter": "put in iron ore (or 3 nuggets) and charcoal",
|
||||||
|
"smoking_rack": "put in raw meat or fish, and wood for the fire"}
|
||||||
|
|
||||||
var inventory := Inventory.new(SLOTS)
|
var inventory := Inventory.new(SLOTS)
|
||||||
var progress := 0.0 ## seconds into the current batch
|
var progress := 0.0 ## seconds into the current batch
|
||||||
@@ -49,7 +59,9 @@ func _build() -> void:
|
|||||||
m.emission = Color(1.0, 0.45, 0.12)
|
m.emission = Color(1.0, 0.45, 0.12)
|
||||||
m.emission_energy_multiplier = 3.0
|
m.emission_energy_multiplier = 3.0
|
||||||
_glow.material_override = m
|
_glow.material_override = m
|
||||||
_glow.position = Vector3(0, 0.3 if item_id == "smelter" else 0.25, 0.58 if item_id == "smelter" else 0.72)
|
_glow.position = {"smelter": Vector3(0, 0.3, 0.58), "smoking_rack": Vector3(0, 0.12, 0.0)}.get(item_id, Vector3(0, 0.25, 0.72))
|
||||||
|
if item_id == "smoking_rack":
|
||||||
|
_glow.rotation.x = -PI * 0.5 # (embers on the ground under the rack)
|
||||||
_glow.visible = false
|
_glow.visible = false
|
||||||
add_child(_glow)
|
add_child(_glow)
|
||||||
|
|
||||||
@@ -82,7 +94,7 @@ func step(secs: float) -> void:
|
|||||||
progress -= float(r.seconds)
|
progress -= float(r.seconds)
|
||||||
for id in r.inputs:
|
for id in r.inputs:
|
||||||
inventory.remove(id, int(r.inputs[id]))
|
inventory.remove(id, int(r.inputs[id]))
|
||||||
inventory.add(r.output, 1)
|
inventory.add(r.output, int(r.get("count", 1)))
|
||||||
r = current()
|
r = current()
|
||||||
if r.is_empty():
|
if r.is_empty():
|
||||||
progress = 0.0
|
progress = 0.0
|
||||||
@@ -107,10 +119,9 @@ func dismantle_block_reason() -> String:
|
|||||||
|
|
||||||
func interact_prompt(_player: Node) -> String:
|
func interact_prompt(_player: Node) -> String:
|
||||||
var r := current()
|
var r := current()
|
||||||
var what := "charcoal" if item_id == "charcoal_kiln" else "an iron bar"
|
var what: String = MAKES.get(item_id, "something")
|
||||||
if r.is_empty():
|
if r.is_empty():
|
||||||
var needs := "put in wood" if item_id == "charcoal_kiln" else "put in iron ore (or 3 nuggets) and charcoal"
|
return "%s (idle: %s) [E] Open" % [display_name(), NEEDS.get(item_id, "put something in")]
|
||||||
return "%s (idle: %s) [E] Open" % [display_name(), needs]
|
|
||||||
if WorldNet.is_client():
|
if WorldNet.is_client():
|
||||||
return "%s (making %s) [E] Open" % [display_name(), what]
|
return "%s (making %s) [E] Open" % [display_name(), what]
|
||||||
return "%s (making %s, %d s) [E] Open" % [display_name(), what, ceili(float(r.seconds) - progress)]
|
return "%s (making %s, %d s) [E] Open" % [display_name(), what, ceili(float(r.seconds) - progress)]
|
||||||
|
|||||||
@@ -622,14 +622,24 @@ func eat(player: Node3D, slot: int) -> bool:
|
|||||||
var food: Dictionary = ItemDB.get_item(s.id).get("food", {})
|
var food: Dictionary = ItemDB.get_item(s.id).get("food", {})
|
||||||
if food.is_empty():
|
if food.is_empty():
|
||||||
return false
|
return false
|
||||||
|
if player.survival.sick > 0.0:
|
||||||
|
tell(player, "You feel too sick to eat")
|
||||||
|
return false
|
||||||
if player.survival.hunger >= FULL_HUNGER:
|
if player.survival.hunger >= FULL_HUNGER:
|
||||||
notify.emit("You're full")
|
notify.emit("You're full")
|
||||||
return false
|
return false
|
||||||
player.inventory.take_from(slot, 1)
|
player.inventory.take_from(slot, 1)
|
||||||
player.survival.eat(food)
|
player.survival.eat(food)
|
||||||
notify.emit("Ate %s" % ItemDB.item_name(s.id).to_lower())
|
notify.emit("Ate %s" % ItemDB.item_name(s.id).to_lower())
|
||||||
|
# Raw meat and fish can make you ill (P4). Cooked, smoked or salted can't.
|
||||||
|
if food_rng.randf() < float(food.get("risk", 0.0)):
|
||||||
|
player.survival.poison()
|
||||||
|
tell(player, "Your stomach turns: food poisoning. Cook your meat!")
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
## Rolls for food poisoning (tests seed it).
|
||||||
|
var food_rng := RandomNumberGenerator.new()
|
||||||
|
|
||||||
|
|
||||||
## Feeds a campfire from the player's inventory: wood first, then sticks.
|
## Feeds a campfire from the player's inventory: wood first, then sticks.
|
||||||
func add_fuel(player: Node3D, fire: Node3D) -> bool:
|
func add_fuel(player: Node3D, fire: Node3D) -> bool:
|
||||||
|
|||||||
@@ -33,19 +33,103 @@ static func _stack_max(id: String) -> int:
|
|||||||
|
|
||||||
## A stack can carry "dur": uses left on a tool or weapon that wears out
|
## A stack can carry "dur": uses left on a tool or weapon that wears out
|
||||||
## (ItemDB "durability"). No "dur" means as new.
|
## (ItemDB "durability"). No "dur" means as new.
|
||||||
|
## Food that spoils (ItemDB "keeps", in days) carries "rot": the world time
|
||||||
|
## (seconds since day 1, 00:00) at which it goes rotten. It's stamped when
|
||||||
|
## the food first goes into an inventory; two stacks that merge share the
|
||||||
|
## average. world.gd turns rotten stacks into rotten food (spoil()).
|
||||||
static func _norm(stack):
|
static func _norm(stack):
|
||||||
if stack == null or int(stack.count) <= 0:
|
if stack == null or int(stack.count) <= 0:
|
||||||
return null
|
return null
|
||||||
var out := {"id": String(stack.id), "count": int(stack.count)}
|
var out := {"id": String(stack.id), "count": int(stack.count)}
|
||||||
if stack.has("dur"):
|
if stack.has("dur"):
|
||||||
out.dur = int(stack.dur)
|
out.dur = int(stack.dur)
|
||||||
|
if stack.has("rot"):
|
||||||
|
out.rot = float(stack.rot)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
## Adds a whole stack, keeping what's special about it (a worn tool's "dur").
|
## What's special about a stack, for when it's dropped on the ground (and
|
||||||
## Returns how many didn't fit.
|
## kept when it's picked up again): a tool's wear, food's freshness.
|
||||||
|
static func extra_of(stack: Dictionary) -> Dictionary:
|
||||||
|
var out := {}
|
||||||
|
if stack.has("dur"):
|
||||||
|
out.dur = int(stack.dur)
|
||||||
|
if stack.has("rot"):
|
||||||
|
out.rot = float(stack.rot)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- spoilage (P4, docs/PROGRESSION_DESIGN.md §5) ---------------------------------
|
||||||
|
|
||||||
|
## The world clock (seconds since day 1, 00:00) and how long a day is, kept
|
||||||
|
## up to date by world.gd. `rot_speed`: 1 normal, 0.5 slow, 0 off (the
|
||||||
|
## world's or server's "rot" setting).
|
||||||
|
static var clock := 0.0
|
||||||
|
static var day_seconds := 1200.0
|
||||||
|
static var rot_speed := 1.0
|
||||||
|
const ROTTEN := "rotten_food"
|
||||||
|
|
||||||
|
static func _db() -> Node:
|
||||||
|
return (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ItemDB")
|
||||||
|
|
||||||
|
## Days `id` keeps before it rots (0: it doesn't).
|
||||||
|
static func keeps_days(id: String) -> float:
|
||||||
|
var db := _db()
|
||||||
|
return float(db.get_item(id).get("keeps", 0.0)) if db != null else 0.0
|
||||||
|
|
||||||
|
## How long `id` keeps, in world seconds, at the current rot speed (0: forever).
|
||||||
|
static func keeps_for(id: String) -> float:
|
||||||
|
var d := keeps_days(id)
|
||||||
|
if d <= 0.0 or rot_speed <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
return d * day_seconds / rot_speed
|
||||||
|
|
||||||
|
## The "rot" stamp for `id` made right now (-1: it doesn't spoil).
|
||||||
|
static func fresh_rot(id: String) -> float:
|
||||||
|
var k := keeps_for(id)
|
||||||
|
# (Whole seconds, so a stamp comes back from a JSON save exactly as it went in.)
|
||||||
|
return roundf(clock + k) if k > 0.0 else -1.0
|
||||||
|
|
||||||
|
## 0..1: how fresh a stack is (1 = just made or doesn't spoil).
|
||||||
|
static func freshness(stack) -> float:
|
||||||
|
if stack == null or not stack.has("rot"):
|
||||||
|
return 1.0
|
||||||
|
var k := keeps_for(String(stack.id))
|
||||||
|
if k <= 0.0:
|
||||||
|
return 1.0
|
||||||
|
return clampf((float(stack.rot) - clock) / k, 0.0, 1.0)
|
||||||
|
|
||||||
|
## In-game hours until a stack rots (INF: never).
|
||||||
|
static func hours_left(stack) -> float:
|
||||||
|
if stack == null or not stack.has("rot") or rot_speed <= 0.0:
|
||||||
|
return INF
|
||||||
|
return maxf(float(stack.rot) - clock, 0.0) / day_seconds * 24.0
|
||||||
|
|
||||||
|
## Stamps food that has no "rot" yet, and turns food past its time into
|
||||||
|
## rotten food. Returns how many items went rotten.
|
||||||
|
func spoil() -> int:
|
||||||
|
if rot_speed <= 0.0:
|
||||||
|
return 0
|
||||||
|
var rotted := 0
|
||||||
|
var touched := false
|
||||||
|
for i in range(slots.size()):
|
||||||
|
var s = slots[i]
|
||||||
|
if s == null or keeps_days(String(s.id)) <= 0.0:
|
||||||
|
continue
|
||||||
|
if not s.has("rot"):
|
||||||
|
s.rot = fresh_rot(String(s.id))
|
||||||
|
touched = true
|
||||||
|
elif clock >= float(s.rot):
|
||||||
|
rotted += int(s.count)
|
||||||
|
slots[i] = {"id": ROTTEN, "count": int(s.count)}
|
||||||
|
touched = true
|
||||||
|
if touched:
|
||||||
|
changed.emit()
|
||||||
|
return rotted
|
||||||
|
|
||||||
|
## Adds a whole stack, keeping what's special about it (a worn tool's "dur",
|
||||||
|
## food's "rot"). Returns how many didn't fit.
|
||||||
func add_stack(stack: Dictionary) -> int:
|
func add_stack(stack: Dictionary) -> int:
|
||||||
if not stack.has("dur"):
|
if not stack.has("dur"):
|
||||||
return add(String(stack.id), int(stack.count))
|
return add(String(stack.id), int(stack.count), float(stack.get("rot", -1.0)))
|
||||||
for i in range(slots.size()):
|
for i in range(slots.size()):
|
||||||
if slots[i] == null:
|
if slots[i] == null:
|
||||||
slots[i] = _norm(stack)
|
slots[i] = _norm(stack)
|
||||||
@@ -72,14 +156,18 @@ func space_for(id: String) -> int:
|
|||||||
return room
|
return room
|
||||||
|
|
||||||
## Adds up to `n` of `id`, topping up existing stacks first. Returns the
|
## Adds up to `n` of `id`, topping up existing stacks first. Returns the
|
||||||
## leftover that didn't fit (0 = everything fitted).
|
## leftover that didn't fit (0 = everything fitted). Food gets a fresh "rot"
|
||||||
func add(id: String, n: int) -> int:
|
## stamp, or `rot` if it's coming from somewhere with one of its own.
|
||||||
|
func add(id: String, n: int, rot: float = -1.0) -> int:
|
||||||
var cap := _stack_max(id)
|
var cap := _stack_max(id)
|
||||||
|
var r := rot if rot >= 0.0 else fresh_rot(id)
|
||||||
for s in slots:
|
for s in slots:
|
||||||
if n <= 0:
|
if n <= 0:
|
||||||
break
|
break
|
||||||
if s != null and s.id == id and s.count < cap:
|
if s != null and s.id == id and s.count < cap:
|
||||||
var take := mini(cap - s.count, n)
|
var take := mini(cap - s.count, n)
|
||||||
|
if r >= 0.0:
|
||||||
|
s.rot = _mix_rot(s, r, take)
|
||||||
s.count += take
|
s.count += take
|
||||||
n -= take
|
n -= take
|
||||||
for i in range(slots.size()):
|
for i in range(slots.size()):
|
||||||
@@ -88,10 +176,20 @@ func add(id: String, n: int) -> int:
|
|||||||
if slots[i] == null:
|
if slots[i] == null:
|
||||||
var take := mini(cap, n)
|
var take := mini(cap, n)
|
||||||
slots[i] = {"id": id, "count": take}
|
slots[i] = {"id": id, "count": take}
|
||||||
|
if r >= 0.0:
|
||||||
|
slots[i].rot = r
|
||||||
n -= take
|
n -= take
|
||||||
changed.emit()
|
changed.emit()
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
## The "rot" of stack `s` once `n` more with `rot` are added to it: the
|
||||||
|
## average, so mixing fresh and old food gives you food in between.
|
||||||
|
static func _mix_rot(s: Dictionary, rot: float, n: int) -> float:
|
||||||
|
if rot < 0.0:
|
||||||
|
return float(s.get("rot", -1.0))
|
||||||
|
var have := float(s.get("rot", rot))
|
||||||
|
return roundf((have * int(s.count) + rot * n) / float(int(s.count) + n))
|
||||||
|
|
||||||
## Removes `n` of `id` (from the last stacks first, so the hotbar keeps its
|
## Removes `n` of `id` (from the last stacks first, so the hotbar keeps its
|
||||||
## items longest). Returns false and changes nothing if there aren't enough.
|
## items longest). Returns false and changes nothing if there aren't enough.
|
||||||
func remove(id: String, n: int) -> bool:
|
func remove(id: String, n: int) -> bool:
|
||||||
@@ -135,9 +233,16 @@ func quick_move(i: int, other: Inventory) -> void:
|
|||||||
if s == null:
|
if s == null:
|
||||||
return
|
return
|
||||||
var left := other.add_stack(s)
|
var left := other.add_stack(s)
|
||||||
slots[i] = null if left <= 0 else _norm({"id": s.id, "count": left, "dur": s.dur} if s.has("dur") else {"id": s.id, "count": left})
|
slots[i] = null if left <= 0 else _part(s, left)
|
||||||
changed.emit()
|
changed.emit()
|
||||||
|
|
||||||
|
## `n` of stack `s`, with its wear or freshness.
|
||||||
|
static func _part(s: Dictionary, n: int):
|
||||||
|
var out := extra_of(s)
|
||||||
|
out.id = s.id
|
||||||
|
out.count = n
|
||||||
|
return _norm(out)
|
||||||
|
|
||||||
## Clicking slot `i` while holding `cursor` (a stack or null). Returns the new
|
## Clicking slot `i` while holding `cursor` (a stack or null). Returns the new
|
||||||
## cursor stack. Left click: pick up / put down / merge / swap. Right click:
|
## cursor stack. Left click: pick up / put down / merge / swap. Right click:
|
||||||
## pick up half, or put down one.
|
## pick up half, or put down one.
|
||||||
@@ -150,23 +255,25 @@ func click(i: int, cursor, right: bool):
|
|||||||
if right and s.count > 1:
|
if right and s.count > 1:
|
||||||
var half := int(ceil(s.count / 2.0))
|
var half := int(ceil(s.count / 2.0))
|
||||||
s.count -= half
|
s.count -= half
|
||||||
result = {"id": s.id, "count": half}
|
result = _part(s, half)
|
||||||
else:
|
else:
|
||||||
result = s
|
result = s
|
||||||
slots[i] = null
|
slots[i] = null
|
||||||
elif s == null:
|
elif s == null:
|
||||||
if right and cursor.count > 1:
|
if right and cursor.count > 1:
|
||||||
slots[i] = {"id": cursor.id, "count": 1}
|
slots[i] = _part(cursor, 1)
|
||||||
result = _norm({"id": cursor.id, "count": cursor.count - 1})
|
result = _part(cursor, cursor.count - 1)
|
||||||
else:
|
else:
|
||||||
slots[i] = cursor
|
slots[i] = cursor
|
||||||
result = null
|
result = null
|
||||||
elif s.id == cursor.id:
|
elif s.id == cursor.id and not s.has("dur") and not cursor.has("dur"):
|
||||||
var cap := _stack_max(s.id)
|
var cap := _stack_max(s.id)
|
||||||
var move: int = 1 if right else cursor.count
|
var move: int = 1 if right else cursor.count
|
||||||
move = mini(move, cap - s.count)
|
move = mini(move, cap - s.count)
|
||||||
|
if move > 0 and cursor.has("rot"):
|
||||||
|
s.rot = _mix_rot(s, float(cursor.rot), move)
|
||||||
s.count += move
|
s.count += move
|
||||||
result = _norm({"id": cursor.id, "count": cursor.count - move})
|
result = _part(cursor, cursor.count - move)
|
||||||
else:
|
else:
|
||||||
slots[i] = cursor
|
slots[i] = cursor
|
||||||
result = s
|
result = s
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ func list_worlds() -> Array:
|
|||||||
out.sort_custom(func(a, b): return float(a.get("last_played", 0)) > float(b.get("last_played", 0)))
|
out.sort_custom(func(a, b): return float(a.get("last_played", 0)) > float(b.get("last_played", 0)))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
func create_world(world_name: String, seed: int) -> String:
|
## `options` are the world's own settings, kept in meta.json (P4: "rot",
|
||||||
|
## how fast food spoils: "normal", "slow" or "off").
|
||||||
|
func create_world(world_name: String, seed: int, options: Dictionary = {}) -> String:
|
||||||
DirAccess.make_dir_recursive_absolute(root_dir)
|
DirAccess.make_dir_recursive_absolute(root_dir)
|
||||||
var base := world_name.to_lower().replace(" ", "_").validate_filename()
|
var base := world_name.to_lower().replace(" ", "_").validate_filename()
|
||||||
if base.is_empty():
|
if base.is_empty():
|
||||||
@@ -52,10 +54,13 @@ func create_world(world_name: String, seed: int) -> String:
|
|||||||
n += 1
|
n += 1
|
||||||
DirAccess.make_dir_recursive_absolute(_dir(id))
|
DirAccess.make_dir_recursive_absolute(_dir(id))
|
||||||
var now := Time.get_unix_time_from_system()
|
var now := Time.get_unix_time_from_system()
|
||||||
_write_json(_dir(id).path_join("meta.json"), {
|
var meta := {
|
||||||
"name": world_name, "seed": seed, "created": now, "last_played": now,
|
"name": world_name, "seed": seed, "created": now, "last_played": now,
|
||||||
"play_time": 0.0, "version": VERSION,
|
"play_time": 0.0, "version": VERSION,
|
||||||
})
|
}
|
||||||
|
for k in options:
|
||||||
|
meta[k] = options[k]
|
||||||
|
_write_json(_dir(id).path_join("meta.json"), meta)
|
||||||
return id
|
return id
|
||||||
|
|
||||||
## The world slot called exactly `id` (a server's world), made with `seed` if
|
## The world slot called exactly `id` (a server's world), made with `seed` if
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func _process(delta: float) -> void:
|
|||||||
for kind in BUDGET:
|
for kind in BUDGET:
|
||||||
if _cooldown[kind] > 0.0:
|
if _cooldown[kind] > 0.0:
|
||||||
continue
|
continue
|
||||||
if alive(kind).size() < budget(kind, everyone.size()):
|
if alive(kind).size() < winter_budget(kind, everyone.size()):
|
||||||
var who: Node3D = everyone[0] if everyone.size() == 1 else everyone[_rng.randi() % everyone.size()]
|
var who: Node3D = everyone[0] if everyone.size() == 1 else everyone[_rng.randi() % everyone.size()]
|
||||||
if _spawn_near(kind, RING_MIN, RING_MAX, who.global_position) != null:
|
if _spawn_near(kind, RING_MIN, RING_MAX, who.global_position) != null:
|
||||||
_cooldown[kind] = RESPAWN_DELAY * 0.25
|
_cooldown[kind] = RESPAWN_DELAY * 0.25
|
||||||
@@ -91,6 +91,13 @@ func _process(delta: float) -> void:
|
|||||||
static func budget(kind: String, players: int) -> int:
|
static func budget(kind: String, players: int) -> int:
|
||||||
return BUDGET[kind] * clampi(players, 1, 2)
|
return BUDGET[kind] * clampi(players, 1, 2)
|
||||||
|
|
||||||
|
## In a hard winter (P4) half as many: the animals are holed up.
|
||||||
|
func winter_budget(kind: String, players: int) -> int:
|
||||||
|
var b := budget(kind, players)
|
||||||
|
if world != null and world.weather != null and world.weather.in_hard_winter():
|
||||||
|
b = maxi(b / 2, 1)
|
||||||
|
return b
|
||||||
|
|
||||||
func _on_died(c: Node) -> void:
|
func _on_died(c: Node) -> void:
|
||||||
_cooldown[c.kind] = RESPAWN_DELAY
|
_cooldown[c.kind] = RESPAWN_DELAY
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ static func build(key: String) -> ArrayMesh:
|
|||||||
"bear_fat": m = _pebble(271, Color(0.92, 0.86, 0.68), Vector3(0.1, 0.06, 0.08), 0.1)
|
"bear_fat": m = _pebble(271, Color(0.92, 0.86, 0.68), Vector3(0.1, 0.06, 0.08), 0.1)
|
||||||
"fur_rug": m = _rug()
|
"fur_rug": m = _rug()
|
||||||
"barrow_key": m = _barrow_key()
|
"barrow_key": m = _barrow_key()
|
||||||
|
"rotten_food": m = _pebble(321, Color(0.33, 0.3, 0.16), Vector3(0.11, 0.06, 0.1), 0.45)
|
||||||
|
"salt": m = _salt()
|
||||||
|
"smoking_rack": m = _smoking_rack()
|
||||||
|
"smoked_meat": m = _meat(Color(0.36, 0.17, 0.1))
|
||||||
|
"salted_meat": m = _meat(Color(0.68, 0.42, 0.38))
|
||||||
|
"smoked_fish": m = _fish(Color(0.72, 0.5, 0.22), 0.2, true)
|
||||||
|
"salted_fish": m = _fish(Color(0.75, 0.73, 0.66), 0.2, false)
|
||||||
"berry_bush": m = _berry_bush(true)
|
"berry_bush": m = _berry_bush(true)
|
||||||
"berry_bush_bare": m = _berry_bush(false)
|
"berry_bush_bare": m = _berry_bush(false)
|
||||||
_:
|
_:
|
||||||
@@ -1019,3 +1026,39 @@ static func _barrow_key() -> ArrayMesh:
|
|||||||
_box(st, c + Vector3(0, 0.02, 0), Vector3(w, 0.04, 0.12), P._mul(Color(0.45, 0.46, 0.44), r.randf_range(0.9, 1.05)), Basis(Vector3.UP, (a0 + a1) * 0.5))
|
_box(st, c + Vector3(0, 0.02, 0), Vector3(w, 0.04, 0.12), P._mul(Color(0.45, 0.46, 0.44), r.randf_range(0.9, 1.05)), Basis(Vector3.UP, (a0 + a1) * 0.5))
|
||||||
_box(st, Vector3(0, 0.042, 0.1), Vector3(0.1, 0.006, 0.02), Color(0.82, 0.8, 0.7))
|
_box(st, Vector3(0, 0.042, 0.1), Vector3(0.1, 0.006, 0.02), Color(0.82, 0.8, 0.7))
|
||||||
return P._commit(st)
|
return P._commit(st)
|
||||||
|
|
||||||
|
# --- P4 food keeping ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
## A crust of white salt crystals.
|
||||||
|
static func _salt() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(331)
|
||||||
|
for i in range(7):
|
||||||
|
var c := Vector3(r.randf_range(-0.07, 0.07), 0.02, r.randf_range(-0.06, 0.06))
|
||||||
|
_box(st, c, Vector3.ONE * r.randf_range(0.025, 0.05), P._mul(Color(0.93, 0.93, 0.9), r.randf_range(0.9, 1.05)),
|
||||||
|
Basis(Vector3(r.randf(), r.randf(), r.randf()).normalized(), r.randf() * TAU))
|
||||||
|
return P._commit(st)
|
||||||
|
|
||||||
|
## Two forked posts with a crossbar, strips of meat hanging from it, embers
|
||||||
|
## below. The fire sits at the origin.
|
||||||
|
static func _smoking_rack() -> ArrayMesh:
|
||||||
|
var st := P._begin()
|
||||||
|
var r := _rng(333)
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 333
|
||||||
|
for sx in [-0.7, 0.7]:
|
||||||
|
P._cylinder(st, Vector3(sx, 0.0, 0), Vector3(sx, 1.35, 0), 0.05, 0.04, 6, P.BARK, r, -1.0, PLANK)
|
||||||
|
P._cylinder(st, Vector3(sx, 1.25, 0), Vector3(sx + 0.08 * signf(sx), 1.45, 0), 0.03, 0.02, 4, P.BARK, r)
|
||||||
|
P._cylinder(st, Vector3(-0.8, 1.32, 0), Vector3(0.8, 1.32, 0), 0.035, 0.035, 6, P.BARK, r, -1.0, PLANK)
|
||||||
|
for i in range(5):
|
||||||
|
var x := -0.48 + 0.24 * i
|
||||||
|
_box(st, Vector3(x, 1.12, 0), Vector3(0.12, 0.32 + 0.06 * sin(i * 2.1), 0.03), P._mul(Color(0.42, 0.18, 0.12), r.randf_range(0.8, 1.1)))
|
||||||
|
for i in range(6):
|
||||||
|
var a := TAU * i / 6.0
|
||||||
|
P._blob(st, Vector3(cos(a) * 0.32, 0.05, sin(a) * 0.32), Vector3(0.1, 0.07, 0.09), n, 0.3, STONE, r, 1)
|
||||||
|
P._blob(st, Vector3(0, 0.03, 0), Vector3(0.24, 0.035, 0.24), n, 0.2, ASH, r, 1)
|
||||||
|
for i in range(3):
|
||||||
|
var a := 0.9 * i
|
||||||
|
var d := Vector3(cos(a), 0, sin(a)) * 0.18
|
||||||
|
P._cylinder(st, Vector3(0, 0.06, 0) - d, Vector3(0, 0.06, 0) + d, 0.035, 0.035, 5, P.BARK, r, -1.0, CHARCOAL)
|
||||||
|
return P._commit(st)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
|
|||||||
const Identity = preload("res://scripts/net/identity.gd")
|
const Identity = preload("res://scripts/net/identity.gd")
|
||||||
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
||||||
|
|
||||||
const PROTOCOL := 10 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3)
|
const PROTOCOL := 11 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4)
|
||||||
const DEFAULT_PORT := 24242
|
const DEFAULT_PORT := 24242
|
||||||
const HANDSHAKE_TIMEOUT := 10.0
|
const HANDSHAKE_TIMEOUT := 10.0
|
||||||
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
|
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ func _on_died(peer: int) -> void:
|
|||||||
var at: Vector3 = pos + off
|
var at: Vector3 = pos + off
|
||||||
at.y = w.terrain.sample_height(at.x, at.z) + 0.05
|
at.y = w.terrain.sample_height(at.x, at.z) + 0.05
|
||||||
w.drop_item(st.id, int(st.count), Transform3D(Basis(Vector3.UP, rng.randf() * TAU), at),
|
w.drop_item(st.id, int(st.count), Transform3D(Basis(Vector3.UP, rng.randf() * TAU), at),
|
||||||
{"dur": int(st.dur)} if st.has("dur") else {})
|
Inventory.extra_of(st))
|
||||||
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
|
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
|
||||||
# ("keep": you keep everything.)
|
# ("keep": you keep everything.)
|
||||||
# They stay down on the death screen for a few seconds, then ask to respawn.
|
# They stay down on the death screen for a few seconds, then ask to respawn.
|
||||||
@@ -369,7 +369,7 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
var left: int = p.inventory.add_stack(p.cursor)
|
var left: int = p.inventory.add_stack(p.cursor)
|
||||||
if left > 0:
|
if left > 0:
|
||||||
w.drop_item(p.cursor.id, left, Transform3D(Basis(), p.global_position + Vector3(0, 0.1, 0)),
|
w.drop_item(p.cursor.id, left, Transform3D(Basis(), p.global_position + Vector3(0, 0.1, 0)),
|
||||||
{"dur": int(p.cursor.dur)} if p.cursor.has("dur") else {})
|
Inventory.extra_of(p.cursor))
|
||||||
p.cursor = null
|
p.cursor = null
|
||||||
"debug_give":
|
"debug_give":
|
||||||
if cheats and not ItemDB.get_item(_s(a, 0)).is_empty():
|
if cheats and not ItemDB.get_item(_s(a, 0)).is_empty():
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ static func difficulty(k: String) -> float:
|
|||||||
var d: Variant = v("difficulty")
|
var d: Variant = v("difficulty")
|
||||||
return float(DIFFICULTY.get(String(d) if d != null else "normal", DIFFICULTY.normal)[k])
|
return float(DIFFICULTY.get(String(d) if d != null else "normal", DIFFICULTY.normal)[k])
|
||||||
|
|
||||||
|
## How fast food rots: "normal", "slow" (half speed) or "off".
|
||||||
|
static func rot() -> String:
|
||||||
|
var r: Variant = v("rot")
|
||||||
|
return String(r) if r != null else "normal"
|
||||||
|
|
||||||
static func grace_days() -> int:
|
static func grace_days() -> int:
|
||||||
var g: Variant = v("wolf_grace_days")
|
var g: Variant = v("wolf_grace_days")
|
||||||
return clampi(int(g), 0, 10) if g != null else 3
|
return clampi(int(g), 0, 10) if g != null else 3
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const DEFAULTS := {
|
|||||||
"day_length_minutes": 20,
|
"day_length_minutes": 20,
|
||||||
"wolf_grace_days": 3,
|
"wolf_grace_days": 3,
|
||||||
"difficulty": "normal",
|
"difficulty": "normal",
|
||||||
|
"rot": "normal",
|
||||||
"autosave_minutes": 2,
|
"autosave_minutes": 2,
|
||||||
"backups_kept": 10,
|
"backups_kept": 10,
|
||||||
"backup_hours": 6,
|
"backup_hours": 6,
|
||||||
@@ -32,7 +33,7 @@ const DEFAULTS := {
|
|||||||
}
|
}
|
||||||
## Settings sent to players when they join (never the password or the lists).
|
## Settings sent to players when they join (never the password or the lists).
|
||||||
const PUBLIC := ["server_name", "motd", "max_players", "worldlet", "pvp", "raiding", "locks",
|
const PUBLIC := ["server_name", "motd", "max_players", "worldlet", "pvp", "raiding", "locks",
|
||||||
"death_penalty", "sleep", "pause_when_empty", "day_length_minutes", "wolf_grace_days", "difficulty"]
|
"death_penalty", "sleep", "pause_when_empty", "day_length_minutes", "wolf_grace_days", "difficulty", "rot"]
|
||||||
|
|
||||||
var path := DEFAULT_PATH
|
var path := DEFAULT_PATH
|
||||||
var data: Dictionary = {} ## what's in server.json (saved)
|
var data: Dictionary = {} ## what's in server.json (saved)
|
||||||
@@ -146,6 +147,7 @@ const SETTABLE := {
|
|||||||
"sleep": ["all_online", "half", "anyone", "off"],
|
"sleep": ["all_online", "half", "anyone", "off"],
|
||||||
"pause_when_empty": ["false", "true"],
|
"pause_when_empty": ["false", "true"],
|
||||||
"difficulty": ["easy", "normal", "hard"],
|
"difficulty": ["easy", "normal", "hard"],
|
||||||
|
"rot": ["normal", "slow", "off"],
|
||||||
"wolf_grace_days": "0-10",
|
"wolf_grace_days": "0-10",
|
||||||
"motd": "text",
|
"motd": "text",
|
||||||
"server_name": "text",
|
"server_name": "text",
|
||||||
|
|||||||
@@ -365,6 +365,21 @@ func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void:
|
|||||||
n.apply_remote(hp, point, from)
|
n.apply_remote(hp, point, from)
|
||||||
received += 1
|
received += 1
|
||||||
|
|
||||||
|
## Lightning struck a tree (hazards.gd, P4): everyone sees it burn. (It
|
||||||
|
## falls later through _s2c_node, like any felled tree.)
|
||||||
|
func struck(n: Node3D) -> void:
|
||||||
|
_all("_s2c_strike", [String(n.uid)])
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_strike(uid: String) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
var n: Node3D = world.props.resource_nodes.get(uid)
|
||||||
|
var hz: Node = world.get_node_or_null("Hazards")
|
||||||
|
if n != null and is_instance_valid(n) and hz != null:
|
||||||
|
hz.show_strike(n)
|
||||||
|
received += 1
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_regrown(uid: String) -> void:
|
func _s2c_regrown(uid: String) -> void:
|
||||||
if not is_client():
|
if not is_client():
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ var _shelter_left := 0.0
|
|||||||
var _rest_build := 0.0
|
var _rest_build := 0.0
|
||||||
## Food buffs: id -> {name, left (s), stamina_regen, health_regen (multipliers)}.
|
## Food buffs: id -> {name, left (s), stamina_regen, health_regen (multipliers)}.
|
||||||
var buffs: Dictionary = {}
|
var buffs: Dictionary = {}
|
||||||
|
var sick := 0.0 ## seconds of food poisoning left (P4): slow stamina, can't eat
|
||||||
|
const SICK_SECONDS := 60.0
|
||||||
var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit
|
var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit
|
||||||
var cause := "" ## what last hurt you, as the death screen says it ("You froze")
|
var cause := "" ## what last hurt you, as the death screen says it ("You froze")
|
||||||
var drain_mult := 1.0 ## hunger and diet drain (a server's difficulty, rules.gd)
|
var drain_mult := 1.0 ## hunger and diet drain (a server's difficulty, rules.gd)
|
||||||
@@ -128,7 +130,9 @@ func step(dt: float) -> void:
|
|||||||
wet = maxf(wet - dt * (3.0 if heat > 0.3 else 1.0), 0.0) # fires dry you off
|
wet = maxf(wet - dt * (3.0 if heat > 0.3 else 1.0), 0.0) # fires dry you off
|
||||||
target = clampf(target, 0.0, MAX)
|
target = clampf(target, 0.0, MAX)
|
||||||
# Warm up faster than you cool down: getting to a fire should feel good.
|
# Warm up faster than you cool down: getting to a fire should feel good.
|
||||||
warmth = move_toward(warmth, target, (7.0 if target > warmth else 1.6) * dt)
|
# (Out in a blizzard you lose it twice as fast.)
|
||||||
|
var cool := 1.6 * (2.0 if world.weather.blizzard_chill(sheltered) > 0.0 else 1.0)
|
||||||
|
warmth = move_toward(warmth, target, (7.0 if target > warmth else cool) * dt)
|
||||||
|
|
||||||
hunger = maxf(hunger - HUNGER_DRAIN * drain_mult * dt * (1.5 if warmth < 30.0 else 1.0), 0.0)
|
hunger = maxf(hunger - HUNGER_DRAIN * drain_mult * dt * (1.5 if warmth < 30.0 else 1.0), 0.0)
|
||||||
|
|
||||||
@@ -137,6 +141,7 @@ func step(dt: float) -> void:
|
|||||||
else:
|
else:
|
||||||
var malnourished := diet_lacking().size() >= 2
|
var malnourished := diet_lacking().size() >= 2
|
||||||
stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * buff_mult("stamina_regen")
|
stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * buff_mult("stamina_regen")
|
||||||
|
* (0.5 if sick > 0.0 else 1.0)
|
||||||
* (0.7 if malnourished else 1.0) * dt, MAX)
|
* (0.7 if malnourished else 1.0) * dt, MAX)
|
||||||
|
|
||||||
for g in DIET_GROUPS:
|
for g in DIET_GROUPS:
|
||||||
@@ -148,6 +153,7 @@ func step(dt: float) -> void:
|
|||||||
if buffs[id].left <= 0.0:
|
if buffs[id].left <= 0.0:
|
||||||
buffs.erase(id)
|
buffs.erase(id)
|
||||||
_hurt_cd = maxf(_hurt_cd - dt, 0.0)
|
_hurt_cd = maxf(_hurt_cd - dt, 0.0)
|
||||||
|
sick = maxf(sick - dt, 0.0)
|
||||||
|
|
||||||
var dmg := 0.0
|
var dmg := 0.0
|
||||||
if hunger <= 0.0:
|
if hunger <= 0.0:
|
||||||
@@ -196,6 +202,8 @@ func _update_statuses() -> void:
|
|||||||
statuses.append("%s (need %s)" % ["Malnourished" if lacking.size() >= 2 else "Poor diet", ", ".join(need)])
|
statuses.append("%s (need %s)" % ["Malnourished" if lacking.size() >= 2 else "Poor diet", ", ".join(need)])
|
||||||
elif balanced_diet():
|
elif balanced_diet():
|
||||||
statuses.append("Balanced diet")
|
statuses.append("Balanced diet")
|
||||||
|
if sick > 0.0:
|
||||||
|
statuses.append("Food poisoning")
|
||||||
if sheltered:
|
if sheltered:
|
||||||
statuses.append("Sheltered")
|
statuses.append("Sheltered")
|
||||||
if cozy:
|
if cozy:
|
||||||
@@ -230,6 +238,13 @@ func eat(food: Dictionary) -> void:
|
|||||||
func soak() -> void:
|
func soak() -> void:
|
||||||
wet = WET_SECONDS
|
wet = WET_SECONDS
|
||||||
|
|
||||||
|
## Something you ate disagreed with you (raw meat, raw fish): stamina comes
|
||||||
|
## back at half speed and you can't keep anything down for a minute.
|
||||||
|
func poison() -> void:
|
||||||
|
sick = SICK_SECONDS
|
||||||
|
_update_statuses()
|
||||||
|
changed.emit()
|
||||||
|
|
||||||
## After a night's sleep: a little hungrier, dry, warm and rested.
|
## After a night's sleep: a little hungrier, dry, warm and rested.
|
||||||
func after_sleep(seconds: float) -> void:
|
func after_sleep(seconds: float) -> void:
|
||||||
hunger = maxf(hunger - HUNGER_DRAIN * seconds * 0.3, 0.0)
|
hunger = maxf(hunger - HUNGER_DRAIN * seconds * 0.3, 0.0)
|
||||||
@@ -284,7 +299,7 @@ func add_buff(b: Dictionary) -> void:
|
|||||||
|
|
||||||
func to_data() -> Dictionary:
|
func to_data() -> Dictionary:
|
||||||
return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet, "rested": rested,
|
return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet, "rested": rested,
|
||||||
"buffs": buffs.duplicate(true), "diet": diet.duplicate()}
|
"buffs": buffs.duplicate(true), "diet": diet.duplicate(), "sick": sick}
|
||||||
|
|
||||||
func load_data(d: Dictionary) -> void:
|
func load_data(d: Dictionary) -> void:
|
||||||
health = float(d.get("health", health))
|
health = float(d.get("health", health))
|
||||||
@@ -293,6 +308,7 @@ func load_data(d: Dictionary) -> void:
|
|||||||
warmth = float(d.get("warmth", warmth))
|
warmth = float(d.get("warmth", warmth))
|
||||||
wet = float(d.get("wet", 0.0))
|
wet = float(d.get("wet", 0.0))
|
||||||
rested = float(d.get("rested", 0.0))
|
rested = float(d.get("rested", 0.0))
|
||||||
|
sick = float(d.get("sick", 0.0))
|
||||||
buffs = (d.get("buffs", {}) as Dictionary).duplicate(true)
|
buffs = (d.get("buffs", {}) as Dictionary).duplicate(true)
|
||||||
# Older saves have no diet: start it where a new world does.
|
# Older saves have no diet: start it where a new world does.
|
||||||
var sd: Dictionary = d.get("diet", {})
|
var sd: Dictionary = d.get("diet", {})
|
||||||
|
|||||||
@@ -82,9 +82,27 @@ func set_stack(stack) -> void:
|
|||||||
_wear_fill.anchor_right = k
|
_wear_fill.anchor_right = k
|
||||||
_wear_fill.color = Color(0.85, 0.25, 0.2) if k < 0.2 else (Color(0.9, 0.75, 0.25) if k < 0.5 else Color(0.45, 0.8, 0.35))
|
_wear_fill.color = Color(0.85, 0.25, 0.2) if k < 0.2 else (Color(0.9, 0.75, 0.25) if k < 0.5 else Color(0.45, 0.8, 0.35))
|
||||||
tooltip_text += "\n%d of %d uses left" % [left, full]
|
tooltip_text += "\n%d of %d uses left" % [left, full]
|
||||||
|
elif stack.has("rot") and Inventory.keeps_for(String(stack.id)) > 0.0:
|
||||||
|
# Food: a bar that runs down green to brown as it goes off.
|
||||||
|
var k := Inventory.freshness(stack)
|
||||||
|
_wear.visible = true
|
||||||
|
_wear_fill.anchor_right = maxf(k, 0.04)
|
||||||
|
_wear_fill.color = Color(0.45, 0.8, 0.35).lerp(Color(0.5, 0.33, 0.15), 1.0 - k)
|
||||||
|
tooltip_text += "\n" + freshness_text(stack)
|
||||||
else:
|
else:
|
||||||
_wear.visible = false
|
_wear.visible = false
|
||||||
|
|
||||||
|
## "Fresh", "Going off (about 9 hours left)", "Nearly rotten (2 hours left)".
|
||||||
|
static func freshness_text(stack) -> String:
|
||||||
|
var k := Inventory.freshness(stack)
|
||||||
|
var h := Inventory.hours_left(stack)
|
||||||
|
var left := ("%d hours left" % roundi(h)) if h < 48.0 else ("%d days left" % roundi(h / 24.0))
|
||||||
|
if k > 0.6:
|
||||||
|
return "Fresh (%s)" % left
|
||||||
|
if k > 0.2:
|
||||||
|
return "Going off (%s)" % left
|
||||||
|
return "Nearly rotten (%s)" % left
|
||||||
|
|
||||||
func set_selected(on: bool) -> void:
|
func set_selected(on: bool) -> void:
|
||||||
add_theme_stylebox_override("panel", _selected_style if on else _style)
|
add_theme_stylebox_override("panel", _selected_style if on else _style)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const WORLD_SCENE := "res://scenes/world.tscn"
|
|||||||
var _pages: Dictionary = {} ## name -> Control
|
var _pages: Dictionary = {} ## name -> Control
|
||||||
var _name_edit: LineEdit
|
var _name_edit: LineEdit
|
||||||
var _seed_edit: LineEdit
|
var _seed_edit: LineEdit
|
||||||
|
var _rot_pick: OptionButton
|
||||||
|
## [setting, what the New World page calls it]
|
||||||
|
const ROT_CHOICES := [["normal", "Normally (raw meat lasts 2 days)"], ["slow", "Slowly (twice as long)"], ["off", "Never"]]
|
||||||
var _list: VBoxContainer
|
var _list: VBoxContainer
|
||||||
var _armed_delete := ""
|
var _armed_delete := ""
|
||||||
|
|
||||||
@@ -181,6 +184,7 @@ func _show(page: String) -> void:
|
|||||||
elif page == "new":
|
elif page == "new":
|
||||||
_name_edit.text = _unique_name()
|
_name_edit.text = _unique_name()
|
||||||
_seed_edit.text = ""
|
_seed_edit.text = ""
|
||||||
|
_rot_pick.select(0)
|
||||||
_name_edit.grab_focus()
|
_name_edit.grab_focus()
|
||||||
_name_edit.select_all()
|
_name_edit.select_all()
|
||||||
|
|
||||||
@@ -571,6 +575,12 @@ func _new_page() -> Control:
|
|||||||
_seed_edit.custom_minimum_size = Vector2(360, 40)
|
_seed_edit.custom_minimum_size = Vector2(360, 40)
|
||||||
_seed_edit.text_submitted.connect(func(_t): _create())
|
_seed_edit.text_submitted.connect(func(_t): _create())
|
||||||
v.add_child(_seed_edit)
|
v.add_child(_seed_edit)
|
||||||
|
v.add_child(UITheme.label("Food spoils", 15, UITheme.TEXT_DIM))
|
||||||
|
_rot_pick = OptionButton.new()
|
||||||
|
for t in ROT_CHOICES:
|
||||||
|
_rot_pick.add_item(t[1])
|
||||||
|
_rot_pick.custom_minimum_size = Vector2(360, 36)
|
||||||
|
v.add_child(_rot_pick)
|
||||||
var h := HBoxContainer.new()
|
var h := HBoxContainer.new()
|
||||||
h.add_theme_constant_override("separation", 10)
|
h.add_theme_constant_override("separation", 10)
|
||||||
h.add_child(_button("Create & Play", _create, 170))
|
h.add_child(_button("Create & Play", _create, 170))
|
||||||
@@ -660,7 +670,8 @@ func _create() -> void:
|
|||||||
world_name = _unique_name()
|
world_name = _unique_name()
|
||||||
var seed_text := _seed_edit.text.strip_edges()
|
var seed_text := _seed_edit.text.strip_edges()
|
||||||
var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi())
|
var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi())
|
||||||
_play(Saves.create_world(world_name, seed))
|
var rot: String = ROT_CHOICES[maxi(_rot_pick.selected, 0)][0]
|
||||||
|
_play(Saves.create_world(world_name, seed, {"rot": rot}))
|
||||||
|
|
||||||
func _play(id: String) -> void:
|
func _play(id: String) -> void:
|
||||||
Saves.current_id = id
|
Saves.current_id = id
|
||||||
|
|||||||
288
scripts/world/hazards.gd
Normal file
288
scripts/world/hazards.gd
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
extends Node3D
|
||||||
|
## Weather hazards (P4, docs/PROGRESSION_DESIGN.md §5) that need more than
|
||||||
|
## the weather model:
|
||||||
|
##
|
||||||
|
## - Hard winters (weather.gd hard_winter()): a warning the day before and
|
||||||
|
## on the day; the lake's edge freezes over (walkable ice you can't fish
|
||||||
|
## through), on every machine since it comes from the shared clock.
|
||||||
|
## - Lightning: in a storm, every minute or so a bolt hits a tree near
|
||||||
|
## someone, preferring tall trees near the rim. It burns for a while (a
|
||||||
|
## fire you can warm yourself by), then falls, leaving charcoal. Decided:
|
||||||
|
## trees only for now; buildings are never struck.
|
||||||
|
##
|
||||||
|
## Strikes and warnings happen where the game decides (single-player, a
|
||||||
|
## server); players' games are told (world_net.gd _s2c_strike).
|
||||||
|
|
||||||
|
const Weather = preload("res://scripts/world/weather.gd")
|
||||||
|
|
||||||
|
const STRIKE_MIN := 50.0
|
||||||
|
const STRIKE_MAX := 110.0
|
||||||
|
const STRIKE_RANGE := 90.0 ## trees this near someone can be struck
|
||||||
|
const BURN_SECONDS := 40.0
|
||||||
|
const CHARCOAL := 2
|
||||||
|
const ICE_IN := 0.62 ## the ice: from this x lake radius out to the shore
|
||||||
|
const ICE_OUT := 1.7
|
||||||
|
|
||||||
|
var world: Node3D
|
||||||
|
var deciding := true ## false on a player's game online
|
||||||
|
var strikes := 0
|
||||||
|
var _strike_left := 30.0
|
||||||
|
var _rng := RandomNumberGenerator.new()
|
||||||
|
var _burning: Dictionary = {} ## resource node -> [fire node, seconds left]
|
||||||
|
var _ice: MeshInstance3D
|
||||||
|
var _ice_body: StaticBody3D
|
||||||
|
var _warned := {} ## day -> true
|
||||||
|
|
||||||
|
func setup(w: Node3D, decides: bool) -> void:
|
||||||
|
world = w
|
||||||
|
deciding = decides
|
||||||
|
_rng.seed = w.terrain.noise_seed + 1201
|
||||||
|
_build_ice()
|
||||||
|
w.day_night.new_day.connect(_on_new_day)
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
step(delta)
|
||||||
|
|
||||||
|
func step(dt: float) -> void:
|
||||||
|
_set_ice(world.weather.in_hard_winter())
|
||||||
|
_snow_left -= dt
|
||||||
|
if _snow_left <= 0.0:
|
||||||
|
_snow_left = 2.0
|
||||||
|
_update_roof_snow()
|
||||||
|
for n in _burning.keys():
|
||||||
|
var e: Array = _burning[n]
|
||||||
|
e[1] = float(e[1]) - dt
|
||||||
|
if float(e[1]) <= 0.0 or not is_instance_valid(n):
|
||||||
|
_burn_out(n)
|
||||||
|
if not deciding:
|
||||||
|
return
|
||||||
|
var w: Node = world.weather
|
||||||
|
if w.state == "storm" and w.precip > 0.7:
|
||||||
|
_strike_left -= dt
|
||||||
|
if _strike_left <= 0.0:
|
||||||
|
_strike_left = _rng.randf_range(STRIKE_MIN, STRIKE_MAX)
|
||||||
|
var t := pick_tree()
|
||||||
|
if t != null:
|
||||||
|
strike(t)
|
||||||
|
|
||||||
|
# --- lightning -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## A standing tree near someone, the taller and nearer the rim the likelier.
|
||||||
|
func pick_tree() -> Node3D:
|
||||||
|
var best: Node3D = null
|
||||||
|
var best_s := -INF
|
||||||
|
for n in world.props.resource_nodes.values():
|
||||||
|
if n.kind != "tree" or n.depleted or _burning.has(n):
|
||||||
|
continue
|
||||||
|
var near := false
|
||||||
|
for p in world.players():
|
||||||
|
if (n as Node3D).global_position.distance_to(p.global_position) < STRIKE_RANGE:
|
||||||
|
near = true
|
||||||
|
break
|
||||||
|
if not near:
|
||||||
|
continue
|
||||||
|
var pos: Vector3 = n.global_position
|
||||||
|
var s: float = world.props._near_rim(pos.x, pos.z) * 2.0 + n.base_xf.basis.get_scale().y + _rng.randf() * 1.5
|
||||||
|
if s > best_s:
|
||||||
|
best_s = s
|
||||||
|
best = n
|
||||||
|
return best
|
||||||
|
|
||||||
|
## Lightning hits tree `n`: a flash, a crack, and it's on fire.
|
||||||
|
func strike(n: Node3D) -> void:
|
||||||
|
strikes += 1
|
||||||
|
show_strike(n)
|
||||||
|
if world.server_mode:
|
||||||
|
WorldNet.struck(n)
|
||||||
|
for p in world.players():
|
||||||
|
if p.global_position.distance_to(n.global_position) < 60.0:
|
||||||
|
Actions.tell(p, "Lightning struck a tree! It's burning.")
|
||||||
|
|
||||||
|
## What everyone sees: the flash, and the tree alight.
|
||||||
|
func show_strike(n: Node3D) -> void:
|
||||||
|
world.weather.flash = 1.0
|
||||||
|
if _burning.has(n):
|
||||||
|
return
|
||||||
|
var fire := TreeFire.new()
|
||||||
|
fire.name = "TreeFire"
|
||||||
|
add_child(fire)
|
||||||
|
fire.global_position = n.global_position
|
||||||
|
fire.build(DisplayServer.get_name() != "headless")
|
||||||
|
_burning[n] = [fire, BURN_SECONDS]
|
||||||
|
|
||||||
|
func burning(n: Node3D) -> bool:
|
||||||
|
return _burning.has(n)
|
||||||
|
|
||||||
|
## Burnt through: the tree falls (where the game decides; players' games
|
||||||
|
## see it fall via world_net), leaving charcoal at its foot.
|
||||||
|
func _burn_out(n: Node3D) -> void:
|
||||||
|
var e: Array = _burning[n]
|
||||||
|
_burning.erase(n)
|
||||||
|
if is_instance_valid(e[0]):
|
||||||
|
(e[0] as Node).queue_free()
|
||||||
|
if not deciding or not is_instance_valid(n) or n.depleted:
|
||||||
|
return
|
||||||
|
var top: Vector3 = n.global_position + Vector3(0, 2.0, 0)
|
||||||
|
var from: Vector3 = n.global_position + Vector3(1, 0, 0)
|
||||||
|
n._show_hit(0.0, top, from)
|
||||||
|
if world.server_mode:
|
||||||
|
WorldNet.node_hit(n, top, from)
|
||||||
|
world.drop_item("charcoal", CHARCOAL, Transform3D(Basis(), n.global_position + Vector3(0.8, 0.3, 0.4)))
|
||||||
|
|
||||||
|
## A burning tree's fire: flames up the trunk, a light, and warmth.
|
||||||
|
class TreeFire extends Node3D:
|
||||||
|
const RADIUS := 6.0
|
||||||
|
func build(visual: bool) -> void:
|
||||||
|
add_to_group("heat")
|
||||||
|
if not visual:
|
||||||
|
return
|
||||||
|
var light := OmniLight3D.new()
|
||||||
|
light.light_color = Color(1.0, 0.55, 0.25)
|
||||||
|
light.light_energy = 3.0
|
||||||
|
light.omni_range = 14.0
|
||||||
|
light.position = Vector3(0, 3.0, 0)
|
||||||
|
add_child(light)
|
||||||
|
# The campfire's soft additive flames, bigger and up the trunk.
|
||||||
|
var CF: Script = load("res://scripts/building/campfire.gd")
|
||||||
|
var ramp: Gradient = CF._gradient([[0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.15, Color(1.0, 0.62, 0.2, 0.8)],
|
||||||
|
[0.6, Color(0.95, 0.3, 0.06, 0.5)], [1.0, Color(0.5, 0.08, 0.02, 0.0)]])
|
||||||
|
for i in range(3):
|
||||||
|
var p: CPUParticles3D = CF._particles("Flames%d" % i, 30, 1.0, Vector3(0, 1.2 + 1.6 * i, 0), 0.8, 2.0,
|
||||||
|
0.6, 1.2, ramp, true, 0.9, 0.7 - 0.15 * i)
|
||||||
|
add_child(p)
|
||||||
|
p.emitting = true
|
||||||
|
func heat_at(pos: Vector3) -> float:
|
||||||
|
return 1.0 - smoothstep(2.0, RADIUS, pos.distance_to(global_position))
|
||||||
|
|
||||||
|
# --- hard winters ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_new_day(day: int) -> void:
|
||||||
|
if not deciding or _warned.has(day):
|
||||||
|
return
|
||||||
|
_warned[day] = true
|
||||||
|
if Weather.hard_winter(day + 1) and not Weather.hard_winter(day):
|
||||||
|
Actions.tell_all("The air has a bite to it. A hard winter is coming tomorrow: three bitter days. Stock up on food and firewood.")
|
||||||
|
elif Weather.hard_winter(day) and not Weather.hard_winter(day - 1):
|
||||||
|
Actions.tell_all("A hard winter has set in. The lake's edge has frozen, and the animals are holed up.")
|
||||||
|
elif Weather.hard_winter(day - 1) and not Weather.hard_winter(day):
|
||||||
|
Actions.tell_all("The hard winter is over.")
|
||||||
|
|
||||||
|
## A ring of ice round the lake, at the water line: it shows (and you can
|
||||||
|
## walk on it) wherever the water is, from the shore out to ICE_IN of the
|
||||||
|
## lake's radius. Off except in a hard winter.
|
||||||
|
func _build_ice() -> void:
|
||||||
|
var t: Node3D = world.terrain
|
||||||
|
var c: Vector2 = t.lake_center
|
||||||
|
var r0: float = t.lake_radius * ICE_IN
|
||||||
|
var r1: float = t.lake_radius * ICE_OUT
|
||||||
|
var y: float = t.water_level + 0.04
|
||||||
|
var segs := 64
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
var faces := PackedVector3Array()
|
||||||
|
var nz := FastNoiseLite.new()
|
||||||
|
nz.seed = t.noise_seed + 1202
|
||||||
|
for i in range(segs):
|
||||||
|
var a0 := TAU * i / segs
|
||||||
|
var a1 := TAU * (i + 1) / segs
|
||||||
|
var k0 := 1.0 + 0.12 * nz.get_noise_1d(i * 7.0)
|
||||||
|
var k1 := 1.0 + 0.12 * nz.get_noise_1d((i + 1) * 7.0)
|
||||||
|
var p := [Vector3(c.x + cos(a0) * r0 * k0, y, c.y + sin(a0) * r0 * k0), Vector3(c.x + cos(a0) * r1, y, c.y + sin(a0) * r1),
|
||||||
|
Vector3(c.x + cos(a1) * r1, y, c.y + sin(a1) * r1), Vector3(c.x + cos(a1) * r0 * k1, y, c.y + sin(a1) * r0 * k1)]
|
||||||
|
for tri in [[p[0], p[2], p[1]], [p[0], p[3], p[2]]]:
|
||||||
|
for v in tri:
|
||||||
|
st.set_normal(Vector3.UP)
|
||||||
|
st.add_vertex(v)
|
||||||
|
faces.append(v)
|
||||||
|
_ice = MeshInstance3D.new()
|
||||||
|
_ice.name = "LakeIce"
|
||||||
|
_ice.mesh = st.commit()
|
||||||
|
var m := StandardMaterial3D.new()
|
||||||
|
m.albedo_color = Color(0.86, 0.93, 0.98, 0.96)
|
||||||
|
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
m.roughness = 0.35
|
||||||
|
m.metallic_specular = 0.8
|
||||||
|
_ice.material_override = m
|
||||||
|
_ice.visible = false
|
||||||
|
add_child(_ice)
|
||||||
|
_ice_body = StaticBody3D.new()
|
||||||
|
_ice_body.name = "IceBody"
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
var shape := ConcavePolygonShape3D.new()
|
||||||
|
shape.set_faces(faces)
|
||||||
|
shape.backface_collision = true
|
||||||
|
cs.shape = shape
|
||||||
|
_ice_body.add_child(cs)
|
||||||
|
_ice_body.collision_layer = 0
|
||||||
|
add_child(_ice_body)
|
||||||
|
|
||||||
|
func _set_ice(on: bool) -> void:
|
||||||
|
if _ice.visible == on:
|
||||||
|
return
|
||||||
|
_ice.visible = on
|
||||||
|
_ice_body.collision_layer = 1 if on else 0
|
||||||
|
|
||||||
|
# --- snow on roofs ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _snow_left := 0.0
|
||||||
|
var _snow_mat: StandardMaterial3D
|
||||||
|
var _snow_mesh: BoxMesh
|
||||||
|
|
||||||
|
## Snow piles up on roofs as it settles (weather.gd snow_cover), and melts
|
||||||
|
## off with it. Just a look: a white layer on each roof piece, thicker the
|
||||||
|
## more has settled. (Visual only, so not on a headless server.)
|
||||||
|
func _update_roof_snow() -> void:
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
return
|
||||||
|
var cover: float = world.weather.snow_cover
|
||||||
|
if _snow_mesh == null:
|
||||||
|
var BP: Script = load("res://scripts/building/build_piece.gd")
|
||||||
|
_snow_mesh = BoxMesh.new()
|
||||||
|
_snow_mesh.size = Vector3(2.04, 0.12, sqrt(5.0) + 0.25)
|
||||||
|
_snow_mat = StandardMaterial3D.new()
|
||||||
|
_snow_mat.albedo_color = Color(0.93, 0.95, 0.98)
|
||||||
|
_snow_mat.roughness = 0.9
|
||||||
|
_snow_mesh.material = _snow_mat
|
||||||
|
_snow_angle = BP.ROOF_ANGLE
|
||||||
|
_snow_t = BP.ROOF_T
|
||||||
|
for p in world.build.build_pieces():
|
||||||
|
if p.kind != "roof":
|
||||||
|
continue
|
||||||
|
var s: MeshInstance3D = p.get_node_or_null("Snow")
|
||||||
|
if cover < 0.2:
|
||||||
|
if s != null:
|
||||||
|
s.visible = false
|
||||||
|
continue
|
||||||
|
if s == null:
|
||||||
|
s = MeshInstance3D.new()
|
||||||
|
s.name = "Snow"
|
||||||
|
s.mesh = _snow_mesh
|
||||||
|
p.add_child(s)
|
||||||
|
var k := clampf((cover - 0.2) / 0.8, 0.0, 1.0)
|
||||||
|
var n := Vector3(0, cos(_snow_angle), sin(_snow_angle))
|
||||||
|
var along := Vector3(0, sin(_snow_angle), -cos(_snow_angle))
|
||||||
|
s.visible = true
|
||||||
|
s.transform = Transform3D(Basis(Vector3.RIGHT, _snow_angle).scaled(Vector3(1.0, maxf(k, 0.15), 1.0)),
|
||||||
|
along * (sqrt(5.0) * 0.5 - 0.1) + n * (_snow_t + 0.11 + 0.06 * k))
|
||||||
|
|
||||||
|
var _snow_angle := 0.4636476
|
||||||
|
var _snow_t := 0.12
|
||||||
|
|
||||||
|
## How many roofs have snow on them right now (tests).
|
||||||
|
func snowy_roofs() -> int:
|
||||||
|
var n := 0
|
||||||
|
for p in world.build.build_pieces():
|
||||||
|
var s: Node = p.get_node_or_null("Snow")
|
||||||
|
if s != null and s.visible:
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
func lake_frozen() -> bool:
|
||||||
|
return _ice.visible
|
||||||
|
|
||||||
|
## Frozen over at (x, z): ice, not open water.
|
||||||
|
func iced(x: float, z: float) -> bool:
|
||||||
|
if not lake_frozen():
|
||||||
|
return false
|
||||||
|
var c: Vector2 = world.terrain.lake_center
|
||||||
|
return Vector2(x - c.x, z - c.y).length() > world.terrain.lake_radius * ICE_IN * 0.9
|
||||||
@@ -611,9 +611,9 @@ func _scatter_pickups() -> void:
|
|||||||
rng.seed = world_seed + 6
|
rng.seed = world_seed + 6
|
||||||
# Order matters: new kinds go at the END so existing saves keep their ids.
|
# Order matters: new kinds go at the END so existing saves keep their ids.
|
||||||
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150,
|
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150,
|
||||||
"iron_nugget": 70, "clay": 60}
|
"iron_nugget": 70, "clay": 60, "salt": 40}
|
||||||
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45,
|
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45,
|
||||||
"iron_nugget": 0.25, "clay": 0.3}
|
"iron_nugget": 0.25, "clay": 0.3, "salt": 0.25}
|
||||||
for id: String in want:
|
for id: String in want:
|
||||||
# Fibre grows as a plant (wild flax) you pick, 2 fibre a clump.
|
# Fibre grows as a plant (wild flax) you pick, 2 fibre a clump.
|
||||||
var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else ItemDB.mesh_for(id)
|
var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else ItemDB.mesh_for(id)
|
||||||
@@ -655,8 +655,11 @@ func _scatter_pickups() -> void:
|
|||||||
var by_lake: bool = Vector2(x - lake.x, z - lake.y).length() < terrain.lake_radius * 1.6
|
var by_lake: bool = Vector2(x - lake.x, z - lake.y).length() < terrain.lake_radius * 1.6
|
||||||
var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h)
|
var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h)
|
||||||
chance = shore * 0.95 if by_lake else 0.0
|
chance = shore * 0.95 if by_lake else 0.0
|
||||||
|
"salt":
|
||||||
|
# Crusts on the cliff tops above the sea (P4).
|
||||||
|
chance = 0.95 if terrain.sea_weight(x, z) > 0.6 and _near_rim(x, z) > 0.35 else 0.0
|
||||||
# A few right by the spawn so the first craft is quick.
|
# A few right by the spawn so the first craft is quick.
|
||||||
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay"]:
|
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt"]:
|
||||||
chance += 0.5
|
chance += 0.5
|
||||||
if keep > chance:
|
if keep > chance:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ extends Node
|
|||||||
## (and a storm becomes a blizzard). Above the snowline it always snows.
|
## (and a storm becomes a blizzard). Above the snowline it always snows.
|
||||||
## Outputs ease toward the state's targets over about a minute, so fronts
|
## Outputs ease toward the state's targets over about a minute, so fronts
|
||||||
## roll in rather than switching.
|
## roll in rather than switching.
|
||||||
|
##
|
||||||
|
## P4 (docs/PROGRESSION_DESIGN.md §5): every 12 days a hard winter, three
|
||||||
|
## bitter cold days in a row (hard_winter()), colder still, with more snow and
|
||||||
|
## blizzards. A blizzard cuts visibility right down and its wind strips the
|
||||||
|
## warmth from anyone out in it (blizzard_chill(); a shelter keeps it out).
|
||||||
|
|
||||||
signal state_changed(state: String)
|
signal state_changed(state: String)
|
||||||
|
|
||||||
@@ -30,12 +35,18 @@ const TARGETS := {
|
|||||||
"rain": {"clouds": 0.92, "precip": 0.6, "wind": 0.60, "light": 0.50, "fog": 2.2, "warmth": -6.0},
|
"rain": {"clouds": 0.92, "precip": 0.6, "wind": 0.60, "light": 0.50, "fog": 2.2, "warmth": -6.0},
|
||||||
"storm": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.30, "fog": 3.0, "warmth": -12.0},
|
"storm": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.30, "fog": 3.0, "warmth": -12.0},
|
||||||
"snow": {"clouds": 0.90, "precip": 0.6, "wind": 0.35, "light": 0.68, "fog": 2.6, "warmth": -14.0},
|
"snow": {"clouds": 0.90, "precip": 0.6, "wind": 0.35, "light": 0.68, "fog": 2.6, "warmth": -14.0},
|
||||||
"blizzard": {"clouds": 1.00, "precip": 1.0, "wind": 0.90, "light": 0.45, "fog": 4.5, "warmth": -24.0},
|
"blizzard": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.40, "fog": 7.0, "warmth": -24.0},
|
||||||
}
|
}
|
||||||
const COLD_DAY_WARMTH := -8.0
|
const COLD_DAY_WARMTH := -8.0
|
||||||
const BLEND_SECONDS := 50.0 ## time for outputs to cross from one state to another
|
const BLEND_SECONDS := 50.0 ## time for outputs to cross from one state to another
|
||||||
const CALM_START := 600.0 ## a new world's first 10 minutes are always clear
|
const CALM_START := 600.0 ## a new world's first 10 minutes are always clear
|
||||||
const COLD_DAY_CHANCE := 0.25
|
const COLD_DAY_CHANCE := 0.25
|
||||||
|
const HARD_WINTER_EVERY := 12 ## days 12-14, 24-26, ...
|
||||||
|
const HARD_WINTER_DAYS := 3
|
||||||
|
const HARD_WINTER_WARMTH := -8.0 ## on top of a cold day's
|
||||||
|
const BLIZZARD_CHILL := 18.0 ## more warmth lost out in a blizzard's wind (a hard winter's freezes you by day)
|
||||||
|
## In a hard winter the weather leans wet (more snow and blizzards).
|
||||||
|
const HARD_WINTER_FRONT := -0.18
|
||||||
|
|
||||||
var world_seed := 1337
|
var world_seed := 1337
|
||||||
var day_night: Node
|
var day_night: Node
|
||||||
@@ -76,9 +87,22 @@ func setup(seed: int, dn: Node) -> void:
|
|||||||
func world_seconds() -> float:
|
func world_seconds() -> float:
|
||||||
return (float(day_night.day - 1) + float(day_night.time)) * float(day_night.day_length)
|
return (float(day_night.day - 1) + float(day_night.time)) * float(day_night.day_length)
|
||||||
|
|
||||||
|
## Day `day` is one of a hard winter's three bitter days.
|
||||||
|
static func hard_winter(day: int) -> bool:
|
||||||
|
return day >= HARD_WINTER_EVERY and (day - HARD_WINTER_EVERY) % HARD_WINTER_EVERY < HARD_WINTER_DAYS
|
||||||
|
|
||||||
|
## The first day of the next hard winter on or after `day`.
|
||||||
|
static func next_hard_winter(day: int) -> int:
|
||||||
|
var d := maxi(day, HARD_WINTER_EVERY)
|
||||||
|
while not hard_winter(d):
|
||||||
|
d += 1
|
||||||
|
return d
|
||||||
|
|
||||||
func is_cold_day(day: int) -> bool:
|
func is_cold_day(day: int) -> bool:
|
||||||
if day <= 1:
|
if day <= 1:
|
||||||
return false
|
return false
|
||||||
|
if hard_winter(day):
|
||||||
|
return true
|
||||||
return float(absi(hash(Vector2i(world_seed, day))) % 10000) / 10000.0 < COLD_DAY_CHANCE
|
return float(absi(hash(Vector2i(world_seed, day))) % 10000) / 10000.0 < COLD_DAY_CHANCE
|
||||||
|
|
||||||
## The raw front value at an absolute time (higher = fairer).
|
## The raw front value at an absolute time (higher = fairer).
|
||||||
@@ -100,7 +124,7 @@ func _state_for(v: float, cold: bool) -> String:
|
|||||||
## The state at an absolute time, ignoring smoothing and overrides.
|
## The state at an absolute time, ignoring smoothing and overrides.
|
||||||
func state_at(t: float) -> String:
|
func state_at(t: float) -> String:
|
||||||
var day := int(floor(t / float(day_night.day_length))) + 1
|
var day := int(floor(t / float(day_night.day_length))) + 1
|
||||||
return _state_for(front_at(t), is_cold_day(day))
|
return _state_for(front_at(t) + (HARD_WINTER_FRONT if hard_winter(day) else 0.0), is_cold_day(day))
|
||||||
|
|
||||||
func force(s: String) -> void:
|
func force(s: String) -> void:
|
||||||
override = s
|
override = s
|
||||||
@@ -116,7 +140,7 @@ func snap() -> void:
|
|||||||
wind = tg.wind
|
wind = tg.wind
|
||||||
light = tg.light
|
light = tg.light
|
||||||
fog = tg.fog
|
fog = tg.fog
|
||||||
warmth = tg.warmth + (COLD_DAY_WARMTH if cold_day else 0.0)
|
warmth = tg.warmth + _cold_extra()
|
||||||
ground_wet = 1.0 if state in ["rain", "storm"] else 0.0
|
ground_wet = 1.0 if state in ["rain", "storm"] else 0.0
|
||||||
snow_cover = 0.7 if state in ["snow", "blizzard"] else (0.25 if cold_day else 0.0)
|
snow_cover = 0.7 if state in ["snow", "blizzard"] else (0.25 if cold_day else 0.0)
|
||||||
|
|
||||||
@@ -128,7 +152,7 @@ func _pick_state() -> void:
|
|||||||
if frozen and s.is_empty():
|
if frozen and s.is_empty():
|
||||||
s = "clear"
|
s = "clear"
|
||||||
if s.is_empty():
|
if s.is_empty():
|
||||||
s = _state_for(front_at(world_seconds()), cold_day)
|
s = _state_for(front_at(world_seconds()) + (HARD_WINTER_FRONT if hard_winter(day_night.day) else 0.0), cold_day)
|
||||||
if s != state:
|
if s != state:
|
||||||
state = s
|
state = s
|
||||||
state_changed.emit(state)
|
state_changed.emit(state)
|
||||||
@@ -145,7 +169,7 @@ func step(dt: float) -> void:
|
|||||||
wind = move_toward(wind, tg.wind, k)
|
wind = move_toward(wind, tg.wind, k)
|
||||||
light = move_toward(light, tg.light, k * 0.8)
|
light = move_toward(light, tg.light, k * 0.8)
|
||||||
fog = move_toward(fog, tg.fog, k * 3.0)
|
fog = move_toward(fog, tg.fog, k * 3.0)
|
||||||
warmth = move_toward(warmth, tg.warmth + (COLD_DAY_WARMTH if cold_day else 0.0), k * 20.0)
|
warmth = move_toward(warmth, tg.warmth + _cold_extra(), k * 20.0)
|
||||||
# The ground soaks in a couple of minutes of rain and dries over ten.
|
# The ground soaks in a couple of minutes of rain and dries over ten.
|
||||||
var raining := precip > 0.2 and not snowing_globally()
|
var raining := precip > 0.2 and not snowing_globally()
|
||||||
ground_wet = move_toward(ground_wet, 1.0 if raining else 0.0, dt / (120.0 if raining else 600.0))
|
ground_wet = move_toward(ground_wet, 1.0 if raining else 0.0, dt / (120.0 if raining else 600.0))
|
||||||
@@ -163,6 +187,22 @@ func step(dt: float) -> void:
|
|||||||
flash = 1.0
|
flash = 1.0
|
||||||
lightning_count += 1
|
lightning_count += 1
|
||||||
|
|
||||||
|
## Extra cold from the day: a cold day, and a hard winter on top.
|
||||||
|
func _cold_extra() -> float:
|
||||||
|
if not cold_day:
|
||||||
|
return 0.0
|
||||||
|
return COLD_DAY_WARMTH + (HARD_WINTER_WARMTH if day_night != null and hard_winter(day_night.day) else 0.0)
|
||||||
|
|
||||||
|
func in_hard_winter() -> bool:
|
||||||
|
return day_night != null and hard_winter(day_night.day)
|
||||||
|
|
||||||
|
## Extra warmth lost by someone out in a blizzard's wind (0 when sheltered or
|
||||||
|
## when it isn't blowing).
|
||||||
|
func blizzard_chill(sheltered: bool) -> float:
|
||||||
|
if sheltered or state != "blizzard":
|
||||||
|
return 0.0
|
||||||
|
return BLIZZARD_CHILL * clampf(precip, 0.0, 1.0)
|
||||||
|
|
||||||
func snowing_globally() -> bool:
|
func snowing_globally() -> bool:
|
||||||
return state in ["snow", "blizzard"]
|
return state in ["snow", "blizzard"]
|
||||||
|
|
||||||
@@ -188,4 +228,6 @@ func label() -> String:
|
|||||||
var s: String = names[state]
|
var s: String = names[state]
|
||||||
if cold_day and state in ["clear", "cloudy"]:
|
if cold_day and state in ["clear", "cloudy"]:
|
||||||
s += ", cold"
|
s += ", cold"
|
||||||
|
if in_hard_winter():
|
||||||
|
s += " (hard winter)"
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const PIECES := {
|
|||||||
"bed": preload("res://scripts/building/bed.gd"),
|
"bed": preload("res://scripts/building/bed.gd"),
|
||||||
"kiln": preload("res://scripts/building/processor.gd"),
|
"kiln": preload("res://scripts/building/processor.gd"),
|
||||||
"smelter": preload("res://scripts/building/processor.gd"),
|
"smelter": preload("res://scripts/building/processor.gd"),
|
||||||
|
"smoker": preload("res://scripts/building/processor.gd"),
|
||||||
"forge": preload("res://scripts/building/forge.gd"),
|
"forge": preload("res://scripts/building/forge.gd"),
|
||||||
"hearth": preload("res://scripts/building/hearth.gd"),
|
"hearth": preload("res://scripts/building/hearth.gd"),
|
||||||
"rug": preload("res://scripts/building/rug.gd"),
|
"rug": preload("res://scripts/building/rug.gd"),
|
||||||
@@ -65,6 +66,7 @@ var bobber: Node3D ## bobber.gd: the cast fishing float, if any
|
|||||||
var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site)
|
var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site)
|
||||||
var bears: Node3D ## bear_range.gd: the island's bear (null online as a client)
|
var bears: Node3D ## bear_range.gd: the island's bear (null online as a client)
|
||||||
var thieves: Node3D ## thieves.gd: crows and foxes after food left out
|
var thieves: Node3D ## thieves.gd: crows and foxes after food left out
|
||||||
|
var hazards: Node3D ## hazards.gd: lightning on trees, hard-winter ice (P4)
|
||||||
var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run)
|
var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run)
|
||||||
var _saved_bear: Dictionary = {} ## its saved state, kept until bears exists (and re-saved if it never does)
|
var _saved_bear: Dictionary = {} ## its saved state, kept until bears exists (and re-saved if it never does)
|
||||||
var net_sync: Node ## net_sync.gd when playing on a server, else null
|
var net_sync: Node ## net_sync.gd when playing on a server, else null
|
||||||
@@ -165,7 +167,7 @@ func air_warmth(pos: Vector3, sheltered: bool = false) -> float:
|
|||||||
if sheltered:
|
if sheltered:
|
||||||
w += ww * 0.25 + 8.0
|
w += ww * 0.25 + 8.0
|
||||||
else:
|
else:
|
||||||
w += ww
|
w += ww - weather.blizzard_chill(false) # (P4: the wind cuts through you)
|
||||||
return w
|
return w
|
||||||
|
|
||||||
## 0..1 heat from fires and a held torch.
|
## 0..1 heat from fires and a held torch.
|
||||||
@@ -326,6 +328,7 @@ func skip_time(secs: float) -> void:
|
|||||||
for f in get_tree().get_nodes_in_group("heat"):
|
for f in get_tree().get_nodes_in_group("heat"):
|
||||||
if f.has_method("burn"):
|
if f.has_method("burn"):
|
||||||
f.burn(secs)
|
f.burn(secs)
|
||||||
|
spoil_all() # (food goes off overnight too)
|
||||||
|
|
||||||
func _on_terrain_ready() -> void:
|
func _on_terrain_ready() -> void:
|
||||||
var t0 := Time.get_ticks_msec()
|
var t0 := Time.get_ticks_msec()
|
||||||
@@ -357,6 +360,12 @@ func _on_terrain_ready() -> void:
|
|||||||
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
|
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
|
||||||
if menu_mode:
|
if menu_mode:
|
||||||
return
|
return
|
||||||
|
# Weather hazards (P4): lightning on trees, the hard winter's ice. Decided
|
||||||
|
# here unless we're a player's game online (then the server tells us).
|
||||||
|
hazards = preload("res://scripts/world/hazards.gd").new()
|
||||||
|
hazards.name = "Hazards"
|
||||||
|
add_child(hazards)
|
||||||
|
hazards.setup(self, not is_online())
|
||||||
if server_mode:
|
if server_mode:
|
||||||
if Saves.has_world():
|
if Saves.has_world():
|
||||||
var saved: Dictionary = Saves.load_state(Saves.current_id)
|
var saved: Dictionary = Saves.load_state(Saves.current_id)
|
||||||
@@ -376,6 +385,7 @@ func _on_terrain_ready() -> void:
|
|||||||
_setup_bear()
|
_setup_bear()
|
||||||
apply_rules()
|
apply_rules()
|
||||||
return
|
return
|
||||||
|
_apply_world_rot()
|
||||||
_respawn()
|
_respawn()
|
||||||
if Saves.has_world():
|
if Saves.has_world():
|
||||||
var state: Dictionary = Saves.load_state(Saves.current_id)
|
var state: Dictionary = Saves.load_state(Saves.current_id)
|
||||||
@@ -430,6 +440,48 @@ func apply_rules() -> void:
|
|||||||
wolves.pack_bonus = int(R.difficulty("pack"))
|
wolves.pack_bonus = int(R.difficulty("pack"))
|
||||||
for p in PlayerNet.proxies.values():
|
for p in PlayerNet.proxies.values():
|
||||||
p.survival.drain_mult = R.difficulty("drain")
|
p.survival.drain_mult = R.difficulty("drain")
|
||||||
|
Inventory.rot_speed = ROT_SPEEDS.get(R.rot(), 1.0)
|
||||||
|
|
||||||
|
# --- food spoilage (P4, docs/PROGRESSION_DESIGN.md §5) ----------------------------
|
||||||
|
|
||||||
|
const ROT_SPEEDS := {"normal": 1.0, "slow": 0.5, "off": 0.0}
|
||||||
|
const SPOIL_EVERY := 2.0
|
||||||
|
var _spoil_left := 0.0
|
||||||
|
|
||||||
|
## Keeps Inventory's clock (food's "rot" stamps) on world time. Players'
|
||||||
|
## games online run it too (for showing freshness; the server does the rotting).
|
||||||
|
func _tick_clock() -> void:
|
||||||
|
Inventory.day_seconds = float(day_night.day_length)
|
||||||
|
Inventory.clock = weather.world_seconds()
|
||||||
|
|
||||||
|
## The single-player world's rot setting, from its meta.json (a server's
|
||||||
|
## comes from its rules: apply_rules()).
|
||||||
|
func _apply_world_rot() -> void:
|
||||||
|
var r := String(Saves.read_meta(Saves.current_id).get("rot", "normal")) if Saves.has_world() else "normal"
|
||||||
|
Inventory.rot_speed = ROT_SPEEDS.get(r, 1.0)
|
||||||
|
|
||||||
|
## Food rots: in everyone's bags, in chests, graves and racks, and lying on
|
||||||
|
## the ground (where rotten food just goes). Where the game decides (single
|
||||||
|
## player, a server); players' games online are sent the result.
|
||||||
|
func spoil_all() -> void:
|
||||||
|
if net_sync != null or menu_mode:
|
||||||
|
return
|
||||||
|
_tick_clock()
|
||||||
|
for p in players():
|
||||||
|
if p.get("inventory") != null:
|
||||||
|
p.inventory.spoil()
|
||||||
|
for piece in all_pieces():
|
||||||
|
if piece.get("inventory") != null:
|
||||||
|
piece.inventory.spoil()
|
||||||
|
if Inventory.rot_speed <= 0.0:
|
||||||
|
return
|
||||||
|
for d in all_dropped():
|
||||||
|
if Inventory.keeps_days(d.item_id) <= 0.0:
|
||||||
|
continue
|
||||||
|
if not d.extra.has("rot"):
|
||||||
|
d.extra.rot = Inventory.fresh_rot(d.item_id)
|
||||||
|
elif Inventory.clock >= float(d.extra.rot):
|
||||||
|
take_dropped(d)
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if menu_mode:
|
if menu_mode:
|
||||||
@@ -442,6 +494,11 @@ func _process(delta: float) -> void:
|
|||||||
var lake: Vector2 = terrain.lake_center
|
var lake: Vector2 = terrain.lake_center
|
||||||
_menu_cam.look_at(Vector3(lake.x * 0.5, 2.0, lake.y * 0.5), Vector3.UP)
|
_menu_cam.look_at(Vector3(lake.x * 0.5, 2.0, lake.y * 0.5), Vector3.UP)
|
||||||
return
|
return
|
||||||
|
_tick_clock()
|
||||||
|
_spoil_left -= delta
|
||||||
|
if _spoil_left <= 0.0:
|
||||||
|
_spoil_left = SPOIL_EVERY
|
||||||
|
spoil_all()
|
||||||
if not Saves.has_world() or get_tree().paused:
|
if not Saves.has_world() or get_tree().paused:
|
||||||
return
|
return
|
||||||
_session_time += delta
|
_session_time += delta
|
||||||
@@ -619,8 +676,8 @@ func add_dropped_entry(e: Dictionary) -> Node3D:
|
|||||||
if ItemDB.get_item(e.get("item", "")).is_empty():
|
if ItemDB.get_item(e.get("item", "")).is_empty():
|
||||||
return null
|
return null
|
||||||
var ex: Dictionary = {}
|
var ex: Dictionary = {}
|
||||||
if e.get("extra") is Dictionary and e.extra.has("dur"):
|
if e.get("extra") is Dictionary:
|
||||||
ex = {"dur": int(e.extra.dur)}
|
ex = Inventory.extra_of(e.extra) # (a worn tool's "dur", food's "rot")
|
||||||
var d := _make_dropped(e.item, int(e.get("count", 1)), _array_to_xf(e.xf), ex)
|
var d := _make_dropped(e.item, int(e.get("count", 1)), _array_to_xf(e.xf), ex)
|
||||||
if e.has("nid"):
|
if e.has("nid"):
|
||||||
d.set_meta("nid", int(e.nid))
|
d.set_meta("nid", int(e.nid))
|
||||||
@@ -781,6 +838,8 @@ func _update_grass_cuts() -> void:
|
|||||||
func water_at(x: float, z: float) -> Dictionary:
|
func water_at(x: float, z: float) -> Dictionary:
|
||||||
var ground: float = terrain.sample_height(x, z)
|
var ground: float = terrain.sample_height(x, z)
|
||||||
if terrain.inside_boundary(x, z, -2.0) and ground < terrain.water_level:
|
if terrain.inside_boundary(x, z, -2.0) and ground < terrain.water_level:
|
||||||
|
if hazards != null and hazards.iced(x, z):
|
||||||
|
return {} # (frozen over in a hard winter: cast further out)
|
||||||
return {"type": "lake", "surface": terrain.water_level, "depth": terrain.water_level - ground}
|
return {"type": "lake", "surface": terrain.water_level, "depth": terrain.water_level - ground}
|
||||||
if terrain.sea_weight(x, z) > 0.5 and ground < terrain.sea_level:
|
if terrain.sea_weight(x, z) > 0.5 and ground < terrain.sea_level:
|
||||||
return {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground}
|
return {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
|
|||||||
echo "Importing the project first (fresh copy)..."
|
echo "Importing the project first (fresh copy)..."
|
||||||
godot --headless --path . --import >/dev/null 2>&1
|
godot --headless --path . --import >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_den_sites test_fishing_diet test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do
|
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do
|
||||||
extra=""
|
extra=""
|
||||||
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
||||||
# A suite that waits on a world that failed to load would hang forever,
|
# A suite that waits on a world that failed to load would hang forever,
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ func _body(world: Node3D) -> void:
|
|||||||
A.eat(player, 0)
|
A.eat(player, 0)
|
||||||
check(sv.health >= 1.0 and sv.health <= 2.0, "raw meat can hurt, but never kills (health %.0f)" % sv.health)
|
check(sv.health >= 1.0 and sv.health <= 2.0, "raw meat can hurt, but never kills (health %.0f)" % sv.health)
|
||||||
sv.health = 60.0
|
sv.health = 60.0
|
||||||
|
sv.sick = 0.0 # (raw meat may have made you ill: tests/test_food.gd covers that)
|
||||||
_hold(player, "cooked_meat")
|
_hold(player, "cooked_meat")
|
||||||
A.eat(player, 0)
|
A.eat(player, 0)
|
||||||
check(sv.buffs.has("well_fed") and sv.buff_mult("stamina_regen") > 1.2, "cooked meat makes you Well fed")
|
check(sv.buffs.has("well_fed") and sv.buff_mult("stamina_regen") > 1.2, "cooked meat makes you Well fed")
|
||||||
|
|||||||
365
tests/test_food.gd
Normal file
365
tests/test_food.gd
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## P4 food (docs/PROGRESSION_DESIGN.md §5): food spoils on schedule wherever
|
||||||
|
## it is (bag, chest, the ground), stacks average their freshness, the
|
||||||
|
## smoking rack and salt preserve it, raw meat and fish can make you ill,
|
||||||
|
## and a world (or server) can make food rot slowly or not at all.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_food.gd
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var A: Node
|
||||||
|
var S: Node
|
||||||
|
var DB: Node
|
||||||
|
var _toasts: Array[String] = []
|
||||||
|
var world: Node3D
|
||||||
|
var player: CharacterBody3D
|
||||||
|
var inv: Inventory
|
||||||
|
const DAY := 1200.0
|
||||||
|
|
||||||
|
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 _run() -> void:
|
||||||
|
A = root.get_node("Actions")
|
||||||
|
S = root.get_node("Saves")
|
||||||
|
DB = root.get_node("ItemDB")
|
||||||
|
A.notify.connect(func(t): _toasts.append(t))
|
||||||
|
S.root_dir = "user://test_worlds"
|
||||||
|
for w in S.list_worlds():
|
||||||
|
S.delete_world(w.id)
|
||||||
|
S.current_id = S.create_world("Food", 1337)
|
||||||
|
await _load()
|
||||||
|
await _body()
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
for w in S.list_worlds():
|
||||||
|
S.delete_world(w.id)
|
||||||
|
DirAccess.remove_absolute(S.root_dir)
|
||||||
|
Inventory.rot_speed = 1.0
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
func _load() -> void:
|
||||||
|
world = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(world)
|
||||||
|
for _i in range(5):
|
||||||
|
await physics_frame
|
||||||
|
world.creatures.enabled = false
|
||||||
|
for c in world.creatures.alive():
|
||||||
|
c.queue_free()
|
||||||
|
if world.wolves != null:
|
||||||
|
world.wolves.enabled = false
|
||||||
|
world.bears.enabled = false
|
||||||
|
if world.bears.bear != null:
|
||||||
|
world.bears.bear.queue_free()
|
||||||
|
world.thieves.enabled = false
|
||||||
|
player = world.player
|
||||||
|
player.set_physics_process(false)
|
||||||
|
player.survival.set_process(false)
|
||||||
|
world.day_night.frozen = true
|
||||||
|
world.weather.set_process(false)
|
||||||
|
inv = player.inventory
|
||||||
|
|
||||||
|
func _reload() -> void:
|
||||||
|
check(world.save_now(), "saved")
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
await _load()
|
||||||
|
|
||||||
|
func _clear() -> void:
|
||||||
|
inv.load_data(Inventory.new(40).to_data())
|
||||||
|
|
||||||
|
func _slot_of(id: String) -> int:
|
||||||
|
for i in range(inv.size()):
|
||||||
|
var s = inv.get_slot(i)
|
||||||
|
if s != null and s.id == id:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
func _place(id: String, side: float) -> Node3D:
|
||||||
|
var at: Vector3 = player.global_position + player.global_basis.x * side - player.global_basis.z * 3.0
|
||||||
|
at.y = world.terrain.sample_height(at.x, at.z)
|
||||||
|
return world.spawn_piece(DB.get_item(id), Transform3D(Basis(), at))
|
||||||
|
|
||||||
|
## Moves the world on `secs` (like sleeping): food rots in the meantime.
|
||||||
|
func _pass(secs: float) -> void:
|
||||||
|
world.skip_time(secs)
|
||||||
|
|
||||||
|
func _body() -> void:
|
||||||
|
_data()
|
||||||
|
_stamps()
|
||||||
|
await _rotting()
|
||||||
|
await _in_chests_and_on_the_ground()
|
||||||
|
await _keeping()
|
||||||
|
await _poisoning()
|
||||||
|
await _settings()
|
||||||
|
await _saving()
|
||||||
|
_finished = true
|
||||||
|
|
||||||
|
# --- what keeps how long --------------------------------------------------------------
|
||||||
|
|
||||||
|
func _data() -> void:
|
||||||
|
print("how long food keeps")
|
||||||
|
var want := {"raw_meat": 2, "perch": 2, "trout": 2, "berries": 3, "mushroom": 4, "cooked_meat": 4,
|
||||||
|
"grilled_trout": 4, "roasted_mushroom": 6, "smoked_meat": 12, "smoked_fish": 12, "salted_meat": 20, "salted_fish": 20}
|
||||||
|
var wrong := []
|
||||||
|
for id in want:
|
||||||
|
if absf(Inventory.keeps_days(id) - float(want[id])) > 0.01:
|
||||||
|
wrong.append("%s=%s" % [id, Inventory.keeps_days(id)])
|
||||||
|
check(wrong.is_empty(), "raw meat and fish 2 days, berries 3, cooked 4, smoked 12, salted 20 (wrong: %s)" % [wrong])
|
||||||
|
check(Inventory.keeps_days("wood") == 0.0 and Inventory.keeps_days("rotten_food") == 0.0, "wood (and rotten food) never spoil")
|
||||||
|
check(DB.get_item("rotten_food").get("food") == null, "rotten food isn't food")
|
||||||
|
for m in ["rotten_food", "salt", "smoking_rack", "smoked_meat", "smoked_fish", "salted_meat", "salted_fish"]:
|
||||||
|
check(DB.mesh_for(m).get_aabb().size.length() > 0.05 and DB.mesh_for(m).get_aabb().size.length() != DB.mesh_for("nonexistent_x").get_aabb().size.length(), "%s has its own mesh" % m)
|
||||||
|
|
||||||
|
func _stamps() -> void:
|
||||||
|
print("freshness")
|
||||||
|
_clear()
|
||||||
|
world._tick_clock()
|
||||||
|
var t0: float = Inventory.clock
|
||||||
|
inv.add("raw_meat", 4)
|
||||||
|
var s = inv.get_slot(_slot_of("raw_meat"))
|
||||||
|
check(s.has("rot") and absf(float(s.rot) - (t0 + 2.0 * DAY)) < 1.5, "raw meat put in your bag rots in 2 days (stamp %.0f, now %.0f)" % [float(s.get("rot", 0)), t0])
|
||||||
|
check(Inventory.freshness(s) > 0.99 and absf(Inventory.hours_left(s) - 48.0) < 0.1, "fresh: 48 hours left")
|
||||||
|
check(not inv.get_slot(_slot_of("raw_meat")).has("dur"), "(and no wear)")
|
||||||
|
inv.add("wood", 5)
|
||||||
|
check(not inv.get_slot(_slot_of("wood")).has("rot"), "wood gets no stamp")
|
||||||
|
# A day later, 4 more: the stack is halfway between.
|
||||||
|
Inventory.clock = t0 + DAY
|
||||||
|
inv.add("raw_meat", 4, -1.0)
|
||||||
|
s = inv.get_slot(_slot_of("raw_meat"))
|
||||||
|
check(int(s.count) == 8 and absf(float(s.rot) - (t0 + 2.5 * DAY)) < 1.5, "4 fresh added a day later: 8 that rot in 1.5 days (the average)")
|
||||||
|
var Slot: Script = load("res://scripts/ui/slot.gd")
|
||||||
|
check(Slot.freshness_text(s).begins_with("Going off") or Slot.freshness_text(s).begins_with("Fresh"), "the tooltip says so: \"%s\"" % Slot.freshness_text(s))
|
||||||
|
print("splitting and moving")
|
||||||
|
var cursor = inv.click(_slot_of("raw_meat"), null, true) # (right-click: pick up half)
|
||||||
|
check(cursor != null and int(cursor.count) == 4 and float(cursor.rot) == float(s.rot), "half a stack keeps its freshness")
|
||||||
|
var empty := inv.slots.find(null)
|
||||||
|
cursor = inv.click(empty, cursor, true) # (right-click: put one down)
|
||||||
|
check(inv.get_slot(empty).has("rot") and float(inv.get_slot(empty).rot) == float(s.rot), "so does one put down on its own")
|
||||||
|
var fresh := {"id": "raw_meat", "count": 3, "rot": Inventory.clock + 2.0 * DAY}
|
||||||
|
var other := inv.slots.find(null)
|
||||||
|
inv.set_slot(other, fresh)
|
||||||
|
cursor = inv.click(other, cursor, false) # (merge the 3 left on the cursor into the fresh 3)
|
||||||
|
check(cursor == null and int(inv.get_slot(other).count) == 6 and absf(float(inv.get_slot(other).rot) - (Inventory.clock + 1.75 * DAY)) < 1.5,
|
||||||
|
"dropping older food onto fresher food averages them")
|
||||||
|
var box := Inventory.new(8)
|
||||||
|
inv.quick_move(other, box)
|
||||||
|
check(box.get_slot(0) != null and absf(float(box.get_slot(0).rot) - (Inventory.clock + 1.75 * DAY)) < 1.5, "shift-click into a chest: same freshness")
|
||||||
|
Inventory.clock = t0
|
||||||
|
_clear()
|
||||||
|
|
||||||
|
func _rotting() -> void:
|
||||||
|
print("rotting")
|
||||||
|
_clear()
|
||||||
|
world._tick_clock()
|
||||||
|
inv.add("raw_meat", 3)
|
||||||
|
inv.add("cooked_meat", 2)
|
||||||
|
inv.add("berries", 5)
|
||||||
|
inv.add("wood", 4)
|
||||||
|
_pass(DAY * 1.9)
|
||||||
|
check(inv.count("raw_meat") == 3, "a little under 2 days: the raw meat is still good")
|
||||||
|
check(Inventory.freshness(inv.get_slot(_slot_of("raw_meat"))) < 0.1, "but nearly rotten")
|
||||||
|
_pass(DAY * 0.2)
|
||||||
|
check(inv.count("raw_meat") == 0 and inv.count("rotten_food") == 3, "past 2 days: 3 rotten food")
|
||||||
|
check(inv.count("cooked_meat") == 2 and inv.count("berries") == 5 and inv.count("wood") == 4, "cooked meat and berries keep longer (and wood forever)")
|
||||||
|
_pass(DAY * 1.1)
|
||||||
|
check(inv.count("berries") == 0 and inv.count("cooked_meat") == 2, "berries go after 3 days")
|
||||||
|
_pass(DAY * 1.0)
|
||||||
|
check(inv.count("cooked_meat") == 0 and inv.count("rotten_food") == 10, "cooked meat after 4 (10 rotten food now)")
|
||||||
|
_clear()
|
||||||
|
|
||||||
|
func _in_chests_and_on_the_ground() -> void:
|
||||||
|
print("in a chest")
|
||||||
|
world._tick_clock()
|
||||||
|
var chest := _place("chest", 3.0)
|
||||||
|
chest.inventory.add("trout", 2)
|
||||||
|
_pass(DAY * 2.1)
|
||||||
|
check(chest.inventory.count("trout") == 0 and chest.inventory.count("rotten_food") == 2, "food rots in a chest too")
|
||||||
|
world.remove_piece(chest)
|
||||||
|
print("on the ground")
|
||||||
|
_clear()
|
||||||
|
world._tick_clock()
|
||||||
|
inv.add("cooked_meat", 2)
|
||||||
|
var st: Dictionary = inv.get_slot(_slot_of("cooked_meat"))
|
||||||
|
var stamp := float(st.rot)
|
||||||
|
_pass(DAY * 1.0)
|
||||||
|
var at: Vector3 = player.global_position - player.global_basis.z * 1.2 + Vector3(0, 0.2, 0)
|
||||||
|
var dropped: Node3D = world.drop_item("cooked_meat", 2, Transform3D(Basis(), at), Inventory.extra_of(st))
|
||||||
|
_clear()
|
||||||
|
_pass(DAY * 1.0)
|
||||||
|
check(not dropped.taken and float(dropped.extra.rot) == stamp, "dropped, it keeps its stamp (a day in the bag, a day on the ground)")
|
||||||
|
check(A.pickup(player, dropped), "picked up again")
|
||||||
|
check(float(inv.get_slot(_slot_of("cooked_meat")).rot) == stamp, "and it's just as old as it was")
|
||||||
|
var st2: Dictionary = inv.get_slot(_slot_of("cooked_meat"))
|
||||||
|
var d2: Node3D = world.drop_item("cooked_meat", 2, Transform3D(Basis(), at), Inventory.extra_of(st2))
|
||||||
|
_clear()
|
||||||
|
_pass(DAY * 2.1)
|
||||||
|
check(d2.taken or not is_instance_valid(d2), "left on the ground past its time, it rots away")
|
||||||
|
|
||||||
|
func _keeping() -> void:
|
||||||
|
print("the smoking rack")
|
||||||
|
_clear()
|
||||||
|
world._tick_clock()
|
||||||
|
inv.add("stick", 6)
|
||||||
|
inv.add("wood", 6)
|
||||||
|
inv.add("fiber", 4)
|
||||||
|
check(A.craft(player, "smoking_rack"), "made by hand (6 sticks, 4 wood, 4 fibre)")
|
||||||
|
var rack := _place("smoking_rack", -3.0)
|
||||||
|
check(rack != null and rack.get("inventory") != null, "placed, it opens like a chest")
|
||||||
|
rack.inventory.add("raw_meat", 4)
|
||||||
|
check(not rack.working() and rack.interact_prompt(player).contains("wood"), "no wood, no smoke: \"%s\"" % rack.interact_prompt(player))
|
||||||
|
rack.inventory.add("wood", 2)
|
||||||
|
check(rack.working(), "with wood it gets to work")
|
||||||
|
rack.step(59.0)
|
||||||
|
check(rack.inventory.count("smoked_meat") == 0, "not yet at 59 s")
|
||||||
|
rack.step(62.0)
|
||||||
|
check(rack.inventory.count("smoked_meat") == 4 and rack.inventory.count("raw_meat") == 0 and rack.inventory.count("wood") == 0,
|
||||||
|
"2 meat and a wood a minute: 4 smoked meat")
|
||||||
|
rack.inventory.add("perch", 2)
|
||||||
|
rack.inventory.add("trout", 1)
|
||||||
|
rack.inventory.add("wood", 2)
|
||||||
|
rack.step(121.0)
|
||||||
|
check(rack.inventory.count("smoked_fish") == 4, "and fish (2 perch, or 1 trout, make 2 smoked fish)")
|
||||||
|
_pass(DAY * 5.0)
|
||||||
|
check(rack.inventory.count("smoked_meat") == 4 and Inventory.freshness(rack.inventory.get_slot(0)) > 0.5, "smoked meat keeps on the rack: still good after 5 days")
|
||||||
|
_pass(DAY * 7.5)
|
||||||
|
check(rack.inventory.count("smoked_meat") == 0, "(but not forever: 12 days)")
|
||||||
|
world.remove_piece(rack)
|
||||||
|
print("salt")
|
||||||
|
var salt: Array = world.props.pickups.values().filter(func(p): return p.item_id == "salt")
|
||||||
|
check(salt.size() >= 20, "%d salt crusts on the island" % salt.size())
|
||||||
|
var by_sea := salt.filter(func(p): return world.terrain.sea_weight(p.global_position.x, p.global_position.z) > 0.5).size()
|
||||||
|
check(by_sea == salt.size(), "all of them up above the sea cliffs")
|
||||||
|
check(world.props.pickups.has("p0") and world.props.pickups["p0"].item_id == "stick", "(older pickups keep their ids)")
|
||||||
|
_clear()
|
||||||
|
world._tick_clock()
|
||||||
|
inv.add("raw_meat", 2)
|
||||||
|
inv.add("salt", 1)
|
||||||
|
inv.add("perch", 2)
|
||||||
|
inv.add("trout", 1)
|
||||||
|
inv.add("salt", 2)
|
||||||
|
check(A.craft(player, "salted_meat") and inv.count("salted_meat") == 2, "2 raw meat and a salt: 2 salted meat")
|
||||||
|
check(A.craft(player, "salted_fish") and A.craft(player, "salted_trout") and inv.count("salted_fish") == 4, "and salted fish (perch or trout)")
|
||||||
|
_pass(DAY * 15.0)
|
||||||
|
check(inv.count("salted_meat") == 2 and inv.count("salted_fish") == 4, "15 days on, still good")
|
||||||
|
_clear()
|
||||||
|
|
||||||
|
func _poisoning() -> void:
|
||||||
|
print("food poisoning")
|
||||||
|
var sv: Node = player.survival
|
||||||
|
A.food_rng.seed = 12345
|
||||||
|
_clear()
|
||||||
|
world._tick_clock()
|
||||||
|
inv.add("cooked_meat", 20)
|
||||||
|
var bad := 0
|
||||||
|
for _i in range(20):
|
||||||
|
sv.hunger = 10.0
|
||||||
|
sv.sick = 0.0
|
||||||
|
A.eat(player, _slot_of("cooked_meat"))
|
||||||
|
if sv.sick > 0.0:
|
||||||
|
bad += 1
|
||||||
|
check(bad == 0, "20 cooked meals: never ill")
|
||||||
|
inv.add("raw_meat", 20)
|
||||||
|
var tries := 0
|
||||||
|
sv.sick = 0.0
|
||||||
|
while sv.sick <= 0.0 and tries < 20:
|
||||||
|
sv.hunger = 10.0
|
||||||
|
A.eat(player, _slot_of("raw_meat"))
|
||||||
|
tries += 1
|
||||||
|
check(sv.sick > 0.0, "raw meat: ill after %d" % tries)
|
||||||
|
sv._update_statuses()
|
||||||
|
check("Food poisoning" in sv.statuses, "the HUD says Food poisoning (%s)" % [sv.statuses])
|
||||||
|
var n := inv.count("raw_meat")
|
||||||
|
_toasts.clear()
|
||||||
|
sv.hunger = 10.0
|
||||||
|
check(not A.eat(player, _slot_of("raw_meat")) and inv.count("raw_meat") == n, "you can't eat while you're ill")
|
||||||
|
check(_toasts.any(func(t): return t.contains("too sick")), "\"%s\"" % [_toasts])
|
||||||
|
sv.stamina = 0.0
|
||||||
|
sv._stamina_wait = 0.0
|
||||||
|
sv.step(1.0)
|
||||||
|
var ill_gain: float = sv.stamina
|
||||||
|
sv.sick = 0.0
|
||||||
|
sv.stamina = 0.0
|
||||||
|
sv._stamina_wait = 0.0
|
||||||
|
sv.step(1.0)
|
||||||
|
check(ill_gain > 0.0 and absf(ill_gain * 2.0 - sv.stamina) < 0.05, "and stamina comes back at half speed (%.2f vs %.2f a second)" % [ill_gain, sv.stamina])
|
||||||
|
sv.poison()
|
||||||
|
for _i in range(61):
|
||||||
|
sv.step(1.0)
|
||||||
|
check(sv.sick == 0.0 and A.eat(player, _slot_of("raw_meat")) or sv.hunger >= A.FULL_HUNGER, "a minute later you can eat again")
|
||||||
|
var risky := 0
|
||||||
|
var tried := 0
|
||||||
|
A.food_rng.seed = 1
|
||||||
|
for _i in range(200):
|
||||||
|
if A.food_rng.randf() < 0.35:
|
||||||
|
risky += 1
|
||||||
|
tried += 1
|
||||||
|
check(risky > 50 and risky < 90, "(about 1 raw meat in 3 makes you ill: %d of %d)" % [risky, tried])
|
||||||
|
for id in ["perch", "trout"]:
|
||||||
|
check(float(DB.get_item(id).food.get("risk", 0.0)) > 0.0, "raw %s is risky too" % id)
|
||||||
|
for id in ["smoked_meat", "salted_meat", "smoked_fish", "salted_fish", "berries"]:
|
||||||
|
check(float(DB.get_item(id).food.get("risk", 0.0)) == 0.0, "%s is safe" % id)
|
||||||
|
sv.sick = 0.0
|
||||||
|
_clear()
|
||||||
|
|
||||||
|
func _settings() -> void:
|
||||||
|
print("rot settings")
|
||||||
|
check(Inventory.rot_speed == 1.0, "a new world rots normally")
|
||||||
|
var meta: Dictionary = S.read_meta(S.current_id)
|
||||||
|
meta["rot"] = "slow"
|
||||||
|
S._write_json(S._dir(S.current_id).path_join("meta.json"), meta)
|
||||||
|
world._apply_world_rot()
|
||||||
|
check(Inventory.rot_speed == 0.5, "slow: half speed")
|
||||||
|
_clear()
|
||||||
|
world._tick_clock()
|
||||||
|
inv.add("raw_meat", 2)
|
||||||
|
check(absf(Inventory.hours_left(inv.get_slot(0)) - 96.0) < 0.1, "raw meat now lasts 4 days")
|
||||||
|
_pass(DAY * 3.0)
|
||||||
|
check(inv.count("raw_meat") == 2, "and is still good after 3")
|
||||||
|
meta["rot"] = "off"
|
||||||
|
S._write_json(S._dir(S.current_id).path_join("meta.json"), meta)
|
||||||
|
world._apply_world_rot()
|
||||||
|
_pass(DAY * 10.0)
|
||||||
|
check(inv.count("raw_meat") == 2 and Inventory.rot_speed == 0.0, "off: nothing rots, ever")
|
||||||
|
inv.add("berries", 3)
|
||||||
|
check(not inv.get_slot(_slot_of("berries")).has("rot"), "(and new food isn't even stamped)")
|
||||||
|
meta["rot"] = "normal"
|
||||||
|
S._write_json(S._dir(S.current_id).path_join("meta.json"), meta)
|
||||||
|
world._apply_world_rot()
|
||||||
|
var id: String = S.create_world("Slow Island", 99, {"rot": "slow"})
|
||||||
|
check(String(S.read_meta(id).get("rot", "")) == "slow", "a new world remembers its choice")
|
||||||
|
S.delete_world(id)
|
||||||
|
var cfg: RefCounted = load("res://scripts/net/server_config.gd").new()
|
||||||
|
cfg.data = cfg.DEFAULTS.duplicate(true)
|
||||||
|
check(String(cfg.data.rot) == "normal", "a server rots normally unless told")
|
||||||
|
cfg.path = "user://test_worlds/_cfg.json"
|
||||||
|
check(cfg.set_rule("rot", "slow") == "" and String(cfg.v("rot")) == "slow", "an admin can /set rot slow")
|
||||||
|
check(cfg.set_rule("rot", "fast") != "", "(but not to nonsense)")
|
||||||
|
check("rot" in cfg.PUBLIC, "and players are told the server's setting")
|
||||||
|
DirAccess.remove_absolute("user://test_worlds/_cfg.json")
|
||||||
|
_clear()
|
||||||
|
|
||||||
|
func _saving() -> void:
|
||||||
|
print("saving")
|
||||||
|
world._tick_clock()
|
||||||
|
_clear()
|
||||||
|
inv.add("raw_meat", 3)
|
||||||
|
var stamp := float(inv.get_slot(0).rot)
|
||||||
|
var chest := _place("chest", 3.0)
|
||||||
|
chest.inventory.add("berries", 4)
|
||||||
|
var cstamp := float(chest.inventory.get_slot(0).rot)
|
||||||
|
var at: Vector3 = chest.global_position
|
||||||
|
await _reload()
|
||||||
|
check(inv.count("raw_meat") == 3 and float(inv.get_slot(_slot_of("raw_meat")).rot) == stamp, "your food is as fresh after a reload as before")
|
||||||
|
var back: Node3D = null
|
||||||
|
for p in world.all_pieces():
|
||||||
|
if p.item_id == "chest" and p.global_position.distance_to(at) < 0.1:
|
||||||
|
back = p
|
||||||
|
check(back != null and float(back.inventory.get_slot(0).rot) == cstamp, "and the chest's")
|
||||||
|
_pass(DAY * 2.1)
|
||||||
|
check(inv.count("rotten_food") == 3, "and rots on the same schedule")
|
||||||
289
tests/test_hazards.gd
Normal file
289
tests/test_hazards.gd
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## P4 weather hazards (docs/PROGRESSION_DESIGN.md §5): hard winters every 12
|
||||||
|
## days (warnings, bitter cold, fewer animals, the lake's edge freezing),
|
||||||
|
## blizzards that freeze you outdoors but not in a hearth room, and
|
||||||
|
## lightning that sets trees (never buildings) on fire.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_hazards.gd
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var A: Node
|
||||||
|
var S: Node
|
||||||
|
var DB: Node
|
||||||
|
var _toasts: Array[String] = []
|
||||||
|
var world: Node3D
|
||||||
|
var player: CharacterBody3D
|
||||||
|
var W: Script
|
||||||
|
|
||||||
|
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 _run() -> void:
|
||||||
|
A = root.get_node("Actions")
|
||||||
|
S = root.get_node("Saves")
|
||||||
|
DB = root.get_node("ItemDB")
|
||||||
|
W = load("res://scripts/world/weather.gd")
|
||||||
|
A.notify.connect(func(t): _toasts.append(t))
|
||||||
|
S.root_dir = "user://test_worlds"
|
||||||
|
for w in S.list_worlds():
|
||||||
|
S.delete_world(w.id)
|
||||||
|
S.current_id = S.create_world("Hazards", 1337)
|
||||||
|
world = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(world)
|
||||||
|
for _i in range(5):
|
||||||
|
await physics_frame
|
||||||
|
world.creatures.enabled = false
|
||||||
|
for c in world.creatures.alive():
|
||||||
|
c.queue_free()
|
||||||
|
if world.wolves != null:
|
||||||
|
world.wolves.enabled = false
|
||||||
|
world.bears.enabled = false
|
||||||
|
world.thieves.enabled = false
|
||||||
|
player = world.player
|
||||||
|
player.set_physics_process(false)
|
||||||
|
player.survival.set_process(false)
|
||||||
|
world.day_night.frozen = true
|
||||||
|
world.weather.set_process(false)
|
||||||
|
world.hazards.set_physics_process(false)
|
||||||
|
await _body()
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
for w in S.list_worlds():
|
||||||
|
S.delete_world(w.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 _set_time(day: int, hours: float) -> void:
|
||||||
|
world.day_night.day = day
|
||||||
|
world.day_night.time = hours / 24.0
|
||||||
|
world.day_night.apply(true)
|
||||||
|
|
||||||
|
func _toast_has(s: String) -> bool:
|
||||||
|
for t in _toasts:
|
||||||
|
if t.contains(s):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _body() -> void:
|
||||||
|
_calendar()
|
||||||
|
await _blizzard()
|
||||||
|
_animals()
|
||||||
|
await _ice()
|
||||||
|
await _lightning()
|
||||||
|
_finished = true
|
||||||
|
|
||||||
|
# --- hard winters --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _calendar() -> void:
|
||||||
|
print("hard winters")
|
||||||
|
var hw := []
|
||||||
|
for d in range(1, 40):
|
||||||
|
if W.hard_winter(d):
|
||||||
|
hw.append(d)
|
||||||
|
check(hw == [12, 13, 14, 24, 25, 26, 36, 37, 38], "every 12 days, three in a row: %s" % [hw])
|
||||||
|
check(W.next_hard_winter(3) == 12 and W.next_hard_winter(15) == 24 and W.next_hard_winter(25) == 25, "(the next one from any day)")
|
||||||
|
var wx: Node = world.weather
|
||||||
|
check(wx.is_cold_day(12) and wx.is_cold_day(13) and wx.is_cold_day(14), "they're all cold days")
|
||||||
|
_set_time(13, 12.0)
|
||||||
|
wx.force("clear")
|
||||||
|
var bitter: float = wx.warmth
|
||||||
|
var cold := -1
|
||||||
|
for d in range(2, 12):
|
||||||
|
if wx.is_cold_day(d):
|
||||||
|
cold = d
|
||||||
|
check(cold > 0, "(an ordinary cold day for comparison: day %d)" % cold)
|
||||||
|
_set_time(cold, 12.0)
|
||||||
|
wx.force("clear")
|
||||||
|
check(bitter < wx.warmth - 5.0, "colder than an ordinary cold day (%.0f against %.0f)" % [bitter, wx.warmth])
|
||||||
|
_set_time(13, 12.0)
|
||||||
|
wx.force("clear")
|
||||||
|
check(wx.label().contains("hard winter"), "the HUD says so: \"%s\"" % wx.label())
|
||||||
|
var wet := 0
|
||||||
|
var wet_hw := 0
|
||||||
|
for i in range(600):
|
||||||
|
var t := (11.0 + i / 600.0 * 3.0) * 1200.0
|
||||||
|
var t2 := (2.0 + i / 600.0 * 3.0) * 1200.0
|
||||||
|
if wx.state_at(t) in ["snow", "blizzard"]:
|
||||||
|
wet_hw += 1
|
||||||
|
if wx.state_at(t2) in ["rain", "storm", "snow", "blizzard"]:
|
||||||
|
wet += 1
|
||||||
|
check(wet_hw > wet, "more snow in a hard winter (%d%% of the time, against %d%% wet weather on days 3-5)" % [wet_hw / 6, wet / 6])
|
||||||
|
print("warnings")
|
||||||
|
_toasts.clear()
|
||||||
|
world.hazards._on_new_day(11)
|
||||||
|
check(_toast_has("hard winter is coming"), "the day before: %s" % [_toasts])
|
||||||
|
_toasts.clear()
|
||||||
|
world.hazards._on_new_day(12)
|
||||||
|
check(_toast_has("has set in"), "on the day: %s" % [_toasts])
|
||||||
|
_toasts.clear()
|
||||||
|
world.hazards._on_new_day(15)
|
||||||
|
check(_toast_has("is over"), "and when it's over")
|
||||||
|
_toasts.clear()
|
||||||
|
world.hazards._on_new_day(8)
|
||||||
|
check(_toasts.is_empty(), "(an ordinary day says nothing)")
|
||||||
|
|
||||||
|
# --- blizzard ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _blizzard() -> void:
|
||||||
|
print("a blizzard")
|
||||||
|
var bs: Node = world.build
|
||||||
|
var sv: Node = player.survival
|
||||||
|
_set_time(13, 15.0)
|
||||||
|
var wx: Node = world.weather
|
||||||
|
wx.force("blizzard")
|
||||||
|
check(wx.fog >= 6.0 and wx.light < 0.5, "you can barely see (fog x%.1f)" % wx.fog)
|
||||||
|
var site: Vector3 = bs.debug_find_site(player.global_position - player.global_basis.z * 16.0, 3.0)
|
||||||
|
var yaw: float = player.rotation.y
|
||||||
|
var hut: Dictionary = await bs.debug_build_hut(site, yaw, "stone")
|
||||||
|
for _i in range(3):
|
||||||
|
await physics_frame
|
||||||
|
check(hut.placed == 20, "a stone hut (%d pieces)" % hut.placed)
|
||||||
|
var b := Basis(Vector3.UP, yaw)
|
||||||
|
var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1)
|
||||||
|
var hearth: Node3D = world.spawn_piece(DB.get_item("stone_hearth"), Transform3D(b, center + b * Vector3(0, 0, -1.25)))
|
||||||
|
hearth.fuel = hearth.MAX_FUEL
|
||||||
|
var inside: Vector3 = center + b * Vector3(0.6, 0.05, 0.8)
|
||||||
|
var outside: Vector3 = site + b * Vector3(9, 0, 9)
|
||||||
|
outside.y = world.terrain.sample_height(outside.x, outside.z)
|
||||||
|
player.inventory.load_data(Inventory.new(40).to_data())
|
||||||
|
# Outside: no fire, no coat.
|
||||||
|
player.global_position = outside
|
||||||
|
sv.warmth = 70.0
|
||||||
|
sv.wet = 0.0
|
||||||
|
sv._shelter_left = 0.0
|
||||||
|
for _i in range(15):
|
||||||
|
sv.step(1.0)
|
||||||
|
var lost: float = 70.0 - sv.warmth
|
||||||
|
check(lost > 45.0, "outside, a blizzard strips your warmth: %.0f lost in 15 s" % lost)
|
||||||
|
for _i in range(30):
|
||||||
|
sv.step(1.0)
|
||||||
|
check(sv.warmth < 12.0 and "Freezing" in sv.statuses, "soon you're Freezing (%.0f)" % sv.warmth)
|
||||||
|
player.worn = "hide_cloak"
|
||||||
|
for _i in range(40):
|
||||||
|
sv.step(1.0)
|
||||||
|
check(sv.warmth >= 12.0 and not "Freezing" in sv.statuses, "a hide cloak keeps you out of Freezing (%.0f)" % sv.warmth)
|
||||||
|
player.worn = ""
|
||||||
|
var air_out: float = world.air_warmth(outside, false)
|
||||||
|
wx.force("snow")
|
||||||
|
var air_snow: float = world.air_warmth(outside, false)
|
||||||
|
wx.force("blizzard")
|
||||||
|
check(air_out < air_snow - 10.0, "the wind makes it far colder than plain snow (%.0f against %.0f)" % [air_out, air_snow])
|
||||||
|
# Inside the stone hut by the hearth.
|
||||||
|
player.global_position = inside
|
||||||
|
sv.warmth = 20.0
|
||||||
|
sv._shelter_left = 0.0
|
||||||
|
for _i in range(30):
|
||||||
|
sv.step(1.0)
|
||||||
|
check(sv.sheltered and "Warm" in sv.statuses and sv.warmth > 60.0,
|
||||||
|
"in the hearth room it's Sheltered and Warm: %.0f (%s)" % [sv.warmth, sv.statuses])
|
||||||
|
hearth._set_lit(false)
|
||||||
|
sv._shelter_left = 0.0
|
||||||
|
sv.step(0.5)
|
||||||
|
check(world.air_warmth(inside, true) > air_out + 30.0, "even with the fire out, walls keep the wind off (%.0f inside, %.0f out)" % [world.air_warmth(inside, true), air_out])
|
||||||
|
sv.health = 100.0
|
||||||
|
wx.force("")
|
||||||
|
for p in hut.pieces:
|
||||||
|
if is_instance_valid(p):
|
||||||
|
p.queue_free()
|
||||||
|
hearth.queue_free()
|
||||||
|
for _i in range(3):
|
||||||
|
await physics_frame
|
||||||
|
|
||||||
|
func _animals() -> void:
|
||||||
|
print("animals")
|
||||||
|
var sp: Node = world.creatures
|
||||||
|
_set_time(5, 12.0)
|
||||||
|
var normal: int = sp.winter_budget("deer", 1)
|
||||||
|
_set_time(13, 12.0)
|
||||||
|
var winter: int = sp.winter_budget("deer", 1)
|
||||||
|
check(winter * 2 == normal, "half as many deer in a hard winter (%d, not %d)" % [winter, normal])
|
||||||
|
check(sp.winter_budget("boar", 1) >= 1, "(but never none)")
|
||||||
|
|
||||||
|
func _ice() -> void:
|
||||||
|
print("the lake freezes")
|
||||||
|
var hz: Node = world.hazards
|
||||||
|
var t: Node3D = world.terrain
|
||||||
|
var c: Vector2 = t.lake_center
|
||||||
|
_set_time(5, 12.0)
|
||||||
|
hz.step(0.1)
|
||||||
|
check(not hz.lake_frozen(), "not on an ordinary day")
|
||||||
|
# Somewhere in the lake near its edge, and its middle.
|
||||||
|
var edge := Vector3.INF
|
||||||
|
for i in range(72):
|
||||||
|
var a := TAU * i / 72.0
|
||||||
|
for k in [0.95, 0.9, 0.85, 0.8, 0.75]:
|
||||||
|
var x: float = c.x + cos(a) * t.lake_radius * k
|
||||||
|
var z: float = c.y + sin(a) * t.lake_radius * k
|
||||||
|
if t.sample_height(x, z) < t.water_level - 0.3:
|
||||||
|
edge = Vector3(x, t.water_level, z)
|
||||||
|
break
|
||||||
|
if edge != Vector3.INF:
|
||||||
|
break
|
||||||
|
var mid := Vector3(c.x, t.water_level, c.y)
|
||||||
|
check(edge != Vector3.INF and not world.water_at(edge.x, edge.z).is_empty(), "(open water by the edge, %.0f m out)" % Vector2(edge.x - c.x, edge.z - c.y).length())
|
||||||
|
_set_time(13, 12.0)
|
||||||
|
hz.step(0.1)
|
||||||
|
await physics_frame
|
||||||
|
await physics_frame
|
||||||
|
check(hz.lake_frozen(), "in a hard winter it's frozen over at the edge")
|
||||||
|
check(world.water_at(edge.x, edge.z).is_empty(), "no fishing through the ice there")
|
||||||
|
check(not world.water_at(mid.x, mid.z).is_empty() or t.sample_height(mid.x, mid.z) >= t.water_level, "but the middle is still open water")
|
||||||
|
var q := PhysicsRayQueryParameters3D.create(edge + Vector3(0, 3, 0), edge + Vector3(0, -3, 0), 1)
|
||||||
|
var hit := world.get_world_3d().direct_space_state.intersect_ray(q)
|
||||||
|
check(not hit.is_empty() and absf(hit.position.y - t.water_level) < 0.1, "and you can walk on the ice")
|
||||||
|
_set_time(16, 12.0)
|
||||||
|
hz.step(0.1)
|
||||||
|
await physics_frame
|
||||||
|
await physics_frame
|
||||||
|
hit = world.get_world_3d().direct_space_state.intersect_ray(q)
|
||||||
|
check(not hz.lake_frozen() and (hit.is_empty() or hit.position.y < t.water_level - 0.2), "after it, the ice is gone")
|
||||||
|
|
||||||
|
# --- lightning -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _lightning() -> void:
|
||||||
|
print("lightning")
|
||||||
|
var hz: Node = world.hazards
|
||||||
|
var wx: Node = world.weather
|
||||||
|
_set_time(6, 14.0)
|
||||||
|
# Stand among trees near the rim.
|
||||||
|
var trees: Array = world.props.resource_nodes.values().filter(func(n): return n.kind == "tree" and not n.depleted)
|
||||||
|
trees.sort_custom(func(a, b): return world.props._near_rim(a.global_position.x, a.global_position.z) > world.props._near_rim(b.global_position.x, b.global_position.z))
|
||||||
|
var at: Vector3 = trees[0].global_position + Vector3(4, 0, 0)
|
||||||
|
at.y = world.terrain.sample_height(at.x, at.z)
|
||||||
|
player.global_position = at
|
||||||
|
wx.force("clear")
|
||||||
|
hz._strike_left = 0.0
|
||||||
|
hz.step(0.1)
|
||||||
|
check(hz.strikes == 0, "no lightning on a clear day")
|
||||||
|
wx.force("storm")
|
||||||
|
_toasts.clear()
|
||||||
|
var before: int = world.all_dropped().filter(func(d): return d.item_id == "charcoal").size()
|
||||||
|
hz._strike_left = 0.0
|
||||||
|
hz.step(0.1)
|
||||||
|
check(hz.strikes == 1 and hz._burning.size() == 1, "in a storm, a bolt hits a tree")
|
||||||
|
var tree: Node3D = hz._burning.keys()[0]
|
||||||
|
check(tree.global_position.distance_to(at) < hz.STRIKE_RANGE, "near you (%.0f m away)" % tree.global_position.distance_to(at))
|
||||||
|
check(_toast_has("Lightning struck"), "you see it (%s)" % [_toasts])
|
||||||
|
check(world.heat_at(tree.global_position + Vector3(2.5, 0, 0)) > 0.3, "it burns: you can warm yourself by it")
|
||||||
|
check(not tree.depleted, "still standing while it burns")
|
||||||
|
for _i in range(41):
|
||||||
|
hz.step(1.0)
|
||||||
|
await physics_frame
|
||||||
|
check(tree.depleted and not hz.burning(tree), "then it falls (%s)" % ("felled" if tree.depleted else "standing"))
|
||||||
|
var coal: Array = world.all_dropped().filter(func(d): return d.item_id == "charcoal")
|
||||||
|
check(coal.size() == before + 1 and int(coal.back().amount) == 2 and coal.back().global_position.distance_to(tree.global_position) < 2.0, "leaving 2 charcoal at its foot")
|
||||||
|
print("a long storm")
|
||||||
|
var n0: int = hz.strikes
|
||||||
|
var pieces0: int = world.all_pieces().size()
|
||||||
|
var wall: Node3D = world.spawn_piece(DB.get_item("wood_wall"), Transform3D(Basis(), at + Vector3(0, 0, 3)))
|
||||||
|
for _i in range(600):
|
||||||
|
hz.step(1.0)
|
||||||
|
var n: int = hz.strikes - n0
|
||||||
|
check(n >= 4 and n <= 13, "about one strike a minute or so (%d in 10 minutes)" % n)
|
||||||
|
check(is_instance_valid(wall) and not wall.is_queued_for_deletion() and world.all_pieces().size() == pieces0 + 1, "it never strikes a building")
|
||||||
|
wx.force("")
|
||||||
@@ -59,20 +59,27 @@ func _weather_model() -> void:
|
|||||||
var cold_days := 0
|
var cold_days := 0
|
||||||
var snow_on_warm_day := false
|
var snow_on_warm_day := false
|
||||||
var days := 60
|
var days := 60
|
||||||
|
var samples := 0
|
||||||
var t := 0.0
|
var t := 0.0
|
||||||
while t < days * 1200.0:
|
while t < days * 1200.0:
|
||||||
var s: String = wa.state_at(t)
|
var s: String = wa.state_at(t)
|
||||||
if s != wb.state_at(t):
|
if s != wb.state_at(t):
|
||||||
same = false
|
same = false
|
||||||
counts[s] = counts.get(s, 0) + 1
|
|
||||||
var day := int(t / 1200.0) + 1
|
var day := int(t / 1200.0) + 1
|
||||||
|
if not wa.hard_winter(day): # (the mix over ordinary days; hard winters lean snowy)
|
||||||
|
counts[s] = counts.get(s, 0) + 1
|
||||||
|
samples += 1
|
||||||
if s in ["snow", "blizzard"] and not wa.is_cold_day(day):
|
if s in ["snow", "blizzard"] and not wa.is_cold_day(day):
|
||||||
snow_on_warm_day = true
|
snow_on_warm_day = true
|
||||||
t += 20.0
|
t += 20.0
|
||||||
|
var ordinary := 0
|
||||||
for d in range(1, days + 1):
|
for d in range(1, days + 1):
|
||||||
|
if wa.hard_winter(d):
|
||||||
|
continue # (P4's hard winters are always cold: tests/test_hazards.gd)
|
||||||
|
ordinary += 1
|
||||||
if wa.is_cold_day(d):
|
if wa.is_cold_day(d):
|
||||||
cold_days += 1
|
cold_days += 1
|
||||||
var total := float(days * 60)
|
var total := float(samples)
|
||||||
var frac := func(names: Array) -> float:
|
var frac := func(names: Array) -> float:
|
||||||
var n := 0
|
var n := 0
|
||||||
for k in names:
|
for k in names:
|
||||||
@@ -84,7 +91,7 @@ func _weather_model() -> void:
|
|||||||
check(wet > 0.2 and wet < 0.4, "rain or snow about 30%% of the time (%.0f%%)" % (wet * 100))
|
check(wet > 0.2 and wet < 0.4, "rain or snow about 30%% of the time (%.0f%%)" % (wet * 100))
|
||||||
var hard: float = frac.call(["storm", "blizzard"])
|
var hard: float = frac.call(["storm", "blizzard"])
|
||||||
check(hard > 0.04 and hard < 0.16, "storms about 10%% of the time (%.0f%%)" % (hard * 100))
|
check(hard > 0.04 and hard < 0.16, "storms about 10%% of the time (%.0f%%)" % (hard * 100))
|
||||||
check(cold_days >= 8 and cold_days <= 24, "about a quarter of days are cold (%d of %d)" % [cold_days, days])
|
check(cold_days >= ordinary * 0.12 and cold_days <= ordinary * 0.4, "about a quarter of ordinary days are cold (%d of %d)" % [cold_days, ordinary])
|
||||||
check(not snow_on_warm_day, "it only snows in the valley on cold days")
|
check(not snow_on_warm_day, "it only snows in the valley on cold days")
|
||||||
check(wa.state_at(0.0) == "clear" and wa.state_at(500.0) == "clear", "a new world starts with clear weather")
|
check(wa.state_at(0.0) == "clear" and wa.state_at(500.0) == "clear", "a new world starts with clear weather")
|
||||||
var other: Node = load("res://scripts/world/weather.gd").new()
|
var other: Node = load("res://scripts/world/weather.gd").new()
|
||||||
|
|||||||
Reference in New Issue
Block a user