0.4.0: map (N) that fills in as you explore and a compass (closes #1); Old Tam's seam and Cove tips (voiced); the journey and sea fish in the guide; trailer with the tunnel and the Cove; protocol 16

This commit is contained in:
Paul Bailey
2026-11-06 18:36:20 +00:00
parent 980ee53579
commit 2b7e52cd82
28 changed files with 678 additions and 43 deletions

View File

@@ -16,7 +16,7 @@ addon SDK with guard rails.
## Status
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
**Emberwild 0.3.0** is released (download page and player guide:
**Emberwild 0.4.0** is released (download page and player guide:
https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
@@ -26,8 +26,14 @@ https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
- T1, T2, T4 (`docs/TUTORIAL_DESIGN.md`): the tutorial with Old Tam (spoken),
and his tips in normal worlds.
- M4 so far: packaged builds, the update check, music, ambience, sound effects
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages.
Next: the worldlets and the journey between them (`docs/WORLDLETS_DESIGN.md`).
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages,
the map (N) and compass.
- W0-W3, J1 (`docs/WORLDLETS_DESIGN.md`): worldlets as data, the Cove (beach,
pier, sea fishing), and the cliff tunnel between them, with a save file per
worldlet (single-player).
Next: J3, the journey on a server ("spread out"), then the Highlands.
Running from the source, `godot --path . -- --dev-worldlets` lets New World
start in any worldlet, to try one directly.
## Requirements
- **Godot 4.3+** (standard build; no C# needed — this project uses GDScript).

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,6 @@
{
"hint_cold": "91c5088e453fb0e6",
"hint_cove": "126f0b5e977fbef7",
"hint_died": "e865f8a63622b3c3",
"hint_diet": "3178b319c44e9fa0",
"hint_freezing": "48b1e505119a0fc9",
@@ -8,6 +9,7 @@
"hint_hungry": "ad76f22cb8e034a0",
"hint_night": "2a07d9c0bfae4bf9",
"hint_rotten": "98b69de03dbee089",
"hint_seam": "b353f46816d329b7",
"hint_sheltered": "ba6d6ff8bc4a1c80",
"hint_sick": "ec07963c46638ffc",
"hint_snow": "8f0ac7d093f73840",

View File

@@ -1,6 +1,6 @@
{
"guide": "Old Tam",
"_doc": "T4 (docs/TUTORIAL_DESIGN.md §5): one-off tips in normal worlds, each shown once per player (user://hints.json), in list order when several are due. 'when' is kind:value, with | for alternatives: status (a survival status, by prefix), weather (a state), has (an item in your bag), event (howl, died), or a plain check (night, stamina_low, wolf_moon, hard_winter). 'hold' is how long it must stay true first (seconds, default 1).",
"_doc": "T4 (docs/TUTORIAL_DESIGN.md §5): one-off tips in normal worlds, each shown once per player (user://hints.json), in list order when several are due. 'when' is kind:value, with | for alternatives: status (a survival status, by prefix), weather (a state), has (an item in your bag), event (howl, died), worldlet (where you are on the journey), or a plain check (night, stamina_low, wolf_moon, hard_winter, seam: holding the antler pickaxe with the cliff tunnel's seam still to dig). 'hold' is how long it must stay true first (seconds, default 1).",
"hints": [
{"id": "died", "when": "event:died", "title": "Back on your feet",
"text": "Dying's not the end out here. Whatever you carried is waiting where you fell, so go back for it before anything else. Sleep in a bed once and that's where you'll wake."},
@@ -33,6 +33,10 @@
{"id": "stamina", "when": "stamina_low", "title": "Out of breath",
"text": "You're spent. Stop swinging and running a moment and your stamina comes back. Rested by a fire, it comes back faster."},
{"id": "sheltered", "when": "status:Sheltered", "hold": 3, "title": "A proper shelter",
"text": "Roof and walls: the weather can't touch you here. Sit by a fire in here a little while and you'll be Rested."}
"text": "Roof and walls: the weather can't touch you here. Sit by a fire in here a little while and you'll be Rested."},
{"id": "seam", "when": "seam", "hold": 3, "title": "The cracked seam",
"text": "That antler pick bites rock iron only scratches. Out on the clifftop, near the edge, there's a seam of cracked rock, and a way down to the sea behind it if you dig. Take wood to prop it as you go, and lights: it's black as pitch in there."},
{"id": "cove", "when": "worldlet:cove", "hold": 3, "title": "The Cove",
"text": "You made it down. Sand, salt and sea fish. Fish off the end of the pier: mackerel bite close in, cod further out where it's deep. The sea cave takes you back up the tunnel, and home's waiting at the top."}
]
}

View File

@@ -87,7 +87,11 @@ phases M2.0-M2.7).
- PvP / raiding rules, building damage
- Better UI, settings, controls. *Done so far:* volume sliders, tips and
update settings in the pause menu; `Q` drops items; `K`/`M` put any bag row
on the 1-8 keys (0.3.0).
on the 1-8 keys (0.3.0); the map on `N`, filling in as you explore, and a
compass along the top (0.4.0, Gitea issue #1).
- More places: the worldlets and the journey (`docs/WORLDLETS_DESIGN.md`).
*Done (0.4.0):* W0-W3 and J1, the Cove reached down the cliff tunnel
(single-player). Next J3 (multiplayer, "spread out"), then J2/J4.
- Audio, music, weather. *Done (0.2.0-0.2.1):* mood music (calm, night,
storm, danger, boss) that crossfades with what's happening, ambience (CC0
birdsong, crickets, owls, fire), sound effects for every action (Kenney

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.3.1"
application/version="0.3.1"
application/short_version="0.4.0"
application/version="0.4.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.3.1"
application/product_version="0.3.1"
application/file_version="0.4.0"
application/product_version="0.4.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.3.1"
config/version="0.4.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 · Q drop · K/M bag row · Tab inventory & crafting · Esc menu & controls"
text = "E interact · LMB use/place · RMB eat · R rotate · X dismantle · Q drop · K/M bag row · N map · Tab inventory & crafting · Esc menu & controls"
label_settings = SubResource("Label_1")

View File

@@ -34,6 +34,7 @@ func _ready() -> void:
_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
_bind("map", [KEY_N]) # the map (Gitea issue #1)
for i in range(8):
_bind("hotbar_%d" % (i + 1), [KEY_1 + i])
_bind_mouse("attack", MOUSE_BUTTON_LEFT)

View File

@@ -135,4 +135,9 @@ func _met(when: String) -> bool:
return not dead and world.day_night.is_night() and world.day_night.wolf_moon_now()
"hard_winter":
return not dead and world.weather != null and world.weather.in_hard_winter()
"worldlet": # (where you are on the journey: "worldlet:cove")
return not dead and world.has_method("worldlet_id") and String(world.worldlet_id()) in vals
"seam": # (an antler pickaxe in your bag, and the cliff tunnel's seam still to dig)
return not dead and world.get("tunnel") != null and not world.tunnel.opened \
and player.inventory.count("antler_pickaxe") > 0
return false

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 := 15 ## 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); 15: placed lights (candle, torch stand)
const PROTOCOL := 16 ## 16: worldlets (the Cove), sea fish (mackerel, cod); 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); 15: placed lights (candle, torch stand)
const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join

53
scripts/ui/compass.gd Normal file
View File

@@ -0,0 +1,53 @@
extends Control
## The compass (Gitea issue #1): a strip across the top of the screen that
## shows which way you're facing, N/E/S/W and the points between, with a tick
## at the centre. North is the map's top (-Z).
const UITheme = preload("res://scripts/ui/theme.gd")
const WIDTH := 420.0
const SPAN := 180.0 ## degrees shown across the strip
const POINTS := [[0, "N"], [45, "NE"], [90, "E"], [135, "SE"], [180, "S"], [225, "SW"], [270, "W"], [315, "NW"]]
var player: Node3D
func setup(p: Node3D) -> void:
player = p
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_preset(Control.PRESET_CENTER_TOP)
offset_left = -WIDTH * 0.5
offset_right = WIDTH * 0.5
offset_top = 44.0
offset_bottom = 74.0
## Which way `p` faces, in degrees clockwise from north (0 = N, 90 = E).
static func heading(p: Node3D) -> float:
var f: Vector3 = -p.global_basis.z
return fposmod(rad_to_deg(atan2(f.x, -f.z)), 360.0)
func _process(_d: float) -> void:
visible = player != null and is_instance_valid(player) and not bool(player.get("ui_open"))
queue_redraw()
func _draw() -> void:
if player == null or not is_instance_valid(player):
return
var h := heading(player)
var font := get_theme_default_font()
draw_rect(Rect2(Vector2.ZERO, size), Color(0, 0, 0, 0.35))
for deg in range(0, 360, 15):
var off := wrapf(deg - h, -180.0, 180.0)
if absf(off) > SPAN * 0.5:
continue
var x := WIDTH * 0.5 + off / SPAN * WIDTH
var big := deg % 45 == 0
draw_line(Vector2(x, 0), Vector2(x, 6 if not big else 9), Color(1, 1, 1, 0.5 if not big else 0.8), 1.0)
for pt in POINTS:
var off := wrapf(float(pt[0]) - h, -180.0, 180.0)
if absf(off) > SPAN * 0.5 - 6.0:
continue
var x := WIDTH * 0.5 + off / SPAN * WIDTH
var s := 17 if String(pt[1]).length() == 1 else 13
var col := Color(1.0, 0.55, 0.35) if pt[1] == "N" else Color(1, 1, 1, 0.9)
var w := font.get_string_size(pt[1], HORIZONTAL_ALIGNMENT_CENTER, -1, s).x
draw_string(font, Vector2(x - w * 0.5, 25), pt[1], HORIZONTAL_ALIGNMENT_LEFT, -1, s, col)
draw_line(Vector2(WIDTH * 0.5, 0), Vector2(WIDTH * 0.5, 30), Color(1.0, 0.85, 0.4, 0.9), 2.0)

View File

@@ -0,0 +1 @@
uid://b86dwl7mmrk2y

View File

@@ -21,6 +21,8 @@ var _diet_bars: Dictionary = {}
var _status: Label
var _clock: Label
var _hurt: ColorRect
var compass: Control
var map: Control
## A red flash at the screen edge when something hits you.
func _on_hurt(amount: float, _from: Vector3) -> void:
@@ -196,6 +198,15 @@ func _build_stats(root: Control) -> void:
tips.name = "HintCard"
root.add_child(tips)
tips.setup()
# The compass along the top, and the map on N (Gitea issue #1).
compass = preload("res://scripts/ui/compass.gd").new()
compass.name = "Compass"
root.add_child(compass)
compass.setup(player)
map = preload("res://scripts/ui/map_screen.gd").new()
map.name = "Map"
root.add_child(map)
map.setup(player.get_parent(), player)
func _process(_d: float) -> void:
_fade_chat()
@@ -387,7 +398,9 @@ func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
if is_dead_screen():
return # (respawn first)
if _screen.is_open():
if map != null and map.is_open():
map.close()
elif _screen.is_open():
_screen.close()
elif _menu.is_open():
_menu.back()
@@ -398,6 +411,13 @@ func _input(event: InputEvent) -> void:
func _unhandled_input(event: InputEvent) -> void:
if _menu.is_open():
return
if event.is_action_pressed("map") and not _screen.is_open() and not is_dead_screen() \
and (map.is_open() or not player.ui_open):
map.toggle()
get_viewport().set_input_as_handled()
return
if map.is_open():
return
if event.is_action_pressed("inventory") and not _screen.is_open():
_screen.open(null)
get_viewport().set_input_as_handled()

182
scripts/ui/map_screen.gd Normal file
View File

@@ -0,0 +1,182 @@
extends Control
## The map (Gitea issue #1), on N. The worldlet you're in, drawn from its
## terrain (water, sand, grass, rock, snow, shaded by slope), but only where
## you've been: the rest is blank until you explore it (world.explored).
## Marked where you've seen them: the den, the barrow, the standing stones,
## the tunnel's seam, the pier and sea cave, your beds and buildings, your
## camp (fires, chests, benches) and your grave. You're a dot; which way
## you're facing is on the compass (and under the map). North is up.
const UITheme = preload("res://scripts/ui/theme.gd")
const Compass = preload("res://scripts/ui/compass.gd")
const RES := 200 ## map image pixels per side
const SIDE := 500.0 ## map size on screen
const MARKS := {
"den": [Color(0.85, 0.30, 0.25), "Wolf den"],
"barrow": [Color(0.75, 0.70, 0.55), "Barrow"],
"stones": [Color(0.80, 0.80, 0.85), "Standing stones"],
"seam": [Color(0.95, 0.60, 0.25), "Cliff tunnel"],
"pier": [Color(0.70, 0.50, 0.30), "Pier"],
"cave": [Color(0.95, 0.60, 0.25), "Sea cave"],
"bed": [Color(0.40, 0.75, 1.00), "Bed"],
"build": [Color(0.62, 0.45, 0.28), "Your buildings"],
"camp": [Color(1.00, 0.80, 0.35), "Camp"],
"grave": [Color(0.85, 0.85, 0.85), "Your things"],
}
var world: Node3D
var player: Node3D
var _tex: TextureRect
var _marks: Control
var _title: Label
var _facing: Label
func setup(w: Node3D, p: Node3D) -> void:
world = w
player = p
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
visible = false
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.6)
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
centre.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(centre)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 6)
centre.add_child(box)
_title = UITheme.label("", 22)
_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_title)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 14)
box.add_child(row)
var frame := Control.new()
frame.custom_minimum_size = Vector2(SIDE, SIDE)
row.add_child(frame)
_tex = TextureRect.new()
_tex.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_tex.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_tex.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
frame.add_child(_tex)
_marks = Control.new()
_marks.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_marks.mouse_filter = Control.MOUSE_FILTER_IGNORE
_marks.draw.connect(_draw_marks)
frame.add_child(_marks)
var legend := VBoxContainer.new()
legend.add_theme_constant_override("separation", 4)
row.add_child(legend)
legend.add_child(UITheme.label("Key", 16, UITheme.TEXT_DIM))
var you := UITheme.label("● You", 14)
you.add_theme_color_override("font_color", Color(1, 1, 1))
legend.add_child(you)
for k in MARKS:
var l := UITheme.label("■ %s" % MARKS[k][1], 14)
l.add_theme_color_override("font_color", MARKS[k][0])
legend.add_child(l)
_facing = UITheme.label("", 15, UITheme.TEXT_DIM)
_facing.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_facing)
var help := UITheme.label("N or Esc to close · north is up · the map fills in as you explore", 13, UITheme.TEXT_DIM)
help.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(help)
func is_open() -> bool:
return visible
func open() -> void:
_tex.texture = ImageTexture.create_from_image(render(world))
_title.text = "The %s" % preload("res://scripts/world/terrain.gd").worldlet_name(world.worldlet_id())
visible = true
player.ui_open = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func close() -> void:
visible = false
player.ui_open = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func toggle() -> void:
if visible:
close()
else:
open()
func _process(_d: float) -> void:
if not visible:
return
var h: float = Compass.heading(player)
var names := ["north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west"]
_facing.text = "Facing %s" % names[int(round(h / 45.0)) % 8]
if world.underground_at(player.global_position):
_facing.text += " · in the tunnel, under the cliff"
_marks.queue_redraw()
## Where (x, z) in the world falls on the map, in its frame's pixels.
func _to_map(p: Vector3) -> Vector2:
var s: float = world.terrain.size
return Vector2((p.x + s * 0.5) / s, (p.z + s * 0.5) / s) * SIDE
func _draw_marks() -> void:
var font := get_theme_default_font()
for m in world.map_marks():
var at := _to_map(m.pos)
var col: Color = MARKS.get(m.kind, [Color.WHITE])[0]
var r := 3.0 if m.kind == "build" else 6.0
_marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), col)
_marks.draw_rect(Rect2(at - Vector2(r, r), Vector2(r, r) * 2.0), Color(0, 0, 0, 0.7), false, 1.0)
if m.kind in ["den", "barrow", "stones", "seam", "pier", "cave"]:
_marks.draw_string(font, at + Vector2(9, 5), String(m.label), HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col)
# You: a dot (the way you're facing is on the compass).
var p := player.global_position
if world.underground_at(p) and world.tunnel != null:
p = world.tunnel.seam_at # (down the tunnel: shown at its top)
var me := _to_map(p)
_marks.draw_circle(me, 6.5, Color(0, 0, 0, 0.8))
_marks.draw_circle(me, 5.0, Color(1, 1, 1))
# North.
_marks.draw_string(font, Vector2(SIDE - 26, 24), "N", HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(1.0, 0.55, 0.35))
_marks.draw_line(Vector2(SIDE - 20, 30), Vector2(SIDE - 20, 48), Color(1.0, 0.55, 0.35), 2.0)
## The map image: the terrain, coloured and shaded, blank where unexplored.
static func render(w: Node3D) -> Image:
var t: Node3D = w.terrain
var img := Image.create(RES, RES, false, Image.FORMAT_RGB8)
var s: float = t.size
var step := s / RES
var blank := Color(0.16, 0.14, 0.12)
for iy in range(RES):
for ix in range(RES):
var x := -s * 0.5 + (ix + 0.5) * step
var z := -s * 0.5 + (iy + 0.5) * step
if not w.seen(x, z):
# (a faint paper grain, so blank reads as "not yet", not "nothing")
img.set_pixel(ix, iy, blank.lightened(0.04 * float((ix * 7 + iy * 13) % 3) / 2.0))
continue
img.set_pixel(ix, iy, ground_colour(t, x, z))
return img
static func ground_colour(t: Node3D, x: float, z: float) -> Color:
var h: float = t.sample_height(x, z)
var surface: float = t.water_level if t.inside_boundary(x, z, -2.0) else t.sea_level
if h < surface:
var deep := clampf((surface - h) / 6.0, 0.0, 1.0)
return Color(0.30, 0.52, 0.62).lerp(Color(0.10, 0.22, 0.36), deep)
var n: Vector3 = t.sample_normal(x, z)
var c: Color
if h > t.snow_height:
c = Color(0.92, 0.93, 0.95)
elif n.y < 0.75:
c = Color(0.48, 0.46, 0.43) # rock
elif h < surface + 0.9:
c = Color(0.80, 0.74, 0.55) # shore
else:
c = Color(0.36, 0.52, 0.27).lerp(Color(0.46, 0.50, 0.32), clampf(h / 20.0, 0.0, 1.0))
# Shading from the north-west, so hills and cliffs stand out.
var light := clampf(0.75 + (-n.x - n.z) * 0.9, 0.45, 1.25)
return Color(c.r * light, c.g * light, c.b * light)

View File

@@ -0,0 +1 @@
uid://b80va7jn1pyxo

View File

@@ -15,6 +15,7 @@ const CONTROLS := [
["E on a bed", "Set spawn point / sleep (night, under a roof)"],
["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)"],
["N", "Map (fills in as you explore; the compass is along the top)"],
["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"],
["Esc", "This menu (game autosaves every 2 min)"],
]

View File

@@ -163,7 +163,7 @@ func _on_new_day(day: int) -> void:
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.")
Actions.tell_all("A hard winter has set in. The %s's edge has frozen, and the animals are holed up." % ("lake" if world.worldlet_id() == "valley" else "pond"))
elif Weather.hard_winter(day - 1) and not Weather.hard_winter(day):
Actions.tell_all("The hard winter is over.")

View File

@@ -566,6 +566,11 @@ func _process(delta: float) -> void:
if _spoil_left <= 0.0:
_spoil_left = SPOIL_EVERY
spoil_all()
if not server_mode:
_explore_left -= delta
if _explore_left <= 0.0:
_explore_left = 0.5
explore(player.global_position)
if not Saves.has_world() or get_tree().paused:
return
_session_time += delta
@@ -601,6 +606,7 @@ func save_now() -> bool:
func _exit_tree() -> void:
if server_mode and Saves.has_world() and _session_time > 0.0:
save_now()
save_online_map()
if Guide.world == self:
Guide.stop()
if Hints.world == self:
@@ -676,6 +682,7 @@ func collect_save() -> Dictionary:
"barrow": barrow.save_data() if barrow != null else {},
"stones": stones.save_data() if stones != null else {},
"tunnel": tunnel.to_data() if tunnel != null else {},
"map": map_data(),
}
func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, dropped: Array, pieces: Array) -> Dictionary:
@@ -823,6 +830,8 @@ func _apply_save(s: Dictionary, with_player: bool) -> void:
stones.load_data(s.get("stones", {}))
if tunnel != null:
tunnel.load_data(s.get("tunnel", {}))
if with_player:
load_map_data(String(s.get("map", "")))
# The bear (bear_range.gd): applied when it's set up, after loading.
_saved_bear = s.get("bear", {})
if bears != null:
@@ -864,6 +873,107 @@ func _physics_process(delta: float) -> void:
push_warning("[world] player left the map at %s; respawning" % p)
_respawn()
# --- the map (Gitea issue #1) ---------------------------------------------------
# What you've seen of this worldlet, as a grid of MAP_CELLS x MAP_CELLS cells
# over the whole chunk, filled in around you as you go (scripts/ui/map_screen.gd
# draws it). Saved with the worldlet in single-player; online, on your own
# computer per server and worldlet (the server doesn't need to know).
const MAP_CELLS := 80
const SEE_RADIUS := 30.0 ## how far around you the map fills in
var explored := PackedByteArray()
var _explore_left := 0.0
func _ensure_map() -> void:
if explored.size() != MAP_CELLS * MAP_CELLS:
explored = PackedByteArray()
explored.resize(MAP_CELLS * MAP_CELLS)
## The map cell (x, z) falls in.
func map_cell(x: float, z: float) -> Vector2i:
var k: float = float(MAP_CELLS) / terrain.size
return Vector2i(clampi(int((x + terrain.size * 0.5) * k), 0, MAP_CELLS - 1), clampi(int((z + terrain.size * 0.5) * k), 0, MAP_CELLS - 1))
func seen(x: float, z: float) -> bool:
_ensure_map()
var c := map_cell(x, z)
return explored[c.y * MAP_CELLS + c.x] != 0
## Fills in the map around `p` (not from down the tunnel: nothing to see).
func explore(p: Vector3) -> void:
if underground_at(p) or terrain == null or not terrain.built:
return
_ensure_map()
var cell: float = terrain.size / float(MAP_CELLS)
var r := int(ceil(SEE_RADIUS / cell))
var c := map_cell(p.x, p.z)
for dz in range(-r, r + 1):
for dx in range(-r, r + 1):
var x := c.x + dx
var z := c.y + dz
if x < 0 or z < 0 or x >= MAP_CELLS or z >= MAP_CELLS:
continue
if Vector2(dx, dz).length() * cell <= SEE_RADIUS:
explored[z * MAP_CELLS + x] = 1
func map_data() -> String:
_ensure_map()
return Marshalls.raw_to_base64(explored.compress(FileAccess.COMPRESSION_DEFLATE))
func load_map_data(s: String) -> void:
explored = PackedByteArray()
if not s.is_empty():
var raw: PackedByteArray = Marshalls.base64_to_raw(s)
if not raw.is_empty():
explored = raw.decompress(MAP_CELLS * MAP_CELLS, FileAccess.COMPRESSION_DEFLATE)
_ensure_map()
## Online: your map of this server's worldlet, kept on your computer.
func _online_map_path() -> String:
var key := "%s_%d_%s" % [String(Net.server_info.get("server_name", "server")), int(terrain.noise_seed), worldlet_id()]
return "user://maps/%s.map" % key.validate_filename()
func save_online_map() -> void:
if not is_online():
return
DirAccess.make_dir_recursive_absolute("user://maps")
var f := FileAccess.open(_online_map_path(), FileAccess.WRITE)
if f != null:
f.store_string(map_data())
func load_online_map() -> void:
load_map_data(FileAccess.get_file_as_string(_online_map_path()) if FileAccess.file_exists(_online_map_path()) else "")
## What the map marks, where you've seen it: [{kind, pos (Vector3), label}].
func map_marks() -> Array:
var out: Array = []
var add := func(kind: String, pos: Vector3, label: String):
if seen(pos.x, pos.z):
out.append({"kind": kind, "pos": pos, "label": label})
if den != null and den.found:
add.call("den", den.global_position, "Wolf den")
if barrow != null and barrow.found:
add.call("barrow", barrow.global_position, "The barrow")
if stones != null and stones.found:
add.call("stones", stones.global_position, "Standing stones")
if tunnel != null:
add.call("seam", tunnel.seam_at, "The tunnel" if tunnel.opened else "Cracked seam")
if pier != null:
add.call("pier", (pier.start + pier.end) * 0.5, "The pier")
if sea_cave != null:
add.call("cave", sea_cave.global_position, "Sea cave")
for piece in all_pieces():
var id: String = String(piece.get("item_id"))
if piece.is_in_group("bed"):
add.call("bed", piece.global_position, ItemDB.item_name(id))
elif BuildPiece.is_build(ItemDB.get_item(id)):
add.call("build", piece.global_position, "")
elif id in ["campfire", "chest", "workbench", "forge", "hearth"]:
add.call("camp", piece.global_position, ItemDB.item_name(id))
elif id == "grave":
add.call("grave", piece.global_position, "Your things")
return out
## In the cliff tunnel (J1): no daylight, no weather, sheltered.
func underground_at(p: Vector3) -> bool:
return tunnel != null and tunnel.contains(p)
@@ -1086,6 +1196,8 @@ func _go_online() -> void:
net_sync.name = "NetSync"
add_child(net_sync)
net_sync.setup(self)
load_online_map() # (your map of this server, from your own computer)
Net.left.connect(func(_why = ""): save_online_map(), CONNECT_ONE_SHOT)
Net.left.connect(_on_net_left)
get_tree().auto_accept_quit = false # leave the server cleanly on window close
# The island's changes come from the server from now on.

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_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_tunnel 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_worldlets test_worldlet_pick test_water_bodies test_cove test_tunnel test_map 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,

View File

@@ -75,7 +75,7 @@ func _for(secs: float) -> void:
func _body() -> void:
print("the tips")
var kinds := ["event", "status", "weather", "has", "night", "stamina_low", "wolf_moon", "hard_winter"]
var kinds := ["event", "status", "weather", "has", "night", "stamina_low", "wolf_moon", "hard_winter", "worldlet", "seam"]
var bad: Array = []
for h in H.hints:
if not String(h.when).get_slice(":", 0) in kinds or String(h.title).is_empty() or String(h.text).is_empty():

139
tests/test_map.gd Normal file
View File

@@ -0,0 +1,139 @@
extends SceneTree
## The map and compass (Gitea issue #1). The map starts blank and fills in
## around you as you go; places are marked once you've seen them (the den,
## the barrow, the stones, the tunnel's seam, the Cove's pier and sea cave,
## your beds and buildings); you're a dot, and the compass says which way
## you're facing. What you've seen is saved with each worldlet.
## Run: godot --headless --path . --script res://tests/test_map.gd
var _fails := 0
var _finished := false
var S: Node
var DB: Node
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
S = root.get_node("Saves")
DB = root.get_node("ItemDB")
S.root_dir = "user://test_map_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 _load_world() -> Node3D:
var w: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(w)
for _i in range(8):
await physics_frame
w.creatures.enabled = false
w.player.set_physics_process(false)
return w
func _count(w: Node3D) -> int:
var n := 0
for b in w.explored:
n += 1 if b != 0 else 0
return n
func _body() -> void:
S.current_id = S.create_world("Mapped", 1337)
var w: Node3D = await _load_world()
var player: Node3D = w.player
print("filling in")
await create_timer(0.7).timeout
var start := _count(w)
var all: int = w.MAP_CELLS * w.MAP_CELLS
check(start > 0 and start < all / 8, "at first, only the ground around you (%d of %d cells)" % [start, all])
check(w.seen(player.global_position.x, player.global_position.z), "(including where you stand)")
check(w.den.found and not w.seen(w.den.global_position.x, w.den.global_position.z), "the den isn't on it yet")
check(not w.map_marks().any(func(m): return m.kind == "den"), "(nor marked)")
player.global_position = w.den.global_position + Vector3(6, 1, 0)
w.explore(player.global_position)
check(w.map_marks().any(func(m): return m.kind == "den" and m.label == "Wolf den"), "go near it and it's marked")
check(_count(w) > start, "and the map's filled in on the way")
w.explore(w.tunnel.seam_at)
w.explore(w.barrow.global_position)
w.explore(w.stones.global_position)
var kinds: Array = w.map_marks().map(func(m): return m.kind)
check("seam" in kinds and "barrow" in kinds and "stones" in kinds, "the cracked seam, the barrow and the stones, once seen (%s)" % [kinds])
var bed: Node3D = w.spawn_piece(DB.get_item("bed"), Transform3D(Basis(), w._spawn + Vector3(4, 0, 2)))
var floor_def: Dictionary = DB.get_item("wood_floor")
if not floor_def.is_empty():
w.spawn_piece(floor_def, Transform3D(Basis(), w._spawn + Vector3(-4, 0, 2)))
w.explore(w._spawn)
kinds = w.map_marks().map(func(m): return m.kind)
check("bed" in kinds, "your bed")
check(floor_def.is_empty() or "build" in kinds, "and your buildings")
var below: Vector3 = w.tunnel.ORIGIN
var before := _count(w)
w.explore(below)
check(_count(w) == before, "(nothing fills in down the tunnel)")
print("the screen")
var MS: Script = load("res://scripts/ui/map_screen.gd")
var img: Image = MS.render(w)
check(img.get_width() == MS.RES, "the map image (%d px)" % img.get_width())
var far := Vector2i(2, 2)
var near: Vector2i = Vector2i(((w._spawn.x + w.terrain.size * 0.5) / w.terrain.size * MS.RES), ((w._spawn.z + w.terrain.size * 0.5) / w.terrain.size * MS.RES))
check(img.get_pixelv(far).is_equal_approx(img.get_pixelv(Vector2i(4, 2))) or img.get_pixelv(far).v < 0.25, "blank where you haven't been")
check(img.get_pixelv(near).v > 0.25, "drawn where you have")
var lake: Vector2 = w.terrain.lake_center
check(MS.ground_colour(w.terrain, lake.x, lake.y).b > MS.ground_colour(w.terrain, lake.x, lake.y).g, "water is blue")
var screen: Control = MS.new()
root.add_child(screen)
screen.setup(w, player)
screen.open()
await process_frame
await process_frame
check(screen.is_open() and player.ui_open and screen._title.text == "The Valley", "N opens it: the Valley")
check(screen._facing.text.begins_with("Facing "), "with which way you face (%s)" % screen._facing.text)
screen.close()
check(not screen.is_open() and not player.ui_open, "and closes it")
screen.queue_free()
print("the compass")
var C: Script = load("res://scripts/ui/compass.gd")
player.rotation = Vector3.ZERO # (facing -Z)
await process_frame
check(absf(C.heading(player)) < 1.0 or absf(C.heading(player) - 360.0) < 1.0, "facing -Z is north (%.0f)" % C.heading(player))
player.rotation = Vector3(0, -PI * 0.5, 0) # (facing +X)
await process_frame
check(absf(C.heading(player) - 90.0) < 1.0, "facing +X is east (%.0f)" % C.heading(player))
print("saved")
var seen_n := _count(w)
w.save_now()
w.queue_free()
for _i in range(3):
await process_frame
w = await _load_world()
check(_count(w) >= seen_n, "what you've seen is still on the map after a reload (%d cells)" % _count(w))
check(w.map_marks().any(func(m): return m.kind == "den"), "(the den too)")
w.queue_free()
for _i in range(3):
await process_frame
print("the Cove")
S.current_id = S.create_world("Cove map", 1337, {"worldlet": "cove"})
w = await _load_world()
await create_timer(0.7).timeout
check(_count(w) < start * 2, "a map of its own: only the ground round the start so far (%d cells)" % _count(w))
w.explore(w.pier.end)
w.explore(w.sea_cave.global_position)
kinds = w.map_marks().map(func(m): return m.kind)
check("pier" in kinds and "cave" in kinds, "the Cove's map: the pier and the sea cave (%s)" % [kinds])
w.queue_free()
await process_frame
_finished = true

1
tests/test_map.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://i5xx2lpb66s7

View File

@@ -43,6 +43,8 @@ The tutorial island is calm: no wolf pack (just one demonstration wolf), no bear
- `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.
- `N` opens the map. It starts blank and fills in as you explore, marking the places you've found, your camp and your bed. You're the white dot.
- The compass across the top of the screen shows which way you're facing.
- `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.
@@ -284,7 +286,7 @@ Food on the ground that nobody has been within 10 m of for 20 seconds draws a th
### 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.
Hold a fishing rod and `Left click` to cast into the lake. The float needs water at least half a metre deep. The sea below the Valley's cliffs is out of reach, but the Cove has sea fishing (see [The journey](#journey)). 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.
@@ -326,7 +328,29 @@ A ring of standing stones at the foot of the rim has an altar with three hollows
- 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.
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. It's also the only pick that can open the way out of the Valley.
## The journey {#journey}
The Valley is only the start. Beyond it lie other lands, each reached a different way, and each with its own ground, weather and creatures. The first is the **Cove**, down at the foot of the sea cliffs. For now the journey is single-player; on a server everyone stays in the Valley.
### The cliff tunnel
- **The seam:** on the clifftop, near the edge, is an outcrop of cracked rock. Only the **antler pickaxe** bites into it (anything less is "too hard"); about 10 blows open it. `E` on the opening goes down.
- **Digging down:** the tunnel goes down inside the cliff in six sloping stretches, each turning back under the last like a stair, about 24 m in all. Each stretch is a rock face of about 15 blows, and knocks loose some stone.
- **Props:** before you can dig the next stretch, prop the one you've just opened: `E` at its mouth sets a timber prop for 2 wood. Unpropped, the next face won't take a blow ("It's loose"). Nothing caves in. That's 10 wood all the way down.
- **Light:** no daylight reaches the tunnel, so it's pitch black. Set **candles** and **torch stands** on its floor as you go; they stay, so the way is lit when you come back. It's dry and out of the weather down there.
- **Out:** `E` on the passage at the top climbs back out onto the clifftop.
- **The bottom:** the last stretch breaks into a sea cave. Walk out of its mouth and you're in the Cove. Your bag, what you're wearing, your health and food, and the day all come with you.
### The Cove
Low hills run down to a sheltered sandy bay, with mountains behind and a small pond inland. Wade out from the beach and it gets deeper slowly; swim too far and a strong current carries you back to the shore.
- **The pier** runs out over the bay. Fish off its end for **mackerel**, the usual catch. **Cod** are bigger but only bite where the water's at least 2.2 m deep, so cast well out. Both bite best at dawn and dusk, and grill at a campfire, salt or smoke like lake fish.
- **The sea cave** at the end of the beach is the way back: `E` goes into it and up the same tunnel, arriving at the bottom.
Each land keeps its own state. What you build in the Valley waits for you while you're away, and time stands still in a land you're not in.
## Multiplayer {#multiplayer}

View File

@@ -90,6 +90,8 @@
<li>Wolves move in, and every seventh night the moon turns red.</li>
<li>Weather that matters: storms, blizzards and hard winters.</li>
<li>A bear, a barrow, and the Old Stag at the standing stones.</li>
<li>Dig down through the sea cliffs to the Cove, and fish off its pier.</li>
<li>A map that fills in as you explore, and a compass.</li>
<li>Play alone, or host a server for friends on your own machine.</li>
<li>A guided tutorial with Old Tam the hermit.</li>
</ul>

View File

@@ -165,14 +165,18 @@ def ref_sections(data):
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"]:
depth = f.get("min_depth", 0) or w.get("min_depth", 0)
rows.append(["<b>%s</b>" % esc(name(f["id"])), esc(water.title()),
"%g m" % depth,
"%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)))
waits = ", ".join("%g to %g s in the %s" % (w["bite_wait"][0], w["bite_wait"][1], water)
for water, w in fish.get("waters", {}).items())
out.append(("fish", "Fish", "<p>What bites where. Bites come %s after the float settles, sooner at dawn "
"(05:00-08:30) and dusk (17:00-20:30) and in rain. A fish that needs deeper water only bites "
"where the float is at least that deep (the share shown is where it can bite).</p>"
% waits
+ table(["Fish", "Water", "Deep enough", "Usually", "At dawn and dusk"], rows)))
# Server settings.
desc = {
@@ -216,7 +220,7 @@ PAGE = """<!doctype html>
<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.">
<meta name="description" content="How to play Emberwild: controls, survival, food, building, weather, wildlife, hunting, fishing, iron, the Old Stag, the journey to the Cove 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; }

View File

@@ -1,8 +1,9 @@
extends SceneTree
## Temporary: a ~32 s test trailer, recorded with Movie Maker:
## The trailer (~46 s), recorded with Movie Maker (tools/trailer/make_trailer.sh):
## godot --path . --fixed-fps 30 --write-movie out.avi --script tools/trailer/trailer.gd -- --no-creatures
## Six shots: dawn flyover + title, morning meadow, a hut at dusk, the wolf
## moon, a blizzard, and an end card.
## Eight shots: dawn flyover + title, morning meadow, a hut at dusk, the wolf
## moon, a blizzard, down the cliff tunnel, the Cove's pier, and an end card
## over the bay (with the version from project.godot).
const FPS := 30.0
const UITheme = preload("res://scripts/ui/theme.gd")
@@ -32,6 +33,37 @@ func _run() -> void:
H.path = "user://trailer_hints.json" # (never your real tips)
H.load_prefs()
H.enabled = false
# (Its own save folder, so the Cove can be loaded as a world of its own.)
var S: Node = root.get_node("Saves")
S.root_dir = "user://trailer_worlds"
_overlay()
await _enter_world("")
await _stage()
await _shot_dawn()
await _shot_meadow()
await _shot_hut()
await _shot_wolves()
await _shot_blizzard()
await _shot_tunnel()
await _enter_world("cove")
await _shot_cove()
await _shot_end()
for w in S.list_worlds():
S.delete_world(w.id)
DirAccess.remove_absolute(S.root_dir)
DirAccess.remove_absolute(H.path)
quit(0)
## Loads the world to film: the Valley with no save (""), or a fresh world
## in `worldlet`. Hides the HUD and takes the camera.
func _enter_world(worldlet: String) -> void:
var S: Node = root.get_node("Saves")
if world != null:
black.modulate.a = 1.0
world.queue_free()
for _i in range(3):
await process_frame
S.current_id = "" if worldlet.is_empty() else S.create_world("Trailer", 1337, {"worldlet": worldlet})
world = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(8):
@@ -53,16 +85,6 @@ func _run() -> void:
cam.fov = 62
world.add_child(cam)
cam.current = true
_overlay()
await _stage()
await _shot_dawn()
await _shot_meadow()
await _shot_hut()
await _shot_wolves()
await _shot_blizzard()
await _shot_end()
DirAccess.remove_absolute(H.path)
quit(0)
# --- set dressing --------------------------------------------------------------------
@@ -261,14 +283,65 @@ func _shot_blizzard() -> void:
if world.weather_fx != null:
world.weather_fx.visible = false # (no leftover snow on the end card)
## Down the cliff tunnel (J1): dug through and propped, lit by torch stands
## you've set, the camera walking down the first stretch in the dark.
func _shot_tunnel() -> void:
var t: Node3D = world.tunnel
if t == null:
return
t.opened = true
t.dug = t.STRETCHES
for k in range(1, t.STRETCHES + 1):
t.props[k] = true
t._rebuild()
var ts: Dictionary = ItemDBRef().get_item("torch_stand")
var candle: Dictionary = ItemDBRef().get_item("tallow_candle")
var land1: Vector3 = t.ORIGIN + Vector3(t._landing_x(1), t._floor_y(1), t._lane_z(1))
world.spawn_piece(ts, Transform3D(Basis(), t._mouth(1) + Vector3(-0.5, 0, 0.9)))
world.spawn_piece(ts, Transform3D(Basis(), land1 + Vector3(0.6, 0, -0.8)))
world.spawn_piece(candle, Transform3D(Basis(), t._mouth(1) + Vector3(3.0, -2.0, -0.95)))
world.spawn_piece(candle, Transform3D(Basis(), t._mouth(2) + Vector3(-0.4, 0, 0.95)))
for _i in range(5):
await physics_frame
var from: Vector3 = t.top + Vector3(-0.6, 1.65, 0)
var to: Vector3 = land1 + Vector3(-1.4, 1.65, 0)
dn.underground = 1.0
await _shot(5.5, func(_t: float, k: float):
dn.underground = 1.0
_clock(13.0)
var e := k * k * (3.0 - 2.0 * k)
var pos := from.lerp(to, e)
cam.global_position = pos
cam.look_at(pos + Vector3(3.0, -1.4 + e * 0.9, 0.0) + Vector3(0, 0, lerpf(0.0, 1.2, e)))
_park_player(pos), "Dig through the cliffs.")
dn.underground = 0.0
## The Cove (W3): morning over the bay, along the pier.
func _shot_cove() -> void:
var pier: Node3D = world.pier
if pier == null:
return
var side: Vector3 = pier.dir.cross(Vector3.UP)
var from: Vector3 = pier.end + pier.dir * 18.0 + side * 14.0 + Vector3(0, 2.5, 0)
var to: Vector3 = pier.end + pier.dir * 6.0 + side * 7.0 + Vector3(0, 1.6, 0)
await _shot(6.0, func(_t: float, k: float):
_clock(8.2 + k * 0.3)
var e := k * k * (3.0 - 2.0 * k)
cam.global_position = from.lerp(to, e)
cam.look_at(pier.start.lerp(pier.end, 0.35) + Vector3(0, 1.0, 0))
_park_player(Vector3(pier.start.x, pier.start.y + 1.0, pier.start.z)), "Find what lies beyond.")
func _shot_end() -> void:
big.text = "EMBERWILD"
sub.text = "Self-hosted co-op survival · 0.1.0"
var b := Basis(Vector3.UP, yaw)
sub.text = "Self-hosted co-op survival · %s" % String(ProjectSettings.get_setting("application/config/version", ""))
# Rising over the Cove's bay, looking back at the land.
var pier: Node3D = world.pier
var c: Vector3 = Vector3.ZERO if pier == null else pier.start
var out: Vector3 = Vector3(0, 0, 1) if pier == null else pier.dir
await _shot(6.0, func(t: float, k: float):
_clock(17.6 + k * 0.4)
cam.global_position = _g(hut_c + b * Vector3(10, 0, 9), 0.0) + Vector3(0, 6.0 + k * 10.0, 0) + b * Vector3(0, 0, k * 10.0)
cam.look_at(hut_c + Vector3(0, 1.0, 0))
_park_player(cam.global_position)
cam.global_position = c + out * (40.0 + k * 14.0) + Vector3(0, 8.0 + k * 14.0, 0)
cam.look_at(c - out * 30.0 + Vector3(0, 4.0, 0))
_park_player(c + Vector3(0, 1.0, 0))
big.modulate.a = _env(t, 0.8, 5.6, 0.8, 0.6)
sub.modulate.a = _env(t, 1.6, 5.6, 0.8, 0.6), "", 0.7)