2 Commits

31 changed files with 1462 additions and 45 deletions

View File

@@ -347,7 +347,7 @@
"name": "Hide",
"category": "resource",
"stack": 20,
"description": "Raw hide from a deer or boar. Makes a warm cloak.",
"description": "Rawhide from an animal. For cloaks and bedrolls.",
"mesh": "hide"
},
{
@@ -355,7 +355,7 @@
"name": "Feather",
"category": "resource",
"stack": 50,
"description": "Found in meadows. Fletching for arrows.",
"description": "Fletching for arrows, and stuffing for a bedroll.",
"mesh": "feather"
},
{
@@ -443,7 +443,7 @@
"name": "Wolf Pelt",
"category": "resource",
"stack": 20,
"description": "Thick grey fur. The warmest thing on the island.",
"description": "A grey wolf pelt. For wolf and alpha cloaks, or a rug.",
"mesh": "wolf_pelt"
},
{
@@ -564,7 +564,7 @@
"name": "Clay",
"category": "resource",
"stack": 50,
"description": "Grey clay from the lake shore. For kilns and smelters.",
"description": "Grey clay from the lake shore. For kilns, smelters and, fired at a campfire, clay jars that keep food fresh.",
"mesh": "clay"
},
{
@@ -928,7 +928,7 @@
"name": "Alpha Pelt",
"category": "resource",
"stack": 5,
"description": "The alpha's black fur, thick as a blanket.",
"description": "The alpha's dark pelt. For the alpha cloak or a fang necklace.",
"mesh": "alpha_pelt"
},
{
@@ -936,7 +936,7 @@
"name": "Wolf Fang",
"category": "resource",
"stack": 20,
"description": "A long yellowed fang from the alpha. A trophy, and a fine clasp.",
"description": "A long yellowed fang from the alpha. For the alpha cloak, and a fang necklace that blunts wolf bites.",
"mesh": "wolf_fang"
},
{
@@ -956,7 +956,7 @@
"name": "Bear Hide",
"category": "resource",
"stack": 5,
"description": "A huge brown hide. Makes a fur rug.",
"description": "A huge brown hide. For a fur rug or a bear cloak, the warmest there is.",
"mesh": "bear_hide"
},
{
@@ -964,7 +964,7 @@
"name": "Bear Fat",
"category": "resource",
"stack": 20,
"description": "Rendered fat. Burns three times as long as wood in a fire.",
"description": "Thick white fat. Burns long on a fire, and makes pemmican.",
"mesh": "bear_fat",
"fuel": 180
},
@@ -996,7 +996,7 @@
"name": "Rotten Food",
"category": "resource",
"stack": 50,
"description": "It's gone off. Good for nothing: throw it out.",
"description": "It's gone off. Q drops it. Carried while fishing it's bait: bites come sooner, and each bite takes one.",
"mesh": "rotten_food"
},
{
@@ -1129,6 +1129,93 @@
},
"station": "forge"
}
},
{
"id": "clay_jar",
"name": "Clay Jar",
"category": "placeable",
"stack": 5,
"description": "Fired clay with a lid. Holds 6 stacks, and food kept in it lasts three times as long.",
"mesh": "clay_jar",
"place": {
"piece": "jar",
"footprint": [
0.7,
0.7
]
}
},
{
"id": "bedroll",
"name": "Bedroll",
"category": "placeable",
"stack": 1,
"description": "Hide stuffed with feathers. Sets your spawn point like a bed, and you can sleep the night out under the open sky (but not in rain or snow).",
"mesh": "bedroll",
"place": {
"piece": "bed",
"footprint": [
0.9,
2.0
],
"open_air": true
}
},
{
"id": "pelt_rug",
"name": "Wolf-pelt Rug",
"category": "placeable",
"stack": 1,
"description": "Wolf pelts stitched into a rug. In a sheltered room: a little warmer, and you get Rested twice as fast.",
"mesh": "pelt_rug",
"place": {
"piece": "rug",
"footprint": [
1.4,
1.8
]
}
},
{
"id": "fang_necklace",
"name": "Fang Necklace",
"category": "tool",
"stack": 1,
"description": "The alpha's fangs on a strip of its hide. Carry it anywhere in your bag and wolves, the alpha too, bite 30% less hard.",
"mesh": "fang_necklace",
"charm": {
"against": "wolf",
"damage": 0.7
}
},
{
"id": "bear_cloak",
"name": "Bear Cloak",
"category": "tool",
"stack": 1,
"description": "Right-click to wear it (+40 warmth, rain barely touches you): the warmest cloak there is. Take it off from the inventory screen.",
"mesh": "bear_cloak",
"wear": {
"warmth": 40,
"wet_chill": 0.3
}
},
{
"id": "pemmican",
"name": "Pemmican",
"category": "food",
"stack": 20,
"description": "Cooked meat and berries pounded into bear fat. Filling, counts as meat and greens, and keeps 30 days.",
"mesh": "pemmican",
"keeps": 30,
"food": {
"hunger": 40,
"health": 10,
"diet": {
"meat": 28,
"plants": 28
}
}
}
]
}

View File

@@ -589,6 +589,75 @@
"iron_bar": 1,
"wood": 2
}
},
{
"id": "clay_jar",
"output": "clay_jar",
"count": 1,
"station": "campfire",
"category": "furniture",
"inputs": {
"clay": 8
}
},
{
"id": "bedroll",
"output": "bedroll",
"count": 1,
"station": "workbench",
"category": "furniture",
"inputs": {
"hide": 3,
"feather": 6,
"fiber": 4
}
},
{
"id": "pelt_rug",
"output": "pelt_rug",
"count": 1,
"station": "workbench",
"category": "furniture",
"inputs": {
"wolf_pelt": 3,
"fiber": 2
}
},
{
"id": "fang_necklace",
"output": "fang_necklace",
"count": 1,
"station": "workbench",
"category": "clothing",
"inputs": {
"wolf_fang": 2,
"alpha_pelt": 1,
"fiber": 2
}
},
{
"id": "bear_cloak",
"output": "bear_cloak",
"count": 1,
"station": "workbench",
"category": "clothing",
"inputs": {
"bear_hide": 2,
"hide": 2,
"fiber": 4
}
},
{
"id": "pemmican",
"output": "pemmican",
"count": 3,
"station": "campfire",
"category": "cooking",
"inputs": {
"cooked_meat": 2,
"berries": 3,
"bear_fat": 1
}
}
]
}

View File

@@ -29,8 +29,8 @@ application/icon_interpolation=4
application/bundle_identifier="com.pbailey.emberwild"
application/signature=""
application/app_category="Games"
application/short_version="0.2.1"
application/version="0.2.1"
application/short_version="0.3.0"
application/version="0.3.0"
application/copyright=""
display/high_res=true
codesign/codesign=1
@@ -89,8 +89,8 @@ application/modify_resources=false
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version="0.2.1"
application/product_version="0.2.1"
application/file_version="0.3.0"
application/product_version="0.3.0"
application/company_name=""
application/product_name="Emberwild"
application/file_description="Emberwild"

View File

@@ -19,7 +19,7 @@ config/description="Low-poly survival game — Valheim look, Rust depth, self-ho
run/main_scene="res://scenes/title.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
config/version="0.2.1"
config/version="0.3.0"
[autoload]

View File

@@ -43,5 +43,5 @@ offset_left = 16.0
offset_top = 12.0
offset_right = 600.0
offset_bottom = 60.0
text = "E interact · LMB use/place · RMB eat · R rotate · X dismantle · Tab inventory & crafting · Esc menu & controls"
text = "E interact · LMB use/place · RMB eat · R rotate · X dismantle · Q drop · K/M bag row · Tab inventory & crafting · Esc menu & controls"
label_settings = SubResource("Label_1")

View File

@@ -2,8 +2,15 @@ extends "res://scripts/building/piece.gd"
## Bed: E sets your spawn point here. Once it's yours, E at night sleeps until
## morning, if the bed is under a roof with walls around it.
func _build() -> void:
add_to_group("bed") # (a bed or a bedroll: either is a spawn point)
func display_name() -> String:
return "Bed"
return String(def.get("name", "Bed"))
## A bedroll: you can sleep in the open (world.sleep_block_reason).
func open_air() -> bool:
return bool(def.get("place", {}).get("open_air", false))
func is_spawn() -> bool:
var w := get_parent().get_parent()
@@ -11,8 +18,8 @@ func is_spawn() -> bool:
func interact_prompt(_player: Node) -> String:
if not is_spawn():
return "Bed [E] Set spawn point"
return "Bed (your spawn point) [E] Sleep"
return "%s [E] Set spawn point" % display_name()
return "%s (your spawn point) [E] Sleep" % display_name()
func interact(player: Node) -> void:
Actions.use_bed(player, self)

29
scripts/building/jar.gd Normal file
View File

@@ -0,0 +1,29 @@
extends "res://scripts/building/piece.gd"
## Clay jar: 6 slots of storage, opened with E like a chest, and food kept in
## it lasts three times as long (Inventory.keep_mult).
const SLOTS := 6
const KEEP := 3.0
var inventory := Inventory.new(SLOTS)
func _build() -> void:
inventory.keep_mult = KEEP
func display_name() -> String:
return "Clay Jar"
func dismantle_block_reason() -> String:
for s in inventory.slots:
if s != null:
return "Empty the jar first"
return ""
func interact_prompt(_player: Node) -> String:
var used := 0
for s in inventory.slots:
if s != null:
used += 1
return "Clay jar (%d/%d, food keeps 3x as long) [E] Open" % [used, SLOTS]
func interact(player: Node) -> void:
player.open_inventory(self)

View File

@@ -31,6 +31,9 @@ func _ready() -> void:
_bind("inventory", [KEY_TAB, KEY_I])
_bind("rotate_piece", [KEY_R])
_bind("dismantle", [KEY_X])
_bind("drop", [KEY_Q]) # (shift+Q drops just one)
_bind("row_up", [KEY_K]) # the 1-8 keys pick from the bag row above...
_bind("row_down", [KEY_M]) # ...or below
for i in range(8):
_bind("hotbar_%d" % (i + 1), [KEY_1 + i])
_bind_mouse("attack", MOUSE_BUTTON_LEFT)
@@ -736,8 +739,53 @@ static func harvest_sound(tool_kind: String, felled: bool) -> String:
## A piece going down: wood, stone, or something soft.
static func place_sound(id: String) -> String:
if id.contains("stone") or id in ["campfire", "forge", "hearth", "stonecutter"]:
if id.contains("stone") or id.contains("jar") or id in ["campfire", "forge", "hearth", "stonecutter"]:
return "place_stone"
if id.contains("rug") or id.contains("bed") or id == "smoking_rack":
return "place_soft"
return "place_wood"
# --- charms and bait ------------------------------------------------------------------
## A charm carried anywhere in the bag (the fang necklace: {"against": "wolf",
## "damage": 0.7}) takes the edge off hits from what it's against. `by` is the
## hit's "a wolf" / "a alpha wolf" / "a spirit wolf" (survival.damage).
static func charm_mult(inv: Inventory, by: String) -> float:
var m := 1.0
if by.is_empty():
return m
for s in inv.slots:
if s == null:
continue
var c: Dictionary = ItemDB.get_item(String(s.id)).get("charm", {})
if not c.is_empty() and by.to_lower().contains(String(c.get("against", "?"))):
m = minf(m, float(c.get("damage", 1.0)))
return m
const BAIT := "rotten_food"
const BAIT_WAIT := 0.6 ## carried while fishing: bites come this much sooner
## Drops the stack in `slot` (or `count` of it) at your feet, a little in
## front (Q drops what's in your hand). Online the server does it, for everyone.
func drop(player: Node3D, slot: int, count: int = 0) -> bool:
if _online():
return _ask("drop", [slot, count])
var s = player.inventory.get_slot(slot)
if s == null:
return false
var n: int = int(s.count) if count <= 0 else mini(count, int(s.count))
# (on a server the player is a proxy under PlayerNet, not a child of the world)
var world: Node = WorldNet.world if Net.is_server() else player.get_parent()
if world == null or not world.has_method("drop_item"):
return false
var fwd: Vector3 = -player.global_basis.z
fwd.y = 0.0
var at: Vector3 = player.global_position + fwd.normalized() * 0.9
at.y = world.terrain.sample_height(at.x, at.z) + 0.05 if world.get("terrain") != null else player.global_position.y
var extra := Inventory.extra_of(s)
if world.drop_item(String(s.id), n, Transform3D(Basis(), at), extra) == null:
return false
player.inventory.take_from(slot, n)
Sfx.at("pickup", at)
notify.emit("Dropped %d %s" % [n, ItemDB.item_name(String(s.id))])
return true

View File

@@ -65,6 +65,10 @@ static func extra_of(stack: Dictionary) -> Dictionary:
static var clock := 0.0
static var day_seconds := 1200.0
static var rot_speed := 1.0
## How many times longer food keeps in here (a clay jar: 3), and the clock at
## the last spoil() (the time since is what's slowed).
var keep_mult := 1.0
var _last_clock := -1.0
const ROTTEN := "rotten_food"
static func _db() -> Node:
@@ -110,6 +114,15 @@ func spoil() -> int:
return 0
var rotted := 0
var touched := false
# A clay jar (keep_mult 3): food in it ages at a third of the rate, so its
# "rot" stamp moves back by two thirds of the time that's passed.
if keep_mult > 1.0:
if _last_clock >= 0.0 and clock > _last_clock:
var later := (clock - _last_clock) * (1.0 - 1.0 / keep_mult)
for s in slots:
if s != null and s.has("rot") and float(s.rot) > _last_clock:
s.rot = float(s.rot) + later
_last_clock = clock
for i in range(slots.size()):
var s = slots[i]
if s == null or keeps_days(String(s.id)) <= 0.0:

View File

@@ -74,6 +74,8 @@ func _dawn_dusk() -> bool:
return (h > 5.0 and h < 8.5) or (h > 17.0 and h < 20.5)
## How long until the next bite (seconds), from data/fish.json.
var baited := false ## this wait was shortened by bait (rotten food)
func _next_wait() -> float:
var mods: Dictionary = ItemDB.fishing.modifiers
var w: Array = table.get("bite_wait", [5.0, 14.0])
@@ -82,6 +84,10 @@ func _next_wait() -> float:
k *= float(mods.get("dawn_dusk_wait", 0.6)) # fish feed at dawn and dusk
if world.weather.precip > 0.3:
k *= float(mods.get("rain_wait", 0.8))
# Rotten food in your bag is bait: the fish come sooner, and a bite takes one.
baited = player != null and player.get("inventory") != null and player.inventory.count(Actions.BAIT) > 0
if baited:
k *= Actions.BAIT_WAIT
return rng.randf_range(w[0], w[1]) * k
## A weighted pick from this water's fish (dawn/dusk weights when they apply).
@@ -139,6 +145,8 @@ func step(dt: float) -> void:
bite_left = BITE_WINDOW
Actions.tell(player, "Bite! Click to reel in")
Sfx.at("bite", global_position)
if baited and player.inventory.remove(Actions.BAIT, 1):
baited = false
"biting":
bite_left -= dt
# The float bobs hard and goes under.

View File

@@ -115,6 +115,12 @@ static func build(key: String) -> ArrayMesh:
"bear_hide": m = _pelt(BEAR_FUR)
"bear_fat": m = _pebble(271, Color(0.92, 0.86, 0.68), Vector3(0.1, 0.06, 0.08), 0.1)
"fur_rug": m = _rug()
"pelt_rug": m = _rug(WOLF_FUR, 0.85)
"bear_cloak": m = _cloak(BEAR_FUR)
"clay_jar": m = _jar()
"bedroll": m = _bedroll()
"fang_necklace": m = _necklace()
"pemmican": m = _pebble(331, Color(0.42, 0.24, 0.16), Vector3(0.1, 0.05, 0.07), 0.15)
"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()
@@ -999,19 +1005,58 @@ static func _fang() -> ArrayMesh:
const BEAR_FUR := Color(0.36, 0.24, 0.15)
## A bearskin laid flat: body, four splayed legs and the head toward -Z.
static func _rug() -> ArrayMesh:
## A skin rug with its head at -Z: the bear's (fur_rug), or wolves' a bit smaller.
static func _rug(fur: Color = BEAR_FUR, k: float = 1.0) -> ArrayMesh:
var st := P._begin()
var r := _rng(273)
_box(st, Vector3(0, 0.015, 0.1), Vector3(1.0, 0.03, 1.5), BEAR_FUR)
_box(st, Vector3(0, 0.02, 0.1), Vector3(0.8, 0.03, 1.3), P._mul(BEAR_FUR, 1.12))
_box(st, Vector3(0, 0.015, 0.1) * k, Vector3(1.0, 0.03 / k, 1.5) * k, fur)
_box(st, Vector3(0, 0.02, 0.1) * k, Vector3(0.8, 0.03 / k, 1.3) * k, P._mul(fur, 1.12))
for sx in [-1.0, 1.0]:
for sz in [-0.45, 0.6]:
_box(st, Vector3(sx * 0.62, 0.012, sz), Vector3(0.42, 0.024, 0.24), _board(r, BEAR_FUR), Basis(Vector3.UP, sx * (0.5 if sz < 0 else -0.5)))
_box(st, Vector3(0, 0.05, -0.85), Vector3(0.38, 0.1, 0.34), P._mul(BEAR_FUR, 0.95))
_box(st, Vector3(0, 0.045, -1.06), Vector3(0.18, 0.08, 0.14), Color(0.58, 0.45, 0.32))
_box(st, Vector3(sx * 0.62, 0.012, sz) * k, Vector3(0.42, 0.024 / k, 0.24) * k, _board(r, fur), Basis(Vector3.UP, sx * (0.5 if sz < 0 else -0.5)))
_box(st, Vector3(0, 0.05, -0.85) * k, Vector3(0.38, 0.1, 0.34) * k, P._mul(fur, 0.95))
_box(st, Vector3(0, 0.045, -1.06) * k, Vector3(0.18, 0.08, 0.14) * k, Color(0.58, 0.45, 0.32))
for sx in [-0.14, 0.14]:
_box(st, Vector3(sx, 0.11, -0.8), Vector3(0.08, 0.05, 0.05), P._mul(BEAR_FUR, 0.8))
_box(st, Vector3(0, 0.02, 0.92), Vector3(0.12, 0.03, 0.12), BEAR_FUR)
_box(st, Vector3(sx, 0.11, -0.8) * k, Vector3(0.08, 0.05, 0.05) * k, P._mul(fur, 0.8))
_box(st, Vector3(0, 0.02, 0.92) * k, Vector3(0.12, 0.03, 0.12) * k, fur)
return P._commit(st)
## A fired clay jar with a wooden lid, about knee high.
static func _jar() -> ArrayMesh:
var st := P._begin()
var r := _rng(337)
P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 0.12, 0), 0.16, 0.26, 10, P._mul(CLAY_FIRED, 0.92), r, -1.0, CLAY_FIRED)
P._cylinder(st, Vector3(0, 0.12, 0), Vector3(0, 0.42, 0), 0.26, 0.24, 10, CLAY_FIRED, r)
P._cylinder(st, Vector3(0, 0.42, 0), Vector3(0, 0.52, 0), 0.24, 0.16, 10, P._mul(CLAY_FIRED, 1.06), r)
P._cylinder(st, Vector3(0, 0.52, 0), Vector3(0, 0.56, 0), 0.17, 0.17, 10, P._mul(CLAY_FIRED, 0.85), r)
P._cylinder(st, Vector3(0, 0.56, 0), Vector3(0, 0.6, 0), 0.19, 0.17, 10, PLANK_DARK, r, -1.0, PLANK) # lid
_box(st, Vector3(0, 0.63, 0), Vector3(0.08, 0.04, 0.05), PLANK_DARK)
return P._commit(st)
## A hide bedroll: a flat mat with its rolled-up end and a feather pillow.
static func _bedroll() -> ArrayMesh:
var st := P._begin()
var r := _rng(347)
_box(st, Vector3(0, 0.03, 0.15), Vector3(0.8, 0.06, 1.6), HIDE)
_box(st, Vector3(0, 0.065, 0.2), Vector3(0.66, 0.02, 1.35), P._mul(HIDE, 1.12))
P._cylinder(st, Vector3(-0.4, 0.13, 0.95), Vector3(0.4, 0.13, 0.95), 0.12, 0.12, 8, _board(r, HIDE), r, -1.0, P._mul(HIDE, 0.8))
_box(st, Vector3(0, 0.1, -0.5), Vector3(0.5, 0.09, 0.28), FEATHER)
for sx in [-0.25, 0.25]:
_box(st, Vector3(sx, 0.18, 0.95), Vector3(0.04, 0.26, 0.04), BINDING)
return P._commit(st)
## A cord with five fangs hanging from a dark scrap of pelt.
static func _necklace() -> ArrayMesh:
var st := P._begin()
var r := _rng(353)
for i in range(12):
var a := TAU * i / 12.0
var b := TAU * (i + 1) / 12.0
P._cylinder(st, Vector3(cos(a) * 0.12, 0.01, sin(a) * 0.1), Vector3(cos(b) * 0.12, 0.01, sin(b) * 0.1), 0.006, 0.006, 4, BINDING, r)
_box(st, Vector3(0, 0.015, 0.1), Vector3(0.09, 0.02, 0.05), ALPHA_FUR)
for i in range(5):
var x := (i - 2) * 0.035
P._cylinder(st, Vector3(x, 0.012, 0.12), Vector3(x * 1.2, 0.012, 0.19 - absf(i - 2) * 0.012), 0.012, 0.002, 5, Color(0.88, 0.82, 0.62), r)
return P._commit(st)
# --- P3 the barrow --------------------------------------------------------------------

View File

@@ -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 ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 13 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd)
const PROTOCOL := 14 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican)
const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join

View File

@@ -341,7 +341,7 @@ func _run(p: Node3D, what: String, a: Array) -> void:
WorldNet.piece_state(f)
"bed":
var b := WorldNet.piece(_i(a, 0))
if b != null and b.item_id == "bed" and _allowed(Rules.use_problem(p.player_id, b)):
if b != null and b.is_in_group("bed") and _allowed(Rules.use_problem(p.player_id, b)):
Actions.use_bed(p, b)
"inv_click":
# Slot `a[1]` of your own inventory (a[0] = 0) or a chest's.
@@ -365,6 +365,10 @@ func _run(p: Node3D, what: String, a: Array) -> void:
var slot := _i(a, 1)
if slot >= 0 and slot < from.size():
Actions.transfer(p, from, slot, to, c)
"drop":
var ds := _i(a, 0)
if ds >= 0 and ds < p.inventory.size():
Actions.drop(p, ds, _i(a, 1))
"close":
# The inventory screen closed: what's on the cursor goes back in
# your bag, or at your feet if it's full.

View File

@@ -63,7 +63,7 @@ func held_item_id() -> String:
return s.id if s != null else ""
func select(i: int) -> void:
selected = wrapi(i, 0, HOTBAR)
selected = wrapi(i, 0, inventory.size()) # (any bag slot: player.gd's rows)
selected_changed.emit()
func held_heat() -> float:
@@ -85,7 +85,7 @@ func bed_piece(world: Node3D) -> Node3D:
if not has_bed_spawn:
return null
for p in world.all_pieces():
if p.item_id == "bed" and claims_bed(p):
if p.is_in_group("bed") and claims_bed(p):
return p
return null

View File

@@ -237,13 +237,22 @@ func _unhandled_input(event: InputEvent) -> void:
return
for i in range(8):
if event.is_action_pressed("hotbar_%d" % (i + 1)):
player.select(i)
player.select_in_row(i)
return
if event.is_action_pressed("row_up"):
player.change_row(-1)
return
if event.is_action_pressed("row_down"):
player.change_row(1)
return
if event.is_action_pressed("drop"):
Actions.drop(player, player.selected, 1 if Input.is_key_pressed(KEY_SHIFT) else 0)
return
if event is InputEventMouseButton and event.pressed and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
player.select(player.selected - 1)
player.select_in_row(player.selected % player.HOTBAR - 1)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
player.select(player.selected + 1)
player.select_in_row(player.selected % player.HOTBAR + 1)
if event.is_action_released("attack") and _drawing:
_release_bow()
return

View File

@@ -49,10 +49,26 @@ func held_item_id() -> String:
var s = inventory.get_slot(selected)
return s.id if s != null else ""
## Holds the item in bag slot `i` (any slot: 1-8 pick from the current row,
## K and M move between rows of 8).
func select(i: int) -> void:
selected = wrapi(i, 0, HOTBAR)
selected = wrapi(i, 0, inventory.size())
selected_changed.emit()
## The bag row (0 = the top) the 1-8 keys and the hotbar show.
func row() -> int:
return selected / HOTBAR
func rows() -> int:
return inventory.size() / HOTBAR
## Column `col` of the current row; and the same column a row up or down.
func select_in_row(col: int) -> void:
select(row() * HOTBAR + wrapi(col, 0, HOTBAR))
func change_row(by: int) -> void:
select(wrapi(row() + by, 0, rows()) * HOTBAR + selected % HOTBAR)
## Called by workbenches and chests (container = null for plain crafting).
func open_inventory(container: Node) -> void:
inventory_requested.emit(container)

View File

@@ -274,6 +274,8 @@ func reset_after_death() -> void:
func damage(amount: float, from: Vector3, by: String = "") -> void:
if dead:
return
if player != null and player.get("inventory") != null:
amount *= Actions.charm_mult(player.inventory, by) # (a fang necklace against wolves)
cause = "Killed by %s" % by if not by.is_empty() else "You were killed"
health = maxf(health - amount, 0.0)
_hurt_cd = HURT_NO_REGEN

View File

@@ -12,6 +12,7 @@ var player: CharacterBody3D
var _prompt: Label
var _held_name: Label
var _hotbar: Array = []
var _row_label: Label
var _toasts: VBoxContainer
var _screen: Control
var _menu: Control
@@ -66,6 +67,16 @@ func bind(p: CharacterBody3D) -> void:
bar.add_child(s)
_hotbar.append(s)
root.add_child(bar)
# Which bag row the hotbar is showing (K / M change it), beside the bar.
_row_label = UITheme.label("", 14, UITheme.TEXT_DIM)
_row_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM)
_row_label.grow_horizontal = Control.GROW_DIRECTION_BOTH
_row_label.grow_vertical = Control.GROW_DIRECTION_BEGIN
_row_label.offset_left = 300
_row_label.offset_right = 420
_row_label.offset_bottom = -40
_row_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(_row_label)
_held_name = UITheme.label("", 15, UITheme.TEXT_DIM)
_held_name.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM)
@@ -392,9 +403,11 @@ func _unhandled_input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
func _refresh() -> void:
var first: int = player.row() * _hotbar.size()
for i in range(_hotbar.size()):
_hotbar[i].set_stack(player.inventory.get_slot(i))
_hotbar[i].set_selected(i == player.selected)
_hotbar[i].set_stack(player.inventory.get_slot(first + i))
_hotbar[i].set_selected(first + i == player.selected)
_row_label.text = "Row %d of %d\nK / M" % [player.row() + 1, player.rows()]
var id: String = player.held_item_id()
_held_name.text = ItemDB.item_name(id) if not id.is_empty() else ""

View File

@@ -74,7 +74,7 @@ func bind(p: CharacterBody3D) -> void:
if i == 7:
pass
pv.add_child(grid)
pv.add_child(UITheme.label("Row 1 is your hotbar. Shift-click moves stacks to/from a chest.", 13, UITheme.TEXT_DIM))
pv.add_child(UITheme.label("1-8 pick from the row you're on; K / M change row. Shift-click moves stacks to/from a chest. Q drops what you hold.", 13, UITheme.TEXT_DIM))
var wr := HBoxContainer.new()
wr.add_theme_constant_override("separation", 10)
_worn_label = UITheme.label("", 15)

View File

@@ -13,7 +13,8 @@ const CONTROLS := [
["Left click (fishing rod)", "Cast / reel in when the float dips"],
["R", "Rotate piece (building pieces: quarter turns)"], ["X", "Dismantle the piece you're looking at"],
["E on a bed", "Set spawn point / sleep (night, under a roof)"],
["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"],
["1 - 8, wheel", "Hotbar (the bag row you're on)"], ["K / M", "Hotbar shows the bag row above / below"],
["Q (shift+Q)", "Drop what you're holding (just one)"], ["Tab", "Inventory & crafting (shift-click Craft for 5)"],
["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"],
["Esc", "This menu (game autosaves every 2 min)"],
]

View File

@@ -30,6 +30,7 @@ const PIECES := {
"forge": preload("res://scripts/building/forge.gd"),
"hearth": preload("res://scripts/building/hearth.gd"),
"rug": preload("res://scripts/building/rug.gd"),
"jar": preload("res://scripts/building/jar.gd"),
"stonecutter": preload("res://scripts/building/stonecutter.gd"),
}
const BuildPiece = preload("res://scripts/building/build_piece.gd")
@@ -268,7 +269,7 @@ func spawn_bed() -> Node3D:
if not has_bed_spawn:
return null
for p in _pieces.get_children():
if p.item_id == "bed" and not p.is_queued_for_deletion() and p.global_position.distance_to(bed_spawn) < 0.3:
if p.is_in_group("bed") and not p.is_queued_for_deletion() and p.global_position.distance_to(bed_spawn) < 0.3:
return p
return null
@@ -299,7 +300,11 @@ func sleep_block_reason(bed: Node3D) -> String:
var h: float = day_night.hours()
if h > 5.5 and h < 19.5:
return "You can only sleep at night (after 19:30)"
if not build.sheltered_at(bed.global_position):
if bed.has_method("open_air") and bed.open_air():
# A bedroll: anywhere, as long as it isn't raining or snowing on you.
if not build.sheltered_at(bed.global_position) and weather.precip > 0.3:
return "Not out here in this weather: find shelter, or wait for it to clear"
elif not build.sheltered_at(bed.global_position):
return "Your bed needs a roof and walls around it"
if wolves != null:
for w in wolves.alive():
@@ -724,7 +729,7 @@ func add_dropped_entry(e: Dictionary) -> Node3D:
func remove_pieces_net(gone: Array, collapsed_nids: Array) -> void:
var any_build := false
for p in gone:
if p.item_id == "bed" and spawn_bed() == p:
if p.is_in_group("bed") and spawn_bed() == p:
has_bed_spawn = false
if p.is_in_group("build"):
any_build = true
@@ -1031,7 +1036,7 @@ signal launched(id: String, origin: Vector3, vel: Vector3, shooter: Node3D)
## support collapses, and its item goes back to `refund_to` if there's room.
## Returns how many pieces collapsed.
func remove_piece(piece: Node3D, refund_to: Node3D = null) -> int:
if piece.item_id == "bed" and spawn_bed() == piece:
if piece.is_in_group("bed") and spawn_bed() == piece:
has_bed_spawn = false
Actions.notify.emit("Spawn point cleared")
var was_build := piece.is_in_group("build")

View File

@@ -83,6 +83,17 @@ func _do(cmd: Dictionary) -> Dictionary:
var ok: bool = pk != null and root.get_node("Actions").pickup(player, pk)
await create_timer(0.6).timeout
return {"asked": ok, "inv": player.inventory.to_data()}
"drop":
# Q on the first stack of cmd.item (count 0: the whole stack).
var slot := -1
for i in range(player.inventory.size()):
var st = player.inventory.get_slot(i)
if st != null and st.id == String(cmd.item):
slot = i
break
var asked: bool = slot >= 0 and root.get_node("Actions").drop(player, slot, int(cmd.get("count", 0)))
await create_timer(0.6).timeout
return {"asked": asked, "inv": player.inventory.to_data()}
"chest_take":
var c: Node3D = WN.pieces.get(int(cmd.nid))
if c == null:

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1
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_boss 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_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_audio 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_boss 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_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_terrain_edges test_boundary; do
extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever,
@@ -22,6 +22,10 @@ for t in test_survival_core test_survival_stats test_save_load test_weather_buil
if [ $code -ne 0 ] || printf '%s\n' "$out" | grep -q "SCRIPT ERROR"; then
echo "FAIL $t $result"
printf '%s\n' "$out" | grep -E "FAIL|SCRIPT ERROR|at: " | head -n 20
# The whole output, for failures that don't happen twice.
mkdir -p build/test-logs
printf '%s\n' "$out" > "build/test-logs/$t.log"
echo " (full output: build/test-logs/$t.log)"
status=1
else
echo "ok $t $result"

234
tests/test_items_more.gd Normal file
View File

@@ -0,0 +1,234 @@
extends SceneTree
## Uses for every material (the clay jar, bedroll, wolf-pelt rug, fang
## necklace, bear cloak, pemmican, and rotten food as bait), dropping items
## (Q), and picking from any bag row with 1-8 (K / M change row).
## Run: godot --headless --path . --script res://tests/test_items_more.gd
var _fails := 0
var _finished := false
var A: Node
var S: Node
var DB: Node
var world: Node3D
var player: CharacterBody3D
var inv: Inventory
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")
S.root_dir = "user://test_worlds"
for w in S.list_worlds():
S.delete_world(w.id)
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 _ground(p: Vector3) -> Vector3:
return Vector3(p.x, world.terrain.sample_height(p.x, p.z), p.z)
func _place(id: String, at: Vector3) -> Node3D:
return world.spawn_piece(DB.get_item(id), Transform3D(Basis(), _ground(at)))
func _body() -> void:
print("every material has a use")
var items: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://data/items.json"))
var recipes: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://data/recipes.json"))
var used := {}
for r in recipes.recipes:
for k in r.inputs:
used[k] = true
for i in items.items:
for k in i.get("repair", {}).get("inputs", {}):
used[k] = true
for k in i.get("bow", {}).get("ammo", []):
used[k] = true
if i.has("fuel"):
used[i.id] = true
# (used by a machine or a place rather than a recipe)
for k in ["iron_ore", "iron_nugget", "charcoal", "barrow_key", A.BAIT]:
used[k] = true
var idle: Array = []
for i in items.items:
if i.category == "resource" and not i.get("hidden", false) and not used.has(i.id):
idle.append(i.id)
check(idle.is_empty(), "no material is only good for filling your bag %s" % [idle])
var made := {}
for r in recipes.recipes:
made[r.output] = true
var missing: Array = []
for id in ["clay_jar", "bedroll", "pelt_rug", "fang_necklace", "bear_cloak", "pemmican"]:
if not made.has(id) or DB.get_item(id).is_empty() or DB.mesh_for(id) == null:
missing.append(id)
check(missing.is_empty(), "the six new things each have a recipe and a model %s" % [missing])
S.current_id = S.create_world("Items", 1337, {})
world = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(5):
await physics_frame
player = world.player
player.set_physics_process(false)
player.survival.set_process(false)
world.day_night.frozen = true
world.weather.set_process(false)
world.weather.force("clear")
world.creatures.enabled = false
if world.wolves != null:
world.wolves.enabled = false
inv = player.inventory
var here := player.global_position
print("the clay jar")
var jar := _place("clay_jar", here + Vector3(3, 0, 0))
var chest := _place("chest", here + Vector3(-3, 0, 0))
check(jar != null and jar.inventory.size() == 6, "a clay jar holds 6 stacks")
jar.inventory.add("raw_meat", 1)
chest.inventory.add("raw_meat", 1)
jar.inventory.spoil()
chest.inventory.spoil()
var keep: float = Inventory.keeps_for("raw_meat")
var c0 := Inventory.clock
for k in range(1, 31): # a day and a half, in steps
Inventory.clock = c0 + keep * 1.5 * k / 30.0
jar.inventory.spoil()
chest.inventory.spoil()
check(chest.inventory.count("rotten_food") == 1, "raw meat in a chest is rotten after 1.5x its keep time")
check(jar.inventory.count("raw_meat") == 1, "in a clay jar it's still good")
var left: float = Inventory.hours_left(jar.inventory.get_slot(0))
# 1.5 keep-times in a jar ages it by half of one: half its life left
# (counted as if it came out now; in the jar that lasts three times as long).
var expect: float = keep * 0.5 / Inventory.day_seconds * 24.0
check(absf(left - expect) < 0.5, "with half its life left (%.0f h, expected %.0f h)" % [left, expect])
Inventory.clock = c0
print("the bedroll")
var roll := _place("bedroll", here + Vector3(0, 0, 6))
var bed := _place("bed", here + Vector3(0, 0, -6))
world.day_night.time = 22.0 / 24.0
world.day_night.apply(true)
check(roll.is_in_group("bed") and roll.open_air(), "a bedroll is a bed you can use outdoors")
check(world.sleep_block_reason(roll).is_empty(), "under the open sky on a clear night: you can sleep")
check(world.sleep_block_reason(bed).contains("roof"), "(a bed out in the open still needs a roof)")
world.weather.force("rain")
for _i in range(3):
world.weather._process(30.0) if world.weather.has_method("_process") else null
world.weather.precip = 1.0
check(not world.sleep_block_reason(roll).is_empty(), "in the rain: not out here")
world.weather.force("clear")
world.weather.precip = 0.0
player.global_position = roll.global_position + Vector3(1.0, 0, 0)
check(A.use_bed(player, roll) and roll.is_spawn(), "E on it: your spawn point")
world.day_night.time = 10.0 / 24.0
world.day_night.apply(true)
print("the fang necklace")
player.survival.health = 100.0
player.survival.damage(10.0, here, "a wolf")
check(absf(player.survival.health - 90.0) < 0.01, "without it a wolf's bite is 10")
inv.add("fang_necklace", 1)
player.survival.health = 100.0
player.survival.damage(10.0, here, "a alpha wolf")
check(absf(player.survival.health - 93.0) < 0.01, "with it in your bag the alpha's is 7")
player.survival.health = 100.0
player.survival.damage(10.0, here, "a boar")
check(absf(player.survival.health - 90.0) < 0.01, "(a boar's isn't softened)")
inv.remove("fang_necklace", 1)
print("the bear cloak, the rug and pemmican")
inv.add("bear_cloak", 1)
var slot := -1
for i in range(inv.size()):
if inv.get_slot(i) != null and inv.get_slot(i).id == "bear_cloak":
slot = i
check(A.wear(player, slot) and player.worn == "bear_cloak", "you can wear the bear cloak")
var warmest := 0.0
for i in items.items:
warmest = maxf(warmest, float(i.get("wear", {}).get("warmth", 0)))
check(float(DB.get_item("bear_cloak").wear.warmth) == warmest, "the warmest cloak there is")
check(_place("pelt_rug", here + Vector3(4, 0, 4)).is_in_group("rug"), "a wolf-pelt rug is a rug (Cozy, like the fur rug)")
player.global_position = here
var fire := _place("campfire", here + Vector3(1.5, 0, 0))
fire.fuel = fire.MAX_FUEL
inv.add("cooked_meat", 2)
inv.add("berries", 3)
inv.add("bear_fat", 1)
check(A.craft(player, "pemmican") and inv.count("pemmican") == 3, "pemmican at the campfire (3 from 2 cooked meat, 3 berries, a lump of fat)")
var d0: Dictionary = player.survival.diet.duplicate()
player.survival.hunger = 30.0
for i in range(inv.size()):
if inv.get_slot(i) != null and inv.get_slot(i).id == "pemmican":
A.eat(player, i)
break
check(player.survival.diet.meat > d0.meat and player.survival.diet.plants > d0.plants, "it counts as meat and greens")
print("bait")
var B: Script = load("res://scripts/fishing/bobber.gd")
var bob: Node3D = B.new()
bob.player = player
bob.world = world
bob.table = {"bite_wait": [10.0, 10.0], "fish": [{"id": "perch", "weight": 1}]}
var plain: float = bob._next_wait()
inv.add("rotten_food", 2)
var baited: float = bob._next_wait()
check(absf(baited - plain * A.BAIT_WAIT) < 0.01 and bob.baited, "rotten food in your bag: bites come sooner (%.0f s, not %.0f s)" % [baited, plain])
bob.free()
print("dropping (Q)")
var dropped0: int = world._dropped.get_child_count()
inv.add("stone", 5)
for i in range(inv.size()):
if inv.get_slot(i) != null and inv.get_slot(i).id == "stone":
slot = i
break
check(A.drop(player, slot, 1) and inv.count("stone") == 4, "shift+Q drops one")
check(A.drop(player, slot) and inv.count("stone") == 0, "Q drops the rest of the stack")
check(world._dropped.get_child_count() == dropped0 + 2, "both lie on the ground in front of you")
var rot0: int = inv.count("rotten_food")
for i in range(inv.size()):
if inv.get_slot(i) != null and inv.get_slot(i).id == "rotten_food":
A.drop(player, i)
check(rot0 > 0 and inv.count("rotten_food") == 0, "and at last you can get rid of rotten food")
inv.add("stone_axe", 1)
for i in range(inv.size()):
if inv.get_slot(i) != null and inv.get_slot(i).id == "stone_axe":
inv.get_slot(i).dur = 7
slot = i
A.drop(player, slot)
var axe: Node3D = world._dropped.get_child(world._dropped.get_child_count() - 1)
check(axe.item_id == "stone_axe" and int(axe.extra.get("dur", -1)) == 7, "a worn tool keeps its wear on the ground")
print("bag rows on 1-8 (K / M)")
inv.load_data(Inventory.new(inv.size()).to_data())
inv.set_slot(11, {"id": "torch", "count": 1})
player.select(0)
player.select_in_row(3)
check(player.selected == 3 and player.row() == 0, "4 on the top row: slot 4")
player.change_row(1)
check(player.selected == 11 and player.row() == 1 and player.held_item_id() == "torch", "M: the row below, same key: you're holding its torch")
player.select_in_row(8)
check(player.selected == 8, "the wheel wraps round within the row")
player.select(0)
player.change_row(-1)
check(player.row() == player.rows() - 1 and player.rows() == 5, "K from the top row: the bottom one (5 rows)")
var SP: Script = load("res://scripts/net/server_player.gd")
var sp: Node = SP.new()
sp.select(27)
check(sp.selected == 27, "a server lets you hold from any row")
sp.free()
world.queue_free()
await process_frame
_finished = true

View File

@@ -318,6 +318,11 @@ func _wolves() -> void:
check(wolves.size() >= 2, "Alice sees the pack: %d wolves, from the server" % wolves.size())
check(PN.hurts > a0, "the wolves bite Alice (%d)" % (PN.hurts - a0))
check(bob_hurts > b0, "and Bob, 10 m away, at the same time (%d), after %.0f s" % [bob_hurts - b0, t])
if bob_hurts == b0:
# (rare, and not reproduced: say what each wolf was doing, for next time)
for w in wolves:
print(" wolf: %s, %.1f m from Alice, %.1f m from Bob" % [w.state,
(w as Node3D).global_position.distance_to(player.global_position), (w as Node3D).global_position.distance_to(b)])
check(player.survival.health < 100.0, "Alice's health is down on her screen (%.0f)" % player.survival.health)
# --- helpers --------------------------------------------------------------------

View File

@@ -382,6 +382,18 @@ func _items_on_the_ground() -> void:
check(brep.get("sounds", []).has("pickup"), "and so does Bob")
await _sleep(0.3)
check(WN.dropped.is_empty() or not WN.dropped.has(int(key.substr(1))), "and it's gone for Alice")
print("dropping (Q)")
var dr := await _bob({"do": "drop", "item": "flint", "count": 1})
check(dr.get("asked", false) and _count(dr.get("inv", []), "flint") == 1, "Bob drops one flint (shift+Q): the server takes it from his bag")
var seen := false
t = 0.0
while not seen and t < 3.0:
for k in WN.dropped:
if WN.dropped[k].item_id == "flint" and int(WN.dropped[k].amount) == 1:
seen = true
await _sleep(0.05)
t += 0.05
check(seen, "and it lands in the shared world: Alice sees it at his feet")
print("first come, first served")
var pk := _nearest(world.props.pickups.values().filter(func(p): return not p.taken))

27
tools/site/dump_data.gd Normal file
View File

@@ -0,0 +1,27 @@
extends SceneTree
## Writes the game's own reference data as JSON, for the help pages
## (tools/site/make_guide.py): creatures (creature.gd KINDS), server settings
## (server_config.gd DEFAULTS and which can be changed live), and the fish.
## Items and recipes come straight from data/*.json.
## godot --headless --path . --script res://tools/site/dump_data.gd -- <out.json>
func _initialize() -> void:
var out_path := "user://site_data.json"
var args := OS.get_cmdline_user_args()
if not args.is_empty():
out_path = args[0]
var C: Script = load("res://scripts/creatures/creature.gd")
var SC: Script = load("res://scripts/net/server_config.gd")
var consts: Dictionary = SC.get_script_constant_map()
var d := {
"creatures": C.get_script_constant_map().get("KINDS", {}),
"server_defaults": consts.get("DEFAULTS", {}),
"server_public": consts.get("PUBLIC", []),
"server_settable": consts.get("SETTABLE", {}),
"version": String(ProjectSettings.get_setting("application/config/version", "")),
}
var f := FileAccess.open(out_path, FileAccess.WRITE)
f.store_string(JSON.stringify(d, "\t"))
f.close()
print("wrote ", out_path)
quit(0)

View File

@@ -0,0 +1,413 @@
## Getting started {#getting-started}
Emberwild drops you on a wooded island ringed by mountains, with a lake in the middle and sea cliffs on one side. You start with nothing: everything comes from the island.
### Installing
- **Mac:** unzip and move Emberwild to Applications. It isn't notarised by Apple, so the first time, right-click it and choose **Open** (or System Settings > Privacy & Security > Open Anyway).
- **Windows:** unzip and run `Emberwild.exe`. Keep `Emberwild.pck` in the same folder or the game won't start. It's unsigned, so if SmartScreen says "Windows protected your PC", choose **More info**, then **Run anyway**.
### The title screen
- **Continue** picks up your most recent world.
- **Tutorial** starts a guided first game, or carries it on.
- **New World** asks for a name, a seed and how fast food spoils. A blank seed gives a random island; any number or word gives a repeatable one; 1337 is the original island. Food spoils **Normally** (raw meat lasts 2 days), **Slowly** (twice as long) or **Never**.
- **Load World** lists your worlds with their seed and time played. **Delete** asks you to click again first.
- **Host Game** runs a server on your computer and puts you in it.
- **Join Game** connects to someone else's server.
- **Profile** sets your name and look for multiplayer.
The version number is at the bottom right, with a card there when a newer release is out.
### The tutorial with Old Tam
The tutorial takes about 20 minutes. Old Tam, a hermit who has lived on the island longer than he'll admit, walks you through five chapters: first tools, fire and food, hunting, fishing, and shelter and night. Each step is a real goal (gather sticks and stones, craft an axe, roast a mushroom, hunt a rabbit, build a hut, sleep) and ticks off as soon as you've done it. Nothing is on a timer.
- `H` hides or shows his panel.
- `Enter` moves on from steps that only want you to read.
- The pause menu can skip a step, or leave the tutorial to carry on later.
The tutorial island is calm: no wolf pack (just one demonstration wolf), no bear, no thieves and no barrow guards. At the end you can **Start your own world** or **Keep exploring**.
## Controls {#controls}
- `W` `A` `S` `D` move, the mouse looks.
- `Shift` sprints (uses stamina).
- `Space` jumps, or swims up. At the water's edge it kicks you up onto the bank.
- `E` interacts: pick up, open, add fuel, butcher, pick berries, open doors, use a bed.
- `Left click` uses what you hold: swing a tool or weapon, place a piece, cast or reel in a fishing line.
- Hold `Left click` with a bow to draw, release to shoot.
- `Right click` eats held food, puts on a held cloak, or throws a held spear.
- `R` rotates the piece you're placing (building pieces in quarter turns, other things in smaller steps).
- `X` dismantles the piece you're looking at.
- `1` to `8` or the mouse wheel pick a hotbar slot.
- `K` and `M` move the hotbar up and down your bag's rows, so `1` to `8` pick from the row above or below. The hotbar shows which row you're on.
- `Q` drops what you're holding at your feet; `Shift` and `Q` drops just one.
- `Tab` or `I` opens your inventory and crafting.
- In the inventory: `Left click` picks up or puts down a stack, `Right click` splits a stack or puts down one, `Shift` + click moves a stack to or from a chest, `Shift` + click on **Craft** makes up to five.
- `Esc` closes the inventory, or opens the pause menu.
- `Enter` respawns from the death screen, and continues tutorial steps that just want reading.
- `H` hides Old Tam's tutorial panel or tip card.
- `T` opens chat on a server.
## Staying alive {#survival}
Four bars sit at the bottom left: **Health**, **Stamina**, **Food** and **Warmth**, each out of 100, with three short **Diet** bars below for meat, fish and greens. Your statuses show as words above them. The clock and weather are top right.
### Health and stamina
Health comes back slowly on its own when you're fed (Food above 40), warm (Warmth above 35), not short of any diet group, and haven't been hurt for 6 seconds. It drains while you're starving, freezing or malnourished.
Sprinting, jumping, swinging, drawing a bow, throwing and casting cost stamina, which comes back a second after you stop. When you're spent, actions fail with "Too tired".
### Food and diet
A full stomach empties in about a day and a quarter, faster when you're cold. Under 25 you're **Hungry**; at zero you're **Starving** and losing health. You can't eat when you're nearly full.
Each food also tops up one diet group, and each group runs down from full in about a day and a half:
- **One group low** (its bar blinks): **Poor diet**. You stop healing naturally.
- **Two or more low**: **Malnourished**. Health slowly drains and stamina recovers more slowly. Berries alone will kill you.
- **All three kept well up**: **Balanced diet**, and you heal half as fast again.
### Warmth
Warmth drifts towards what your surroundings allow, rising quickly and falling slowly.
- The air is mild by day and cold at night: a night out in the open leaves you **Cold**.
- The higher you climb, the colder it gets.
- Cloud, rain, storms, snow and blizzards each take more warmth away.
- A lit campfire or hearth makes you **Warm**. A held torch gives a little warmth.
- Cloaks add warmth: the hide cloak +15, the wolf cloak +25, the alpha cloak +35 and the bear cloak +40. They also cut the chill of being wet, the better ones more so.
- A proper shelter keeps most of the weather out and adds a little warmth. Mostly stone walls add a bit more, and a fur rug more again.
Below 30 you're **Cold** and get hungry faster. Below 12 you're **Freezing** and losing health.
### Wet
Rain soaks you without a roof overhead, and so does waist-deep water. **Wet** lasts a minute and a half after the soaking stops and costs a lot of warmth. A fire dries you three times as fast. Snow doesn't wet you.
### Sheltered, Rested and Cozy
- **Sheltered:** a roof overhead and walls in at least six of eight directions within 8 m. Windows count as wall.
- **Rested:** 20 seconds sheltered close to a fire makes you Rested for 5 minutes: stamina comes back 50% faster. A night's sleep gives 10 minutes.
- **Cozy:** sheltered near a fur rug or a wolf-pelt rug. A little warmer, and Rested twice as fast.
### Food poisoning
Raw meat (about a third of the time) and raw fish (about a fifth) can make you ill. For a minute stamina comes back at half speed and you can't eat. Cooked, smoked and salted food is safe. Raw meat also costs a little health, but never kills you by itself.
### Death and graves
The death screen says what killed you. Everything you carried, cloak included, goes into a **grave** where you fell (on the nearest shore if you died in the lake). After 3 seconds you can respawn: at your bed if it's still standing, otherwise at the island's start, with half health, half a stomach and your diet topped up to a safe level. Press `E` on the grave to take your things back; it crumbles once empty.
## Food {#food}
### Eating and buffs
Hold food and `Right click` to eat one. Food fills your stomach, may restore health and tops up its diet group. Cooked meat also gives **Well fed** for 10 minutes: faster stamina and double healing.
### Foraging
- **Berry bushes** grow along forest edges and in meadows. `E` picks 2 to 4 berries and a little fibre; the bush is then bare for 10 minutes.
- **Mushrooms** grow in deep forest. Fine raw, much better roasted.
Both count as greens.
### Cooking
Stand near a lit campfire or stone hearth and open your inventory: the **Cooking** tab shows what you can make. Raw meat cooks, mushrooms roast, perch and trout grill. A fire that has gone out cooks nothing.
### Spoilage
Food goes off. Its slot shows a bar running from green to brown, and the tooltip says Fresh, Going off or Nearly rotten, with the time left. At the normal setting:
- raw meat and fish keep 2 days,
- berries 3 days and mushrooms 4,
- cooked meat and grilled fish 4 days, roasted mushrooms 6,
- smoked meat and fish 12 days,
- salted meat and fish 20 days.
Food rots wherever it is, chests included, but in a **clay jar** (8 clay, fired at a campfire; 6 stacks) it lasts three times as long. Adding fresh food to an older stack gives a stack somewhere in between. Food on the ground disappears when it rots. Rotten food can't be eaten, but carried while fishing it's bait (see Hunting and fishing), and `Q` drops it. A world's spoilage setting can make food last twice as long, or never spoil.
**Pemmican** (2 cooked meat, 3 berries and a lump of bear fat at a campfire, makes 3) is the trail food: filling, counts as both meat and greens, and keeps 30 days.
### Smoking rack and salt
A **smoking rack** is a fire with a rack over it. Open it with `E`, put in raw meat or fish and some wood, and every minute it turns two raw meat (or two perch, or one trout) and a wood into two smoked. It keeps working while you're away and overnight.
**Salt** crusts the rocks on the cliff tops above the sea. Two raw meat (or two perch, or one trout) and a salt make two salted, by hand with no station. Salted food is safe as it is and keeps longest.
## Gathering and crafting {#gathering-and-crafting}
### Pickups
Look at something on the ground and press `E`. Sticks lie under trees, stones everywhere, flint mostly near water and on rocky ground, feathers in open meadows. Wild flax, the pale tall plants, gives 2 fibre a clump. There's always a little of each near the start. Further out you'll find iron nuggets in the scree at the foot of the mountains, clay on the lake shore and salt on the sea cliffs.
### Trees and boulders
Hold an axe and `Left click` a tree. It takes 5 hits with a stone axe and gives 4 to 6 wood and a few sticks as it falls. Boulders need a pickaxe: 6 hits with a stone one, a stone every hit, then more stone and some flint when it breaks. Rust-streaked boulders also give iron ore. Iron tools hit twice as hard and the antler pickaxe three times. Every swing costs stamina.
### Regrowth
While you play (and while you sleep) the island grows back: ground pickups after 15 minutes, felled trees after 40, broken boulders after an hour, berry bushes 10 minutes after picking. Nothing regrows right beside you or under something you've built, and building over sticks, stones and plants clears them for good.
### Crafting and stations
`Tab` opens your inventory beside the crafting list. Recipes you can't make yet are dimmed, with what's missing in red, and the panel says which stations are near.
The basics (stone axe and pickaxe, club, torch, campfire, workbench, charcoal kiln, smoking rack, salted food) need no station. Everything else needs one within about 6 m:
- **Workbench:** better tools and weapons, chests, the bed, cloaks, rugs, wooden building pieces, and the smelter, forge and stonecutter.
- **Lit campfire or stone hearth:** cooking.
- **Forge:** iron tools and weapons, iron arrows, nails, the lantern and the iron-banded door.
- **Stonecutter:** cut stone and all stone pieces.
### Wear and repair
Tools and weapons wear with use and break when they run out. A bar under the slot shows what's left, and you're warned when it's nearly gone. Repair from the inventory screen, at the station it names, for a little of what it's made of: stone tools and wooden weapons at a workbench, iron tools and the antler pickaxe at the forge.
### Inventory and chests
You carry 40 slots in five rows of 8. The hotbar shows one row at a time: `K` and `M` change which, and you can hold anything from it. A **wooden chest** holds 16 stacks and a **clay jar** 6; both must be empty before you dismantle them. `Q` drops what you're holding.
## Building {#building}
### Placing and snapping
Select a piece on your hotbar and a ghost shows where it will go: green if it fits, red if not, with the reason in the prompt. `Left click` places it, `R` turns it.
Building pieces (floors, walls, roofs, gables, doors, windows, stairs, posts, palisades) are 2 m on a side and **snap** to each other: aim near a piece and the ghost lines up with it. On open ground a floor sits level and a wall sits flush. You can't build in water. A roof rises 1 m over 2 m, so two from opposite walls of a 4 m hut meet at the ridge; gables close the ends.
### Support
Look at a piece to see its **support**. Anything on the ground has full support; each piece above or beside takes support from the best piece it's joined to, losing a little going up and a lot going sideways. Below 30% a piece can't be placed, and pieces left without support collapse back into your inventory. A hut, a second storey or a short overhang works; a bridge in mid-air doesn't. Posts and pillars carry their load straight down.
### Wood and stone
Stone loses much less support going up, so it builds tall. Wood can sit on stone, but stone can't stand on wood. A room walled mostly in stone is a little warmer.
### Dismantling
`X` takes a piece back into your inventory. Chests, kilns, smelters and racks must be emptied first. Graves can't be dismantled.
### Doors
`E` opens and closes a door. Wolves can't bite through a closed door or wall. On a wolf moon the alpha can batter a wooden door in; mend it with `E` for 2 wood. An iron-banded door can't be broken.
### Shelter, fires and rugs
You're **Sheltered** with a roof overhead and walls in at least six of eight directions. A roof alone keeps the rain off you and your fire. A campfire works fine indoors. The **stone hearth** warms a room half as wide again as a campfire, its chimney keeps the rain out, and it cooks. A **fur rug** in a sheltered room makes you Cozy.
### Beds
`E` on a bed makes it your spawn point. Once it's yours, `E` sleeps until 06:00, if it's between 19:30 and 05:30, the bed is sheltered, and no wolves are prowling within 40 m. A **bedroll** (3 hide, 6 feathers, 4 fibre) works the same but needs no shelter: you can sleep under the open sky, as long as it isn't raining or snowing. While you sleep, fires burn, kilns and smelters work, things regrow and food spoils. You wake dry, warm, full of stamina, Rested and a little hungrier, and the game saves.
## Weather and seasons {#weather-and-seasons}
### Day and night
A day lasts 20 real minutes, starting on day 1 at 08:00. The sun rises about 05:00 and sets about 19:00. For sleeping and for wolves, night is 19:30 to 05:30.
### Weather
Over many days it's roughly 40% clear, 30% cloudy, 20% rain and 10% storm. Fronts roll in over a minute or so, and a new world's first 10 minutes are always clear. Rain makes an uncovered campfire burn faster, up to four times as fast in a storm.
### Cold days and the snowline
About one day in four (never the first) is a **cold day**: colder air, snow instead of rain, a blizzard instead of a storm. Snow settles over a few minutes and only melts on a milder day. High on the mountains it always snows, and it's colder still.
### Hard winters
Every 12 days comes a **hard winter**: three bitter days in a row (days 12 to 14, 24 to 26, and so on), announced the day before. Every day is extra cold, snow and blizzards are likelier, only half as many animals are about, and the lake's edge freezes: you can walk on the ice but not fish through it.
### Blizzards
A blizzard cuts visibility right down and its wind strips your warmth twice as fast, even by day. Shelter keeps it out.
### Lightning
In a storm, roughly once every minute or two, lightning strikes a tree near someone, preferring tall trees near the rim. It burns for 40 seconds (warm yourself by it), then falls, leaving 2 charcoal. Buildings are never struck.
### Snow on roofs
Snow piles up on roofs as it settles and melts off with it. It's only for looks.
## Wildlife {#wildlife}
Animals live around you, appearing out of sight 30 to 70 m away: up to 6 rabbits, 4 deer and 3 boars at once. You need a weapon (or at least an axe or pickaxe) to hurt anything. A carcass stays for 5 minutes.
### Rabbits, deer and boars
Rabbits live in meadows and zig-zag when they bolt. Deer keep to forest edges, notice you from further off and outrun your sprint. Sprinting makes you noisy, so walk when stalking. Boars live deep in the woods and ignore you until hit; then they charge and gore you until badly hurt. All give meat; deer and boars give hide.
### Wolves and the den
The wolves' den is a cave of piled boulders at the foot of the mountains.
- **Days 1 and 2:** the den is empty.
- **Day 3:** wolves move in, resting by day and howling at night.
- **From night 4:** the pack hunts, going home at dawn. It's 2 wolves at first, 3 from day 6, 4 from day 9. Wolves you kill are replaced while nobody's watching.
By day, wolves watch you within 14 m of the den and attack within 8 m. At night the pack circles you and takes turns darting in, one at a time. Within 8 m of a lit campfire or hearth they circle wide and never close in. A torch or lantern in hand makes them come in far less often. A lone wolf keeps its distance, and a badly hurt one runs off for the night. Wolves give meat and pelts.
### The wolf moon and the alpha
Every 7th night (days 7, 14, 21...) the moon turns red and an **alpha** hunts with the pack. It has three wolves' health, bites harder, ignores fire, doesn't take turns and doesn't run. If it can't reach you it batters the nearest closed door: a wooden one gives way after about five blows. It leaves at dawn, dead or not, and drops meat, an alpha pelt and wolf fangs. Its pelt makes the alpha cloak or, with two fangs, a **fang necklace**: carried anywhere in your bag, it makes every wolf's bite, the alpha's too, 30% weaker.
### The bear
One bear lives in the densest forest near the rim. By day it ambles round its patch; at night it sleeps there. It won't start a fight but rears up and warns you inside 8 m, and charges if you then come within 6 m, or if you hit it. Badly hurt, it runs home.
From day 3 it raids food: a chest with food in it, or food on the ground, within 60 m of it, with nobody within 12 m. It eats every bit of food and wanders off. It can't reach what it can't walk to and doesn't break things, so a closed hut or palisade ring keeps it out. Killed, it's back 3 days later. It drops lots of meat, a bear hide and bear fat, which burns three times as long as wood.
### Crows and foxes
Food on the ground that nobody has been within 10 m of for 20 seconds draws a thief. By day a crow pecks the stack away bit by bit, scared off if you come within 7 m. At dusk and night a fox grabs the whole stack and runs; come within 10 m as it creeps in and it bolts empty-mouthed, or kill it to make it drop the food. Food in a chest is safe from both.
## Hunting and fishing {#hunting-and-fishing}
### Weapons
- **Wooden club:** quick to make and hard-hitting. Blunt, so 50% more against skeletons.
- **Spear:** `Left click` thrusts with long reach, `Right click` throws for more damage; pick it up where it lands. On its last use it breaks where it lands. The iron spear is much deadlier.
- **Crude bow:** hold `Left click` to draw (just under a second for full power) and release. The fuller the draw, the faster and harder the arrow; a barely drawn bow fires nothing. Iron arrows are used first and do half as much again. Arrows that miss can be picked up.
- **Axes and pickaxes** make poor weapons, though the iron axe hits well.
### Butchering
`E` on a carcass gives its meat, hide and so on, plus any arrows stuck in it. What doesn't fit drops at your feet.
### Fishing
Hold a fishing rod and `Left click` to cast into the lake. The float needs water at least half a metre deep. The sea has no fish. Fish swimming near the surface, and the odd one jumping, show you where to cast.
- A bite comes after 5 to 14 seconds, sooner at dawn and dusk and a little sooner in rain.
- **Bait:** with rotten food anywhere in your bag, bites come 40% sooner, and each bite uses one.
- When the float dips you have a second and a half to click. Miss and it gets away.
- More than 26 m from the float and the line snaps. Switching away from the rod reels in.
Perch are the usual catch. Trout are rarer but bite much more often at dawn (05:00 to 08:30) and dusk (17:00 to 20:30).
## Iron and progression {#iron-and-progression}
### Finding iron
Iron nuggets lie in the scree at the foot of the mountains. Rust-streaked boulders, commonest near the rim, give iron ore to a stone pickaxe. Clay comes from the lake shore. The barrow holds bars and nuggets.
### Smelting
- A **charcoal kiln** (stone and clay, by hand) turns two wood into one charcoal every 20 seconds.
- A **smelter** (at the workbench) turns one iron ore and one charcoal into an iron bar every 30 seconds. Three nuggets can stand in for one ore.
- A **forge** (six iron bars, at the workbench) is the station for everything iron.
Kilns, smelters and smoking racks work on their own while you're away, and glow while they work. Take the results out of the same slots.
### What iron gets you
Iron axes and pickaxes take about half the hits and last far longer. The forge makes the iron spear, iron arrows, the lantern, nails (for wooden stairs, windows and palisades) and the iron-banded door. The **stonecutter** turns stone into cut stone and makes every stone piece, including the stone hearth.
### The barrow
Out on open ground stands a burial mound in a ring of standing stones, with a passage to a chamber and a stone coffin. Three skeletal **barrow guards** stand inside. They wake if someone enters the stone ring at night (or any time after day 10), when the coffin is opened, or when one is hit. They're slow enough to walk away from but hit hard, ignore fire, and won't follow you far. A club does them extra damage.
`E` on the coffin takes 1 to 3 iron bars, 3 to 6 nuggets and one **barrow key piece**. It fills again 5 days after it's emptied, early one morning with nobody near, and fallen guards return then too.
### The Old Stag
A ring of standing stones at the foot of the rim has an altar with three hollows for barrow key pieces, so you need three coffin trips. Set them in with `E`, over as many visits as you like. With all three in, `E` at night calls the **Old Stag**, a huge antlered spirit.
- It circles, lowers its antlers as a warning, then charges straight through where you stood. Step aside.
- After a charge it's stunned for a moment and takes half as much again from your hits.
- At two thirds health it calls three spirit wolves out of the mist.
- At one third it calls two more, moves faster and stamps the ground if you stay close.
It doesn't run and ignores fire. If dawn comes, or everyone leaves or dies, it fades away and the key pieces stay in the altar for another night. Beat it and it drops its antler and a trophy you can place; the key pieces are used up. At the forge, the antler, an iron bar and some wood make the **antler pickaxe**: faster than iron, longer-lasting and lighter to swing.
## Multiplayer {#multiplayer}
### Your profile and identity
Set a name (3 to 16 letters, digits, spaces, `_` or `-`), tunic colour, hair colour and beard under **Profile** before joining. There are no accounts: the game makes a private key on first run that never leaves your computer, and servers know you by it. A server keeps your name for you.
### Hosting
**Host Game** starts a server on your computer and puts you in. Pick a server name, a rules preset, an optional password, the port (24242 by default) and, the first time, a seed. As its first player you're its admin. A message tells you the addresses friends can join at. Leaving saves the island and stops the server; next time it carries on. On a Mac, allow incoming connections if asked.
### Dedicated servers
A spare computer can run an always-on server with no window, set up in its `server.json`. The first player to join a new server becomes its admin.
### Joining
**Join Game** takes an address (`host` or `host:port`, default port 24242) and a password if needed. On Tailscale, use the server's 100.x address or machine name. A lobby shows the server's message, rules and players, then the island loads. Your game and the server must be the same version; if not, you're told which side needs updating.
### Playing together
The server decides everything, so the pause menu doesn't pause; it lists who's online and has **Disconnect**. Each player has their own bed. Only you can open your grave. Looking at someone's piece shows whose it is. More players means more animals about, and the wolf pack splits between the players it hunts, preferring those away from a fire. The night passes once enough players are in bed.
### Rules and presets
- **Co-op:** no PvP, no raiding, locks for owner and friends, a grave when you die.
- **Friendly rivals:** as co-op, but with PvP.
- **Adversarial:** PvP and raiding on, locks for the owner only, and your things scatter on the ground when you die.
The rules themselves:
- **pvp:** players can hurt each other.
- **raiding:** players can dismantle each other's pieces.
- **locks:** who may use chests, doors and beds: `off`, `owner` or `owner_and_friends`.
- **death_penalty:** `grave`, `keep` (keep everything) or `drop_all` (scattered for anyone).
- **sleep:** who must be in bed for the night to pass: `all_online`, `half`, `anyone`, or `off`.
- **difficulty:** `easy`, `normal` or `hard`: hunger and diet drain, animal damage, and one fewer or one more wolf.
- **rot:** `normal`, `slow` or `off`.
- **wolf_grace_days:** days before wolves move in (3 by default, 0 to 10); they hunt the night after.
- **pause_when_empty:** the clock stops when nobody's on.
A server also sets its own day length.
### Chat and commands
`T` opens chat. Plain text goes to everyone; `/` starts a command.
- Anyone: `/help`, `/players`, `/rules`, `/friend <name>` (they may use your doors, chests and beds), `/unfriend <name>`, `/friends`.
- Admins: `/kick <name>`, `/ban <name>`, `/unban <name>`, `/admin <name>`, `/save`, `/backup`, `/backups`, `/time 08:00`, `/weather clear|cloudy|rain|storm|snow|blizzard|auto`, `/set <rule> <value>`, `/say <message>`, `/release-name <name>`.
A dedicated server's terminal takes the same commands without the `/`.
### The Old Stag with friends
Each extra player at the stones when it's called gives the Old Stag 60% more health and one more spirit wolf (up to four more). Everyone who struck it is named when it falls.
## Settings and sound {#settings-and-sound}
### The pause menu
`Esc` opens it (and pauses your own world). It has **Resume**, **Controls**, **Fullscreen**, **Mouse sensitivity**, volumes for **Music**, **Sounds**, **Ambience** and **Old Tam's voice**, **Old Tam's tips** on or off with **Show again**, **Check for updates at start**, and **Save & Quit** to the title or desktop. The game autosaves every 2 minutes and when you sleep. In the tutorial it also offers **Skip this tutorial step** and **Leave the tutorial**.
### Old Tam's tips
In your own worlds Old Tam offers a one-off tip the first time something comes up: cold, freezing, soaked, hungry, a poor diet, food poisoning, rotten food, nightfall, the first howl, a red moon, snow, storms, a hard winter, running out of stamina, your first shelter, your first death. Each shows once, under the clock for about 14 seconds. `H` puts it away.
### Music and sound
The music follows what's happening: calm by day, a night theme after dark, a storm theme in storms, blizzards and wolf moons, danger music when something is coming for you, and a boss theme for the Old Stag. Between pieces there's ambience: birds by day, crickets and owls at night. Indoors everything is quieter, and the music dips while Old Tam speaks.
### Updates
With the update check on, the title screen tells you when a newer Emberwild is out and offers a **Download** button or the release page.
## Tips for your first days {#tips}
- Gather sticks, stones, flint and fibre straight away and craft a stone axe and pickaxe by hand.
- Keep your campfire fed: wood burns a minute, sticks 20 seconds, and a fire holds 15 minutes of fuel.
- Carry a torch at night for light, a little warmth and hesitant wolves.
- Have a roof, walls, a door and a bed up before night 4. Press `E` on the bed so you respawn there.
- Keep a fire in or right by your hut: rest by it, and wolves won't close in.
- Eat a bit of everything and never let two diet bars run low.
- Cook meat and fish. Raw food often makes you ill.
- Don't leave food lying about: thieves take it from the ground, and from day 3 the bear raids chests it can reach.
- Make a hide cloak before the first cold day, and stock wood and food before day 12.
- Walk, don't sprint, when stalking deer and rabbits.
- Leave boars alone until you have a spear or bow.
- Repair tools before they break: a stone tool costs one flint at the workbench.

View File

@@ -22,6 +22,7 @@
header { padding: 48px 0 20px; text-align: center; }
h1 { font-size: clamp(44px, 9vw, 84px); letter-spacing: .06em; margin: 0; font-weight: 600; }
.tag { color: var(--dim); font-size: 20px; margin: 6px 0 0; }
.guide-link { margin: 10px 0 0; font-size: 17px; }
.video { margin: 28px 0 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden;
background: #000; aspect-ratio: 16 / 9; }
.video video { width: 100%; height: 100%; display: block; }
@@ -47,6 +48,7 @@
<header class="wrap">
<h1>EMBERWILD</h1>
<p class="tag">Survive the island. Build a home.</p>
<p class="guide-link"><a href="guide.html">Read the guide</a>: how to play, and everything in the game.</p>
<div class="video">
<video controls preload="metadata" poster="poster.jpg" aria-label="Emberwild trailer">
<source src="trailer.mp4" type="video/mp4">
@@ -102,7 +104,7 @@
</section>
</main>
<footer class="wrap">Emberwild {{VERSION}}. Made with Godot.</footer>
<footer class="wrap">Emberwild {{VERSION}}. Made with Godot. <a href="guide.html">The guide</a>.</footer>
<script>
// Point out the download for the visitor's own computer (it still works without this).

344
tools/site/make_guide.py Normal file
View File

@@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""Builds the help pages, build/site/guide.html: the written guide
(tools/site/guide_sections.md) plus reference tables made from the game's own
data, so they can't drift from the game: every item (data/items.json), every
recipe (data/recipes.json), the fish (data/fish.json), and the creatures and
server settings (dumped from the scripts by tools/site/dump_data.gd).
python3 tools/site/make_guide.py <site_data.json> <out dir>
Called by tools/site/make_site.sh.
"""
import html, json, os, re, sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
esc = lambda s: html.escape(str(s), quote=True)
# --- the written guide: a small, strict markdown subset -----------------------------
def inline(s):
s = esc(s)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", s)
return s
def written_sections():
"""[(id, title, html)] from guide_sections.md."""
out, cur, buf, in_list = [], None, [], False
def close_list():
nonlocal in_list
if in_list:
buf.append("</ul>")
in_list = False
for raw in open(os.path.join(ROOT, "tools", "site", "guide_sections.md"), encoding="utf-8"):
line = raw.rstrip("\n")
m = re.match(r"^## (.+?) \{#([a-z0-9-]+)\}\s*$", line)
if m:
close_list()
if cur:
out.append((cur[1], cur[0], "\n".join(buf)))
cur, buf = (m.group(1), m.group(2)), []
continue
if cur is None:
continue
if line.startswith("### "):
close_list()
buf.append("<h3>%s</h3>" % inline(line[4:]))
elif line.startswith("- "):
if not in_list:
buf.append("<ul>")
in_list = True
buf.append("<li>%s</li>" % inline(line[2:]))
elif line.strip() == "":
close_list()
else:
close_list()
buf.append("<p>%s</p>" % inline(line))
close_list()
if cur:
out.append((cur[1], cur[0], "\n".join(buf)))
return out
# --- reference tables from the game's data ---------------------------------------------
def table(headers, rows, cls=""):
h = "".join("<th scope=\"col\">%s</th>" % esc(x) for x in headers)
body = "".join("<tr>%s</tr>" % "".join("<td>%s</td>" % c for c in r) for r in rows)
return "<div class=\"tbl\"><table class=\"%s\"><thead><tr>%s</tr></thead><tbody>%s</tbody></table></div>" % (cls, h, body)
def ref_sections(data):
items = json.load(open(os.path.join(ROOT, "data", "items.json")))
items = items["items"] if isinstance(items, dict) else items
recipes = json.load(open(os.path.join(ROOT, "data", "recipes.json")))
recipes = recipes["recipes"] if isinstance(recipes, dict) else recipes
fish = json.load(open(os.path.join(ROOT, "data", "fish.json")))
by_id = {i["id"]: i for i in items}
name = lambda i: by_id[i]["name"] if i in by_id else i.replace("_", " ").title()
stations = {"": "By hand", "workbench": "Workbench", "campfire": "Campfire", "forge": "Forge",
"stonecutter": "Stonecutter"}
out = []
# Items, by category.
cats = [("resource", "Materials"), ("tool", "Tools and clothing"), ("weapon", "Weapons"), ("food", "Food"),
("building", "Building pieces"), ("placeable", "Things to place")]
parts = []
for cat, title in cats:
rows = []
for i in items:
if i.get("category") != cat or i.get("hidden"):
continue
facts = []
f = i.get("food")
if f:
facts.append("food %+d" % f.get("hunger", 0))
if f.get("health"):
facts.append("health %+d" % f["health"])
for g, v in f.get("diet", {}).items():
facts.append("%s %+d" % ({"plants": "greens"}.get(g, g), v))
if f.get("risk"):
facts.append("%d%% chance of food poisoning" % round(f["risk"] * 100))
if "keeps" in i:
facts.append("keeps %s days" % i["keeps"])
t = i.get("tool")
if t:
facts.append("%s, tier %s" % (t.get("kind", ""), t.get("tier", 1)))
w = i.get("weapon")
if w and "damage" in w:
facts.append("damage %s" % w["damage"])
if w and "throw" in w:
facts.append("thrown: damage %s" % w["throw"].get("damage"))
b = i.get("bow")
if b:
facts.append("damage %s to %s, by draw" % tuple(b["damage"]))
if "wear" in i:
facts.append("+%s warmth" % i["wear"].get("warmth", 0))
if "durability" in i:
facts.append("%s uses" % i["durability"])
if "fuel" in i:
facts.append("burns %s s" % i["fuel"])
rows.append(["<b>%s</b>" % esc(i["name"]), esc(i.get("description", "")), esc(", ".join(facts))])
if rows:
parts.append("<h3 id=\"items-%s\">%s</h3>" % (cat, esc(title)) + table(["Item", "What it is", "Numbers"], rows))
out.append(("items", "All items", "<p>Every item in the game, from its own data. Food values are how much a "
"bite fills each bar; diet groups are meat, fish and greens.</p>" + "".join(parts)))
# Recipes, by where you make them.
parts = []
for st in ["", "workbench", "campfire", "stonecutter", "forge"] + sorted(
{r["station"] for r in recipes} - set(stations)):
rows = []
for r in recipes:
if r["station"] != st:
continue
ins = ", ".join("%d %s" % (n, esc(name(k))) for k, n in r["inputs"].items())
makes = "<b>%s</b>%s" % (esc(name(r["output"])), " x%d" % r["count"] if r["count"] > 1 else "")
rows.append([makes, ins])
if rows:
parts.append("<h3 id=\"recipes-%s\">%s</h3>" % (st or "hand", esc(stations.get(st, st.title())))
+ table(["Makes", "From"], rows))
out.append(("recipes", "All recipes", "<p>What you can make and where. <b>By hand</b> needs nothing nearby; the "
"others need that station within about 6 m (a campfire must be lit).</p>" + "".join(parts)))
# Creatures.
order = ["rabbit", "deer", "boar", "crow", "fox", "wolf", "alpha", "bear", "guard", "spirit_wolf", "old_stag"]
cr = data["creatures"]
rows = []
for k in order + sorted(set(cr) - set(order)):
if k not in cr:
continue
c = cr[k]
drops = ", ".join("%s %s" % (("%d" % a[0]) if a[0] == a[1] else "%d-%d" % (a[0], a[1]), esc(name(i)))
for i, a in c.get("drops", {}).items()) or "nothing"
dmg = "%g every %g s" % (c["damage"], c.get("attack_cd", 1)) if c.get("damage") else "harmless"
rows.append(["<b>%s</b>" % esc(c["name"]), "%g" % c["hp"], dmg, "%g m/s" % c.get("run", 0), drops])
out.append(("creatures", "All creatures", "<p>Health, how hard each hits (on normal difficulty), its top speed "
"and what it leaves when butchered. Your walk is 5 m/s and your sprint 8 m/s.</p>"
+ table(["Creature", "Health", "Damage", "Runs", "Drops"], rows)))
# Fish.
rows = []
for water, w in fish.get("waters", {}).items():
total = sum(f["weight"] for f in w["fish"])
total_dd = sum(f.get("dawn_dusk", f["weight"]) for f in w["fish"])
for f in w["fish"]:
rows.append(["<b>%s</b>" % esc(name(f["id"])), esc(water.title()),
"%d%%" % round(100 * f["weight"] / total),
"%d%%" % round(100 * f.get("dawn_dusk", f["weight"]) / total_dd)])
m = fish.get("modifiers", {})
out.append(("fish", "Fish", "<p>What bites where. Bites come %g to %g s after the float settles, sooner at dawn "
"(05:00-08:30) and dusk (17:00-20:30) and in rain.</p>"
% tuple(fish["waters"]["lake"]["bite_wait"])
+ table(["Fish", "Water", "Usually", "At dawn and dusk"], rows)))
# Server settings.
desc = {
"server_name": "Its name in the player list", "motd": "The welcome message",
"password": "Players need it to join (blank: anyone)", "port": "The UDP port players connect to",
"max_players": "How many can play at once", "world": "The save folder for the island",
"worldlet": "The island type", "seed": "The island's seed, for a new world",
"pvp": "Players can hurt each other", "raiding": "Players can take down others' buildings",
"locks": "Who can open your chests and doors and use your bed",
"death_penalty": "What happens to your things when you die",
"sleep": "How many must be in bed to sleep through the night",
"pause_when_empty": "The island waits while nobody is on",
"day_length_minutes": "Real minutes in a whole day", "wolf_grace_days": "Days before the wolves move in",
"difficulty": "How hard creatures hit and how fast you get hungry", "rot": "How fast food spoils",
"autosave_minutes": "How often it saves", "backups_kept": "How many backups it keeps",
"backup_hours": "How often it backs up while running", "check_updates": "Says in the log when a newer version is out",
"admins": "Player ids of admins", "allowlist": "If not empty, only these player ids may join",
"banned": "Player ids that may not join",
}
settable = data.get("server_settable", {})
rows = []
for k, v in data["server_defaults"].items():
if k == "worldlet":
continue
dv = ", ".join(v) if isinstance(v, list) else ("(none)" if v in ("", []) else str(v).lower())
opts = settable.get(k)
live = ("yes: " + " / ".join(opts)) if isinstance(opts, list) else (("yes: " + opts) if opts else "no")
rows.append(["<code>%s</code>" % esc(k), esc(desc.get(k, "")), esc(dv), esc(live)])
out.append(("server-settings", "Server settings", "<p>Everything in a server's <code>server.json</code>, with its "
"default. Admins can change those marked live while it runs with <code>/set &lt;setting&gt; "
"&lt;value&gt;</code>; the rest take effect when the server restarts.</p>"
+ table(["Setting", "What it does", "Default", "Change live"], rows)))
return out
# --- the page ----------------------------------------------------------------------------
PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Emberwild guide: everything in the game</title>
<meta name="description" content="How to play Emberwild: controls, survival, food, building, weather, wildlife, hunting, fishing, iron, the Old Stag and multiplayer, with every item, recipe and creature.">
<style>
:root { --bg: #15110d; --panel: #221b14; --line: #3a2e22; --text: #f1e8da; --dim: #b8a892;
--ember: #e8833a; --ember-hi: #f6a35c; --mark: #5a3a14; }
* { box-sizing: border-box; }
html { scroll-padding-top: 16px; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 17px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
a { color: var(--ember-hi); }
a:focus-visible, input:focus-visible { outline: 3px solid var(--ember-hi); outline-offset: 2px; }
.skip { position: absolute; left: -999px; } .skip:focus { left: 12px; top: 12px; background: var(--panel); padding: 8px; z-index: 9; }
.layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 36px; max-width: 1180px; margin: 0 auto; padding: 0 20px; }
nav.toc { position: sticky; top: 0; align-self: start; max-height: 100vh; overflow: auto; padding: 24px 0; }
nav.toc .home { display: block; font-weight: 700; letter-spacing: .08em; font-size: 22px; color: var(--text); text-decoration: none; margin-bottom: 14px; }
nav.toc input { width: 100%; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font-size: 15px; }
nav.toc ol { list-style: none; padding: 0; margin: 14px 0 0; }
nav.toc li a { display: block; padding: 4px 8px; border-radius: 6px; color: var(--dim); text-decoration: none; font-size: 15px; }
nav.toc li a:hover { background: var(--panel); color: var(--text); }
nav.toc .group { color: var(--dim); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; margin: 14px 8px 4px; }
main { padding: 28px 0 60px; min-width: 0; }
h1 { font-size: 38px; margin: 0 0 6px; } .lead { color: var(--dim); margin: 0 0 28px; }
section { border-top: 1px solid var(--line); padding: 22px 0 6px; }
h2 { font-size: 27px; margin: 0 0 10px; } h3 { font-size: 19px; margin: 22px 0 6px; color: var(--ember-hi); }
p, ul { margin: 0 0 12px; } li { margin-bottom: 4px; }
code { background: var(--panel); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; font-size: .9em; color: var(--text); }
.tbl { overflow-x: auto; margin: 6px 0 16px; border: 1px solid var(--line); border-radius: 8px; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
th, td { text-align: left; vertical-align: top; padding: 8px 10px; border-bottom: 1px solid var(--line); }
th { background: var(--panel); color: var(--dim); font-weight: 600; position: sticky; top: 0; }
tbody tr:last-child td { border-bottom: 0; }
mark { background: var(--mark); color: var(--text); border-radius: 3px; }
.hidden { display: none; } .none { color: var(--dim); }
footer { color: var(--dim); font-size: 14px; border-top: 1px solid var(--line); padding: 20px 0 40px; }
@media (max-width: 820px) { .layout { grid-template-columns: 1fr; } nav.toc { position: static; max-height: none; padding-bottom: 0; } }
</style>
</head>
<body>
<a class="skip" href="#content">Skip to the guide</a>
<div class="layout">
<nav class="toc" aria-label="Guide contents">
<a class="home" href="index.html">EMBERWILD</a>
<label for="q" class="group" style="display:block;margin:0 0 6px">Search the guide</label>
<input id="q" type="search" placeholder="wolves, bed, iron..." autocomplete="off">
<ol>
<li class="group">How to play</li>
{TOC_GUIDE}
<li class="group">Reference</li>
{TOC_REF}
</ol>
</nav>
<main id="content">
<h1>The Emberwild guide</h1>
<p class="lead">Everything in Emberwild {VERSION}: how to play, what can happen, and every item, recipe and
creature. <a href="index.html">Download the game</a>.</p>
<p id="none" class="none hidden" role="status">Nothing matches that.</p>
{SECTIONS}
<footer>Emberwild {VERSION}. The reference tables are made from the game's own data each time this page is built.</footer>
</main>
</div>
<script>
// Search: hide sections (and table rows, list items, paragraphs) without the words; highlight them.
(function () {
var q = document.getElementById("q"), none = document.getElementById("none");
var secs = Array.prototype.slice.call(document.querySelectorAll("main section"));
var bits = secs.map(function (s) { return Array.prototype.slice.call(s.querySelectorAll("p, li, tbody tr, h3")); });
function clear(el) { el.querySelectorAll("mark").forEach(function (m) { m.replaceWith(m.textContent); }); el.normalize(); }
function mark(el, words) {
var walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT), nodes = [], n;
while ((n = walk.nextNode())) nodes.push(n);
var re = new RegExp("(" + words.map(function (w) { return w.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&"); }).join("|") + ")", "gi");
nodes.forEach(function (t) {
if (!re.test(t.nodeValue)) return; re.lastIndex = 0;
var span = document.createElement("span");
span.innerHTML = t.nodeValue.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(re, "<mark>$1</mark>");
t.replaceWith(span);
});
}
var timer;
q.addEventListener("input", function () { clearTimeout(timer); timer = setTimeout(run, 120); });
function run() {
var words = q.value.toLowerCase().split(/\\s+/).filter(function (w) { return w.length > 1; });
var shown = 0;
secs.forEach(function (s, i) {
clear(s);
if (!words.length) { s.classList.remove("hidden"); bits[i].forEach(function (b) { b.classList.remove("hidden"); }); return; }
var any = false;
bits[i].forEach(function (b) {
var t = b.textContent.toLowerCase(), hit = words.every(function (w) { return t.indexOf(w) >= 0; });
b.classList.toggle("hidden", !hit && b.tagName !== "H3"); any = any || hit;
});
var title = s.querySelector("h2").textContent.toLowerCase();
if (words.every(function (w) { return title.indexOf(w) >= 0; })) { any = true; bits[i].forEach(function (b) { b.classList.remove("hidden"); }); }
s.classList.toggle("hidden", !any);
if (any) { shown++; mark(s, words); }
});
none.classList.toggle("hidden", shown > 0 || !words.length);
}
})();
</script>
</body>
</html>
"""
def main():
data = json.load(open(sys.argv[1]))
out_dir = sys.argv[2]
guide = written_sections()
ref = ref_sections(data)
ids = [i for i, _, _ in guide + ref]
assert len(ids) == len(set(ids)), "two sections share an id"
toc = lambda secs: "\n".join(" <li><a href=\"#%s\">%s</a></li>" % (i, esc(t)) for i, t, _ in secs)
sections = "\n".join("<section id=\"%s\" aria-labelledby=\"h-%s\"><h2 id=\"h-%s\">%s</h2>\n%s\n</section>"
% (i, i, i, esc(t), body) for i, t, body in guide + ref)
page = PAGE.replace("{TOC_GUIDE}", toc(guide)).replace("{TOC_REF}", toc(ref)) \
.replace("{SECTIONS}", sections).replace("{VERSION}", esc(data.get("version", "")))
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "guide.html"), "w", encoding="utf-8") as f:
f.write(page)
words = len(re.sub(r"<[^>]+>", " ", sections).split())
print("built %s: %d written sections, %d reference tables, ~%d words" %
(os.path.relpath(os.path.join(out_dir, "guide.html"), ROOT), len(guide), len(ref), words))
if __name__ == "__main__":
main()

View File

@@ -43,3 +43,12 @@ assert "{{" not in page, "a placeholder was left unfilled"
open("build/site/index.html", "w").write(page)
print("built build/site/ for %s:\n %s\n %s" % (v["version"], mac_url, win_url))
EOF
# The help pages (guide.html): the written guide plus tables from the game's
# own data, which Godot dumps from the scripts so they're exact.
. tools/godot_env.sh
DATA=build/site_data.json
"$GODOT" --headless --path . --script res://tools/site/dump_data.gd -- "$PWD/$DATA" >/dev/null 2>&1 \
|| { echo "couldn't dump the game data for the guide"; exit 1; }
python3 tools/site/make_guide.py "$DATA" build/site || exit 1
rm -f "$DATA"