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:
12
README.md
12
README.md
@@ -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).
|
||||
|
||||
BIN
assets/audio/voice/hint_cove.ogg
Normal file
BIN
assets/audio/voice/hint_cove.ogg
Normal file
Binary file not shown.
BIN
assets/audio/voice/hint_seam.ogg
Normal file
BIN
assets/audio/voice/hint_seam.ogg
Normal file
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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."}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
53
scripts/ui/compass.gd
Normal 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)
|
||||
1
scripts/ui/compass.gd.uid
Normal file
1
scripts/ui/compass.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b86dwl7mmrk2y
|
||||
@@ -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
182
scripts/ui/map_screen.gd
Normal 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)
|
||||
1
scripts/ui/map_screen.gd.uid
Normal file
1
scripts/ui/map_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b80va7jn1pyxo
|
||||
@@ -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)"],
|
||||
]
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
139
tests/test_map.gd
Normal 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
1
tests/test_map.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://i5xx2lpb66s7
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user