Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19d2295bec | ||
|
|
069abdb3fa |
No files matched your search
@@ -13,7 +13,7 @@ the obvious default.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/check.sh # parse-check every script (~5s) -- run after every edit
|
tools/check.sh # parse-check every script (~5s) -- run after every edit
|
||||||
tools/test.sh # 534 GUT tests, headless (~4s)
|
tools/test.sh # 551 GUT tests, headless (~4s)
|
||||||
tools/smoke.sh # real server + 4 bot clients over ENet (~40s)
|
tools/smoke.sh # real server + 4 bot clients over ENet (~40s)
|
||||||
tools/server.sh # dedicated server
|
tools/server.sh # dedicated server
|
||||||
tools/client.sh --listen # host and play, no menu
|
tools/client.sh --listen # host and play, no menu
|
||||||
@@ -29,7 +29,7 @@ godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up
|
|||||||
godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats
|
godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats
|
||||||
godot --headless --path . --script tools/bench.gd # sim cost per tick
|
godot --headless --path . --script tools/bench.gd # sim cost per tick
|
||||||
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
|
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
|
||||||
SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # menus, HUD, roster, and the fog in a dungeon and a tunnel (needs a display)
|
SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # every screen, the fog, a staged boss fight (needs a display; own store and account)
|
||||||
```
|
```
|
||||||
|
|
||||||
`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke
|
`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke
|
||||||
@@ -37,6 +37,11 @@ test cannot cover any of them: bots are poor shots, so they rarely kill
|
|||||||
anything, which means they neither earn levels, produce drops, nor ever reach
|
anything, which means they neither earn levels, produce drops, nor ever reach
|
||||||
the quartermaster.
|
the quartermaster.
|
||||||
|
|
||||||
|
**Every key the game names to the player goes through `Settings.short_label()`.**
|
||||||
|
All of them are rebindable, and a prompt that names the default is wrong for
|
||||||
|
anyone who changed it — the HUD said "LMB fire" and the upgrade screen said
|
||||||
|
"Close (E)" whatever those were bound to.
|
||||||
|
|
||||||
**Nothing automated can see the screen.** `check.sh`, the suite and `smoke.sh`
|
**Nothing automated can see the screen.** `check.sh`, the suite and `smoke.sh`
|
||||||
have all passed with the entire interface rendering unstyled, an invisible
|
have all passed with the entire interface rendering unstyled, an invisible
|
||||||
slider and an invisible scrollbar. After touching anything under `src/ui/` or
|
slider and an invisible scrollbar. After touching anything under `src/ui/` or
|
||||||
@@ -51,7 +56,8 @@ Everything after `--` goes to `GameOpts.parse()`:
|
|||||||
| `--join` | Skip the menu and connect. |
|
| `--join` | Skip the menu and connect. |
|
||||||
| `--listen` | Skip the menu and host a listen server with a local player. |
|
| `--listen` | Skip the menu and host a listen server with a local player. |
|
||||||
| `--bot` | Scripted input instead of the keyboard. Implies `--join`. |
|
| `--bot` | Scripted input instead of the keyboard. Implies `--join`. |
|
||||||
| `--host`, `--port`, `--name` | Connection details. |
|
| `--host`, `--port` | Connection details. |
|
||||||
|
| `--name` | Bots only: what a bot calls the character it creates. People name characters on the roster screen; the connect screen no longer asks. |
|
||||||
| `--autoquit N` | Quit after N physics ticks. |
|
| `--autoquit N` | Quit after N physics ticks. |
|
||||||
| `--boss-rush` | Server-side: dungeons spawn the boss and no trash. |
|
| `--boss-rush` | Server-side: dungeons spawn the boss and no trash. |
|
||||||
| `--depth N` | Server-side: depth of new dungeons, which drives map size. |
|
| `--depth N` | Server-side: depth of new dungeons, which drives map size. |
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ against itself.
|
|||||||
| Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) |
|
| Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) |
|
||||||
| Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables |
|
| Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables |
|
||||||
| Enemies placed per room at creation | done | `Instance._populate()` |
|
| Enemies placed per room at creation | done | `Instance._populate()` |
|
||||||
|
| HUD: player panel, party health, message feed, low-health edge | done | [src/ui/hud.gd](../src/ui/hud.gd) — stacked by a running cursor, so nothing has a fixed row to collide on |
|
||||||
|
| Prompts name the keys actually bound | done | `Settings.short_label()` |
|
||||||
|
| Boss bar waits until the boss has been seen | done | `Hud._draw_boss_bar`, reading the fog's `Visibility` |
|
||||||
|
| A death is announced on the roster screen | done | `ClientRuntime.last_fallen`, `GameScene.fallen_line` |
|
||||||
|
| Occasional enemy groups in tunnels | done | `Instance._populate_tunnels()` — about one depth-1 run in three, never within aggro range of the entrance |
|
||||||
| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) |
|
| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) |
|
||||||
| Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` |
|
| Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` |
|
||||||
| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
|
| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ const SFX_BUS := "SFX"
|
|||||||
|
|
||||||
static var master_volume: float = 0.8
|
static var master_volume: float = 0.8
|
||||||
static var sfx_volume: float = 0.8
|
static var sfx_volume: float = 0.8
|
||||||
|
## The server last joined, so the connect screen does not ask for the same
|
||||||
|
## address every launch. Empty until the first join. Client-local like
|
||||||
|
## everything else here -- it is where you play, not who you are.
|
||||||
|
static var last_address: String = ""
|
||||||
|
static var last_port: int = 0
|
||||||
## action -> a serialisable description of its event. See _describe().
|
## action -> a serialisable description of its event. See _describe().
|
||||||
static var bindings: Dictionary[String, Dictionary] = {}
|
static var bindings: Dictionary[String, Dictionary] = {}
|
||||||
|
|
||||||
@@ -64,6 +69,8 @@ static func load_and_apply() -> void:
|
|||||||
if cfg.load(path) == OK:
|
if cfg.load(path) == OK:
|
||||||
master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0)
|
master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0)
|
||||||
sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0)
|
sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0)
|
||||||
|
last_address = String(cfg.get_value("connection", "address", ""))
|
||||||
|
last_port = clampi(int(cfg.get_value("connection", "port", 0)), 0, 65535)
|
||||||
bindings.clear()
|
bindings.clear()
|
||||||
for action in REBINDABLE:
|
for action in REBINDABLE:
|
||||||
var key := String(action[0])
|
var key := String(action[0])
|
||||||
@@ -86,6 +93,9 @@ static func save() -> void:
|
|||||||
var cfg := ConfigFile.new()
|
var cfg := ConfigFile.new()
|
||||||
cfg.set_value("audio", "master", master_volume)
|
cfg.set_value("audio", "master", master_volume)
|
||||||
cfg.set_value("audio", "sfx", sfx_volume)
|
cfg.set_value("audio", "sfx", sfx_volume)
|
||||||
|
if not last_address.is_empty():
|
||||||
|
cfg.set_value("connection", "address", last_address)
|
||||||
|
cfg.set_value("connection", "port", last_port)
|
||||||
for action in bindings:
|
for action in bindings:
|
||||||
cfg.set_value("input", action, bindings[action])
|
cfg.set_value("input", action, bindings[action])
|
||||||
var err := cfg.save(path)
|
var err := cfg.save(path)
|
||||||
@@ -93,6 +103,16 @@ static func save() -> void:
|
|||||||
GameLog.warn("settings", "could not write %s (error %d)" % [path, err])
|
GameLog.warn("settings", "could not write %s (error %d)" % [path, err])
|
||||||
|
|
||||||
|
|
||||||
|
## Remember where the player just tried to connect.
|
||||||
|
static func remember_server(address: String, port: int) -> void:
|
||||||
|
var clean := address.strip_edges()
|
||||||
|
if clean.is_empty() or port <= 0 or port > 65535:
|
||||||
|
return
|
||||||
|
last_address = clean
|
||||||
|
last_port = port
|
||||||
|
save()
|
||||||
|
|
||||||
|
|
||||||
# --- Audio -------------------------------------------------------------------
|
# --- Audio -------------------------------------------------------------------
|
||||||
|
|
||||||
## Ensure the SFX bus exists and push both volumes onto the mixer.
|
## Ensure the SFX bus exists and push both volumes onto the mixer.
|
||||||
@@ -191,6 +211,20 @@ static func binding_label(action: String) -> String:
|
|||||||
return describe_label(current_binding(action))
|
return describe_label(current_binding(action))
|
||||||
|
|
||||||
|
|
||||||
|
## The binding as short as it can be said, for prompts on screen: "LMB" rather
|
||||||
|
## than "Mouse Left", "Esc" rather than "Escape". Every key the game names to the
|
||||||
|
## player goes through this, because every one of them is rebindable and a
|
||||||
|
## prompt that names the default is wrong for anyone who changed it.
|
||||||
|
static func short_label(action: String) -> String:
|
||||||
|
var label := binding_label(action)
|
||||||
|
match label:
|
||||||
|
"Mouse Left": return "LMB"
|
||||||
|
"Mouse Right": return "RMB"
|
||||||
|
"Mouse Middle": return "MMB"
|
||||||
|
"Escape": return "Esc"
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
static func describe_label(described: Dictionary) -> String:
|
static func describe_label(described: Dictionary) -> String:
|
||||||
match String(described.get("type", "")):
|
match String(described.get("type", "")):
|
||||||
"key":
|
"key":
|
||||||
|
|||||||
@@ -31,10 +31,26 @@ var stage_delay: int = 0
|
|||||||
var boss_id: StringName = &""
|
var boss_id: StringName = &""
|
||||||
## Generated layout, used to place enemies room by room.
|
## Generated layout, used to place enemies room by room.
|
||||||
var rooms: Array[Rect2i] = []
|
var rooms: Array[Rect2i] = []
|
||||||
|
## The rectangle each tunnel leg was carved over -- see MapGen. Overlaps the
|
||||||
|
## rooms at its ends, so _tunnel_tiles() decides what is actually tunnel.
|
||||||
|
var tunnels: Array[Rect2i] = []
|
||||||
var boss_spawn := Vector2.ZERO
|
var boss_spawn := Vector2.ZERO
|
||||||
var boss_room := Rect2i()
|
var boss_room := Rect2i()
|
||||||
|
|
||||||
|
|
||||||
|
## Chance that any one tunnel leg has a group of enemies in it. Rolled per leg,
|
||||||
|
## and most legs are too short or too near the entrance to qualify anyway, so a
|
||||||
|
## depth-1 dungeon has a tunnel group about one run in three. Rare on purpose:
|
||||||
|
## tunnels are where you go between fights, and a tunnel that is usually empty
|
||||||
|
## is what makes the one that is not a surprise.
|
||||||
|
const TUNNEL_GROUP_CHANCE := 0.1
|
||||||
|
const TUNNEL_GROUP_MIN := 2
|
||||||
|
const TUNNEL_GROUP_MAX := 3
|
||||||
|
## Members stand within this many tiles of the group's centre, so a group reads
|
||||||
|
## as one encounter rather than a tunnel's worth of strays.
|
||||||
|
const TUNNEL_GROUP_SPREAD := 2
|
||||||
|
|
||||||
|
|
||||||
static func make_lobby(instance_id: int) -> Instance:
|
static func make_lobby(instance_id: int) -> Instance:
|
||||||
var inst := Instance.new()
|
var inst := Instance.new()
|
||||||
inst.id = instance_id
|
inst.id = instance_id
|
||||||
@@ -78,6 +94,7 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
|
|||||||
inst.depth, flavour.arena)
|
inst.depth, flavour.arena)
|
||||||
inst.world.set_map(built["grid"])
|
inst.world.set_map(built["grid"])
|
||||||
inst.rooms = built["rooms"]
|
inst.rooms = built["rooms"]
|
||||||
|
inst.tunnels = built["tunnels"]
|
||||||
inst.boss_spawn = built["boss_pos"]
|
inst.boss_spawn = built["boss_pos"]
|
||||||
inst.boss_room = built["boss_room"]
|
inst.boss_room = built["boss_room"]
|
||||||
inst.world.spawn_point = built["spawn"]
|
inst.world.spawn_point = built["spawn"]
|
||||||
@@ -119,6 +136,87 @@ func _populate() -> void:
|
|||||||
if world.map.circle_blocked(at, def.radius):
|
if world.map.circle_blocked(at, def.radius):
|
||||||
continue
|
continue
|
||||||
world.spawn_enemy(def, at, rng.randi_range(0, 120))
|
world.spawn_enemy(def, at, rng.randi_range(0, 120))
|
||||||
|
# After every room, so adding tunnel groups leaves each room's population
|
||||||
|
# exactly what that seed always produced.
|
||||||
|
_populate_tunnels(rng, flavour)
|
||||||
|
|
||||||
|
|
||||||
|
## Now and then, a group of enemies in a tunnel.
|
||||||
|
##
|
||||||
|
## Never within aggro range of the entrance, by the same reasoning room 0 is
|
||||||
|
## left empty: arriving into a fight you cannot see yet is not a decision, it
|
||||||
|
## is an ambush. And never a lone straggler -- if a leg cannot fit at least
|
||||||
|
## TUNNEL_GROUP_MIN, it gets nobody.
|
||||||
|
func _populate_tunnels(rng: RandomNumberGenerator, flavour: DungeonDef) -> void:
|
||||||
|
var clear_of_spawn := _widest_aggro() + MapGrid.TILE * 2.0
|
||||||
|
for leg in tunnels:
|
||||||
|
# Every leg rolls the same three numbers whether or not it is used, so
|
||||||
|
# one tunnel's dice never depend on another tunnel's shape.
|
||||||
|
var roll := rng.randf()
|
||||||
|
var pick := rng.randi()
|
||||||
|
var size := rng.randi_range(TUNNEL_GROUP_MIN, TUNNEL_GROUP_MAX)
|
||||||
|
if roll >= TUNNEL_GROUP_CHANCE:
|
||||||
|
continue
|
||||||
|
var open := _tunnel_tiles(leg)
|
||||||
|
if open.size() < TUNNEL_GROUP_MAX * 2:
|
||||||
|
continue
|
||||||
|
var centre: Vector2i = open[pick % open.size()]
|
||||||
|
var def := flavour.apply_to_enemy(_pick_enemy(rng))
|
||||||
|
var spots: Array[Vector2] = []
|
||||||
|
for t in open:
|
||||||
|
if absi(t.x - centre.x) > TUNNEL_GROUP_SPREAD \
|
||||||
|
or absi(t.y - centre.y) > TUNNEL_GROUP_SPREAD:
|
||||||
|
continue
|
||||||
|
var at := world.map.tile_centre(t.x, t.y)
|
||||||
|
# Per member, not per group: members stand up to two tiles off the
|
||||||
|
# centre, so a centre that clears the entrance does not mean every
|
||||||
|
# member does.
|
||||||
|
if at.distance_to(world.spawn_point) < clear_of_spawn:
|
||||||
|
continue
|
||||||
|
if not world.map.circle_blocked(at, def.radius):
|
||||||
|
spots.append(at)
|
||||||
|
if spots.size() < TUNNEL_GROUP_MIN:
|
||||||
|
continue
|
||||||
|
for i in range(spots.size() - 1, 0, -1):
|
||||||
|
var j := rng.randi_range(0, i)
|
||||||
|
var held := spots[i]
|
||||||
|
spots[i] = spots[j]
|
||||||
|
spots[j] = held
|
||||||
|
for i in mini(size, spots.size()):
|
||||||
|
# A fresh definition per member: apply_to_enemy mutates in place,
|
||||||
|
# and sharing one object would scale it once per member.
|
||||||
|
var member := def if i == 0 else flavour.apply_to_enemy(Content.enemy(def.id))
|
||||||
|
world.spawn_enemy(member, spots[i], rng.randi_range(0, 120))
|
||||||
|
|
||||||
|
|
||||||
|
## Floor tiles of [param leg] that are tunnel rather than room: clear of every
|
||||||
|
## room by a tile, so a group is never standing in a doorway, and outside the
|
||||||
|
## boss arena.
|
||||||
|
func _tunnel_tiles(leg: Rect2i) -> Array[Vector2i]:
|
||||||
|
var out: Array[Vector2i] = []
|
||||||
|
for ty in range(leg.position.y, leg.end.y):
|
||||||
|
for tx in range(leg.position.x, leg.end.x):
|
||||||
|
if world.map.at(tx, ty) != MapGrid.Kind.FLOOR:
|
||||||
|
continue
|
||||||
|
var here := Vector2i(tx, ty)
|
||||||
|
if boss_room.has_point(here):
|
||||||
|
continue
|
||||||
|
var in_room := false
|
||||||
|
for r in rooms:
|
||||||
|
if r.grow(1).has_point(here):
|
||||||
|
in_room = true
|
||||||
|
break
|
||||||
|
if not in_room:
|
||||||
|
out.append(here)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## The furthest any enemy in the game notices a player from.
|
||||||
|
static func _widest_aggro() -> float:
|
||||||
|
var widest := 0.0
|
||||||
|
for id in Content.ALL_ENEMIES:
|
||||||
|
widest = maxf(widest, Content.enemy(id).aggro_range)
|
||||||
|
return widest
|
||||||
|
|
||||||
|
|
||||||
func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef:
|
func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef:
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ var request_escape: bool = false
|
|||||||
## Who is online and where, for the hub's player list. Server-pushed.
|
## Who is online and where, for the hub's player list. Server-pushed.
|
||||||
var roster: Array[Dictionary] = []
|
var roster: Array[Dictionary] = []
|
||||||
|
|
||||||
|
## The character this player just lost, as { "name", "level", "where" }, or
|
||||||
|
## empty. Death is permanent and the server unbinds you on the same tick, so
|
||||||
|
## without this the only sign anything happened was that the roster screen
|
||||||
|
## appeared. Recorded from the server's own PLAYER_DIED event, never guessed.
|
||||||
|
var last_fallen: Dictionary = {}
|
||||||
|
|
||||||
## This account's characters, and which one is being played. Server-pushed;
|
## This account's characters, and which one is being played. Server-pushed;
|
||||||
## the client never invents an entry.
|
## the client never invents an entry.
|
||||||
var characters: Array[Dictionary] = []
|
var characters: Array[Dictionary] = []
|
||||||
@@ -546,6 +552,14 @@ func on_events(data: PackedByteArray) -> void:
|
|||||||
SimEvent.Type.PLAYER_DIED:
|
SimEvent.Type.PLAYER_DIED:
|
||||||
if int(ev["peer"]) == my_peer:
|
if int(ev["peer"]) == my_peer:
|
||||||
my_alive = false
|
my_alive = false
|
||||||
|
var who := current_character()
|
||||||
|
if not who.is_empty():
|
||||||
|
last_fallen = {
|
||||||
|
"name": String(who["name"]),
|
||||||
|
"level": Progression.level_for_xp(my_total_xp),
|
||||||
|
"where": Dungeons.get_or_default(dungeon_id).display_name
|
||||||
|
if instance_kind == Protocol.InstanceKind.DUNGEON else "",
|
||||||
|
}
|
||||||
hud_dirty.emit()
|
hud_dirty.emit()
|
||||||
SimEvent.Type.PLAYER_RESPAWNED:
|
SimEvent.Type.PLAYER_RESPAWNED:
|
||||||
if int(ev["peer"]) == my_peer:
|
if int(ev["peer"]) == my_peer:
|
||||||
@@ -620,6 +634,20 @@ func loot_in_reach() -> Dictionary:
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
## The entrance this player is standing on, as { "pos", "dungeon" }, or empty.
|
||||||
|
## Purely for the prompt, like loot_in_reach: nearest wins, the same tie-break
|
||||||
|
## SimWorld.portal_at() uses, and the server decides for itself regardless.
|
||||||
|
func portal_here() -> Dictionary:
|
||||||
|
var best := {}
|
||||||
|
var best_d := SimConfig.PORTAL_RADIUS * SimConfig.PORTAL_RADIUS
|
||||||
|
for p: Dictionary in portals:
|
||||||
|
var d: float = predicted_pos.distance_squared_to(p["pos"])
|
||||||
|
if d <= best_d:
|
||||||
|
best_d = d
|
||||||
|
best = p
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
## True when every slot is taken, so the HUD can explain why a pickup did
|
## True when every slot is taken, so the HUD can explain why a pickup did
|
||||||
## nothing rather than looking broken.
|
## nothing rather than looking broken.
|
||||||
func inventory_full() -> bool:
|
func inventory_full() -> bool:
|
||||||
|
|||||||
+12
-6
@@ -151,10 +151,11 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
|
|||||||
# Connect each room to the previous one, then the last to the arena door,
|
# Connect each room to the previous one, then the last to the arena door,
|
||||||
# so every room is reachable by construction rather than by luck.
|
# so every room is reachable by construction rather than by luck.
|
||||||
var widths := PackedInt32Array()
|
var widths := PackedInt32Array()
|
||||||
|
var legs: Array[Rect2i] = []
|
||||||
for i in range(1, rooms.size()):
|
for i in range(1, rooms.size()):
|
||||||
var from := _centre_of(rooms[i - 1])
|
var from := _centre_of(rooms[i - 1])
|
||||||
widths.append_array(_carve_corridor(grid, rng, from, _centre_of(rooms[i]),
|
widths.append_array(_carve_corridor(grid, rng, from, _centre_of(rooms[i]),
|
||||||
(from.x + from.y) % 2 == 0, boss_room))
|
(from.x + from.y) % 2 == 0, boss_room, legs))
|
||||||
if not rooms.is_empty():
|
if not rooms.is_empty():
|
||||||
# Aimed at the tile OUTSIDE the door, and always arriving along the
|
# Aimed at the tile OUTSIDE the door, and always arriving along the
|
||||||
# door's own row. Aiming at the door itself, an L that bent the other
|
# door's own row. Aiming at the door itself, an L that bent the other
|
||||||
@@ -164,7 +165,7 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
|
|||||||
# rather than eating into it.
|
# rather than eating into it.
|
||||||
var outside := door + Vector2i(-1, 0)
|
var outside := door + Vector2i(-1, 0)
|
||||||
widths.append_array(_carve_corridor(grid, rng,
|
widths.append_array(_carve_corridor(grid, rng,
|
||||||
_centre_of(rooms[rooms.size() - 1]), outside, false, boss_room))
|
_centre_of(rooms[rooms.size() - 1]), outside, false, boss_room, legs))
|
||||||
# The door tile itself, and the tile outside it, must be floor.
|
# The door tile itself, and the tile outside it, must be floor.
|
||||||
grid.set_tile(door.x, door.y, MapGrid.Kind.FLOOR)
|
grid.set_tile(door.x, door.y, MapGrid.Kind.FLOOR)
|
||||||
grid.set_tile(outside.x, outside.y, MapGrid.Kind.FLOOR)
|
grid.set_tile(outside.x, outside.y, MapGrid.Kind.FLOOR)
|
||||||
@@ -193,6 +194,10 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
|
|||||||
# builds a world -- it is here so a test can check the widths actually
|
# builds a world -- it is here so a test can check the widths actually
|
||||||
# vary, which the tiles alone cannot say once tunnels overlap.
|
# vary, which the tiles alone cannot say once tunnels overlap.
|
||||||
"corridor_widths": widths,
|
"corridor_widths": widths,
|
||||||
|
# The rectangle each tunnel leg was carved over. Overlaps the rooms it
|
||||||
|
# joins at its ends, so whoever uses it decides what counts as tunnel --
|
||||||
|
# Instance._populate_tunnels keeps only floor clear of every room.
|
||||||
|
"tunnels": legs,
|
||||||
# Which boss lives here follows from WHICH arena was stamped. Choosing
|
# Which boss lives here follows from WHICH arena was stamped. Choosing
|
||||||
# it anywhere else would let a dungeon put the Cantor in the Warden's
|
# it anywhere else would let a dungeon put the Cantor in the Warden's
|
||||||
# hall, where its patterns assume geometry that is not there.
|
# hall, where its patterns assume geometry that is not there.
|
||||||
@@ -214,7 +219,7 @@ static func _centre_of(r: Rect2i) -> Vector2i:
|
|||||||
## walls it was drawn with.
|
## walls it was drawn with.
|
||||||
static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
|
static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
|
||||||
from: Vector2i, to: Vector2i, horizontal_first: bool,
|
from: Vector2i, to: Vector2i, horizontal_first: bool,
|
||||||
keep_out: Rect2i) -> PackedInt32Array:
|
keep_out: Rect2i, legs: Array[Rect2i]) -> PackedInt32Array:
|
||||||
var first := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH)
|
var first := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH)
|
||||||
var second := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH)
|
var second := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH)
|
||||||
var bend := Vector2i(to.x, from.y) if horizontal_first else Vector2i(from.x, to.y)
|
var bend := Vector2i(to.x, from.y) if horizontal_first else Vector2i(from.x, to.y)
|
||||||
@@ -223,8 +228,8 @@ static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
|
|||||||
# the INNER side of the bend, so leaving this out never narrows a tunnel
|
# the INNER side of the bend, so leaving this out never narrows a tunnel
|
||||||
# below its width -- it just bevels the outer corner, which reads as a
|
# below its width -- it just bevels the outer corner, which reads as a
|
||||||
# notch rather than a turn.
|
# notch rather than a turn.
|
||||||
_carve_leg(grid, from, bend, first, second / 2, keep_out)
|
legs.append(_carve_leg(grid, from, bend, first, second / 2, keep_out))
|
||||||
_carve_leg(grid, bend, to, second, first / 2, keep_out)
|
legs.append(_carve_leg(grid, bend, to, second, first / 2, keep_out))
|
||||||
return PackedInt32Array([first, second])
|
return PackedInt32Array([first, second])
|
||||||
|
|
||||||
|
|
||||||
@@ -235,7 +240,7 @@ static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
|
|||||||
## world is sealed by construction, so a wide tunnel that hugs the edge must
|
## world is sealed by construction, so a wide tunnel that hugs the edge must
|
||||||
## stop one tile short of it rather than open the map to nothing.
|
## stop one tile short of it rather than open the map to nothing.
|
||||||
static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int,
|
static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int,
|
||||||
overshoot: int, keep_out: Rect2i) -> void:
|
overshoot: int, keep_out: Rect2i) -> Rect2i:
|
||||||
# Width is laid across the leg, centred on its line: two tiles is the line
|
# Width is laid across the leg, centred on its line: two tiles is the line
|
||||||
# and the one after it, three is one either side, four leans one way.
|
# and the one after it, three is one either side, four leans one way.
|
||||||
var lo := -(width - 1) / 2
|
var lo := -(width - 1) / 2
|
||||||
@@ -254,6 +259,7 @@ static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int,
|
|||||||
if keep_out.has_point(Vector2i(x, y)):
|
if keep_out.has_point(Vector2i(x, y)):
|
||||||
continue
|
continue
|
||||||
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
|
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
|
||||||
|
return rect
|
||||||
|
|
||||||
|
|
||||||
## A few pillars and pits inside generated rooms, so open rooms still have
|
## A few pillars and pits inside generated rooms, so open rooms still have
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ var _list: VBoxContainer
|
|||||||
var _name_field: LineEdit
|
var _name_field: LineEdit
|
||||||
var _create_button: Button
|
var _create_button: Button
|
||||||
var _status: Label
|
var _status: Label
|
||||||
|
var _fallen: Label
|
||||||
var _title: Label
|
var _title: Label
|
||||||
var _close_button: Button
|
var _close_button: Button
|
||||||
var _known: Array[Dictionary] = []
|
var _known: Array[Dictionary] = []
|
||||||
@@ -51,6 +52,14 @@ func _ready() -> void:
|
|||||||
_title.add_theme_font_size_override("font_size", 26)
|
_title.add_theme_font_size_override("font_size", 26)
|
||||||
panel.add_child(_title)
|
panel.add_child(_title)
|
||||||
|
|
||||||
|
_fallen = Label.new()
|
||||||
|
_fallen.add_theme_font_size_override("font_size", 14)
|
||||||
|
_fallen.add_theme_color_override("font_color", Color(0.95, 0.55, 0.55))
|
||||||
|
_fallen.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_fallen.custom_minimum_size = Vector2(460.0, 0.0)
|
||||||
|
_fallen.visible = false
|
||||||
|
panel.add_child(_fallen)
|
||||||
|
|
||||||
_list = VBoxContainer.new()
|
_list = VBoxContainer.new()
|
||||||
_list.add_theme_constant_override("separation", 4)
|
_list.add_theme_constant_override("separation", 4)
|
||||||
panel.add_child(_list)
|
panel.add_child(_list)
|
||||||
@@ -166,5 +175,13 @@ func _row_for(c: Dictionary) -> Control:
|
|||||||
|
|
||||||
## Whether the player may dismiss this screen. False when they have nothing to
|
## Whether the player may dismiss this screen. False when they have nothing to
|
||||||
## play, in which case there is nowhere to dismiss it to.
|
## play, in which case there is nowhere to dismiss it to.
|
||||||
|
## A line for the character that was just lost, above the list of the ones
|
||||||
|
## that are left. Separate from the status line, which is for refusals: a death
|
||||||
|
## is not an error, and the status line clears the moment anything else is said.
|
||||||
|
func set_fallen(text: String) -> void:
|
||||||
|
_fallen.text = text
|
||||||
|
_fallen.visible = not text.is_empty()
|
||||||
|
|
||||||
|
|
||||||
func set_dismissible(can_close: bool) -> void:
|
func set_dismissible(can_close: bool) -> void:
|
||||||
_close_button.visible = can_close
|
_close_button.visible = can_close
|
||||||
+457
-179
@@ -1,6 +1,13 @@
|
|||||||
extends CanvasLayer
|
extends CanvasLayer
|
||||||
## Heads-up display. Built in code rather than as a scene because every element
|
## Heads-up display. Built in code rather than as a scene because every element
|
||||||
## is data-driven -- there is no layout here a designer would want to drag.
|
## is data-driven -- there is no layout here a designer would want to drag.
|
||||||
|
##
|
||||||
|
## Layout, because several things here used to land on top of each other:
|
||||||
|
## top-left is you (name, where, health, experience, escape, level-ups), stacked
|
||||||
|
## by a running cursor so nothing has a fixed row to collide on; top-centre is
|
||||||
|
## the boss; top-right is everyone else; the bottom centre is the inventory
|
||||||
|
## with the pickup prompt and the message feed stacked above it; the controls
|
||||||
|
## hint has the bottom-left corner to itself.
|
||||||
|
|
||||||
const MARGIN := 24.0
|
const MARGIN := 24.0
|
||||||
const BAR_W := 260.0
|
const BAR_W := 260.0
|
||||||
@@ -19,16 +26,51 @@ const SLOT_GAP := 8.0
|
|||||||
const BAR_CAP := 6.0
|
const BAR_CAP := 6.0
|
||||||
## How far above the bottom of the screen the inventory row sits.
|
## How far above the bottom of the screen the inventory row sits.
|
||||||
const SLOT_BOTTOM := 26.0
|
const SLOT_BOTTOM := 26.0
|
||||||
|
const BOSS_BAR_W := 480.0
|
||||||
|
|
||||||
|
## The message feed: how long a message stays, and how many at once.
|
||||||
|
const TOAST_LIFE := 2.6
|
||||||
|
const TOAST_FADE := 0.8
|
||||||
|
const TOAST_MAX := 4
|
||||||
|
## Health below this share of maximum pulses the screen edge.
|
||||||
|
const LOW_HEALTH := 0.35
|
||||||
|
## How long the controls hint stays up after arriving in a dungeon. In the hub
|
||||||
|
## it stays: that is where you are learning the keys, and nothing is shooting.
|
||||||
|
const HINT_HOLD := 6.0
|
||||||
|
const HINT_FADE := 1.5
|
||||||
|
|
||||||
|
const COL_TEXT := Color(0.86, 0.9, 0.97)
|
||||||
|
const COL_DIM := Color(0.58, 0.63, 0.74)
|
||||||
|
const COL_HEALTH := Color(0.35, 0.9, 0.6)
|
||||||
|
const COL_GOLD := Color(1.0, 0.85, 0.4)
|
||||||
|
const COL_DANGER := Color(1.0, 0.25, 0.3)
|
||||||
|
|
||||||
signal respawn_pressed
|
signal respawn_pressed
|
||||||
|
|
||||||
|
## What the world view could see this frame, handed over by the game scene so
|
||||||
|
## the boss bar can wait until the boss has actually been seen. See
|
||||||
|
## _draw_boss_bar.
|
||||||
|
var sight: Visibility = null
|
||||||
|
## Mirrors the F1 overlay. The instance number and frame rate are for whoever
|
||||||
|
## is debugging, not for whoever is playing, so they live behind it.
|
||||||
|
var debug_info: bool = false
|
||||||
|
|
||||||
var _canvas: Control
|
var _canvas: Control
|
||||||
var _status: Label
|
|
||||||
var _hint: Label
|
|
||||||
var _respawn_button: Button
|
var _respawn_button: Button
|
||||||
var _hit_flash: float = 0.0
|
var _hit_flash: float = 0.0
|
||||||
var client: ClientRuntime = null
|
var client: ClientRuntime = null
|
||||||
|
|
||||||
|
var _toasts: Array[Dictionary] = []
|
||||||
|
## Which boss, in which instance, has been seen -- see _draw_boss_bar.
|
||||||
|
var _boss_seen := ""
|
||||||
|
## The level last drawn and whose it was, so a level-up can be announced the
|
||||||
|
## moment it happens and a character switch is not mistaken for one.
|
||||||
|
var _seen_level: int = -1
|
||||||
|
var _seen_character := ""
|
||||||
|
## When the current instance was entered, for fading the controls hint.
|
||||||
|
var _instance_seen: int = -1
|
||||||
|
var _instance_since: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
layer = 10
|
layer = 10
|
||||||
@@ -45,20 +87,6 @@ func _ready() -> void:
|
|||||||
_canvas.draw.connect(_draw_hud)
|
_canvas.draw.connect(_draw_hud)
|
||||||
add_child(_canvas)
|
add_child(_canvas)
|
||||||
|
|
||||||
_status = _make_label(Vector2(MARGIN, MARGIN))
|
|
||||||
_hint = _make_label(Vector2(MARGIN, 0.0))
|
|
||||||
_hint.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
|
|
||||||
# `.position` assigns an ABSOLUTE coordinate regardless of anchors (that's
|
|
||||||
# what stranded this label off-screen despite the anchor being correct).
|
|
||||||
# offset_left/offset_top are the anchor-relative ones -- negative offset_top
|
|
||||||
# here means "56px above the bottom anchor", which is what "-56" was meant
|
|
||||||
# to say in the first place.
|
|
||||||
_hint.offset_left = MARGIN
|
|
||||||
# Above the inventory row, not through it. Derived from the row's own
|
|
||||||
# geometry rather than written down, because the slots grew from 46px to 64
|
|
||||||
# and a hardcoded -56 put the control hints straight across them.
|
|
||||||
_hint.offset_top = -(SLOT_BOTTOM + SLOT + 34.0)
|
|
||||||
|
|
||||||
# A real Button rather than something drawn in _draw_hud: this is the one
|
# A real Button rather than something drawn in _draw_hud: this is the one
|
||||||
# HUD element the player has to actually click, and it starts disabled so
|
# HUD element the player has to actually click, and it starts disabled so
|
||||||
# death is not exited by whatever key happened to be under a finger.
|
# death is not exited by whatever key happened to be under a finger.
|
||||||
@@ -74,24 +102,58 @@ func _ready() -> void:
|
|||||||
_canvas.add_child(_respawn_button)
|
_canvas.add_child(_respawn_button)
|
||||||
|
|
||||||
|
|
||||||
func _make_label(pos: Vector2) -> Label:
|
|
||||||
var l := Label.new()
|
|
||||||
l.position = pos
|
|
||||||
l.add_theme_font_size_override("font_size", 14)
|
|
||||||
l.add_theme_color_override("font_color", Color(0.8, 0.85, 0.95))
|
|
||||||
_canvas.add_child(l)
|
|
||||||
return l
|
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
client = Net.client
|
client = Net.client
|
||||||
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
|
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
|
||||||
_status.text = _status_text()
|
var now := _now()
|
||||||
_hint.text = _hint_text()
|
_toasts = _toasts.filter(func(t: Dictionary) -> bool:
|
||||||
|
return now - float(t["born"]) < TOAST_LIFE)
|
||||||
|
if client != null:
|
||||||
|
if client.instance_id != _instance_seen:
|
||||||
|
_instance_seen = client.instance_id
|
||||||
|
_instance_since = now
|
||||||
|
_watch_level()
|
||||||
_update_respawn_button()
|
_update_respawn_button()
|
||||||
_canvas.queue_redraw()
|
_canvas.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _now() -> float:
|
||||||
|
return float(Time.get_ticks_msec()) / 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
## A short line in the message feed above the inventory. Called for things the
|
||||||
|
## server just confirmed -- an item taken, used or dropped, a level gained -- so
|
||||||
|
## the feed is a record of what happened, never of what the client hoped.
|
||||||
|
func notify(text: String, colour: Color = COL_TEXT) -> void:
|
||||||
|
_toasts.append({"text": text, "colour": colour, "born": _now()})
|
||||||
|
while _toasts.size() > TOAST_MAX:
|
||||||
|
_toasts.pop_front()
|
||||||
|
|
||||||
|
|
||||||
|
func flash_hit() -> void:
|
||||||
|
_hit_flash = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
## Announce a level the moment the snapshot's experience crosses one. Watched
|
||||||
|
## rather than signalled because experience rides the snapshot, and the first
|
||||||
|
## sight of a character -- logging in, swapping -- must not read as a level-up.
|
||||||
|
func _watch_level() -> void:
|
||||||
|
var who := client.current_character()
|
||||||
|
if who.is_empty():
|
||||||
|
_seen_character = ""
|
||||||
|
return
|
||||||
|
var level := Progression.level_for_xp(client.my_total_xp)
|
||||||
|
if String(who["id"]) != _seen_character:
|
||||||
|
_seen_character = String(who["id"])
|
||||||
|
_seen_level = level
|
||||||
|
return
|
||||||
|
if level > _seen_level:
|
||||||
|
var where := "" if client.instance_kind == Protocol.InstanceKind.LOBBY \
|
||||||
|
else " -- a choice waits at the quartermaster"
|
||||||
|
notify("LEVEL %d%s" % [level, where], COL_GOLD)
|
||||||
|
_seen_level = level
|
||||||
|
|
||||||
|
|
||||||
func _update_respawn_button() -> void:
|
func _update_respawn_button() -> void:
|
||||||
var downed := client != null and not client.my_alive
|
var downed := client != null and not client.my_alive
|
||||||
_respawn_button.visible = downed
|
_respawn_button.visible = downed
|
||||||
@@ -105,91 +167,311 @@ func _update_respawn_button() -> void:
|
|||||||
else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait)
|
else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait)
|
||||||
|
|
||||||
|
|
||||||
func _status_text() -> String:
|
# --- Text -------------------------------------------------------------------
|
||||||
if client == null:
|
|
||||||
return "connecting..."
|
## Text with a one-pixel shadow. The floor art is busy and mid-toned, and plain
|
||||||
# Named rather than just "DUNGEON": there is more than one kind now, and
|
## light text over it was readable on the dark parts of a tile and not on the
|
||||||
# knowing which one you are standing in is the whole point of having two.
|
## light ones.
|
||||||
var where := "LOBBY"
|
func _text(at: Vector2, s: String, size: int, colour: Color,
|
||||||
if client.instance_kind != Protocol.InstanceKind.LOBBY:
|
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT, width: float = -1.0) -> void:
|
||||||
where = Dungeons.get_or_default(client.dungeon_id).display_name.to_upper()
|
var font := ThemeDB.fallback_font
|
||||||
var who := client.current_character()
|
_canvas.draw_string(font, at + Vector2(1.0, 1.0), s, align, width, size,
|
||||||
var name_part := ""
|
Color(0.0, 0.0, 0.0, 0.7 * colour.a))
|
||||||
if not who.is_empty():
|
_canvas.draw_string(font, at, s, align, width, size, colour)
|
||||||
name_part = "%s lv %d " % [
|
|
||||||
who["name"], Progression.level_for_xp(client.my_total_xp)]
|
|
||||||
return "%s%s instance %d hp %d/%d %d fps" % [
|
|
||||||
name_part, where, client.instance_id, client.my_hp, client.my_max_hp,
|
|
||||||
Engine.get_frames_per_second()]
|
|
||||||
|
|
||||||
|
|
||||||
func _hint_text() -> String:
|
func _text_width(s: String, size: int) -> float:
|
||||||
if client == null:
|
return ThemeDB.fallback_font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x
|
||||||
return ""
|
|
||||||
if not client.my_alive:
|
|
||||||
return "DOWN"
|
|
||||||
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
|
||||||
return "WASD move mouse aim LMB fire E on a ring to enter that dungeon Esc menu F1 hitboxes"
|
|
||||||
return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu F1 hitboxes"
|
|
||||||
|
|
||||||
|
|
||||||
|
func _centred(y: float, s: String, size: int, colour: Color) -> void:
|
||||||
|
_text(Vector2(0.0, y), s, size, colour, HORIZONTAL_ALIGNMENT_CENTER, _canvas.size.x)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Key names --------------------------------------------------------------
|
||||||
|
|
||||||
|
## What the player has [param action] bound to. Read from Settings every time
|
||||||
|
## rather than written into the strings: every one of these is rebindable, and
|
||||||
|
## the hint used to say "LMB fire" whatever fire was actually on.
|
||||||
|
static func key(action: String) -> String:
|
||||||
|
return Settings.short_label(action)
|
||||||
|
|
||||||
|
|
||||||
|
## The four movement keys as one word when they are single characters -- WASD,
|
||||||
|
## ZQSD, ESDF -- and spelled out when they are not, like the arrow keys.
|
||||||
|
static func move_keys() -> String:
|
||||||
|
var parts: Array[String] = []
|
||||||
|
for action in ["move_up", "move_left", "move_down", "move_right"]:
|
||||||
|
parts.append(key(action))
|
||||||
|
for p in parts:
|
||||||
|
if p.length() != 1:
|
||||||
|
return "/".join(parts)
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
## The slot keys, as a range when they run 1-2-3-4 and listed when they do not.
|
||||||
|
static func slot_keys(count: int) -> String:
|
||||||
|
var parts: Array[String] = []
|
||||||
|
for i in count:
|
||||||
|
parts.append(key("use_slot_%d" % (i + 1)))
|
||||||
|
var plain := true
|
||||||
|
for i in count:
|
||||||
|
if parts[i] != str(i + 1):
|
||||||
|
plain = false
|
||||||
|
return "1-%d" % count if plain else " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
static func controls_hint(in_hub: bool) -> String:
|
||||||
|
if in_hub:
|
||||||
|
return "%s move mouse aim %s fire %s on a ring to enter it %s menu" % [
|
||||||
|
move_keys(), key("fire"), key("interact"), key("system_menu")]
|
||||||
|
return "%s move %s fire hold %s to return to the hub %s menu" % [
|
||||||
|
move_keys(), key("fire"), key("emergency_escape"), key("system_menu")]
|
||||||
|
|
||||||
|
|
||||||
|
static func inventory_hint(count: int) -> String:
|
||||||
|
return "%s use shift+%s drop %s pick up" % [
|
||||||
|
slot_keys(count), slot_keys(count), key("interact")]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Drawing ----------------------------------------------------------------
|
||||||
|
|
||||||
func _draw_hud() -> void:
|
func _draw_hud() -> void:
|
||||||
if client == null:
|
if client == null:
|
||||||
|
_text(Vector2(MARGIN, MARGIN + 14.0), "connecting...", 14, COL_DIM)
|
||||||
return
|
return
|
||||||
var origin := Vector2(MARGIN, MARGIN + 28.0)
|
# Under everything, so the edge of the screen colours without dimming any of
|
||||||
_bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)),
|
# the text on top of it.
|
||||||
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18))
|
_draw_vignette()
|
||||||
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 4.0))
|
|
||||||
|
|
||||||
if client.my_escaping:
|
|
||||||
_bar(origin + Vector2(0.0, (BAR_H + 4.0) * 2.0), client.my_escape,
|
|
||||||
Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2))
|
|
||||||
_canvas.draw_string(ThemeDB.fallback_font,
|
|
||||||
origin + Vector2(BAR_W + 12.0, (BAR_H + 4.0) * 2.0 + BAR_H - 3.0),
|
|
||||||
"ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0))
|
|
||||||
|
|
||||||
|
var y := _draw_player_panel()
|
||||||
|
_draw_level_up_notice(y)
|
||||||
_draw_boss_bar()
|
_draw_boss_bar()
|
||||||
|
_draw_arriving()
|
||||||
if client.my_spawn_grace:
|
|
||||||
_canvas.draw_string(ThemeDB.fallback_font,
|
|
||||||
origin + Vector2(BAR_W + 12.0, BAR_H),
|
|
||||||
"ARRIVING -- invulnerable, weapons cold",
|
|
||||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.6, 0.9, 1.0))
|
|
||||||
|
|
||||||
_draw_cleared_countdown()
|
_draw_cleared_countdown()
|
||||||
_draw_roster()
|
_draw_others()
|
||||||
_draw_inventory()
|
_draw_inventory()
|
||||||
_draw_pickup_prompt()
|
var above := _draw_pickup_prompt()
|
||||||
_draw_level_up_notice()
|
_draw_toasts(above)
|
||||||
|
_draw_controls_hint()
|
||||||
|
_draw_debug_info()
|
||||||
|
|
||||||
if not client.my_alive:
|
if not client.my_alive:
|
||||||
# Centred on the canvas, which is only correct because _canvas actually
|
_centred(_canvas.size.y * 0.5, "DOWN", 34, Color(1.0, 0.4, 0.4))
|
||||||
# has the viewport's size now -- see the anchor note in _ready().
|
|
||||||
var centre := _canvas.size * 0.5
|
|
||||||
_canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(52.0, 8.0),
|
|
||||||
"DOWN", HORIZONTAL_ALIGNMENT_LEFT, -1, 34, Color(1.0, 0.4, 0.4))
|
|
||||||
|
|
||||||
|
|
||||||
if _hit_flash > 0.0:
|
## Who you are, where you are, and how you are doing. Returns the y below the
|
||||||
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
|
## last thing drawn, so whatever comes next stacks rather than overlaps.
|
||||||
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
|
func _draw_player_panel() -> float:
|
||||||
|
var x := MARGIN
|
||||||
|
var y := MARGIN
|
||||||
|
var who := client.current_character()
|
||||||
|
if not who.is_empty():
|
||||||
|
var name_text := String(who["name"])
|
||||||
|
_text(Vector2(x, y + 15.0), name_text, 17, COL_TEXT)
|
||||||
|
_text(Vector2(x + _text_width(name_text, 17) + 10.0, y + 15.0),
|
||||||
|
"level %d" % Progression.level_for_xp(client.my_total_xp), 13, COL_DIM)
|
||||||
|
y += 22.0
|
||||||
|
|
||||||
|
# Named rather than just "DUNGEON": there is more than one kind, and knowing
|
||||||
|
# which one you are standing in is the whole point of having more than one.
|
||||||
|
var where := "THE HUB"
|
||||||
|
var tint := COL_DIM
|
||||||
|
if client.instance_kind != Protocol.InstanceKind.LOBBY:
|
||||||
|
var def := Dungeons.get_or_default(client.dungeon_id)
|
||||||
|
where = def.display_name.to_upper()
|
||||||
|
tint = def.tint
|
||||||
|
_text(Vector2(x, y + 12.0), where, 12, tint)
|
||||||
|
y += 20.0
|
||||||
|
|
||||||
|
var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1))
|
||||||
|
# Red when low, not a blend toward it: green-to-red through the middle is
|
||||||
|
# brown, which reads as a colour rather than as a warning.
|
||||||
|
var hp_tint := COL_HEALTH if frac >= LOW_HEALTH else Color(1.0, 0.38, 0.36)
|
||||||
|
_bar(Vector2(x, y), frac, hp_tint)
|
||||||
|
_text(Vector2(x + BAR_W + 10.0, y + 13.0),
|
||||||
|
"%d / %d" % [client.my_hp, client.my_max_hp], 12, hp_tint)
|
||||||
|
y += BAR_H + 4.0
|
||||||
|
|
||||||
|
if _draw_xp_bar(Vector2(x, y)):
|
||||||
|
y += BAR_H + 4.0
|
||||||
|
|
||||||
|
if client.my_escaping:
|
||||||
|
_bar(Vector2(x, y), client.my_escape, Color(0.5, 0.85, 1.0))
|
||||||
|
_text(Vector2(x + BAR_W + 10.0, y + 13.0), "ESCAPING", 12,
|
||||||
|
Color(0.5, 0.85, 1.0))
|
||||||
|
y += BAR_H + 4.0
|
||||||
|
return y
|
||||||
|
|
||||||
|
|
||||||
|
## A thin bar under health: progress toward the next level, and the level
|
||||||
|
## itself. Drawn from the server's numbers, never recomputed locally. Returns
|
||||||
|
## whether it drew, since a player at the roster screen has no character.
|
||||||
|
func _draw_xp_bar(at: Vector2) -> bool:
|
||||||
|
if client.current_character().is_empty():
|
||||||
|
return false
|
||||||
|
# Derived from the snapshot's live experience total rather than the roster's
|
||||||
|
# copy, which only arrives when the set of characters changes.
|
||||||
|
var level := Progression.level_for_xp(client.my_total_xp)
|
||||||
|
var capped := level >= Progression.MAX_LEVEL
|
||||||
|
var progress := Progression.level_progress(client.my_total_xp)
|
||||||
|
var tint := COL_GOLD if capped else Color(0.6, 0.55, 1.0)
|
||||||
|
_bar(at, progress, tint)
|
||||||
|
# The number as well as the bar: "how far to the next level" is a question
|
||||||
|
# a bar answers vaguely and a percentage answers exactly.
|
||||||
|
var text := "MAX" if capped else "%d%% to level %d" % [
|
||||||
|
int(floor(progress * 100.0)), level + 1]
|
||||||
|
_text(at + Vector2(BAR_W + 10.0, 13.0), text, 12, tint)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
## Unspent level-ups. Shown wherever you are, because the choice is banked in a
|
## Unspent level-ups. Shown wherever you are, because the choice is banked in a
|
||||||
## dungeon and spent in the hub -- if it only appeared next to the NPC, a player
|
## dungeon and spent in the hub -- if it only appeared next to the NPC, a player
|
||||||
## would have to already know to go and look.
|
## would have to already know to go and look.
|
||||||
func _draw_level_up_notice() -> void:
|
func _draw_level_up_notice(y: float) -> void:
|
||||||
if client.upgrades_pending <= 0:
|
if client.upgrades_pending <= 0:
|
||||||
return
|
return
|
||||||
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004)
|
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004)
|
||||||
var where := " — see the quartermaster in the hub" \
|
var where := " -- see the quartermaster in the hub" \
|
||||||
if client.instance_kind != Protocol.InstanceKind.LOBBY else ""
|
if client.instance_kind != Protocol.InstanceKind.LOBBY else ""
|
||||||
_canvas.draw_string(ThemeDB.fallback_font,
|
_text(Vector2(MARGIN, y + 16.0),
|
||||||
Vector2(MARGIN, MARGIN + 74.0),
|
|
||||||
"%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending,
|
"%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending,
|
||||||
"" if client.upgrades_pending == 1 else "S", where],
|
"" if client.upgrades_pending == 1 else "S", where],
|
||||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 14,
|
13, Color(COL_GOLD, 0.6 + 0.4 * pulse))
|
||||||
Color(1.0, 0.85, 0.4, 0.6 + 0.4 * pulse))
|
|
||||||
|
|
||||||
|
## The boss's health, once you have seen the boss.
|
||||||
|
##
|
||||||
|
## The server sends the boss to anyone within the actor interest radius, which
|
||||||
|
## is deliberately wider than sight and passes through walls -- so the bar used
|
||||||
|
## to announce "Warden of the Fold" to a player still standing in the entrance
|
||||||
|
## room, which says exactly where the boss is to someone who has not found it.
|
||||||
|
## It now waits until the fog has actually lit the boss once, and then stays for
|
||||||
|
## the rest of the fight: losing the bar every time the boss stepped behind a
|
||||||
|
## pillar would be worse than the leak.
|
||||||
|
func _draw_boss_bar() -> void:
|
||||||
|
var b := client.boss_state()
|
||||||
|
if b.is_empty() or client.boss_def == null:
|
||||||
|
return
|
||||||
|
var seen_key := "%d:%d" % [client.instance_id, int(b["id"])]
|
||||||
|
if _boss_seen != seen_key:
|
||||||
|
if sight != null and sight.light_at(b["pos"]) <= 0.05:
|
||||||
|
return
|
||||||
|
_boss_seen = seen_key
|
||||||
|
var pos := Vector2((_canvas.size.x - BOSS_BAR_W) * 0.5, MARGIN + 20.0)
|
||||||
|
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
|
||||||
|
var rect := Rect2(pos, Vector2(BOSS_BAR_W, BAR_H))
|
||||||
|
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
|
||||||
|
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
|
||||||
|
Vector2((BOSS_BAR_W - 6.0) * frac, BAR_H - 6.0))
|
||||||
|
if inner.size.x > 0.5:
|
||||||
|
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false,
|
||||||
|
Color(0.95, 0.35, 0.45))
|
||||||
|
var phase_index := int(b["phase"])
|
||||||
|
var phase_name := ""
|
||||||
|
if phase_index < client.boss_def.phases.size():
|
||||||
|
phase_name = client.boss_def.phases[phase_index].name
|
||||||
|
_text(Vector2(pos.x, pos.y - 6.0), client.boss_def.display_name, 15,
|
||||||
|
Color(1.0, 0.75, 0.8))
|
||||||
|
_text(Vector2(pos.x, pos.y - 6.0), phase_name, 12, Color(1.0, 0.6, 0.65, 0.85),
|
||||||
|
HORIZONTAL_ALIGNMENT_RIGHT, BOSS_BAR_W)
|
||||||
|
|
||||||
|
|
||||||
|
## Arrival protection, as a banner rather than a line tucked beside the health
|
||||||
|
## bar: it is the one moment the rules are different, and both halves of it --
|
||||||
|
## you cannot be hurt, and you cannot shoot -- change what you should do.
|
||||||
|
func _draw_arriving() -> void:
|
||||||
|
if not client.my_spawn_grace:
|
||||||
|
return
|
||||||
|
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.008)
|
||||||
|
var y := _canvas.size.y * 0.28
|
||||||
|
_centred(y, "ARRIVING", 24, Color(0.6, 0.9, 1.0, 0.75 + 0.25 * pulse))
|
||||||
|
_centred(y + 22.0, "invulnerable · weapons cold", 13, Color(0.6, 0.9, 1.0, 0.8))
|
||||||
|
|
||||||
|
|
||||||
|
## Shown after the boss dies, so the victory lap has a visible clock on it.
|
||||||
|
func _draw_cleared_countdown() -> void:
|
||||||
|
if client.cleared_countdown >= Protocol.COUNTDOWN_NONE:
|
||||||
|
return
|
||||||
|
var y := _canvas.size.y * 0.5 - 60.0
|
||||||
|
_centred(y, "DUNGEON CLEARED", 24, Color(0.6, 1.0, 0.75))
|
||||||
|
_centred(y + 22.0, "returning to the hub in %ds" % client.cleared_countdown, 14,
|
||||||
|
Color(0.6, 1.0, 0.75, 0.85))
|
||||||
|
|
||||||
|
|
||||||
|
## Top right: in the hub, who is online and where; in a dungeon, the party you
|
||||||
|
## are in and how each of them is doing. Different questions -- in the hub you
|
||||||
|
## are deciding whether to go in, in a dungeon you are deciding who needs help.
|
||||||
|
func _draw_others() -> void:
|
||||||
|
if client.roster.is_empty():
|
||||||
|
return
|
||||||
|
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
||||||
|
_draw_online()
|
||||||
|
else:
|
||||||
|
_draw_party()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_online() -> void:
|
||||||
|
var x := _canvas.size.x - 240.0
|
||||||
|
var y := MARGIN + 12.0
|
||||||
|
_text(Vector2(x, y), "ONLINE", 13, COL_DIM)
|
||||||
|
y += 20.0
|
||||||
|
for entry in client.roster:
|
||||||
|
var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON
|
||||||
|
var where := "in a dungeon" if in_dungeon else "in the hub"
|
||||||
|
var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9)
|
||||||
|
if not entry["alive"]:
|
||||||
|
col = Color(0.85, 0.4, 0.4)
|
||||||
|
where += ", down"
|
||||||
|
var me := int(entry["peer"]) == client.my_peer
|
||||||
|
_text(Vector2(x, y), "%s%s" % [entry["name"], " (you)" if me else ""], 13, col)
|
||||||
|
_text(Vector2(x, y), where, 11, Color(col, 0.7), HORIZONTAL_ALIGNMENT_RIGHT, 216.0)
|
||||||
|
y += 18.0
|
||||||
|
|
||||||
|
|
||||||
|
## Everyone else in this dungeon, with their health. The snapshot only carries
|
||||||
|
## players inside the interest radius, so a party member further away than that
|
||||||
|
## is listed without a bar -- the client genuinely does not know.
|
||||||
|
func _draw_party() -> void:
|
||||||
|
var near := {}
|
||||||
|
for p in client.remote_players():
|
||||||
|
near[int(p["peer"])] = p
|
||||||
|
var members: Array[Dictionary] = []
|
||||||
|
for entry in client.roster:
|
||||||
|
if int(entry["instance"]) == client.instance_id and int(entry["peer"]) != client.my_peer:
|
||||||
|
members.append(entry)
|
||||||
|
if members.is_empty():
|
||||||
|
return
|
||||||
|
var x := _canvas.size.x - 240.0
|
||||||
|
var y := MARGIN + 12.0
|
||||||
|
_text(Vector2(x, y), "PARTY", 13, COL_DIM)
|
||||||
|
y += 10.0
|
||||||
|
var w := 140.0
|
||||||
|
for entry in members:
|
||||||
|
var peer := int(entry["peer"])
|
||||||
|
_text(Vector2(x, y + 13.0), String(entry["name"]), 13, COL_TEXT)
|
||||||
|
var rec: Dictionary = near.get(peer, {})
|
||||||
|
var bar_at := Vector2(x + 76.0, y + 3.0)
|
||||||
|
if rec.is_empty():
|
||||||
|
_text(Vector2(bar_at.x, y + 13.0), "out of range", 11, COL_DIM)
|
||||||
|
else:
|
||||||
|
var flags := int(rec["flags"])
|
||||||
|
var frac := float(rec["hp"]) / float(maxi(int(rec["max_hp"]), 1))
|
||||||
|
var col: Color = rec["colour"]
|
||||||
|
if (flags & Protocol.F_ALIVE) == 0:
|
||||||
|
_text(Vector2(bar_at.x, y + 13.0), "down", 12, COL_DANGER)
|
||||||
|
else:
|
||||||
|
var rect := Rect2(bar_at, Vector2(w, 12.0))
|
||||||
|
_canvas.draw_rect(rect, Color(0.0, 0.0, 0.0, 0.55))
|
||||||
|
_canvas.draw_rect(Rect2(rect.position + Vector2(2.0, 2.0),
|
||||||
|
Vector2((w - 4.0) * clampf(frac, 0.0, 1.0), 8.0)),
|
||||||
|
col if frac >= LOW_HEALTH else COL_DANGER)
|
||||||
|
if (flags & Protocol.F_LINKDEAD) != 0:
|
||||||
|
_text(Vector2(bar_at.x + w + 6.0, y + 13.0), "dropped", 11, COL_DIM)
|
||||||
|
elif (flags & Protocol.F_ESCAPING) != 0:
|
||||||
|
_text(Vector2(bar_at.x + w + 6.0, y + 13.0), "leaving", 11,
|
||||||
|
Color(0.5, 0.85, 1.0))
|
||||||
|
y += 20.0
|
||||||
|
|
||||||
|
|
||||||
## Four slots, always on screen. Deliberately not a panel you open: an
|
## Four slots, always on screen. Deliberately not a panel you open: an
|
||||||
@@ -213,126 +495,126 @@ func _draw_inventory() -> void:
|
|||||||
# way every other pressed thing in the interface does.
|
# way every other pressed thing in the interface does.
|
||||||
var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
|
var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
|
||||||
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false)
|
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false)
|
||||||
# The slot number, because the key that uses it is the only thing the
|
# The key that uses the slot, because that is the only thing the player
|
||||||
# player actually needs to know about a slot.
|
# actually needs to know about it -- and it is whatever they bound.
|
||||||
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
|
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
|
||||||
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM)
|
key("use_slot_%d" % (i + 1)), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM)
|
||||||
if def == null:
|
if def == null:
|
||||||
continue
|
continue
|
||||||
var icon := Art.item_icon(item)
|
var icon := Art.item_icon(item)
|
||||||
var size := icon.size * Art.SCALE
|
var size := icon.size * Art.SCALE
|
||||||
_canvas.draw_texture_rect_region(Art.TILESET,
|
_canvas.draw_texture_rect_region(Art.TILESET,
|
||||||
Rect2(at + (Vector2(SLOT, SLOT) - size) * 0.5, size), icon)
|
Rect2(at + (Vector2(SLOT, SLOT) - size) * 0.5, size), icon)
|
||||||
_canvas.draw_string(ThemeDB.fallback_font,
|
_text(Vector2(origin.x, origin.y + SLOT + 15.0), inventory_hint(count), 11,
|
||||||
Vector2(origin.x, origin.y + SLOT + 15.0),
|
Color(COL_DIM, 0.85), HORIZONTAL_ALIGNMENT_CENTER, total)
|
||||||
"1-%d use shift+1-%d drop E pick up" % [count, count],
|
|
||||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.5, 0.55, 0.66))
|
|
||||||
|
|
||||||
|
|
||||||
## What pressing interact right now would do. The server runs the same search
|
## What pressing interact right now would do. The server runs the same search
|
||||||
## for itself and does not care what this concluded -- this is a label, not a
|
## for itself and does not care what this concluded -- this is a label, not a
|
||||||
## decision.
|
## decision. Returns the y the next thing up should stack from.
|
||||||
func _draw_pickup_prompt() -> void:
|
##
|
||||||
|
## Loot first, then an entrance, which is the order the server resolves the
|
||||||
|
## same key in: an item in reach is taken, and only a press that took nothing
|
||||||
|
## goes through the portal underneath it.
|
||||||
|
func _draw_pickup_prompt() -> float:
|
||||||
|
var above := _canvas.size.y - SLOT_BOTTOM - SLOT - 14.0
|
||||||
|
if not client.my_alive:
|
||||||
|
return above
|
||||||
var near := client.loot_in_reach()
|
var near := client.loot_in_reach()
|
||||||
if near.is_empty() or not client.my_alive:
|
if near.is_empty():
|
||||||
return
|
var portal := client.portal_here()
|
||||||
|
if portal.is_empty() or client.instance_kind != Protocol.InstanceKind.LOBBY:
|
||||||
|
return above
|
||||||
|
var def := Dungeons.get_or_default(portal["dungeon"])
|
||||||
|
_centred(above, "%s enter %s" % [key("interact"), def.display_name], 15, def.tint)
|
||||||
|
return above - 22.0
|
||||||
var item := Items.by_index(int(near["item"]))
|
var item := Items.by_index(int(near["item"]))
|
||||||
var def := Items.get_def(item)
|
var def := Items.get_def(item)
|
||||||
if def == null:
|
if def == null:
|
||||||
return
|
return above
|
||||||
var full := client.inventory_full()
|
var full := client.inventory_full()
|
||||||
var text := "inventory full -- %s stays where it is" % def.display_name \
|
var text := "inventory full -- %s stays where it is" % def.display_name \
|
||||||
if full else "E take %s" % def.display_name
|
if full else "%s take %s" % [key("interact"), def.display_name]
|
||||||
var tint := Color(0.85, 0.5, 0.45) if full else def.tint
|
var tint := Color(0.85, 0.5, 0.45) if full else def.tint
|
||||||
_canvas.draw_string(ThemeDB.fallback_font,
|
_centred(above, text, 14, tint)
|
||||||
Vector2(_canvas.size.x * 0.5 - 150.0,
|
return above - 22.0
|
||||||
_canvas.size.y - SLOT_BOTTOM - SLOT - 22.0),
|
|
||||||
text, HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
|
|
||||||
|
|
||||||
|
|
||||||
## A thin bar under health: progress toward the next level, and the level
|
## The message feed, newest nearest the inventory, each line fading out.
|
||||||
## itself. Drawn from the server's numbers, never recomputed locally.
|
func _draw_toasts(bottom: float) -> void:
|
||||||
func _draw_xp_bar(at: Vector2) -> void:
|
var now := _now()
|
||||||
if client.current_character().is_empty():
|
var y := bottom
|
||||||
|
for i in range(_toasts.size() - 1, -1, -1):
|
||||||
|
var t: Dictionary = _toasts[i]
|
||||||
|
var age := now - float(t["born"])
|
||||||
|
var alpha := clampf((TOAST_LIFE - age) / TOAST_FADE, 0.0, 1.0)
|
||||||
|
var col: Color = t["colour"]
|
||||||
|
# A short rise on arrival, so a new line catches the eye without a sound.
|
||||||
|
var rise := maxf(0.0, 0.25 - age) * 24.0
|
||||||
|
_centred(y + rise, String(t["text"]), 14, Color(col, col.a * alpha))
|
||||||
|
y -= 20.0
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_controls_hint() -> void:
|
||||||
|
if not client.my_alive:
|
||||||
return
|
return
|
||||||
# Derived from the snapshot's live experience total rather than the roster's
|
var in_hub := client.instance_kind == Protocol.InstanceKind.LOBBY
|
||||||
# copy, which only arrives when the set of characters changes.
|
var alpha := 1.0
|
||||||
var level := Progression.level_for_xp(client.my_total_xp)
|
if not in_hub:
|
||||||
var capped := level >= Progression.MAX_LEVEL
|
var age := _now() - _instance_since
|
||||||
var progress := Progression.level_progress(client.my_total_xp)
|
alpha = clampf(1.0 - (age - HINT_HOLD) / HINT_FADE, 0.0, 1.0)
|
||||||
var tint := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0)
|
if alpha <= 0.0:
|
||||||
_bar(at, progress, tint, Color.BLACK)
|
|
||||||
# The number as well as the bar: "how far to the next level" is a question
|
|
||||||
# a bar answers vaguely and a percentage answers exactly.
|
|
||||||
var text := "MAX" if capped else "%d%% to level %d" % [
|
|
||||||
int(floor(progress * 100.0)), level + 1]
|
|
||||||
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 13.0),
|
|
||||||
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint)
|
|
||||||
|
|
||||||
|
|
||||||
## Shown after the boss dies, so the victory lap has a visible clock on it.
|
|
||||||
func _draw_cleared_countdown() -> void:
|
|
||||||
if client.cleared_countdown >= Protocol.COUNTDOWN_NONE:
|
|
||||||
return
|
return
|
||||||
var text := "DUNGEON CLEARED -- returning to the hub in %ds" % client.cleared_countdown
|
_text(Vector2(MARGIN, _canvas.size.y - 14.0), controls_hint(in_hub), 12,
|
||||||
_canvas.draw_string(ThemeDB.fallback_font,
|
Color(COL_DIM, 0.9 * alpha))
|
||||||
Vector2(_canvas.size.x * 0.5 - 190.0, _canvas.size.y * 0.5 - 60.0),
|
|
||||||
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(0.6, 1.0, 0.75))
|
|
||||||
|
|
||||||
|
|
||||||
## Who else is online, and whether they are already in a dungeon. Only useful in
|
func _draw_debug_info() -> void:
|
||||||
## the hub, which is the one place you are deciding whether to go in.
|
if not debug_info:
|
||||||
func _draw_roster() -> void:
|
|
||||||
if client.instance_kind != Protocol.InstanceKind.LOBBY or client.roster.is_empty():
|
|
||||||
return
|
return
|
||||||
var x := _canvas.size.x - 240.0
|
_text(Vector2(0.0, _canvas.size.y - 14.0),
|
||||||
var y := MARGIN + 4.0
|
"instance %d %d fps" % [client.instance_id, Engine.get_frames_per_second()],
|
||||||
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y), "ONLINE",
|
12, COL_DIM, HORIZONTAL_ALIGNMENT_RIGHT, _canvas.size.x - MARGIN)
|
||||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.65, 0.7, 0.8))
|
|
||||||
y += 20.0
|
|
||||||
for entry in client.roster:
|
|
||||||
var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON
|
|
||||||
var where := "dungeon %d" % int(entry["instance"]) if in_dungeon else "hub"
|
|
||||||
var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9)
|
|
||||||
if not entry["alive"]:
|
|
||||||
col = Color(0.85, 0.4, 0.4)
|
|
||||||
where += " (down)"
|
|
||||||
var me := " <- you" if int(entry["peer"]) == client.my_peer else ""
|
|
||||||
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y),
|
|
||||||
"%s -- %s%s" % [entry["name"], where, me],
|
|
||||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col)
|
|
||||||
y += 18.0
|
|
||||||
|
|
||||||
|
|
||||||
func _draw_boss_bar() -> void:
|
## Red at the edges of the screen: briefly on every hit, and pulsing while
|
||||||
var b := client.boss_state()
|
## health is low.
|
||||||
if b.is_empty() or client.boss_def == null:
|
##
|
||||||
|
## At the edges, not across the middle. This used to tint the whole screen red
|
||||||
|
## on a hit, which in a bullet hell dims the bullets at exactly the moment you
|
||||||
|
## are among them -- the feedback made the next hit more likely.
|
||||||
|
func _draw_vignette() -> void:
|
||||||
|
var strength := _hit_flash * 0.55
|
||||||
|
if client.my_alive:
|
||||||
|
var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1))
|
||||||
|
if frac < LOW_HEALTH:
|
||||||
|
var low := (LOW_HEALTH - frac) / LOW_HEALTH
|
||||||
|
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.006)
|
||||||
|
strength = maxf(strength, (0.2 + 0.35 * low) * (0.65 + 0.35 * pulse))
|
||||||
|
if strength <= 0.01:
|
||||||
return
|
return
|
||||||
var w := 560.0
|
var s := _canvas.size
|
||||||
var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN)
|
var depth := minf(s.x, s.y) * 0.16
|
||||||
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
|
var edge := Color(COL_DANGER, strength)
|
||||||
var rect := Rect2(pos, Vector2(w, BAR_H))
|
var clear := Color(COL_DANGER, 0.0)
|
||||||
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
|
var sides := [
|
||||||
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
|
[Vector2(0, 0), Vector2(s.x, 0), Vector2(s.x, depth), Vector2(0, depth)],
|
||||||
Vector2((w - 6.0) * frac, BAR_H - 6.0))
|
[Vector2(0, s.y), Vector2(0, s.y - depth), Vector2(s.x, s.y - depth), Vector2(s.x, s.y)],
|
||||||
if inner.size.x > 0.5:
|
[Vector2(0, 0), Vector2(depth, 0), Vector2(depth, s.y), Vector2(0, s.y)],
|
||||||
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false,
|
[Vector2(s.x, 0), Vector2(s.x, s.y), Vector2(s.x - depth, s.y), Vector2(s.x - depth, 0)],
|
||||||
Color(0.95, 0.35, 0.45))
|
]
|
||||||
var phase_index := int(b["phase"])
|
var cols := [
|
||||||
var phase_name := ""
|
[edge, edge, clear, clear],
|
||||||
if phase_index < client.boss_def.phases.size():
|
[edge, clear, clear, edge],
|
||||||
phase_name = client.boss_def.phases[phase_index].name
|
[edge, clear, clear, edge],
|
||||||
_canvas.draw_string(ThemeDB.fallback_font, pos + Vector2(0.0, -6.0),
|
[edge, edge, clear, clear],
|
||||||
"%s -- %s" % [client.boss_def.display_name, phase_name],
|
]
|
||||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8))
|
for i in 4:
|
||||||
|
_canvas.draw_polygon(PackedVector2Array(sides[i]), PackedColorArray(cols[i]))
|
||||||
|
|
||||||
|
|
||||||
## A bar in the pack's art: the frame stretched horizontally, the fill tinted.
|
## A bar in the pack's art: the frame stretched horizontally, the fill tinted.
|
||||||
##
|
func _bar(pos: Vector2, frac: float, fill: Color) -> void:
|
||||||
## [param back] is no longer a colour -- the frame sprite is the background --
|
|
||||||
## but the signature keeps it so callers read the same and a future flat-bar
|
|
||||||
## fallback has somewhere to go.
|
|
||||||
func _bar(pos: Vector2, frac: float, fill: Color, _back: Color) -> void:
|
|
||||||
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
|
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
|
||||||
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
|
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
|
||||||
var inset := 3.0
|
var inset := 3.0
|
||||||
@@ -366,7 +648,3 @@ func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void:
|
|||||||
Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0),
|
Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0),
|
||||||
Vector2(draw_cap, rect.size.y)),
|
Vector2(draw_cap, rect.size.y)),
|
||||||
Rect2(src.x - cap, 0.0, cap, src.y), tint)
|
Rect2(src.x - cap, 0.0, cap, src.y), tint)
|
||||||
|
|
||||||
|
|
||||||
func flash_hit() -> void:
|
|
||||||
_hit_flash = 1.0
|
|
||||||
+76
-31
@@ -8,7 +8,6 @@ signal settings_requested
|
|||||||
|
|
||||||
var _address: LineEdit
|
var _address: LineEdit
|
||||||
var _port: LineEdit
|
var _port: LineEdit
|
||||||
var _name: LineEdit
|
|
||||||
var _status: Label
|
var _status: Label
|
||||||
var _join_button: Button
|
var _join_button: Button
|
||||||
var _host_button: Button
|
var _host_button: Button
|
||||||
@@ -33,19 +32,51 @@ func _ready() -> void:
|
|||||||
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
add_child(center)
|
add_child(center)
|
||||||
|
|
||||||
|
# The same dark panel every other screen sits in, rather than controls
|
||||||
|
# floating on the clear colour: this is the first screen anyone sees.
|
||||||
|
var frame := UiTheme.dialog_panel()
|
||||||
|
center.add_child(frame)
|
||||||
var panel := VBoxContainer.new()
|
var panel := VBoxContainer.new()
|
||||||
panel.custom_minimum_size = Vector2(360.0, 0.0)
|
panel.custom_minimum_size = Vector2(400.0, 0.0)
|
||||||
panel.add_theme_constant_override("separation", 8)
|
panel.add_theme_constant_override("separation", 8)
|
||||||
center.add_child(panel)
|
frame.add_child(panel)
|
||||||
|
|
||||||
var title := Label.new()
|
var title := Label.new()
|
||||||
title.text = "TRANSCIENCE"
|
title.text = "TRANSCIENCE"
|
||||||
title.add_theme_font_size_override("font_size", 32)
|
title.add_theme_font_size_override("font_size", 34)
|
||||||
panel.add_child(title)
|
panel.add_child(title)
|
||||||
|
var subtitle := Label.new()
|
||||||
|
subtitle.text = "a co-operative bullet hell"
|
||||||
|
subtitle.add_theme_font_size_override("font_size", 13)
|
||||||
|
subtitle.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78))
|
||||||
|
panel.add_child(subtitle)
|
||||||
|
panel.add_child(_gap(10.0))
|
||||||
|
|
||||||
_name = _field(panel, "name", GameOpts.player_name)
|
# No name field. There used to be one, and for a person it did nothing:
|
||||||
_address = _field(panel, "server address", GameOpts.host_address)
|
# characters are named on the roster screen, and this only ever fed
|
||||||
_port = _field(panel, "port", str(GameOpts.port))
|
# GameOpts.player_name, which names the characters BOTS create. Asking for
|
||||||
|
# a name the game then ignores is worse than not asking.
|
||||||
|
var heading := Label.new()
|
||||||
|
heading.text = "server"
|
||||||
|
heading.add_theme_font_size_override("font_size", 12)
|
||||||
|
panel.add_child(heading)
|
||||||
|
var where := HBoxContainer.new()
|
||||||
|
where.add_theme_constant_override("separation", 8)
|
||||||
|
panel.add_child(where)
|
||||||
|
# Remembered from the last join, so a returning player does not retype it.
|
||||||
|
_address = LineEdit.new()
|
||||||
|
_address.text = Settings.last_address if not Settings.last_address.is_empty() \
|
||||||
|
else GameOpts.host_address
|
||||||
|
_address.placeholder_text = "address"
|
||||||
|
_address.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_address.text_submitted.connect(func(_t: String) -> void: _on_join())
|
||||||
|
where.add_child(_address)
|
||||||
|
_port = LineEdit.new()
|
||||||
|
_port.text = str(Settings.last_port if Settings.last_port > 0 else GameOpts.port)
|
||||||
|
_port.placeholder_text = "port"
|
||||||
|
_port.custom_minimum_size = Vector2(90.0, 0.0)
|
||||||
|
_port.text_submitted.connect(func(_t: String) -> void: _on_join())
|
||||||
|
where.add_child(_port)
|
||||||
|
|
||||||
_join_button = Button.new()
|
_join_button = Button.new()
|
||||||
_join_button.text = "Join"
|
_join_button.text = "Join"
|
||||||
@@ -53,38 +84,55 @@ func _ready() -> void:
|
|||||||
panel.add_child(_join_button)
|
panel.add_child(_join_button)
|
||||||
|
|
||||||
_host_button = Button.new()
|
_host_button = Button.new()
|
||||||
_host_button.text = "Host and play (listen server)"
|
_host_button.text = "Host and play"
|
||||||
|
_host_button.tooltip_text = "Run a server on this machine and join it -- others can join you"
|
||||||
_host_button.pressed.connect(_on_host)
|
_host_button.pressed.connect(_on_host)
|
||||||
panel.add_child(_host_button)
|
panel.add_child(_host_button)
|
||||||
|
panel.add_child(_gap(6.0))
|
||||||
|
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 8)
|
||||||
|
panel.add_child(row)
|
||||||
var settings := Button.new()
|
var settings := Button.new()
|
||||||
settings.text = "Settings"
|
settings.text = "Settings"
|
||||||
|
settings.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
settings.pressed.connect(func() -> void: settings_requested.emit())
|
settings.pressed.connect(func() -> void: settings_requested.emit())
|
||||||
panel.add_child(settings)
|
row.add_child(settings)
|
||||||
|
|
||||||
var quit := Button.new()
|
var quit := Button.new()
|
||||||
quit.text = "Quit"
|
quit.text = "Quit"
|
||||||
|
quit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
quit.pressed.connect(func() -> void: get_tree().quit())
|
quit.pressed.connect(func() -> void: get_tree().quit())
|
||||||
panel.add_child(quit)
|
row.add_child(quit)
|
||||||
|
|
||||||
_status = Label.new()
|
_status = Label.new()
|
||||||
_status.add_theme_color_override("font_color", Color(1.0, 0.55, 0.5))
|
_status.add_theme_font_size_override("font_size", 13)
|
||||||
|
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_status.custom_minimum_size = Vector2(400.0, 0.0)
|
||||||
panel.add_child(_status)
|
panel.add_child(_status)
|
||||||
|
|
||||||
|
# Which protocol this build speaks. A "protocol mismatch" refusal names two
|
||||||
func _field(parent: Control, label: String, value: String) -> LineEdit:
|
# numbers, and this is where a player finds out which one is theirs.
|
||||||
var l := Label.new()
|
var version := Label.new()
|
||||||
l.text = label
|
version.text = "protocol %d" % Protocol.VERSION
|
||||||
l.add_theme_font_size_override("font_size", 12)
|
version.add_theme_font_size_override("font_size", 11)
|
||||||
parent.add_child(l)
|
version.add_theme_color_override("font_color", Color(0.45, 0.5, 0.6))
|
||||||
var e := LineEdit.new()
|
version.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||||
e.text = value
|
panel.add_child(version)
|
||||||
parent.add_child(e)
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
|
func _gap(height: float) -> Control:
|
||||||
|
var c := Control.new()
|
||||||
|
c.custom_minimum_size = Vector2(0.0, height)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
## Shown under the buttons. Coloured by what it is: "connecting..." is not an
|
||||||
|
## error, and it used to be drawn in the same red as one.
|
||||||
func set_status(text: String) -> void:
|
func set_status(text: String) -> void:
|
||||||
_status.text = text
|
_status.text = text
|
||||||
|
var working := text.begins_with("connecting")
|
||||||
|
_status.add_theme_color_override("font_color",
|
||||||
|
Color(0.65, 0.8, 1.0) if working else Color(1.0, 0.55, 0.5))
|
||||||
|
|
||||||
|
|
||||||
## Locks the connect buttons while an attempt is in flight, so a second click
|
## Locks the connect buttons while an attempt is in flight, so a second click
|
||||||
@@ -94,17 +142,14 @@ func set_busy(busy: bool) -> void:
|
|||||||
_host_button.disabled = busy
|
_host_button.disabled = busy
|
||||||
|
|
||||||
|
|
||||||
func _commit_name() -> void:
|
|
||||||
var clean := _name.text.strip_edges()
|
|
||||||
if not clean.is_empty():
|
|
||||||
GameOpts.player_name = clean
|
|
||||||
|
|
||||||
|
|
||||||
func _on_join() -> void:
|
func _on_join() -> void:
|
||||||
_commit_name()
|
if _join_button.disabled:
|
||||||
join_requested.emit(_address.text.strip_edges(), int(_port.text))
|
return
|
||||||
|
var address := _address.text.strip_edges()
|
||||||
|
var port := int(_port.text)
|
||||||
|
Settings.remember_server(address, port)
|
||||||
|
join_requested.emit(address, port)
|
||||||
|
|
||||||
|
|
||||||
func _on_host() -> void:
|
func _on_host() -> void:
|
||||||
_commit_name()
|
|
||||||
host_requested.emit(int(_port.text))
|
host_requested.emit(int(_port.text))
|
||||||
@@ -26,6 +26,7 @@ var _cards: HBoxContainer
|
|||||||
var _taken: Label
|
var _taken: Label
|
||||||
var _summary: Label
|
var _summary: Label
|
||||||
var _status: Label
|
var _status: Label
|
||||||
|
var _close: Button
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -84,11 +85,10 @@ func _ready() -> void:
|
|||||||
_summary.custom_minimum_size = Vector2(740.0, 0.0)
|
_summary.custom_minimum_size = Vector2(740.0, 0.0)
|
||||||
panel.add_child(_summary)
|
panel.add_child(_summary)
|
||||||
|
|
||||||
var close := Button.new()
|
_close = Button.new()
|
||||||
close.text = "Close (E)"
|
_close.custom_minimum_size = Vector2(160.0, 34.0)
|
||||||
close.custom_minimum_size = Vector2(160.0, 34.0)
|
_close.pressed.connect(func() -> void: closed.emit())
|
||||||
close.pressed.connect(func() -> void: closed.emit())
|
panel.add_child(_close)
|
||||||
panel.add_child(close)
|
|
||||||
|
|
||||||
|
|
||||||
func _rule() -> Control:
|
func _rule() -> Control:
|
||||||
@@ -106,6 +106,9 @@ func set_status(text: String) -> void:
|
|||||||
## screen can never show an offer that has already been spent.
|
## screen can never show an offer that has already been spent.
|
||||||
func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -> void:
|
func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -> void:
|
||||||
_status.text = ""
|
_status.text = ""
|
||||||
|
# Named on every refresh rather than once: the same key closes the screen
|
||||||
|
# that opened it, and it is whatever interact is bound to now.
|
||||||
|
_close.text = "Close (%s)" % Settings.short_label("interact")
|
||||||
if pending > 0:
|
if pending > 0:
|
||||||
_title.text = "CHOOSE AN UPGRADE (%d waiting)" % pending
|
_title.text = "CHOOSE AN UPGRADE (%d waiting)" % pending
|
||||||
else:
|
else:
|
||||||
|
|||||||
+35
-4
@@ -75,10 +75,14 @@ func _process(_delta: float) -> void:
|
|||||||
# Item feedback rides the same rule as every other sound here: it
|
# Item feedback rides the same rule as every other sound here: it
|
||||||
# plays because a server event arrived, never because the client
|
# plays because a server event arrived, never because the client
|
||||||
# guessed a pickup succeeded.
|
# guessed a pickup succeeded.
|
||||||
_bound.item_picked_up.connect(func(_i: StringName) -> void:
|
_bound.item_picked_up.connect(func(i: StringName) -> void:
|
||||||
sfx.play(Art.SFX_ENEMY_DEATH, -16.0))
|
sfx.play(Art.SFX_ENEMY_DEATH, -16.0)
|
||||||
_bound.item_used.connect(func(_i: StringName) -> void:
|
_announce_item("+ %s", i))
|
||||||
sfx.play(Art.SFX_SHOOT, -10.0))
|
_bound.item_used.connect(func(i: StringName) -> void:
|
||||||
|
sfx.play(Art.SFX_SHOOT, -10.0)
|
||||||
|
_announce_item("%s used", i))
|
||||||
|
_bound.item_dropped.connect(func(i: StringName) -> void:
|
||||||
|
_announce_item("dropped %s", i))
|
||||||
_bound.characters_changed.connect(_refresh_characters)
|
_bound.characters_changed.connect(_refresh_characters)
|
||||||
_bound.upgrades_changed.connect(_refresh_upgrades)
|
_bound.upgrades_changed.connect(_refresh_upgrades)
|
||||||
_refresh_upgrades()
|
_refresh_upgrades()
|
||||||
@@ -97,10 +101,19 @@ func _process(_delta: float) -> void:
|
|||||||
_roster_open = false
|
_roster_open = false
|
||||||
characters.visible = forced or _roster_open
|
characters.visible = forced or _roster_open
|
||||||
characters.set_dismissible(not forced)
|
characters.set_dismissible(not forced)
|
||||||
|
# Said once, on the screen death sends you to, and gone as soon as the
|
||||||
|
# player has picked who comes next.
|
||||||
|
if forced and not _bound.last_fallen.is_empty():
|
||||||
|
characters.set_fallen(fallen_line(_bound.last_fallen))
|
||||||
|
elif not forced and not _bound.last_fallen.is_empty():
|
||||||
|
_bound.last_fallen = {}
|
||||||
|
characters.set_fallen("")
|
||||||
if _bound != null and _upgrades_open and not _bound.at_upgrade_npc():
|
if _bound != null and _upgrades_open and not _bound.at_upgrade_npc():
|
||||||
_upgrades_open = false
|
_upgrades_open = false
|
||||||
upgrades.visible = _upgrades_open
|
upgrades.visible = _upgrades_open
|
||||||
_follow_camera()
|
_follow_camera()
|
||||||
|
hud.sight = world_view.sight
|
||||||
|
hud.debug_info = world_view.debug_hitboxes
|
||||||
menu.set_in_dungeon(_bound != null
|
menu.set_in_dungeon(_bound != null
|
||||||
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
|
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
|
||||||
|
|
||||||
@@ -147,6 +160,24 @@ func _on_respawn_pressed() -> void:
|
|||||||
_bound.request_respawn = true
|
_bound.request_respawn = true
|
||||||
|
|
||||||
|
|
||||||
|
## Permadeath, said plainly: who, how far they got, and where. A death used to
|
||||||
|
## be announced only by the roster screen appearing.
|
||||||
|
static func fallen_line(fallen: Dictionary) -> String:
|
||||||
|
var where := String(fallen.get("where", ""))
|
||||||
|
return "%s fell at level %d%s. They are gone -- choose who goes next." % [
|
||||||
|
fallen["name"], int(fallen["level"]),
|
||||||
|
" in the %s" % where.trim_prefix("The ") if not where.is_empty() else ""]
|
||||||
|
|
||||||
|
|
||||||
|
## A line in the HUD's message feed for an item the server just moved. The
|
||||||
|
## events are this player's own -- ClientRuntime filters them by peer -- so a
|
||||||
|
## party member's pickup does not read as yours.
|
||||||
|
func _announce_item(pattern: String, item: StringName) -> void:
|
||||||
|
var def := Items.get_def(item)
|
||||||
|
if def != null:
|
||||||
|
hud.notify(pattern % def.display_name, def.tint)
|
||||||
|
|
||||||
|
|
||||||
func _on_local_hit(_damage: int) -> void:
|
func _on_local_hit(_damage: int) -> void:
|
||||||
hud.flash_hit()
|
hud.flash_hit()
|
||||||
sfx.play(Art.SFX_PLAYER_HIT, -4.0)
|
sfx.play(Art.SFX_PLAYER_HIT, -4.0)
|
||||||
@@ -33,8 +33,9 @@ var client: ClientRuntime = null
|
|||||||
var _anim_time: float = 0.0
|
var _anim_time: float = 0.0
|
||||||
## F1 overlay: draws what the simulation collides against, over what is drawn.
|
## F1 overlay: draws what the simulation collides against, over what is drawn.
|
||||||
var debug_hitboxes: bool = false
|
var debug_hitboxes: bool = false
|
||||||
## What the local player can see this frame. See [Visibility].
|
## What the local player can see this frame. See [Visibility]. Read by the HUD
|
||||||
var _sight := Visibility.new()
|
## too, which waits for the boss to have been seen before showing its bar.
|
||||||
|
var sight := Visibility.new()
|
||||||
## The darkness is the window's clear colour, exactly, so where it is complete it
|
## The darkness is the window's clear colour, exactly, so where it is complete it
|
||||||
## is indistinguishable from the undrawn void beyond it and there is no seam.
|
## is indistinguishable from the undrawn void beyond it and there is no seam.
|
||||||
var _dark: Color = ProjectSettings.get_setting(
|
var _dark: Color = ProjectSettings.get_setting(
|
||||||
@@ -76,7 +77,7 @@ func _draw() -> void:
|
|||||||
return
|
return
|
||||||
var map: MapGrid = client.world.map
|
var map: MapGrid = client.world.map
|
||||||
if map != null:
|
if map != null:
|
||||||
_sight.compute(map, client.predicted_pos, SimConfig.FOG_VIEW_RADIUS)
|
sight.compute(map, client.predicted_pos, SimConfig.FOG_VIEW_RADIUS)
|
||||||
_draw_terrain()
|
_draw_terrain()
|
||||||
_draw_portals()
|
_draw_portals()
|
||||||
_draw_upgrade_npc()
|
_draw_upgrade_npc()
|
||||||
@@ -105,7 +106,7 @@ func _draw() -> void:
|
|||||||
## and the shadow is several hundred. Issued from inside _draw on this item's own
|
## and the shadow is several hundred. Issued from inside _draw on this item's own
|
||||||
## canvas, so it lands in order between what was drawn before and after it.
|
## canvas, so it lands in order between what was drawn before and after it.
|
||||||
func _draw_darkness() -> void:
|
func _draw_darkness() -> void:
|
||||||
var mesh := _sight.shadow_mesh(SimConfig.FOG_VIEW_RADIUS + DARK_REACH, _dark)
|
var mesh := sight.shadow_mesh(SimConfig.FOG_VIEW_RADIUS + DARK_REACH, _dark)
|
||||||
RenderingServer.canvas_item_add_triangle_array(get_canvas_item(),
|
RenderingServer.canvas_item_add_triangle_array(get_canvas_item(),
|
||||||
mesh["indices"], mesh["points"], mesh["colors"])
|
mesh["indices"], mesh["points"], mesh["colors"])
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ func _draw_debug() -> void:
|
|||||||
DebugDraw.draw_player(self, p["pos"], p["aim"])
|
DebugDraw.draw_player(self, p["pos"], p["aim"])
|
||||||
DebugDraw.draw_player(self, client.predicted_pos, client.aim)
|
DebugDraw.draw_player(self, client.predicted_pos, client.aim)
|
||||||
DebugDraw.draw_ranges(self, client.predicted_pos)
|
DebugDraw.draw_ranges(self, client.predicted_pos)
|
||||||
DebugDraw.draw_sight(self, _sight)
|
DebugDraw.draw_sight(self, sight)
|
||||||
|
|
||||||
|
|
||||||
## Aggro radius by visual index. Read from the content definitions rather than
|
## Aggro radius by visual index. Read from the content definitions rather than
|
||||||
@@ -247,7 +248,7 @@ func _draw_upgrade_npc() -> void:
|
|||||||
HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
|
HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
|
||||||
if client.at_upgrade_npc():
|
if client.at_upgrade_npc():
|
||||||
draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 18.0),
|
draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 18.0),
|
||||||
"E talk", HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13,
|
"%s talk" % Settings.short_label("interact"), HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13,
|
||||||
Color(0.8, 0.85, 0.95))
|
Color(0.8, 0.85, 0.95))
|
||||||
# A pending choice pulses, so it is visible from across the hub.
|
# A pending choice pulses, so it is visible from across the hub.
|
||||||
if waiting > 0:
|
if waiting > 0:
|
||||||
|
|||||||
@@ -138,3 +138,76 @@ func test_interacting_away_from_the_portal_does_nothing() -> void:
|
|||||||
var used := lobby.world.events.filter(
|
var used := lobby.world.events.filter(
|
||||||
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
|
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
|
||||||
assert_eq(used.size(), 0)
|
assert_eq(used.size(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tunnel groups ----------------------------------------------------------
|
||||||
|
|
||||||
|
func _tunnel_enemies(run: Instance) -> Array[SimEnemy]:
|
||||||
|
var out: Array[SimEnemy] = []
|
||||||
|
for e in run.world.enemies.values():
|
||||||
|
var t := run.world.map.to_tile(e.pos)
|
||||||
|
if run.boss_room.has_point(t):
|
||||||
|
continue
|
||||||
|
var in_room := false
|
||||||
|
for r in run.rooms:
|
||||||
|
if r.grow(1).has_point(t):
|
||||||
|
in_room = true
|
||||||
|
if not in_room:
|
||||||
|
out.append(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func test_tunnels_are_occasionally_occupied_and_usually_not() -> void:
|
||||||
|
# "Rarely" in numbers: across many depth-1 runs, some have a group in a
|
||||||
|
# tunnel and most do not.
|
||||||
|
var occupied := 0
|
||||||
|
var runs := 80
|
||||||
|
for s in range(1, runs + 1):
|
||||||
|
if not _tunnel_enemies(Instance.make_dungeon(2, s * 7919, 1)).is_empty():
|
||||||
|
occupied += 1
|
||||||
|
assert_gt(occupied, runs / 10, "tunnel groups exist (%d of %d runs)" % [occupied, runs])
|
||||||
|
assert_lt(occupied, runs / 2, "but a run with one is the exception (%d of %d)" % [occupied, runs])
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_tunnel_group_is_never_awake_when_you_arrive() -> void:
|
||||||
|
var widest := Instance._widest_aggro()
|
||||||
|
for s in range(1, 120):
|
||||||
|
var run := Instance.make_dungeon(2, s * 7919, 1 + (s % 3))
|
||||||
|
for e in _tunnel_enemies(run):
|
||||||
|
assert_gt(e.pos.distance_to(run.world.spawn_point), widest,
|
||||||
|
"seed %d: a tunnel %s stands within aggro range of the entrance"
|
||||||
|
% [s * 7919, e.def.id])
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_tunnel_group_is_a_group() -> void:
|
||||||
|
# Never a lone straggler, and never spread down the whole tunnel: every
|
||||||
|
# member has another within a few tiles.
|
||||||
|
var near := MapGrid.TILE * (Instance.TUNNEL_GROUP_SPREAD * 2 + 1)
|
||||||
|
var seen := 0
|
||||||
|
for s in range(1, 120):
|
||||||
|
var found := _tunnel_enemies(Instance.make_dungeon(2, s * 7919, 1 + (s % 3)))
|
||||||
|
for e in found:
|
||||||
|
seen += 1
|
||||||
|
var has_company := false
|
||||||
|
for other in found:
|
||||||
|
if other != e and other.pos.distance_to(e.pos) <= near:
|
||||||
|
has_company = true
|
||||||
|
assert_true(has_company, "seed %d: a tunnel %s on its own" % [s * 7919, e.def.id])
|
||||||
|
assert_gt(seen, 0, "the sample produced at least one group to check")
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_tunnel_group_is_scaled_like_everything_else() -> void:
|
||||||
|
# The Proving Grounds scales every enemy it spawns; a tunnel group must not
|
||||||
|
# be the one thing that arrives at full health in the easy harness.
|
||||||
|
for s in range(1, 120):
|
||||||
|
var run := Instance.make_dungeon(2, s * 7919, 2, Dungeons.PROVING)
|
||||||
|
for e in _tunnel_enemies(run):
|
||||||
|
assert_lt(e.hp, Content.enemy(e.def.id).max_hp,
|
||||||
|
"seed %d: a tunnel %s skipped the dungeon's scaling" % [s * 7919, e.def.id])
|
||||||
|
|
||||||
|
|
||||||
|
func test_boss_rush_leaves_the_tunnels_empty_too() -> void:
|
||||||
|
GameOpts.boss_rush = true
|
||||||
|
var run := Instance.make_dungeon(2, 4242, 3)
|
||||||
|
GameOpts.boss_rush = false
|
||||||
|
assert_eq(_tunnel_enemies(run).size(), 0)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
extends GutTest
|
||||||
|
## Permadeath is announced. The server unbinds a character on the tick it dies,
|
||||||
|
## so before this the only sign anything had happened was the roster appearing.
|
||||||
|
|
||||||
|
const GAME_SCENE := preload("res://src/view/game_scene.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_line_names_who_fell_how_far_they_got_and_where() -> void:
|
||||||
|
var line: String = GAME_SCENE.fallen_line(
|
||||||
|
{"name": "Bramcreek", "level": 7, "where": "Warden's Descent"})
|
||||||
|
assert_string_contains(line, "Bramcreek")
|
||||||
|
assert_string_contains(line, "level 7")
|
||||||
|
assert_string_contains(line, "in the Warden's Descent")
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_dungeon_already_called_the_something_is_not_the_the_something() -> void:
|
||||||
|
var line: String = GAME_SCENE.fallen_line(
|
||||||
|
{"name": "Wren", "level": 3, "where": "The Choir Vault"})
|
||||||
|
assert_string_contains(line, "in the Choir Vault")
|
||||||
|
assert_false(line.contains("the The"))
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_client_records_its_own_death_from_the_server_event() -> void:
|
||||||
|
var c: ClientRuntime = autofree(ClientRuntime.new())
|
||||||
|
c.my_peer = 5
|
||||||
|
c.characters = [{"id": "a", "name": "Ash", "active": true}] as Array[Dictionary]
|
||||||
|
c.selected_character = "a"
|
||||||
|
c.instance_kind = Protocol.InstanceKind.DUNGEON
|
||||||
|
c.dungeon_id = Dungeons.VAULT
|
||||||
|
c.my_total_xp = Progression.total_xp_for_level(4)
|
||||||
|
c.on_events(NetCodec.encode_events(10,
|
||||||
|
[{"t": SimEvent.Type.PLAYER_DIED, "peer": 5}] as Array[Dictionary]))
|
||||||
|
assert_eq(c.last_fallen.get("name", ""), "Ash")
|
||||||
|
assert_eq(int(c.last_fallen.get("level", 0)), 4)
|
||||||
|
assert_eq(String(c.last_fallen.get("where", "")), "The Choir Vault")
|
||||||
|
|
||||||
|
|
||||||
|
func test_someone_elses_death_is_not_yours() -> void:
|
||||||
|
var c: ClientRuntime = autofree(ClientRuntime.new())
|
||||||
|
c.my_peer = 5
|
||||||
|
c.characters = [{"id": "a", "name": "Ash", "active": true}] as Array[Dictionary]
|
||||||
|
c.selected_character = "a"
|
||||||
|
c.on_events(NetCodec.encode_events(10,
|
||||||
|
[{"t": SimEvent.Type.PLAYER_DIED, "peer": 6}] as Array[Dictionary]))
|
||||||
|
assert_true(c.last_fallen.is_empty())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://chmutr64rrmsh
|
||||||
@@ -254,3 +254,89 @@ func test_saving_writes_to_the_configured_path() -> void:
|
|||||||
var cfg := ConfigFile.new()
|
var cfg := ConfigFile.new()
|
||||||
assert_eq(cfg.load(_scratch), OK)
|
assert_eq(cfg.load(_scratch), OK)
|
||||||
assert_almost_eq(float(cfg.get_value("audio", "master")), 0.33, 0.001)
|
assert_almost_eq(float(cfg.get_value("audio", "master")), 0.33, 0.001)
|
||||||
|
|
||||||
|
|
||||||
|
# --- The connect screen remembers ---------------------------------------------
|
||||||
|
|
||||||
|
func test_the_last_server_survives_a_save_and_reload() -> void:
|
||||||
|
var saved_address := Settings.last_address
|
||||||
|
var saved_port := Settings.last_port
|
||||||
|
Settings.remember_server(" play.example.net ", 27020)
|
||||||
|
Settings.last_address = ""
|
||||||
|
Settings.last_port = 0
|
||||||
|
Settings.load_and_apply()
|
||||||
|
assert_eq(Settings.last_address, "play.example.net", "trimmed, and back again")
|
||||||
|
assert_eq(Settings.last_port, 27020)
|
||||||
|
Settings.last_address = saved_address
|
||||||
|
Settings.last_port = saved_port
|
||||||
|
|
||||||
|
|
||||||
|
func test_nonsense_is_not_remembered() -> void:
|
||||||
|
var saved_address := Settings.last_address
|
||||||
|
Settings.last_address = "kept.example.net"
|
||||||
|
Settings.remember_server(" ", 27020)
|
||||||
|
Settings.remember_server("x.example.net", 0)
|
||||||
|
Settings.remember_server("x.example.net", 70000)
|
||||||
|
assert_eq(Settings.last_address, "kept.example.net",
|
||||||
|
"a blank address or an impossible port must not replace a good one")
|
||||||
|
Settings.last_address = saved_address
|
||||||
|
|
||||||
|
|
||||||
|
# --- The HUD says what the keys actually are ----------------------------------
|
||||||
|
# Every hint on screen used to be a literal -- "WASD move", "LMB fire", "hold F"
|
||||||
|
# -- while every one of those actions is rebindable. Rebind fire and the HUD
|
||||||
|
# kept telling you to click.
|
||||||
|
|
||||||
|
const HUD := preload("res://src/ui/hud.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_hints_name_the_default_keys() -> void:
|
||||||
|
assert_eq(HUD.move_keys(), "WASD")
|
||||||
|
assert_eq(HUD.key("fire"), "LMB")
|
||||||
|
assert_eq(HUD.slot_keys(4), "1-4")
|
||||||
|
assert_string_contains(HUD.controls_hint(false), "hold F")
|
||||||
|
assert_string_contains(HUD.controls_hint(false), "Esc menu",
|
||||||
|
"spelled the way a keycap spells it, not the way the OS names it")
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_hints_follow_a_rebind() -> void:
|
||||||
|
var click := InputEventMouseButton.new()
|
||||||
|
click.button_index = MOUSE_BUTTON_RIGHT
|
||||||
|
click.device = -1
|
||||||
|
assert_eq(Settings.rebind("fire", click), "")
|
||||||
|
assert_eq(Settings.rebind("emergency_escape", _key_event(KEY_G)), "")
|
||||||
|
var hint := HUD.controls_hint(false)
|
||||||
|
assert_string_contains(hint, "RMB fire")
|
||||||
|
assert_string_contains(hint, "hold G")
|
||||||
|
assert_false(hint.contains("LMB"), "the old key is not mentioned anywhere")
|
||||||
|
|
||||||
|
|
||||||
|
func test_movement_spelled_out_when_it_is_not_four_letters() -> void:
|
||||||
|
assert_eq(Settings.rebind("move_up", _key_event(KEY_UP)), "")
|
||||||
|
var keys := HUD.move_keys()
|
||||||
|
assert_true(keys.begins_with("Up/"), "arrow keys read as words, got %s" % keys)
|
||||||
|
|
||||||
|
|
||||||
|
func test_slot_keys_are_listed_when_they_are_not_one_to_four() -> void:
|
||||||
|
assert_eq(Settings.rebind("use_slot_3", _key_event(KEY_Z)), "")
|
||||||
|
assert_eq(HUD.slot_keys(4), "1 2 Z 4")
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_upgrade_screen_names_the_key_that_closes_it() -> void:
|
||||||
|
assert_eq(Settings.rebind("interact", _key_event(KEY_G)), "")
|
||||||
|
var screen: CanvasLayer = preload("res://src/ui/upgrade_screen.gd").new()
|
||||||
|
add_child_autofree(screen)
|
||||||
|
var none: Array[StringName] = []
|
||||||
|
screen.refresh(0, none, none)
|
||||||
|
assert_eq(screen._close.text, "Close (G)",
|
||||||
|
"it used to say (E) whatever interact was bound to")
|
||||||
|
|
||||||
|
|
||||||
|
func test_short_labels_are_what_a_keycap_says() -> void:
|
||||||
|
var click := InputEventMouseButton.new()
|
||||||
|
click.button_index = MOUSE_BUTTON_LEFT
|
||||||
|
click.device = -1
|
||||||
|
assert_eq(Settings.rebind("interact", click), "fire",
|
||||||
|
"left click is fire's, and the clash is named")
|
||||||
|
assert_eq(Settings.short_label("fire"), "LMB")
|
||||||
|
assert_eq(Settings.short_label("system_menu"), "Esc")
|
||||||
+96
-1
@@ -14,6 +14,14 @@ extends Node
|
|||||||
## touching anything under src/ui/ and open the files.
|
## touching anything under src/ui/ and open the files.
|
||||||
|
|
||||||
const GAME := preload("res://src/game.tscn")
|
const GAME := preload("res://src/game.tscn")
|
||||||
|
const GAME_SCENE := preload("res://src/view/game_scene.gd")
|
||||||
|
## Its own store and its own account, like every diagnostic. Without these the
|
||||||
|
## tool hosted a listen server on the player's REAL character store as the
|
||||||
|
## player's REAL account, and walked their character into dungeons to take
|
||||||
|
## pictures -- with health carried between runs, a stray hit would have stuck,
|
||||||
|
## and a death would have retired someone's character for a screenshot.
|
||||||
|
const STORE := "user://screenshot_characters.json"
|
||||||
|
const ACCOUNT := 777001
|
||||||
## Frame numbers, not seconds: the point is a deterministic sequence, and a
|
## Frame numbers, not seconds: the point is a deterministic sequence, and a
|
||||||
## windowed client at 60fps gets there in about four seconds.
|
## windowed client at 60fps gets there in about four seconds.
|
||||||
const AT_MENU := 30
|
const AT_MENU := 30
|
||||||
@@ -34,7 +42,9 @@ const AT_DUNGEON := 480
|
|||||||
const TO_TUNNEL := 490
|
const TO_TUNNEL := 490
|
||||||
const AT_TUNNEL := 550
|
const AT_TUNNEL := 550
|
||||||
const AT_TUNNEL_DEBUG := 560
|
const AT_TUNNEL_DEBUG := 560
|
||||||
const DONE := 570
|
const TO_BOSS := 570
|
||||||
|
const AT_BOSS := 640
|
||||||
|
const DONE := 650
|
||||||
|
|
||||||
var _frame: int = 0
|
var _frame: int = 0
|
||||||
var _game: Node = null
|
var _game: Node = null
|
||||||
@@ -44,6 +54,9 @@ var _dir: String = "user://"
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
GameOpts.parse()
|
GameOpts.parse()
|
||||||
|
GameOpts.store_path = STORE
|
||||||
|
GameOpts.account_override = ACCOUNT
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE))
|
||||||
Engine.physics_ticks_per_second = SimConfig.TICK_RATE
|
Engine.physics_ticks_per_second = SimConfig.TICK_RATE
|
||||||
Settings.load_and_apply()
|
Settings.load_and_apply()
|
||||||
var from_env := OS.get_environment("SHOT_DIR")
|
var from_env := OS.get_environment("SHOT_DIR")
|
||||||
@@ -78,16 +91,24 @@ func _process(_delta: float) -> void:
|
|||||||
# Held until the shot is taken; one frame of it is not enough.
|
# Held until the shot is taken; one frame of it is not enough.
|
||||||
if _frame > OPEN_UPGRADES and _frame <= AT_UPGRADES:
|
if _frame > OPEN_UPGRADES and _frame <= AT_UPGRADES:
|
||||||
_stand_at_the_npc()
|
_stand_at_the_npc()
|
||||||
|
# On an entrance for the hub shot, so its prompt is in the picture.
|
||||||
|
if _frame > AT_GAME - 40 and _frame <= AT_GAME:
|
||||||
|
_stand_on_a_portal()
|
||||||
# Same reason: the game scene owns this screen's visibility too and decides
|
# Same reason: the game scene owns this screen's visibility too and decides
|
||||||
# it every frame, so one assignment lasts exactly one frame.
|
# it every frame, so one assignment lasts exactly one frame.
|
||||||
if _frame > OPEN_ROSTER and _frame <= AT_ROSTER:
|
if _frame > OPEN_ROSTER and _frame <= AT_ROSTER:
|
||||||
_show_the_roster()
|
_show_the_roster()
|
||||||
|
# Held low, so the low-health edge is in the picture -- and held at all,
|
||||||
|
# because the boss is firing and one landed ring would end the shot.
|
||||||
|
if _frame > TO_BOSS and _frame <= AT_BOSS:
|
||||||
|
_hold_health(0.22)
|
||||||
match _frame:
|
match _frame:
|
||||||
AT_MENU:
|
AT_MENU:
|
||||||
_grab("menu")
|
_grab("menu")
|
||||||
ENTER_GAME:
|
ENTER_GAME:
|
||||||
_menu.queue_free()
|
_menu.queue_free()
|
||||||
Net.host(27455)
|
Net.host(27455)
|
||||||
|
_seed_character()
|
||||||
Net.start_local_client()
|
Net.start_local_client()
|
||||||
_game = GAME.instantiate()
|
_game = GAME.instantiate()
|
||||||
add_child(_game)
|
add_child(_game)
|
||||||
@@ -131,6 +152,7 @@ func _process(_delta: float) -> void:
|
|||||||
# The fog is what these two are for, and the hub is the worst place to
|
# The fog is what these two are for, and the hub is the worst place to
|
||||||
# judge it: one big room with little in it to cast a shadow.
|
# judge it: one big room with little in it to cast a shadow.
|
||||||
_game.set("_roster_open", false)
|
_game.set("_roster_open", false)
|
||||||
|
_game.get_node("CharacterSelect").set_fallen("")
|
||||||
Net.server._send_to_dungeon(Net.LOCAL_PEER)
|
Net.server._send_to_dungeon(Net.LOCAL_PEER)
|
||||||
AT_DUNGEON:
|
AT_DUNGEON:
|
||||||
_grab("dungeon")
|
_grab("dungeon")
|
||||||
@@ -144,6 +166,11 @@ func _process(_delta: float) -> void:
|
|||||||
_game.get_node("WorldView").debug_hitboxes = true
|
_game.get_node("WorldView").debug_hitboxes = true
|
||||||
AT_TUNNEL_DEBUG:
|
AT_TUNNEL_DEBUG:
|
||||||
_grab("tunnel_debug")
|
_grab("tunnel_debug")
|
||||||
|
TO_BOSS:
|
||||||
|
_game.get_node("WorldView").debug_hitboxes = false
|
||||||
|
_stage_a_boss_fight()
|
||||||
|
AT_BOSS:
|
||||||
|
_grab("boss_fight")
|
||||||
DONE:
|
DONE:
|
||||||
Net.shutdown()
|
Net.shutdown()
|
||||||
get_tree().quit(0)
|
get_tree().quit(0)
|
||||||
@@ -170,6 +197,9 @@ func _show_the_roster() -> void:
|
|||||||
p.pos = away
|
p.pos = away
|
||||||
_game.set("_upgrades_open", false)
|
_game.set("_upgrades_open", false)
|
||||||
_game.set("_roster_open", true)
|
_game.set("_roster_open", true)
|
||||||
|
# A death, as it is announced on the screen a death sends you to.
|
||||||
|
_game.get_node("CharacterSelect").set_fallen(GAME_SCENE.fallen_line(
|
||||||
|
{"name": "Quillfell", "level": 9, "where": "The Choir Vault"}))
|
||||||
var roster: Array[Dictionary] = [
|
var roster: Array[Dictionary] = [
|
||||||
_roster_entry("Whole", 4, 1300, 1300, Color(0.5, 0.9, 1.0)),
|
_roster_entry("Whole", 4, 1300, 1300, Color(0.5, 0.9, 1.0)),
|
||||||
_roster_entry("Bruised", 7, 1600, 940, Color(1.0, 0.8, 0.4)),
|
_roster_entry("Bruised", 7, 1600, 940, Color(1.0, 0.8, 0.4)),
|
||||||
@@ -178,6 +208,71 @@ func _show_the_roster() -> void:
|
|||||||
_game.get_node("CharacterSelect").refresh(roster, "c4")
|
_game.get_node("CharacterSelect").refresh(roster, "c4")
|
||||||
|
|
||||||
|
|
||||||
|
## A character worth photographing: levelled, carrying things, so the HUD has
|
||||||
|
## something in every slot and a bar that is not empty. Made in the scratch
|
||||||
|
## store before the local client says hello, so it is who the client resumes.
|
||||||
|
func _seed_character() -> void:
|
||||||
|
var store := Net.server.store
|
||||||
|
var c := store.create_character(ACCOUNT, "Ash")
|
||||||
|
store.grant_xp(ACCOUNT, c.id, Progression.total_xp_for_level(6) + 260)
|
||||||
|
var bag: Array[StringName] = [Items.HEALTH_POTION, Items.WARDENS_RATION,
|
||||||
|
Items.HEALTH_POTION, Items.NONE]
|
||||||
|
store.set_inventory(ACCOUNT, c.id, bag)
|
||||||
|
store.flush()
|
||||||
|
|
||||||
|
|
||||||
|
func _stand_on_a_portal() -> void:
|
||||||
|
if Net.client == null or Net.client.portals.is_empty():
|
||||||
|
return
|
||||||
|
var at: Vector2 = Net.client.portals[0]["pos"]
|
||||||
|
Net.client.predicted_pos = at
|
||||||
|
var p := _player()
|
||||||
|
if p != null:
|
||||||
|
p.pos = at
|
||||||
|
|
||||||
|
|
||||||
|
func _player() -> SimPlayer:
|
||||||
|
var inst := Net.server.instance_of(Net.LOCAL_PEER)
|
||||||
|
return inst.world.players.get(Net.LOCAL_PEER) if inst != null else null
|
||||||
|
|
||||||
|
|
||||||
|
func _hold_health(share: float) -> void:
|
||||||
|
var p := _player()
|
||||||
|
if p != null:
|
||||||
|
p.hp = maxi(1, int(float(p.max_hp) * share))
|
||||||
|
|
||||||
|
|
||||||
|
## Into the boss arena, low on health, with a party member beside you and a
|
||||||
|
## few messages in the feed: everything on the HUD that only exists mid-fight,
|
||||||
|
## in one picture.
|
||||||
|
##
|
||||||
|
## The party member is a world player with no socket behind it -- added to the
|
||||||
|
## simulation and the roster, never to the instance's send list, so the server
|
||||||
|
## never tries to talk to it.
|
||||||
|
func _stage_a_boss_fight() -> void:
|
||||||
|
var inst := Net.server.instance_of(Net.LOCAL_PEER)
|
||||||
|
if inst == null:
|
||||||
|
return
|
||||||
|
var at := inst.boss_spawn + Vector2(-200.0, 60.0)
|
||||||
|
Net.client.predicted_pos = at
|
||||||
|
var me := _player()
|
||||||
|
if me != null:
|
||||||
|
me.pos = at
|
||||||
|
me.spawn_grace = 0
|
||||||
|
var friend := inst.world.add_player(999, "Wren")
|
||||||
|
friend.pos = at + Vector2(-40.0, -70.0)
|
||||||
|
friend.colour = Color(1.0, 0.75, 0.45)
|
||||||
|
friend.hp = int(float(friend.max_hp) * 0.6)
|
||||||
|
friend.spawn_grace = 0
|
||||||
|
Net.server.peer_names[999] = "Wren"
|
||||||
|
Net.server.peer_instance[999] = inst.id
|
||||||
|
Net.server._broadcast_roster()
|
||||||
|
var hud := _game.get_node("HUD")
|
||||||
|
hud.notify("+ Health Potion", Items.get_def(Items.HEALTH_POTION).tint)
|
||||||
|
hud.notify("Warden's Ration used", Items.get_def(Items.WARDENS_RATION).tint)
|
||||||
|
hud.notify("LEVEL 7 -- a choice waits at the quartermaster", Color(1.0, 0.85, 0.4))
|
||||||
|
|
||||||
|
|
||||||
## Move the player, on both sides, into the tunnel closest to where it spawned:
|
## Move the player, on both sides, into the tunnel closest to where it spawned:
|
||||||
## somewhere with walls close on two sides and a corner in view, which is where
|
## somewhere with walls close on two sides and a corner in view, which is where
|
||||||
## the old fog looked worst.
|
## the old fog looked worst.
|
||||||
|
|||||||
Reference in new issue
Block a user