Compare commits

..
9 Commits
Author SHA1 Message Date
Warren H 5045f17b94 Release v0.1.16 2026-07-18 2026-07-18 20:50:51 -04:00
Warren H ea5d4550a5 Release v0.1.15 2026-07-18 2026-07-18 20:37:45 -04:00
Warren H 40aa23be46 new help section explaining classes and their mechanics 2026-07-18 12:05:34 -04:00
Warren H 638aef22b2 updated some performance stuff 2026-07-17 15:39:31 -04:00
Warren H 10ff124466 new rpg mode 2026-07-17 12:47:42 -04:00
Warren H de7b59ce77 Release v0.1.14 2026-07-13 2026-07-13 23:19:40 -04:00
Warren H 4b31480bec Release v0.1.13 2026-07-13 2026-07-13 22:50:51 -04:00
Warren H ed34c2503c Release v0.1.12 2026-07-13 2026-07-13 22:41:35 -04:00
Warren H 8e48bb4fb6 Release v0.1.11 2026-07-13 2026-07-13 21:31:36 -04:00
198 changed files with 24354 additions and 1318 deletions
+1
View File
@@ -10,6 +10,7 @@ vite.config.d.ts
.env
.env.*
!.env.example
/git-token
__pycache__/
*.py[cod]
/public/basis/
+36 -2
View File
@@ -97,8 +97,16 @@ The repository target is:
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
```
The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can
optionally override it for one run. Publish from `main`:
Create `git-token` in the repository root. Paste only the Gitea token into it:
```text
gitea_token_value_goes_here
```
Do not add quotes or a `GITEA_TOKEN=` prefix. The exact `/git-token` path is
Git-ignored. Restrict local file access with `chmod 600 git-token`. The publisher
reads it automatically. `GITEA_TOKEN` remains available as an optional override.
Publish from `main` normally:
```bash
pnpm publish:gitea -- --message "Describe the update"
@@ -138,6 +146,32 @@ outside the repository.
Touch controls on lower display support party targeting, ability casting, map, and inventory.
## Character model rollout and rollback
Healer characters use the modular `Rig_Medium` renderer by default. Version 1 composes
head, upper body, lower body, headwear, back item, main hand, and offhand slots while
continuing to use Aelia's shared animation set.
Load a hunter, then open **Appearance Lab** from the main menu. The upper display shows
the real in-game healer renderer with idle, walk, and cast previews. The lower display
selects healer class and cycles every available part. **Save look** persists the current
class, **Reset** restores its authored default, and **Cancel** discards drafts. **Compare
legacy** shows the previous whole-character model without deleting the modular selection.
Some source assets currently fuse related pieces, so version 1 exposes honest combined
slots such as face + hair, shirt + arms, and pants + shoes. These can split into finer
customization slots when compatible rigged assets are added.
The previous whole-GLB renderer remains intact during rollout. Use either rollback:
```text
?characterModels=legacy
VITE_CHARACTER_MODEL_MODE=legacy
```
The query changes one browser/app launch. The environment variable produces a legacy
build. Remove the switch to return to modular rendering.
## Current game scope
- Five-member AI party with Disc Priest healer
@@ -20,7 +20,7 @@ import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Routes every Thor controller event into one JavaScript input service. */
/** Routes every Thor controller event into one JavaScript input service without WebView focus. */
public abstract class ControllerBridgeActivity extends BridgeActivity {
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final long REPEAT_THROTTLE_MS = 55L;
@@ -43,9 +43,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
getWindow().setAttributes(attributes);
if (bridge != null && bridge.getWebView() != null) {
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
bridge.getWebView().setFocusable(true);
bridge.getWebView().setFocusableInTouchMode(true);
bridge.getWebView().requestFocus();
}
enterImmersiveMode();
}
@@ -60,22 +57,6 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
public void onResume() {
super.onResume();
enterImmersiveMode();
if (bridge != null && bridge.getWebView() != null) bridge.getWebView().requestFocus();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
else clearHeldControllerState();
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN && bridge != null) {
bridge.getWebView().requestFocus();
}
return super.dispatchTouchEvent(event);
}
@Override
@@ -165,10 +146,7 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
});
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
}
private void dispatchNativeControllerMotion(float moveX, float moveY, float lookX, float lookY) {
+74
View File
@@ -66,3 +66,77 @@ CREATE TABLE IF NOT EXISTS rogue_trials_endless_records (
CREATE INDEX IF NOT EXISTS rogue_trials_endless_rank_idx
ON rogue_trials_endless_records (highest_boss_kills DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS hockey_healing_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_returns INTEGER NOT NULL DEFAULT 0 CHECK (highest_returns >= 0),
duration_seconds REAL NOT NULL DEFAULT 0 CHECK (duration_seconds >= 0),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS hockey_healing_rank_idx
ON hockey_healing_records (highest_returns DESC, duration_seconds DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS hockey_pvp_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
wins INTEGER NOT NULL DEFAULT 0 CHECK (wins >= 0),
losses INTEGER NOT NULL DEFAULT 0 CHECK (losses >= 0),
boss_kills INTEGER NOT NULL DEFAULT 0 CHECK (boss_kills >= 0),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS hockey_pvp_wins_rank_idx
ON hockey_pvp_records (wins DESC, losses ASC, updated_at ASC);
CREATE INDEX IF NOT EXISTS hockey_pvp_boss_kills_rank_idx
ON hockey_pvp_records (boss_kills DESC, wins DESC, updated_at ASC);
CREATE TABLE IF NOT EXISTS blockbreaker_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_bricks INTEGER NOT NULL DEFAULT 0 CHECK (highest_bricks >= 0),
bricks_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
longest_seconds REAL NOT NULL DEFAULT 0 CHECK (longest_seconds >= 0),
time_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
highest_score INTEGER NOT NULL DEFAULT 0 CHECK (highest_score >= 0),
score_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS blockbreaker_bricks_rank_idx
ON blockbreaker_records (highest_bricks DESC, bricks_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE INDEX IF NOT EXISTS blockbreaker_time_rank_idx
ON blockbreaker_records (longest_seconds DESC, time_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE INDEX IF NOT EXISTS blockbreaker_score_rank_idx
ON blockbreaker_records (highest_score DESC, score_achieved_at ASC, account_id ASC, slot_id ASC);
CREATE TABLE IF NOT EXISTS aether_assault_records (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
slot_id INTEGER NOT NULL CHECK (slot_id BETWEEN 1 AND 3),
highest_score INTEGER NOT NULL DEFAULT 0 CHECK (highest_score >= 0),
wave_at_best INTEGER NOT NULL DEFAULT 0 CHECK (wave_at_best >= 0),
duration_at_best REAL NOT NULL DEFAULT 0 CHECK (duration_at_best >= 0),
score_achieved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, slot_id),
FOREIGN KEY (account_id, slot_id) REFERENCES hunter_saves(account_id, slot_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS aether_assault_rank_idx
ON aether_assault_records (
highest_score DESC,
wave_at_best DESC,
score_achieved_at ASC,
account_id ASC,
slot_id ASC
);
+4 -3
View File
@@ -1,15 +1,15 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.10",
"version": "0.1.16",
"type": "module",
"scripts": {
"predev": "pnpm assets:sync-basis-transcoder",
"predev": "node scripts/sync_basis_transcoder.mjs",
"dev": "vite --host 0.0.0.0",
"dev:api": "HOST=127.0.0.1 PORT=4174 node server/production.mjs",
"db:backup": "node scripts/backup-db.mjs",
"db:init": "node scripts/init-db.mjs",
"prebuild": "pnpm assets:sync-basis-transcoder",
"prebuild": "node scripts/sync_basis_transcoder.mjs",
"build": "tsc -b && vite build",
"android:sync": "pnpm run build && cap sync android",
"android:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com pnpm run android:sync",
@@ -23,6 +23,7 @@
"assets:build-dungeon-kit": "node scripts/build_dungeon_kit.mjs",
"assets:build-gravehorn": "node scripts/build_gravehorn_triceratops.mjs",
"assets:build-ktx2": "node scripts/build_ktx2_game_assets.mjs",
"assets:build-priest-palette": "node scripts/build_priest_palette.mjs",
"assets:prune-party-animations": "node scripts/prune_party_animations.mjs --write",
"assets:import": "node scripts/import-game-asset.mjs",
"assets:sync-basis-transcoder": "node scripts/sync_basis_transcoder.mjs"
+66
View File
@@ -0,0 +1,66 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { createGameAssetIO } from "./lib/ktx2.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = path.join(repositoryRoot, "game_assets/models/claudecraft/chars/players/mage.glb");
const outputPath = path.join(repositoryRoot, "game_assets/textures/claudecraft/chars/players/priest-vestments.webp");
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function luminance(red, green, blue) {
return (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255;
}
function paintSwatch(pixels, width, channels, rectangle, shadow, highlight) {
const samples = [];
for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) {
for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) {
const offset = (y * width + x) * channels;
if (pixels[offset + 3] === 0) continue;
samples.push(luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2]));
}
}
const minimum = Math.min(...samples);
const maximum = Math.max(...samples);
const range = Math.max(0.001, maximum - minimum);
for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) {
for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) {
const offset = (y * width + x) * channels;
if (pixels[offset + 3] === 0) continue;
const sourceLuminance = luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2]);
const mix = clamp01((sourceLuminance - minimum) / range);
for (let channel = 0; channel < 3; channel += 1) {
pixels[offset + channel] = Math.round(shadow[channel] + (highlight[channel] - shadow[channel]) * mix);
}
}
}
}
const io = await createGameAssetIO();
const document = await io.read(sourcePath);
const sourceTexture = document.getRoot().listTextures().find((texture) => texture.getName() === "mage_texture");
if (!sourceTexture?.getImage()) throw new Error("Mage source is missing mage_texture.");
const decoded = await sharp(sourceTexture.getImage()).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const { width, height, channels } = decoded.info;
if (width !== 512 || height !== 512 || channels !== 4) {
throw new Error(`Unexpected mage palette shape: ${width}x${height}x${channels}.`);
}
const pixels = new Uint8Array(decoded.data);
paintSwatch(pixels, width, channels, { x: 0, y: 128, width: 128, height: 128 }, [48, 58, 94], [244, 239, 211]);
paintSwatch(pixels, width, channels, { x: 128, y: 128, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]);
paintSwatch(pixels, width, channels, { x: 64, y: 256, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]);
paintSwatch(pixels, width, channels, { x: 128, y: 256, width: 64, height: 128 }, [37, 71, 101], [157, 215, 221]);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, await sharp(pixels, { raw: { width, height, channels } })
.webp({ quality: 92, smartSubsample: true })
.toBuffer());
console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);
+81 -31
View File
@@ -8,10 +8,10 @@ import hashlib
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
@@ -22,9 +22,9 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
ANDROID_ROOT = REPO_ROOT / "android"
PACKAGE_JSON = REPO_ROOT / "package.json"
GITEA_TOKEN_FILE = REPO_ROOT / "git-token"
GITEA_REMOTE = "https://git.whoagland.com/phenom/i-want-to-heal-mmo.git"
GITEA_API = "https://git.whoagland.com/api/v1"
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
GITEA_OWNER = "phenom"
GITEA_REPO = "i-want-to-heal-mmo"
BRANCH = "main"
@@ -35,6 +35,15 @@ TRUENAS_GITEA_REPO = Path(
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
class GiteaAPIError(SystemExit):
def __init__(self, method: str, path: str, status: int, details: str) -> None:
self.method = method
self.path = path
self.status = status
self.details = details
super().__init__(f"Gitea API {method} {path} failed ({status}): {details}")
def run(
args: list[str],
*,
@@ -259,11 +268,13 @@ def ensure_tag(version: str, commit: str, message: str) -> str:
def gitea_token() -> str:
token = os.environ.get("GITEA_TOKEN", GITEA_TOKEN).strip()
token = os.environ.get("GITEA_TOKEN", "").strip()
if not token and GITEA_TOKEN_FILE.is_file():
token = GITEA_TOKEN_FILE.read_text().strip()
if not token:
raise SystemExit(
"GITEA_TOKEN is required to create the release and upload the APK. "
"Use --skip-release to push source/tag only."
f"Gitea token is required. Paste it into {GITEA_TOKEN_FILE}, "
"set GITEA_TOKEN, or use --skip-release."
)
return token
@@ -295,7 +306,7 @@ def gitea_request(
details = error.read().decode(errors="replace")
if allow_not_found and error.code == 404:
return None
raise SystemExit(f"Gitea API {method} {path} failed ({error.code}): {details}") from error
raise GiteaAPIError(method, path, error.code, details) from error
return json.loads(payload) if payload else None
@@ -328,28 +339,36 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str,
"prerelease": True,
}
).encode()
result = gitea_request(
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases",
token=token,
method="POST",
body=payload,
)
path = f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases"
result = None
for attempt in range(3):
try:
result = gitea_request(
path,
token=token,
method="POST",
body=payload,
)
break
except GiteaAPIError as error:
tag_sync_race = (
error.status == 500
and "UQE_release_n" in error.details
and "23505" in error.details
)
if not tag_sync_race or attempt == 2:
raise
delay = 0.5 * (attempt + 1)
print(
f"Gitea tag sync still settling for {tag}; "
f"retrying release in {delay:g}s."
)
time.sleep(delay)
if not isinstance(result, dict):
raise SystemExit("Gitea returned an invalid release response")
return result
def multipart_asset(path: Path, media_type: str) -> tuple[bytes, str]:
boundary = f"----iwanttoheal{secrets.token_hex(12)}"
prefix = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
f"Content-Type: {media_type}\r\n\r\n"
).encode()
body = prefix + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
return body, f"multipart/form-data; boundary={boundary}"
def upload_release_asset(
release_id: int,
path: Path,
@@ -357,15 +376,46 @@ def upload_release_asset(
media_type: str,
token: str,
) -> None:
body, content_type = multipart_asset(path, media_type)
gitea_request(
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
f"?name={urllib.parse.quote(path.name)}",
token=token,
method="POST",
body=body,
content_type=content_type,
curl = shutil.which("curl")
if not curl:
raise SystemExit("curl is required to upload Gitea release assets")
url = (
f"{GITEA_API}/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
f"?name={urllib.parse.quote(path.name)}"
)
headers = (
"Accept: application/json\n"
f"Authorization: token {token}\n"
"Expect: 100-continue\n"
)
result = subprocess.run(
[
curl,
"--silent",
"--show-error",
"--fail-with-body",
"--connect-timeout",
"30",
"--max-time",
"300",
"--header",
"@-",
"--form",
f"attachment=@{path};type={media_type}",
url,
],
cwd=REPO_ROOT,
input=headers,
capture_output=True,
text=True,
check=False,
)
if result.returncode:
details = result.stdout.strip() or result.stderr.strip()
raise SystemExit(
f"Gitea release asset upload failed (curl {result.returncode}): {details}"
)
print(f"Release asset uploaded: {path.name}")
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import os
import unittest
from unittest.mock import MagicMock, patch
from scripts import publish_gitea
class GiteaTokenTests(unittest.TestCase):
def test_reads_ignored_token_file(self) -> None:
token_file = MagicMock()
token_file.is_file.return_value = True
token_file.read_text.return_value = "local-token\n"
with (
patch.dict(os.environ, {}, clear=True),
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
):
self.assertEqual(publish_gitea.gitea_token(), "local-token")
def test_environment_token_overrides_file(self) -> None:
token_file = MagicMock()
with (
patch.dict(os.environ, {"GITEA_TOKEN": "environment-token"}, clear=True),
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
):
self.assertEqual(publish_gitea.gitea_token(), "environment-token")
token_file.is_file.assert_not_called()
def test_requires_token(self) -> None:
token_file = MagicMock()
token_file.is_file.return_value = False
with (
patch.dict(os.environ, {}, clear=True),
patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file),
):
with self.assertRaisesRegex(SystemExit, "Gitea token is required"):
publish_gitea.gitea_token()
class CreateReleaseTests(unittest.TestCase):
def test_retries_postgres_tag_sync_collision(self) -> None:
collision = publish_gitea.GiteaAPIError(
"POST",
"/repos/phenom/i-want-to-heal-mmo/releases",
500,
'duplicate key violates "UQE_release_n" (23505)',
)
created = {"id": 15, "tag_name": "v0.1.15"}
with (
patch.object(publish_gitea, "release_for_tag", return_value=None),
patch.object(
publish_gitea,
"gitea_request",
side_effect=[collision, created],
) as request,
patch.object(publish_gitea.time, "sleep") as sleep,
):
result = publish_gitea.create_release(
"v0.1.15",
"ea5d455",
"Release v0.1.15 2026-07-18",
"token",
)
self.assertEqual(result, created)
self.assertEqual(request.call_count, 2)
sleep.assert_called_once_with(0.5)
def test_does_not_retry_unrelated_api_error(self) -> None:
unauthorized = publish_gitea.GiteaAPIError(
"POST",
"/repos/phenom/i-want-to-heal-mmo/releases",
401,
"unauthorized",
)
with (
patch.object(publish_gitea, "release_for_tag", return_value=None),
patch.object(
publish_gitea,
"gitea_request",
side_effect=unauthorized,
) as request,
patch.object(publish_gitea.time, "sleep") as sleep,
):
with self.assertRaises(publish_gitea.GiteaAPIError):
publish_gitea.create_release(
"v0.1.16",
"commit",
"Release v0.1.16",
"token",
)
request.assert_called_once()
sleep.assert_not_called()
if __name__ == "__main__":
unittest.main()
+428 -10
View File
@@ -180,14 +180,15 @@ function validateSlotId(value) {
}
function validateSave(value, slotId) {
if (!value || typeof value !== "object" || Number(value.schemaVersion) !== 5) {
const schemaVersion = Number(value?.schemaVersion);
if (!value || typeof value !== "object" || schemaVersion !== 5 && schemaVersion !== 6) {
throw apiError("Save snapshot is invalid.");
}
if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request.");
if (typeof value.hunterName !== "string" || !value.hunterName.trim()) {
throw apiError("Save snapshot has no hunter name.");
}
return value;
return { ...value, schemaVersion: 6 };
}
function normalizeNonNegativeInteger(value) {
@@ -195,6 +196,60 @@ function normalizeNonNegativeInteger(value) {
return Number.isFinite(number) ? Math.max(0, number) : 0;
}
function normalizeNonNegativeNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? Math.max(0, number) : 0;
}
function mergeLeaderboardHighWater(database, accountId, slotId, save) {
const blockbreaker = database.prepare(`
SELECT highest_bricks AS highestBricks, longest_seconds AS longestSeconds, highest_score AS highestScore
FROM blockbreaker_records WHERE account_id = ? AND slot_id = ?
`).get(accountId, slotId);
const aether = database.prepare(`
SELECT highest_score AS highestScore, wave_at_best AS waveAtBest, duration_at_best AS durationAtBest
FROM aether_assault_records WHERE account_id = ? AND slot_id = ?
`).get(accountId, slotId);
const stats = save.stats && typeof save.stats === "object" ? save.stats : {};
const candidateAether = {
score: normalizeNonNegativeInteger(stats.highestAetherAssaultScore),
wave: normalizeNonNegativeInteger(stats.highestAetherAssaultWaveAtBest),
duration: normalizeNonNegativeNumber(stats.longestAetherAssaultSecondsAtBest),
};
const storedAether = {
score: normalizeNonNegativeInteger(aether?.highestScore),
wave: normalizeNonNegativeInteger(aether?.waveAtBest),
duration: normalizeNonNegativeNumber(aether?.durationAtBest),
};
const aetherRecord = candidateAether.score > storedAether.score
|| candidateAether.score === storedAether.score && candidateAether.wave > storedAether.wave
|| candidateAether.score === storedAether.score && candidateAether.wave === storedAether.wave && candidateAether.duration > storedAether.duration
? candidateAether
: storedAether;
return {
...save,
schemaVersion: 6,
stats: {
...stats,
highestBlockbreakerBricks: Math.max(
normalizeNonNegativeInteger(stats.highestBlockbreakerBricks),
normalizeNonNegativeInteger(blockbreaker?.highestBricks),
),
longestBlockbreakerSeconds: Math.max(
normalizeNonNegativeNumber(stats.longestBlockbreakerSeconds),
normalizeNonNegativeNumber(blockbreaker?.longestSeconds),
),
highestBlockbreakerScore: Math.max(
normalizeNonNegativeInteger(stats.highestBlockbreakerScore),
normalizeNonNegativeInteger(blockbreaker?.highestScore),
),
highestAetherAssaultScore: aetherRecord.score,
highestAetherAssaultWaveAtBest: aetherRecord.wave,
longestAetherAssaultSecondsAtBest: aetherRecord.duration,
},
};
}
function syncLeaderboardStats(database, accountId, slotId, save) {
database.prepare("DELETE FROM boss_kill_records WHERE account_id = ? AND slot_id = ?").run(accountId, slotId);
const insertBoss = database.prepare(`
@@ -225,14 +280,95 @@ function syncLeaderboardStats(database, accountId, slotId, save) {
highest_boss_kills = excluded.highest_boss_kills,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestEndlessKills);
const highestHockeyReturns = normalizeNonNegativeInteger(save.stats?.highestHockeyHealingReturns);
const hockeyDurationSeconds = normalizeNonNegativeNumber(save.stats?.longestHockeyHealingSecondsAtBest);
database.prepare(`
INSERT INTO hockey_healing_records (account_id, slot_id, highest_returns, duration_seconds, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_returns = excluded.highest_returns,
duration_seconds = excluded.duration_seconds,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestHockeyReturns, hockeyDurationSeconds);
const hockeyPvpWins = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpWins);
const hockeyPvpLosses = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpLosses);
const hockeyPvpBossKills = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpBossKills);
database.prepare(`
INSERT INTO hockey_pvp_records (account_id, slot_id, wins, losses, boss_kills, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
wins = excluded.wins,
losses = excluded.losses,
boss_kills = excluded.boss_kills,
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, hockeyPvpWins, hockeyPvpLosses, hockeyPvpBossKills);
const highestBlockbreakerBricks = normalizeNonNegativeInteger(save.stats?.highestBlockbreakerBricks);
const longestBlockbreakerSeconds = normalizeNonNegativeNumber(save.stats?.longestBlockbreakerSeconds);
const highestBlockbreakerScore = normalizeNonNegativeInteger(save.stats?.highestBlockbreakerScore);
database.prepare(`
INSERT INTO blockbreaker_records (
account_id, slot_id,
highest_bricks, bricks_achieved_at,
longest_seconds, time_achieved_at,
highest_score, score_achieved_at,
updated_at
) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
bricks_achieved_at = CASE
WHEN excluded.highest_bricks > blockbreaker_records.highest_bricks THEN CURRENT_TIMESTAMP
ELSE blockbreaker_records.bricks_achieved_at
END,
highest_bricks = MAX(blockbreaker_records.highest_bricks, excluded.highest_bricks),
time_achieved_at = CASE
WHEN excluded.longest_seconds > blockbreaker_records.longest_seconds THEN CURRENT_TIMESTAMP
ELSE blockbreaker_records.time_achieved_at
END,
longest_seconds = MAX(blockbreaker_records.longest_seconds, excluded.longest_seconds),
score_achieved_at = CASE
WHEN excluded.highest_score > blockbreaker_records.highest_score THEN CURRENT_TIMESTAMP
ELSE blockbreaker_records.score_achieved_at
END,
highest_score = MAX(blockbreaker_records.highest_score, excluded.highest_score),
updated_at = CURRENT_TIMESTAMP
`).run(accountId, slotId, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore);
const highestAetherScore = normalizeNonNegativeInteger(save.stats?.highestAetherAssaultScore);
const aetherWaveAtBest = normalizeNonNegativeInteger(save.stats?.highestAetherAssaultWaveAtBest);
const aetherDurationAtBest = normalizeNonNegativeNumber(save.stats?.longestAetherAssaultSecondsAtBest);
database.prepare(`
INSERT INTO aether_assault_records (
account_id, slot_id, highest_score, wave_at_best, duration_at_best, score_achieved_at, updated_at
) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(account_id, slot_id) DO UPDATE SET
highest_score = excluded.highest_score,
wave_at_best = excluded.wave_at_best,
duration_at_best = excluded.duration_at_best,
score_achieved_at = CASE
WHEN excluded.highest_score > aether_assault_records.highest_score
OR excluded.highest_score = aether_assault_records.highest_score
AND excluded.wave_at_best > aether_assault_records.wave_at_best
THEN CURRENT_TIMESTAMP
ELSE aether_assault_records.score_achieved_at
END,
updated_at = CURRENT_TIMESTAMP
WHERE excluded.highest_score > aether_assault_records.highest_score
OR excluded.highest_score = aether_assault_records.highest_score
AND excluded.wave_at_best > aether_assault_records.wave_at_best
OR excluded.highest_score = aether_assault_records.highest_score
AND excluded.wave_at_best = aether_assault_records.wave_at_best
AND excluded.duration_at_best > aether_assault_records.duration_at_best
`).run(accountId, slotId, highestAetherScore, aetherWaveAtBest, aetherDurationAtBest);
}
function writeSave(database, accountId, slotId, rawSave) {
const save = validateSave(rawSave, slotId);
const serialized = JSON.stringify(save);
if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413);
const validatedSave = validateSave(rawSave, slotId);
database.exec("BEGIN IMMEDIATE");
try {
const save = mergeLeaderboardHighWater(database, accountId, slotId, validatedSave);
const serialized = JSON.stringify(save);
if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413);
database.prepare(`
INSERT INTO hunter_saves (account_id, slot_id, hunter_name, save_json, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
@@ -243,11 +379,11 @@ function writeSave(database, accountId, slotId, rawSave) {
`).run(accountId, slotId, save.hunterName.trim().slice(0, 20), serialized);
syncLeaderboardStats(database, accountId, slotId, save);
database.exec("COMMIT");
return save;
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return save;
}
function readSave(database, accountId, slotId) {
@@ -255,7 +391,12 @@ function readSave(database, accountId, slotId) {
SELECT save_json AS saveJson FROM hunter_saves WHERE account_id = ? AND slot_id = ?
`).get(accountId, slotId);
if (!row) return null;
try { return JSON.parse(row.saveJson); } catch { return null; }
try {
const save = JSON.parse(row.saveJson);
return mergeLeaderboardHighWater(database, accountId, slotId, save);
} catch {
return null;
}
}
function listSaves(database, accountId) {
@@ -263,18 +404,22 @@ function listSaves(database, accountId) {
SELECT slot_id AS slotId, save_json AS saveJson, updated_at AS updatedAt
FROM hunter_saves WHERE account_id = ? ORDER BY slot_id
`).all(accountId).flatMap((row) => {
try { return [{ slotId: row.slotId, save: JSON.parse(row.saveJson), updatedAt: row.updatedAt }]; }
try {
const save = mergeLeaderboardHighWater(database, accountId, row.slotId, JSON.parse(row.saveJson));
return [{ slotId: row.slotId, save, updatedAt: row.updatedAt }];
}
catch { return []; }
});
}
function leaderboardEntry(row, valueKey) {
function leaderboardEntry(row, valueKey, secondaryValueKey) {
return {
rank: row.rank,
username: row.username,
hunterName: row.hunterName,
slotId: row.slotId,
value: row[valueKey],
...(secondaryValueKey ? { secondaryValue: row[secondaryValueKey] } : {}),
};
}
@@ -358,11 +503,224 @@ function rogueTrialsEndlessLeaderboard(database, accountId, slotId) {
};
}
function hockeyHealingLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.highest_returns DESC, records.duration_seconds DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_returns AS highestReturns,
records.duration_seconds AS durationSeconds,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM hockey_healing_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE records.highest_returns > 0 OR records.duration_seconds > 0
)
SELECT * FROM ranked
ORDER BY highestReturns DESC, durationSeconds DESC, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "hockey-healing",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestReturns", "durationSeconds")),
current: current ? leaderboardEntry(current, "highestReturns", "durationSeconds") : null,
};
}
function hockeyPvpLeaderboard(database, accountId, slotId, kind) {
const winsBoard = kind === "hockey-pvp-wins";
const order = winsBoard
? "records.wins DESC, records.losses ASC"
: "records.boss_kills DESC, records.wins DESC";
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY ${order}) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.wins,
records.losses,
records.boss_kills AS bossKills,
accounts.username,
saves.hunter_name AS hunterName,
records.updated_at AS updatedAt
FROM hockey_pvp_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE ${winsBoard ? "records.wins > 0 OR records.losses > 0" : "records.boss_kills > 0"}
)
SELECT * FROM ranked
ORDER BY ${winsBoard ? "wins DESC, losses ASC" : "bossKills DESC, wins DESC"}, updatedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
const valueKey = winsBoard ? "wins" : "bossKills";
const secondaryKey = winsBoard ? "losses" : undefined;
return {
kind,
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, valueKey, secondaryKey)),
current: current ? leaderboardEntry(current, valueKey, secondaryKey) : null,
};
}
function blockbreakerLeaderboard(database, accountId, slotId, kind) {
const boards = {
"blockbreaker-bricks": { column: "highest_bricks", valueKey: "highestBricks", achieved: "bricks_achieved_at" },
"blockbreaker-time": { column: "longest_seconds", valueKey: "longestSeconds", achieved: "time_achieved_at" },
"blockbreaker-score": { column: "highest_score", valueKey: "highestScore", achieved: "score_achieved_at" },
};
const board = boards[kind];
if (!board) throw apiError("Invalid Blockbreaker leaderboard.");
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (ORDER BY records.${board.column} DESC) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.${board.column} AS ${board.valueKey},
records.${board.achieved} AS achievedAt,
accounts.username,
saves.hunter_name AS hunterName
FROM blockbreaker_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE records.${board.column} > 0
)
SELECT * FROM ranked
ORDER BY ${board.valueKey} DESC, achievedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind,
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, board.valueKey)),
current: current ? leaderboardEntry(current, board.valueKey) : null,
};
}
function aetherAssaultLeaderboard(database, accountId, slotId) {
const rows = database.prepare(`
WITH ranked AS (
SELECT
RANK() OVER (
ORDER BY records.highest_score DESC, records.wave_at_best DESC,
records.score_achieved_at ASC, records.account_id ASC, records.slot_id ASC
) AS rank,
records.account_id AS accountId,
records.slot_id AS slotId,
records.highest_score AS highestScore,
records.wave_at_best AS waveAtBest,
records.score_achieved_at AS achievedAt,
accounts.username,
saves.hunter_name AS hunterName
FROM aether_assault_records records
JOIN accounts ON accounts.id = records.account_id
JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id
WHERE records.highest_score > 0
)
SELECT * FROM ranked
ORDER BY highestScore DESC, waveAtBest DESC, achievedAt ASC, accountId ASC, slotId ASC
`).all();
const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null;
return {
kind: "aether-assault",
top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestScore", "waveAtBest")),
current: current ? leaderboardEntry(current, "highestScore", "waveAtBest") : null,
};
}
export function createGameApiHandler(options = {}) {
const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data");
mkdirSync(dataDirectory, { recursive: true });
const database = new DatabaseSync(resolve(dataDirectory, "game.db"));
database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8"));
const hockeyPvpTickets = new Map();
const hockeyPvpMatches = new Map();
function queueResult(ticket) {
const match = ticket.matchId ? hockeyPvpMatches.get(ticket.matchId) : null;
if (!match) return { ticketId: ticket.id, status: "waiting" };
const opponentSide = ticket.side === "host" ? "guest" : "host";
const opponent = match.players[opponentSide];
return {
ticketId: ticket.id,
status: "matched",
match: {
id: match.id,
seed: match.seed,
opponentName: opponent.hunterName,
role: ticket.side,
},
};
}
function joinHockeyPvpQueue(session, payload) {
const slotId = validateSlotId(payload?.slotId);
const hunterName = String(payload?.hunterName ?? "").trim().slice(0, 20);
if (!hunterName) throw apiError("Hunter name is required.");
const existing = [...hockeyPvpTickets.values()].find((ticket) =>
ticket.accountId === session.accountId && !ticket.matchId && !ticket.cancelled && !ticket.completed);
if (existing) return queueResult(existing);
const now = Date.now();
for (const ticket of hockeyPvpTickets.values()) {
if (!ticket.matchId && now - ticket.createdAt > 30_000) {
ticket.cancelled = true;
ticket.completed = true;
}
}
const opponent = [...hockeyPvpTickets.values()].find((ticket) =>
!ticket.matchId && !ticket.cancelled && !ticket.completed && ticket.accountId !== session.accountId);
const ticket = {
id: randomBytes(18).toString("base64url"),
accountId: session.accountId,
username: session.username,
slotId,
hunterName,
createdAt: now,
matchId: null,
side: null,
cancelled: false,
completed: false,
};
hockeyPvpTickets.set(ticket.id, ticket);
if (!opponent) return queueResult(ticket);
const matchId = randomBytes(18).toString("base64url");
const match = {
id: matchId,
seed: randomBytes(4).readUInt32BE(0) || 1,
createdAt: now,
players: { host: opponent, guest: ticket },
snapshots: { host: null, guest: null },
};
opponent.matchId = matchId;
opponent.side = "host";
ticket.matchId = matchId;
ticket.side = "guest";
hockeyPvpMatches.set(matchId, match);
return queueResult(ticket);
}
function requireHockeyPvpTicket(session, ticketId) {
const ticket = hockeyPvpTickets.get(ticketId);
if (!ticket || ticket.accountId !== session.accountId || ticket.cancelled) throw apiError("Queue ticket not found.", 404);
return ticket;
}
function requireHockeyPvpMatch(session, matchId) {
const match = hockeyPvpMatches.get(matchId);
if (!match) throw apiError("PVP match not found.", 404);
const side = match.players.host.accountId === session.accountId
? "host"
: match.players.guest.accountId === session.accountId
? "guest"
: null;
if (!side) throw apiError("PVP match access denied.", 403);
return { match, side };
}
async function handle(request, response, next) {
if (!request.url?.startsWith("/api/")) return next();
@@ -400,6 +758,31 @@ export function createGameApiHandler(options = {}) {
}
const session = requireSession(database, request);
if (path === "/api/hockey-pvp/queue" && request.method === "POST") {
return sendJson(response, 200, joinHockeyPvpQueue(session, await readJson(request)));
}
const queueMatch = path.match(/^\/api\/hockey-pvp\/queue\/([A-Za-z0-9_-]+)$/);
if (queueMatch && request.method === "GET") {
return sendJson(response, 200, queueResult(requireHockeyPvpTicket(session, queueMatch[1])));
}
if (queueMatch && request.method === "DELETE") {
const ticket = requireHockeyPvpTicket(session, queueMatch[1]);
if (ticket.matchId) throw apiError("Matched queue cannot be cancelled.", 409);
ticket.cancelled = true;
ticket.completed = true;
return sendJson(response, 200, { ok: true });
}
const pvpStateMatch = path.match(/^\/api\/hockey-pvp\/matches\/([A-Za-z0-9_-]+)\/state$/);
if (pvpStateMatch && request.method === "PUT") {
const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]);
const payload = await readJson(request);
if (!payload?.snapshot || typeof payload.snapshot !== "object") throw apiError("PVP snapshot is invalid.");
match.snapshots[side] = payload.snapshot;
return sendJson(response, 200, {
opponentSnapshot: match.snapshots[side === "host" ? "guest" : "host"],
hostSnapshot: match.snapshots.host,
});
}
if (path === "/api/saves" && request.method === "GET") {
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
}
@@ -425,6 +808,34 @@ export function createGameApiHandler(options = {}) {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, rogueTrialsEndlessLeaderboard(database, session.accountId, slotId));
}
if (path === "/api/leaderboards/hockey-healing" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, hockeyHealingLeaderboard(database, session.accountId, slotId));
}
if (path === "/api/leaderboards/hockey-pvp-wins" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, hockeyPvpLeaderboard(database, session.accountId, slotId, "hockey-pvp-wins"));
}
if (path === "/api/leaderboards/hockey-pvp-boss-kills" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, hockeyPvpLeaderboard(database, session.accountId, slotId, "hockey-pvp-boss-kills"));
}
if (path === "/api/leaderboards/blockbreaker-bricks" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-bricks"));
}
if (path === "/api/leaderboards/blockbreaker-time" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-time"));
}
if (path === "/api/leaderboards/blockbreaker-score" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-score"));
}
if (path === "/api/leaderboards/aether-assault" && request.method === "GET") {
const slotId = validateSlotId(url.searchParams.get("slot"));
return sendJson(response, 200, aetherAssaultLeaderboard(database, session.accountId, slotId));
}
return sendJson(response, 404, { error: "API route not found." });
} catch (error) {
const status = Number(error?.status) || 500;
@@ -434,5 +845,12 @@ export function createGameApiHandler(options = {}) {
}
}
return { handle, close: () => database.close() };
return {
handle,
close: () => {
hockeyPvpTickets.clear();
hockeyPvpMatches.clear();
database.close();
},
};
}
+154 -5
View File
@@ -34,12 +34,12 @@ async function json(path, init = {}) {
return { response, body };
}
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills) {
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins = 0, hockeyHealingPvpLosses = 0, hockeyHealingPvpBossKills = 0, highestBlockbreakerBricks = 0, longestBlockbreakerSeconds = 0, highestBlockbreakerScore = 0, highestAetherAssaultScore = 0, highestAetherAssaultWaveAtBest = 0, longestAetherAssaultSecondsAtBest = 0) {
return {
schemaVersion: 5,
schemaVersion: 6,
slotId,
hunterName,
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills },
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest },
};
}
@@ -63,13 +63,24 @@ test("accounts, server saves, and top-five plus current rankings work end to end
const kills = 60 - index * 10;
const highestRound = 30 - index * 4;
const highestEndlessKills = 24 - index * 3;
const highestHockeyReturns = 30 - index * 4;
const hockeyDuration = 180 - index * 10;
const pvpWins = 30 - index * 4;
const pvpLosses = index + 1;
const pvpBossKills = 120 - index * 12;
const blockbreakerBricks = index < 2 ? 600 : 700 - index * 100;
const blockbreakerSeconds = 360 - index * 30;
const blockbreakerScore = 20_000 - index * 2_000;
const aetherScore = index < 2 ? 50_000 : 54_000 - index * 5_000;
const aetherWave = index < 2 ? 12 : 10 - index;
const aetherDuration = 300 - index * 20;
const upload = await json("/api/saves/1", {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills) }),
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration) }),
});
assert.equal(upload.response.status, 200);
players.push({ token, kills, highestRound, highestEndlessKills });
players.push({ token, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration });
}
const current = players[5];
@@ -97,12 +108,150 @@ test("accounts, server saves, and top-five plus current rankings work end to end
assert.equal(endlessBoard.body.current.rank, 6);
assert.equal(endlessBoard.body.current.value, current.highestEndlessKills);
const hockeyBoard = await json("/api/leaderboards/hockey-healing?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(hockeyBoard.body.kind, "hockey-healing");
assert.equal(hockeyBoard.body.top.length, 5);
assert.equal(hockeyBoard.body.top[0].value, 30);
assert.equal(hockeyBoard.body.current.rank, 6);
assert.equal(hockeyBoard.body.current.value, current.highestHockeyReturns);
assert.equal(hockeyBoard.body.current.secondaryValue, current.hockeyDuration);
const pvpWinsBoard = await json("/api/leaderboards/hockey-pvp-wins?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(pvpWinsBoard.body.kind, "hockey-pvp-wins");
assert.equal(pvpWinsBoard.body.top[0].value, 30);
assert.equal(pvpWinsBoard.body.current.value, current.pvpWins);
assert.equal(pvpWinsBoard.body.current.secondaryValue, current.pvpLosses);
const pvpKillsBoard = await json("/api/leaderboards/hockey-pvp-boss-kills?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(pvpKillsBoard.body.kind, "hockey-pvp-boss-kills");
assert.equal(pvpKillsBoard.body.top[0].value, 120);
assert.equal(pvpKillsBoard.body.current.value, current.pvpBossKills);
const blockbreakerBricksBoard = await json("/api/leaderboards/blockbreaker-bricks?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerBricksBoard.body.kind, "blockbreaker-bricks");
assert.equal(blockbreakerBricksBoard.body.top.length, 5);
assert.equal(blockbreakerBricksBoard.body.top[0].value, 600);
assert.equal(blockbreakerBricksBoard.body.top[0].rank, 1);
assert.equal(blockbreakerBricksBoard.body.top[1].rank, 1);
assert.equal(blockbreakerBricksBoard.body.current.rank, 6);
assert.equal(blockbreakerBricksBoard.body.current.value, current.blockbreakerBricks);
const blockbreakerTimeBoard = await json("/api/leaderboards/blockbreaker-time?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerTimeBoard.body.kind, "blockbreaker-time");
assert.equal(blockbreakerTimeBoard.body.top[0].value, 360);
assert.equal(blockbreakerTimeBoard.body.current.value, current.blockbreakerSeconds);
const blockbreakerScoreBoard = await json("/api/leaderboards/blockbreaker-score?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(blockbreakerScoreBoard.body.kind, "blockbreaker-score");
assert.equal(blockbreakerScoreBoard.body.top[0].value, 20_000);
assert.equal(blockbreakerScoreBoard.body.current.value, current.blockbreakerScore);
const aetherBoard = await json("/api/leaderboards/aether-assault?slot=1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(aetherBoard.body.kind, "aether-assault");
assert.equal(aetherBoard.body.top.length, 5);
assert.equal(aetherBoard.body.top[0].value, 50_000);
assert.equal(aetherBoard.body.top[0].secondaryValue, 12);
assert.equal(aetherBoard.body.top[0].username, "hunter_0");
assert.equal(aetherBoard.body.top[0].rank, 1);
assert.equal(aetherBoard.body.top[1].value, 50_000);
assert.equal(aetherBoard.body.top[1].secondaryValue, 12);
assert.equal(aetherBoard.body.top[1].username, "hunter_1");
assert.equal(aetherBoard.body.top[1].rank, 2);
assert.equal(aetherBoard.body.current.rank, 6);
assert.equal(aetherBoard.body.current.value, current.aetherScore);
assert.equal(aetherBoard.body.current.secondaryValue, current.aetherWave);
const legacySnapshot = save(1, "Hero 5", current.kills, current.highestRound, current.highestEndlessKills, current.highestHockeyReturns, current.hockeyDuration, current.pvpWins, current.pvpLosses, current.pvpBossKills);
delete legacySnapshot.stats.highestBlockbreakerBricks;
delete legacySnapshot.stats.longestBlockbreakerSeconds;
delete legacySnapshot.stats.highestBlockbreakerScore;
delete legacySnapshot.stats.highestAetherAssaultScore;
delete legacySnapshot.stats.highestAetherAssaultWaveAtBest;
delete legacySnapshot.stats.longestAetherAssaultSecondsAtBest;
legacySnapshot.schemaVersion = 5;
const legacyUpload = await json("/api/saves/1", {
method: "PUT",
headers: { Authorization: `Bearer ${current.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ save: legacySnapshot }),
});
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerBricks, current.blockbreakerBricks);
assert.equal(legacyUpload.body.save.stats.longestBlockbreakerSeconds, current.blockbreakerSeconds);
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerScore, current.blockbreakerScore);
assert.equal(legacyUpload.body.save.schemaVersion, 6);
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultScore, current.aetherScore);
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultWaveAtBest, current.aetherWave);
assert.equal(legacyUpload.body.save.stats.longestAetherAssaultSecondsAtBest, current.aetherDuration);
const download = await json("/api/saves/1", {
headers: { Authorization: `Bearer ${current.token}` },
});
assert.equal(download.body.save.hunterName, "Hero 5");
});
test("Healing Hockey PVP queue pairs players and relays match snapshots", async () => {
const registerPlayer = async (username) => {
const registration = await json("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password: `long-password-${username}` }),
});
return registration.body.token;
};
const alphaToken = await registerPlayer("pvp_alpha");
const betaToken = await registerPlayer("pvp_beta");
const alphaQueue = await json("/api/hockey-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ slotId: 1, hunterName: "Alpha" }),
});
assert.equal(alphaQueue.body.status, "waiting");
const betaQueue = await json("/api/hockey-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ slotId: 1, hunterName: "Beta" }),
});
assert.equal(betaQueue.body.status, "matched");
assert.equal(betaQueue.body.match.role, "guest");
assert.equal(betaQueue.body.match.opponentName, "Alpha");
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
headers: { Authorization: `Bearer ${alphaToken}` },
});
assert.equal(alphaMatched.body.status, "matched");
assert.equal(alphaMatched.body.match.role, "host");
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed);
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ snapshot: hostSnapshot }),
});
const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ snapshot: { sequence: 1, party: [] } }),
});
assert.deepEqual(guestExchange.body.opponentSnapshot, hostSnapshot);
assert.deepEqual(guestExchange.body.hostSnapshot, hostSnapshot);
});
test("invalid credentials cannot access server saves", async () => {
const login = await json("/api/auth/login", {
method: "POST",
+113 -11
View File
@@ -3,15 +3,19 @@ import packageJson from "../package.json";
import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { FrontEnd } from "./components/FrontEnd";
import { useActiveHunter, useFrontendStore } from "./frontend/store";
import { useGameStore } from "./game/store";
import { getHockeyPvpNetworkSnapshot, useGameStore } from "./game/store";
import type { BossId } from "./game/types";
import type { DifficultySlug } from "./game/progression/loot";
import { useActionBindings } from "./game/useGameLoop";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { DUAL_SCREEN_EXIT_EVENT, DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
import { startSaveSyncCoordinator } from "./frontend/saveSync";
import type { HockeyPvpMatchConfig } from "./game/hockeyHealingPvp";
import { onlineRepository } from "./frontend/onlineRepository";
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
const HealerModelGallery = lazy(() => import("./components/GameScene").then((module) => ({ default: module.HealerModelGallery })));
function GameLoadingScreen() {
return (
@@ -22,10 +26,11 @@ function GameLoadingScreen() {
);
}
export default function App() {
function MainApp() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
const screen = useFrontendStore((state) => state.screen);
const accountId = useFrontendStore((state) => state.accountId);
const hunter = useActiveHunter();
const settings = useFrontendStore((state) => state.settings);
const navigate = useFrontendStore((state) => state.navigate);
@@ -34,36 +39,65 @@ export default function App() {
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
const recordRoguelikeDefeat = useFrontendStore((state) => state.recordRoguelikeDefeat);
const recordRogueTrialsEndlessDefeat = useFrontendStore((state) => state.recordRogueTrialsEndlessDefeat);
const recordHockeyHealingDefeat = useFrontendStore((state) => state.recordHockeyHealingDefeat);
const recordHockeyPvpResult = useFrontendStore((state) => state.recordHockeyPvpResult);
const recordHockeyPvpBossKill = useFrontendStore((state) => state.recordHockeyPvpBossKill);
const recordBlockbreakerDefeat = useFrontendStore((state) => state.recordBlockbreakerDefeat);
const recordAetherAssaultDefeat = useFrontendStore((state) => state.recordAetherAssaultDefeat);
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
const rewardedBossInstances = useRef(new Set<string>());
const screenRef = useRef(screen);
screenRef.current = screen;
const leaveGame = useCallback(() => {
const { accountId, activeSlotId, uploadSlot } = useFrontendStore.getState();
updateActiveHealerInventory(useGameStore.getState().inventory);
const game = useGameStore.getState();
// RPG Roguelike equipment belongs only to its current run. Never leak it
// into the hunter's permanent inventory when leaving the expedition.
if (game.runMode !== "rpg-roguelike") updateActiveHealerInventory(game.inventory);
touchActiveSave();
navigate("home");
if (accountId && activeSlotId) void uploadSlot(activeSlotId);
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug, hockeyPvpMatch?: HockeyPvpMatchConfig) => {
if (!hunter) return;
const progress = hunter.healers[hunter.activeClassId];
const selectedMode = useFrontendStore.getState().selectedMode;
const runMode = selectedMode === "roguelike-pve" ? "roguelike" : selectedMode === "rogue-trials" ? "rogue-trials" : "encounter";
const runMode = selectedMode === "roguelike-pve"
? "rpg-roguelike"
: selectedMode === "rogue-trials"
? "rogue-trials"
: selectedMode === "hockey-healing"
? "hockey-healing"
: selectedMode === "hockey-healing-pvp"
? "hockey-healing-pvp"
: selectedMode === "blockbreaker"
? "blockbreaker"
: selectedMode === "aether-assault"
? "aether-assault"
: "encounter";
const launchDifficulty = runMode !== "encounter"
? "initiate"
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
rewardedBossInstances.current.clear();
clearRecentRewards();
useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds, runMode, hunter.gearProgress, launchDifficulty);
useGameStore.getState().configureHealer(
hunter.activeClassId,
hunter.hunterName,
progress.inventory,
bossIds,
runMode,
hunter.gearProgress,
launchDifficulty,
hockeyPvpMatch,
);
touchActiveSave();
navigate("game");
}, [clearRecentRewards, hunter, navigate, touchActiveSave]);
useEffect(() => {
const onDualScreenLaunch = (event: Event) => {
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug } | readonly BossId[]>).detail;
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug);
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug; hockeyPvpMatch?: HockeyPvpMatchConfig } | readonly BossId[]>).detail;
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug, detail.hockeyPvpMatch);
else launchGame(detail);
};
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
@@ -75,6 +109,44 @@ export default function App() {
return () => window.removeEventListener(DUAL_SCREEN_EXIT_EVENT, leaveGame);
}, [leaveGame]);
useEffect(() => {
if (!accountId) return;
return startSaveSyncCoordinator((slotId) => useFrontendStore.getState().uploadSlot(slotId));
}, [accountId]);
useEffect(() => {
if (screen !== "game") return;
let stopped = false;
let exchangeActive = false;
const exchange = async () => {
if (stopped || exchangeActive) return;
const state = useGameStore.getState();
if (state.runMode !== "hockey-healing-pvp" || !state.hockeyPvp.matchId || state.hockeyPvp.role === "cpu") return;
const snapshot = getHockeyPvpNetworkSnapshot();
if (!snapshot) return;
exchangeActive = true;
try {
const result = await onlineRepository.exchangeHockeyPvpState(state.hockeyPvp.matchId, snapshot);
if (!stopped && result.opponentSnapshot) {
useGameStore.getState().applyHockeyPvpRemoteSnapshot(
result.opponentSnapshot,
result.hostSnapshot?.puck,
);
}
} catch {
// Last authoritative snapshot remains playable through short network gaps.
} finally {
exchangeActive = false;
}
};
void exchange();
const timer = window.setInterval(() => { void exchange(); }, 120);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [screen]);
useActionBindings(screen === "game", leaveGame);
useEffect(() => {
@@ -97,9 +169,31 @@ export default function App() {
if (state.runMode === "roguelike" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRoguelikeDefeat(state.round);
}
if (state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
if (state.runMode === "rpg-roguelike"
&& (state.phase === "defeat" || state.phase === "victory")
&& state.phase !== previousState.phase) {
recordRoguelikeDefeat(Math.max(1, state.rpgRun?.bossesDefeated ?? 0));
}
if (state.runMode === "rogue-trials" && state.endlessMode && state.phase === "defeat" && previousState.phase !== "defeat") {
recordRogueTrialsEndlessDefeat(state.endlessBossKills);
}
if (state.runMode === "hockey-healing" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordHockeyHealingDefeat(state.hockey.returns, state.time);
}
if (state.runMode === "hockey-healing-pvp"
&& (state.phase === "victory" || state.phase === "defeat")
&& state.phase !== previousState.phase) {
recordHockeyPvpResult(state.phase === "victory");
}
if (state.runMode === "blockbreaker" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordBlockbreakerDefeat(state.blockbreaker.bricksBroken, state.time, state.blockbreaker.score);
}
if (state.runMode === "aether-assault" && state.phase === "defeat" && previousState.phase !== "defeat") {
recordAetherAssaultDefeat(state.aetherAssault.score, state.aetherAssault.wave, state.time);
}
// RPG rewards are generated inside the run reducer. Permanent boss loot
// here would duplicate its chest and break run-only progression.
if (state.runMode === "rpg-roguelike") return;
const bossCount = 1 + state.additionalBosses.length;
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
const primaryInstanceId = state.bossInstanceId;
@@ -108,6 +202,7 @@ export default function App() {
const defeatedBefore = (state.round - 1) * bossCount;
const rewardDifficulty = state.runMode !== "encounter" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(state.boss.id, rewardDifficulty);
if (state.runMode === "hockey-healing-pvp") recordHockeyPvpBossKill();
}
}
for (let index = 0; index < state.additionalBosses.length; index += 1) {
@@ -121,7 +216,7 @@ export default function App() {
recordBossVictory(entry.boss.id, rewardDifficulty);
}
});
}, [clearRecentRewards, recordBossVictory, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
}, [clearRecentRewards, recordAetherAssaultDefeat, recordBlockbreakerDefeat, recordBossVictory, recordHockeyHealingDefeat, recordHockeyPvpBossKill, recordHockeyPvpResult, recordRoguelikeDefeat, recordRogueTrialsEndlessDefeat]);
return (
<main className="app-shell">
@@ -130,8 +225,15 @@ export default function App() {
<p>Offline-first healer roguelike <i /> v{packageJson.version}</p>
</header>
{screen === "game"
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
? <Suspense fallback={<GameLoadingScreen />}><DualDisplayFrame top={<TopScreen onExit={leaveGame} playerAppearance={hunter?.healers[hunter.activeClassId].appearance} />} bottom={<BottomScreen onExit={leaveGame} />} /></Suspense>
: <FrontEnd onLaunch={launchGame} />}
</main>
);
}
export default function App() {
if (import.meta.env.DEV && new URLSearchParams(window.location.search).get("preview") === "healer-models") {
return <Suspense fallback={null}><HealerModelGallery /></Suspense>;
}
return <MainApp />;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+431 -21
View File
@@ -3,9 +3,37 @@ import { useGLTF } from "@react-three/drei";
import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three";
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
import {
BLOCKBREAKER_BIOMES,
blockbreakerBiomeForSeed,
type BlockbreakerArenaBiome,
type BlockbreakerBiomeFixture,
} from "../game/blockbreakerBiomes";
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
import {
HOCKEY_ARENA_CENTER_Z,
HOCKEY_ARENA_LENGTH,
HOCKEY_ARENA_MAX_X,
HOCKEY_ARENA_MAX_Z,
HOCKEY_ARENA_MIN_X,
HOCKEY_ARENA_MIN_Z,
HOCKEY_ARENA_WIDTH,
HOCKEY_GOAL_HALF_WIDTH,
HOCKEY_HEALER_GOAL_Z,
HOCKEY_MIDLINE_Z,
HOCKEY_NPC_GOAL_Z,
} from "../game/hockeyHealing";
import { useGameStore } from "../game/store";
import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
import {
HOCKEY_PVP_ARENA_MAX_X,
HOCKEY_PVP_ARENA_MAX_Z,
HOCKEY_PVP_ARENA_MIN_X,
HOCKEY_PVP_ARENA_MIN_Z,
HOCKEY_PVP_GOAL_HALF_WIDTH,
HOCKEY_PVP_GOAL_Z,
} from "../game/hockeyHealingPvp";
import { RpgRoomPortals } from "./rpgRoguelike/RpgRoomPortals";
const ROOM_CENTER_Z = ARENA_CENTER[1];
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
@@ -170,15 +198,15 @@ function LegacyDungeonAssetInstances({
return <DungeonMeshInstances mesh={mesh} {...props} />;
}
function arenaWalls(room: BossRoomDefinition) {
return ARENA_WALL_SEGMENTS.map((fixture) => ({
function arenaWalls(room: BossRoomDefinition, portalOpenings = false) {
return ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8).map((fixture) => ({
...fixture,
scaleY: room.wallHeight / 4,
}));
}
function LegacyArenaArchitecture({ room }: { room: BossRoomDefinition }) {
const walls = useMemo(() => arenaWalls(room), [room]);
function LegacyArenaArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
return (
<group>
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
@@ -196,9 +224,9 @@ function namedMesh(scene: THREE.Object3D, name: string) {
throw new Error(`Dungeon kit is missing mesh ${name}.`);
}
function DungeonKitArchitecture({ room }: { room: BossRoomDefinition }) {
function DungeonKitArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL);
const walls = useMemo(() => arenaWalls(room), [room]);
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
const meshes = useMemo(() => ({
pillar: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.pillar),
wall: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.wall),
@@ -232,9 +260,13 @@ class DungeonAssetErrorBoundary extends Component<{
}
}
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
function RoomWallFallback({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
const walls = useRef<THREE.Group>(null);
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
const fixtures = useMemo(
() => ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8),
[portalOpenings],
);
useFrame(({ camera }) => {
if (!walls.current) return;
@@ -252,7 +284,7 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
return (
<group ref={walls}>
{ARENA_WALL_SEGMENTS.map((fixture, index) => (
{fixtures.map((fixture, index) => (
<mesh
key={index}
position={[fixture.position[0], room.wallHeight / 2, fixture.position[2]]}
@@ -267,28 +299,28 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
);
}
function LegacyRoomWalls({ room }: { room: BossRoomDefinition }) {
function LegacyRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<Suspense fallback={<RoomWallFallback room={room} />}>
<LegacyArenaArchitecture room={room} />
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
<LegacyArenaArchitecture room={room} portalOpenings={portalOpenings} />
</Suspense>
);
}
function OptimizedRoomWalls({ room }: { room: BossRoomDefinition }) {
function OptimizedRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} />}>
<Suspense fallback={<RoomWallFallback room={room} />}>
<DungeonKitArchitecture room={room} />
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} portalOpenings={portalOpenings} />}>
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
<DungeonKitArchitecture room={room} portalOpenings={portalOpenings} />
</Suspense>
</DungeonAssetErrorBoundary>
);
}
function RoomWalls({ room }: { room: BossRoomDefinition }) {
function RoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return LEGACY_GAME_ASSETS_FORCED
? <LegacyRoomWalls room={room} />
: <OptimizedRoomWalls room={room} />;
? <LegacyRoomWalls room={room} portalOpenings={portalOpenings} />
: <OptimizedRoomWalls room={room} portalOpenings={portalOpenings} />;
}
function RoomMarks({ room }: { room: BossRoomDefinition }) {
@@ -384,7 +416,7 @@ function RoomScenery({ room }: { room: BossRoomDefinition }) {
);
}
function RoomFloor({ room }: { room: BossRoomDefinition }) {
function RoomFloor({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
return (
<group>
<mesh position={[0, -0.45, ROOM_CENTER_Z]} receiveShadow>
@@ -401,14 +433,384 @@ function RoomFloor({ room }: { room: BossRoomDefinition }) {
</mesh>
<RoomMarks room={room} />
<RoomScenery room={room} />
<RoomWalls room={room} />
<RoomWalls room={room} portalOpenings={portalOpenings} />
</group>
);
}
function HockeyGoal({ z, color }: { z: number; color: string }) {
const goalWidth = HOCKEY_GOAL_HALF_WIDTH * 2;
return (
<group position={[0, 0, z]}>
<mesh position={[-HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
<boxGeometry args={[0.18, 2.1, 0.24]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
<boxGeometry args={[0.18, 2.1, 0.24]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[0, 2.03, 0]} castShadow>
<boxGeometry args={[goalWidth, 0.16, 0.22]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.2} metalness={0.45} roughness={0.32} />
</mesh>
<mesh position={[0, 1.05, z === HOCKEY_HEALER_GOAL_Z ? 0.08 : -0.08]}>
<planeGeometry args={[goalWidth, 2]} />
<meshBasicMaterial color={color} transparent opacity={0.14} wireframe depthWrite={false} />
</mesh>
<pointLight color={color} intensity={2.4} distance={7} position={[0, 1.3, 0]} />
</group>
);
}
function HockeyHealingRoom() {
const goalSideWidth = (HOCKEY_ARENA_WIDTH - HOCKEY_GOAL_HALF_WIDTH * 2) * 0.5;
const leftGoalSideX = HOCKEY_ARENA_MIN_X + goalSideWidth * 0.5;
const rightGoalSideX = HOCKEY_ARENA_MAX_X - goalSideWidth * 0.5;
const wallMaterial = <meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />;
return (
<group>
<color attach="background" args={["#020b11"]} />
<fog attach="fog" args={["#061a24", 18, 46]} />
<hemisphereLight args={["#80dfff", "#061015", 1.08]} />
<directionalLight castShadow position={[6, 12, 8]} intensity={1.8} color="#d6f7ff" shadow-mapSize={[512, 512]} />
<pointLight color="#50dfff" intensity={2.2} distance={14} position={[0, 4, HOCKEY_MIDLINE_Z]} />
<mesh position={[0, -0.42, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
<boxGeometry args={[HOCKEY_ARENA_WIDTH + 1.2, 0.8, HOCKEY_ARENA_LENGTH + 1.2]} />
<meshStandardMaterial color="#061117" roughness={0.9} />
</mesh>
<mesh position={[0, -0.01, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
<meshStandardMaterial color="#102731" roughness={0.68} metalness={0.3} />
</mesh>
<mesh position={[0, 0.012, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 0.5, 0.09]} />
<meshBasicMaterial color="#64e7ff" transparent opacity={0.62} />
</mesh>
<mesh position={[0, 0.018, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[2.45, 2.55, 48]} />
<meshBasicMaterial color="#64e7ff" transparent opacity={0.44} />
</mesh>
<mesh position={[HOCKEY_ARENA_MIN_X - 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
{wallMaterial}
</mesh>
<mesh position={[HOCKEY_ARENA_MAX_X + 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
{wallMaterial}
</mesh>
{[HOCKEY_ARENA_MIN_Z - 0.15, HOCKEY_ARENA_MAX_Z + 0.15].flatMap((z) => [leftGoalSideX, rightGoalSideX].map((x) => (
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
<meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />
</mesh>
)))}
<HockeyGoal z={HOCKEY_NPC_GOAL_Z} color="#ff6e5c" />
<HockeyGoal z={HOCKEY_HEALER_GOAL_Z} color="#67e8ff" />
</group>
);
}
const BLOCKBREAKER_BIOME_FIXTURES = [
[-12.75, -12.5, 0.94],
[-12.75, -1, 1.08],
[-12.75, 10.5, 0.9],
[12.75, -12.5, 1.02],
[12.75, -1, 0.88],
[12.75, 10.5, 1.12],
] as const;
function BlockbreakerFixtureGeometry({ fixture }: { fixture: BlockbreakerBiomeFixture }) {
if (fixture === "crystal") return <octahedronGeometry args={[1.25, 0]} />;
if (fixture === "forge") return <cylinderGeometry args={[0.72, 1.18, 2.8, 6]} />;
if (fixture === "spire") return <coneGeometry args={[1.05, 3.7, 5]} />;
if (fixture === "monolith") return <boxGeometry args={[1.25, 3.5, 1.25]} />;
return <torusGeometry args={[1.08, 0.3, 8, 18]} />;
}
function BlockbreakerBiomeFixtures({ biome }: { biome: BlockbreakerArenaBiome }) {
const mesh = useRef<THREE.InstancedMesh>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
useLayoutEffect(() => {
if (!mesh.current) return;
BLOCKBREAKER_BIOME_FIXTURES.forEach(([x, z, scale], index) => {
transform.position.set(x, biome.fixture === "reactor" ? 1.45 : 1.75, z);
transform.rotation.set(
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? -0.08 : 0.08,
index * 0.73,
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? 0.06 : -0.06,
);
transform.scale.setScalar(scale);
transform.updateMatrix();
mesh.current!.setMatrixAt(index, transform.matrix);
});
mesh.current.instanceMatrix.needsUpdate = true;
mesh.current.computeBoundingSphere();
}, [biome.fixture, transform]);
return (
<instancedMesh ref={mesh} args={[undefined, undefined, BLOCKBREAKER_BIOME_FIXTURES.length]} castShadow receiveShadow>
<BlockbreakerFixtureGeometry fixture={biome.fixture} />
<meshStandardMaterial
color={biome.fixtureColor}
emissive={biome.fixtureEmissive}
emissiveIntensity={0.72}
roughness={biome.fixture === "monolith" ? 0.26 : 0.48}
metalness={biome.fixture === "forge" || biome.fixture === "reactor" ? 0.42 : 0.12}
/>
</instancedMesh>
);
}
function BrightArcadeRoom({
variant,
biome = BLOCKBREAKER_BIOMES[0],
}: {
variant: "blockbreaker" | "aether-assault";
biome?: BlockbreakerArenaBiome;
}) {
const roomWidth = HOCKEY_ARENA_WIDTH + 10;
const roomLength = HOCKEY_ARENA_LENGTH + 12;
const roomMinX = HOCKEY_ARENA_MIN_X - 5;
const roomMaxX = HOCKEY_ARENA_MAX_X + 5;
const roomMinZ = HOCKEY_ARENA_MIN_Z - 6;
const roomMaxZ = HOCKEY_ARENA_MAX_Z + 6;
const wallHeight = 4.8;
const wallMaterial = (
<meshStandardMaterial
color={biome.wall}
emissive={biome.wallEmissive}
emissiveIntensity={biome.wallEmissiveIntensity}
roughness={0.62}
metalness={0.12}
/>
);
return (
<group key={variant === "blockbreaker" ? biome.id : variant}>
<color attach="background" args={[biome.background]} />
<fog attach="fog" args={[biome.fog, 34, 72]} />
<ambientLight color={biome.ambient} intensity={biome.ambientIntensity} />
<hemisphereLight args={[biome.sky, biome.ground, biome.hemisphereIntensity]} />
<directionalLight
castShadow
position={[8, 15, 10]}
intensity={biome.keyLightIntensity}
color={biome.keyLight}
shadow-mapSize={[512, 512]}
/>
<pointLight color={biome.fillLightA} intensity={biome.fillLightIntensityA} distance={30} position={[-8, 7, 7]} />
<pointLight color={biome.fillLightB} intensity={biome.fillLightIntensityB} distance={30} position={[8, 7, -9]} />
<mesh position={[0, -0.48, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
<boxGeometry args={[roomWidth + 1.2, 0.9, roomLength + 1.2]} />
<meshStandardMaterial color={biome.foundation} roughness={0.92} metalness={0.04} />
</mesh>
<mesh position={[0, -0.015, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[roomWidth, roomLength]} />
<meshStandardMaterial color={biome.floor} roughness={biome.floorRoughness} metalness={biome.floorMetalness} />
</mesh>
<mesh position={[0, 0.006, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
<meshStandardMaterial color={biome.playfield} transparent opacity={0.72} roughness={0.52} metalness={0.05} />
</mesh>
{[HOCKEY_ARENA_MIN_X, HOCKEY_ARENA_MAX_X].map((x) => (
<mesh key={`lane-x-${x}`} position={[x, 0.025, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.1, HOCKEY_ARENA_LENGTH]} />
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
</mesh>
))}
{[HOCKEY_ARENA_MIN_Z, HOCKEY_ARENA_MAX_Z].map((z) => (
<mesh key={`lane-z-${z}`} position={[0, 0.025, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.1]} />
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
</mesh>
))}
{variant === "blockbreaker" ? (
<mesh position={[0, 0.028, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.12]} />
<meshBasicMaterial color={biome.midline} transparent opacity={0.86} toneMapped={false} />
</mesh>
) : (
<group>
{[-6.7, -3.35, 0, 3.35, 6.7].map((x) => (
<mesh key={`aether-lane-${x}`} position={[x, 0.029, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.055, HOCKEY_ARENA_LENGTH - 0.8]} />
<meshBasicMaterial color="#c6fbff" transparent opacity={x === 0 ? 0.58 : 0.3} toneMapped={false} />
</mesh>
))}
{[-10.7, -8.45, -6.2, -3.95].map((z) => (
<mesh key={`aether-rank-${z}`} position={[0, 0.03, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 1, 0.045]} />
<meshBasicMaterial color="#fff2bf" transparent opacity={0.36} toneMapped={false} />
</mesh>
))}
</group>
)}
{[roomMinX - 0.18, roomMaxX + 0.18].map((x) => (
<mesh key={`room-x-${x}`} position={[x, wallHeight * 0.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
<boxGeometry args={[0.36, wallHeight, roomLength + 0.7]} />
{wallMaterial}
</mesh>
))}
{[roomMinZ - 0.18, roomMaxZ + 0.18].map((z) => (
<mesh key={`room-z-${z}`} position={[0, wallHeight * 0.5, z]} castShadow receiveShadow>
<boxGeometry args={[roomWidth + 0.7, wallHeight, 0.36]} />
{wallMaterial}
</mesh>
))}
{[roomMinX - 0.36, roomMaxX + 0.36].map((x) => (
<mesh key={`light-x-${x}`} position={[x, 3.7, HOCKEY_ARENA_CENTER_Z]}>
<boxGeometry args={[0.12, 0.18, roomLength - 1]} />
<meshBasicMaterial color={biome.railA} toneMapped={false} />
</mesh>
))}
{[roomMinZ - 0.36, roomMaxZ + 0.36].map((z) => (
<mesh key={`light-z-${z}`} position={[0, 3.7, z]}>
<boxGeometry args={[roomWidth - 1, 0.18, 0.12]} />
<meshBasicMaterial color={biome.railB} toneMapped={false} />
</mesh>
))}
{variant === "blockbreaker" && <BlockbreakerBiomeFixtures biome={biome} />}
</group>
);
}
function HealingHockeyPvpRoom() {
const width = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_ARENA_MIN_X;
const length = HOCKEY_PVP_ARENA_MAX_Z - HOCKEY_PVP_ARENA_MIN_Z;
const goalSideWidth = (width - HOCKEY_PVP_GOAL_HALF_WIDTH * 2) * 0.5;
const sideCenters = [
HOCKEY_PVP_ARENA_MIN_X + goalSideWidth * 0.5,
HOCKEY_PVP_ARENA_MAX_X - goalSideWidth * 0.5,
];
return (
<group>
<color attach="background" args={["#040912"]} />
<fog attach="fog" args={["#071522", 28, 68]} />
<hemisphereLight args={["#8deaff", "#130812", 1.1]} />
<directionalLight castShadow position={[7, 14, 10]} intensity={1.9} color="#d8fbff" shadow-mapSize={[512, 512]} />
<pointLight color="#62e7ff" intensity={2.5} distance={18} position={[0, 4, 13]} />
<pointLight color="#ff6f83" intensity={2.5} distance={18} position={[0, 4, -13]} />
<mesh position={[0, -0.42, 0]} receiveShadow>
<boxGeometry args={[width + 1.2, 0.8, length + 1.2]} />
<meshStandardMaterial color="#070d17" roughness={0.9} />
</mesh>
<mesh position={[0, -0.01, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[width, length]} />
<meshStandardMaterial color="#112632" roughness={0.7} metalness={0.32} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[width - 0.5, 0.11]} />
<meshBasicMaterial color="#f4d978" transparent opacity={0.7} />
</mesh>
{[-11, 11].map((z) => <mesh key={z} position={[0, 0.014, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[width - 0.8, 0.07]} />
<meshBasicMaterial color={z > 0 ? "#64e7ff" : "#ff7688"} transparent opacity={0.38} />
</mesh>)}
{[HOCKEY_PVP_ARENA_MIN_X - 0.15, HOCKEY_PVP_ARENA_MAX_X + 0.15].map((x) => (
<mesh key={x} position={[x, 1.5, 0]} castShadow receiveShadow>
<boxGeometry args={[0.3, 3, length + 0.6]} />
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
</mesh>
))}
{[-HOCKEY_PVP_ARENA_MAX_Z - 0.15, HOCKEY_PVP_ARENA_MAX_Z + 0.15].flatMap((z) => sideCenters.map((x) => (
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
</mesh>
)))}
<HockeyGoal z={-HOCKEY_PVP_GOAL_Z} color="#ff647b" />
<HockeyGoal z={HOCKEY_PVP_GOAL_Z} color="#62e7ff" />
<HockeyPvpScoreboards />
</group>
);
}
function HockeyPvpScoreboards() {
const localGoals = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded);
const opponentGoals = useGameStore((state) => state.hockeyPvp.localGoalsConceded);
const texture = useMemo(() => {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 192;
const next = new THREE.CanvasTexture(canvas);
next.colorSpace = THREE.SRGBColorSpace;
next.minFilter = THREE.LinearMipmapLinearFilter;
next.magFilter = THREE.LinearFilter;
return next;
}, []);
useEffect(() => {
const canvas = texture.image as HTMLCanvasElement;
const context = canvas.getContext("2d");
if (!context) return;
const gradient = context.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, "#061d28");
gradient.addColorStop(0.5, "#05080d");
gradient.addColorStop(1, "#2b0a17");
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
context.strokeStyle = "#89efff";
context.lineWidth = 5;
context.strokeRect(5, 5, canvas.width - 10, canvas.height - 10);
context.fillStyle = "#a8c4c9";
context.font = "700 20px Inter, sans-serif";
context.textAlign = "center";
context.fillText("HEALING HOCKEY", canvas.width / 2, 31);
context.fillStyle = "#74ecff";
context.font = "700 18px Inter, sans-serif";
context.fillText("YOU", 132, 58);
context.fillStyle = "#ff819f";
context.fillText("RIVAL", 380, 58);
context.font = "700 94px Impact, Inter, sans-serif";
context.fillStyle = "#eaffff";
context.fillText(String(Math.min(99, localGoals)).padStart(2, "0"), 132, 151);
context.fillStyle = "#ffedf4";
context.fillText(String(Math.min(99, opponentGoals)).padStart(2, "0"), 380, 151);
context.fillStyle = "#f3d87c";
context.font = "700 56px Inter, sans-serif";
context.fillText("", 256, 137);
texture.needsUpdate = true;
}, [localGoals, opponentGoals, texture]);
useEffect(() => () => texture.dispose(), [texture]);
return <>{([-1, 1] as const).map((side) => (
<group
key={side}
position={[side * 9.78, 4.05, 5.5]}
rotation={[0, side < 0 ? Math.PI / 2 : -Math.PI / 2, 0]}
>
<mesh castShadow>
<boxGeometry args={[7.6, 3.25, 0.24]} />
<meshStandardMaterial color="#101923" roughness={0.32} metalness={0.72} />
</mesh>
<mesh position={[0, 0, 0.126]}>
<planeGeometry args={[7.28, 2.93]} />
<meshBasicMaterial map={texture} toneMapped={false} />
</mesh>
</group>
))}</>;
}
/** Main-display room projection. Gameplay stays in the shared arena domain. */
export function BossRoom() {
const bossId = useGameStore((state) => state.boss.id);
const blockbreakerSeed = useGameStore((state) => state.blockbreaker.seed);
const hockeyMode = useGameStore((state) => state.activityMode === "hockey-healing");
const blockbreakerMode = useGameStore((state) => state.activityMode === "blockbreaker");
const aetherAssaultMode = useGameStore((state) => state.activityMode === "aether-assault");
const hockeyPvpMode = useGameStore((state) => state.activityMode === "hockey-healing-pvp");
const rpgPhase = useGameStore((state) => state.rpgRun?.phase ?? null);
const rpgBossRoom = useGameStore((state) => state.runMode === "rpg-roguelike" && state.activityMode === "boss");
if (hockeyPvpMode) return <HealingHockeyPvpRoom />;
if (aetherAssaultMode) return <BrightArcadeRoom variant="aether-assault" />;
if (blockbreakerMode) return <BrightArcadeRoom variant="blockbreaker" biome={blockbreakerBiomeForSeed(blockbreakerSeed)} />;
if (hockeyMode) return <HockeyHealingRoom />;
const room = bossRoomFor(bossId);
return (
<group key={room.id}>
@@ -418,7 +820,15 @@ export function BossRoom() {
<directionalLight castShadow position={[5, 10, 8]} intensity={2.1} color={room.accentSecondary} shadow-mapSize={[512, 512]} />
<pointLight color={room.accent} intensity={2.35} distance={8} position={[-6, 2.8, ROOM_CENTER_Z]} />
<pointLight color={room.accentSecondary} intensity={1.75} distance={7} position={[6, 2.4, ROOM_CENTER_Z - 1]} />
<RoomFloor room={room} />
<RoomFloor room={room} portalOpenings={rpgBossRoom} />
{rpgBossRoom && rpgPhase && (
<RpgRoomPortals
entryOpen
exitOpen={rpgPhase === "boss-cleared" || rpgPhase === "reward"}
accent={room.accent}
wallColor={room.wallColor}
/>
)}
</group>
);
}
+393 -42
View File
@@ -1,12 +1,39 @@
import { ABILITY_ORDER } from "../game/data";
import { HEALER_CLASSES } from "../game/healers";
import { HEALER_CLASSES, resolveSlottedAbility } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { BARRIER_RADIUS, GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { runAbilityCastTime, runAbilityCooldown, runAbilityManaCost } from "../game/roguelike";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import {
HOCKEY_ARENA_CENTER_Z,
HOCKEY_GOAL_HALF_WIDTH,
HOCKEY_HEALER_GOAL_Z,
HOCKEY_NPC_PADDLE_HALF_WIDTH,
HOCKEY_NPC_PADDLE_Z,
HOCKEY_NPC_GOAL_Z,
} from "../game/hockeyHealing";
import {
HOCKEY_PVP_GOAL_DAMAGE,
HOCKEY_PVP_GOAL_HALF_WIDTH,
HOCKEY_PVP_GOAL_Z,
HOCKEY_PVP_SIDE_OFFSET_Z,
} from "../game/hockeyHealingPvp";
import { bottomTabsFor } from "../game/bottomTabs";
import {
BLOCKBREAKER_BREACH_DAMAGE,
BLOCKBREAKER_BRICK_COLORS,
BLOCKBREAKER_DANGER_Z,
blockbreakerColumnX,
blockbreakerRowZ,
blockbreakerTimeMultiplier,
} from "../game/blockbreaker";
import { aetherShipColor } from "./aetherAssaultVisuals";
import { ABILITY_CONTROLLER_BINDINGS } from "../game/controllerBindings";
import { RpgRunTacticalPanel } from "./rpgRoguelike/RpgRunTacticalPanel";
import { isBeaconOfLightTarget } from "../game/healerMechanics";
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
@@ -30,23 +57,27 @@ function PartyFrame({ member }: { member: PartyMember }) {
const selected = useGameStore((state) => state.selectedMemberId === member.id);
const selectMember = useGameStore((state) => state.selectMember);
const time = useGameStore((state) => state.time);
const renewRemaining = Math.max(0, member.renewExpiresAt - time);
const healerMechanic = useGameStore((state) => state.healerMechanic);
const beaconed = isBeaconOfLightTarget(member.id, healerMechanic, time);
const activeHealingEffects = member.healingEffects.filter((effect) => effect.expiresAt > time).slice(0, 3);
const knockedRemaining = Math.max(0, member.knockedUntil - time);
const barrier = useGameStore((state) => state.barrier);
const tankAura = useGameStore((state) => state.partyCombat.tankAura);
const tankPosition = useGameStore((state) => state.partyPositions.brann);
const tankPosition = useGameStore((state) => state.partyPositions[state.partyCombat.tankAura.sourceId]);
const combatant = useGameStore((state) => member.id === "aelia" ? undefined : state.partyCombat.combatants[member.id]);
const position = useGameStore((state) => state.partyPositions[member.id]);
const protectedByBarrier = barrierProtects(position, barrier, time);
const linkedBySpirit = barrier.kind === "spirit-link" && healerFieldContains(position, barrier, time);
const protectedByTank = tankAuraProtects(position, tankPosition, tankAura, time);
const currentAction = combatant?.visualAction && combatant.visualAction.endsAt > time
? PARTY_ABILITY_NAMES[combatant.visualAction.abilityId]
: null;
return (
<button
className={`party-frame ${selected ? "is-selected" : ""} ${member.hp <= 0 ? "is-down" : ""}`}
className={`party-frame ${selected ? "is-selected" : ""} ${beaconed ? "is-beacon" : ""} ${member.hp <= 0 ? "is-down" : ""}`}
onClick={() => selectMember(member.id)}
aria-pressed={selected}
aria-label={`${member.name}, ${Math.ceil(member.hp)} health${beaconed ? ", Beacon of Light" : ""}`}
>
<span className="party-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
<span className="party-data">
@@ -55,10 +86,16 @@ function PartyFrame({ member }: { member: PartyMember }) {
<small>{currentAction ?? member.className}</small>
</span>
<span className="effect-stack">
{beaconed && <i className="effect beacon-effect" title={`Beacon of Light: ${Math.max(0, healerMechanic.beaconExpiresAt - time).toFixed(1)} seconds`}></i>}
{member.absorb > 0 && <i className="effect shield-effect" title={`${Math.ceil(member.absorb)} absorption`}></i>}
{renewRemaining > 0 && <i className="effect renew-effect" title={`Renew: ${renewRemaining.toFixed(1)} seconds`}>{Math.ceil(renewRemaining)}</i>}
{activeHealingEffects.map((effect) => {
const label = effect.id === "renew" ? "R" : effect.id === "regrowth" ? "G" : effect.id === "rejuvenation" ? "J" : effect.id === "lifebloom" ? `L${effect.stacks}` : effect.id === "wild-growth" ? "W" : "T";
return <i key={effect.id} className="effect renew-effect" title={`${effect.id}: ${Math.max(0, effect.expiresAt - time).toFixed(1)} seconds`}>{label}</i>;
})}
{member.reactiveHeal && member.reactiveHeal.expiresAt > time && <i className="effect earth-shield-effect" title={`Earth Shield: ${member.reactiveHeal.charges} charges`}>{member.reactiveHeal.charges}</i>}
{member.debuffs.length > 0 && <i className="effect debuff-effect" title={`${member.debuffs[0].name} — Purify`}>!</i>}
{protectedByBarrier && <i className="effect barrier-effect" title="Barrier: 30% reduced damage">B</i>}
{linkedBySpirit && <i className="effect spirit-link-effect" title="Spirit Link: health equalized each second">S</i>}
{protectedByTank && <i className="effect tank-aura-effect" title="Bulwark March: 30% reduced damage">T</i>}
{knockedRemaining > 0 && <i className="effect knock-effect" title={`Knocked down: ${knockedRemaining.toFixed(1)} seconds`}>KD</i>}
</span>
@@ -78,7 +115,7 @@ function PartyList() {
function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number] }) {
const healerClassId = useGameStore((state) => state.healerClassId);
const ability = HEALER_CLASSES[healerClassId].abilities[abilityId];
const ability = useGameStore((state) => resolveSlottedAbility(state.abilityLoadout, abilityId));
const time = useGameStore((state) => state.time);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
@@ -89,15 +126,21 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
const runModifiers = useGameStore((state) => state.runModifiers);
const healerMechanic = useGameStore((state) => state.healerMechanic);
if (!ability) {
return <button className={`ability ability-${abilityId} is-empty`} disabled aria-label={`Empty ${abilityId}`}><span className="ability-key">{Number(abilityId.slice(-1))}</span><span className="ability-icon"></span><span className="ability-copy"><strong>Empty</strong><small>No spell drafted</small></span><span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span></button>;
}
const remaining = abilityRemaining(abilityId, time, cooldowns);
const manaCost = runAbilityManaCost(abilityId, ability.mana, runModifiers);
const castTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const baseCastTime = ability.castTime ? runAbilityCastTime(abilityId, ability.castTime, runModifiers) : 0;
const castTime = ability.id === "shaman-healing-wave" && healerMechanic.resource > 0 ? baseCastTime * 0.5 : baseCastTime;
const cooldownDuration = runAbilityCooldown(abilityId, ability.cooldown, runModifiers);
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
const noDispel = ability.pulseKind === "cleanse" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
const resourceName = HEALER_CLASSES[healerClassId].resourceName.toLowerCase();
const resourceCopy = `${manaCost ? `${manaCost} ${resourceName}` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
<button
@@ -108,10 +151,10 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
title={ability.description}
aria-label={`${ability.name}. ${ability.description}`}
>
<span className="ability-key">{ability.key}</span>
<span className="ability-key">{Number(abilityId.slice(-1))}</span>
<span className="ability-icon">{ability.icon}</span>
<span className="ability-copy"><strong>{ability.shortName}</strong><small>{resourceCopy}</small></span>
<span className="ability-pad">{ability.gamepad}</span>
<span className="ability-pad">{ABILITY_CONTROLLER_BINDINGS[abilityId].glyph}</span>
{remaining > 0 && (
<span className="cooldown-mask" style={{ "--cooldown-progress": Math.min(1, remaining / cooldownDuration) } as React.CSSProperties}>
<b>{remaining < 1 ? remaining.toFixed(1) : Math.ceil(remaining)}</b>
@@ -131,17 +174,37 @@ function AbilityTray() {
const healer = HEALER_CLASSES[healerClassId];
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const healerMechanic = useGameStore((state) => state.healerMechanic);
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const time = useGameStore((state) => state.time);
const boss = useGameStore((state) => state.boss);
const bossMotion = useGameStore((state) => state.bossMotion);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const mechanic = upcomingEncounterMechanic({ boss, bossMotion, additionalBosses, time });
const activityMode = useGameStore((state) => state.activityMode);
const hockeyReturns = useGameStore((state) => state.hockey.returns);
const bossKills = useGameStore((state) => state.endlessBossKills);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyMode = activityMode === "hockey-healing";
const pvpMode = activityMode === "hockey-healing-pvp";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherMode = activityMode === "aether-assault";
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
const nextRowSeconds = Math.max(0, blockbreaker.nextRowAt - time);
return (
<div className="ability-column">
<div className="ability-meta">
<div className={`ability-meta ${hockeyMode || pvpMode || blockbreakerMode || aetherMode ? "is-hockey" : ""} ${pvpMode ? "is-pvp" : ""}`}>
<div className="target-chip"><span>Target</span><strong>{selected.name}</strong></div>
<div className="mana-wrap"><span>{healer.resourceName}</span><b>{Math.ceil(mana)}</b><i><em style={{ width: `${(mana / maxMana) * 100}%` }} /></i></div>
<div className="resource-stack">
<div className="mana-wrap"><span>{healer.resourceName}</span><b>{Math.ceil(mana)}</b><i><em style={{ width: `${(mana / maxMana) * 100}%` }} /></i></div>
{healer.secondaryResourceName && <div className="class-resource"><span>{healer.secondaryResourceName}</span><b>{healerMechanic.resource} / {healerMechanic.maxResource}</b></div>}
</div>
{hockeyMode && <div className="hockey-run-meta"><span>Returns</span><strong>{hockeyReturns}</strong><small>{duration} · {bossKills} KOs</small></div>}
{pvpMode && <div className="hockey-run-meta pvp-run-meta"><span>Goals · Bosses</span><strong>{hockeyPvp.opponentGoalsConceded}{hockeyPvp.localGoalsConceded}</strong><small>{bossKills}{hockeyPvp.opponentBossKills} · {hockeyPvp.opponentName}</small></div>}
{blockbreakerMode && <div className="hockey-run-meta blockbreaker-run-meta"><span>Score · Bricks</span><strong>{blockbreaker.score} · {blockbreaker.bricksBroken}</strong><small>{blockbreakerTimeMultiplier(time).toFixed(1)}× · next row {nextRowSeconds.toFixed(1)}s</small></div>}
{aetherMode && <div className="hockey-run-meta aether-run-meta"><span>Score · Wave</span><strong>{aetherAssault.score} · {aetherAssault.wave}</strong><small>{aetherAssault.multiplier.toFixed(2)}× · {aetherAssault.ships.length} ships</small></div>}
</div>
<div className="ability-grid">
{ABILITY_ORDER.map((abilityId) => <AbilityButton key={abilityId} abilityId={abilityId} />)}
@@ -165,14 +228,20 @@ function BriefingPanel() {
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
const activityMode = useGameStore((state) => state.activityMode);
const hockeyMode = activityMode === "hockey-healing";
const pvpMode = activityMode === "hockey-healing-pvp";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherMode = activityMode === "aether-assault";
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
return (
<div className="briefing-panel">
<div className="briefing-class">
<div className="class-crest" style={{ color: healer.color }}>{healer.icon}</div>
<span>Chosen discipline</span>
<h2>{healer.specialization}</h2>
<p>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</p>
<button className="start-button" onClick={startEncounter}><span>Face {bossNames}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
<p>{hockeyMode ? "Defend the wide goal while healing through two bosses. Left-stick direction sets every puck return; the moving enemy paddle tracks it and strikes it back. Each fallen boss awards loot and rolls its pet chance before replacement." : pvpMode ? `Face ${opponentName}. Both parties use normalized base gear and fight the same boss order. Every boss kill adds 5% global healing Dampening. Aim returns with left stick. A goal deals ${HOCKEY_PVP_GOAL_DAMAGE} damage to every member of the conceding party. Boss kills still award loot and pet chances.` : blockbreakerMode ? `Aim the puck into advancing five-column color rows while healing through two bosses. Orthogonal matching clusters break together. Rows start every 10 seconds and accelerate. Misses safely re-serve; each breach deals ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member.` : aetherMode ? "Move freely through the full runway while your arcane focus fires automatically. Heal with your normal kit. Dodge enemy volleys and diving ships; ship hits only threaten the healer. Bosses and rewards continue independently." : <>{healer.description} {definitions.map((boss) => boss.briefing).join(" ")}</>}</p>
<button className="start-button" onClick={startEncounter}><span>{hockeyMode ? "Begin Hockey Healing" : pvpMode ? `Face ${opponentName}` : blockbreakerMode ? "Begin Blockbreaker" : aetherMode ? "Begin Aether Assault" : `Face ${bossNames}`}</span><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ENTER</small></button>
</div>
<div className="briefing-kit">
<div className="section-label"><span>Prepared skills</span><small>6 equipped</small></div>
@@ -192,8 +261,10 @@ function BriefingPanel() {
}
function EndPanel({ onExit }: { onExit?: () => void }) {
const hunter = useActiveHunter();
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
@@ -206,35 +277,44 @@ function EndPanel({ onExit }: { onExit?: () => void }) {
const startEncounter = useGameStore((state) => state.startEncounter);
const totalHp = party.reduce((sum, member) => sum + member.hp, 0);
const totalMax = party.reduce((sum, member) => sum + member.maxHp, 0);
const hockey = useGameStore((state) => state.hockey);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
const hockeyDefeat = phase === "defeat" && activityMode === "hockey-healing";
const blockbreakerDefeat = phase === "defeat" && activityMode === "blockbreaker";
const aetherDefeat = phase === "defeat" && activityMode === "aether-assault";
const pvpMatch = activityMode === "hockey-healing-pvp";
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyDefeat && !blockbreakerDefeat && !aetherDefeat;
const endlessHighScore = hunter?.stats.highestRogueTrialsEndlessKills ?? 0;
const endlessHighScoreLabel = `${endlessHighScore} ${endlessHighScore === 1 ? "boss" : "bosses"}`;
return (
<div className={`end-panel end-${phase}`}>
<span className="end-mark">{phase === "victory" ? "✦" : "×"}</span>
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{showEndlessChoice ? "The trial can continue" : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<small>{showEndlessChoice ? "ROGUE TRIALS CLEARED" : hockeyDefeat ? "HOCKEY HEALING COMPLETE" : blockbreakerDefeat ? "BLOCKBREAKER RUN COMPLETE" : aetherDefeat ? "AETHER ASSAULT COMPLETE" : pvpMatch ? phase === "victory" ? "PVP MATCH WON" : "PVP MATCH LOST" : endlessDefeat ? "ENDLESS RUN COMPLETE" : phase === "victory" ? "TRIAL COMPLETE" : "FORMATION LOST"}</small>
<h2>{showEndlessChoice ? "The trial can continue" : hockeyDefeat ? `${hockey.returns} pucks returned` : blockbreakerDefeat ? `${blockbreaker.score} points scored` : aetherDefeat ? `${aetherAssault.score} points scored` : pvpMatch ? phase === "victory" ? `${hockeyPvp.opponentName} fell first` : `${hockeyPvp.opponentName} wins` : endlessDefeat ? `${endlessBossKills} bosses defeated` : phase === "victory" ? "Five souls endure" : "The vault claims its due"}</h2>
<div className="result-stats">
<span><small>Duration</small><strong>{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}</strong></span>
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
<span><small>{endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{endlessDefeat ? endlessBossKills : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
<span><small>{hockeyDefeat ? "Puck returns" : blockbreakerDefeat ? "Bricks broken" : aetherDefeat ? "Wave reached" : pvpMatch ? "Goals" : "Party vitality"}</small><strong>{hockeyDefeat ? hockey.returns : blockbreakerDefeat ? blockbreaker.bricksBroken : aetherDefeat ? aetherAssault.wave : pvpMatch ? `${hockeyPvp.opponentGoalsConceded}${hockeyPvp.localGoalsConceded}` : `${Math.round((totalHp / totalMax) * 100)}%`}</strong></span>
<span><small>{hockeyDefeat || blockbreakerDefeat || aetherDefeat ? "Boss kills" : pvpMatch ? "Boss kills" : endlessDefeat ? "Endless kills" : "Boss"}</small><strong>{hockeyDefeat || blockbreakerDefeat || aetherDefeat || endlessDefeat ? endlessBossKills : pvpMatch ? `${endlessBossKills}${hockeyPvp.opponentBossKills}` : phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div>
{phase === "victory" && <RewardSummary />}
{(phase === "victory" || hockeyDefeat || blockbreakerDefeat || aetherDefeat) && <RewardSummary />}
{showEndlessChoice ? <div className="end-actions endless-choice-actions">
<button
className={endlessChoiceSelection === "continue" ? "is-controller-focused" : ""}
onFocus={() => setEndlessChoiceSelection("continue")}
className={endlessChoiceSelection === "continue" ? "is-controller-selected" : ""}
onPointerEnter={() => setEndlessChoiceSelection("continue")}
onClick={startRogueTrialsEndless}
>Continue Endless</button>
aria-label={`Endless Mode. Current high score: ${endlessHighScoreLabel}.`}
><span>Endless Mode</span><small>High score · {endlessHighScoreLabel}</small></button>
<button
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-focused" : ""}`}
onFocus={() => setEndlessChoiceSelection("quit")}
className={`secondary ${endlessChoiceSelection === "quit" ? "is-controller-selected" : ""}`}
onPointerEnter={() => setEndlessChoiceSelection("quit")}
onClick={onExit}
>Quit to Main Menu</button>
</div> : <div className="end-actions">
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
<button className="secondary" onClick={endlessDefeat ? onExit : restart}>{endlessDefeat ? "Return to main menu" : "Return to briefing"}</button>
<button onClick={() => { if (pvpMatch) onExit?.(); else { restart(); startEncounter(); } }}>{pvpMatch ? "Find new opponent" : "Run again"}</button>
<button className="secondary" onClick={endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? onExit : restart}>{endlessDefeat || hockeyDefeat || blockbreakerDefeat || aetherDefeat || pvpMatch ? "Return to main menu" : "Return to briefing"}</button>
</div>}
</div>
);
@@ -271,10 +351,182 @@ function MapPanel() {
const time = useGameStore((state) => state.time);
const phase = useGameStore((state) => state.phase);
const bossId = useGameStore((state) => state.bossId);
const activityMode = useGameStore((state) => state.activityMode);
const hockey = useGameStore((state) => state.hockey);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvpOpponent = useGameStore((state) => state.hockeyPvpOpponent);
const bossDefinition = BOSS_DEFINITIONS[bossId];
const playerX = 120 + playerPosition[0] * 7;
const playerY = 143 + playerPosition[1] * 5.3;
const bossMotions = [bossMotion, ...additionalBosses.map((entry) => entry.motion)];
if (activityMode === "aether-assault") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
return (
<div className="map-panel hockey-map-panel aether-map-panel">
<div className="map-copy">
<span>Aether Assault</span>
<h2>Arcane Formation Runway</h2>
<p>Focus fire stays automatic. Move anywhere in the rink, heal freely, and evade red volleys plus amber dive warnings.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Bosses <i className="legend-ship" /> Ships <i className="legend-shot" /> Shots</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Aether Assault tactical map">
<defs><linearGradient id="aether-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#18394a" /><stop offset="0.55" stopColor="#d9f3ef" /><stop offset="1" stopColor="#dff8f4" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#aether-room)" />
<path className="map-ring aether-map-lanes" d="M63 20 V260 M91 20 V260 M120 20 V260 M149 20 V260 M177 20 V260" />
{aetherAssault.ships.map((ship, index) => {
const color = aetherShipColor(aetherAssault.seed, aetherAssault.wave, index, ship.kind);
return <g key={ship.id}>
{ship.phase === "diving" && <circle className="aether-map-warning" cx={mapX(ship.position[0])} cy={mapY(ship.position[1])} r="8" />}
<path className={`aether-map-ship is-${ship.kind}`} style={{ fill: color, filter: `drop-shadow(0 0 4px ${color})` }} d={`M${mapX(ship.position[0])} ${mapY(ship.position[1]) - 5} l6 9 h-12 z`} />
</g>;
})}
{aetherAssault.playerShots.map((shot) => <circle key={`player-shot-${shot.id}`} className="aether-map-player-shot" cx={mapX(shot.position[0])} cy={mapY(shot.position[1])} r="2" />)}
{aetherAssault.enemyShots.map((shot) => <circle key={`enemy-shot-${shot.id}`} className="aether-map-enemy-shot" cx={mapX(shot.position[0])} cy={mapY(shot.position[1])} r="2.5" />)}
{bossMotions.map((motion, index) => <circle key={`${motion.bossId}-${index}`} className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="7" />)}
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="11" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `WAVE ${aetherAssault.wave} · ${aetherAssault.score} SCORE` : "FORMATION PREVIEW"}</span>
</div>
</div>
);
}
if (activityMode === "blockbreaker") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
const colors: Record<(typeof BLOCKBREAKER_BRICK_COLORS)[number], string> = {
cyan: "#36d9ef",
amber: "#f1b74f",
magenta: "#e85aa9",
lime: "#93db54",
};
return (
<div className="map-panel hockey-map-panel blockbreaker-map-panel">
<div className="map-copy">
<span>Blockbreaker</span>
<h2>Advancing Color Wall</h2>
<p>Match orthogonal colors. Crossing bricks disappear and deal {BLOCKBREAKER_BREACH_DAMAGE} partywide damage.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Bosses <i className="legend-brick" /> Bricks <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Blockbreaker tactical map">
<defs><linearGradient id="blockbreaker-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#251717" /><stop offset="0.5" stopColor="#10262d" /><stop offset="1" stopColor="#092d39" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#blockbreaker-room)" />
<path className="map-ring" d="M35 140 H205" />
<path className="blockbreaker-map-danger" d={`M35 ${mapY(BLOCKBREAKER_DANGER_Z)} H205`} />
{blockbreaker.bricks.map((brick) => (
<rect
className="blockbreaker-map-brick"
key={brick.id}
x={mapX(blockbreakerColumnX(brick.column)) - 12.5}
y={mapY(blockbreakerRowZ(brick.row)) - 5}
width="25"
height="10"
rx="2"
fill={colors[brick.color]}
/>
))}
{bossMotions.map((motion, index) => <circle key={`${motion.bossId}-${index}`} className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="7" />)}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="12" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />)}
<circle className="hockey-map-puck" cx={mapX(blockbreaker.puckPosition[0])} cy={mapY(blockbreaker.puckPosition[1])} r="5" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `${blockbreaker.bricks.length} BRICKS · ${blockbreaker.score} SCORE` : "WALL PREVIEW"}</span>
</div>
</div>
);
}
if (activityMode === "hockey-healing-pvp") {
const mapX = (x: number) => 120 + x * 8.2;
const mapY = (z: number) => 140 + z * 4.85;
const localWorldZ = (z: number) => z + HOCKEY_PVP_SIDE_OFFSET_Z;
const opponentWorldX = (x: number) => -x;
const opponentWorldZ = (z: number) => -z - HOCKEY_PVP_SIDE_OFFSET_Z;
return (
<div className="map-panel hockey-map-panel pvp-map-panel">
<div className="map-copy">
<span>Healing Hockey PVP</span>
<h2>You vs {hockeyPvp.opponentName}</h2>
<p>Matching boss order. Each goal hits all five allies for {HOCKEY_PVP_GOAL_DAMAGE} damage.</p>
<div className="map-legend"><i className="legend-party" /> Your party <i className="legend-boss" /> Bosses <i className="legend-opponent" /> Rival <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Healing Hockey PVP tactical map">
<defs><linearGradient id="pvp-hockey-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#361819" /><stop offset="0.5" stopColor="#15262b" /><stop offset="1" stopColor="#0a3040" /></linearGradient></defs>
<rect className="map-room hockey-map-room" x="35" y="14" width="170" height="252" rx="3" fill="url(#pvp-hockey-room)" />
<path className="map-ring" d="M35 140 H205 M120 116 A24 24 0 1 0 120 164 A24 24 0 1 0 120 116" />
<path className="hockey-map-goal is-npc" d={`M${mapX(-HOCKEY_PVP_GOAL_HALF_WIDTH)} ${mapY(-HOCKEY_PVP_GOAL_Z)} H${mapX(HOCKEY_PVP_GOAL_HALF_WIDTH)}`} />
<path className="hockey-map-goal is-healer" d={`M${mapX(-HOCKEY_PVP_GOAL_HALF_WIDTH)} ${mapY(HOCKEY_PVP_GOAL_Z)} H${mapX(HOCKEY_PVP_GOAL_HALF_WIDTH)}`} />
<circle className="map-boss" cx={mapX(bossMotion.position[0])} cy={mapY(localWorldZ(bossMotion.position[1]))} r="7" />
<circle className="map-boss is-opponent" cx={mapX(opponentWorldX(hockeyPvpOpponent.bossMotion.position[0]))} cy={mapY(opponentWorldZ(hockeyPvpOpponent.bossMotion.position[1]))} r="7" />
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(localWorldZ(playerPosition[1]))} r="10" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(localWorldZ(playerPosition[1]))} r="5" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(localWorldZ(partyPositions[memberId][1]))} r="3.5" />)}
{(["aelia", "brann", "nia", "orin", "vale"] as const).map((memberId) => <circle key={`opponent-${memberId}`} className="map-ally map-opponent" cx={mapX(opponentWorldX(hockeyPvpOpponent.partyPositions[memberId][0]))} cy={mapY(opponentWorldZ(hockeyPvpOpponent.partyPositions[memberId][1]))} r="3.5" />)}
<circle className="hockey-map-puck" cx={mapX(hockeyPvp.puckPosition[0])} cy={mapY(hockeyPvp.puckPosition[1])} r="5" />
</svg>
<span className="map-state">{phase === "combat" ? `GOALS ${hockeyPvp.opponentGoalsConceded}${hockeyPvp.localGoalsConceded} · LIVE` : "VERSUS RINK"}</span>
</div>
</div>
);
}
if (activityMode === "hockey-healing") {
const mapX = (x: number) => 120 + x * 8.5;
const mapY = (z: number) => 140 + (z - HOCKEY_ARENA_CENTER_Z) * 8;
const npcGoalLeft = mapX(-HOCKEY_GOAL_HALF_WIDTH);
const npcGoalRight = mapX(HOCKEY_GOAL_HALF_WIDTH);
return (
<div className="map-panel hockey-map-panel">
<div className="map-copy">
<span>Hockey Healing</span>
<h2>Rectangular Boss Rink</h2>
<p>Party fights enemy half. Moving Pong paddle tracks each return and strikes it back toward healer.</p>
<div className="map-legend"><i className="legend-party" /> Party <i className="legend-boss" /> Boss <i className="legend-paddle" /> Paddle <i className="legend-exit" /> Puck</div>
</div>
<div className="map-canvas">
<svg viewBox="0 0 240 280" role="img" aria-label="Hockey Healing tactical map">
<defs>
<linearGradient id="hockey-room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#251717" /><stop offset="0.5" stopColor="#10262d" /><stop offset="1" stopColor="#092d39" /></linearGradient>
</defs>
<rect className="map-room hockey-map-room" x="35" y="20" width="170" height="240" rx="3" fill="url(#hockey-room)" />
<path className="map-ring" d="M35 140 H205 M120 116 A24 24 0 1 0 120 164 A24 24 0 1 0 120 116" />
<path className="hockey-map-goal is-npc" d={`M${npcGoalLeft} ${mapY(HOCKEY_NPC_GOAL_Z)} H${npcGoalRight}`} />
<path className="hockey-map-goal is-healer" d={`M${npcGoalLeft} ${mapY(HOCKEY_HEALER_GOAL_Z)} H${npcGoalRight}`} />
<rect
className="hockey-map-paddle"
x={mapX(hockey.paddleX - HOCKEY_NPC_PADDLE_HALF_WIDTH)}
y={mapY(HOCKEY_NPC_PADDLE_Z) - 4}
width={HOCKEY_NPC_PADDLE_HALF_WIDTH * 2 * 8.5}
height="8"
rx="3"
/>
{bossMotions.map((motion, index) => (
<g key={`${motion.bossId}-${index}`}>
<circle className="map-boss" cx={mapX(motion.position[0])} cy={mapY(motion.position[1])} r="8" />
<path className="map-boss-arrow" d={`M${mapX(motion.position[0])} ${mapY(motion.position[1]) - 14} l6 9 h-12 z`} />
</g>
))}
<circle className="map-player-pulse" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="12" />
<circle className="map-player" cx={mapX(playerPosition[0])} cy={mapY(playerPosition[1])} r="6" />
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
<circle key={memberId} className={`map-ally map-ally-${memberId}`} cx={mapX(partyPositions[memberId][0])} cy={mapY(partyPositions[memberId][1])} r="4" />
))}
<circle className="hockey-map-puck" cx={mapX(hockey.puckPosition[0])} cy={mapY(hockey.puckPosition[1])} r="5" />
{barrier.expiresAt > time && <ellipse className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} rx={BARRIER_RADIUS * 8.5} ry={BARRIER_RADIUS * 8} />}
</svg>
<span className="map-state">{phase === "combat" ? `${hockey.returns} RETURNS · LIVE` : "RINK PREVIEW"}</span>
</div>
</div>
);
}
return (
<div className="map-panel">
<div className="map-copy">
@@ -310,8 +562,8 @@ function MapPanel() {
className="map-barrier"
cx={120 + barrier.center[0] * 7}
cy={143 + barrier.center[1] * 5.3}
rx="21"
ry="15.9"
rx={BARRIER_RADIUS * 7}
ry={BARRIER_RADIUS * 5.3}
/>
)}
{(["brann", "nia", "orin", "vale"] as const).map((memberId) => (
@@ -358,33 +610,132 @@ function PackPanel() {
);
}
const tabs: { id: BottomTab; label: string; icon: string; key: string }[] = [
{ id: "combat", label: "Heal", icon: "✦", key: "" },
{ id: "map", label: "Map", icon: "⌁", key: "M" },
{ id: "pack", label: "Pack", icon: "▧", key: "I" },
];
function PvpPanel() {
const opponentName = useGameStore((state) => state.hockeyPvp.opponentName);
const opponentParty = useGameStore((state) => state.hockeyPvpOpponent.party);
const opponentGoalsConceded = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded);
const localGoalsConceded = useGameStore((state) => state.hockeyPvp.localGoalsConceded);
const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills);
const living = opponentParty.filter((member) => member.hp > 0).length;
const currentHealth = opponentParty.reduce((total, member) => total + Math.max(0, member.hp), 0);
const maximumHealth = opponentParty.reduce((total, member) => total + member.maxHp, 0);
return (
<div className="pvp-panel">
<header className="pvp-roster-header">
<span><small>Opponent party</small><strong>{opponentName}</strong></span>
<div><small>Goals</small><strong>{opponentGoalsConceded}{localGoalsConceded}</strong></div>
<div><small>Boss KOs</small><strong>{opponentBossKills}</strong></div>
<div><small>Standing</small><strong>{living} / {opponentParty.length}</strong></div>
</header>
<div className="pvp-party-list" aria-label={`${opponentName} party health`}>
<div className="section-label"><span>Rival health feed</span><small>{Math.ceil(currentHealth)} / {maximumHealth} total</small></div>
{opponentParty.map((member) => (
<article className={`pvp-party-frame ${member.hp <= 0 ? "is-down" : ""}`} key={member.id}>
<span className="party-avatar" style={{ "--member-color": member.color } as React.CSSProperties}>{member.name[0]}</span>
<span className="party-data">
<span className="party-name"><strong>{member.name}</strong><em>{Math.ceil(member.hp)} / {member.maxHp}</em></span>
<HealthBar member={member} />
<small>{member.className}</small>
</span>
<b>{member.hp <= 0 ? "DOWN" : `${Math.ceil((member.hp / member.maxHp) * 100)}%`}</b>
</article>
))}
</div>
</div>
);
}
const tabPresentation: Record<BottomTab, { label: string; icon: string; key: string }> = {
combat: { label: "Heal", icon: "✦", key: "" },
map: { label: "Map", icon: "⌁", key: "M" },
pack: { label: "Pack", icon: "▧", key: "I" },
pvp: { label: "PVP", icon: "⚔", key: "P" },
};
function RpgBottomDisplay({ run, focusedId, paused, onExit }: {
readonly run: NonNullable<ReturnType<typeof useGameStore.getState>["rpgRun"]>;
readonly focusedId: string | null;
readonly paused: boolean;
readonly onExit?: () => void;
}) {
const party = useGameStore((state) => state.party);
const selectedMemberId = useGameStore((state) => state.selectedMemberId);
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const cooldowns = useGameStore((state) => state.cooldowns);
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const time = useGameStore((state) => state.time);
const activeCast = useGameStore((state) => state.activeCast);
const spellResources = useGameStore((state) => state.rpgSpellResources);
const selectMember = useGameStore((state) => state.selectMember);
const castAbility = useGameStore((state) => state.castAbility);
const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction);
const setRpgFocusId = useGameStore((state) => state.setRpgFocusId);
const restart = useGameStore((state) => state.restart);
return (
<section className="display bottom-display rpg-bottom-display" aria-label="RPG Roguelike tactical display">
<RpgRunTacticalPanel
run={run}
focusedId={focusedId}
onFocusChange={setRpgFocusId}
onAction={dispatchRpgAction}
onRestartRun={restart}
onExitRun={onExit}
liveCombat={{
party,
selectedMemberId,
mana,
maxMana,
cooldowns,
globalCooldownUntil,
time,
activeCast,
spellResources,
onSelectMember: selectMember,
onCastAbility: castAbility,
}}
/>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
<span>PAUSED</span><strong>Expedition suspended</strong><small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC resumes · selects menu action</small>
</div>
)}
</section>
);
}
export function BottomScreen({ onExit }: { onExit?: () => void } = {}) {
const activeTab = useGameStore((state) => state.activeTab);
const setActiveTab = useGameStore((state) => state.setActiveTab);
const phase = useGameStore((state) => state.phase);
const paused = useGameStore((state) => state.paused);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const rpgRun = useGameStore((state) => state.rpgRun);
const rpgFocusId = useGameStore((state) => state.rpgFocusId);
const tabs = bottomTabsFor(runMode);
if (runMode === "rpg-roguelike" && rpgRun) {
return <RpgBottomDisplay run={rpgRun} focusedId={rpgFocusId} paused={paused} onExit={onExit} />;
}
return (
<section className="display bottom-display" aria-label="Tactical touch display">
<header className="lower-header">
<div className="lower-brand"><span>IH</span><strong>I Want To Heal</strong><small>{phase === "combat" ? "Encounter live" : "Field console"}</small></div>
<nav aria-label="Lower display sections">
{tabs.map((tab) => (
<button key={tab.id} className={activeTab === tab.id ? "is-active" : ""} onClick={() => setActiveTab(tab.id)}>
<nav aria-label="Lower display sections" role="tablist">
{tabs.map((tabId) => {
const tab = tabPresentation[tabId];
return <button key={tabId} role="tab" aria-selected={activeTab === tabId} className={activeTab === tabId ? "is-active" : ""} onClick={() => setActiveTab(tabId)}>
<i>{tab.icon}</i><span>{tab.label}</span>{tab.key && <small>{tab.key}</small>}
</button>
))}
})}
</nav>
</header>
<main className="lower-content">
{activeTab === "combat" && <CombatPanel onExit={onExit} />}
{activeTab === "map" && <MapPanel />}
{activeTab === "pack" && <PackPanel />}
{activeTab === "pack" && activityMode !== "hockey-healing-pvp" && <PackPanel />}
{activeTab === "pvp" && activityMode === "hockey-healing-pvp" && <PvpPanel />}
</main>
{paused && (
<div className="lower-pause-overlay" aria-hidden="true">
+4 -5
View File
@@ -41,13 +41,12 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
const buff = RUN_BUFFS[buffId];
const rank = effectiveRunBuffRank(runBuffRanks, buffId, passiveRunBuffId);
const nextRank = Math.min(buff.maxRank, rank + 1);
const ability = abilities[buff.abilityId];
const ability = abilities[buff.abilitySlotId];
return (
<button
key={buffId}
className={selected === buffId ? "is-controller-focused" : ""}
className={selected === buffId ? "is-controller-selected" : ""}
style={{ "--buff-accent": buff.accent } as React.CSSProperties}
onFocus={() => setSelected(buffId)}
onPointerEnter={() => setSelected(buffId)}
onClick={() => choose(buffId)}
disabled={inputLocked}
@@ -55,12 +54,12 @@ export function BuffDraftPanel({ className = "" }: { className?: string }) {
>
<i>{buff.icon}</i>
<span><small>{rank ? `Rank ${rank}${nextRank} / ${buff.maxRank}` : `New blessing · Rank 1 / ${buff.maxRank}`}</small><strong>{ability.shortName}: {buff.name}</strong></span>
<b>{formatRunBuffEffect(buffId, nextRank)}</b>
<b>{formatRunBuffEffect(buffId, nextRank, ability.shortName)}</b>
<p>{buff.detail}</p>
</button>
);
}) : (
<button className="buff-mastery-continue is-controller-focused" onClick={continueRun} disabled={inputLocked}>
<button className="buff-mastery-continue is-controller-selected" onClick={continueRun} disabled={inputLocked}>
<i></i>
<span><small>Full mastery</small><strong>Continue Without Buff</strong></span>
<b>All 18 blessings reached maximum rank.</b>
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { CLAUDECRAFT_WEAPON_CATALOG } from "../game/weaponCatalog";
import { characterEquipmentAssetUrl, characterEquipmentAssetUrls } from "./CharacterEquipmentAssets";
describe("Claudecraft runtime weapon URLs", () => {
it("resolves every catalog entry to one emitted GLB URL", () => {
const urls = characterEquipmentAssetUrls();
expect(Object.keys(urls)).toHaveLength(55);
for (const definition of CLAUDECRAFT_WEAPON_CATALOG) {
expect(characterEquipmentAssetUrl(definition.id)).toMatch(/\.glb(?:\?|$)/);
}
});
});
@@ -0,0 +1,35 @@
import {
CLAUDECRAFT_WEAPON_CATALOG,
weaponDefinition,
type WeaponCatalogId,
} from "../game/weaponCatalog";
import { selectedGameAssetUrl } from "./GameAssetProvider";
const WEAPON_ASSET_PREFIX = "../assets/game/models/claudecraft/weapons/";
const WEAPON_ASSET_URLS = import.meta.glob<string>(
"../assets/game/models/claudecraft/weapons/*.glb",
{ eager: true, import: "default", query: "?url" },
);
function importedWeaponUrl(fileName: string) {
const url = WEAPON_ASSET_URLS[`${WEAPON_ASSET_PREFIX}${fileName}`];
if (!url) throw new Error(`Missing imported Claudecraft weapon asset: ${fileName}`);
return url;
}
/** Resolves one selected asset. Creating the URL table does not fetch or decode every GLB. */
export function characterEquipmentAssetUrl(modelId: WeaponCatalogId) {
const definition = weaponDefinition(modelId);
const sourceUrl = importedWeaponUrl(definition.sourceFile);
const optimizedUrl = definition.optimizedFile
? importedWeaponUrl(definition.optimizedFile)
: sourceUrl;
return selectedGameAssetUrl(sourceUrl, optimizedUrl);
}
export function characterEquipmentAssetUrls() {
return Object.fromEntries(CLAUDECRAFT_WEAPON_CATALOG.map((definition) => [
definition.id,
characterEquipmentAssetUrl(definition.id),
])) as Record<WeaponCatalogId, string>;
}
File diff suppressed because it is too large Load Diff
+2153 -325
View File
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
import { useFrame } from "@react-three/fiber";
import { useLayoutEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import type { HealerVisualProfile } from "../game/healerVisuals";
function RelicMaterial({ color }: { color: string }) {
return (
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.45}
metalness={0.35}
roughness={0.42}
/>
);
}
function DawnNimbus({ accentColor, secondaryColor }: { accentColor: string; secondaryColor: string }) {
const rays = useRef<THREE.InstancedMesh>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
useLayoutEffect(() => {
if (!rays.current) return;
for (let index = 0; index < 8; index += 1) {
const angle = index * Math.PI / 4;
transform.position.set(Math.sin(angle) * 0.54, Math.cos(angle) * 0.54, 0);
transform.rotation.set(0, 0, -angle);
transform.updateMatrix();
rays.current.setMatrixAt(index, transform.matrix);
}
rays.current.instanceMatrix.needsUpdate = true;
}, [transform]);
return (
<group position={[0, 0.68, -0.04]}>
<instancedMesh ref={rays} args={[undefined, undefined, 8]}>
<boxGeometry args={[0.045, 0.16, 0.035]} />
<RelicMaterial color={accentColor} />
</instancedMesh>
<mesh>
<torusGeometry args={[0.42, 0.04, 6, 20]} />
<RelicMaterial color={accentColor} />
</mesh>
<mesh position={[0, 0.08, 0.015]} rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.1, 0]} />
<RelicMaterial color={secondaryColor} />
</mesh>
</group>
);
}
export function HealerClassAccessory({ profile }: { profile: HealerVisualProfile }) {
const animated = useRef<THREE.Group>(null);
useFrame((_, delta) => {
if (!animated.current || profile.accessory !== "clockwork-rings") return;
animated.current.rotation.z += delta * 0.42;
animated.current.rotation.y -= delta * 0.18;
});
if (profile.accessory === "sun-halo") {
return <DawnNimbus accentColor={profile.accentColor} secondaryColor={profile.secondaryColor} />;
}
if (profile.accessory === "grove-antlers") {
return (
<group position={[0, 0.34, 0]}>
<mesh position={[-0.2, 0.18, 0]} rotation={[0, 0, -0.36]}>
<coneGeometry args={[0.055, 0.5, 5]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh position={[0.2, 0.18, 0]} rotation={[0, 0, 0.36]}>
<coneGeometry args={[0.055, 0.5, 5]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh position={[-0.31, 0.28, 0]} rotation={[0, 0, -0.62]}>
<tetrahedronGeometry args={[0.1, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh position={[0.31, 0.28, 0]} rotation={[0, 0, 0.62]}>
<tetrahedronGeometry args={[0.1, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
</group>
);
}
if (profile.accessory === "storm-totem") {
return (
<group position={[0, 0.78, 0]}>
<mesh>
<cylinderGeometry args={[0.09, 0.12, 0.42, 6]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh position={[-0.22, 0.1, 0]}>
<octahedronGeometry args={[0.11, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh position={[0.22, -0.08, 0]} scale={0.75}>
<octahedronGeometry args={[0.11, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
</group>
);
}
if (profile.accessory === "sun-crest") {
return (
<group position={[0, 0.68, 0]}>
<mesh>
<torusGeometry args={[0.46, 0.055, 6, 14]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.23, 0]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
</group>
);
}
return (
<group ref={animated} position={[0, 0.68, 0]}>
<mesh>
<torusGeometry args={[0.46, 0.04, 6, 18]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh rotation={[Math.PI / 3, 0, 0]}>
<torusGeometry args={[0.25, 0.035, 6, 14]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
<mesh rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.11, 0]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
</group>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { createPortal } from "@react-three/fiber";
import { useTexture } from "@react-three/drei";
import { useEffect, useMemo } from "react";
import * as THREE from "three";
import {
CHARACTER_PART_CATALOG,
characterAppearancePartIds,
type CharacterAppearanceV1,
type CharacterMaterialVariant,
type CharacterPartId,
} from "../game/characterAppearance";
import type { MemberId } from "../game/types";
import { useGameGLTF } from "./GameAssetProvider";
interface BoundCharacterPart {
group: THREE.Group;
materials: THREE.Material[];
skeletons: THREE.Skeleton[];
}
const CHARACTER_MATERIAL_VARIANT_URLS: Record<CharacterMaterialVariant, string> = {
"priest-vestments": new URL("../assets/game/textures/claudecraft/chars/players/priest-vestments.webp", import.meta.url).href,
};
function rigBonesByName(rigScene: THREE.Object3D) {
const bones = new Map<string, THREE.Bone>();
rigScene.traverse((object) => {
if (object instanceof THREE.Bone) bones.set(object.name, object);
});
return bones;
}
function createBoundCharacterPart(
sourceScene: THREE.Object3D,
rigScene: THREE.Object3D,
partId: CharacterPartId,
materialTexture?: THREE.Texture,
): BoundCharacterPart {
const definition = CHARACTER_PART_CATALOG[partId];
const rigBones = rigBonesByName(rigScene);
const group = new THREE.Group();
group.name = `character-part:${partId}`;
const materialClones = new Map<THREE.Material, THREE.Material>();
const skeletons: THREE.Skeleton[] = [];
sourceScene.updateMatrixWorld(true);
for (const nodeName of definition.nodeNames) {
const sourceNode = sourceScene.getObjectByName(nodeName);
if (!sourceNode) throw new Error(`Character part ${partId} is missing node ${nodeName}.`);
const partNode = sourceNode.clone(true);
partNode.matrix.copy(sourceNode.matrixWorld);
partNode.matrix.decompose(partNode.position, partNode.quaternion, partNode.scale);
partNode.traverse((object) => {
if (!(object instanceof THREE.SkinnedMesh)) return;
if (materialTexture) {
const cloneMaterial = (source: THREE.Material) => {
const existing = materialClones.get(source);
if (existing) return existing;
const clone = source.clone();
if ("map" in clone) clone.map = materialTexture;
clone.needsUpdate = true;
materialClones.set(source, clone);
return clone;
};
object.material = Array.isArray(object.material)
? object.material.map(cloneMaterial)
: cloneMaterial(object.material);
}
const mappedBones = object.skeleton.bones.map((sourceBone) => {
const rigBone = rigBones.get(sourceBone.name);
if (!rigBone) throw new Error(`Character part ${partId} cannot resolve rig bone ${sourceBone.name}.`);
return rigBone;
});
const skeleton = new THREE.Skeleton(
mappedBones,
object.skeleton.boneInverses.map((inverse) => inverse.clone()),
);
const bindMatrix = object.bindMatrix.clone();
object.bind(skeleton, bindMatrix);
object.castShadow = true;
object.receiveShadow = true;
object.frustumCulled = false;
skeletons.push(skeleton);
});
group.add(partNode);
}
return { group, materials: [...materialClones.values()], skeletons };
}
function BoundCharacterPart({
actorScene,
materialTexture,
modelUrls,
partId,
}: {
actorScene: THREE.Object3D;
materialTexture?: THREE.Texture;
modelUrls: Record<MemberId, string>;
partId: CharacterPartId;
}) {
const definition = CHARACTER_PART_CATALOG[partId];
const gltf = useGameGLTF(modelUrls[definition.sourceMemberId]);
const boundPart = useMemo(
() => createBoundCharacterPart(gltf.scene, actorScene, partId, materialTexture),
[actorScene, gltf.scene, materialTexture, partId],
);
useEffect(() => () => {
for (const skeleton of boundPart.skeletons) skeleton.dispose();
for (const material of boundPart.materials) material.dispose();
}, [boundPart]);
const rigRoot = actorScene.getObjectByName("Rig_Medium") ?? actorScene;
return createPortal(<primitive object={boundPart.group} />, rigRoot);
}
function MaterialVariantCharacterPart({
actorScene,
materialVariant,
modelUrls,
partId,
}: {
actorScene: THREE.Object3D;
materialVariant: CharacterMaterialVariant;
modelUrls: Record<MemberId, string>;
partId: CharacterPartId;
}) {
const sourceTexture = useTexture(CHARACTER_MATERIAL_VARIANT_URLS[materialVariant]);
const materialTexture = useMemo(() => {
sourceTexture.flipY = false;
sourceTexture.colorSpace = THREE.SRGBColorSpace;
sourceTexture.magFilter = THREE.NearestFilter;
sourceTexture.needsUpdate = true;
return sourceTexture;
}, [sourceTexture]);
return <BoundCharacterPart actorScene={actorScene} materialTexture={materialTexture} modelUrls={modelUrls} partId={partId} />;
}
function ModularCharacterPart({
actorScene,
modelUrls,
partId,
}: {
actorScene: THREE.Object3D;
modelUrls: Record<MemberId, string>;
partId: CharacterPartId;
}) {
const definition = CHARACTER_PART_CATALOG[partId];
const materialVariant = "materialVariant" in definition ? definition.materialVariant : undefined;
return materialVariant
? <MaterialVariantCharacterPart actorScene={actorScene} materialVariant={materialVariant} modelUrls={modelUrls} partId={partId} />
: <BoundCharacterPart actorScene={actorScene} modelUrls={modelUrls} partId={partId} />;
}
export function ModularCharacterBody({
actorScene,
appearance,
modelUrls,
}: {
actorScene: THREE.Object3D;
appearance: CharacterAppearanceV1;
modelUrls: Record<MemberId, string>;
}) {
return (
<>
{characterAppearancePartIds(appearance).map((partId) => (
<ModularCharacterPart
key={`${partId}:${appearance.version}`}
actorScene={actorScene}
modelUrls={modelUrls}
partId={partId}
/>
))}
</>
);
}
+239 -35
View File
@@ -1,13 +1,47 @@
import { lazy, Suspense } from "react";
import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_CLASSES } from "../game/healers";
import { lazy, memo, Suspense, useEffect, useRef, useState } from "react";
import { barrierProtects, healerFieldContains, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_ABILITIES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { bossRoomFor } from "../game/bossRooms";
import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel";
import { DEFAULT_CONTROLLER_GLYPHS } from "../input/controllerGlyphs";
import { hockeyPvpDampeningPercent } from "../game/hockeyHealingPvp";
import { blockbreakerTimeMultiplier } from "../game/blockbreaker";
import { RpgRunOverlay } from "./rpgRoguelike/RpgRunOverlay";
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
import { healerMaxResource, isBeaconOfLightTarget } from "../game/healerMechanics";
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
const GameScene = memo(lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene }))));
GameScene.displayName = "MemoizedGameScene";
const CONVICTION_PIPS = [0, 1, 2] as const;
const MAX_CONVICTION = healerMaxResource("paladin");
function ConvictionMeter() {
const healerClassId = useGameStore((state) => state.healerClassId);
const runMode = useGameStore((state) => state.runMode);
const encounterConviction = useGameStore((state) => state.healerMechanic.resource);
const rpgConviction = useGameStore((state) => state.rpgSpellResources.conviction);
if (healerClassId !== "paladin") return null;
const conviction = Math.max(0, Math.min(MAX_CONVICTION, runMode === "rpg-roguelike" ? rpgConviction : encounterConviction));
return (
<div
className={`top-conviction-meter ${conviction === MAX_CONVICTION ? "is-full" : ""}`}
role="meter"
aria-label="Conviction"
aria-valuemin={0}
aria-valuemax={MAX_CONVICTION}
aria-valuenow={conviction}
aria-valuetext={`${conviction} of ${MAX_CONVICTION} Conviction`}
title={`${conviction} of ${MAX_CONVICTION} Conviction`}
>
<span><b>Conviction</b><small>{conviction} / {MAX_CONVICTION}</small></span>
<span className="top-conviction-pips" aria-hidden="true">
{CONVICTION_PIPS.map((pip) => <i className={pip < conviction ? "is-filled" : ""} key={pip} />)}
</span>
</div>
);
}
function CompactParty() {
const party = useGameStore((state) => state.party);
@@ -19,17 +53,19 @@ function CompactParty() {
const selectMember = useGameStore((state) => state.selectMember);
const mana = useGameStore((state) => state.mana);
const maxMana = useGameStore((state) => state.maxMana);
const healerMechanic = useGameStore((state) => state.healerMechanic);
return (
<div className="top-party" aria-label="Party health">
<div className="top-party" aria-label="Party health and player resources">
{party.map((member) => {
const health = (member.hp / member.maxHp) * 100;
const shield = (member.absorb / member.maxHp) * 100;
const beaconed = isBeaconOfLightTarget(member.id, healerMechanic, time);
return (
<button
className={`top-party-member ${selected === member.id ? "is-selected" : ""}`}
className={`top-party-member ${selected === member.id ? "is-selected" : ""} ${beaconed ? "is-beacon" : ""}`}
key={member.id}
onClick={() => selectMember(member.id)}
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} health`}
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} health${beaconed ? ", Beacon of Light" : ""}`}
>
<span className="portrait-dot" style={{ background: member.color }}>{member.name[0]}</span>
<span className="top-party-copy"><strong>{member.name}</strong><small>{member.role}</small></span>
@@ -43,26 +79,29 @@ function CompactParty() {
</span>
)}
<span className="top-effects">
{member.renewExpiresAt > 0 && <em className="renew-pip">R</em>}
{beaconed && <em className="beacon-pip" title={`Beacon of Light: ${Math.max(0, healerMechanic.beaconExpiresAt - time).toFixed(1)} seconds`}></em>}
{member.healingEffects.some((effect) => effect.expiresAt > time) && <em className="renew-pip">H</em>}
{member.reactiveHeal && member.reactiveHeal.expiresAt > time && <em className="renew-pip">E{member.reactiveHeal.charges}</em>}
{member.debuffs.length > 0 && <em className="debuff-pip">!</em>}
{barrierProtects(partyPositions[member.id], barrier, time) && <em className="barrier-pip">B</em>}
{tankAuraProtects(partyPositions[member.id], partyPositions.brann, tankAura, time) && <em className="tank-aura-pip">T</em>}
{barrier.kind === "spirit-link" && healerFieldContains(partyPositions[member.id], barrier, time) && <em className="barrier-pip">S</em>}
{tankAuraProtects(partyPositions[member.id], partyPositions[tankAura.sourceId], tankAura, time) && <em className="tank-aura-pip">T</em>}
{member.knockedUntil > time && <em className="knockdown-pip">KD</em>}
</span>
</button>
);
})}
<ConvictionMeter />
</div>
);
}
function CastingBar() {
const healerClassId = useGameStore((state) => state.healerClassId);
const abilityName = HEALER_CLASSES[healerClassId].abilities.mend.name;
const activeCast = useGameStore((state) => state.activeCast);
const time = useGameStore((state) => state.time);
const party = useGameStore((state) => state.party);
if (!activeCast) return null;
const abilityName = HEALER_ABILITIES[activeCast.abilityId].name;
const duration = activeCast.completesAt - activeCast.startedAt;
const progress = Math.min(1, Math.max(0, (time - activeCast.startedAt) / duration));
const target = party.find((member) => member.id === activeCast.targetId);
@@ -78,13 +117,19 @@ function CastingBar() {
function BossBar() {
const boss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const opponentBoss = useGameStore((state) => state.hockeyPvpOpponent.boss);
const phase = useGameStore((state) => state.phase);
const activityMode = useGameStore((state) => state.activityMode);
if (phase === "briefing") return null;
const bosses = [boss, ...additionalBosses.map((entry) => entry.boss)];
const hockeyMode = activityMode === "hockey-healing";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherAssaultMode = activityMode === "aether-assault";
const pvpMode = activityMode === "hockey-healing-pvp";
const bosses = pvpMode ? [boss, opponentBoss] : [boss, ...additionalBosses.map((entry) => entry.boss)];
return (
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""}`}>
{bosses.map((entry) => <div className="boss-bar-entry" key={entry.id}>
<div className="boss-name"><span>Vault Beast</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
<div className={`boss-bar-wrap ${bosses.length > 1 ? "is-multi" : ""} ${bosses.length === 3 ? "is-trio" : ""} ${pvpMode ? "is-pvp" : ""}`}>
{bosses.map((entry, index) => <div className={`boss-bar-entry ${pvpMode && index === 1 ? "is-opponent" : ""}`} key={`${entry.id}-${index}`}>
<div className="boss-name"><span>{pvpMode ? index === 0 ? "Your target" : "Rival target" : hockeyMode ? index === 0 ? "Striker · paddle + damage" : "Frontline · party damage" : blockbreakerMode ? index === 0 ? "Breaker flank" : "Wall pressure" : aetherAssaultMode ? index === 0 ? "Arcade flank" : "Party pressure" : "Vault Beast"}</span><strong>{entry.name}</strong><span>{Math.ceil((entry.hp / entry.maxHp) * 100)}%</span></div>
<div className="boss-bar"><i style={{ width: `${(entry.hp / entry.maxHp) * 100}%` }} /></div>
</div>)}
</div>
@@ -110,48 +155,123 @@ function EncounterCallout() {
function PhaseOverlay() {
const phase = useGameStore((state) => state.phase);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const round = useGameStore((state) => state.round);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const hockey = useGameStore((state) => state.hockey);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
if (runMode === "rpg-roguelike") return null;
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const room = bossRoomFor(primaryBoss.id);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
const hockeyMode = activityMode === "hockey-healing";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherAssaultMode = activityMode === "aether-assault";
const pvpMode = activityMode === "hockey-healing-pvp";
const showEndlessChoice = phase === "victory" && runMode === "rogue-trials" && round === 5 && !endlessMode;
const endlessDefeat = phase === "defeat" && endlessMode;
const briefingMode = runMode === "rogue-trials"
const endlessDefeat = phase === "defeat" && endlessMode && !hockeyMode && !blockbreakerMode && !aetherAssaultMode;
const briefingMode = hockeyMode
? "Endless Goal Defense"
: blockbreakerMode
? "Endless Color Break"
: aetherAssaultMode
? "Endless Arcade Assault"
: pvpMode
? `Versus ${hockeyPvp.opponentName}`
: runMode === "rogue-trials"
? bosses.length === 3 ? "Rogue Trials · Trio Finale" : "Rogue Trials · Dual Round"
: bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial;
if (phase === "combat") return null;
const title = phase === "briefing"
? room.name
? hockeyMode ? "Hockey Healing" : blockbreakerMode ? "Blockbreaker" : aetherAssaultMode ? "Aether Assault" : pvpMode ? "Healing Hockey PVP" : room.name
: phase === "victory"
? showEndlessChoice ? "Rogue Trials Cleared" : `${bossNames} Broken`
: endlessDefeat ? "Endless Run Ended" : "Party Broken";
? showEndlessChoice ? "Rogue Trials Cleared" : pvpMode ? "Match Won" : `${bossNames} Broken`
: hockeyMode ? "Goal Breached" : blockbreakerMode && blockbreaker.status === "lost" ? "Wall Breached" : aetherAssaultMode ? "Formation Lost" : pvpMode ? "Match Lost" : endlessDefeat ? "Endless Run Ended" : "Party Broken";
const eyebrow = phase === "briefing"
? `${briefingMode} · ${room.biome}`
? hockeyMode ? `${briefingMode} · Rectangular Boss Rink` : blockbreakerMode ? `${briefingMode} · Advancing Brick Rink` : aetherAssaultMode ? `${briefingMode} · Bright Five-Lane Rink` : pvpMode ? `${briefingMode} · Extended Versus Rink` : `${briefingMode} · ${room.biome}`
: phase === "victory"
? showEndlessChoice ? "Endless Path Unlocked" : "Encounter Complete"
: endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
? showEndlessChoice ? "Endless Path Unlocked" : pvpMode ? `${hockeyPvp.opponentGoalsConceded} Rival Goals · ${endlessBossKills} Boss Kills` : "Encounter Complete"
: hockeyMode ? `${hockey.returns} Pucks Returned · ${endlessBossKills} Bosses Defeated` : blockbreakerMode ? `${blockbreaker.bricksBroken} Bricks · ${blockbreaker.score.toLocaleString()} Points` : aetherAssaultMode ? `${aetherAssault.score.toLocaleString()} Points · Wave ${aetherAssault.wave}` : pvpMode ? `${hockeyPvp.localGoalsConceded} Goals Conceded · ${hockeyPvp.opponentBossKills} Rival Boss Kills` : endlessDefeat ? `${endlessBossKills} Endless Bosses Defeated` : "Encounter Failed";
const copy = phase === "briefing"
? definitions.map((boss) => boss.briefing).join(" ")
? hockeyMode
? "Defend the wide blue goal. Aim each return; the moving Pong paddle strikes it back. Party fights both bosses on enemy half. Fallen bosses are replaced."
: blockbreakerMode
? "Aim the puck into advancing five-brick rows. Matching orthogonal colors break as one combo while two bosses pressure the party. Missed pucks safely re-serve."
: aetherAssaultMode
? "Move across the full bright rink while spellfire launches automatically. Line up ship formations, dodge red bolts and dives, and keep healing through two endless bosses."
: pvpMode
? "Two parties fight matching boss sequences. Defend your goal and aim each return. Every goal deals 45 damage to all five players. Fallen bosses respawn instantly."
: definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory"
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : "Five entered. Five endured."
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
? showEndlessChoice ? "Leave with the clear, or continue against an unbroken chain of replacement bosses." : pvpMode ? `${hockeyPvp.opponentName}'s party fell first.` : "Five entered. Five endured."
: hockeyMode
? `Run ended after ${hockey.returns} returns and ${endlessBossKills} boss kills.`
: blockbreakerMode
? `Run record: ${blockbreaker.bricksBroken} bricks, ${blockbreaker.score.toLocaleString()} points, and ${endlessBossKills} boss kills.`
: aetherAssaultMode
? `Run record: ${aetherAssault.score.toLocaleString()} points, wave ${aetherAssault.wave}, ${aetherAssault.kills} ships, and ${endlessBossKills} boss kills.`
: pvpMode ? `${hockeyPvp.opponentName} kept their party standing.`
: endlessDefeat ? `Run record: ${endlessBossKills} bosses defeated after the trio finale.` : definitions.map((boss) => boss.failure).join(" ");
return (
<div className={`phase-overlay phase-${phase}`}>
<div className="phase-sigil"></div>
<span>{eyebrow}</span>
<h1>{title}</h1>
<p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Continue or Quit on lower display" : "Restart from lower display"}</small>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode or Quit on lower display" : pvpMode ? "Choose next match from lower display" : "Restart from lower display"}</small>
</div>
);
}
function BlockbreakerScorePopup() {
const activityMode = useGameStore((state) => state.activityMode);
const lastBreakAt = useGameStore((state) => state.blockbreaker.lastBreakAt);
const count = useGameStore((state) => state.blockbreaker.lastBreakCount);
const award = useGameStore((state) => state.blockbreaker.lastScoreAward);
const [visibleAt, setVisibleAt] = useState<number | null>(null);
useEffect(() => {
if (activityMode !== "blockbreaker" || !Number.isFinite(lastBreakAt)) {
setVisibleAt(null);
return;
}
setVisibleAt(lastBreakAt);
const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastBreakAt ? null : current), 850);
return () => window.clearTimeout(timeout);
}, [activityMode, lastBreakAt]);
if (visibleAt === null) return null;
return <div className="blockbreaker-score-popup" role="status" aria-live="polite"><strong>+{award.toLocaleString()}</strong><small>{count} {count === 1 ? "brick" : "brick combo"}</small></div>;
}
function AetherScorePopup() {
const activityMode = useGameStore((state) => state.activityMode);
const lastKillAt = useGameStore((state) => state.aetherAssault.lastKillAt);
const award = useGameStore((state) => state.aetherAssault.lastKillScore);
const multiplier = useGameStore((state) => state.aetherAssault.multiplier);
const [visibleAt, setVisibleAt] = useState<number | null>(null);
useEffect(() => {
if (activityMode !== "aether-assault" || !Number.isFinite(lastKillAt)) {
setVisibleAt(null);
return;
}
setVisibleAt(lastKillAt);
const timeout = window.setTimeout(() => setVisibleAt((current) => current === lastKillAt ? null : current), 700);
return () => window.clearTimeout(timeout);
}, [activityMode, lastKillAt]);
if (visibleAt === null) return null;
return <div className="blockbreaker-score-popup aether-score-popup" role="status" aria-live="polite"><strong>+{award.toLocaleString()}</strong><small>{multiplier.toFixed(2)}× streak</small></div>;
}
function PauseOverlay({ onExit }: { onExit?: () => void }) {
const paused = useGameStore((state) => state.paused);
const selection = useGameStore((state) => state.pauseSelection);
@@ -170,14 +290,12 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
<p>Simulation, damage, and movement are stopped.</p>
<div className="pause-actions">
<button
className={selection === "resume" ? "is-controller-focused" : ""}
onFocus={() => setPauseSelection("resume")}
className={selection === "resume" ? "is-controller-selected" : ""}
onPointerEnter={() => setPauseSelection("resume")}
onClick={() => setPaused(false)}
>Resume <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>
<button
className={`secondary ${selection === "exit" ? "is-controller-focused" : ""}`}
onFocus={() => setPauseSelection("exit")}
className={`secondary ${selection === "exit" ? "is-controller-selected" : ""}`}
onPointerEnter={() => setPauseSelection("exit")}
onClick={exit}
>Return to main menu <small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small></button>
@@ -188,30 +306,116 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
);
}
export function TopScreen({ onExit }: { onExit?: () => void }) {
function GoalPopup() {
const activityMode = useGameStore((state) => state.activityMode);
const goalSequence = useGameStore((state) => state.hockeyPvp.goalSequence);
const lastGoalSide = useGameStore((state) => state.hockeyPvp.lastGoalSide);
const lastSeenSequence = useRef(goalSequence);
const [visibleSequence, setVisibleSequence] = useState<number | null>(null);
useEffect(() => {
if (activityMode !== "hockey-healing-pvp" || goalSequence < lastSeenSequence.current) {
lastSeenSequence.current = goalSequence;
setVisibleSequence(null);
return;
}
if (goalSequence === lastSeenSequence.current) return;
lastSeenSequence.current = goalSequence;
setVisibleSequence(goalSequence);
const timeout = window.setTimeout(() => {
setVisibleSequence((current) => current === goalSequence ? null : current);
}, 1_250);
return () => window.clearTimeout(timeout);
}, [activityMode, goalSequence]);
if (visibleSequence === null) return null;
return (
<div
key={visibleSequence}
className={`goal-popup ${lastGoalSide === "local" ? "is-conceded" : "is-scored"}`}
role="status"
aria-live="assertive"
aria-atomic="true"
>
<strong>GOAL</strong>
</div>
);
}
function DampeningIndicator() {
const activityMode = useGameStore((state) => state.activityMode);
const localBossKills = useGameStore((state) => state.endlessBossKills);
const opponentBossKills = useGameStore((state) => state.hockeyPvp.opponentBossKills);
if (activityMode !== "hockey-healing-pvp") return null;
const percent = hockeyPvpDampeningPercent(localBossKills, opponentBossKills);
return (
<div className="dampening-indicator" role="status" aria-label={`${percent}% healing dampening`}>
<span>Dampening</span><strong>{percent}%</strong>
<i><em style={{ width: `${percent}%` }} /></i>
</div>
);
}
export function TopScreen({
onExit,
playerAppearance,
}: {
onExit?: () => void;
playerAppearance?: CharacterAppearanceV1;
}) {
const phase = useGameStore((state) => state.phase);
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const activityMode = useGameStore((state) => state.activityMode);
const endlessMode = useGameStore((state) => state.endlessMode);
const endlessBossKills = useGameStore((state) => state.endlessBossKills);
const hockeyReturns = useGameStore((state) => state.hockey.returns);
const blockbreaker = useGameStore((state) => state.blockbreaker);
const aetherAssault = useGameStore((state) => state.aetherAssault);
const hockeyPvp = useGameStore((state) => state.hockeyPvp);
const time = useGameStore((state) => state.time);
const setPaused = useGameStore((state) => state.setPaused);
const rpgRun = useGameStore((state) => state.rpgRun);
const rpgFocusId = useGameStore((state) => state.rpgFocusId);
const dispatchRpgAction = useGameStore((state) => state.dispatchRpgAction);
const setRpgFocusId = useGameStore((state) => state.setRpgFocusId);
const restart = useGameStore((state) => state.restart);
const hockeyMode = activityMode === "hockey-healing";
const blockbreakerMode = activityMode === "blockbreaker";
const aetherAssaultMode = activityMode === "aether-assault";
const pvpMode = activityMode === "hockey-healing-pvp";
const duration = `${Math.floor(time / 60)}:${String(Math.floor(time % 60)).padStart(2, "0")}`;
return (
<section className="display top-display" aria-label="Main game viewport">
<Suspense fallback={<div className="scene-loading" aria-label="Loading 3D scene" />}>
<GameScene />
<GameScene playerAppearance={playerAppearance} />
</Suspense>
<div className="top-vignette" />
<div className="top-hud">
<CompactParty />
<BossBar />
<div className="objective-chip"><span>{endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<div className={`objective-chip ${hockeyMode || pvpMode || blockbreakerMode || aetherAssaultMode ? "is-hockey" : ""} ${pvpMode ? "is-pvp" : ""} ${blockbreakerMode ? "is-blockbreaker" : ""} ${aetherAssaultMode ? "is-aether" : ""}`}><span>{hockeyMode ? `Hockey Healing · ${hockeyReturns} returns · ${duration}` : blockbreakerMode ? `Blockbreaker · ${blockbreaker.score.toLocaleString()} pts · ${duration}` : aetherAssaultMode ? `Aether Assault · ${aetherAssault.score.toLocaleString()} pts · ${duration}` : pvpMode ? `VS ${hockeyPvp.opponentName} · ${duration}` : endlessMode ? `Endless · ${endlessBossKills} kills` : runMode !== "encounter" ? `Round ${round}` : "Objective"}</span><strong>{hockeyMode ? `Defend wide goal · ${endlessBossKills} boss kills` : blockbreakerMode ? `${blockbreaker.bricksBroken} bricks · ${blockbreakerTimeMultiplier(time).toFixed(1)}× · row in ${Math.max(0, blockbreaker.nextRowAt - time).toFixed(1)}s` : aetherAssaultMode ? `Wave ${aetherAssault.wave} · ${aetherAssault.ships.length} ships · ${aetherAssault.multiplier.toFixed(2)}×` : pvpMode ? `Goals ${hockeyPvp.opponentGoalsConceded}${hockeyPvp.localGoalsConceded} · Bosses ${endlessBossKills}${hockeyPvp.opponentBossKills}` : endlessMode ? "Defeat bosses · replacements incoming" : bossCount === 3 ? "Defeat trio · keep five alive" : bossCount === 2 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<EncounterCallout />
<DampeningIndicator />
<CastingBar />
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
<div className="control-hint"><b>WASD</b> Move{aetherAssaultMode ? " + auto-fire" : ""} <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
{onExit && <button className="game-menu-button" onClick={() => phase === "combat" ? setPaused(true) : onExit()}><b></b> Menu <small>{DEFAULT_CONTROLLER_GLYPHS.start} / ESC</small></button>}
</div>
<GoalPopup />
<BlockbreakerScorePopup />
<AetherScorePopup />
<PhaseOverlay />
{runMode === "rpg-roguelike" && rpgRun && (
<RpgRunOverlay
run={rpgRun}
focusedId={rpgFocusId}
onFocusChange={setRpgFocusId}
onAction={dispatchRpgAction}
onRestartRun={restart}
onExitRun={onExit}
/>
)}
<PauseOverlay onExit={onExit} />
</section>
);
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
AETHER_ARMORED_SHIP_COLORS,
AETHER_STANDARD_SHIP_COLORS,
aetherShipColor,
aetherShipColorIndex,
} from "./aetherAssaultVisuals";
describe("Aether Assault ship colors", () => {
it("assigns a deterministic mixed palette across a formation", () => {
const first = Array.from({ length: 6 }, (_, index) => aetherShipColor(1234, 1, index, "standard"));
const replay = Array.from({ length: 6 }, (_, index) => aetherShipColor(1234, 1, index, "standard"));
expect(replay).toEqual(first);
expect(new Set(first).size).toBe(AETHER_STANDARD_SHIP_COLORS.length);
});
it("changes palette ordering between waves and keeps armored variants distinct", () => {
const waveOneIndex = aetherShipColorIndex(42, 1, 0);
const waveTwoIndex = aetherShipColorIndex(42, 2, 0);
expect(waveTwoIndex).not.toBe(waveOneIndex);
expect(aetherShipColor(42, 1, 0, "standard")).toBe(AETHER_STANDARD_SHIP_COLORS[waveOneIndex]);
expect(aetherShipColor(42, 1, 0, "armored")).toBe(AETHER_ARMORED_SHIP_COLORS[waveOneIndex]);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { AetherShipKind } from "../game/aetherAssault";
export const AETHER_STANDARD_SHIP_COLORS = [
"#20c8e8",
"#8a5cff",
"#f044b5",
"#31cf74",
"#f06b3c",
"#d6b91c",
] as const;
export const AETHER_ARMORED_SHIP_COLORS = [
"#9ef5ff",
"#d0b8ff",
"#ff9bdc",
"#9af0b8",
"#ffb088",
"#ffe976",
] as const;
export function aetherShipColorIndex(seed: number, wave: number, shipIndex: number) {
const normalizedSeed = Math.floor(Number.isFinite(seed) ? seed : 0) >>> 0;
const mixedSeed = (normalizedSeed ^ (normalizedSeed >>> 16)) >>> 0;
const normalizedWave = Math.max(1, Math.floor(Number.isFinite(wave) ? wave : 1));
const normalizedIndex = Math.max(0, Math.floor(Number.isFinite(shipIndex) ? shipIndex : 0));
return (mixedSeed + (normalizedWave - 1) * 3 + normalizedIndex * 5) % AETHER_STANDARD_SHIP_COLORS.length;
}
export function aetherShipColor(seed: number, wave: number, shipIndex: number, kind: AetherShipKind) {
const palette = kind === "armored" ? AETHER_ARMORED_SHIP_COLORS : AETHER_STANDARD_SHIP_COLORS;
return palette[aetherShipColorIndex(seed, wave, shipIndex)];
}
+215
View File
@@ -0,0 +1,215 @@
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import type { PoolTelegraphKind, WorldPosition } from "../../game/types";
const TAU = Math.PI * 2;
const LANE_PARTICLE_COUNT = 7;
const AREA_PARTICLE_COUNT = 8;
const BREATH_PARTICLE_COUNT = 12;
function useReducedMotion() {
return useMemo(() => (
document.documentElement.classList.contains("force-reduced-motion")
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
), []);
}
export type LaneVfxVariant = "beam" | "charge" | "slash" | "web";
function LaneParticleGeometry({ variant }: { variant: LaneVfxVariant }) {
if (variant === "beam" || variant === "web") return <sphereGeometry args={[0.11, 7, 5]} />;
if (variant === "slash") return <octahedronGeometry args={[0.15, 0]} />;
return <tetrahedronGeometry args={[0.13, 0]} />;
}
/** One instanced draw for motion/readability layered over an existing lane telegraph. */
export function LaneEnergyVfx({
start,
end,
width,
color,
active,
variant,
height = 0.13,
}: {
start: WorldPosition;
end: WorldPosition;
width: number;
color: string;
active: boolean;
variant: LaneVfxVariant;
height?: number;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
const midpoint: [number, number, number] = [(start[0] + end[0]) * 0.5, height, (start[1] + end[1]) * 0.5];
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.35 : clock.elapsedTime;
const speed = active ? 2.6 : variant === "web" ? 0.5 : 0.85;
for (let index = 0; index < LANE_PARTICLE_COUNT; index += 1) {
const offset = index / LANE_PARTICLE_COUNT;
const cycle = (time * speed + offset) % 1;
const cross = Math.sin(time * 2.4 + index * 1.7) * width * (variant === "web" ? 0.08 : 0.28);
const rise = variant === "slash"
? Math.sin(cycle * Math.PI) * (active ? 0.65 : 0.28)
: variant === "web" ? Math.sin(time * 3 + index) * 0.07 : Math.sin(cycle * Math.PI) * 0.18;
transform.position.set(cross, rise, -length * 0.5 + cycle * length);
transform.rotation.set(time * 0.8 + index, index * 0.63, active ? time * 1.5 : 0);
const scale = (active ? 1.25 : 0.78) * (0.75 + Math.sin(cycle * Math.PI) * 0.4);
transform.scale.set(
variant === "slash" ? scale * 0.7 : scale,
variant === "charge" ? scale * 0.55 : scale,
variant === "charge" ? scale * 2.1 : scale,
);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) {
const pulse = reducedMotion ? 0.65 : 0.58 + Math.sin(time * 6.2) * 0.14;
material.current.opacity = active ? pulse + 0.22 : pulse * 0.62;
}
});
return (
<group position={midpoint} rotation={[0, angle, 0]}>
<instancedMesh ref={particles} args={[undefined, undefined, LANE_PARTICLE_COUNT]} frustumCulled={false}>
<LaneParticleGeometry variant={variant} />
<meshBasicMaterial
ref={material}
color={color}
transparent
opacity={active ? 0.82 : 0.38}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
</group>
);
}
export function BreathParticleVfx({
position,
angle,
range,
halfAngle,
active,
}: {
position: WorldPosition;
angle: number;
range: number;
halfAngle: number;
active: boolean;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.4 : clock.elapsedTime;
for (let index = 0; index < BREATH_PARTICLE_COUNT; index += 1) {
const row = index % 4;
const lane = Math.floor(index / 4);
const offsetAngle = -halfAngle + (lane / 2) * halfAngle * 2;
const cycle = (time * (active ? 1.7 : 0.52) + row * 0.24 + lane * 0.08) % 1;
const distance = range * (0.08 + cycle * 0.9);
const scale = (active ? 1 : 0.55) * (1.15 - cycle * 0.55);
transform.position.set(
Math.sin(offsetAngle) * distance,
0.18 + Math.sin(cycle * Math.PI) * (active ? 0.72 : 0.32),
Math.cos(offsetAngle) * distance,
);
transform.rotation.set(time * 1.2 + index, -offsetAngle, cycle * TAU);
transform.scale.set(scale * 0.72, scale * 1.45, scale * 0.72);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) material.current.opacity = active ? 0.74 : 0.26;
});
return (
<group position={[position[0], 0.1, position[1]]} rotation={[0, angle, 0]}>
<instancedMesh ref={particles} args={[undefined, undefined, BREATH_PARTICLE_COUNT]} frustumCulled={false}>
<tetrahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial
ref={material}
color={active ? "#7ceaff" : "#b8f4ff"}
transparent
opacity={active ? 0.74 : 0.26}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
</group>
);
}
export function CircleMechanicVfx({
radius,
innerRadius = 0,
kind,
color,
active,
}: {
radius: number;
innerRadius?: number;
kind: Extract<PoolTelegraphKind, "donut" | "soak" | "spread"> | "pounce";
color: string;
active: boolean;
}) {
const particles = useRef<THREE.InstancedMesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const reducedMotion = useReducedMotion();
useFrame(({ clock }) => {
if (!particles.current) return;
const time = reducedMotion ? 0.3 : clock.elapsedTime;
for (let index = 0; index < AREA_PARTICLE_COUNT; index += 1) {
const offset = index / AREA_PARTICLE_COUNT;
const cycle = (time * (active ? 1.2 : 0.55) + offset) % 1;
const orbit = offset * TAU + time * (kind === "soak" ? -0.55 : 0.7);
let radial = radius * 0.82;
if (kind === "spread") radial = radius * (0.18 + cycle * 0.75);
if (kind === "soak" || kind === "pounce") radial = radius * (0.92 - cycle * 0.68);
if (kind === "donut") radial = index % 2 === 0 ? Math.max(0.2, innerRadius + 0.18) : radius - 0.18;
const scale = (active ? 1 : 0.64) * (0.72 + Math.sin(cycle * Math.PI) * 0.48);
transform.position.set(Math.sin(orbit) * radial, 0.12 + Math.sin(cycle * Math.PI) * 0.52, Math.cos(orbit) * radial);
transform.rotation.set(time + index, orbit, cycle * TAU);
transform.scale.setScalar(scale);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
if (material.current) material.current.opacity = active ? 0.84 : 0.42;
});
return (
<instancedMesh ref={particles} args={[undefined, undefined, AREA_PARTICLE_COUNT]} frustumCulled={false}>
<octahedronGeometry args={[0.16, 0]} />
<meshBasicMaterial
ref={material}
color={color}
transparent
opacity={active ? 0.84 : 0.42}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
);
}
+167 -62
View File
@@ -3,10 +3,19 @@ import { Html } from "@react-three/drei";
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
import * as THREE from "three";
import { BULL_CHARGE, BULL_POUNCE, MEMORY_SEQUENCE, MEMORY_SYMBOLS, SKY_SWEEPER_BREATH } from "../../game/bosses/mechanicPool";
import { bossHazardVfxProfile } from "../../game/bossHazardVisuals";
import { angleTo } from "../../game/geometry";
import { useGameStore } from "../../game/store";
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
import { BOSS_INDICATOR_DEATH_FADE_MS, advanceBossIndicatorOpacity } from "./bossDeathVisuals";
import { BreathParticleVfx, CircleMechanicVfx, LaneEnergyVfx } from "./BossAttackVfx";
import {
BOSS_INDICATOR_DEATH_FADE_MS,
advanceBossIndicatorOpacity,
consumeBossIndicatorPatch,
createBossIndicatorPatchState,
updateBossIndicatorPatchState,
} from "./bossDeathVisuals";
import { CircleHazardVfx } from "./CircleHazardVfx";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
@@ -41,6 +50,10 @@ function bossHpAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 ? state.boss.hp : state.additionalBosses[bossIndex - 1]?.boss.hp ?? 0;
}
function bossAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss;
}
function earliestSoulSiphonInMotion(motion: BossMotionState | undefined, current?: SoulSiphonState) {
if (!motion) return current;
let earliest = current;
@@ -192,26 +205,37 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex:
(lane.start[1] + lane.end[1]) * 0.5,
];
const active = ACTIVE_LANE_MODES.has(motion.mode);
const chargeEffect = motion.mode === "telegraph" || motion.mode === "charging";
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={midpoint} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[lane.width, length]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
<boxGeometry args={[0.07, 0.03, length]} />
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
<>
<group position={midpoint} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[lane.width, length]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
</mesh>
))}
{active && (
<mesh position={[0, 0.055, 0]}>
<boxGeometry args={[0.16, 0.08, length]} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
</mesh>
)}
</group>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
<boxGeometry args={[0.07, 0.03, length]} />
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
</mesh>
))}
{active && (
<mesh position={[0, 0.055, 0]}>
<boxGeometry args={[0.16, 0.08, length]} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
</mesh>
)}
</group>
<LaneEnergyVfx
start={lane.start}
end={lane.end}
width={lane.width}
color={active ? DANGER_HIGHLIGHT_COLOR : color}
active={active}
variant={chargeEffect ? "charge" : "slash"}
/>
</>
);
}
@@ -232,9 +256,10 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
if (!warningMaterial.current) return;
warningMaterial.current.opacity = 0.14 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.08;
});
if (phase !== "combat" || motionMode !== "stacking") return null;
if (phase !== "combat" || (motionMode !== "stacking" && motionMode !== "pouncing")) return null;
const radius = BULL_POUNCE.stackRadius;
const active = motionMode === "pouncing";
return (
<group ref={group}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
@@ -261,6 +286,7 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
</mesh>
</group>
))}
<CircleMechanicVfx radius={radius} kind="pounce" color={active ? "#ffe0a8" : "#ff7278"} active={active} />
</group>
);
}
@@ -291,6 +317,7 @@ export function BindingWebIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
</mesh>
))}
<LaneEnergyVfx start={first} end={second} width={0.34} color={color} active={stretched} variant="web" height={0.2} />
</group>
);
}
@@ -305,16 +332,25 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
if (!motion || phase !== "combat" || (motion.mode !== "breath_telegraph" && motion.mode !== "breath_sweeping")) return null;
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
</mesh>
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
</mesh>
</group>
<>
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
</mesh>
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
</mesh>
</group>
<BreathParticleVfx
position={motion.position}
angle={motion.breathAngle}
range={SKY_SWEEPER_BREATH.range}
halfAngle={SKY_SWEEPER_BREATH.halfAngle}
active={motion.mode === "breath_sweeping"}
/>
</>
);
}
@@ -328,19 +364,8 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
});
if (!hazard) return null;
const active = time >= hazard.activatesAt;
const colors = {
venom_pool: "#a94ee6",
skyfall: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
quake: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
lava_pool: "#ff5a24",
stinger_eruption: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
hourglass: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
tidal_burst: active ? DANGER_ACTIVE_COLOR : "#39c9df",
soul_rift: active ? "#7446d8" : "#aa78ff",
crownfall: active ? DANGER_ACTIVE_COLOR : "#e4c548",
royal_shockwave: active ? DANGER_ACTIVE_COLOR : "#f0ca4d",
} as const;
const color = colors[hazard.kind];
const visual = bossHazardVfxProfile(hazard.kind);
const color = active ? visual.activeColor : visual.warningColor;
return (
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
@@ -358,9 +383,10 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
{!active && (
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.22, 0.34, 24]} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.95} depthWrite={false} />
<meshBasicMaterial color={visual.highlightColor} transparent opacity={0.95} depthWrite={false} />
</mesh>
)}
<CircleHazardVfx hazard={hazard} active={active} />
</group>
);
}
@@ -639,30 +665,41 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
: telegraph.kind === "spread" ? "#e869ff"
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
if (telegraph.kind === "beam") {
if (!telegraph.start || !telegraph.end) return null;
const dx = telegraph.end[0] - telegraph.start[0];
const dz = telegraph.end[1] - telegraph.start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
return (
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[telegraph.width ?? 1, length]} />
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
<boxGeometry args={[0.06, 0.035, length]} />
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
<>
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[telegraph.width ?? 1, length]} />
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
</mesh>
))}
{CHARGE_MARKERS.map((index) => (
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
</mesh>
))}
</group>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
<boxGeometry args={[0.06, 0.035, length]} />
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
</mesh>
))}
{CHARGE_MARKERS.map((index) => (
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
</mesh>
))}
</group>
<LaneEnergyVfx
start={telegraph.start}
end={telegraph.end}
width={telegraph.width ?? 1}
color={active ? "#ffe1b3" : color}
active={active}
variant="beam"
/>
</>
);
}
@@ -705,6 +742,13 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
</mesh>
</group>
))}
<CircleMechanicVfx
radius={telegraph.radius}
innerRadius={innerRadius}
kind={telegraph.kind}
color={color}
active={active}
/>
</group>
);
}
@@ -714,7 +758,60 @@ export function PooledMechanicIndicators({ bossIndex = 0 }: { bossIndex?: number
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
}
export function BasicMeleeVfx({ bossIndex = 0 }: { bossIndex?: number }) {
const group = useRef<THREE.Group>(null);
const slashMaterial = useRef<THREE.MeshBasicMaterial>(null);
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
const initialBoss = bossAt(useGameStore.getState(), bossIndex);
const previousNextMeleeAt = useRef(initialBoss?.nextMeleeAt ?? 0);
const age = useRef(Number.POSITIVE_INFINITY);
useFrame((_, delta) => {
const state = useGameStore.getState();
const boss = bossAt(state, bossIndex);
const motion = motionAt(state, bossIndex);
if (!group.current || !boss || !motion) return;
if (boss.nextMeleeAt !== previousNextMeleeAt.current) {
if (state.phase === "combat" && motion.mode === "holding") age.current = 0;
previousNextMeleeAt.current = boss.nextMeleeAt;
}
age.current += delta;
const visible = state.phase === "combat" && age.current < 0.42 && state.party[1].hp > 0;
group.current.visible = visible;
if (!visible) return;
const target = state.partyPositions.brann;
const progress = THREE.MathUtils.clamp(age.current / 0.42, 0, 1);
group.current.position.set(target[0], 1.02, target[1]);
group.current.rotation.y = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
group.current.scale.setScalar(0.68 + progress * 0.72);
if (slashMaterial.current) slashMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.92;
if (impactMaterial.current) impactMaterial.current.opacity = (1 - progress) * 0.68;
});
return (
<group ref={group} visible={false}>
<mesh rotation={[0, 0, -Math.PI * 0.72]}>
<torusGeometry args={[0.7, 0.055, 6, 28, Math.PI * 1.35]} />
<meshBasicMaterial
ref={slashMaterial}
color="#ffd58a"
transparent
opacity={0}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
<mesh position={[0, -0.94, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.32, 0.5, 24]} />
<meshBasicMaterial ref={impactMaterial} color="#ff8d5c" transparent opacity={0} depthWrite={false} />
</mesh>
</group>
);
}
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
BasicMeleeVfx,
ChargeLaneIndicator,
SlashLaneIndicators,
PounceStackIndicator,
@@ -730,6 +827,7 @@ function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) {
const group = useRef<THREE.Group>(null);
const opacity = useRef(defeated ? 0 : 1);
const patchedMeshes = useRef(new WeakSet<THREE.Mesh>());
const patchState = useRef(createBossIndicatorPatchState(defeated));
const [retainIndicators, setRetainIndicators] = useState(!defeated);
const patchMeshOpacity = useCallback((child: THREE.Object3D) => {
@@ -761,11 +859,18 @@ function BossMechanicIndicatorSet({ bossIndex }: { bossIndex: number }) {
return () => window.clearTimeout(timeout);
}, [defeated]);
useLayoutEffect(() => {
patchState.current = updateBossIndicatorPatchState(patchState.current, defeated);
}, [defeated]);
useFrame((_, delta) => {
if (!group.current) return;
if (patchState.current.pending) {
group.current.traverse(patchMeshOpacity);
patchState.current = consumeBossIndicatorPatch(patchState.current);
}
opacity.current = advanceBossIndicatorOpacity(opacity.current, defeated, delta);
group.current.visible = opacity.current > 0;
group.current.traverse(patchMeshOpacity);
});
return (
+198
View File
@@ -0,0 +1,198 @@
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import { bossHazardVfxProfile, bossHazardVfxSeed, type BossHazardVfxShape } from "../../game/bossHazardVisuals";
import { useGameStore } from "../../game/store";
import type { CircleHazard } from "../../game/types";
const TAU = Math.PI * 2;
function ParticleGeometry({ shape }: { shape: BossHazardVfxShape }) {
if (shape === "orb") return <sphereGeometry args={[0.16, 8, 6]} />;
if (shape === "spike") return <coneGeometry args={[0.16, 0.9, 5]} />;
return <octahedronGeometry args={[0.19, 0]} />;
}
/**
* One instanced particle draw plus one transient accent and an optional column.
* Effects mutate preallocated transforms and never touch combat state.
*/
export function CircleHazardVfx({ hazard, active }: { hazard: CircleHazard; active: boolean }) {
const profile = bossHazardVfxProfile(hazard.kind);
const particles = useRef<THREE.InstancedMesh>(null);
const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
const warningRing = useRef<THREE.Mesh>(null);
const warningMaterial = useRef<THREE.MeshBasicMaterial>(null);
const impactRing = useRef<THREE.Mesh>(null);
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
const column = useRef<THREE.Mesh>(null);
const columnMaterial = useRef<THREE.MeshBasicMaterial>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
const seed = useMemo(() => bossHazardVfxSeed(hazard.id), [hazard.id]);
const reducedMotion = useMemo(() => (
document.documentElement.classList.contains("force-reduced-motion")
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
), []);
useFrame(({ clock }) => {
const time = useGameStore.getState().time;
const activeAge = Math.max(0, time - hazard.activatesAt);
const warningRemaining = Math.max(0, hazard.activatesAt - time);
const effectTime = reducedMotion ? seed * 2 : clock.elapsedTime + seed * 7.3;
const activeDuration = Math.max(0.22, hazard.expiresAt - hazard.activatesAt);
const impactDuration = Math.min(0.48, activeDuration);
const impactProgress = THREE.MathUtils.clamp(activeAge / impactDuration, 0, 1);
if (particles.current) {
for (let index = 0; index < profile.particleCount; index += 1) {
const offset = (index + 0.5) / profile.particleCount;
const baseAngle = offset * TAU + seed * TAU;
const angle = baseAngle + effectTime * profile.spinSpeed;
const stagger = (offset + seed * 0.37) % 1;
let radial = hazard.radius * (0.28 + (index % 3) * 0.2);
let y = 0.12;
let width = 0.72;
let height = 0.72;
if (profile.motion === "bubble") {
const cycle = (effectTime * 0.48 + stagger) % 1;
radial = hazard.radius * (0.22 + (index % 3) * 0.22);
y = 0.08 + cycle * profile.height;
width = 0.55 + Math.sin(cycle * Math.PI) * 0.65;
height = width;
} else if (profile.motion === "fall") {
const cycle = 1 - ((effectTime * 0.72 + stagger) % 1);
radial = hazard.radius * (0.18 + (index % 3) * 0.2);
y = 0.22 + cycle * profile.height;
width = active ? 0.82 : 0.55;
height = active ? 2.9 : 2.15;
} else if (profile.motion === "flame") {
const cycle = (effectTime * 0.95 + stagger) % 1;
radial = hazard.radius * (0.2 + (index % 3) * 0.21);
y = 0.12 + cycle * profile.height * 0.62;
width = 0.5 + Math.sin(cycle * Math.PI) * 0.85;
height = 0.75 + Math.sin(cycle * Math.PI) * 1.6;
} else if (profile.motion === "orbit") {
radial = hazard.radius * (0.3 + (index % 2) * 0.24);
y = 0.28 + ((index % 4) / 3) * profile.height + Math.sin(effectTime * 2.2 + index) * 0.16;
width = 0.65 + (index % 2) * 0.35;
height = profile.shape === "crystal" ? 1.55 : width;
} else if (profile.motion === "shockwave") {
const wave = active ? impactProgress : 0.22 + (1 - Math.min(1, warningRemaining / 1.6)) * 0.18;
radial = hazard.radius * wave;
y = 0.13 + Math.sin(offset * Math.PI) * profile.height * 0.12;
width = active ? 0.72 + impactProgress * 0.75 : 0.58;
height = active ? 1.25 : 0.75;
} else if (profile.motion === "spike") {
const rise = active ? THREE.MathUtils.clamp(activeAge / 0.16, 0, 1) : 0.12;
radial = hazard.radius * (0.2 + (index % 3) * 0.23);
width = 0.75 + (index % 2) * 0.35;
height = Math.max(0.18, rise * (profile.height + (index % 3) * 0.34));
y = height * 0.45;
} else {
const cycle = (effectTime * 0.72 + stagger) % 1;
radial = hazard.radius * (0.18 + cycle * 0.68);
y = 0.12 + Math.sin(cycle * Math.PI) * profile.height;
width = 0.6 + Math.sin(cycle * Math.PI) * 0.72;
height = width * 1.4;
}
const warningScale = active ? 1 : 0.56;
transform.position.set(Math.sin(angle) * radial, y, Math.cos(angle) * radial);
transform.rotation.set(index * 0.73, -angle, profile.motion === "fall" ? 0 : effectTime * 0.45 + index);
transform.scale.set(width * warningScale, height * warningScale, width * warningScale);
transform.updateMatrix();
particles.current.setMatrixAt(index, transform.matrix);
}
particles.current.instanceMatrix.needsUpdate = true;
}
if (particleMaterial.current) {
const pulse = reducedMotion ? 0.72 : 0.64 + Math.sin(effectTime * 4.6) * 0.12;
particleMaterial.current.opacity = active ? pulse + 0.16 : pulse * 0.48;
}
if (warningRing.current && warningMaterial.current) {
const countdown = THREE.MathUtils.clamp(warningRemaining / 1.8, 0, 1);
const scale = hazard.radius * (0.18 + countdown * 0.82);
warningRing.current.visible = !active;
warningRing.current.scale.setScalar(scale);
warningRing.current.rotation.z = reducedMotion ? seed * TAU : effectTime * profile.spinSpeed * 0.42;
warningMaterial.current.opacity = 0.48 + (reducedMotion ? 0 : Math.sin(effectTime * 7) * 0.16);
}
if (impactRing.current && impactMaterial.current) {
impactRing.current.visible = active && impactProgress < 0.999;
impactRing.current.scale.setScalar(hazard.radius * (0.16 + impactProgress * 1.02));
impactMaterial.current.opacity = (1 - impactProgress) * 0.92;
}
if (column.current && columnMaterial.current) {
const columnPulse = reducedMotion ? 0.78 : 0.7 + Math.sin(effectTime * 3.8) * 0.18;
column.current.rotation.y = effectTime * profile.spinSpeed * 0.3;
column.current.scale.set(
hazard.radius * (active ? 0.9 : 0.62),
profile.height * (active ? 1 : 0.72),
hazard.radius * (active ? 0.9 : 0.62),
);
columnMaterial.current.opacity = profile.columnOpacity * columnPulse * (active ? 1 : 0.52);
}
});
const color = active ? profile.activeColor : profile.warningColor;
return (
<group>
<instancedMesh ref={particles} args={[undefined, undefined, profile.particleCount]} frustumCulled={false}>
<ParticleGeometry shape={profile.shape} />
<meshBasicMaterial
ref={particleMaterial}
color={color}
transparent
opacity={active ? 0.8 : 0.35}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</instancedMesh>
<mesh ref={warningRing} position={[0, 0.085, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<torusGeometry args={[1, 0.026, 5, 36]} />
<meshBasicMaterial
ref={warningMaterial}
color={profile.highlightColor}
transparent
opacity={0.58}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
<mesh ref={impactRing} position={[0, 0.12, 0]} rotation={[-Math.PI / 2, 0, 0]} visible={false}>
<ringGeometry args={[0.84, 1, 40]} />
<meshBasicMaterial
ref={impactMaterial}
color={profile.highlightColor}
transparent
opacity={0}
depthWrite={false}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
{profile.columnOpacity > 0 && (
<mesh ref={column} position={[0, profile.height * 0.5, 0]}>
<cylinderGeometry args={[0.48, 0.16, 1, 18, 1, true]} />
<meshBasicMaterial
ref={columnMaterial}
color={color}
transparent
opacity={profile.columnOpacity}
depthWrite={false}
side={THREE.DoubleSide}
blending={THREE.AdditiveBlending}
toneMapped={false}
/>
</mesh>
)}
</group>
);
}
@@ -6,7 +6,12 @@ import {
BOSS_DEATH_HOLD_SECONDS,
advanceBossIndicatorOpacity,
bossCanTrackTarget,
bossDeathDespawnSeconds,
bossDeathHoldSeconds,
bossDeathOpacity,
consumeBossIndicatorPatch,
createBossIndicatorPatchState,
updateBossIndicatorPatchState,
} from "./bossDeathVisuals";
describe("boss death visuals", () => {
@@ -23,9 +28,32 @@ describe("boss death visuals", () => {
expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1);
});
it("patches indicator meshes once per defeat, including a replacement boss", () => {
let state = createBossIndicatorPatchState(false);
expect(state.pending).toBe(false);
state = updateBossIndicatorPatchState(state, true);
expect(state.pending).toBe(true);
state = consumeBossIndicatorPatch(state);
expect(state.pending).toBe(false);
expect(updateBossIndicatorPatchState(state, true)).toBe(state);
state = updateBossIndicatorPatchState(state, false);
expect(state.pending).toBe(false);
state = updateBossIndicatorPatchState(state, true);
expect(state.pending).toBe(true);
});
it("holds the death pose for a few seconds, then fades the model", () => {
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS)).toBe(1);
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS / 2)).toBeCloseTo(0.5);
expect(bossDeathOpacity(BOSS_DEATH_DESPAWN_SECONDS)).toBe(0);
});
it("keeps Gravehorn visible through its full authored fall", () => {
const hold = bossDeathHoldSeconds("gravehorn-triceratops");
expect(hold).toBeGreaterThan(5.73);
expect(bossDeathOpacity(5.73, "gravehorn-triceratops")).toBe(1);
expect(bossDeathOpacity(bossDeathDespawnSeconds("gravehorn-triceratops"), "gravehorn-triceratops")).toBe(0);
});
});
+23
View File
@@ -2,11 +2,34 @@ export {
BOSS_DEATH_DESPAWN_SECONDS,
BOSS_DEATH_FADE_SECONDS,
BOSS_DEATH_HOLD_SECONDS,
bossDeathDespawnSeconds,
bossDeathHoldSeconds,
bossDeathOpacity,
} from "../../game/bossDeath";
export const BOSS_INDICATOR_DEATH_FADE_MS = 250;
export interface BossIndicatorPatchState {
defeated: boolean;
pending: boolean;
}
export function createBossIndicatorPatchState(defeated: boolean): BossIndicatorPatchState {
return { defeated, pending: defeated };
}
export function updateBossIndicatorPatchState(
state: BossIndicatorPatchState,
defeated: boolean,
): BossIndicatorPatchState {
if (state.defeated === defeated) return state;
return { defeated, pending: defeated };
}
export function consumeBossIndicatorPatch(state: BossIndicatorPatchState): BossIndicatorPatchState {
return state.pending ? { ...state, pending: false } : state;
}
export function bossCanTrackTarget(hp: number) {
return hp > 0;
}
@@ -0,0 +1,171 @@
import { useFrame } from "@react-three/fiber";
import { useRef } from "react";
import * as THREE from "three";
import { ARENA_CENTER, ARENA_WALL_RADIUS } from "../../game/arena";
import { BOSS_ARENA_PORTAL_HALF_WIDTH } from "../../game/rpgRoguelike/playSpace";
export interface RpgRoomPortalsProps {
entryOpen: boolean;
exitOpen: boolean;
accent: string;
wallColor: string;
}
interface PortalDoorwayProps {
open: boolean;
accent: string;
wallColor: string;
position: readonly [number, number, number];
rotationY: number;
hideNearCamera?: boolean;
}
const DOOR_HEIGHT = 3.25;
const DOOR_DEPTH = 0.24;
const FRAME_DEPTH = 0.72;
const FRAME_POST_WIDTH = 0.48;
const OPEN_ANGLE = Math.PI * 0.56;
const OPEN_DAMPING = 8;
const CAMERA_HIDE_RADIUS = 3.6;
function DoorLeaf({ side, accent, wallColor }: {
side: "left" | "right";
accent: string;
wallColor: string;
}) {
const leafWidth = BOSS_ARENA_PORTAL_HALF_WIDTH;
const centerX = side === "left" ? leafWidth * 0.5 : -leafWidth * 0.5;
return (
<group position={[centerX, DOOR_HEIGHT * 0.5, 0]}>
<mesh castShadow receiveShadow>
<boxGeometry args={[leafWidth - 0.035, DOOR_HEIGHT, DOOR_DEPTH]} />
<meshStandardMaterial
color={wallColor}
emissive={accent}
emissiveIntensity={0.08}
metalness={0.36}
roughness={0.62}
/>
</mesh>
{[-0.9, 0, 0.9].map((y) => (
<mesh key={y} position={[0, y, DOOR_DEPTH * 0.56]}>
<boxGeometry args={[leafWidth - 0.12, 0.09, 0.055]} />
<meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={0.4} metalness={0.7} roughness={0.3} />
</mesh>
))}
</group>
);
}
function PortalDoorway({
open,
accent,
wallColor,
position,
rotationY,
hideNearCamera = false,
}: PortalDoorwayProps) {
const root = useRef<THREE.Group>(null);
const leftHinge = useRef<THREE.Group>(null);
const rightHinge = useRef<THREE.Group>(null);
useFrame(({ camera }, delta) => {
if (root.current) {
const distanceToCamera = Math.hypot(
camera.position.x - position[0],
camera.position.z - position[2],
);
root.current.visible = !hideNearCamera || distanceToCamera >= CAMERA_HIDE_RADIUS;
}
if (!leftHinge.current || !rightHinge.current) return;
const reducedMotion = typeof document !== "undefined"
&& document.documentElement.classList.contains("force-reduced-motion");
const leftTarget = open ? OPEN_ANGLE : 0;
const rightTarget = -leftTarget;
if (reducedMotion) {
leftHinge.current.rotation.y = leftTarget;
rightHinge.current.rotation.y = rightTarget;
return;
}
leftHinge.current.rotation.y = THREE.MathUtils.damp(
leftHinge.current.rotation.y,
leftTarget,
OPEN_DAMPING,
delta,
);
rightHinge.current.rotation.y = THREE.MathUtils.damp(
rightHinge.current.rotation.y,
rightTarget,
OPEN_DAMPING,
delta,
);
});
const postX = BOSS_ARENA_PORTAL_HALF_WIDTH + FRAME_POST_WIDTH * 0.5;
return (
<group ref={root} position={position} rotation={[0, rotationY, 0]}>
{([-1, 1] as const).map((side) => (
<mesh key={side} position={[side * postX, DOOR_HEIGHT * 0.55, 0]} castShadow receiveShadow>
<boxGeometry args={[FRAME_POST_WIDTH, DOOR_HEIGHT * 1.1, FRAME_DEPTH]} />
<meshStandardMaterial color={wallColor} roughness={0.82} metalness={0.08} />
</mesh>
))}
<mesh position={[0, DOOR_HEIGHT, 0]} castShadow receiveShadow>
<torusGeometry args={[BOSS_ARENA_PORTAL_HALF_WIDTH + 0.04, 0.24, 6, 20, Math.PI]} />
<meshStandardMaterial color={wallColor} roughness={0.78} metalness={0.1} />
</mesh>
<mesh position={[0, DOOR_HEIGHT + BOSS_ARENA_PORTAL_HALF_WIDTH + 0.06, 0.03]} rotation={[0, 0, Math.PI / 4]} castShadow>
<octahedronGeometry args={[0.34, 0]} />
<meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={0.65} metalness={0.42} roughness={0.28} />
</mesh>
<mesh position={[0, 0.055, 0]} receiveShadow>
<boxGeometry args={[BOSS_ARENA_PORTAL_HALF_WIDTH * 2 + FRAME_POST_WIDTH, 0.11, FRAME_DEPTH]} />
<meshStandardMaterial color={wallColor} roughness={0.88} />
</mesh>
<group ref={leftHinge} position={[-BOSS_ARENA_PORTAL_HALF_WIDTH, 0, 0]}>
<DoorLeaf side="left" accent={accent} wallColor={wallColor} />
</group>
<group ref={rightHinge} position={[BOSS_ARENA_PORTAL_HALF_WIDTH, 0, 0]}>
<DoorLeaf side="right" accent={accent} wallColor={wallColor} />
</group>
<group visible={!open} position={[0, DOOR_HEIGHT * 0.52, DOOR_DEPTH * 0.76]}>
<mesh castShadow>
<boxGeometry args={[BOSS_ARENA_PORTAL_HALF_WIDTH * 1.3, 0.16, 0.12]} />
<meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={0.48} metalness={0.72} roughness={0.24} />
</mesh>
<mesh position={[0, 0, 0.1]} rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.28, 0]} />
<meshBasicMaterial color={accent} toneMapped={false} />
</mesh>
</group>
</group>
);
}
/**
* Projects the linear RPG route through the boss arena: entry is the south
* portal (+Z), while the next-room exit is the north portal (-Z).
*/
export function RpgRoomPortals({ entryOpen, exitOpen, accent, wallColor }: RpgRoomPortalsProps) {
return (
<group>
<PortalDoorway
open={exitOpen}
accent={accent}
wallColor={wallColor}
position={[ARENA_CENTER[0], 0, ARENA_CENTER[1] - ARENA_WALL_RADIUS]}
rotationY={0}
/>
<PortalDoorway
open={entryOpen}
accent={accent}
wallColor={wallColor}
position={[ARENA_CENTER[0], 0, ARENA_CENTER[1] + ARENA_WALL_RADIUS]}
rotationY={Math.PI}
hideNearCamera
/>
</group>
);
}
@@ -0,0 +1,431 @@
import { useEffect, useRef, type KeyboardEvent } from "react";
import { HEALER_ABILITIES } from "../../game/healers";
import {
ABILITY_LOADOUT_SLOTS,
MAX_ACTIVE_ROSTER,
MAX_EQUIPPED_SPELLS,
PARTY_DRAFT_WAVE_COUNT,
PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE,
rpgFocusId,
} from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
import {
ChallengeObjective,
createUiContext,
currentBoss,
FocusButton,
GearCard,
PartyCard,
rewardSummary,
RoutePips,
RunHeader,
runAccentStyle,
SpellCard,
type RpgRunUiContext,
type RpgRunUiProps,
} from "./RpgRunUiShared";
import "./rpgRoguelike.css";
function DraftFooter({ context, focusId, action, disabled, label, hint }: {
readonly context: RpgRunUiContext;
readonly focusId: string;
readonly action: Parameters<RpgRunUiProps["onAction"]>[0];
readonly disabled: boolean;
readonly label: string;
readonly hint: string;
}) {
return (
<footer className="rpg-draft-footer">
<span>{hint}</span>
<FocusButton
context={context}
focusId={focusId}
command={{ type: "run-action", action }}
className="rpg-primary-action"
disabled={disabled}
>
{label}<b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
</FocusButton>
</footer>
);
}
function PartyDraft({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const draft = run.partyDraft!;
const canRemove = draft.waveIndex > 0;
const canRecruit = run.roster.length < MAX_ACTIVE_ROSTER
&& draft.recruitedThisWaveIds.length < PARTY_RECRUITS_PER_WAVE;
const finalWave = draft.waveIndex === PARTY_DRAFT_WAVE_COUNT - 1;
const canContinue = finalWave
? run.roster.length === MAX_ACTIVE_ROSTER && run.roster.some((member) => member.role === "Tank")
: run.roster.length + (PARTY_DRAFT_WAVE_COUNT - draft.waveIndex - 1) * PARTY_RECRUITS_PER_WAVE >= MAX_ACTIVE_ROSTER;
return (
<section className="rpg-phase-panel rpg-party-draft" aria-labelledby="rpg-party-draft-title">
<div className="rpg-phase-title">
<div><small>Draft {draft.waveIndex + 1} / {PARTY_DRAFT_WAVE_COUNT}</small><h2 id="rpg-party-draft-title">Assemble your party</h2></div>
<p>Recruit up to {PARTY_RECRUITS_PER_WAVE} this wave. Four companions enter each room.</p>
<b>{draft.recruitedThisWaveIds.length}/{PARTY_RECRUITS_PER_WAVE} picked</b>
</div>
<div className="rpg-party-offer-grid">
{draft.offers.map((candidate) => {
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
const disabled = !recruited && !canRecruit;
const action = recruited
? { type: "party-remove", memberId: candidate.candidateId } as const
: { type: "party-recruit", candidateId: candidate.candidateId } as const;
return (
<PartyCard
key={candidate.candidateId}
candidate={candidate}
selected={recruited}
action={disabled ? <span className="rpg-card-status">Party full</span> : (
<FocusButton
context={context}
focusId={rpgFocusId.partyOffer(candidate.candidateId)}
command={{ type: "run-action", action }}
className="rpg-card-action"
disabled={recruited && !canRemove}
pressed={recruited}
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}`}
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
)}
/>
);
})}
</div>
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}`}>
<strong>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</strong>
{run.roster.map((member) => (
<FocusButton
key={member.instanceId}
context={context}
focusId={rpgFocusId.partyMember(member.instanceId)}
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className={`rpg-picked-chip rarity-${member.rarity}`}
disabled={!canRemove}
label={`Remove ${member.name}`}
><i style={{ background: member.color }} />{member.name}<small>{member.className}</small><b>×</b></FocusButton>
))}
{Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)}
</div>
<DraftFooter
context={context}
focusId={rpgFocusId.partyContinue}
action={{ type: "party-next-wave" }}
disabled={!canContinue}
label={finalWave ? "Lock Party" : "Reroll Offers"}
hint={finalWave && !canContinue ? `Recruit exactly ${MAX_ACTIVE_ROSTER} companions, including a tank.` : "Previous recruits stay; remove any companion before locking the run."}
/>
</section>
);
}
function SpellDraft({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const draft = run.spellDraft!;
const canRemove = draft.waveIndex > 0;
const canPick = run.selectedSpellIds.length < MAX_EQUIPPED_SPELLS
&& draft.pickedThisWaveIds.length < SPELL_PICKS_PER_WAVE;
const finalWave = draft.waveIndex === SPELL_DRAFT_WAVE_COUNT - 1;
const canContinue = !finalWave || run.selectedSpellIds.length > 0;
return (
<section className="rpg-phase-panel rpg-spell-draft" aria-labelledby="rpg-spell-draft-title">
<div className="rpg-phase-title">
<div><small>Draft {draft.waveIndex + 1} / {SPELL_DRAFT_WAVE_COUNT}</small><h2 id="rpg-spell-draft-title">Build your spellbook</h2></div>
<p>Learn up to {SPELL_PICKS_PER_WAVE} spells. Abilities from every enabled healer class can mix.</p>
<b>{draft.pickedThisWaveIds.length}/{SPELL_PICKS_PER_WAVE} picked</b>
</div>
<div className="rpg-spell-offer-grid">
{draft.offers.map((spellId) => {
const selected = run.selectedSpellIds.includes(spellId);
const disabled = !selected && !canPick;
const action = selected
? { type: "spell-remove", spellId } as const
: { type: "spell-pick", spellId } as const;
return (
<SpellCard
key={spellId}
spellId={spellId}
selected={selected}
action={disabled ? <span className="rpg-card-status">Wave cap reached</span> : (
<FocusButton
context={context}
focusId={rpgFocusId.spellOffer(spellId)}
command={{ type: "run-action", action }}
className="rpg-card-action"
disabled={selected && !canRemove}
pressed={selected}
label={`${selected ? "Remove" : "Learn"} ${HEALER_ABILITIES[spellId].name}`}
><span>{selected ? canRemove ? "Remove" : "Locked" : "Learn"}</span></FocusButton>
)}
/>
);
})}
</div>
<div className="rpg-spellbook-strip" aria-label={`Spellbook, ${run.selectedSpellIds.length} of ${MAX_EQUIPPED_SPELLS}`}>
<strong>Spellbook {run.selectedSpellIds.length}/{MAX_EQUIPPED_SPELLS}</strong>
{ABILITY_LOADOUT_SLOTS.map((slotId, index) => ({ slotId, index, spellId: run.abilityLoadout[slotId] }))
.filter((entry): entry is { slotId: typeof ABILITY_LOADOUT_SLOTS[number]; index: number; spellId: NonNullable<typeof entry.spellId> } => Boolean(entry.spellId))
.map(({ spellId, index }) => {
const spell = HEALER_ABILITIES[spellId];
return (
<FocusButton
key={spellId}
context={context}
focusId={rpgFocusId.spellSelected(spellId)}
command={{ type: "run-action", action: { type: "spell-remove", spellId } }}
className="rpg-spellbook-chip"
disabled={!canRemove}
style={runAccentStyle(spell.color)}
label={`Remove ${spell.name}`}
><b>{index + 1}</b><i>{spell.icon}</i><span>{spell.shortName}</span><small>×</small></FocusButton>
);
})}
</div>
<DraftFooter
context={context}
focusId={rpgFocusId.spellContinue}
action={{ type: "spell-next-wave" }}
disabled={!canContinue}
label={finalWave ? "Lock Spellbook" : "Reroll Spells"}
hint={draft.waveIndex > 0 ? "Remove drafted spells to make room for new magic." : "Each learned spell fills the next ability slot."}
/>
</section>
);
}
function Briefing({ context, kind }: { context: RpgRunUiContext; kind: "challenge" | "boss" }) {
const { run } = context;
const boss = currentBoss(run);
if (kind === "challenge") {
return (
<section className="rpg-phase-panel rpg-centered-panel" aria-labelledby="rpg-challenge-title">
<span className="rpg-hero-sigil"></span>
<small>Hallway challenge</small>
<h2 id="rpg-challenge-title">Prove the party before the next door</h2>
<ChallengeObjective run={run} />
<p className="rpg-soft-fail-copy">Failure does not end the run. Success adds gold and improves the next chest.</p>
<FocusButton context={context} focusId={rpgFocusId.challengeStart} command={{ type: "run-action", action: { type: "challenge-start" } }} className="rpg-primary-action rpg-large-action">
Start Challenge <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
</FocusButton>
</section>
);
}
return (
<section className="rpg-phase-panel rpg-centered-panel rpg-boss-briefing" style={boss ? runAccentStyle(boss.accent) : undefined} aria-labelledby="rpg-boss-title">
<span className="rpg-hero-sigil">{boss?.icon ?? "♛"}</span>
<small>{run.bossIndex === run.bossRoute.length - 1 ? "Final encounter" : "Boss chamber"}</small>
<h2 id="rpg-boss-title">{boss?.name ?? "Unknown Guardian"}</h2>
<h3>{boss?.title}</h3>
<p>{boss?.briefing}</p>
{run.lastChallengeResult && (
<div className={`rpg-challenge-result ${run.lastChallengeResult.succeeded ? "is-success" : "is-failure"}`}>
{run.lastChallengeResult.succeeded ? `Challenge cleared · +${run.lastChallengeResult.objective.rewardCurrency} gold · upgraded chest` : "Challenge missed · standard chest remains"}
</div>
)}
<FocusButton context={context} focusId={rpgFocusId.bossStart} command={{ type: "run-action", action: { type: "boss-start" } }} className="rpg-primary-action rpg-large-action">
Enter Chamber <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
</FocusButton>
</section>
);
}
function BossCleared({ context }: { context: RpgRunUiContext }) {
const boss = currentBoss(context.run);
return (
<section className="rpg-phase-panel rpg-cleared-panel" style={boss ? runAccentStyle(boss.accent) : undefined} aria-labelledby="rpg-cleared-title">
<div className="rpg-exit-arrow"><i /><span>North gate open</span></div>
<small>Boss defeated</small>
<h2 id="rpg-cleared-title">{boss?.name} has fallen</h2>
<p>Walk through the far door to claim the chest. Party health carries forward.</p>
<FocusButton context={context} focusId={rpgFocusId.rewardOpen} command={{ type: "run-action", action: { type: "reward-open" } }} className="rpg-primary-action rpg-large-action">
Open Chest <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
</FocusButton>
</section>
);
}
function Rewards({ context }: { context: RpgRunUiContext }) {
const chest = context.run.pendingReward;
if (!chest) return null;
return (
<section className="rpg-phase-panel rpg-reward-panel" aria-labelledby="rpg-reward-title">
<div className="rpg-phase-title">
<div><small>Chest quality +{chest.quality}</small><h2 id="rpg-reward-title">Choose one reward</h2></div>
<p>Rewards improve this run only. Gear auto-equips when stronger.</p>
</div>
<div className="rpg-reward-grid">
{chest.choices.map((choice) => {
const summary = rewardSummary(choice);
return (
<FocusButton
key={choice.id}
context={context}
focusId={rpgFocusId.rewardChoice(choice.id)}
command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }}
className="rpg-reward-card"
style={runAccentStyle(summary.accent)}
>
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p><b>Claim</b>
</FocusButton>
);
})}
</div>
<footer className="rpg-controller-hint"><b> / </b> Choose <i /> <b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Claim</footer>
</section>
);
}
function Shop({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const shop = run.shop;
if (!shop) return null;
const needsRest = run.playerHp > 0 && run.playerHp < 100
|| run.roster.some((member) => member.hp > 0 && member.hp < member.stats.maxHp);
const dead = run.roster.filter((member) => member.hp <= 0);
return (
<section className="rpg-phase-panel rpg-shop-panel" aria-labelledby="rpg-shop-title">
<div className="rpg-phase-title">
<div><small>Safe intermission</small><h2 id="rpg-shop-title">Wayfarer's Exchange</h2></div>
<p>Run gear auto-equips when stronger. Sell displaced gear from the bag.</p>
</div>
<div className="rpg-shop-layout">
<section><h3>Buy gear</h3><div className="rpg-shop-grid">
{shop.offers.map((offer) => {
const disabled = offer.sold || run.currency < offer.price;
return (
<GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={
<FocusButton
context={context}
focusId={rpgFocusId.shopOffer(offer.id)}
command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }}
className="rpg-card-action"
disabled={disabled}
label={offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price}`}
><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton>
} />
);
})}
</div></section>
<section><h3>Bag & services</h3><div className="rpg-shop-side-list">
{run.bag.length ? run.bag.map((item) => (
<FocusButton key={item.id} context={context} focusId={rpgFocusId.shopSell(item.id)} command={{ type: "run-action", action: { type: "shop-sell", itemId: item.id } }} className="rpg-shop-row">
<span><small>{item.slotId} · +{item.enhancement}</small>{item.name}</span><b>Sell {item.sellPrice}</b>
</FocusButton>
)) : <p className="rpg-empty-copy">Bag empty. Replaced gear appears here.</p>}
<FocusButton context={context} focusId={rpgFocusId.shopRest} command={{ type: "run-action", action: { type: "shop-rest" } }} className="rpg-shop-row" disabled={!needsRest || run.currency < shop.restCost}>
<span><small>Living members</small>Rest party</span><b> {shop.restCost}</b>
</FocusButton>
{dead.map((member) => (
<FocusButton key={member.instanceId} context={context} focusId={rpgFocusId.shopRevive(member.instanceId)} command={{ type: "run-action", action: { type: "shop-revive", memberId: member.instanceId } }} className="rpg-shop-row" disabled={run.currency < shop.reviveCost}>
<span><small>Return at 50% HP</small>Revive {member.name}</span><b> {shop.reviveCost}</b>
</FocusButton>
))}
</div></section>
</div>
<DraftFooter context={context} focusId={rpgFocusId.shopLeave} action={{ type: "shop-leave" }} disabled={false} label="Leave Shop" hint="Next act begins after leaving." />
</section>
);
}
function Terminal({ context }: { context: RpgRunUiContext }) {
const victory = context.run.phase === "victory";
return (
<section className={`rpg-phase-panel rpg-centered-panel rpg-terminal ${victory ? "is-victory" : "is-defeat"}`} aria-labelledby="rpg-terminal-title">
<span className="rpg-hero-sigil">{victory ? "♛" : "◇"}</span>
<small>{victory ? "Expedition complete" : "Run ended"}</small>
<h2 id="rpg-terminal-title">{victory ? "The gauntlet is conquered" : "The party has fallen"}</h2>
<p>{context.run.bossesDefeated} bosses defeated · {context.run.selectedSpellIds.length} spells drafted · {context.run.currency} remaining</p>
<div className="rpg-terminal-actions">
<FocusButton context={context} focusId={rpgFocusId.restartRun} command={{ type: "restart-run" }} className="rpg-primary-action rpg-large-action" disabled={!context.onRestartRun}>New Run</FocusButton>
<FocusButton context={context} focusId={rpgFocusId.exitRun} command={{ type: "exit-run" }} className="rpg-secondary-action rpg-large-action" disabled={!context.onExitRun}>Mode Select</FocusButton>
</div>
</section>
);
}
function CompactCombatHud({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const boss = currentBoss(run);
const cleared = run.phase === "boss-cleared";
return (
<section className="rpg-compact-combat-hud" aria-live="polite">
<div>
<small>{run.phase === "challenge-active" ? "Hallway challenge" : cleared ? "Room cleared" : "RPG Roguelike"}</small>
<strong>{run.phase === "challenge-active" ? run.currentChallenge?.objective.name : cleared ? "North gate open · cross it or confirm" : boss?.name}</strong>
</div>
{run.phase === "challenge-active" ? <ChallengeObjective run={run} compact /> : <RoutePips run={run} />}
</section>
);
}
/** Main-display overlay. Draft/reward/shop phases gate play; live phases render compact HUD only. */
export function RpgRunOverlay(props: RpgRunUiProps) {
const context = createUiContext(props);
const live = props.run.phase === "challenge-active" || props.run.phase === "boss-combat" || props.run.phase === "boss-cleared";
const overlayRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (live) return;
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
return () => {
const previous = previousFocusRef.current;
if (previous?.isConnected) previous.focus({ preventScroll: true });
previousFocusRef.current = null;
};
}, [live]);
useEffect(() => {
if (live) return;
const frame = requestAnimationFrame(() => {
const selected = overlayRef.current?.querySelector<HTMLButtonElement>("[data-rpg-focus].is-controller-selected:not(:disabled)");
selected?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [context.activeFocusId, live, props.run.phase]);
const trapModalTab = (event: KeyboardEvent<HTMLDivElement>) => {
if (live || event.key !== "Tab") return;
const buttons = [...(overlayRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? [])];
if (!buttons.length) {
event.preventDefault();
overlayRef.current?.focus({ preventScroll: true });
return;
}
const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement);
const nextIndex = event.shiftKey
? currentIndex <= 0 ? buttons.length - 1 : currentIndex - 1
: currentIndex < 0 || currentIndex === buttons.length - 1 ? 0 : currentIndex + 1;
event.preventDefault();
buttons[nextIndex].focus({ preventScroll: true });
};
return (
<div
ref={overlayRef}
className={`rpg-run-overlay ${live ? "is-live" : "is-gated"} ${props.className ?? ""}`.trim()}
role={live ? "status" : "dialog"}
aria-modal={live ? undefined : true}
aria-label="RPG roguelike run"
tabIndex={live ? undefined : -1}
onKeyDown={trapModalTab}
>
{live ? <CompactCombatHud context={context} /> : (
<>
<RunHeader run={props.run} />
<RoutePips run={props.run} />
{props.run.phase === "party-draft" && <PartyDraft context={context} />}
{props.run.phase === "spell-draft" && <SpellDraft context={context} />}
{props.run.phase === "challenge-briefing" && <Briefing context={context} kind="challenge" />}
{props.run.phase === "boss-briefing" && <Briefing context={context} kind="boss" />}
{props.run.phase === "reward" && <Rewards context={context} />}
{props.run.phase === "shop" && <Shop context={context} />}
{(props.run.phase === "victory" || props.run.phase === "defeat") && <Terminal context={context} />}
</>
)}
</div>
);
}
@@ -0,0 +1,410 @@
import type { CSSProperties } from "react";
import { HEALER_ABILITIES } from "../../game/healers";
import { ABILITY_CONTROLLER_BINDINGS } from "../../game/controllerBindings";
import {
ABILITY_LOADOUT_SLOTS,
MAX_ACTIVE_ROSTER,
MAX_EQUIPPED_SPELLS,
PARTY_DRAFT_WAVE_COUNT,
PARTY_RECRUITS_PER_WAVE,
SPELL_DRAFT_WAVE_COUNT,
SPELL_PICKS_PER_WAVE,
rpgFocusId,
} from "../../game/rpgRoguelike";
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
import {
ChallengeObjective,
createUiContext,
currentBoss,
FocusButton,
GearCard,
PartyCard,
rewardSummary,
RoutePips,
RunHeader,
runAccentStyle,
SpellCard,
type RpgRunUiContext,
type RpgLiveCombatUi,
type RpgRunUiProps,
} from "./RpgRunUiShared";
import "./rpgRoguelike.css";
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
const { run } = context;
return (
<section className="rpg-tactical-section">
<header><h3>Party</h3><span>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</span></header>
<div className="rpg-tactical-party-grid">
{run.roster.map((member) => (
<PartyCard key={member.instanceId} member={member} compact action={interactive ? (
<FocusButton
context={context}
focusId={rpgFocusId.partyMember(member.instanceId)}
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
className="rpg-card-action"
label={`Remove ${member.name}`}
><span>Remove</span></FocusButton>
) : undefined} />
))}
{!run.roster.length && <p className="rpg-empty-copy">Drafted companions appear here.</p>}
</div>
</section>
);
}
function TacticalSpellbook({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
const { run } = context;
return (
<section className="rpg-tactical-section">
<header><h3>Spellbook</h3><span>{run.selectedSpellIds.length}/{MAX_EQUIPPED_SPELLS} slots</span></header>
<div className="rpg-tactical-spell-grid">
{ABILITY_LOADOUT_SLOTS.map((slotId, index) => ({ slotId, index, spellId: run.abilityLoadout[slotId] }))
.filter((entry): entry is { slotId: typeof ABILITY_LOADOUT_SLOTS[number]; index: number; spellId: NonNullable<typeof entry.spellId> } => Boolean(entry.spellId))
.map(({ spellId, index }) => {
const spell = HEALER_ABILITIES[spellId];
return interactive ? (
<FocusButton
key={spellId}
context={context}
focusId={rpgFocusId.spellSelected(spellId)}
command={{ type: "run-action", action: { type: "spell-remove", spellId } }}
className="rpg-tactical-spell"
style={runAccentStyle(spell.color)}
label={`Remove ${spell.name}`}
><b>{index + 1}</b><i>{spell.icon}</i><span>{spell.shortName}<small>Rank {run.spellRanks[spellId] ?? 0}</small></span><em>×</em></FocusButton>
) : (
<div key={spellId} className="rpg-tactical-spell" style={runAccentStyle(spell.color)}>
<b>{index + 1}</b><i>{spell.icon}</i><span>{spell.shortName}<small>Rank {run.spellRanks[spellId] ?? 0}</small></span>
</div>
);
})}
{!run.selectedSpellIds.length && <p className="rpg-empty-copy">Drafted spells appear here.</p>}
</div>
</section>
);
}
function TacticalContinue({ context, focusId, action, label, disabled = false }: {
readonly context: RpgRunUiContext;
readonly focusId: string;
readonly action: Parameters<RpgRunUiProps["onAction"]>[0];
readonly label: string;
readonly disabled?: boolean;
}) {
return (
<FocusButton context={context} focusId={focusId} command={{ type: "run-action", action }} className="rpg-tactical-continue" disabled={disabled}>
<span>{label}</span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b>
</FocusButton>
);
}
function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const draft = run.partyDraft!;
const canRemove = draft.waveIndex > 0;
const canRecruit = run.roster.length < MAX_ACTIVE_ROSTER && draft.recruitedThisWaveIds.length < PARTY_RECRUITS_PER_WAVE;
const finalWave = draft.waveIndex === PARTY_DRAFT_WAVE_COUNT - 1;
const canContinue = finalWave
? run.roster.length === MAX_ACTIVE_ROSTER && run.roster.some((member) => member.role === "Tank")
: run.roster.length + (PARTY_DRAFT_WAVE_COUNT - draft.waveIndex - 1) * PARTY_RECRUITS_PER_WAVE >= MAX_ACTIVE_ROSTER;
return (
<>
<section className="rpg-tactical-section rpg-tactical-offers">
<header><h3>Offers · wave {draft.waveIndex + 1}/{PARTY_DRAFT_WAVE_COUNT}</h3><span>{draft.recruitedThisWaveIds.length}/{PARTY_RECRUITS_PER_WAVE} picked</span></header>
<div className="rpg-tactical-list">
{draft.offers.map((candidate) => {
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
const disabled = (recruited && !canRemove) || (!recruited && !canRecruit);
return (
<FocusButton
key={candidate.candidateId}
context={context}
focusId={rpgFocusId.partyOffer(candidate.candidateId)}
command={{ type: "run-action", action: recruited ? { type: "party-remove", memberId: candidate.candidateId } : { type: "party-recruit", candidateId: candidate.candidateId } }}
className={`rpg-tactical-offer rarity-${candidate.rarity}`}
style={runAccentStyle(candidate.color)}
disabled={disabled}
pressed={recruited}
>
<i>{candidate.role === "Tank" ? "⬡" : "⚔"}</i>
<span><small>{candidate.rarity} · {candidate.role}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
<b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b>
<u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u>
</FocusButton>
);
})}
</div>
</section>
<TacticalParty context={context} interactive={canRemove} />
<TacticalContinue context={context} focusId={rpgFocusId.partyContinue} action={{ type: "party-next-wave" }} label={finalWave ? "Lock party" : "Reroll offers"} disabled={!canContinue} />
</>
);
}
function TacticalSpellDraft({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const draft = run.spellDraft!;
const canRemove = draft.waveIndex > 0;
const canPick = run.selectedSpellIds.length < MAX_EQUIPPED_SPELLS && draft.pickedThisWaveIds.length < SPELL_PICKS_PER_WAVE;
const finalWave = draft.waveIndex === SPELL_DRAFT_WAVE_COUNT - 1;
return (
<>
<section className="rpg-tactical-section rpg-tactical-offers">
<header><h3>Spells · wave {draft.waveIndex + 1}/{SPELL_DRAFT_WAVE_COUNT}</h3><span>{draft.pickedThisWaveIds.length}/{SPELL_PICKS_PER_WAVE} picked</span></header>
<div className="rpg-tactical-list">
{draft.offers.map((spellId) => {
const spell = HEALER_ABILITIES[spellId];
const selected = run.selectedSpellIds.includes(spellId);
const disabled = (selected && !canRemove) || (!selected && !canPick);
return (
<FocusButton
key={spellId}
context={context}
focusId={rpgFocusId.spellOffer(spellId)}
command={{ type: "run-action", action: selected ? { type: "spell-remove", spellId } : { type: "spell-pick", spellId } }}
className="rpg-tactical-offer is-spell"
style={runAccentStyle(spell.color)}
disabled={disabled}
pressed={selected}
>
<i>{spell.icon}</i><span><small>{spell.targeting} · {spell.mana} mana</small><strong>{spell.name}</strong><em>{spell.description}</em></span><u>{selected ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Learn"}</u>
</FocusButton>
);
})}
</div>
</section>
<TacticalSpellbook context={context} interactive={canRemove} />
<TacticalContinue context={context} focusId={rpgFocusId.spellContinue} action={{ type: "spell-next-wave" }} label={finalWave ? "Lock spellbook" : "Reroll spells"} disabled={finalWave && !run.selectedSpellIds.length} />
</>
);
}
function TacticalBriefing({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const challenge = run.phase === "challenge-briefing";
const boss = currentBoss(run);
return (
<>
<section className="rpg-tactical-hero" style={!challenge && boss ? runAccentStyle(boss.accent) : undefined}>
<i>{challenge ? "◇" : boss?.icon ?? "♛"}</i>
<small>{challenge ? "Hallway challenge" : "Boss chamber"}</small>
<h2>{challenge ? run.currentChallenge?.objective.name : boss?.name}</h2>
<p>{challenge ? "Success improves the next chest. Failure still opens the boss door." : boss?.briefing}</p>
</section>
{challenge && <ChallengeObjective run={run} />}
<TacticalParty context={context} />
<TacticalContinue
context={context}
focusId={challenge ? rpgFocusId.challengeStart : rpgFocusId.bossStart}
action={challenge ? { type: "challenge-start" } : { type: "boss-start" }}
label={challenge ? "Start challenge" : "Enter chamber"}
/>
</>
);
}
function TacticalLiveParty({ live }: { live: RpgLiveCombatUi }) {
return (
<section className="rpg-tactical-section rpg-live-party">
<header><h3>Live party</h3><span>{live.party.filter((member) => member.hp > 0).length}/{live.party.length} standing</span></header>
<div className="rpg-live-party-grid">
{live.party.map((member) => {
const health = Math.max(0, Math.min(100, member.hp / Math.max(1, member.maxHp) * 100));
const selected = member.id === live.selectedMemberId;
return (
<button
key={member.id}
type="button"
className={`${selected ? "is-selected" : ""} ${member.hp <= 0 ? "is-down" : ""}`.trim()}
onClick={() => live.onSelectMember?.(member.id)}
disabled={member.hp <= 0}
aria-pressed={selected}
aria-label={`Target ${member.name}, ${Math.ceil(member.hp)} of ${member.maxHp} health`}
>
<i style={{ "--rpg-member-color": member.color } as CSSProperties}>{member.name[0]}</i>
<span><strong>{member.name}</strong><small>{member.className}</small><em><b style={{ width: `${health}%` }} /></em></span>
<u>{member.hp <= 0 ? "DOWN" : `${Math.ceil(member.hp)} / ${member.maxHp}`}</u>
</button>
);
})}
</div>
</section>
);
}
function TacticalLiveSpellbook({ context, live }: { context: RpgRunUiContext; live: RpgLiveCombatUi }) {
const castProgress = live.activeCast
? Math.max(0, Math.min(1, (live.time - live.activeCast.startedAt) / Math.max(0.001, live.activeCast.completesAt - live.activeCast.startedAt)))
: 0;
return (
<section className="rpg-tactical-section rpg-live-spells">
<header><h3>Spell arsenal</h3><span>{Math.ceil(live.mana)} / {live.maxMana} mana</span></header>
<div className="rpg-live-resource-bar"><i style={{ width: `${Math.max(0, Math.min(100, live.mana / Math.max(1, live.maxMana) * 100))}%` }} /></div>
<div className="rpg-live-resources">
<span><b>Verdancy</b>{live.spellResources.verdancy}/5</span>
<span><b>Tidal Surge</b>{live.spellResources.tidalSurge}/2</span>
<span><b>Conviction</b>{live.spellResources.conviction}/3</span>
<span><b>Chronoshards</b>{live.spellResources.chronoshards}/3</span>
</div>
{live.activeCast && (
<div className="rpg-live-cast" style={{ "--rpg-cast-progress": `${castProgress * 100}%` } as CSSProperties}>
<span>Casting <b>{HEALER_ABILITIES[live.activeCast.abilityId].name}</b></span><i><em /></i>
</div>
)}
<div className="rpg-live-spell-grid">
{ABILITY_LOADOUT_SLOTS.map((slotId, index) => {
const spellId = context.run.abilityLoadout[slotId];
if (!spellId) return <div key={slotId} className="rpg-live-spell is-empty"><b>{index + 1}</b><span>Empty</span></div>;
const spell = HEALER_ABILITIES[spellId];
const cooldown = Math.max(0, live.cooldowns[slotId] - live.time, live.globalCooldownUntil - live.time);
const selected = live.party.find((member) => member.id === live.selectedMemberId);
const disabled = !live.onCastAbility || live.activeCast !== null || cooldown > 0 || live.mana < spell.mana
|| (spell.targeting === "ally" && (!selected || selected.hp <= 0))
|| (spell.pulseKind === "cleanse" && !selected?.debuffs.length);
return (
<button
key={slotId}
type="button"
className={`rpg-live-spell ${cooldown > 0 ? "is-cooling" : ""}`}
style={{ "--rpg-accent": spell.color } as CSSProperties}
onClick={() => live.onCastAbility?.(slotId)}
disabled={disabled}
aria-label={`${spell.name}, ${cooldown > 0 ? `${cooldown.toFixed(1)} seconds remaining` : "ready"}`}
>
<b>{ABILITY_CONTROLLER_BINDINGS[slotId].glyph}</b><i>{spell.icon}</i><span><strong>{spell.shortName}</strong><small>{spell.mana} mana · Rank {context.run.spellRanks[spellId] ?? 0}</small></span>
<u>{cooldown > 0 ? cooldown.toFixed(1) : "Ready"}</u>
</button>
);
})}
</div>
</section>
);
}
function TacticalLive({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const boss = currentBoss(run);
const live = context.liveCombat;
return (
<>
{run.phase === "challenge-active" ? <ChallengeObjective run={run} /> : (
<section className="rpg-tactical-hero is-live" style={boss ? runAccentStyle(boss.accent) : undefined}>
<i>{boss?.icon ?? "♛"}</i><small>Boss battle</small><h2>{boss?.name}</h2><p>{boss?.summary}</p>
</section>
)}
{live ? <TacticalLiveParty live={live} /> : <TacticalParty context={context} />}
{live ? <TacticalLiveSpellbook context={context} live={live} /> : <TacticalSpellbook context={context} />}
<p className="rpg-controller-hint"><b>D-pad</b> Target <i /> <b>Face buttons</b> Cast</p>
</>
);
}
function TacticalCleared({ context }: { context: RpgRunUiContext }) {
return (
<>
<section className="rpg-tactical-hero rpg-tactical-cleared">
<i></i><small>North gate open</small><h2>Room cleared</h2><p>Cross the far doorway. Health and fallen companions carry forward.</p>
</section>
<TacticalParty context={context} />
<TacticalContinue context={context} focusId={rpgFocusId.rewardOpen} action={{ type: "reward-open" }} label="Open chest" />
</>
);
}
function TacticalRewards({ context }: { context: RpgRunUiContext }) {
const chest = context.run.pendingReward;
if (!chest) return null;
return (
<section className="rpg-tactical-section">
<header><h3>Choose one reward</h3><span>Chest +{chest.quality}</span></header>
<div className="rpg-tactical-rewards">
{chest.choices.map((choice) => {
const summary = rewardSummary(choice);
return (
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)}>
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p></span><b>Claim</b>
</FocusButton>
);
})}
</div>
</section>
);
}
function TacticalShop({ context }: { context: RpgRunUiContext }) {
const { run } = context;
const shop = run.shop;
if (!shop) return null;
const needsRest = run.playerHp > 0 && run.playerHp < 100
|| run.roster.some((member) => member.hp > 0 && member.hp < member.stats.maxHp);
return (
<>
<section className="rpg-tactical-section">
<header><h3>Buy gear</h3><span> {run.currency}</span></header>
<div className="rpg-tactical-shop-grid">
{shop.offers.map((offer) => (
<GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`Buy ${offer.item.name}`}>
<span>{offer.sold ? "Sold" : "Buy"}</span>
</FocusButton>
} />
))}
</div>
</section>
<section className="rpg-tactical-section">
<header><h3>Bag & services</h3><span>{run.bag.length} stored</span></header>
<div className="rpg-tactical-list">
{run.bag.map((item) => (
<FocusButton key={item.id} context={context} focusId={rpgFocusId.shopSell(item.id)} command={{ type: "run-action", action: { type: "shop-sell", itemId: item.id } }} className="rpg-service-row">
<span><small>Sell gear</small>{item.name}</span><b> {item.sellPrice}</b>
</FocusButton>
))}
<FocusButton context={context} focusId={rpgFocusId.shopRest} command={{ type: "run-action", action: { type: "shop-rest" } }} className="rpg-service-row" disabled={!needsRest || run.currency < shop.restCost}>
<span><small>Restore living party</small>Rest</span><b> {shop.restCost}</b>
</FocusButton>
{run.roster.filter((member) => member.hp <= 0).map((member) => (
<FocusButton key={member.instanceId} context={context} focusId={rpgFocusId.shopRevive(member.instanceId)} command={{ type: "run-action", action: { type: "shop-revive", memberId: member.instanceId } }} className="rpg-service-row" disabled={run.currency < shop.reviveCost}>
<span><small>Return at 50% HP</small>Revive {member.name}</span><b> {shop.reviveCost}</b>
</FocusButton>
))}
</div>
</section>
<TacticalContinue context={context} focusId={rpgFocusId.shopLeave} action={{ type: "shop-leave" }} label="Leave shop" />
</>
);
}
function TacticalTerminal({ context }: { context: RpgRunUiContext }) {
const victory = context.run.phase === "victory";
return (
<section className={`rpg-tactical-terminal ${victory ? "is-victory" : "is-defeat"}`}>
<i>{victory ? "♛" : "◇"}</i><small>{victory ? "Run complete" : "Run ended"}</small><h2>{victory ? "Victory" : "Party fallen"}</h2>
<p>{context.run.bossesDefeated} bosses · {context.run.selectedSpellIds.length} spells · {context.run.currency}</p>
<FocusButton context={context} focusId={rpgFocusId.restartRun} command={{ type: "restart-run" }} className="rpg-tactical-continue" disabled={!context.onRestartRun}><span>New run</span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b></FocusButton>
<FocusButton context={context} focusId={rpgFocusId.exitRun} command={{ type: "exit-run" }} className="rpg-secondary-action" disabled={!context.onExitRun}>Mode select</FocusButton>
</section>
);
}
/** Secondary-display tactical projection. Same semantic focus IDs as top overlay. */
export function RpgRunTacticalPanel(props: RpgRunUiProps) {
const context = createUiContext(props);
const run = props.run;
return (
<aside className={`rpg-run-tactical ${props.className ?? ""}`.trim()} aria-label="RPG roguelike tactics">
<RunHeader run={run} compact />
<RoutePips run={run} />
<div className="rpg-tactical-body">
{run.phase === "party-draft" && <TacticalPartyDraft context={context} />}
{run.phase === "spell-draft" && <TacticalSpellDraft context={context} />}
{(run.phase === "challenge-briefing" || run.phase === "boss-briefing") && <TacticalBriefing context={context} />}
{(run.phase === "challenge-active" || run.phase === "boss-combat") && <TacticalLive context={context} />}
{run.phase === "boss-cleared" && <TacticalCleared context={context} />}
{run.phase === "reward" && <TacticalRewards context={context} />}
{run.phase === "shop" && <TacticalShop context={context} />}
{(run.phase === "victory" || run.phase === "defeat") && <TacticalTerminal context={context} />}
</div>
</aside>
);
}
@@ -0,0 +1,325 @@
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
import { BOSS_DEFINITIONS } from "../../game/bossCatalog";
import { HEALER_ABILITIES } from "../../game/healers";
import type { AbilitySlotId, ActiveCast, HealerAbilityId, MemberId, PartyMember } from "../../game/types";
import type {
PartyDraftCandidate,
PartyRosterMember,
RewardChoice,
RpgRoguelikeAction,
RpgRoguelikeRunState,
RunGearItem,
} from "../../game/rpgRoguelike";
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike";
import type { RpgUiCommand } from "../../game/rpgRoguelike";
import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
export interface RpgRunUiProps {
readonly run: RpgRoguelikeRunState;
readonly focusedId?: string | null;
readonly onFocusChange?: (focusId: string) => void;
readonly onAction: (action: RpgRoguelikeAction) => void;
readonly onRestartRun?: () => void;
readonly onExitRun?: () => void;
readonly className?: string;
readonly liveCombat?: RpgLiveCombatUi;
}
export interface RpgLiveCombatUi {
readonly party: readonly PartyMember[];
readonly selectedMemberId: MemberId;
readonly mana: number;
readonly maxMana: number;
readonly cooldowns: Readonly<Record<AbilitySlotId, number>>;
readonly globalCooldownUntil: number;
readonly time: number;
readonly activeCast: ActiveCast | null;
readonly spellResources: {
readonly verdancy: number;
readonly tidalSurge: number;
readonly conviction: number;
readonly chronoshards: number;
};
readonly onSelectMember?: (memberId: MemberId) => void;
readonly onCastAbility?: (abilitySlotId: AbilitySlotId) => void;
}
export interface RpgRunUiContext extends RpgRunUiProps {
readonly activeFocusId: string | null;
}
export function createUiContext(props: RpgRunUiProps): RpgRunUiContext {
return { ...props, activeFocusId: normalizeRpgFocusId(props.run, props.focusedId) };
}
export function runAccentStyle(accent: string): CSSProperties {
return { "--rpg-accent": accent } as CSSProperties;
}
export function executeUiCommand(context: RpgRunUiContext, command: RpgUiCommand): void {
if (command.type === "run-action") context.onAction(command.action);
else if (command.type === "restart-run") context.onRestartRun?.();
else context.onExitRun?.();
}
interface FocusButtonProps {
readonly context: RpgRunUiContext;
readonly focusId: string;
readonly command: RpgUiCommand;
readonly className?: string;
readonly disabled?: boolean;
readonly pressed?: boolean;
readonly style?: CSSProperties;
readonly children: ReactNode;
readonly label?: string;
}
export function FocusButton({
context,
focusId,
command,
className = "",
disabled = false,
pressed,
style,
children,
label,
}: FocusButtonProps) {
const selected = context.activeFocusId === focusId;
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const button = buttonRef.current;
if (!selected || !button) return;
let parent = button.parentElement;
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
const childRect = button.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
if (parent.scrollHeight > parent.clientHeight) {
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
}
if (parent.scrollWidth > parent.clientWidth) {
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
}
parent = parent.parentElement;
}
}, [selected]);
return (
<button
ref={buttonRef}
type="button"
className={`${className} ${selected ? "is-controller-selected" : ""}`.trim()}
data-rpg-focus={focusId}
aria-current={selected ? "true" : undefined}
aria-pressed={pressed}
aria-label={label}
disabled={disabled}
style={style}
onFocus={() => context.onFocusChange?.(focusId)}
onPointerEnter={() => !disabled && context.onFocusChange?.(focusId)}
onClick={() => executeUiCommand(context, command)}
>
{children}
</button>
);
}
export function phaseLabel(run: RpgRoguelikeRunState): string {
switch (run.phase) {
case "party-draft": return "Party Draft";
case "spell-draft": return "Spell Draft";
case "challenge-briefing": return "Hallway Challenge";
case "challenge-active": return "Challenge Active";
case "boss-briefing": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Boss" : "Boss Door";
case "boss-combat": return run.bossIndex === TOTAL_BOSS_COUNT - 1 ? "Final Battle" : "Boss Battle";
case "boss-cleared": return "Room Cleared";
case "reward": return "Reward Chest";
case "shop": return `Act ${run.shop?.act ?? 1} Intermission`;
case "victory": return "Run Complete";
case "defeat": return "Party Fallen";
}
}
export function routeLabel(run: RpgRoguelikeRunState): string {
if (run.bossIndex >= TOTAL_BOSS_COUNT - 1) return "Finale";
return `Act ${Math.floor(run.bossIndex / BOSSES_PER_ACT) + 1} · Room ${(run.bossIndex % BOSSES_PER_ACT) + 1}`;
}
export function currentBoss(run: RpgRoguelikeRunState) {
const bossId = run.bossRoute[run.bossIndex];
return bossId ? BOSS_DEFINITIONS[bossId] : null;
}
export function RunHeader({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) {
return (
<header className={`rpg-run-header ${compact ? "is-compact" : ""}`.trim()}>
<div>
<small>{routeLabel(run)}</small>
<strong>{phaseLabel(run)}</strong>
</div>
<div className="rpg-run-resources" aria-label={`${run.currency} gold, ${Math.ceil(run.playerHp)} healer health, ${run.bossesDefeated} bosses defeated`}>
<span><i></i>{run.currency}</span>
<span><i></i>{Math.ceil(run.playerHp)}</span>
<span><i></i>{run.bossesDefeated}/{TOTAL_BOSS_COUNT}</span>
</div>
</header>
);
}
export function RoutePips({ run }: { run: RpgRoguelikeRunState }) {
return (
<div className="rpg-route-pips" aria-label={`${run.bossesDefeated} of ${TOTAL_BOSS_COUNT} bosses defeated`}>
{run.bossRoute.map((bossId, index) => (
<i
key={`${bossId}-${index}`}
className={`${index < run.bossesDefeated ? "is-cleared" : ""} ${index === run.bossIndex ? "is-current" : ""} ${index === TOTAL_BOSS_COUNT - 1 ? "is-finale" : ""}`.trim()}
title={BOSS_DEFINITIONS[bossId].name}
/>
))}
</div>
);
}
function titleCase(value: string): string {
return value.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
}
export function PartyCard({
member,
candidate,
selected = false,
compact = false,
action,
}: {
readonly member?: PartyRosterMember;
readonly candidate?: PartyDraftCandidate;
readonly selected?: boolean;
readonly compact?: boolean;
readonly action?: ReactNode;
}) {
const entry = member ?? candidate;
if (!entry) return null;
const hp = member?.hp ?? entry.stats.maxHp;
const hpPercent = Math.max(0, Math.min(100, (hp / entry.stats.maxHp) * 100));
return (
<article
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
style={runAccentStyle(entry.color)}
>
<div className="rpg-card-kicker"><span>{entry.rarity}</span><b>{entry.role}</b></div>
<h3>{entry.name}</h3>
<p>{entry.className}</p>
{!compact && (
<div className="rpg-stat-row" aria-label="Combat stats">
<span><small>HP</small>{entry.stats.maxHp}</span>
<span><small>ST</small>{entry.stats.singleTarget.toFixed(2)}</span>
<span><small>AOE</small>{entry.stats.areaDamage.toFixed(2)}</span>
<span><small>DEF</small>{entry.stats.defense.toFixed(2)}</span>
</div>
)}
{member && (
<div className="rpg-vital-bar" aria-label={`${Math.round(hp)} of ${entry.stats.maxHp} health`}>
<i style={{ width: `${hpPercent}%` }} />
<small>{hp <= 0 ? "Fallen" : `${Math.ceil(hp)} / ${entry.stats.maxHp}`}</small>
</div>
)}
{!compact && entry.traitIds.length > 0 && <footer>{entry.traitIds.map(titleCase).join(" · ")}</footer>}
{action}
</article>
);
}
export function SpellCard({
spellId,
rank = 0,
selected = false,
compact = false,
action,
}: {
readonly spellId: HealerAbilityId;
readonly rank?: number;
readonly selected?: boolean;
readonly compact?: boolean;
readonly action?: ReactNode;
}) {
const spell = HEALER_ABILITIES[spellId];
return (
<article
className={`rpg-spell-card ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
style={runAccentStyle(spell.color)}
>
<i className="rpg-spell-icon">{spell.icon}</i>
<div>
<small>{spell.targeting} · {spell.mana} mana{rank > 0 ? ` · Rank ${rank}` : ""}</small>
<h3>{spell.name}</h3>
{!compact && <p>{spell.description}</p>}
</div>
{action}
</article>
);
}
export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): string {
if (item.ownerId === "player") return "Healer";
return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion";
}
export function GearCard({ run, item, price, action }: {
readonly run: RpgRoguelikeRunState;
readonly item: RunGearItem;
readonly price?: number;
readonly action?: ReactNode;
}) {
return (
<article className="rpg-gear-card">
<i>{item.slotId === "weapon" ? "⚔" : item.slotId === "armor" ? "⬡" : "◇"}</i>
<div>
<small>{gearOwnerName(run, item)} · {item.slotId}</small>
<h3>{item.name}</h3>
<p>+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}</p>
</div>
{action}
</article>
);
}
export function rewardSummary(choice: RewardChoice): { icon: string; eyebrow: string; detail: string; accent: string } {
if (choice.kind === "spell-rank") {
const spell = HEALER_ABILITIES[choice.spellId];
return { icon: spell.icon, eyebrow: "Spell upgrade", detail: `${spell.name} reaches rank ${choice.nextRank}.`, accent: spell.color };
}
if (choice.kind === "member-rarity") {
return { icon: "♟", eyebrow: "Party upgrade", detail: `Promote companion to ${choice.nextRarity}.`, accent: "#b47aec" };
}
if (choice.kind === "run-gear") {
return { icon: "◇", eyebrow: `Gear +${choice.item.enhancement}`, detail: `Equip ${choice.item.name}; displaced gear moves to bag.`, accent: "#65aef2" };
}
return { icon: "◆", eyebrow: "Run currency", detail: `Gain ${choice.amount} gold for this run.`, accent: "#efc858" };
}
export function ChallengeObjective({ run, compact = false }: { run: RpgRoguelikeRunState; compact?: boolean }) {
const challenge = run.currentChallenge;
const previous = run.lastChallengeResult;
if (!challenge && !previous) return null;
const objective = challenge?.objective ?? previous!.objective;
const metrics = challenge?.metrics ?? previous!.metrics;
const current = metrics[objective.metric];
const progress = Math.max(0, Math.min(100, (current / objective.target) * 100));
const copy = objective.challengeId === "blockbreaker"
? `Break ${objective.target} bricks before the board falls.`
: objective.challengeId === "hockey"
? `Defeat ${objective.target} ${objective.target === 1 ? "boss" : "bosses"} without conceding.`
: `Defeat ${objective.target} enemies before the assault ends.`;
return (
<section className={`rpg-challenge-objective ${compact ? "is-compact" : ""}`.trim()}>
<div><small>Repeat tier {objective.repeatIndex + 1}</small><strong>{objective.name}</strong></div>
{!compact && <p>{copy}</p>}
<div className="rpg-objective-progress">
<i style={{ width: `${progress}%` }} />
<span>{current} / {objective.target}</span>
</div>
{!compact && <footer>Success: {objective.rewardCurrency} · chest quality +{objective.chestQualityBonus}</footer>}
</section>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { RpgRoomPortals, type RpgRoomPortalsProps } from "./RpgRoomPortals";
export { RpgRunOverlay } from "./RpgRunOverlay";
export { RpgRunTacticalPanel } from "./RpgRunTacticalPanel";
export type { RpgRunUiProps } from "./RpgRunUiShared";
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
import { describe, expect, it, vi } from "vitest";
import { bossDeathDespawnSeconds } from "../game/bossDeath";
import {
OUTCOME_RENDER_TAIL_SECONDS,
activePlayfieldKind,
consumeSimulationSteps,
outcomeElapsedAfterPhaseChange,
resetSceneClockForMode,
sceneCanvasFrameloop,
sceneFrameIntervalMs,
selectSceneRenderMode,
startSceneFrameLoop,
summarizeFramePerformance,
type SceneFrameSample,
} from "./sceneFramePolicy";
function createFakeFrames() {
let nextHandle = 1;
const callbacks = new Map<number, FrameRequestCallback>();
return {
requestFrame(callback: FrameRequestCallback) {
const handle = nextHandle++;
callbacks.set(handle, callback);
return handle;
},
cancelFrame(handle: number) {
callbacks.delete(handle);
},
runNext(now: number) {
const entry = callbacks.entries().next().value as [number, FrameRequestCallback] | undefined;
if (!entry) return false;
callbacks.delete(entry[0]);
entry[1](now);
return true;
},
pending() {
return callbacks.size;
},
};
}
describe("scene render policy", () => {
it("selects active, outcome, static, and suspended modes", () => {
expect(selectSceneRenderMode({ phase: "combat", paused: false, visible: true, outcomeTailComplete: false })).toBe("active");
expect(selectSceneRenderMode({ phase: "victory", paused: false, visible: true, outcomeTailComplete: false })).toBe("outcome");
expect(selectSceneRenderMode({ phase: "victory", paused: false, visible: true, outcomeTailComplete: true })).toBe("static");
expect(selectSceneRenderMode({ phase: "briefing", paused: false, visible: true, outcomeTailComplete: false })).toBe("static");
expect(selectSceneRenderMode({ phase: "combat", paused: true, visible: true, outcomeTailComplete: false })).toBe("static");
expect(selectSceneRenderMode({ phase: "combat", paused: false, visible: false, outcomeTailComplete: false })).toBe("suspended");
});
it("keeps simulation fixed at 10 Hz and caps delayed catch-up at three steps", () => {
expect(consumeSimulationSteps(0, 0.09)).toEqual({ steps: 0, remainderSeconds: 0.09 });
const oneStep = consumeSimulationSteps(0.09, 0.02);
expect(oneStep.steps).toBe(1);
expect(oneStep.remainderSeconds).toBeCloseTo(0.01);
const delayed = consumeSimulationSteps(0.09, 1);
expect(delayed.steps).toBe(3);
expect(delayed.remainderSeconds).toBeCloseTo(0.04);
});
it("has no continuous interval for static or suspended scenes", () => {
expect(sceneCanvasFrameloop("active")).toBe("demand");
expect(sceneCanvasFrameloop("outcome")).toBe("demand");
expect(sceneCanvasFrameloop("static")).toBe("demand");
expect(sceneCanvasFrameloop("suspended")).toBe("never");
expect(sceneFrameIntervalMs("static")).toBeNull();
expect(sceneFrameIntervalMs("suspended")).toBeNull();
});
it("resets the demand clock before active and outcome frame requests", () => {
const start = vi.fn();
for (const mode of ["static", "suspended", "active", "outcome"] as const) {
resetSceneClockForMode({ start }, mode);
}
expect(start).toHaveBeenCalledTimes(2);
});
it("mounts only the selected special playfield", () => {
expect(activePlayfieldKind("boss")).toBeNull();
expect(activePlayfieldKind("hockey-healing")).toBe("hockey-healing");
expect(activePlayfieldKind("blockbreaker")).toBe("blockbreaker");
expect(activePlayfieldKind("aether-assault")).toBe("aether-assault");
expect(activePlayfieldKind("hockey-healing-pvp")).toBe("hockey-healing-pvp");
});
it("keeps outcome rendering longer than the longest authored boss death", () => {
expect(OUTCOME_RENDER_TAIL_SECONDS).toBeGreaterThan(bossDeathDespawnSeconds("gravehorn-triceratops"));
});
it("restarts the outcome tail when the outcome phase identity changes", () => {
expect(outcomeElapsedAfterPhaseChange("victory", "victory", 3.5)).toBe(3.5);
expect(outcomeElapsedAfterPhaseChange("victory", "intermission", 3.5)).toBe(0);
expect(outcomeElapsedAfterPhaseChange("defeat", null, 3.5)).toBe(0);
});
});
describe("scene frame request loop", () => {
it("caps active rendering at 60 FPS", () => {
const fake = createFakeFrames();
const samples: SceneFrameSample[] = [];
const stop = startSceneFrameLoop({
mode: "active",
requestFrame: fake.requestFrame,
cancelFrame: fake.cancelFrame,
onFrame: (sample) => samples.push(sample),
});
expect(fake.runNext(0)).toBe(true);
expect(fake.runNext(8)).toBe(true);
expect(fake.runNext(16.7)).toBe(true);
expect(fake.runNext(25)).toBe(true);
expect(fake.runNext(33.4)).toBe(true);
expect(samples).toHaveLength(3);
expect(samples.map((sample) => sample.elapsedSeconds)).toEqual([0, 0.0167, 0.0167]);
stop();
expect(fake.pending()).toBe(0);
});
it("renders an outcome for seven animation seconds without simulation ownership", () => {
const fake = createFakeFrames();
const completed = vi.fn();
const samples: SceneFrameSample[] = [];
startSceneFrameLoop({
mode: "outcome",
requestFrame: fake.requestFrame,
cancelFrame: fake.cancelFrame,
onFrame: (sample) => samples.push(sample),
onOutcomeComplete: completed,
});
fake.runNext(0);
let now = 0;
while (fake.pending() && now < 8_000) {
now += 1000 / 30;
fake.runNext(now);
}
expect(completed).toHaveBeenCalledTimes(1);
expect(samples.at(-1)?.outcomeElapsedSeconds).toBeGreaterThanOrEqual(OUTCOME_RENDER_TAIL_SECONDS);
expect(fake.pending()).toBe(0);
});
it("finishes an outcome after seven visible wall seconds when RAF is throttled", () => {
const fake = createFakeFrames();
const completed = vi.fn();
const samples: SceneFrameSample[] = [];
startSceneFrameLoop({
mode: "outcome",
requestFrame: fake.requestFrame,
cancelFrame: fake.cancelFrame,
onFrame: (sample) => samples.push(sample),
onOutcomeComplete: completed,
});
fake.runNext(0);
for (let now = 1_000; now <= 7_000; now += 1_000) fake.runNext(now);
expect(completed).toHaveBeenCalledTimes(1);
expect(samples.at(-1)?.outcomeElapsedSeconds).toBe(7);
expect(fake.pending()).toBe(0);
});
it("cancels immediately and resumes without hidden-time catch-up", () => {
const fake = createFakeFrames();
const beforeHide: SceneFrameSample[] = [];
const stop = startSceneFrameLoop({
mode: "active",
requestFrame: fake.requestFrame,
cancelFrame: fake.cancelFrame,
onFrame: (sample) => beforeHide.push(sample),
});
fake.runNext(100);
fake.runNext(117);
stop();
expect(fake.pending()).toBe(0);
const afterResume: SceneFrameSample[] = [];
const stopResumed = startSceneFrameLoop({
mode: "active",
requestFrame: fake.requestFrame,
cancelFrame: fake.cancelFrame,
onFrame: (sample) => afterResume.push(sample),
});
fake.runNext(60_000);
expect(beforeHide.at(-1)?.elapsedSeconds).toBeCloseTo(0.017);
expect(afterResume[0]).toMatchObject({
elapsedMs: 0,
elapsedSeconds: 0,
});
stopResumed();
});
});
describe("performance summaries", () => {
it("uses mode-specific targets and only counts active missed frames", () => {
const active = summarizeFramePerformance("active", [16, 18, 20]);
expect(active.targetFps).toBe(60);
expect(active.overBudget).toBe(1);
const outcome = summarizeFramePerformance("outcome", [32, 35]);
expect(outcome.targetFps).toBe(30);
expect(outcome.budgetMs).toBeCloseTo(1000 / 30);
expect(outcome.overBudget).toBe(0);
expect(summarizeFramePerformance("static", [])).toMatchObject({ targetFps: 0, budgetMs: null, samples: 0, overBudget: 0 });
expect(summarizeFramePerformance("suspended", [])).toMatchObject({ targetFps: 0, budgetMs: null, samples: 0, overBudget: 0 });
});
});
+208
View File
@@ -0,0 +1,208 @@
import type * as THREE from "three";
import type { GamePhase, GameplayActivity } from "../game/types";
export type SceneRenderMode = "active" | "outcome" | "static" | "suspended";
export type OutcomePhase = Extract<GamePhase, "victory" | "defeat" | "intermission">;
// Visible modes stay in R3F's demand clock domain. Suspended mode blocks loader
// and host-commit invalidations as well as the explicit gameplay request loop.
export function sceneCanvasFrameloop(mode: SceneRenderMode): "demand" | "never" {
return mode === "suspended" ? "never" : "demand";
}
export function resetSceneClockForMode(clock: Pick<THREE.Clock, "start">, mode: SceneRenderMode) {
if (mode === "active" || mode === "outcome") clock.start();
}
export const GAMEPLAY_FRAME_INTERVAL_MS = 1_000 / 60;
export const OUTCOME_FRAME_INTERVAL_MS = 1_000 / 30;
export const FRAME_INTERVAL_JITTER_MS = 1.5;
export const SIMULATION_STEP_SECONDS = 0.1;
export const MAX_SIMULATION_STEPS_PER_FRAME = 3;
export const MAX_FRAME_DELTA_SECONDS = 0.25;
export const OUTCOME_RENDER_TAIL_SECONDS = 7;
export interface SceneRenderState {
phase: GamePhase;
paused: boolean;
visible: boolean;
outcomeTailComplete: boolean;
}
export function isOutcomePhase(phase: GamePhase): phase is OutcomePhase {
return phase === "victory" || phase === "defeat" || phase === "intermission";
}
export function outcomeElapsedAfterPhaseChange(
previousPhase: OutcomePhase | null,
nextPhase: OutcomePhase | null,
elapsedSeconds: number,
) {
return previousPhase === nextPhase ? Math.max(0, elapsedSeconds) : 0;
}
export function selectSceneRenderMode({
phase,
paused,
visible,
outcomeTailComplete,
}: SceneRenderState): SceneRenderMode {
if (!visible) return "suspended";
if (phase === "combat" && !paused) return "active";
if (isOutcomePhase(phase) && !outcomeTailComplete) return "outcome";
return "static";
}
export function sceneFrameIntervalMs(mode: SceneRenderMode) {
if (mode === "active") return GAMEPLAY_FRAME_INTERVAL_MS;
if (mode === "outcome") return OUTCOME_FRAME_INTERVAL_MS;
return null;
}
export function sceneTargetFps(mode: SceneRenderMode) {
if (mode === "active") return 60;
if (mode === "outcome") return 30;
return 0;
}
export interface SimulationStepResult {
steps: number;
remainderSeconds: number;
}
export function consumeSimulationSteps(
accumulatorSeconds: number,
elapsedSeconds: number,
): SimulationStepResult {
const boundedElapsed = Math.min(MAX_FRAME_DELTA_SECONDS, Math.max(0, elapsedSeconds));
let remainderSeconds = Math.max(0, accumulatorSeconds) + boundedElapsed;
const steps = Math.min(
MAX_SIMULATION_STEPS_PER_FRAME,
Math.floor((remainderSeconds + Number.EPSILON) / SIMULATION_STEP_SECONDS),
);
remainderSeconds -= steps * SIMULATION_STEP_SECONDS;
return { steps, remainderSeconds };
}
export interface SceneFrameSample {
elapsedMs: number;
elapsedSeconds: number;
outcomeElapsedSeconds: number;
}
export interface SceneFrameLoopOptions {
mode: "active" | "outcome";
initialOutcomeElapsedSeconds?: number;
requestFrame: (callback: FrameRequestCallback) => number;
cancelFrame: (handle: number) => void;
onFrame: (sample: SceneFrameSample) => void;
onOutcomeComplete?: () => void;
}
/**
* Starts the bounded request loop used for active gameplay and finite outcome
* animation tails. Static and suspended modes intentionally have no loop.
*/
export function startSceneFrameLoop({
mode,
initialOutcomeElapsedSeconds = 0,
requestFrame,
cancelFrame,
onFrame,
onOutcomeComplete,
}: SceneFrameLoopOptions) {
const intervalMs = sceneFrameIntervalMs(mode)!;
let frameHandle: number | null = null;
let stopped = false;
let lastRenderedAt: number | null = null;
let outcomeElapsedSeconds = mode === "outcome" ? Math.max(0, initialOutcomeElapsedSeconds) : 0;
const scheduleNext = () => {
frameHandle = requestFrame(runFrame);
};
const runFrame: FrameRequestCallback = (now) => {
if (stopped) return;
const previous = lastRenderedAt;
if (previous === null) {
lastRenderedAt = now;
onFrame({ elapsedMs: 0, elapsedSeconds: 0, outcomeElapsedSeconds });
} else {
const elapsedMs = now - previous;
if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= intervalMs) {
lastRenderedAt = now;
const wallElapsedSeconds = Math.max(0, elapsedMs / 1_000);
const elapsedSeconds = mode === "active"
? Math.min(MAX_FRAME_DELTA_SECONDS, wallElapsedSeconds)
: wallElapsedSeconds;
if (mode === "outcome") outcomeElapsedSeconds += wallElapsedSeconds;
onFrame({ elapsedMs, elapsedSeconds, outcomeElapsedSeconds });
if (mode === "outcome" && outcomeElapsedSeconds >= OUTCOME_RENDER_TAIL_SECONDS) {
stopped = true;
frameHandle = null;
onOutcomeComplete?.();
return;
}
}
}
scheduleNext();
};
scheduleNext();
return () => {
stopped = true;
if (frameHandle !== null) cancelFrame(frameHandle);
frameHandle = null;
};
}
export type ActivePlayfieldKind = Exclude<GameplayActivity, "boss">;
export function activePlayfieldKind(activity: GameplayActivity): ActivePlayfieldKind | null {
return activity === "boss" ? null : activity;
}
export interface FramePerformanceSummary {
mode: SceneRenderMode;
targetFps: number;
budgetMs: number | null;
averageMs: number;
p95Ms: number;
p99Ms: number;
overBudget: number;
samples: number;
}
function percentile(sorted: readonly number[], ratio: number) {
if (!sorted.length) return 0;
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
}
export function summarizeFramePerformance(
mode: SceneRenderMode,
samples: readonly number[],
): FramePerformanceSummary {
const targetFps = sceneTargetFps(mode);
const budgetMs = targetFps > 0 ? 1_000 / targetFps : null;
const sorted = [...samples].sort((left, right) => left - right);
let total = 0;
let overBudget = 0;
for (const duration of samples) {
total += duration;
if (mode === "active" && budgetMs !== null && duration > budgetMs + FRAME_INTERVAL_JITTER_MS) overBudget += 1;
}
return {
mode,
targetFps,
budgetMs,
averageMs: samples.length ? total / samples.length : 0,
p95Ms: percentile(sorted, 0.95),
p99Ms: percentile(sorted, 0.99),
overBudget,
samples: samples.length,
};
}
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from "vitest";
import { createDefaultHealerAppearance } from "../game/healerVisuals";
import { hasPendingSaveSync } from "./saveSync";
import { getFrontendSnapshot, useFrontendStore } from "./store";
const originalState = useFrontendStore.getState();
afterEach(() => {
useFrontendStore.getState().deleteSlot(3);
useFrontendStore.setState({
activeSlotId: originalState.activeSlotId,
selectedSlotId: originalState.selectedSlotId,
screen: originalState.screen,
appearanceClassId: originalState.appearanceClassId,
appearanceDrafts: structuredClone(originalState.appearanceDrafts),
previewMode: originalState.previewMode,
previewAnimation: originalState.previewAnimation,
notice: originalState.notice,
});
});
describe("Appearance Lab frontend state", () => {
it("opens from saved looks, previews drafts, saves explicitly, and cancels unsaved edits", () => {
const frontend = useFrontendStore.getState();
frontend.deleteSlot(3);
expect(frontend.createSlot(3, "Wardrobe Tester")).toBe(true);
frontend.playSlot(3);
useFrontendStore.getState().openAppearanceLab();
let state = useFrontendStore.getState();
const savedPriest = state.slots[2].local!.healers.priest.appearance;
expect(state.screen).toBe("appearance");
expect(state.appearanceClassId).toBe("priest");
expect(state.appearanceDrafts.priest).toEqual(savedPriest);
expect(state.appearanceDrafts.priest).not.toBe(savedPriest);
expect(state.previewMode).toBe("modular");
expect(state.previewAnimation).toBe("idle");
const mixedPriest = {
...state.appearanceDrafts.priest,
headPartId: "rogue-head" as const,
};
state.updateAppearanceDraft(mixedPriest);
state.setAppearancePreviewMode("legacy");
state.setAppearancePreviewAnimation("cast");
state = useFrontendStore.getState();
expect(state.appearanceDrafts.priest.headPartId).toBe("rogue-head");
expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe(
createDefaultHealerAppearance("priest").headPartId,
);
expect(state.previewMode).toBe("legacy");
expect(state.previewAnimation).toBe("cast");
expect(state.saveAppearanceDraft()).toBe(true);
state = useFrontendStore.getState();
expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe("rogue-head");
expect(hasPendingSaveSync(3)).toBe(true);
state.updateAppearanceDraft({
...state.appearanceDrafts.priest,
headPartId: "ranger-head",
});
useFrontendStore.getState().closeAppearanceLab();
state = useFrontendStore.getState();
expect(state.screen).toBe("home");
expect(state.appearanceDrafts.priest.headPartId).toBe("rogue-head");
expect(state.slots[2].local!.healers.priest.appearance.headPartId).toBe("rogue-head");
});
it("resets only the selected class and includes state but no actions in snapshots", () => {
useFrontendStore.setState((state) => ({
appearanceClassId: "paladin",
appearanceDrafts: {
...state.appearanceDrafts,
paladin: { ...state.appearanceDrafts.paladin, headPartId: "rogue-head" },
},
previewMode: "legacy",
previewAnimation: "walk",
}));
useFrontendStore.getState().resetAppearanceDraft();
const snapshot = getFrontendSnapshot();
expect(snapshot.appearanceClassId).toBe("paladin");
expect(snapshot.appearanceDrafts.paladin).toEqual(createDefaultHealerAppearance("paladin"));
expect(snapshot.previewMode).toBe("legacy");
expect(snapshot.previewAnimation).toBe("walk");
expect("openAppearanceLab" in snapshot).toBe(false);
expect("updateAppearanceDraft" in snapshot).toBe(false);
expect("saveAppearanceDraft" in snapshot).toBe(false);
expect("setAppearancePreviewAnimation" in snapshot).toBe(false);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from "vitest";
import { getFrontendSnapshot, useFrontendStore } from "./store";
const originalState = useFrontendStore.getState();
afterEach(() => {
useFrontendStore.setState({
activeSlotId: originalState.activeSlotId,
screen: originalState.screen,
guideClassId: originalState.guideClassId,
guideAbilityId: originalState.guideAbilityId,
notice: originalState.notice,
});
});
describe("Class Help frontend state", () => {
it("opens on ability one, resets selection on class change, and syncs guide state", () => {
useFrontendStore.setState({
activeSlotId: null,
screen: "home",
guideClassId: "shaman",
guideAbilityId: "ability6",
notice: "Old notice",
});
useFrontendStore.getState().openClassHelp();
let state = useFrontendStore.getState();
expect(state.screen).toBe("class-help");
expect(state.guideClassId).toBe("shaman");
expect(state.guideAbilityId).toBe("ability1");
expect(state.notice).toBe("");
state.selectGuideClass("paladin");
state.selectGuideAbility("ability5");
const snapshot = getFrontendSnapshot();
expect(snapshot.guideClassId).toBe("paladin");
expect(snapshot.guideAbilityId).toBe("ability5");
expect("openClassHelp" in snapshot).toBe(false);
expect("selectGuideClass" in snapshot).toBe(false);
expect("selectGuideAbility" in snapshot).toBe(false);
});
});
+17 -3
View File
@@ -1,12 +1,26 @@
import { describe, expect, it } from "vitest";
import { buildCollections, MODE_COPY, selectRandomBoss } from "./data";
import { buildCollections, createHunterSave, MODE_COPY, selectRandomBoss } from "./data";
import { HEALER_CLASS_ORDER } from "../game/healers";
import { HEALER_VISUAL_PROFILES } from "../game/healerVisuals";
import { selectRandomBossPair } from "../game/roguelike";
import { GROUP_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
describe("game mode configuration", () => {
it("separates randomized PVE from selectable Dungeons", () => {
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
it("initializes progression for every playable healer", () => {
const save = createHunterSave(1, "2026-07-16T00:00:00.000Z", "Aelia");
expect(Object.keys(save.healers)).toEqual(HEALER_CLASS_ORDER);
expect(save.healers.paladin.inventory.length).toBeGreaterThan(0);
expect(save.healers.chronomancer.inventory.length).toBeGreaterThan(0);
for (const classId of HEALER_CLASS_ORDER) {
expect(save.healers[classId].appearance).toEqual(HEALER_VISUAL_PROFILES[classId].appearance);
expect(save.healers[classId].appearance).not.toBe(HEALER_VISUAL_PROFILES[classId].appearance);
}
});
it("separates RPG Roguelike from Rogue Trials and selectable Dungeons", () => {
expect(MODE_COPY["roguelike-pve"].title).toBe("RPG Roguelike");
expect(MODE_COPY["roguelike-pve"].description).toContain("Draft");
expect(MODE_COPY["rogue-trials"].detail).toContain("Endless");
expect(MODE_COPY.dungeons.title).toBe("Dungeons");
});
+53 -12
View File
@@ -1,9 +1,11 @@
import type { BossGroupCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "../game/bossCatalog";
import { createClassInventory } from "../game/healers";
import { createClassInventory, HEALER_CLASS_ORDER } from "../game/healers";
import type { BossId } from "../game/types";
import { createDefaultGearProgress } from "../game/progression/gear";
import { BOSS_PET_DROPS, GROUP_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type LootRarity, type MaterialStack } from "../game/progression/loot";
import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker";
import { createDefaultHealerAppearance } from "../game/healerVisuals";
export const DEFAULT_SETTINGS: GameSettings = {
masterVolume: 80,
@@ -66,10 +68,10 @@ export const DEFAULT_COLLECTIONS: BossGroupCollection[] = buildCollections(DEFAU
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
"roguelike-pve": {
eyebrow: "14 hunters · randomized PVE",
title: "PVE",
description: "Enter without an encounter briefing, adapt to two randomized guardians, and build toward a full roguelike run.",
detail: "Two bosses selected when the run begins",
eyebrow: "Solo healer · drafted RPG expedition",
title: "RPG Roguelike",
description: "Draft a random four-companion party and mixed healing spellbook, clear escalating arcade hallways, defeat ten bosses, and build a run-only loadout.",
detail: "Three acts, three shops, and a final guardian",
status: "Playable now",
},
"rogue-trials": {
@@ -86,11 +88,39 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
detail: `${AVAILABLE_BOSS_IDS.length} animated guardians available`,
status: "Playable now",
},
"hockey-healing": {
eyebrow: "14 hunters · endless healer pressure",
title: "Hockey Healing",
description: "Defend a wide goal while healing through two active bosses. Aim returns past a moving Pong paddle that strikes back.",
detail: "Every fallen boss drops loot, rolls its pet chance, then receives a replacement",
status: "Playable now",
},
"hockey-healing-pvp": {
eyebrow: "1v1 healer duel · online queue",
title: "Healing Hockey PVP",
description: "Defend your net, keep your party alive, and race a rival through the same endless boss order in a mirrored hockey arena.",
detail: "Goals deal 45 partywide damage · 5% Dampening per boss · normalized base gear",
status: "Playable now",
},
blockbreaker: {
eyebrow: "14 hunters · endless color-break PVE",
title: "Blockbreaker",
description: "Heal through two endless bosses while aiming a puck into advancing rows of linked color bricks.",
detail: `Breaches deal ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member · survive while four allies stand`,
status: "Playable now",
},
"aether-assault": {
eyebrow: "14 hunters · endless movement-only arcade PVE",
title: "Aether Assault",
description: "Move freely and heal through endless bosses while automatic spellfire cuts through arcane ship formations.",
detail: "No extra buttons · fixed-forward auto-fire · ship strikes damage only the healer",
status: "Playable now",
},
"roguelike-pvp": {
eyebrow: "3v3 · mirrored expeditions",
title: "Roguelike PvP",
description: "Race a rival squad through shifting rooms. Send hazards across the veil while keeping your own formation alive.",
detail: "Draft order, rival pressure, and sudden-death rules",
detail: "Draft order, normalized base gear, rival pressure, and sudden-death rules",
status: "Mode shell ready",
},
"stadium-pvp": {
@@ -120,15 +150,15 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
const normalizedName = normalizeHunterName(hunterName);
if (!normalizedName) throw new Error("Hunter name is required.");
return {
schemaVersion: 5,
schemaVersion: 6,
slotId,
hunterName: normalizedName,
activeClassId: "priest",
healers: {
priest: { level: 1, inventory: createClassInventory("priest") },
druid: { level: 1, inventory: createClassInventory("druid") },
shaman: { level: 1, inventory: createClassInventory("shaman") },
},
healers: Object.fromEntries(HEALER_CLASS_ORDER.map((classId) => [classId, {
level: 1,
inventory: createClassInventory(classId),
appearance: createDefaultHealerAppearance(classId),
}])) as HunterSave["healers"],
location: "Ember Vault Approach",
playSeconds: 0,
updatedAt: now,
@@ -140,6 +170,17 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
bossKills: {},
highestRoguelikeRound: 0,
highestRogueTrialsEndlessKills: 0,
highestHockeyHealingReturns: 0,
longestHockeyHealingSecondsAtBest: 0,
hockeyHealingPvpWins: 0,
hockeyHealingPvpLosses: 0,
hockeyHealingPvpBossKills: 0,
highestBlockbreakerBricks: 0,
longestBlockbreakerSeconds: 0,
highestBlockbreakerScore: 0,
highestAetherAssaultScore: 0,
highestAetherAssaultWaveAtBest: 0,
longestAetherAssaultSecondsAtBest: 0,
},
materials: [] as MaterialStack[],
collectionLog: createEmptyCollectionLog(),
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { LeaderboardCache } from "./leaderboardCache";
import type { LeaderboardResult } from "./onlineRepository";
function memoryStorage() {
const values = new Map<string, string>();
return {
values,
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
};
}
const result: LeaderboardResult = {
kind: "roguelike",
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12 }],
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12 },
};
describe("leaderboard cache", () => {
it("persists rankings for signed-in and offline reads", () => {
const storage = memoryStorage();
const cache = new LeaderboardCache(storage, () => "2026-07-14T00:00:00.000Z");
cache.write("hunter", "Aelia", 1, "roguelike", result);
expect(cache.read(1, "roguelike", "Aelia", "hunter")?.result).toEqual(result);
expect(cache.read(1, "roguelike", "Aelia", null)?.accountId).toBe("hunter");
expect(cache.read(1, "roguelike", "Other", null)).toBeNull();
expect(cache.read(1, "roguelike", "Aelia", "different-account")).toBeNull();
});
it("ignores corrupt persisted responses", () => {
const storage = memoryStorage();
storage.setItem("i-want-to-heal:leaderboards:cache:v1", JSON.stringify({
"1:roguelike": { accountId: "hunter", hunterName: "Aelia", slotId: 1, statId: "roguelike", updatedAt: "today", result: {} },
}));
expect(new LeaderboardCache(storage).read(1, "roguelike", "Aelia", "hunter")).toBeNull();
});
it("preserves Hockey Healing duration tiebreakers", () => {
const cache = new LeaderboardCache(memoryStorage());
const hockeyResult: LeaderboardResult = {
kind: "hockey-healing",
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 20, secondaryValue: 95.5 }],
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 20, secondaryValue: 95.5 },
};
cache.write("hunter", "Aelia", 1, "hockey-healing", hockeyResult);
expect(cache.read(1, "hockey-healing", "Aelia", "hunter")?.result).toEqual(hockeyResult);
});
it("validates Blockbreaker metric boards for offline reads", () => {
const cache = new LeaderboardCache(memoryStorage());
const blockbreakerResult: LeaderboardResult = {
kind: "blockbreaker-score",
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12_500 }],
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 12_500 },
};
cache.write("hunter", "Aelia", 1, "blockbreaker-score", blockbreakerResult);
expect(cache.read(1, "blockbreaker-score", "Aelia", "hunter")?.result).toEqual(blockbreakerResult);
});
it("preserves Aether Assault wave tiebreakers for offline reads", () => {
const cache = new LeaderboardCache(memoryStorage());
const aetherResult: LeaderboardResult = {
kind: "aether-assault",
top: [{ rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 48_500, secondaryValue: 9 }],
current: { rank: 1, username: "hunter", hunterName: "Aelia", slotId: 1, value: 48_500, secondaryValue: 9 },
};
cache.write("hunter", "Aelia", 1, "aether-assault", aetherResult);
expect(cache.read(1, "aether-assault", "Aelia", "hunter")?.result).toEqual(aetherResult);
});
});
+145
View File
@@ -0,0 +1,145 @@
import type { LeaderboardEntry, LeaderboardResult } from "./onlineRepository";
import type { ProfileStatId, SaveSlotId } from "./types";
interface StorageAdapter {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface CachedLeaderboard {
accountId: string;
hunterName: string;
slotId: SaveSlotId;
statId: ProfileStatId;
updatedAt: string;
result: LeaderboardResult;
}
const CACHE_KEY = "i-want-to-heal:leaderboards:cache:v1";
const fallbackMemory = new Map<string, string>();
const fallbackStorage: StorageAdapter = {
getItem: (key) => fallbackMemory.get(key) ?? null,
setItem: (key, value) => { fallbackMemory.set(key, value); },
};
function browserStorage(): StorageAdapter {
try {
if (typeof localStorage !== "undefined") return localStorage;
} catch {
// Android WebView can deny storage before its host is ready.
}
return fallbackStorage;
}
function cacheId(slotId: SaveSlotId, statId: ProfileStatId) {
return `${slotId}:${statId}`;
}
function leaderboardEntry(value: unknown): LeaderboardEntry | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<LeaderboardEntry>;
if (!Number.isInteger(candidate.rank) || Number(candidate.rank) < 1) return null;
if (typeof candidate.username !== "string" || typeof candidate.hunterName !== "string") return null;
if (candidate.slotId !== 1 && candidate.slotId !== 2 && candidate.slotId !== 3) return null;
if (!Number.isFinite(candidate.value) || Number(candidate.value) < 0) return null;
if (candidate.secondaryValue !== undefined && (!Number.isFinite(candidate.secondaryValue) || Number(candidate.secondaryValue) < 0)) return null;
return {
rank: Number(candidate.rank),
username: candidate.username,
hunterName: candidate.hunterName,
slotId: candidate.slotId,
value: Number(candidate.value),
...(candidate.secondaryValue === undefined ? {} : { secondaryValue: Number(candidate.secondaryValue) }),
};
}
function leaderboardResult(value: unknown): LeaderboardResult | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<LeaderboardResult>;
if (candidate.kind !== "boss"
&& candidate.kind !== "roguelike"
&& candidate.kind !== "rogue-trials-endless"
&& candidate.kind !== "hockey-healing"
&& candidate.kind !== "hockey-pvp-wins"
&& candidate.kind !== "hockey-pvp-boss-kills"
&& candidate.kind !== "blockbreaker-bricks"
&& candidate.kind !== "blockbreaker-time"
&& candidate.kind !== "blockbreaker-score"
&& candidate.kind !== "aether-assault") return null;
if (!Array.isArray(candidate.top)) return null;
const top = candidate.top.map(leaderboardEntry);
if (top.some((entry) => !entry)) return null;
const current = candidate.current === null ? null : leaderboardEntry(candidate.current);
if (candidate.current !== null && !current) return null;
return {
kind: candidate.kind,
...(candidate.kind === "boss" && typeof candidate.bossId === "string" ? { bossId: candidate.bossId } : {}),
top: top as LeaderboardEntry[],
current,
};
}
function cachedLeaderboard(value: unknown): CachedLeaderboard | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<CachedLeaderboard>;
if (typeof candidate.accountId !== "string" || typeof candidate.hunterName !== "string") return null;
if (candidate.slotId !== 1 && candidate.slotId !== 2 && candidate.slotId !== 3) return null;
if (typeof candidate.statId !== "string" || typeof candidate.updatedAt !== "string") return null;
if (Number.isNaN(Date.parse(candidate.updatedAt))) return null;
const result = leaderboardResult(candidate.result);
if (!result) return null;
return { ...candidate, result } as CachedLeaderboard;
}
export class LeaderboardCache {
constructor(
private readonly storage: StorageAdapter = browserStorage(),
private readonly now: () => string = () => new Date().toISOString(),
) {}
read(slotId: SaveSlotId, statId: ProfileStatId, hunterName: string, accountId: string | null): CachedLeaderboard | null {
const entry = this.readAll()[cacheId(slotId, statId)];
if (!entry || entry.slotId !== slotId || entry.statId !== statId || entry.hunterName !== hunterName) return null;
if (accountId && entry.accountId !== accountId) return null;
return structuredClone(entry);
}
write(accountId: string, hunterName: string, slotId: SaveSlotId, statId: ProfileStatId, result: LeaderboardResult): CachedLeaderboard {
const entries = this.readAll();
const entry: CachedLeaderboard = {
accountId,
hunterName,
slotId,
statId,
updatedAt: this.now(),
result: structuredClone(result),
};
entries[cacheId(slotId, statId)] = entry;
this.storage.setItem(CACHE_KEY, JSON.stringify(entries));
return structuredClone(entry);
}
clearSlot(slotId: SaveSlotId) {
const entries = this.readAll();
for (const key of Object.keys(entries)) {
if (entries[key].slotId === slotId) delete entries[key];
}
this.storage.setItem(CACHE_KEY, JSON.stringify(entries));
}
private readAll(): Record<string, CachedLeaderboard> {
try {
const raw = this.storage.getItem(CACHE_KEY);
const parsed = raw ? JSON.parse(raw) as Record<string, unknown> : {};
if (!parsed || typeof parsed !== "object") return {};
return Object.fromEntries(Object.entries(parsed).flatMap(([key, value]) => {
const entry = cachedLeaderboard(value);
return entry ? [[key, entry]] : [];
}));
} catch {
return {};
}
}
}
export const leaderboardCache = new LeaderboardCache();
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { onlineRepository } from "./onlineRepository";
import { hasPendingSaveSync } from "./saveSync";
import { useFrontendStore } from "./store";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("offline leaderboard publishing", () => {
it("keeps earned stats pending without attempting an offline upload", async () => {
const original = useFrontendStore.getState();
original.deleteSlot(3);
expect(original.createSlot(3, "Offline Hunter")).toBe(true);
original.playSlot(3);
useFrontendStore.setState({ accountId: "offline-account" });
useFrontendStore.getState().recordBossVictory("bulldrome", "initiate");
useFrontendStore.getState().recordAetherAssaultDefeat(18_750, 6, 214.5);
expect(hasPendingSaveSync(3)).toBe(true);
vi.stubGlobal("navigator", { onLine: false });
const write = vi.spyOn(onlineRepository, "writeSave");
expect(await useFrontendStore.getState().uploadSlot(3)).toBe(false);
expect(write).not.toHaveBeenCalled();
expect(useFrontendStore.getState().slots[2].local?.stats.bossKills.bulldrome).toBe(1);
expect(useFrontendStore.getState().slots[2].local?.stats).toMatchObject({
highestAetherAssaultScore: 18_750,
highestAetherAssaultWaveAtBest: 6,
longestAetherAssaultSecondsAtBest: 214.5,
});
useFrontendStore.getState().deleteSlot(3);
useFrontendStore.setState({
accountId: original.accountId,
activeSlotId: original.activeSlotId,
selectedSlotId: original.selectedSlotId,
screen: original.screen,
notice: original.notice,
});
});
});
+71 -1
View File
@@ -1,6 +1,7 @@
import { Capacitor } from "@capacitor/core";
import type { HunterSave, SaveSlotId } from "./types";
import type { BossId } from "../game/types";
import type { HockeyPvpRemoteSnapshot, HockeyPvpRole } from "../game/hockeyHealingPvp";
export interface OnlineAccount {
id: number;
@@ -19,15 +20,32 @@ export interface LeaderboardEntry {
hunterName: string;
slotId: SaveSlotId;
value: number;
secondaryValue?: number;
}
export interface LeaderboardResult {
kind: "boss" | "roguelike" | "rogue-trials-endless";
kind: "boss" | "roguelike" | "rogue-trials-endless" | "hockey-healing" | "hockey-pvp-wins" | "hockey-pvp-boss-kills" | "blockbreaker-bricks" | "blockbreaker-time" | "blockbreaker-score" | "aether-assault";
bossId?: BossId;
top: LeaderboardEntry[];
current: LeaderboardEntry | null;
}
export interface HockeyPvpQueueResult {
ticketId: string;
status: "waiting" | "matched";
match?: {
id: string;
seed: number;
opponentName: string;
role: Exclude<HockeyPvpRole, "cpu">;
};
}
export interface HockeyPvpExchangeResult {
opponentSnapshot: HockeyPvpRemoteSnapshot | null;
hostSnapshot: HockeyPvpRemoteSnapshot | null;
}
interface TokenStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
@@ -151,6 +169,58 @@ export class OnlineRepository {
rogueTrialsEndlessLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/rogue-trials-endless?slot=${slotId}`);
}
hockeyHealingLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/hockey-healing?slot=${slotId}`);
}
hockeyPvpWinsLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/hockey-pvp-wins?slot=${slotId}`);
}
hockeyPvpBossKillsLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/hockey-pvp-boss-kills?slot=${slotId}`);
}
blockbreakerBricksLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/blockbreaker-bricks?slot=${slotId}`);
}
blockbreakerTimeLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/blockbreaker-time?slot=${slotId}`);
}
blockbreakerScoreLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/blockbreaker-score?slot=${slotId}`);
}
aetherAssaultLeaderboard(slotId: SaveSlotId): Promise<LeaderboardResult> {
return this.request(`/api/leaderboards/aether-assault?slot=${slotId}`);
}
joinHockeyPvpQueue(slotId: SaveSlotId, hunterName: string): Promise<HockeyPvpQueueResult> {
return this.request("/api/hockey-pvp/queue", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slotId, hunterName }),
});
}
pollHockeyPvpQueue(ticketId: string): Promise<HockeyPvpQueueResult> {
return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`);
}
cancelHockeyPvpQueue(ticketId: string): Promise<void> {
return this.request(`/api/hockey-pvp/queue/${encodeURIComponent(ticketId)}`, { method: "DELETE" });
}
exchangeHockeyPvpState(matchId: string, snapshot: HockeyPvpRemoteSnapshot): Promise<HockeyPvpExchangeResult> {
return this.request(`/api/hockey-pvp/matches/${encodeURIComponent(matchId)}/state`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ snapshot }),
});
}
}
export const onlineRepository = new OnlineRepository();
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { buildCollections } from "./data";
import { alphabeticalBosses, defaultStatForSection, profileSectionForStat } from "./profileSections";
describe("hunter profile sections", () => {
const bosses = alphabeticalBosses(buildCollections({ dropsFound: {}, petsFound: {} }, {}));
it("groups related records under one section", () => {
expect(profileSectionForStat("blockbreaker-bricks")).toBe("blockbreaker");
expect(profileSectionForStat("blockbreaker-time")).toBe("blockbreaker");
expect(profileSectionForStat("hockey-pvp-boss-kills")).toBe("hockey-pvp");
expect(profileSectionForStat("aether-assault")).toBe("aether-assault");
expect(profileSectionForStat("bulldrome")).toBe("bosses");
});
it("builds one alphabetical boss index across mechanic groups", () => {
const names = bosses.map((boss) => boss.bossName);
expect(names).toEqual([...names].sort((left, right) => left.localeCompare(right)));
expect(new Set(bosses.map((boss) => boss.bossId)).size).toBe(bosses.length);
});
it("opens each section on its primary record", () => {
expect(defaultStatForSection("hockey-pvp", bosses)).toBe("hockey-pvp-wins");
expect(defaultStatForSection("blockbreaker", bosses)).toBe("blockbreaker-score");
expect(defaultStatForSection("aether-assault", bosses)).toBe("aether-assault");
expect(defaultStatForSection("bosses", bosses)).toBe(bosses[0].bossId);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { AVAILABLE_BOSS_IDS } from "../game/bossCatalog";
import type { BossId } from "../game/types";
import type { GroupBossCollection, ProfileStatId } from "./types";
export type ProfileSectionId = "roguelike" | "rogue-trials" | "hockey" | "hockey-pvp" | "blockbreaker" | "aether-assault" | "bosses";
export interface ProfileSectionDefinition {
id: ProfileSectionId;
label: string;
copy: string;
icon: string;
statIds: readonly ProfileStatId[];
}
export const PROFILE_SECTIONS: readonly ProfileSectionDefinition[] = [
{ id: "roguelike", label: "Roguelike", copy: "Highest completed round", icon: "∞", statIds: ["roguelike"] },
{ id: "rogue-trials", label: "Trials Endless", copy: "Best endless boss run", icon: "Ⅲ", statIds: ["rogue-trials-endless"] },
{ id: "hockey", label: "Hockey", copy: "Returns and survival", icon: "◌", statIds: ["hockey-healing"] },
{ id: "hockey-pvp", label: "Hockey PVP", copy: "Record and race kills", icon: "◇", statIds: ["hockey-pvp-wins", "hockey-pvp-boss-kills"] },
{ id: "blockbreaker", label: "Blockbreaker", copy: "Score, bricks, survival", icon: "▦", statIds: ["blockbreaker-score", "blockbreaker-bricks", "blockbreaker-time"] },
{ id: "aether-assault", label: "Aether Assault", copy: "Score, wave, survival", icon: "⌁", statIds: ["aether-assault"] },
{ id: "bosses", label: "Bosses", copy: "Kills and boss pets", icon: "♛", statIds: [] },
] as const;
const BOSS_IDS = new Set<string>(AVAILABLE_BOSS_IDS);
export function isBossProfileStat(statId: ProfileStatId): statId is BossId {
return BOSS_IDS.has(statId);
}
export function profileSectionForStat(statId: ProfileStatId): ProfileSectionId {
if (isBossProfileStat(statId)) return "bosses";
return PROFILE_SECTIONS.find((section) => section.statIds.includes(statId))?.id ?? "roguelike";
}
export function alphabeticalBosses(groups: readonly { bosses: readonly GroupBossCollection[] }[]): GroupBossCollection[] {
return groups
.flatMap((group) => group.bosses)
.sort((left, right) => left.bossName.localeCompare(right.bossName));
}
export function defaultStatForSection(sectionId: ProfileSectionId, bosses: readonly GroupBossCollection[]): ProfileStatId {
if (sectionId === "bosses") return bosses[0]?.bossId ?? "roguelike";
return PROFILE_SECTIONS.find((section) => section.id === sectionId)?.statIds[0] ?? "roguelike";
}
+150 -10
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { SaveRepository, type StorageAdapter } from "./saveRepository";
import { groupDrop } from "../game/progression/loot";
import { RUN_BUFF_ORDER } from "../game/roguelike";
import { HEALER_VISUAL_PROFILES } from "../game/healerVisuals";
function memoryStorage(): StorageAdapter {
const data = new Map<string, string>();
@@ -45,10 +46,22 @@ describe("SaveRepository", () => {
const repository = new SaveRepository(memoryStorage(), () => now);
const serverSave = repository.create(1, "Aelia");
serverSave.healers.priest.level = 40;
const legacyStats = structuredClone(serverSave.stats) as unknown as Record<string, unknown>;
delete legacyStats.highestAetherAssaultScore;
delete legacyStats.highestAetherAssaultWaveAtBest;
delete legacyStats.longestAetherAssaultSecondsAtBest;
now = "2026-07-10T14:00:00.000Z";
repository.replaceLocal(serverSave);
repository.replaceLocal({ ...serverSave, schemaVersion: 5, stats: legacyStats } as never);
expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40);
expect(repository.listLocal()[0].local?.updatedAt).toBe(now);
expect(repository.listLocal()[0].local).toMatchObject({
schemaVersion: 6,
stats: {
highestAetherAssaultScore: 0,
highestAetherAssaultWaveAtBest: 0,
longestAetherAssaultSecondsAtBest: 0,
},
});
});
it("deletes the local copy without inventing an online record", () => {
@@ -68,7 +81,11 @@ describe("SaveRepository", () => {
activeClassId: "druid",
healers: {
...save.healers,
druid: { level: 8, inventory: [...save.healers.druid.inventory, { ...save.healers.druid.inventory[0], id: "druid-drop" }] },
druid: {
...save.healers.druid,
level: 8,
inventory: [...save.healers.druid.inventory, { ...save.healers.druid.inventory[0], id: "druid-drop" }],
},
},
}));
@@ -81,7 +98,95 @@ describe("SaveRepository", () => {
expect(save.healers.priest.inventory).toHaveLength(4);
});
it("resets every legacy save into fresh v5 progression while preserving identity and timestamp", () => {
it("adds default appearances to existing v6 saves without resetting progression or timestamps", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-16T12:00:00.000Z");
const created = repository.create(1, "Veteran");
const legacy = structuredClone(created) as unknown as {
updatedAt: string;
healers: Record<string, { level: number; inventory: unknown[]; appearance?: unknown }>;
};
legacy.healers.priest.level = 37;
legacy.updatedAt = "2026-07-15T09:30:00.000Z";
for (const healer of Object.values(legacy.healers)) delete healer.appearance;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(6);
expect(migrated.updatedAt).toBe("2026-07-15T09:30:00.000Z");
expect(migrated.healers.priest.level).toBe(37);
for (const [classId, profile] of Object.entries(HEALER_VISUAL_PROFILES)) {
expect(migrated.healers[classId as keyof typeof migrated.healers].appearance).toEqual(profile.appearance);
}
const persisted = JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}") as Record<string, typeof migrated>;
expect(persisted["1"].updatedAt).toBe("2026-07-15T09:30:00.000Z");
expect(persisted["1"].healers.paladin.appearance).toEqual(HEALER_VISUAL_PROFILES.paladin.appearance);
});
it("repairs corrupt appearance fields without resetting safe choices or another healer", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-16T12:00:00.000Z");
const created = repository.create(1, "Mixer");
const druidAppearance = structuredClone(created.healers.druid.appearance);
const corrupt = structuredClone(created) as unknown as Record<string, unknown>;
const corruptHealers = (corrupt.healers as Record<string, Record<string, unknown>>);
corruptHealers.priest.level = 22;
corruptHealers.priest.appearance = {
version: 1,
rigId: "medium",
scaleSourceMemberId: "unknown-member",
headPartId: "knight-upper",
upperBodyPartId: "knight-upper",
lowerBodyPartId: "unknown-lower",
headwearPartId: "druid-backpack",
backPartId: "ranger-cape",
mainHand: { modelId: "sword", grip: "staff" },
offHand: { modelId: "unknown-weapon", grip: "prop" },
};
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: corrupt }));
const normalized = repository.listLocal()[0].local!;
expect(normalized.healers.priest.level).toBe(22);
expect(normalized.healers.priest.appearance).toEqual({
...HEALER_VISUAL_PROFILES.priest.appearance,
upperBodyPartId: "knight-upper",
backPartId: "ranger-cape",
mainHand: { modelId: "cc/adv_sword_1handed", grip: "upright" },
});
expect(normalized.healers.priest.appearance.offHand).toBeUndefined();
expect(normalized.healers.druid.appearance).toEqual(druidAppearance);
});
it("round-trips and deep-copies a valid mixed appearance", () => {
const repository = new SaveRepository(memoryStorage(), () => "2026-07-16T12:00:00.000Z");
repository.create(1, "Mixer");
repository.updateLocal(1, (save) => ({
...save,
healers: {
...save.healers,
priest: {
...save.healers.priest,
appearance: {
...save.healers.priest.appearance,
headPartId: "rogue-head",
upperBodyPartId: "knight-upper",
lowerBodyPartId: "ranger-lower",
headwearPartId: "mage-hat",
backPartId: "druid-backpack",
mainHand: { modelId: "cc/wand_b", grip: "wand" },
offHand: { modelId: "cc/spellbook_open", grip: "prop" },
},
},
},
}));
repository.copyLocal(1, 2);
const [source, copy] = repository.listLocal().map((slot) => slot.local);
expect(copy?.healers.priest.appearance).toEqual(source?.healers.priest.appearance);
expect(copy?.healers.priest.appearance).not.toBe(source?.healers.priest.appearance);
expect(copy?.healers.priest.appearance.mainHand).not.toBe(source?.healers.priest.appearance.mainHand);
});
it("resets every legacy save into fresh v6 progression while preserving identity and timestamp", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Legacy");
@@ -103,16 +208,35 @@ describe("SaveRepository", () => {
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(5);
expect(migrated.schemaVersion).toBe(6);
expect(migrated.hunterName).toBe("Legacy");
expect(migrated.activeClassId).toBe("priest");
expect(migrated.playSeconds).toBe(0);
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0, highestRogueTrialsEndlessKills: 0 });
expect(migrated.stats).toEqual({
totalBossKills: 0,
flawlessClears: 0,
alliesSaved: 0,
healingDone: 0,
bossKills: {},
highestRoguelikeRound: 0,
highestRogueTrialsEndlessKills: 0,
highestHockeyHealingReturns: 0,
longestHockeyHealingSecondsAtBest: 0,
hockeyHealingPvpWins: 0,
hockeyHealingPvpLosses: 0,
hockeyHealingPvpBossKills: 0,
highestBlockbreakerBricks: 0,
longestBlockbreakerSeconds: 0,
highestBlockbreakerScore: 0,
highestAetherAssaultScore: 0,
highestAetherAssaultWaveAtBest: 0,
longestAetherAssaultSecondsAtBest: 0,
});
expect(migrated.materials).toEqual([]);
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(5);
expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(6);
});
it("preserves valid v5 progression and group-drop inventory", () => {
@@ -123,15 +247,31 @@ describe("SaveRepository", () => {
created.healers.priest.level = 8;
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
created.stats.highestRogueTrialsEndlessKills = 14;
created.stats.highestHockeyHealingReturns = 31;
created.stats.longestHockeyHealingSecondsAtBest = 188.5;
created.stats.highestBlockbreakerBricks = 52;
created.stats.longestBlockbreakerSeconds = 245.25;
created.stats.highestBlockbreakerScore = 9_800;
created.stats.highestAetherAssaultScore = 12_400;
created.stats.highestAetherAssaultWaveAtBest = 7;
created.stats.longestAetherAssaultSecondsAtBest = 191.5;
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } }));
const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(5);
expect(migrated.schemaVersion).toBe(6);
expect(migrated.healers.priest.level).toBe(8);
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14);
expect(migrated.stats.highestHockeyHealingReturns).toBe(31);
expect(migrated.stats.longestHockeyHealingSecondsAtBest).toBe(188.5);
expect(migrated.stats.highestBlockbreakerBricks).toBe(52);
expect(migrated.stats.longestBlockbreakerSeconds).toBe(245.25);
expect(migrated.stats.highestBlockbreakerScore).toBe(9_800);
expect(migrated.stats.highestAetherAssaultScore).toBe(12_400);
expect(migrated.stats.highestAetherAssaultWaveAtBest).toBe(7);
expect(migrated.stats.longestAetherAssaultSecondsAtBest).toBe(191.5);
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
expect(migrated.collectionLog).toEqual(created.collectionLog);
});
@@ -145,10 +285,10 @@ describe("SaveRepository", () => {
created.gearProgress.druid.passiveInfusionId = "mend-echo";
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
created.gearProgress.brann.passiveInfusionId = "deep-wells" as never;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 5 } }));
const migrated = repository.listLocal()[0].local!;
expect(migrated.schemaVersion).toBe(5);
expect(migrated.schemaVersion).toBe(6);
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull();
expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo");
+18 -5
View File
@@ -1,10 +1,11 @@
import { createHunterSave } from "./data";
import { createClassInventory } from "../game/healers";
import { createClassInventory, HEALER_CLASS_ORDER } from "../game/healers";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "../game/bossCatalog";
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL, type GearProgress } from "../game/progression/gear";
import { normalizeActiveInfusionId, normalizePassiveInfusionId } from "../game/progression/infusions";
import { GROUP_DROP_TABLES, type CollectionLog, type MaterialStack } from "../game/progression/loot";
import type { BossId, HealerClassId } from "../game/types";
import { normalizeHealerAppearance } from "../game/healerVisuals";
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
export interface StorageAdapter {
@@ -48,7 +49,7 @@ interface LegacyHunterSave {
gearProgress?: GearProgress;
}
const HEALER_IDS: HealerClassId[] = ["priest", "druid", "shaman"];
const HEALER_IDS: readonly HealerClassId[] = HEALER_CLASS_ORDER;
function positiveCounts(value: unknown): Record<string, number> {
if (!value || typeof value !== "object") return {};
@@ -121,7 +122,7 @@ function normalizeSave(value: unknown): HunterSave | null {
if (!value || typeof value !== "object") return null;
const candidate = value as LegacyHunterSave;
if (!candidate.slotId || !candidate.hunterName) return null;
if (candidate.schemaVersion !== 5) {
if (candidate.schemaVersion !== 5 && candidate.schemaVersion !== 6) {
try {
return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName);
} catch {
@@ -132,13 +133,14 @@ function normalizeSave(value: unknown): HunterSave | null {
const bossKills = normalizeBossKills(candidate.stats?.bossKills);
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
return {
schemaVersion: 5,
schemaVersion: 6,
slotId: candidate.slotId,
hunterName: candidate.hunterName,
activeClassId,
healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, {
level: Math.max(1, candidate.healers?.[classId]?.level ?? (classId === "priest" ? candidate.level ?? 1 : 1)),
inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId),
appearance: normalizeHealerAppearance(classId, candidate.healers?.[classId]?.appearance),
}])) as HunterSave["healers"],
location: candidate.location ?? "Ember Vault Approach",
playSeconds: Math.max(0, candidate.playSeconds ?? 0),
@@ -151,6 +153,17 @@ function normalizeSave(value: unknown): HunterSave | null {
bossKills,
highestRoguelikeRound: Math.max(0, Math.floor(candidate.stats?.highestRoguelikeRound ?? 0)),
highestRogueTrialsEndlessKills: Math.max(0, Math.floor(candidate.stats?.highestRogueTrialsEndlessKills ?? 0)),
highestHockeyHealingReturns: Math.max(0, Math.floor(candidate.stats?.highestHockeyHealingReturns ?? 0)),
longestHockeyHealingSecondsAtBest: Math.max(0, Number(candidate.stats?.longestHockeyHealingSecondsAtBest) || 0),
hockeyHealingPvpWins: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpWins ?? 0)),
hockeyHealingPvpLosses: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpLosses ?? 0)),
hockeyHealingPvpBossKills: Math.max(0, Math.floor(candidate.stats?.hockeyHealingPvpBossKills ?? 0)),
highestBlockbreakerBricks: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerBricks ?? 0)),
longestBlockbreakerSeconds: Math.max(0, Number(candidate.stats?.longestBlockbreakerSeconds) || 0),
highestBlockbreakerScore: Math.max(0, Math.floor(candidate.stats?.highestBlockbreakerScore ?? 0)),
highestAetherAssaultScore: Math.max(0, Math.floor(candidate.stats?.highestAetherAssaultScore ?? 0)),
highestAetherAssaultWaveAtBest: Math.max(0, Math.floor(candidate.stats?.highestAetherAssaultWaveAtBest ?? 0)),
longestAetherAssaultSecondsAtBest: Math.max(0, Number(candidate.stats?.longestAetherAssaultSecondsAtBest) || 0),
},
materials: normalizeMaterials(candidate.materials, collectionLog),
collectionLog,
@@ -224,7 +237,7 @@ export class SaveRepository {
}
replaceLocal(save: HunterSave): HunterSave {
const normalized = { ...cloneSave(save), updatedAt: this.now() };
const normalized = { ...(normalizeSave(save) ?? cloneSave(save)), updatedAt: this.now() };
this.setLocal(normalized);
return normalized;
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { PendingSaveSyncRepository, SAVE_SYNC_RETRY_DELAYS_MS, SaveSyncRetryCoordinator } from "./saveSync";
function memoryStorage(initial = "[]") {
const values = new Map([["i-want-to-heal:saves:pending-sync:v1", initial]]);
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
};
}
describe("pending save sync", () => {
it("persists unique dirty slots and tolerates corrupt state", () => {
const storage = memoryStorage();
const pending = new PendingSaveSyncRepository(storage);
pending.mark(2);
pending.mark(2);
pending.mark(1);
expect(pending.list()).toEqual([2, 1]);
pending.clear(2);
expect(pending.list()).toEqual([1]);
expect(new PendingSaveSyncRepository(memoryStorage("not-json")).list()).toEqual([]);
});
it("waits offline and caps automatic retries", async () => {
const pending = new PendingSaveSyncRepository(memoryStorage());
pending.mark(1);
let online = false;
const uploads: number[] = [];
const tasks: Array<{ run: () => void; delay: number }> = [];
const coordinator = new SaveSyncRetryCoordinator(
pending,
async (slotId) => { uploads.push(slotId); return false; },
() => online,
(run, delay) => {
tasks.push({ run, delay });
return tasks.length as unknown as ReturnType<typeof setTimeout>;
},
() => undefined,
);
coordinator.flush();
expect(uploads).toEqual([]);
online = true;
coordinator.flush(true);
await Promise.resolve();
expect(uploads).toEqual([1]);
for (const delay of SAVE_SYNC_RETRY_DELAYS_MS) {
coordinator.failed(1);
expect(tasks[0]?.delay).toBe(delay);
tasks.shift()!.run();
await Promise.resolve();
}
coordinator.failed(1);
expect(tasks).toEqual([]);
expect(uploads).toHaveLength(1 + SAVE_SYNC_RETRY_DELAYS_MS.length);
coordinator.dispose();
});
});
+153
View File
@@ -0,0 +1,153 @@
import type { SaveSlotId } from "./types";
interface StorageAdapter {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
type TimerHandle = ReturnType<typeof setTimeout>;
type UploadSave = (slotId: SaveSlotId) => Promise<boolean>;
const PENDING_KEY = "i-want-to-heal:saves:pending-sync:v1";
export const SAVE_SYNC_RETRY_DELAYS_MS = [2_000, 10_000, 30_000, 120_000] as const;
const fallbackMemory = new Map<string, string>();
const fallbackStorage: StorageAdapter = {
getItem: (key) => fallbackMemory.get(key) ?? null,
setItem: (key, value) => { fallbackMemory.set(key, value); },
};
function browserStorage(): StorageAdapter {
try {
if (typeof localStorage !== "undefined") return localStorage;
} catch {
// Android WebView can deny storage before its host is ready.
}
return fallbackStorage;
}
function isSlotId(value: number): value is SaveSlotId {
return value === 1 || value === 2 || value === 3;
}
export function networkAppearsOnline() {
return typeof navigator === "undefined" || navigator.onLine !== false;
}
export class PendingSaveSyncRepository {
constructor(private readonly storage: StorageAdapter = browserStorage()) {}
list(): SaveSlotId[] {
try {
const parsed = JSON.parse(this.storage.getItem(PENDING_KEY) ?? "[]") as unknown;
if (!Array.isArray(parsed)) return [];
return [...new Set(parsed.map(Number).filter(isSlotId))];
} catch {
return [];
}
}
has(slotId: SaveSlotId) {
return this.list().includes(slotId);
}
mark(slotId: SaveSlotId) {
const slots = this.list();
if (!slots.includes(slotId)) this.write([...slots, slotId]);
}
clear(slotId: SaveSlotId) {
this.write(this.list().filter((candidate) => candidate !== slotId));
}
private write(slots: readonly SaveSlotId[]) {
this.storage.setItem(PENDING_KEY, JSON.stringify(slots));
}
}
export class SaveSyncRetryCoordinator {
private readonly attempts = new Map<SaveSlotId, number>();
private readonly timers = new Map<SaveSlotId, TimerHandle>();
private readonly active = new Set<SaveSlotId>();
private disposed = false;
constructor(
private readonly pending: PendingSaveSyncRepository,
private readonly upload: UploadSave,
private readonly isOnline: () => boolean = networkAppearsOnline,
private readonly schedule: (run: () => void, delay: number) => TimerHandle = setTimeout,
private readonly cancel: (timer: TimerHandle) => void = clearTimeout,
) {}
flush(resetAttempts = false) {
if (resetAttempts) this.attempts.clear();
if (!this.isOnline()) return;
for (const slotId of this.pending.list()) this.run(slotId);
}
failed(slotId: SaveSlotId) {
if (this.disposed || !this.pending.has(slotId) || !this.isOnline() || this.timers.has(slotId)) return;
const attempt = this.attempts.get(slotId) ?? 0;
const delay = SAVE_SYNC_RETRY_DELAYS_MS[attempt];
if (delay === undefined) return;
this.attempts.set(slotId, attempt + 1);
const timer = this.schedule(() => {
this.timers.delete(slotId);
this.run(slotId);
}, delay);
this.timers.set(slotId, timer);
}
succeeded(slotId: SaveSlotId) {
this.attempts.delete(slotId);
const timer = this.timers.get(slotId);
if (timer !== undefined) this.cancel(timer);
this.timers.delete(slotId);
}
dispose() {
this.disposed = true;
for (const timer of this.timers.values()) this.cancel(timer);
this.timers.clear();
this.active.clear();
}
private run(slotId: SaveSlotId) {
if (this.disposed || this.active.has(slotId) || this.timers.has(slotId) || !this.pending.has(slotId) || !this.isOnline()) return;
this.active.add(slotId);
void this.upload(slotId).finally(() => this.active.delete(slotId));
}
}
const pendingSaveSync = new PendingSaveSyncRepository();
let activeCoordinator: SaveSyncRetryCoordinator | null = null;
export function markSaveSyncPending(slotId: SaveSlotId) {
pendingSaveSync.mark(slotId);
}
export function hasPendingSaveSync(slotId: SaveSlotId) {
return pendingSaveSync.has(slotId);
}
export function clearSaveSyncPending(slotId: SaveSlotId) {
pendingSaveSync.clear(slotId);
activeCoordinator?.succeeded(slotId);
}
export function scheduleSaveSyncRetry(slotId: SaveSlotId) {
activeCoordinator?.failed(slotId);
}
export function startSaveSyncCoordinator(upload: UploadSave) {
const coordinator = new SaveSyncRetryCoordinator(pendingSaveSync, upload);
activeCoordinator?.dispose();
activeCoordinator = coordinator;
const onOnline = () => coordinator.flush(true);
window.addEventListener("online", onOnline);
coordinator.flush();
return () => {
window.removeEventListener("online", onOnline);
coordinator.dispose();
if (activeCoordinator === coordinator) activeCoordinator = null;
};
}
+356 -18
View File
@@ -2,9 +2,27 @@ import { create } from "zustand";
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
import { SaveRepository } from "./saveRepository";
import { AccountRepository, type AccountResult } from "./accountRepository";
import { onlineRepository, type OnlineSaveSlot } from "./onlineRepository";
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
import type { AbilityId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types";
import { OnlineApiError, onlineRepository, type OnlineSaveSlot } from "./onlineRepository";
import { leaderboardCache } from "./leaderboardCache";
import {
clearSaveSyncPending,
markSaveSyncPending,
networkAppearsOnline,
scheduleSaveSyncRetry,
} from "./saveSync";
import type {
AppScreen,
GameModeId,
GameSettings,
HunterSave,
ProfileCollectionView,
ProfileStatId,
SaveSlotId,
SaveSlotState,
} from "./types";
import type { BossGroupId } from "../game/bossCatalog";
import type { AbilitySlotId, BossId, HealerClassId, InventoryItem, RunBuffId } from "../game/types";
import { HEALER_CLASS_ORDER } from "../game/healers";
import { RUN_BUFF_ORDER, RUN_BUFFS } from "../game/roguelike";
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
import {
@@ -13,20 +31,27 @@ import {
infusionsForOwner,
} from "../game/progression/infusions";
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
import { highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat } from "../game/progression/hunterStats";
import { bestAetherAssaultRecord, bestBlockbreakerRecords, bestHockeyHealingRecord, highestEndlessBossKillsAfterDefeat, highestRoguelikeRoundAfterDefeat, hockeyPvpRecordAfterMatch } from "../game/progression/hunterStats";
import {
cloneCharacterAppearance,
type CharacterAppearanceV1,
type CharacterModelMode,
} from "../game/characterAppearance";
import { createDefaultHealerAppearance, normalizeHealerAppearance } from "../game/healerVisuals";
const repository = new SaveRepository();
const accounts = new AccountRepository();
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
const onlineSaveQueues = new Map<SaveSlotId, Promise<HunterSave>>();
const onlineSaveQueues = new Map<SaveSlotId, { updatedAt: string; promise: Promise<HunterSave> }>();
function writeServerSaveSerially(save: HunterSave): Promise<HunterSave> {
const previous = onlineSaveQueues.get(save.slotId);
const next = (previous ? previous.catch(() => save) : Promise.resolve(save))
if (previous?.updatedAt === save.updatedAt) return previous.promise;
const next = (previous ? previous.promise.catch(() => save) : Promise.resolve(save))
.then(() => onlineRepository.writeSave(save));
onlineSaveQueues.set(save.slotId, next);
onlineSaveQueues.set(save.slotId, { updatedAt: save.updatedAt, promise: next });
void next.finally(() => {
if (onlineSaveQueues.get(save.slotId) === next) onlineSaveQueues.delete(save.slotId);
if (onlineSaveQueues.get(save.slotId)?.promise === next) onlineSaveQueues.delete(save.slotId);
}).catch(() => undefined);
return next;
}
@@ -76,6 +101,16 @@ function replaceOnlineSlot(current: readonly SaveSlotState[], save: HunterSave):
return refreshLocalSlots(current).map((slot) => slot.id === save.slotId ? { ...slot, online: save } : slot);
}
export type AppearancePreviewAnimation = "idle" | "walk" | "cast";
export type AppearanceDrafts = Record<HealerClassId, CharacterAppearanceV1>;
function appearanceDraftsFor(save: HunterSave | null): AppearanceDrafts {
return Object.fromEntries(HEALER_CLASS_ORDER.map((classId) => [
classId,
cloneCharacterAppearance(save?.healers[classId].appearance ?? createDefaultHealerAppearance(classId)),
])) as AppearanceDrafts;
}
export interface FrontendState {
screen: AppScreen;
accountId: string | null;
@@ -89,8 +124,17 @@ export interface FrontendState {
selectedGearSlotId: GearSlotId;
gearWorkshopMode: "upgrade" | "infusion";
selectedInfusionId: string;
selectedPassiveAbilityId: AbilityId;
selectedPassiveAbilityId: AbilitySlotId;
selectedPassiveInfusionId: RunBuffId;
guideClassId: HealerClassId;
guideAbilityId: AbilitySlotId;
profileCollectionView: ProfileCollectionView;
selectedProfileGroupId: BossGroupId;
selectedProfileStatId: ProfileStatId;
appearanceClassId: HealerClassId;
appearanceDrafts: AppearanceDrafts;
previewMode: CharacterModelMode;
previewAnimation: AppearancePreviewAnimation;
recentRewards: BossRewardAward[];
settings: GameSettings;
notice: string;
@@ -105,7 +149,7 @@ export interface FrontendState {
playSlot: (slotId: SaveSlotId) => void;
deleteSlot: (slotId: SaveSlotId) => void;
copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void;
uploadSlot: (slotId: SaveSlotId) => Promise<void>;
uploadSlot: (slotId: SaveSlotId) => Promise<boolean>;
downloadSlot: (slotId: SaveSlotId) => Promise<void>;
selectMode: (mode: GameModeId) => void;
selectBoss: (bossId: BossId) => void;
@@ -114,8 +158,22 @@ export interface FrontendState {
selectGearSlot: (slotId: GearSlotId) => void;
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
selectInfusion: (infusionId: string) => void;
selectPassiveAbility: (abilityId: AbilityId) => void;
selectPassiveAbility: (abilityId: AbilitySlotId) => void;
selectPassiveInfusion: (passiveId: RunBuffId) => void;
openClassHelp: () => void;
selectGuideClass: (classId: HealerClassId) => void;
selectGuideAbility: (abilityId: AbilitySlotId) => void;
selectProfileCollectionView: (view: ProfileCollectionView) => void;
selectProfileGroup: (groupId: BossGroupId) => void;
selectProfileStat: (statId: ProfileStatId) => void;
openAppearanceLab: () => void;
selectAppearanceClass: (classId: HealerClassId) => void;
updateAppearanceDraft: (appearance: CharacterAppearanceV1) => void;
resetAppearanceDraft: () => void;
saveAppearanceDraft: () => boolean;
closeAppearanceLab: () => void;
setAppearancePreviewMode: (mode: CharacterModelMode) => void;
setAppearancePreviewAnimation: (animation: AppearancePreviewAnimation) => void;
upgradeSelectedGear: () => boolean;
equipSelectedInfusion: () => boolean;
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
@@ -126,6 +184,11 @@ export interface FrontendState {
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
recordRoguelikeDefeat: (round: number) => void;
recordRogueTrialsEndlessDefeat: (bossKills: number) => void;
recordHockeyHealingDefeat: (returns: number, durationSeconds: number) => void;
recordHockeyPvpResult: (won: boolean) => void;
recordHockeyPvpBossKill: () => void;
recordBlockbreakerDefeat: (bricks: number, durationSeconds: number, score: number) => void;
recordAetherAssaultDefeat: (score: number, wave: number, durationSeconds: number) => void;
clearRecentRewards: () => void;
clearNotice: () => void;
}
@@ -147,8 +210,17 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
selectedGearSlotId: "weapon",
gearWorkshopMode: "upgrade",
selectedInfusionId: infusionsForOwner("priest")[0].id,
selectedPassiveAbilityId: "mend",
selectedPassiveAbilityId: "ability1",
selectedPassiveInfusionId: "mend-echo",
guideClassId: "priest",
guideAbilityId: "ability1",
profileCollectionView: "stats",
selectedProfileGroupId: "charge",
selectedProfileStatId: "roguelike",
appearanceClassId: "priest",
appearanceDrafts: appearanceDraftsFor(null),
previewMode: "modular",
previewAnimation: "idle",
recentRewards: [],
settings: loadSettings(),
notice: "",
@@ -200,7 +272,15 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
void accounts.logout();
set({ accountId: null, slots: repository.listLocal(), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." });
},
navigate: (screen) => set({ screen, notice: "" }),
navigate: (screen) => set(screen === "profile"
? {
screen,
profileCollectionView: "stats",
selectedProfileGroupId: "charge",
selectedProfileStatId: "roguelike",
notice: "",
}
: { screen, notice: "" }),
selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }),
createSlot: (slotId, rawHunterName) => {
const hunterName = normalizeHunterName(rawHunterName);
@@ -209,6 +289,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
return false;
}
repository.create(slotId, hunterName);
leaderboardCache.clearSlot(slotId);
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` }));
return true;
},
@@ -219,6 +300,8 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
},
deleteSlot: (slotId) => {
repository.deleteLocal(slotId);
leaderboardCache.clearSlot(slotId);
clearSaveSyncPending(slotId);
set((state) => ({
slots: refreshLocalSlots(state.slots),
activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId,
@@ -228,30 +311,48 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
copySlot: (sourceId, targetId) => {
const copy = repository.copyLocal(sourceId, targetId);
if (!copy) return;
leaderboardCache.clearSlot(targetId);
set((state) => ({ slots: refreshLocalSlots(state.slots), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` }));
},
uploadSlot: async (slotId) => {
const { accountId } = get();
if (!accountId) return set({ notice: "Sign in before syncing online." });
if (!accountId) {
set({ notice: "Sign in before syncing online." });
return false;
}
const local = repository.listLocal().find((slot) => slot.id === slotId)?.local;
if (!local) return set({ notice: "No offline save to sync." });
if (!local) {
set({ notice: "No offline save to sync." });
return false;
}
markSaveSyncPending(slotId);
if (!networkAppearsOnline()) {
set({ notice: `Slot ${slotId} saved locally. Online sync waits for connection.` });
return false;
}
try {
const uploaded = await writeServerSaveSerially(local);
if (get().accountId === accountId) {
set((state) => ({ slots: replaceOnlineSlot(state.slots, uploaded), notice: `Slot ${slotId} synced to TrueNAS.` }));
}
clearSaveSyncPending(slotId);
return true;
} catch (error) {
const retryable = !(error instanceof OnlineApiError) || error.status === 0 || error.status >= 500;
if (retryable) scheduleSaveSyncRetry(slotId);
set({ notice: error instanceof Error ? error.message : "Save upload failed." });
return false;
}
},
downloadSlot: async (slotId) => {
const { accountId } = get();
if (!accountId) return set({ notice: "Sign in before downloading an online save." });
try {
await onlineSaveQueues.get(slotId)?.catch(() => undefined);
await onlineSaveQueues.get(slotId)?.promise.catch(() => undefined);
const serverSave = await onlineRepository.readSave(slotId);
if (!serverSave) return set({ notice: "No online version exists for this slot." });
const downloaded = repository.replaceLocal(serverSave);
clearSaveSyncPending(slotId);
if (get().accountId === accountId) {
set((state) => ({ slots: replaceOnlineSlot(state.slots, downloaded), notice: `Slot ${slotId} downloaded from TrueNAS.` }));
}
@@ -271,14 +372,99 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
selectPassiveAbility: (selectedPassiveAbilityId) => {
const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilityId === selectedPassiveAbilityId) ?? "mend-echo";
const selectedPassiveInfusionId = RUN_BUFF_ORDER.find((id) => RUN_BUFFS[id].abilitySlotId === selectedPassiveAbilityId) ?? "mend-echo";
set({ selectedPassiveAbilityId, selectedPassiveInfusionId, notice: "" });
},
selectPassiveInfusion: (selectedPassiveInfusionId) => set({
selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilityId,
selectedPassiveAbilityId: RUN_BUFFS[selectedPassiveInfusionId].abilitySlotId,
selectedPassiveInfusionId,
notice: "",
}),
openClassHelp: () => {
const hunter = activeSave(get().slots, get().activeSlotId);
set({
screen: "class-help",
guideClassId: hunter?.activeClassId ?? get().guideClassId,
guideAbilityId: "ability1",
notice: "",
});
},
selectGuideClass: (guideClassId) => set({ guideClassId, guideAbilityId: "ability1" }),
selectGuideAbility: (guideAbilityId) => set({ guideAbilityId }),
selectProfileCollectionView: (profileCollectionView) => set({ profileCollectionView }),
selectProfileGroup: (selectedProfileGroupId) => set({ selectedProfileGroupId }),
selectProfileStat: (selectedProfileStatId) => set({ selectedProfileStatId }),
openAppearanceLab: () => set((state) => {
const save = activeSave(state.slots, state.activeSlotId);
return {
screen: "appearance",
appearanceClassId: save?.activeClassId ?? state.appearanceClassId,
appearanceDrafts: appearanceDraftsFor(save),
previewMode: "modular",
previewAnimation: "idle",
notice: "Appearance Lab opened. Changes remain drafts until saved.",
};
}),
selectAppearanceClass: (appearanceClassId) => set({ appearanceClassId, notice: "" }),
updateAppearanceDraft: (appearance) => set((state) => ({
appearanceDrafts: {
...state.appearanceDrafts,
[state.appearanceClassId]: cloneCharacterAppearance(appearance),
},
notice: "Preview updated. Save to keep this look.",
})),
resetAppearanceDraft: () => set((state) => ({
appearanceDrafts: {
...state.appearanceDrafts,
[state.appearanceClassId]: createDefaultHealerAppearance(state.appearanceClassId),
},
notice: "Class default restored in preview. Save to keep it.",
})),
saveAppearanceDraft: () => {
const { activeSlotId, appearanceClassId, appearanceDrafts } = get();
if (!activeSlotId) {
set({ notice: "Load a hunter save before changing appearance." });
return false;
}
const appearance = normalizeHealerAppearance(appearanceClassId, appearanceDrafts[appearanceClassId]);
const updated = repository.updateLocal(activeSlotId, (save) => ({
...save,
healers: {
...save.healers,
[appearanceClassId]: {
...save.healers[appearanceClassId],
appearance,
},
},
}));
if (!updated) {
set({ notice: "Appearance could not be saved." });
return false;
}
markSaveSyncPending(activeSlotId);
set((state) => ({
slots: refreshLocalSlots(state.slots),
appearanceDrafts: {
...state.appearanceDrafts,
[appearanceClassId]: cloneCharacterAppearance(appearance),
},
notice: `${appearanceClassId[0].toUpperCase() + appearanceClassId.slice(1)} appearance saved locally.`,
}));
return true;
},
closeAppearanceLab: () => set((state) => {
const save = activeSave(state.slots, state.activeSlotId);
return {
screen: "home",
appearanceClassId: save?.activeClassId ?? state.appearanceClassId,
appearanceDrafts: appearanceDraftsFor(save),
previewMode: "modular",
previewAnimation: "idle",
notice: "Appearance Lab closed.",
};
}),
setAppearancePreviewMode: (previewMode) => set({ previewMode }),
setAppearancePreviewAnimation: (previewAnimation) => set({ previewAnimation }),
upgradeSelectedGear: () => {
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
if (!activeSlotId) return false;
@@ -392,6 +578,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
recentRewards: awarded ? [...state.recentRewards, awarded].slice(-12) : state.recentRewards,
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved.` : "Boss clear saved.",
}));
markSaveSyncPending(activeSlotId);
return awarded;
},
recordRoguelikeDefeat: (round) => {
@@ -405,6 +592,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
},
}));
if (!updated) return;
markSaveSyncPending(activeSlotId);
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
recordRogueTrialsEndlessDefeat: (bossKills) => {
@@ -418,6 +606,118 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
},
}));
if (!updated) return;
markSaveSyncPending(activeSlotId);
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
recordHockeyHealingDefeat: (returns, durationSeconds) => {
const { activeSlotId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => {
const record = bestHockeyHealingRecord(
save.stats.highestHockeyHealingReturns,
save.stats.longestHockeyHealingSecondsAtBest,
returns,
durationSeconds,
);
return {
...save,
stats: {
...save.stats,
highestHockeyHealingReturns: record.returns,
longestHockeyHealingSecondsAtBest: record.durationSeconds,
},
};
});
if (!updated) return;
markSaveSyncPending(activeSlotId);
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
recordHockeyPvpResult: (won) => {
const { activeSlotId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => {
const record = hockeyPvpRecordAfterMatch(
save.stats.hockeyHealingPvpWins,
save.stats.hockeyHealingPvpLosses,
won,
);
return {
...save,
stats: {
...save.stats,
hockeyHealingPvpWins: record.wins,
hockeyHealingPvpLosses: record.losses,
},
};
});
if (!updated) return;
markSaveSyncPending(activeSlotId);
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
recordHockeyPvpBossKill: () => {
const { activeSlotId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => ({
...save,
stats: {
...save.stats,
hockeyHealingPvpBossKills: save.stats.hockeyHealingPvpBossKills + 1,
},
}));
if (!updated) return;
markSaveSyncPending(activeSlotId);
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
recordBlockbreakerDefeat: (bricks, durationSeconds, score) => {
const { activeSlotId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => {
const record = bestBlockbreakerRecords(
save.stats.highestBlockbreakerBricks,
save.stats.longestBlockbreakerSeconds,
save.stats.highestBlockbreakerScore,
bricks,
durationSeconds,
score,
);
return {
...save,
stats: {
...save.stats,
highestBlockbreakerBricks: record.bricks,
longestBlockbreakerSeconds: record.durationSeconds,
highestBlockbreakerScore: record.score,
},
};
});
if (!updated) return;
markSaveSyncPending(activeSlotId);
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
recordAetherAssaultDefeat: (score, wave, durationSeconds) => {
const { activeSlotId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => {
const record = bestAetherAssaultRecord(
save.stats.highestAetherAssaultScore,
save.stats.highestAetherAssaultWaveAtBest,
save.stats.longestAetherAssaultSecondsAtBest,
score,
wave,
durationSeconds,
);
return {
...save,
stats: {
...save.stats,
highestAetherAssaultScore: record.score,
highestAetherAssaultWaveAtBest: record.wave,
longestAetherAssaultSecondsAtBest: record.durationSeconds,
},
};
});
if (!updated) return;
markSaveSyncPending(activeSlotId);
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
clearRecentRewards: () => set({ recentRewards: [] }),
@@ -447,6 +747,20 @@ export type FrontendSnapshot = Omit<FrontendState,
| "selectInfusion"
| "selectPassiveAbility"
| "selectPassiveInfusion"
| "openClassHelp"
| "selectGuideClass"
| "selectGuideAbility"
| "selectProfileCollectionView"
| "selectProfileGroup"
| "selectProfileStat"
| "openAppearanceLab"
| "selectAppearanceClass"
| "updateAppearanceDraft"
| "resetAppearanceDraft"
| "saveAppearanceDraft"
| "closeAppearanceLab"
| "setAppearancePreviewMode"
| "setAppearancePreviewAnimation"
| "upgradeSelectedGear"
| "equipSelectedInfusion"
| "equipPassiveInfusion"
@@ -457,6 +771,11 @@ export type FrontendSnapshot = Omit<FrontendState,
| "recordBossVictory"
| "recordRoguelikeDefeat"
| "recordRogueTrialsEndlessDefeat"
| "recordHockeyHealingDefeat"
| "recordHockeyPvpResult"
| "recordHockeyPvpBossKill"
| "recordBlockbreakerDefeat"
| "recordAetherAssaultDefeat"
| "clearRecentRewards"
| "clearNotice"
>;
@@ -485,6 +804,20 @@ export function getFrontendSnapshot(): FrontendSnapshot {
selectInfusion: _selectInfusion,
selectPassiveAbility: _selectPassiveAbility,
selectPassiveInfusion: _selectPassiveInfusion,
openClassHelp: _openClassHelp,
selectGuideClass: _selectGuideClass,
selectGuideAbility: _selectGuideAbility,
selectProfileCollectionView: _selectProfileCollectionView,
selectProfileGroup: _selectProfileGroup,
selectProfileStat: _selectProfileStat,
openAppearanceLab: _openAppearanceLab,
selectAppearanceClass: _selectAppearanceClass,
updateAppearanceDraft: _updateAppearanceDraft,
resetAppearanceDraft: _resetAppearanceDraft,
saveAppearanceDraft: _saveAppearanceDraft,
closeAppearanceLab: _closeAppearanceLab,
setAppearancePreviewMode: _setAppearancePreviewMode,
setAppearancePreviewAnimation: _setAppearancePreviewAnimation,
upgradeSelectedGear: _upgradeSelectedGear,
equipSelectedInfusion: _equipSelectedInfusion,
equipPassiveInfusion: _equipPassiveInfusion,
@@ -495,6 +828,11 @@ export function getFrontendSnapshot(): FrontendSnapshot {
recordBossVictory: _recordBossVictory,
recordRoguelikeDefeat: _recordRoguelikeDefeat,
recordRogueTrialsEndlessDefeat: _recordRogueTrialsEndlessDefeat,
recordHockeyHealingDefeat: _recordHockeyHealingDefeat,
recordHockeyPvpResult: _recordHockeyPvpResult,
recordHockeyPvpBossKill: _recordHockeyPvpBossKill,
recordBlockbreakerDefeat: _recordBlockbreakerDefeat,
recordAetherAssaultDefeat: _recordAetherAssaultDefeat,
clearRecentRewards: _clearRecentRewards,
clearNotice: _clearNotice,
...snapshot

Some files were not shown because too many files have changed in this diff Show More