new rpg mode

This commit is contained in:
Warren H
2026-07-17 12:47:42 -04:00
parent de7b59ce77
commit 10ff124466
180 changed files with 20863 additions and 1014 deletions
+26
View File
@@ -138,6 +138,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
);
+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.
+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>
);
}
+378 -36
View File
@@ -1,12 +1,38 @@
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 { 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 { 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";
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
@@ -30,14 +56,15 @@ 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 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]
@@ -56,9 +83,14 @@ function PartyFrame({ member }: { member: PartyMember }) {
</span>
<span className="effect-stack">
{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 +110,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 +121,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 +146,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 +169,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="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 +223,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>
@@ -195,6 +259,7 @@ 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);
@@ -207,38 +272,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}
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>
);
@@ -275,10 +346,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 && <circle className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} r="23" />}
</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 && <circle className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} r="23" />}
</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 && <circle className="map-barrier" cx={mapX(barrier.center[0])} cy={mapY(barrier.center[1])} r="23" />}
</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">
@@ -362,33 +605,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
+1602 -160
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
import { useFrame } from "@react-three/fiber";
import { useRef } from "react";
import type * 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}
/>
);
}
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 (
<group position={[0, 0.72, 0]}>
<mesh>
<torusGeometry args={[0.42, 0.045, 6, 18]} />
<RelicMaterial color={profile.accentColor} />
</mesh>
<mesh position={[0, 0.08, 0]} rotation={[0, 0, Math.PI / 4]}>
<octahedronGeometry args={[0.11, 0]} />
<RelicMaterial color={profile.secondaryColor} />
</mesh>
</group>
);
}
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>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { createPortal } from "@react-three/fiber";
import { useEffect, useMemo } from "react";
import * as THREE from "three";
import {
CHARACTER_PART_CATALOG,
characterAppearancePartIds,
type CharacterAppearanceV1,
type CharacterPartId,
} from "../game/characterAppearance";
import type { MemberId } from "../game/types";
import { useGameGLTF } from "./GameAssetProvider";
interface BoundCharacterPart {
group: THREE.Group;
skeletons: THREE.Skeleton[];
}
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,
): BoundCharacterPart {
const definition = CHARACTER_PART_CATALOG[partId];
const rigBones = rigBonesByName(rigScene);
const group = new THREE.Group();
group.name = `character-part:${partId}`;
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;
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, skeletons };
}
function ModularCharacterPart({
actorScene,
modelUrls,
partId,
}: {
actorScene: THREE.Object3D;
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),
[actorScene, gltf.scene, partId],
);
useEffect(() => () => {
for (const skeleton of boundPart.skeletons) skeleton.dispose();
}, [boundPart]);
const rigRoot = actorScene.getObjectByName("Rig_Medium") ?? actorScene;
return createPortal(<primitive object={boundPart.group} />, rigRoot);
}
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}
/>
))}
</>
);
}
+200 -30
View File
@@ -1,11 +1,15 @@
import { lazy, Suspense } from "react";
import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { HEALER_CLASSES } from "../game/healers";
import { lazy, 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";
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
@@ -43,10 +47,12 @@ function CompactParty() {
</span>
)}
<span className="top-effects">
{member.renewExpiresAt > 0 && <em className="renew-pip">R</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>
@@ -57,12 +63,11 @@ function CompactParty() {
}
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 +83,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,36 +121,69 @@ 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."
? 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}`}>
@@ -147,11 +191,53 @@ function PhaseOverlay() {
<span>{eyebrow}</span>
<h1>{title}</h1>
<p>{copy}</p>
<small>{phase === "briefing" ? "Begin from lower display" : showEndlessChoice ? "Choose Endless Mode 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 +256,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 +272,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)];
}
@@ -6,6 +6,8 @@ import {
BOSS_DEATH_HOLD_SECONDS,
advanceBossIndicatorOpacity,
bossCanTrackTarget,
bossDeathDespawnSeconds,
bossDeathHoldSeconds,
bossDeathOpacity,
} from "./bossDeathVisuals";
@@ -28,4 +30,11 @@ describe("boss death visuals", () => {
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);
});
});
+2
View File
@@ -2,6 +2,8 @@ export {
BOSS_DEATH_DESPAWN_SECONDS,
BOSS_DEATH_FADE_SECONDS,
BOSS_DEATH_HOLD_SECONDS,
bossDeathDespawnSeconds,
bossDeathHoldSeconds,
bossDeathOpacity,
} from "../../game/bossDeath";
@@ -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
+90
View File
@@ -0,0 +1,90 @@
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("mage-head");
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);
});
});
+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;
};
}
+332 -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,15 @@ export interface FrontendState {
selectedGearSlotId: GearSlotId;
gearWorkshopMode: "upgrade" | "infusion";
selectedInfusionId: string;
selectedPassiveAbilityId: AbilityId;
selectedPassiveAbilityId: AbilitySlotId;
selectedPassiveInfusionId: RunBuffId;
profileCollectionView: ProfileCollectionView;
selectedProfileGroupId: BossGroupId;
selectedProfileStatId: ProfileStatId;
appearanceClassId: HealerClassId;
appearanceDrafts: AppearanceDrafts;
previewMode: CharacterModelMode;
previewAnimation: AppearancePreviewAnimation;
recentRewards: BossRewardAward[];
settings: GameSettings;
notice: string;
@@ -105,7 +147,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 +156,19 @@ 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;
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 +179,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 +205,15 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
selectedGearSlotId: "weapon",
gearWorkshopMode: "upgrade",
selectedInfusionId: infusionsForOwner("priest")[0].id,
selectedPassiveAbilityId: "mend",
selectedPassiveAbilityId: "ability1",
selectedPassiveInfusionId: "mend-echo",
profileCollectionView: "stats",
selectedProfileGroupId: "charge",
selectedProfileStatId: "roguelike",
appearanceClassId: "priest",
appearanceDrafts: appearanceDraftsFor(null),
previewMode: "modular",
previewAnimation: "idle",
recentRewards: [],
settings: loadSettings(),
notice: "",
@@ -200,7 +265,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 +282,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 +293,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 +304,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 +365,88 @@ 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: "",
}),
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 +560,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 +574,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
},
}));
if (!updated) return;
markSaveSyncPending(activeSlotId);
set((state) => ({ slots: refreshLocalSlots(state.slots) }));
},
recordRogueTrialsEndlessDefeat: (bossKills) => {
@@ -418,6 +588,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 +729,17 @@ export type FrontendSnapshot = Omit<FrontendState,
| "selectInfusion"
| "selectPassiveAbility"
| "selectPassiveInfusion"
| "selectProfileCollectionView"
| "selectProfileGroup"
| "selectProfileStat"
| "openAppearanceLab"
| "selectAppearanceClass"
| "updateAppearanceDraft"
| "resetAppearanceDraft"
| "saveAppearanceDraft"
| "closeAppearanceLab"
| "setAppearancePreviewMode"
| "setAppearancePreviewAnimation"
| "upgradeSelectedGear"
| "equipSelectedInfusion"
| "equipPassiveInfusion"
@@ -457,6 +750,11 @@ export type FrontendSnapshot = Omit<FrontendState,
| "recordBossVictory"
| "recordRoguelikeDefeat"
| "recordRogueTrialsEndlessDefeat"
| "recordHockeyHealingDefeat"
| "recordHockeyPvpResult"
| "recordHockeyPvpBossKill"
| "recordBlockbreakerDefeat"
| "recordAetherAssaultDefeat"
| "clearRecentRewards"
| "clearNotice"
>;
@@ -485,6 +783,17 @@ export function getFrontendSnapshot(): FrontendSnapshot {
selectInfusion: _selectInfusion,
selectPassiveAbility: _selectPassiveAbility,
selectPassiveInfusion: _selectPassiveInfusion,
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 +804,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
+18 -3
View File
@@ -2,10 +2,13 @@ import type { BossGroupId } from "../game/bossCatalog";
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
import type { GearProgress } from "../game/progression/gear";
import type { CollectionLog, MaterialStack } from "../game/progression/loot";
import type { CharacterAppearanceV1 } from "../game/characterAppearance";
export type SaveSlotId = 1 | 2 | 3;
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game";
export type GameModeId = "roguelike-pve" | "rogue-trials" | "dungeons" | "roguelike-pvp" | "stadium-pvp";
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "appearance" | "settings" | "mode" | "game";
export type GameModeId = "roguelike-pve" | "rogue-trials" | "dungeons" | "hockey-healing" | "hockey-healing-pvp" | "blockbreaker" | "aether-assault" | "roguelike-pvp" | "stadium-pvp";
export type ProfileCollectionView = "loot" | "trophies" | "stats";
export type ProfileStatId = BossId | "roguelike" | "rogue-trials-endless" | "hockey-healing" | "hockey-pvp-wins" | "hockey-pvp-boss-kills" | "blockbreaker-bricks" | "blockbreaker-time" | "blockbreaker-score" | "aether-assault";
export interface CollectionDrop {
id: string;
@@ -43,15 +46,27 @@ export interface HunterStats {
bossKills: Record<string, number>;
highestRoguelikeRound: number;
highestRogueTrialsEndlessKills: number;
highestHockeyHealingReturns: number;
longestHockeyHealingSecondsAtBest: number;
hockeyHealingPvpWins: number;
hockeyHealingPvpLosses: number;
hockeyHealingPvpBossKills: number;
highestBlockbreakerBricks: number;
longestBlockbreakerSeconds: number;
highestBlockbreakerScore: number;
highestAetherAssaultScore: number;
highestAetherAssaultWaveAtBest: number;
longestAetherAssaultSecondsAtBest: number;
}
export interface HealerProgress {
level: number;
inventory: InventoryItem[];
appearance: CharacterAppearanceV1;
}
export interface HunterSave {
schemaVersion: 5;
schemaVersion: 6;
slotId: SaveSlotId;
hunterName: string;
activeClassId: HealerClassId;
+46
View File
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createClassAbilityLoadout, HEALER_ABILITIES, HEALER_ABILITY_IDS, HEALER_CLASSES, HEALER_CLASS_ORDER, resolveSlottedAbility } from "./healers";
import { healingEffect } from "./healerEffects";
import { useGameStore } from "./store";
describe("composable healer ability loadouts", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", []);
});
it("projects every class kit through the flat spell registry", () => {
for (const classId of HEALER_CLASS_ORDER) {
const loadout = createClassAbilityLoadout(classId);
for (const [slotId, definition] of Object.entries(HEALER_CLASSES[classId].abilities)) {
expect(resolveSlottedAbility(loadout, slotId as keyof typeof loadout)).toBe(HEALER_ABILITIES[definition.id]);
}
}
expect(new Set(HEALER_ABILITY_IDS).size).toBe(HEALER_ABILITY_IDS.length);
expect(Object.keys(HEALER_ABILITIES)).toEqual(HEALER_ABILITY_IDS);
});
it("casts a spell assigned outside its native class and slot", () => {
useGameStore.getState().setAbilityLoadout({ ability1: "shaman-riptide" });
useGameStore.getState().startEncounter();
useGameStore.getState().selectMember("brann");
expect(useGameStore.getState().castAbility("ability1")).toBe(true);
expect(healingEffect(useGameStore.getState().party[1], "riptide")).toBeDefined();
});
it("resolves cast completion from recorded spell identity after cross-slot assignment", () => {
useGameStore.getState().setAbilityLoadout({ ability6: "druid-regrowth" });
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
party: state.party.map((member) => member.id === "brann" ? { ...member, hp: 50 } : member),
}));
useGameStore.getState().selectMember("brann");
expect(useGameStore.getState().castAbility("ability6")).toBe(true);
useGameStore.getState().tick(0.51);
const brann = useGameStore.getState().party[1];
expect(brann.hp).toBeGreaterThan(50);
expect(healingEffect(brann, "regrowth")).toBeDefined();
});
});
+224
View File
@@ -0,0 +1,224 @@
import { describe, expect, it } from "vitest";
import {
AETHER_ARMORED_SCORE,
AETHER_DIVE_DAMAGE,
AETHER_ENEMY_SHOT_DAMAGE,
AETHER_MAX_ENEMY_SHOTS,
AETHER_MAX_PLAYER_SHOTS,
AETHER_MAX_SHIPS,
AETHER_STANDARD_SCORE,
advanceAetherAssault,
aetherMultiplier,
aetherShipCount,
aetherWaveClearBonus,
createAetherAssaultState,
} from "./aetherAssault";
describe("Aether Assault", () => {
it("creates deterministic eight-ship opening formations", () => {
const first = createAetherAssaultState(true, 42);
const second = createAetherAssaultState(true, 42);
expect(first.ships).toEqual(second.ships);
expect(first.ships).toHaveLength(8);
expect(first.wave).toBe(1);
});
it("grows formations by two ships and caps them at twenty", () => {
expect(aetherShipCount(1)).toBe(8);
expect(aetherShipCount(4)).toBe(14);
expect(aetherShipCount(99)).toBe(AETHER_MAX_SHIPS);
});
it("fires five fixed-forward player shots per second only while enabled", () => {
let state = createAetherAssaultState(true, 7);
state = advanceAetherAssault(state, {
delta: 0.8,
time: 0.8,
playerPosition: [3, 8],
canAutoFire: true,
}).state;
expect(state.playerShots.length).toBe(5);
expect(state.playerShots.every((shot) => shot.velocity[0] === 0 && shot.velocity[1] < 0)).toBe(true);
const paused = advanceAetherAssault(state, {
delta: 1,
time: 1.8,
playerPosition: [3, 8],
canAutoFire: false,
}).state;
expect(paused.playerShots.length).toBeLessThanOrEqual(state.playerShots.length);
});
it("awards kills, streak multiplier, armored points, and wave bonuses", () => {
expect(aetherMultiplier(0)).toBe(1);
expect(aetherMultiplier(5)).toBe(1.25);
expect(aetherMultiplier(100)).toBe(5);
expect(AETHER_STANDARD_SCORE).toBe(100);
expect(AETHER_ARMORED_SCORE).toBe(250);
expect(aetherWaveClearBonus(3)).toBe(2250);
});
it("applies the fifth-kill multiplier to an armored kill", () => {
const state = createAetherAssaultState(true, 17);
const ship = state.ships[0];
ship.kind = "armored";
ship.hp = 1;
ship.maxHp = 3;
ship.phase = "formation";
ship.phaseStartedAt = 0;
ship.position = [0, -5];
ship.formationPosition = [0, -5];
state.ships = [ship];
state.killStreak = 4;
state.multiplier = 1;
state.playerShots = [{ id: 99, position: [0, 5], velocity: [0, -18] }];
state.nextPlayerShotAt = 10;
const next = advanceAetherAssault(state, {
delta: 0.75,
time: 1,
playerPosition: [4, 8],
canAutoFire: false,
}).state;
expect(next.killStreak).toBe(5);
expect(next.multiplier).toBe(1.25);
expect(next.lastKillScore).toBe(Math.round(AETHER_ARMORED_SCORE * 1.25));
expect(next.score).toBe(next.lastKillScore + aetherWaveClearBonus(1));
});
it("uses segment collision for fast player projectiles", () => {
const state = createAetherAssaultState(true, 9);
const ship = state.ships[0];
ship.phase = "formation";
ship.phaseStartedAt = 0;
ship.position = [0, -5];
ship.formationPosition = [0, -5];
state.ships = [ship];
state.playerShots = [{ id: 99, position: [0, 5], velocity: [0, -18] }];
state.nextPlayerShotAt = 10;
const next = advanceAetherAssault(state, {
delta: 0.75,
time: 1,
playerPosition: [4, 8],
canAutoFire: false,
}).state;
expect(next.ships).toHaveLength(0);
expect(next.kills).toBe(1);
expect(next.score).toBe(AETHER_STANDARD_SCORE + aetherWaveClearBonus(1));
});
it("damages only through returned player damage and resets streak with hit grace", () => {
const state = createAetherAssaultState(true, 3);
state.killStreak = 8;
state.multiplier = aetherMultiplier(8);
state.enemyShots = [
{ id: 1, position: [0, 7], velocity: [0, 5] },
{ id: 2, position: [0.1, 7], velocity: [0, 5] },
];
state.nextPlayerShotAt = 10;
const hit = advanceAetherAssault(state, {
delta: 0.4,
time: 1,
playerPosition: [0, 8],
canAutoFire: false,
});
expect(hit.playerDamage).toBe(AETHER_ENEMY_SHOT_DAMAGE);
expect(hit.state.killStreak).toBe(0);
expect(hit.state.multiplier).toBe(1);
});
it("launches formation dives and resolves dive contact once", () => {
const state = createAetherAssaultState(true, 23);
const ship = state.ships[0];
ship.phase = "formation";
ship.phaseStartedAt = 0;
ship.position = [0, -5];
ship.formationPosition = [0, -5];
state.ships = [ship];
state.nextDiveAt = 0;
state.nextEnemyShotAt = 99;
state.nextPlayerShotAt = 99;
const launched = advanceAetherAssault(state, {
delta: 0.01,
time: 0.01,
playerPosition: [3, 8],
canAutoFire: false,
}).state;
expect(launched.ships[0].phase).toBe("diving");
expect(launched.ships[0].targetPosition[0]).toBe(3);
launched.ships[0].position = [0, 0];
launched.ships[0].startPosition = [0, 0];
launched.ships[0].targetPosition = [0, 0];
launched.ships[0].phaseStartedAt = 0;
launched.nextDiveAt = 99;
const contact = advanceAetherAssault(launched, {
delta: 0.1,
time: 0.1,
playerPosition: [0, 0],
canAutoFire: false,
});
expect(contact.playerDamage).toBe(AETHER_DIVE_DAMAGE);
expect(contact.state.ships[0].contactResolved).toBe(true);
});
it("advances to a ten-ship second wave after the clear delay", () => {
const state = createAetherAssaultState(true, 31);
const ship = state.ships[0];
ship.phase = "formation";
ship.phaseStartedAt = 0;
ship.position = [0, -5];
ship.formationPosition = [0, -5];
state.ships = [ship];
state.playerShots = [{ id: 99, position: [0, 5], velocity: [0, -18] }];
state.nextPlayerShotAt = 99;
state.nextEnemyShotAt = 99;
state.nextDiveAt = 99;
const cleared = advanceAetherAssault(state, {
delta: 0.75,
time: 1,
playerPosition: [4, 8],
canAutoFire: false,
}).state;
expect(cleared.nextWaveAt).toBe(2.5);
const next = advanceAetherAssault(cleared, {
delta: 1.6,
time: 2.6,
playerPosition: [4, 8],
canAutoFire: false,
}).state;
expect(next.wave).toBe(2);
expect(next.ships).toHaveLength(10);
expect(next.nextWaveAt).toBeNull();
});
it("never exceeds projectile caps", () => {
const state = createAetherAssaultState(true, 12);
state.nextPlayerShotAt = 0;
state.nextEnemyShotAt = 0;
state.playerShots = Array.from({ length: AETHER_MAX_PLAYER_SHOTS }, (_, id) => ({
id,
position: [9, 0] as [number, number],
velocity: [0, -18] as [number, number],
}));
state.enemyShots = Array.from({ length: AETHER_MAX_ENEMY_SHOTS }, (_, id) => ({
id: 100 + id,
position: [9, -8] as [number, number],
velocity: [0, 5] as [number, number],
}));
const next = advanceAetherAssault(state, {
delta: 0.01,
time: 0.01,
playerPosition: [-9, 12],
canAutoFire: true,
}).state;
expect(next.playerShots.length).toBeLessThanOrEqual(AETHER_MAX_PLAYER_SHOTS);
expect(next.enemyShots.length).toBeLessThanOrEqual(AETHER_MAX_ENEMY_SHOTS);
});
});
+432
View File
@@ -0,0 +1,432 @@
import {
HOCKEY_ARENA_MAX_X,
HOCKEY_ARENA_MAX_Z,
HOCKEY_ARENA_MIN_X,
HOCKEY_ARENA_MIN_Z,
} from "./hockeyHealing";
import type { WorldPosition } from "./types";
export type AetherAssaultStatus = "inactive" | "live";
export type AetherShipKind = "standard" | "armored";
export type AetherShipPhase = "entering" | "formation" | "diving" | "returning";
export interface AetherShip {
id: string;
kind: AetherShipKind;
hp: number;
maxHp: number;
position: WorldPosition;
formationPosition: WorldPosition;
phase: AetherShipPhase;
phaseStartedAt: number;
phaseEndsAt: number;
startPosition: WorldPosition;
targetPosition: WorldPosition;
contactResolved: boolean;
}
export interface AetherProjectile {
id: number;
position: WorldPosition;
velocity: WorldPosition;
}
export interface AetherAssaultState {
status: AetherAssaultStatus;
seed: number;
randomState: number;
wave: number;
score: number;
kills: number;
killStreak: number;
multiplier: number;
ships: AetherShip[];
playerShots: AetherProjectile[];
enemyShots: AetherProjectile[];
nextProjectileId: number;
nextPlayerShotAt: number;
nextEnemyShotAt: number;
nextDiveAt: number;
nextWaveAt: number | null;
lastPlayerHitAt: number;
lastKillAt: number;
lastKillScore: number;
}
export interface AetherAssaultStep {
delta: number;
time: number;
playerPosition: WorldPosition;
canAutoFire: boolean;
}
export interface AetherAssaultAdvance {
state: AetherAssaultState;
playerDamage: number;
}
export const AETHER_MAX_SHIPS = 20;
export const AETHER_MAX_PLAYER_SHOTS = 32;
export const AETHER_MAX_ENEMY_SHOTS = 64;
export const AETHER_PLAYER_SHOT_DAMAGE = 1;
export const AETHER_ENEMY_SHOT_DAMAGE = 10;
export const AETHER_DIVE_DAMAGE = 24;
export const AETHER_HIT_GRACE_SECONDS = 0.6;
export const AETHER_PLAYER_SHOTS_PER_SECOND = 5;
export const AETHER_WAVE_CLEAR_DELAY = 1.5;
export const AETHER_STANDARD_SCORE = 100;
export const AETHER_ARMORED_SCORE = 250;
const PLAYER_SHOT_SPEED = 18;
const PLAYER_SHOT_RADIUS = 0.24;
const ENEMY_SHOT_RADIUS = 0.3;
const SHIP_RADIUS = 0.72;
const PLAYER_HIT_RADIUS = 0.62;
const DIVE_HIT_RADIUS = 1.05;
const ENTRY_DURATION = 1.45;
const RETURN_DURATION = 1.25;
const DIVE_DURATION = 2.15;
function normalizeSeed(seed: number) {
const normalized = Math.floor(Number(seed)) >>> 0;
return normalized || 0x9e3779b9;
}
function nextRandom(state: number) {
let next = normalizeSeed(state);
next ^= next << 13;
next ^= next >>> 17;
next ^= next << 5;
return next >>> 0;
}
function randomUnit(state: number) {
const next = nextRandom(state);
return { state: next, value: next / 0x100000000 };
}
export function createAetherAssaultSeed(random: () => number = Math.random) {
const sample = Number(random());
const normalized = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999999, sample)) : 0;
return normalizeSeed(Math.floor(normalized * 0x100000000));
}
export function aetherShipCount(wave: number) {
return Math.min(AETHER_MAX_SHIPS, 8 + Math.max(0, Math.floor(wave) - 1) * 2);
}
export function aetherMultiplier(killStreak: number) {
return Math.min(5, 1 + Math.floor(Math.max(0, killStreak) / 5) * 0.25);
}
export function aetherWaveClearBonus(wave: number) {
return Math.max(1, Math.floor(wave)) * 750;
}
function formationPosition(index: number, count: number): WorldPosition {
const columns = 5;
const rows = Math.ceil(count / columns);
const row = Math.floor(index / columns);
const column = index % columns;
const itemsInRow = row === rows - 1 && count % columns !== 0 ? count % columns : columns;
const centeredColumn = column - (itemsInRow - 1) * 0.5;
return [centeredColumn * 3.35, -10.7 + row * 2.25];
}
function armoredCount(wave: number, count: number) {
if (wave % 3 !== 0) return 0;
return Math.min(count, 1 + Math.floor(wave / 6));
}
function createWave(wave: number, startsAt: number, randomState: number) {
const count = aetherShipCount(wave);
const armored = armoredCount(wave, count);
const ships: AetherShip[] = [];
let nextState = randomState;
for (let index = 0; index < count; index += 1) {
const random = randomUnit(nextState);
nextState = random.state;
const formation = formationPosition(index, count);
const entersFromLeft = index % 2 === 0;
const start: WorldPosition = [
entersFromLeft ? HOCKEY_ARENA_MIN_X - 4 - random.value * 3 : HOCKEY_ARENA_MAX_X + 4 + random.value * 3,
HOCKEY_ARENA_MIN_Z - 2.5 - (index % 4) * 0.65,
];
const kind: AetherShipKind = index >= count - armored ? "armored" : "standard";
const spawnAt = startsAt + index * 0.11;
ships.push({
id: `${wave}:${index}`,
kind,
hp: kind === "armored" ? 3 : 1,
maxHp: kind === "armored" ? 3 : 1,
position: [...start],
formationPosition: formation,
phase: "entering",
phaseStartedAt: spawnAt,
phaseEndsAt: spawnAt + ENTRY_DURATION,
startPosition: [...start],
targetPosition: [...formation],
contactResolved: false,
});
}
return { ships, randomState: nextState };
}
export function createAetherAssaultState(active = false, requestedSeed = 1): AetherAssaultState {
const seed = normalizeSeed(requestedSeed);
const wave = createWave(1, 0, seed);
return {
status: active ? "live" : "inactive",
seed,
randomState: wave.randomState,
wave: 1,
score: 0,
kills: 0,
killStreak: 0,
multiplier: 1,
ships: active ? wave.ships : [],
playerShots: [],
enemyShots: [],
nextProjectileId: 1,
nextPlayerShotAt: 0,
nextEnemyShotAt: 1.2,
nextDiveAt: 4,
nextWaveAt: null,
lastPlayerHitAt: Number.NEGATIVE_INFINITY,
lastKillAt: Number.NEGATIVE_INFINITY,
lastKillScore: 0,
};
}
function cloneShip(ship: AetherShip): AetherShip {
return {
...ship,
position: [...ship.position],
formationPosition: [...ship.formationPosition],
startPosition: [...ship.startPosition],
targetPosition: [...ship.targetPosition],
};
}
function easeOutCubic(value: number) {
return 1 - (1 - value) ** 3;
}
function lerpPosition(start: WorldPosition, end: WorldPosition, progress: number): WorldPosition {
return [start[0] + (end[0] - start[0]) * progress, start[1] + (end[1] - start[1]) * progress];
}
function divePosition(ship: AetherShip, progress: number): WorldPosition {
const controlX = ship.targetPosition[0] + Math.sign(ship.targetPosition[0] - ship.startPosition[0] || 1) * 3.2;
const controlZ = (ship.startPosition[1] + ship.targetPosition[1]) * 0.5 - 1.5;
const inverse = 1 - progress;
return [
inverse * inverse * ship.startPosition[0] + 2 * inverse * progress * controlX + progress * progress * ship.targetPosition[0],
inverse * inverse * ship.startPosition[1] + 2 * inverse * progress * controlZ + progress * progress * ship.targetPosition[1],
];
}
function segmentDistanceSquared(start: WorldPosition, end: WorldPosition, point: WorldPosition) {
const dx = end[0] - start[0];
const dz = end[1] - start[1];
const lengthSquared = dx * dx + dz * dz;
const projection = lengthSquared < 0.000001
? 0
: Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared));
const nearestX = start[0] + dx * projection;
const nearestZ = start[1] + dz * projection;
return (point[0] - nearestX) ** 2 + (point[1] - nearestZ) ** 2;
}
function canDamagePlayer(state: AetherAssaultState, time: number) {
return time - state.lastPlayerHitAt >= AETHER_HIT_GRACE_SECONDS;
}
function registerPlayerHit(state: AetherAssaultState, time: number, damage: number) {
if (!canDamagePlayer(state, time)) return 0;
state.lastPlayerHitAt = time;
state.killStreak = 0;
state.multiplier = 1;
return damage;
}
function awardKill(state: AetherAssaultState, ship: AetherShip, time: number) {
state.kills += 1;
state.killStreak += 1;
state.multiplier = aetherMultiplier(state.killStreak);
const base = ship.kind === "armored" ? AETHER_ARMORED_SCORE : AETHER_STANDARD_SCORE;
const award = Math.round(base * state.multiplier);
state.score += award;
state.lastKillAt = time;
state.lastKillScore = award;
}
function chooseShip(state: AetherAssaultState, candidates: readonly AetherShip[]) {
if (!candidates.length) return undefined;
const random = randomUnit(state.randomState);
state.randomState = random.state;
return candidates[Math.min(candidates.length - 1, Math.floor(random.value * candidates.length))];
}
function enemyFireInterval(wave: number) {
return Math.max(0.52, 1.85 - Math.max(0, wave - 1) * 0.055);
}
function diveInterval(wave: number) {
return Math.max(1.7, 4.4 - Math.max(0, wave - 1) * 0.11);
}
export function advanceAetherAssault(source: AetherAssaultState, step: AetherAssaultStep): AetherAssaultAdvance {
if (source.status !== "live" || step.delta <= 0) return { state: source, playerDamage: 0 };
const state: AetherAssaultState = {
...source,
ships: source.ships.map(cloneShip),
playerShots: source.playerShots.map((shot) => ({ ...shot, position: [...shot.position], velocity: [...shot.velocity] })),
enemyShots: source.enemyShots.map((shot) => ({ ...shot, position: [...shot.position], velocity: [...shot.velocity] })),
};
let playerDamage = 0;
if (step.canAutoFire) {
while (state.nextPlayerShotAt <= step.time && state.playerShots.length < AETHER_MAX_PLAYER_SHOTS) {
state.playerShots.push({
id: state.nextProjectileId,
position: [step.playerPosition[0], step.playerPosition[1] - 0.7],
velocity: [0, -PLAYER_SHOT_SPEED],
});
state.nextProjectileId += 1;
state.nextPlayerShotAt += 1 / AETHER_PLAYER_SHOTS_PER_SECOND;
}
} else if (state.nextPlayerShotAt < step.time) {
state.nextPlayerShotAt = step.time;
}
for (const ship of state.ships) {
if (step.time < ship.phaseStartedAt) continue;
const previous = [...ship.position] as WorldPosition;
if (ship.phase === "entering") {
const progress = Math.max(0, Math.min(1, (step.time - ship.phaseStartedAt) / Math.max(0.001, ship.phaseEndsAt - ship.phaseStartedAt)));
ship.position = lerpPosition(ship.startPosition, ship.formationPosition, easeOutCubic(progress));
if (progress >= 1) {
ship.phase = "formation";
ship.position = [...ship.formationPosition];
}
} else if (ship.phase === "diving") {
const progress = Math.max(0, Math.min(1, (step.time - ship.phaseStartedAt) / DIVE_DURATION));
ship.position = divePosition(ship, progress);
if (!ship.contactResolved
&& segmentDistanceSquared(previous, ship.position, step.playerPosition) <= DIVE_HIT_RADIUS ** 2) {
ship.contactResolved = true;
playerDamage += registerPlayerHit(state, step.time, AETHER_DIVE_DAMAGE);
}
if (progress >= 1) {
ship.phase = "returning";
ship.phaseStartedAt = step.time;
ship.phaseEndsAt = step.time + RETURN_DURATION;
ship.startPosition = [...ship.position];
ship.targetPosition = [...ship.formationPosition];
}
} else if (ship.phase === "returning") {
const progress = Math.max(0, Math.min(1, (step.time - ship.phaseStartedAt) / RETURN_DURATION));
ship.position = lerpPosition(ship.startPosition, ship.formationPosition, easeOutCubic(progress));
if (progress >= 1) {
ship.phase = "formation";
ship.position = [...ship.formationPosition];
ship.contactResolved = false;
}
}
}
if (step.time >= state.nextDiveAt) {
const diver = chooseShip(state, state.ships.filter((ship) => ship.phase === "formation"));
if (diver) {
diver.phase = "diving";
diver.phaseStartedAt = step.time;
diver.phaseEndsAt = step.time + DIVE_DURATION;
diver.startPosition = [...diver.position];
diver.targetPosition = [step.playerPosition[0], HOCKEY_ARENA_MAX_Z + 1.8];
diver.contactResolved = false;
state.nextDiveAt = step.time + diveInterval(state.wave);
} else {
state.nextDiveAt = step.time + 0.25;
}
}
if (step.time >= state.nextEnemyShotAt && state.enemyShots.length < AETHER_MAX_ENEMY_SHOTS) {
const shooter = chooseShip(state, state.ships.filter((ship) => ship.phase === "formation" || ship.phase === "diving"));
if (shooter) {
const dx = step.playerPosition[0] - shooter.position[0];
const dz = step.playerPosition[1] - shooter.position[1];
const length = Math.max(0.001, Math.hypot(dx, dz));
const speed = Math.min(8.5, 5.6 + state.wave * 0.08);
state.enemyShots.push({
id: state.nextProjectileId,
position: [...shooter.position],
velocity: [dx / length * speed, dz / length * speed],
});
state.nextProjectileId += 1;
state.nextEnemyShotAt = step.time + enemyFireInterval(state.wave);
} else {
state.nextEnemyShotAt = step.time + 0.25;
}
}
const survivingPlayerShots: AetherProjectile[] = [];
for (const shot of state.playerShots) {
const start = [...shot.position] as WorldPosition;
const end: WorldPosition = [start[0] + shot.velocity[0] * step.delta, start[1] + shot.velocity[1] * step.delta];
let hit: AetherShip | undefined;
let hitDistance = Number.POSITIVE_INFINITY;
for (const ship of state.ships) {
if (step.time < ship.phaseStartedAt) continue;
if (segmentDistanceSquared(start, end, ship.position) > (SHIP_RADIUS + PLAYER_SHOT_RADIUS) ** 2) continue;
const distance = Math.hypot(ship.position[0] - start[0], ship.position[1] - start[1]);
if (distance < hitDistance) {
hit = ship;
hitDistance = distance;
}
}
if (hit) {
hit.hp -= AETHER_PLAYER_SHOT_DAMAGE;
if (hit.hp <= 0) awardKill(state, hit, step.time);
continue;
}
shot.position = end;
if (end[1] >= HOCKEY_ARENA_MIN_Z - 3 && Math.abs(end[0]) <= HOCKEY_ARENA_MAX_X + 4) survivingPlayerShots.push(shot);
}
state.playerShots = survivingPlayerShots;
state.ships = state.ships.filter((ship) => ship.hp > 0);
const survivingEnemyShots: AetherProjectile[] = [];
for (const shot of state.enemyShots) {
const start = [...shot.position] as WorldPosition;
const end: WorldPosition = [start[0] + shot.velocity[0] * step.delta, start[1] + shot.velocity[1] * step.delta];
if (segmentDistanceSquared(start, end, step.playerPosition) <= (PLAYER_HIT_RADIUS + ENEMY_SHOT_RADIUS) ** 2) {
playerDamage += registerPlayerHit(state, step.time, AETHER_ENEMY_SHOT_DAMAGE);
continue;
}
shot.position = end;
if (end[1] <= HOCKEY_ARENA_MAX_Z + 3
&& end[1] >= HOCKEY_ARENA_MIN_Z - 3
&& Math.abs(end[0]) <= HOCKEY_ARENA_MAX_X + 4) {
survivingEnemyShots.push(shot);
}
}
state.enemyShots = survivingEnemyShots;
if (state.ships.length === 0 && state.nextWaveAt === null) {
state.score += aetherWaveClearBonus(state.wave);
state.nextWaveAt = step.time + AETHER_WAVE_CLEAR_DELAY;
}
if (state.nextWaveAt !== null && step.time >= state.nextWaveAt) {
state.wave += 1;
const wave = createWave(state.wave, step.time, state.randomState);
state.ships = wave.ships;
state.randomState = wave.randomState;
state.nextWaveAt = null;
state.nextEnemyShotAt = step.time + Math.min(1.2, enemyFireInterval(state.wave));
state.nextDiveAt = step.time + Math.min(3, diveInterval(state.wave));
}
return { state, playerDamage };
}
+124
View File
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createClassInventory } from "./healers";
import { HOCKEY_ARENA_MAX_Z, HOCKEY_ARENA_MIN_Z } from "./hockeyHealing";
import { useGameStore } from "./store";
function disableBossPressure() {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
additionalBosses: state.additionalBosses.map((entry) => ({
...entry,
boss: { ...entry.boss, nextMeleeAt: 999 },
motion: { ...entry.motion, nextMechanicAt: 999 },
})),
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
}));
}
describe("Aether Assault store integration", () => {
beforeEach(() => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["bulldrome", "broodfang-spider"],
"aether-assault",
);
useGameStore.getState().startEncounter();
disableBossPressure();
});
it("keeps auto-fire running while the healer casts and uses the global cooldown", () => {
useGameStore.setState((state) => ({
party: state.party.map((member) => member.id === "brann" ? { ...member, hp: member.hp - 40 } : member),
}));
useGameStore.getState().selectMember("brann");
expect(useGameStore.getState().castAbility("ability1")).toBe(true);
expect(useGameStore.getState().activeCast).not.toBeNull();
useGameStore.getState().tick(0.21);
const state = useGameStore.getState();
expect(state.activeCast).not.toBeNull();
expect(state.globalCooldownUntil).toBeGreaterThan(state.time);
expect(state.aetherAssault.playerShots.length).toBeGreaterThanOrEqual(2);
});
it("lets the healer traverse the full rink", () => {
useGameStore.getState().setPlayerPosition([0, -100]);
expect(useGameStore.getState().playerPosition[1]).toBeCloseTo(HOCKEY_ARENA_MIN_Z + 0.65);
useGameStore.getState().setPlayerPosition([0, 100]);
expect(useGameStore.getState().playerPosition[1]).toBeCloseTo(HOCKEY_ARENA_MAX_Z - 0.65);
});
it("applies mitigated ship damage only to the healer and grants hit protection", () => {
const before = useGameStore.getState();
const healerPosition = before.partyPositions.aelia;
const initialHp = before.party.map((member) => member.hp);
useGameStore.setState((state) => ({
barrier: { ...state.barrier, kind: "barrier", center: [...healerPosition], expiresAt: 10 },
aetherAssault: {
...state.aetherAssault,
killStreak: 5,
multiplier: 1.25,
enemyShots: [{ id: 900, position: [...healerPosition], velocity: [0, 0] }],
},
}));
useGameStore.getState().tick(0.01);
const afterFirst = useGameStore.getState();
expect(afterFirst.party[0].hp).toBeCloseTo(initialHp[0] - 7);
expect(afterFirst.party.slice(1).map((member) => member.hp)).toEqual(initialHp.slice(1));
expect(afterFirst.aetherAssault.killStreak).toBe(0);
expect(afterFirst.aetherAssault.multiplier).toBe(1);
useGameStore.setState((state) => ({
aetherAssault: {
...state.aetherAssault,
enemyShots: [{ id: 901, position: [...state.partyPositions.aelia], velocity: [0, 0] }],
},
}));
useGameStore.getState().tick(0.1);
expect(useGameStore.getState().party[0].hp).toBeCloseTo(afterFirst.party[0].hp);
});
it("keeps boss replacement independent from arcade waves", () => {
const originalInstance = useGameStore.getState().bossInstanceId;
useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 1 } }));
useGameStore.getState().tick(2);
expect(useGameStore.getState().endlessBossKills).toBe(1);
expect(useGameStore.getState().boss.hp).toBe(0);
useGameStore.getState().tick(2);
useGameStore.getState().tick(1.3);
const state = useGameStore.getState();
expect(state.endlessBossKills).toBe(1);
expect(state.boss.hp).toBeGreaterThan(0);
expect(state.bossInstanceId).not.toBe(originalInstance);
expect(state.aetherAssault.status).toBe("live");
});
it("pauses arcade simulation, resets a run, and ends only on party wipe", () => {
useGameStore.getState().tick(0.21);
const beforePause = useGameStore.getState().aetherAssault;
useGameStore.getState().setPaused(true);
useGameStore.getState().tick(1);
expect(useGameStore.getState().aetherAssault).toBe(beforePause);
useGameStore.getState().setPaused(false);
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.01);
expect(useGameStore.getState().phase).toBe("defeat");
useGameStore.getState().restart();
const restarted = useGameStore.getState();
expect(restarted.phase).toBe("briefing");
expect(restarted.aetherAssault.wave).toBe(1);
expect(restarted.aetherAssault.score).toBe(0);
expect(restarted.aetherAssault.ships).toHaveLength(8);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { createDefaultHealerAppearance } from "./healerVisuals";
import {
APPEARANCE_SLOT_DEFINITIONS,
appearanceSlotEnabled,
appearanceSlotLabel,
appearancesMatch,
cycleAppearanceSlot,
} from "./appearanceLab";
import { weaponDefinitionsForSlot } from "./weaponCatalog";
describe("appearance lab choices", () => {
it("cycles every visible slot without mutating the saved appearance", () => {
const original = createDefaultHealerAppearance("priest");
let draft = original;
for (const slot of APPEARANCE_SLOT_DEFINITIONS) {
const before = appearanceSlotLabel(draft, slot.id);
draft = cycleAppearanceSlot(draft, slot.id, 1);
expect(appearanceSlotLabel(draft, slot.id), slot.id).not.toBe(before);
}
expect(createDefaultHealerAppearance("priest")).toEqual(original);
expect(appearancesMatch(draft, original)).toBe(false);
});
it("wraps choices in both directions", () => {
const original = createDefaultHealerAppearance("druid");
const previous = cycleAppearanceSlot(original, "main-hand", -1);
const restored = cycleAppearanceSlot(previous, "main-hand", 1);
expect(restored.mainHand).toEqual(original.mainHand);
});
it("makes every imported held design reachable without a 49-item flat cycle", () => {
let appearance = createDefaultHealerAppearance("priest");
const firstFamily = appearanceSlotLabel(appearance, "main-hand-family");
const visitedFamilies = new Set<string>();
const visitedMainHands = new Set<string>();
do {
const family = appearanceSlotLabel(appearance, "main-hand-family");
expect(visitedFamilies.has(family)).toBe(false);
visitedFamilies.add(family);
const firstModelId = appearance.mainHand.modelId;
do {
visitedMainHands.add(appearance.mainHand.modelId);
appearance = cycleAppearanceSlot(appearance, "main-hand", 1);
} while (appearance.mainHand.modelId !== firstModelId);
appearance = cycleAppearanceSlot(appearance, "main-hand-family", 1);
} while (appearanceSlotLabel(appearance, "main-hand-family") !== firstFamily);
expect(visitedFamilies).toHaveLength(10);
expect(visitedMainHands).toHaveLength(49);
expect(visitedMainHands).toEqual(new Set(weaponDefinitionsForSlot("main").map((entry) => entry.id)));
});
it("preserves a saved offhand while a two-handed style disables its row", () => {
const paladin = createDefaultHealerAppearance("paladin");
const twoHanded = {
...paladin,
mainHand: { modelId: "cc/staff_d", grip: "staff" } as const,
};
expect(appearanceSlotEnabled(twoHanded, "off-hand")).toBe(false);
expect(appearanceSlotLabel(twoHanded, "off-hand")).toBe("Hidden by two-hander");
expect(cycleAppearanceSlot(twoHanded, "off-hand", 1)).toBe(twoHanded);
expect(twoHanded.offHand).toEqual(paladin.offHand);
});
});
+203
View File
@@ -0,0 +1,203 @@
import type {
CharacterAppearanceV1,
CharacterHeldItemVisual,
CharacterPartIdFor,
} from "./characterAppearance";
import {
heldItemForModel,
weaponDefinition,
weaponDefinitionsForSlot,
weaponUsesBothHands,
type CharacterWeaponCategory,
type CharacterWeaponModelId,
type WeaponDefinition,
} from "./weaponCatalog";
export type AppearanceSlotId = "head" | "upper" | "lower" | "headwear" | "back" | "main-hand-family" | "main-hand" | "off-hand";
interface AppearanceChoice<Value> {
value: Value;
label: string;
}
export interface AppearanceSlotDefinition {
id: AppearanceSlotId;
label: string;
assetNote: string;
}
export const APPEARANCE_SLOT_DEFINITIONS: readonly AppearanceSlotDefinition[] = [
{ id: "head", label: "Face + hair", assetNote: "Combined in current character assets" },
{ id: "upper", label: "Shirt + arms", assetNote: "Combined in current character assets" },
{ id: "lower", label: "Pants + shoes", assetNote: "Combined in current character assets" },
{ id: "headwear", label: "Hat", assetNote: "Separate skinned equipment" },
{ id: "back", label: "Back item", assetNote: "Capes, packs, and skinned quiver" },
{ id: "main-hand-family", label: "Weapon type", assetNote: "10 Claudecraft families" },
{ id: "main-hand", label: "Weapon style", assetNote: "49 imported designs" },
{ id: "off-hand", label: "Offhand", assetNote: "Saved while a two-hander is active" },
] as const;
const HEAD_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"head">>[] = [
{ value: "druid-head", label: "Grove" },
{ value: "mage-head", label: "Mystic" },
{ value: "ranger-head", label: "Wayfinder" },
{ value: "knight-head", label: "Vanguard" },
{ value: "rogue-head", label: "Chronicle" },
];
const UPPER_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"upper-body">>[] = [
{ value: "druid-upper", label: "Grove leathers" },
{ value: "mage-upper", label: "Mystic robes" },
{ value: "ranger-upper", label: "Wayfinder mail" },
{ value: "knight-upper", label: "Vanguard plate" },
{ value: "rogue-upper", label: "Chronicle coat" },
];
const LOWER_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"lower-body">>[] = [
{ value: "druid-lower", label: "Grove boots" },
{ value: "mage-lower", label: "Mystic boots" },
{ value: "ranger-lower", label: "Wayfinder boots" },
{ value: "knight-lower", label: "Vanguard greaves" },
{ value: "rogue-lower", label: "Chronicle boots" },
];
const HEADWEAR_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"headwear"> | null>[] = [
{ value: null, label: "None" },
{ value: "mage-hat", label: "Mystic hat" },
{ value: "knight-helmet", label: "Vanguard helm" },
];
const BACK_CHOICES: readonly AppearanceChoice<CharacterPartIdFor<"back"> | null>[] = [
{ value: null, label: "None" },
{ value: "druid-backpack", label: "Grove pack" },
{ value: "mage-cape", label: "Mystic cape" },
{ value: "ranger-cape", label: "Wayfinder cape" },
{ value: "ranger-quiver", label: "Wayfinder quiver" },
{ value: "knight-cape", label: "Vanguard cape" },
{ value: "rogue-cape", label: "Chronicle cape" },
];
const MAIN_HAND_CATEGORY_ORDER = [
"staff",
"wand",
"sword",
"axe",
"hammer",
"dagger",
"crossbow",
"halberd",
"scythe",
"spear",
] as const satisfies readonly CharacterWeaponCategory[];
const WEAPON_CATEGORY_LABELS: Record<CharacterWeaponCategory, string> = {
axe: "Axes",
crossbow: "Crossbows",
dagger: "Daggers",
halberd: "Halberds",
hammer: "Hammers",
scythe: "Scythes",
shield: "Shields",
spear: "Spears",
spellbook: "Spellbooks",
staff: "Staves",
sword: "Swords",
wand: "Wands",
};
const MAIN_HAND_CATEGORY_CHOICES: readonly AppearanceChoice<CharacterWeaponCategory>[] = MAIN_HAND_CATEGORY_ORDER.map((value) => ({
value,
label: WEAPON_CATEGORY_LABELS[value],
}));
function definitionsInCategory(
definitions: readonly WeaponDefinition[],
category: CharacterWeaponCategory,
) {
return definitions.filter((definition) => definition.category === category);
}
const MAIN_HAND_DEFINITIONS = MAIN_HAND_CATEGORY_ORDER.flatMap((category) =>
definitionsInCategory(weaponDefinitionsForSlot("main"), category));
const MAIN_HAND_CHOICES: readonly AppearanceChoice<CharacterHeldItemVisual>[] = MAIN_HAND_DEFINITIONS.map((definition) => ({
value: heldItemForModel(definition.id as CharacterWeaponModelId),
label: definition.label,
}));
const OFF_HAND_CATEGORY_ORDER = ["shield", "spellbook", "dagger", "sword", "axe"] as const;
const OFF_HAND_DEFINITIONS = OFF_HAND_CATEGORY_ORDER.flatMap((category) =>
definitionsInCategory(weaponDefinitionsForSlot("off"), category));
const OFF_HAND_CHOICES: readonly AppearanceChoice<CharacterHeldItemVisual | undefined>[] = [
{ value: undefined, label: "None" },
...OFF_HAND_DEFINITIONS.map((definition) => ({
value: heldItemForModel(definition.id as CharacterWeaponModelId),
label: definition.label,
})),
];
function cycleChoice<Value>(
choices: readonly AppearanceChoice<Value>[],
current: Value,
direction: -1 | 1,
equals: (left: Value, right: Value) => boolean = Object.is,
) {
const currentIndex = Math.max(0, choices.findIndex((choice) => equals(choice.value, current)));
return choices[(currentIndex + direction + choices.length) % choices.length];
}
function heldItemEquals(left: CharacterHeldItemVisual | undefined, right: CharacterHeldItemVisual | undefined) {
return left?.modelId === right?.modelId && left?.grip === right?.grip;
}
function mainHandCategory(appearance: CharacterAppearanceV1): CharacterWeaponCategory {
return weaponDefinition(appearance.mainHand.modelId).category as CharacterWeaponCategory;
}
export function appearanceSlotEnabled(appearance: CharacterAppearanceV1, slotId: AppearanceSlotId) {
return slotId !== "off-hand" || !weaponUsesBothHands(appearance.mainHand.modelId);
}
export function appearanceSlotLabel(appearance: CharacterAppearanceV1, slotId: AppearanceSlotId) {
if (slotId === "head") return HEAD_CHOICES.find((choice) => choice.value === appearance.headPartId)?.label ?? appearance.headPartId;
if (slotId === "upper") return UPPER_CHOICES.find((choice) => choice.value === appearance.upperBodyPartId)?.label ?? appearance.upperBodyPartId;
if (slotId === "lower") return LOWER_CHOICES.find((choice) => choice.value === appearance.lowerBodyPartId)?.label ?? appearance.lowerBodyPartId;
if (slotId === "headwear") return HEADWEAR_CHOICES.find((choice) => choice.value === appearance.headwearPartId)?.label ?? "None";
if (slotId === "back") return BACK_CHOICES.find((choice) => choice.value === appearance.backPartId)?.label ?? "None";
if (slotId === "main-hand-family") return WEAPON_CATEGORY_LABELS[mainHandCategory(appearance)];
if (slotId === "main-hand") return MAIN_HAND_CHOICES.find((choice) => heldItemEquals(choice.value, appearance.mainHand))?.label ?? appearance.mainHand.modelId;
if (!appearanceSlotEnabled(appearance, slotId)) return "Hidden by two-hander";
return OFF_HAND_CHOICES.find((choice) => heldItemEquals(choice.value, appearance.offHand))?.label ?? "None";
}
export function cycleAppearanceSlot(
appearance: CharacterAppearanceV1,
slotId: AppearanceSlotId,
direction: -1 | 1,
): CharacterAppearanceV1 {
if (slotId === "head") return { ...appearance, headPartId: cycleChoice(HEAD_CHOICES, appearance.headPartId, direction).value };
if (slotId === "upper") return { ...appearance, upperBodyPartId: cycleChoice(UPPER_CHOICES, appearance.upperBodyPartId, direction).value };
if (slotId === "lower") return { ...appearance, lowerBodyPartId: cycleChoice(LOWER_CHOICES, appearance.lowerBodyPartId, direction).value };
if (slotId === "headwear") return { ...appearance, headwearPartId: cycleChoice(HEADWEAR_CHOICES, appearance.headwearPartId, direction).value };
if (slotId === "back") return { ...appearance, backPartId: cycleChoice(BACK_CHOICES, appearance.backPartId, direction).value };
if (slotId === "main-hand-family") {
const category = cycleChoice(MAIN_HAND_CATEGORY_CHOICES, mainHandCategory(appearance), direction).value;
const definition = MAIN_HAND_DEFINITIONS.find((candidate) => candidate.category === category)!;
return { ...appearance, mainHand: heldItemForModel(definition.id as CharacterWeaponModelId) };
}
if (slotId === "main-hand") {
const choices = MAIN_HAND_CHOICES.filter((choice) => weaponDefinition(choice.value.modelId).category === mainHandCategory(appearance));
const mainHand = cycleChoice(choices, appearance.mainHand, direction, heldItemEquals).value;
return { ...appearance, mainHand: { ...mainHand } };
}
if (!appearanceSlotEnabled(appearance, slotId)) return appearance;
const offHand = cycleChoice(OFF_HAND_CHOICES, appearance.offHand, direction, heldItemEquals).value;
return { ...appearance, offHand: offHand ? { ...offHand } : undefined };
}
export function appearancesMatch(left: CharacterAppearanceV1, right: CharacterAppearanceV1) {
return left.headPartId === right.headPartId
&& left.upperBodyPartId === right.upperBodyPartId
&& left.lowerBodyPartId === right.lowerBodyPartId
&& left.headwearPartId === right.headwearPartId
&& left.backPartId === right.backPartId
&& heldItemEquals(left.mainHand, right.mainHand)
&& heldItemEquals(left.offHand, right.offHand);
}
+26
View File
@@ -1,4 +1,11 @@
import type { BossMotionState, WorldPosition } from "./types";
import {
HOCKEY_ARENA_MAX_X,
HOCKEY_ARENA_MAX_Z,
HOCKEY_ARENA_MIN_X,
HOCKEY_ARENA_MIN_Z,
HOCKEY_MIDLINE_Z,
} from "./hockeyHealing";
export const ARENA_CENTER: WorldPosition = [0, -1];
/** Keeps simulation limits and the room renderer in lockstep. */
@@ -29,3 +36,22 @@ export function constrainBossMotion(motion: BossMotionState): BossMotionState {
export function isInsideArena(position: WorldPosition, tolerance = 0.001) {
return Math.hypot(position[0] - ARENA_CENTER[0], position[1] - ARENA_CENTER[1]) <= ARENA_RADIUS + tolerance;
}
function clampToHockeyBounds(position: WorldPosition, minZ: number, maxZ: number, padding = 0): WorldPosition {
return [
Math.max(HOCKEY_ARENA_MIN_X + padding, Math.min(HOCKEY_ARENA_MAX_X - padding, position[0])),
Math.max(minZ + padding, Math.min(maxZ - padding, position[1])),
];
}
export function clampToHockeyArena(position: WorldPosition, padding = 0): WorldPosition {
return clampToHockeyBounds(position, HOCKEY_ARENA_MIN_Z, HOCKEY_ARENA_MAX_Z, padding);
}
export function clampToHockeyHealerHalf(position: WorldPosition, padding = 0): WorldPosition {
return clampToHockeyBounds(position, HOCKEY_MIDLINE_Z, HOCKEY_ARENA_MAX_Z, padding);
}
export function clampToHockeyEnemyHalf(position: WorldPosition, padding = 0): WorldPosition {
return clampToHockeyBounds(position, HOCKEY_ARENA_MIN_Z, HOCKEY_MIDLINE_Z, padding);
}
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import {
BLOCKBREAKER_BRICK_COLORS,
BLOCKBREAKER_DANGER_Z,
BLOCKBREAKER_MINIMUM_ROW_INTERVAL,
BLOCKBREAKER_PUCK_SPEED,
BLOCKBREAKER_RESERVE_SECONDS,
advanceBlockbreaker,
blockbreakerClusterScore,
blockbreakerColumnX,
blockbreakerConnectedCluster,
blockbreakerRowInterval,
blockbreakerRowZ,
blockbreakerTimeMultiplier,
createBlockbreakerState,
type BlockbreakerBrick,
} from "./blockbreaker";
import { HOCKEY_HEALER_GOAL_Z } from "./hockeyHealing";
describe("Blockbreaker", () => {
it("creates deterministic five-brick rows from four colors", () => {
const first = createBlockbreakerState(true, 12345);
const second = createBlockbreakerState(true, 12345);
expect(first.bricks).toHaveLength(5);
expect(first.bricks.map((brick) => brick.color)).toEqual(second.bricks.map((brick) => brick.color));
expect(first.bricks.every((brick) => BLOCKBREAKER_BRICK_COLORS.includes(brick.color))).toBe(true);
expect(Math.hypot(...first.puckVelocity)).toBeCloseTo(BLOCKBREAKER_PUCK_SPEED);
});
it("accelerates rows by ten percent per minute with a three-second floor", () => {
expect(blockbreakerRowInterval(0)).toBe(10);
expect(blockbreakerRowInterval(60)).toBe(9);
expect(blockbreakerRowInterval(600)).toBeCloseTo(3.486784401, 8);
expect(blockbreakerRowInterval(2_000)).toBe(BLOCKBREAKER_MINIMUM_ROW_INTERVAL);
});
it("uses the per-brick ladder and current uncapped time multiplier", () => {
expect(blockbreakerTimeMultiplier(0)).toBe(1);
expect(blockbreakerTimeMultiplier(30)).toBe(1.1);
expect(blockbreakerTimeMultiplier(60)).toBe(1.2);
expect(blockbreakerTimeMultiplier(600)).toBe(3);
expect(blockbreakerClusterScore(1, 0)).toBe(10);
expect(blockbreakerClusterScore(3, 0)).toBe(60);
expect(blockbreakerClusterScore(3, 30)).toBe(66);
expect(blockbreakerClusterScore(3, 60)).toBe(72);
});
it("propagates through orthogonal matches but not diagonals", () => {
const bricks: BlockbreakerBrick[] = [
{ id: "source", row: 1, column: 1, color: "cyan" },
{ id: "right", row: 1, column: 2, color: "cyan" },
{ id: "up", row: 0, column: 2, color: "cyan" },
{ id: "diagonal", row: 0, column: 0, color: "cyan" },
{ id: "other", row: 2, column: 1, color: "amber" },
];
expect(blockbreakerConnectedCluster(bricks, bricks[0]).map((brick) => brick.id).sort()).toEqual(["right", "source", "up"]);
});
it("breaks the swept-hit cluster and applies score at collision time", () => {
const state = createBlockbreakerState(true, 7);
state.nextRowAt = 999;
state.bricks = [
{ id: "front", row: 0, column: 2, color: "magenta" },
{ id: "behind", row: 1, column: 2, color: "magenta" },
];
state.puckPosition = [blockbreakerColumnX(2), blockbreakerRowZ(0) + 2.2];
state.puckVelocity = [0, -6.25];
const next = advanceBlockbreaker(state, { delta: 0.4, time: 60, playerPosition: [8, 8] });
expect(next.bricks).toEqual([]);
expect(next.bricksBroken).toBe(2);
expect(next.score).toBe(36);
expect(next.puckVelocity[1]).toBeGreaterThan(0);
});
it("removes a breached row and keeps the run live", () => {
let state = createBlockbreakerState(true, 8);
state.puckVelocity = [0, 0];
while (state.breaches === 0) {
state = advanceBlockbreaker(state, { delta: 0.1, time: state.nextRowAt, playerPosition: [0, 8.5] });
}
expect(state.status).toBe("live");
expect(state.breaches).toBe(1);
expect(state.bricks.every((brick) => blockbreakerRowZ(brick.row) < BLOCKBREAKER_DANGER_Z)).toBe(true);
expect(state.lostAt).toBeNull();
});
it("adds complete five-column rows on schedule", () => {
const state = createBlockbreakerState(true, 81);
state.puckVelocity = [0, 0];
state.puckPosition = [0, 8];
const next = advanceBlockbreaker(state, { delta: 0.1, time: 10, playerPosition: [0, 8.5] });
expect(next.bricks).toHaveLength(10);
expect(next.bricks.filter((brick) => brick.row === 0).map((brick) => brick.column).sort()).toEqual([0, 1, 2, 3, 4]);
expect(next.bricks.filter((brick) => brick.row === 1).map((brick) => brick.column).sort()).toEqual([0, 1, 2, 3, 4]);
});
it("rebounds from side and far walls", () => {
const side = createBlockbreakerState(true, 82);
side.nextRowAt = 999;
side.bricks = [];
side.puckPosition = [9.45, 0];
side.puckVelocity = [6.25, 0];
const sideBounce = advanceBlockbreaker(side, { delta: 0.2, time: 1, playerPosition: [0, 8.5] });
expect(sideBounce.puckVelocity[0]).toBeLessThan(0);
const far = createBlockbreakerState(true, 83);
far.nextRowAt = 999;
far.bricks = [];
far.puckPosition = [0, -14];
far.puckVelocity = [0, -6.25];
const farBounce = advanceBlockbreaker(far, { delta: 0.2, time: 1, playerPosition: [8, 8.5] });
expect(farBounce.puckVelocity[1]).toBeGreaterThan(0);
});
it("uses healer position and aim for player rebounds", () => {
const state = createBlockbreakerState(true, 84);
state.nextRowAt = 999;
state.bricks = [];
state.aimDirection = [1, -1];
state.puckPosition = [0, 7.2];
state.puckVelocity = [0, 6.25];
const returned = advanceBlockbreaker(state, { delta: 0.2, time: 1, playerPosition: [0, 8] });
expect(returned.puckVelocity[0]).toBeGreaterThan(0);
expect(returned.puckVelocity[1]).toBeLessThan(0);
});
it("safe re-serves one second after a missed puck without ending the run", () => {
const state = createBlockbreakerState(true, 9);
state.nextRowAt = 999;
state.bricks = [];
state.puckPosition = [0, HOCKEY_HEALER_GOAL_Z - 0.1];
state.puckVelocity = [0, 6.25];
const missed = advanceBlockbreaker(state, { delta: 0.1, time: 5, playerPosition: [8, 8] });
expect(missed.status).toBe("reserving");
expect(missed.lostAt).toBeNull();
expect(missed.reServeAt).toBe(5 + BLOCKBREAKER_RESERVE_SECONDS);
const served = advanceBlockbreaker(missed, { delta: 0.1, time: missed.reServeAt!, playerPosition: [8, 8] });
expect(served.status).toBe("live");
expect(served.puckVelocity[1]).toBeGreaterThan(0);
});
});
+394
View File
@@ -0,0 +1,394 @@
import {
HOCKEY_ARENA_MAX_X,
HOCKEY_ARENA_MIN_X,
HOCKEY_ARENA_WIDTH,
HOCKEY_HEALER_GOAL_Z,
HOCKEY_MIDLINE_Z,
HOCKEY_NPC_GOAL_Z,
hockeyReturnDirection,
} from "./hockeyHealing";
import type { WorldPosition } from "./types";
export const BLOCKBREAKER_COLUMN_COUNT = 5;
export const BLOCKBREAKER_BRICK_COLORS = ["cyan", "amber", "magenta", "lime"] as const;
export type BlockbreakerBrickColor = typeof BLOCKBREAKER_BRICK_COLORS[number];
export interface BlockbreakerBrick {
id: string;
row: number;
column: number;
color: BlockbreakerBrickColor;
}
export type BlockbreakerStatus = "inactive" | "live" | "reserving" | "lost";
export interface BlockbreakerState {
status: BlockbreakerStatus;
seed: number;
randomState: number;
bricks: BlockbreakerBrick[];
puckPosition: WorldPosition;
puckVelocity: WorldPosition;
aimDirection: WorldPosition;
bricksBroken: number;
score: number;
rowsSpawned: number;
breaches: number;
nextRowAt: number;
serveIndex: number;
reServeAt: number | null;
lastBreakAt: number;
lastBreakCount: number;
lastScoreAward: number;
lostAt: number | null;
}
export interface BlockbreakerStep {
delta: number;
time: number;
playerPosition: WorldPosition;
}
export const BLOCKBREAKER_PUCK_RADIUS = 0.42;
export const BLOCKBREAKER_BRICK_WIDTH = HOCKEY_ARENA_WIDTH / BLOCKBREAKER_COLUMN_COUNT - 0.28;
export const BLOCKBREAKER_BRICK_DEPTH = 1.34;
export const BLOCKBREAKER_ROW_SPACING = 1.72;
export const BLOCKBREAKER_SPAWN_Z = HOCKEY_NPC_GOAL_Z + 1.42;
export const BLOCKBREAKER_DANGER_Z = HOCKEY_MIDLINE_Z + (HOCKEY_HEALER_GOAL_Z - HOCKEY_MIDLINE_Z) * 0.5;
export const BLOCKBREAKER_RESERVE_SECONDS = 1;
export const BLOCKBREAKER_BREACH_DAMAGE = 25;
export const BLOCKBREAKER_PUCK_SPEED = 7.25;
export const BLOCKBREAKER_STARTING_ROW_INTERVAL = 10;
export const BLOCKBREAKER_MINIMUM_ROW_INTERVAL = 3;
export const BLOCKBREAKER_MAX_ROWS = Math.ceil((BLOCKBREAKER_DANGER_Z - BLOCKBREAKER_SPAWN_Z) / BLOCKBREAKER_ROW_SPACING) + 1;
export const BLOCKBREAKER_MAX_BRICKS = BLOCKBREAKER_MAX_ROWS * BLOCKBREAKER_COLUMN_COUNT;
const PLAYER_INTERCEPT_RADIUS = 1.05;
const MAX_SUBSTEPS = 12;
const MAX_SUBSTEP_DISTANCE = 0.3;
const MAX_ROW_SPAWNS_PER_STEP = 8;
const SERVE_LANES = [0, -0.62, 0.68, -0.3, 0.36, -0.8, 0.82] as const;
function normalizeSeed(seed: number) {
const normalized = Math.floor(Number(seed)) >>> 0;
return normalized || 0x9e3779b9;
}
export function createBlockbreakerSeed(random: () => number = Math.random) {
const sample = Number(random());
const normalized = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999999, sample)) : 0;
return normalizeSeed(Math.floor(normalized * 0x100000000));
}
function nextRandom(state: number) {
let next = normalizeSeed(state);
next ^= next << 13;
next ^= next >>> 17;
next ^= next << 5;
return next >>> 0;
}
export function blockbreakerColumnX(column: number) {
const laneWidth = HOCKEY_ARENA_WIDTH / BLOCKBREAKER_COLUMN_COUNT;
return HOCKEY_ARENA_MIN_X + laneWidth * (Math.max(0, Math.min(BLOCKBREAKER_COLUMN_COUNT - 1, column)) + 0.5);
}
export function blockbreakerRowZ(row: number) {
return BLOCKBREAKER_SPAWN_Z + Math.max(0, row) * BLOCKBREAKER_ROW_SPACING;
}
export function blockbreakerRowInterval(elapsedSeconds: number) {
const minute = Math.max(0, Math.floor((Number(elapsedSeconds) || 0) / 60));
return Math.max(BLOCKBREAKER_MINIMUM_ROW_INTERVAL, BLOCKBREAKER_STARTING_ROW_INTERVAL * 0.9 ** minute);
}
export function blockbreakerTimeMultiplierTenths(elapsedSeconds: number) {
return 10 + Math.max(0, Math.floor((Number(elapsedSeconds) || 0) / 30));
}
export function blockbreakerTimeMultiplier(elapsedSeconds: number) {
return blockbreakerTimeMultiplierTenths(elapsedSeconds) / 10;
}
export function blockbreakerClusterBase(clusterSize: number) {
const size = Math.max(0, Math.floor(Number(clusterSize) || 0));
return 10 * size * (size + 1) / 2;
}
export function blockbreakerClusterScore(clusterSize: number, elapsedSeconds: number) {
return blockbreakerClusterBase(clusterSize) * blockbreakerTimeMultiplierTenths(elapsedSeconds) / 10;
}
function generateRow(randomState: number, rowSequence: number) {
const bricks: BlockbreakerBrick[] = [];
let nextState = randomState;
for (let column = 0; column < BLOCKBREAKER_COLUMN_COUNT; column += 1) {
nextState = nextRandom(nextState);
bricks.push({
id: `${rowSequence}:${column}`,
row: 0,
column,
color: BLOCKBREAKER_BRICK_COLORS[nextState % BLOCKBREAKER_BRICK_COLORS.length],
});
}
return { bricks, randomState: nextState };
}
function servePosition(serveIndex: number): WorldPosition {
const lane = SERVE_LANES[serveIndex % SERVE_LANES.length];
return [lane * (HOCKEY_ARENA_MAX_X - 1.25), BLOCKBREAKER_SPAWN_Z + BLOCKBREAKER_BRICK_DEPTH * 0.5 + 0.72];
}
function serveVelocity(position: WorldPosition, serveIndex: number): WorldPosition {
const lane = SERVE_LANES[(serveIndex + 3) % SERVE_LANES.length];
const target: WorldPosition = [lane * (HOCKEY_ARENA_MAX_X - 1.4), HOCKEY_HEALER_GOAL_Z];
const dx = target[0] - position[0];
const dz = target[1] - position[1];
const length = Math.max(0.0001, Math.hypot(dx, dz));
return [dx / length * BLOCKBREAKER_PUCK_SPEED, dz / length * BLOCKBREAKER_PUCK_SPEED];
}
export function createBlockbreakerState(active = false, requestedSeed = 1): BlockbreakerState {
const seed = normalizeSeed(requestedSeed);
const row = generateRow(seed, 0);
const puckPosition = servePosition(0);
return {
status: active ? "live" : "inactive",
seed,
randomState: row.randomState,
bricks: row.bricks,
puckPosition,
puckVelocity: active ? serveVelocity(puckPosition, 0) : [0, 0],
aimDirection: [0, -1],
bricksBroken: 0,
score: 0,
rowsSpawned: 1,
breaches: 0,
nextRowAt: BLOCKBREAKER_STARTING_ROW_INTERVAL,
serveIndex: 0,
reServeAt: null,
lastBreakAt: Number.NEGATIVE_INFINITY,
lastBreakCount: 0,
lastScoreAward: 0,
lostAt: null,
};
}
export function setBlockbreakerAim(state: BlockbreakerState, aim: WorldPosition): BlockbreakerState {
const x = Number.isFinite(aim[0]) ? aim[0] : 0;
const z = Number.isFinite(aim[1]) ? aim[1] : 0;
if (state.aimDirection[0] === x && state.aimDirection[1] === z) return state;
return { ...state, aimDirection: [x, z] };
}
export function blockbreakerConnectedCluster(bricks: readonly BlockbreakerBrick[], source: BlockbreakerBrick) {
const byCell = new Map(bricks.map((brick) => [`${brick.row}:${brick.column}`, brick]));
const connected: BlockbreakerBrick[] = [];
const visited = new Set<string>();
const pending: BlockbreakerBrick[] = [source];
while (pending.length) {
const brick = pending.pop()!;
if (visited.has(brick.id) || brick.color !== source.color) continue;
visited.add(brick.id);
connected.push(brick);
for (const [row, column] of [
[brick.row - 1, brick.column],
[brick.row + 1, brick.column],
[brick.row, brick.column - 1],
[brick.row, brick.column + 1],
]) {
const neighbor = byCell.get(`${row}:${column}`);
if (neighbor && !visited.has(neighbor.id) && neighbor.color === source.color) pending.push(neighbor);
}
}
return connected;
}
function segmentDistanceSquared(
startX: number,
startZ: number,
endX: number,
endZ: number,
point: WorldPosition,
) {
const dx = endX - startX;
const dz = endZ - startZ;
const lengthSquared = dx * dx + dz * dz;
const projection = lengthSquared < 0.000001
? 0
: Math.max(0, Math.min(1, ((point[0] - startX) * dx + (point[1] - startZ) * dz) / lengthSquared));
const nearestX = startX + dx * projection;
const nearestZ = startZ + dz * projection;
return (point[0] - nearestX) ** 2 + (point[1] - nearestZ) ** 2;
}
type SegmentHit = { time: number; normalX: number; normalZ: number };
function segmentExpandedBrickHit(start: WorldPosition, end: WorldPosition, brick: BlockbreakerBrick): SegmentHit | null {
const centerX = blockbreakerColumnX(brick.column);
const centerZ = blockbreakerRowZ(brick.row);
const minX = centerX - BLOCKBREAKER_BRICK_WIDTH * 0.5 - BLOCKBREAKER_PUCK_RADIUS;
const maxX = centerX + BLOCKBREAKER_BRICK_WIDTH * 0.5 + BLOCKBREAKER_PUCK_RADIUS;
const minZ = centerZ - BLOCKBREAKER_BRICK_DEPTH * 0.5 - BLOCKBREAKER_PUCK_RADIUS;
const maxZ = centerZ + BLOCKBREAKER_BRICK_DEPTH * 0.5 + BLOCKBREAKER_PUCK_RADIUS;
const dx = end[0] - start[0];
const dz = end[1] - start[1];
let entry = 0;
let exit = 1;
let normalX = 0;
let normalZ = 0;
for (const axis of [
{ start: start[0], delta: dx, min: minX, max: maxX, nx: -Math.sign(dx), nz: 0 },
{ start: start[1], delta: dz, min: minZ, max: maxZ, nx: 0, nz: -Math.sign(dz) },
]) {
if (Math.abs(axis.delta) < 0.000001) {
if (axis.start < axis.min || axis.start > axis.max) return null;
continue;
}
const near = (axis.min - axis.start) / axis.delta;
const far = (axis.max - axis.start) / axis.delta;
const axisEntry = Math.min(near, far);
const axisExit = Math.max(near, far);
if (axisEntry > entry) {
entry = axisEntry;
normalX = axis.nx;
normalZ = axis.nz;
}
exit = Math.min(exit, axisExit);
if (entry > exit) return null;
}
if (entry < 0 || entry > 1 || exit < 0) return null;
if (normalX === 0 && normalZ === 0) {
if (Math.abs(dx) > Math.abs(dz)) normalX = -Math.sign(dx);
else normalZ = -Math.sign(dz);
}
return { time: entry, normalX, normalZ };
}
function addBlockbreakerRow(state: BlockbreakerState, spawnedAt: number) {
const shifted = state.bricks.map((brick) => ({ ...brick, row: brick.row + 1 }));
const survivors = shifted.filter((brick) => blockbreakerRowZ(brick.row) < BLOCKBREAKER_DANGER_Z);
if (survivors.length < shifted.length) state.breaches += 1;
const row = generateRow(state.randomState, state.rowsSpawned);
state.randomState = row.randomState;
state.bricks = [...survivors, ...row.bricks];
state.rowsSpawned += 1;
}
function breakCluster(state: BlockbreakerState, hitBrick: BlockbreakerBrick, time: number) {
const cluster = blockbreakerConnectedCluster(state.bricks, hitBrick);
const ids = new Set(cluster.map((brick) => brick.id));
const award = blockbreakerClusterScore(cluster.length, time);
state.bricks = state.bricks.filter((brick) => !ids.has(brick.id));
state.bricksBroken += cluster.length;
state.score += award;
state.lastBreakAt = time;
state.lastBreakCount = cluster.length;
state.lastScoreAward = award;
}
function beginReserve(state: BlockbreakerState, time: number) {
state.status = "reserving";
state.puckVelocity = [0, 0];
state.reServeAt = time + BLOCKBREAKER_RESERVE_SECONDS;
}
function completeReserve(state: BlockbreakerState) {
state.serveIndex += 1;
state.puckPosition = servePosition(state.serveIndex);
state.puckVelocity = serveVelocity(state.puckPosition, state.serveIndex);
state.status = "live";
state.reServeAt = null;
}
export function blockbreakerAimPreviewVisible(state: BlockbreakerState, playerPosition: WorldPosition) {
if (state.status !== "live" || state.puckVelocity[1] <= 0) return false;
const distance = Math.hypot(playerPosition[0] - state.puckPosition[0], playerPosition[1] - state.puckPosition[1]);
return distance / Math.max(0.001, Math.hypot(state.puckVelocity[0], state.puckVelocity[1])) <= 1.5;
}
export function advanceBlockbreaker(source: BlockbreakerState, step: BlockbreakerStep): BlockbreakerState {
if (source.status === "inactive" || source.status === "lost" || step.delta <= 0) return source;
const state: BlockbreakerState = {
...source,
// Bricks are immutable between row shifts and breaks; both operations replace
// this array, so sharing it avoids cloning every brick on each 10 Hz tick.
bricks: source.bricks,
puckPosition: [...source.puckPosition],
puckVelocity: [...source.puckVelocity],
aimDirection: [...source.aimDirection],
};
let spawnedRows = 0;
while (state.status !== "lost" && step.time + 0.0001 >= state.nextRowAt && spawnedRows < MAX_ROW_SPAWNS_PER_STEP) {
const spawnedAt = state.nextRowAt;
addBlockbreakerRow(state, spawnedAt);
state.nextRowAt = spawnedAt + blockbreakerRowInterval(spawnedAt);
spawnedRows += 1;
}
if (state.status === "lost") return state;
if (state.status === "reserving") {
if (state.reServeAt !== null && step.time >= state.reServeAt) completeReserve(state);
else return state;
}
const speed = Math.hypot(state.puckVelocity[0], state.puckVelocity[1]);
const substeps = Math.max(1, Math.min(MAX_SUBSTEPS, Math.ceil(speed * step.delta / MAX_SUBSTEP_DISTANCE)));
const subDelta = step.delta / substeps;
const interceptRadiusSquared = (PLAYER_INTERCEPT_RADIUS + BLOCKBREAKER_PUCK_RADIUS) ** 2;
for (let substep = 0; substep < substeps && state.status === "live"; substep += 1) {
const start: WorldPosition = [...state.puckPosition];
const end: WorldPosition = [
start[0] + state.puckVelocity[0] * subDelta,
start[1] + state.puckVelocity[1] * subDelta,
];
const minX = HOCKEY_ARENA_MIN_X + BLOCKBREAKER_PUCK_RADIUS;
const maxX = HOCKEY_ARENA_MAX_X - BLOCKBREAKER_PUCK_RADIUS;
if (end[0] < minX || end[0] > maxX) {
end[0] = Math.max(minX, Math.min(maxX, end[0]));
state.puckVelocity[0] *= -1;
}
let closest: { brick: BlockbreakerBrick; hit: SegmentHit } | null = null;
for (const brick of state.bricks) {
const hit = segmentExpandedBrickHit(start, end, brick);
if (hit && (!closest || hit.time < closest.hit.time)) closest = { brick, hit };
}
if (closest) {
state.puckPosition = [
start[0] + (end[0] - start[0]) * closest.hit.time + closest.hit.normalX * 0.012,
start[1] + (end[1] - start[1]) * closest.hit.time + closest.hit.normalZ * 0.012,
];
if (closest.hit.normalX) state.puckVelocity[0] *= -1;
if (closest.hit.normalZ) state.puckVelocity[1] *= -1;
breakCluster(state, closest.brick, step.time);
continue;
}
if (state.puckVelocity[1] > 0
&& segmentDistanceSquared(start[0], start[1], end[0], end[1], step.playerPosition) <= interceptRadiusSquared) {
const direction = hockeyReturnDirection(state.aimDirection);
state.puckPosition = [end[0], Math.min(end[1], step.playerPosition[1])];
state.puckVelocity = [direction[0] * BLOCKBREAKER_PUCK_SPEED, direction[1] * BLOCKBREAKER_PUCK_SPEED];
continue;
}
if (state.puckVelocity[1] < 0 && end[1] <= HOCKEY_NPC_GOAL_Z + BLOCKBREAKER_PUCK_RADIUS) {
state.puckPosition = [end[0], HOCKEY_NPC_GOAL_Z + BLOCKBREAKER_PUCK_RADIUS];
state.puckVelocity[1] = Math.abs(state.puckVelocity[1]);
continue;
}
if (state.puckVelocity[1] > 0 && end[1] >= HOCKEY_HEALER_GOAL_Z) {
state.puckPosition = [end[0], HOCKEY_HEALER_GOAL_Z];
beginReserve(state, step.time);
continue;
}
state.puckPosition = end;
}
return state;
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { BLOCKBREAKER_BRICK_COLORS } from "./blockbreaker";
import {
BLOCKBREAKER_BIOMES,
BLOCKBREAKER_BIOME_IDS,
blockbreakerBiomeForSeed,
} from "./blockbreakerBiomes";
describe("Blockbreaker arena biomes", () => {
it("defines five distinct arena and fixture styles", () => {
expect(BLOCKBREAKER_BIOMES.map((biome) => biome.id)).toEqual(BLOCKBREAKER_BIOME_IDS);
expect(new Set(BLOCKBREAKER_BIOMES.map((biome) => biome.name)).size).toBe(5);
expect(new Set(BLOCKBREAKER_BIOMES.map((biome) => biome.fixture)).size).toBe(5);
expect(new Set(BLOCKBREAKER_BIOMES.map((biome) => biome.background)).size).toBe(5);
expect(new Set(BLOCKBREAKER_BIOMES.map((biome) => biome.keyLight)).size).toBe(5);
});
it("keeps every gameplay brick color visually distinct in every biome", () => {
for (const biome of BLOCKBREAKER_BIOMES) {
const colors = BLOCKBREAKER_BRICK_COLORS.map((color) => biome.bricks[color]);
expect(colors.every((color) => /^#[0-9a-f]{6}$/i.test(color))).toBe(true);
expect(new Set(colors).size).toBe(BLOCKBREAKER_BRICK_COLORS.length);
}
});
it("selects a deterministic biome from the match seed", () => {
expect(blockbreakerBiomeForSeed(0x12345678)).toBe(blockbreakerBiomeForSeed(0x12345678));
const sampledIds = new Set(Array.from({ length: 256 }, (_, seed) => blockbreakerBiomeForSeed(seed).id));
expect(sampledIds).toEqual(new Set(BLOCKBREAKER_BIOME_IDS));
});
});
+253
View File
@@ -0,0 +1,253 @@
import type { BlockbreakerBrickColor } from "./blockbreaker";
export const BLOCKBREAKER_BIOME_IDS = [
"prism-circuit",
"ember-foundry",
"frost-vault",
"void-grid",
"verdant-reactor",
] as const;
export type BlockbreakerBiomeId = typeof BLOCKBREAKER_BIOME_IDS[number];
export type BlockbreakerBiomeFixture = "crystal" | "forge" | "spire" | "monolith" | "reactor";
export interface BlockbreakerArenaBiome {
id: BlockbreakerBiomeId;
name: string;
fixture: BlockbreakerBiomeFixture;
background: string;
fog: string;
ambient: string;
sky: string;
ground: string;
keyLight: string;
fillLightA: string;
fillLightB: string;
foundation: string;
floor: string;
playfield: string;
wall: string;
wallEmissive: string;
boundary: string;
railA: string;
railB: string;
midline: string;
fixtureColor: string;
fixtureEmissive: string;
ambientIntensity: number;
hemisphereIntensity: number;
keyLightIntensity: number;
fillLightIntensityA: number;
fillLightIntensityB: number;
wallEmissiveIntensity: number;
floorRoughness: number;
floorMetalness: number;
bricks: Readonly<Record<BlockbreakerBrickColor, string>>;
}
export const BLOCKBREAKER_BIOMES: readonly BlockbreakerArenaBiome[] = [
{
id: "prism-circuit",
name: "Prism Circuit",
fixture: "crystal",
background: "#163640",
fog: "#214b55",
ambient: "#dffcff",
sky: "#efffff",
ground: "#24535b",
keyLight: "#f4ffff",
fillLightA: "#8cefff",
fillLightB: "#fff0bd",
foundation: "#173c44",
floor: "#2d6972",
playfield: "#367985",
wall: "#39717c",
wallEmissive: "#143a43",
boundary: "#b9f8ff",
railA: "#9ef6ff",
railB: "#fff2c2",
midline: "#e8feff",
fixtureColor: "#49a6b7",
fixtureEmissive: "#70efff",
ambientIntensity: 0.72,
hemisphereIntensity: 2.3,
keyLightIntensity: 3.15,
fillLightIntensityA: 5.5,
fillLightIntensityB: 4.8,
wallEmissiveIntensity: 0.32,
floorRoughness: 0.58,
floorMetalness: 0.08,
bricks: {
cyan: "#42ddff",
amber: "#ffc247",
magenta: "#ff55ad",
lime: "#8bf065",
},
},
{
id: "ember-foundry",
name: "Ember Foundry",
fixture: "forge",
background: "#2a0d0a",
fog: "#4b1b11",
ambient: "#ffd7a8",
sky: "#ffb45c",
ground: "#240a0b",
keyLight: "#ffe0b2",
fillLightA: "#ff5b32",
fillLightB: "#ffc54d",
foundation: "#250b0a",
floor: "#5a2416",
playfield: "#7a321e",
wall: "#52241d",
wallEmissive: "#6a170c",
boundary: "#ffb15f",
railA: "#ff6a38",
railB: "#ffd15c",
midline: "#ffe2a6",
fixtureColor: "#5d2117",
fixtureEmissive: "#ff5a26",
ambientIntensity: 0.58,
hemisphereIntensity: 1.85,
keyLightIntensity: 2.75,
fillLightIntensityA: 6.4,
fillLightIntensityB: 5.4,
wallEmissiveIntensity: 0.48,
floorRoughness: 0.76,
floorMetalness: 0.18,
bricks: {
cyan: "#54dcff",
amber: "#ffbd3d",
magenta: "#ff5b78",
lime: "#d7ff5c",
},
},
{
id: "frost-vault",
name: "Frost Vault",
fixture: "spire",
background: "#071a2f",
fog: "#143b56",
ambient: "#dff8ff",
sky: "#c6f4ff",
ground: "#10233d",
keyLight: "#ffffff",
fillLightA: "#55dfff",
fillLightB: "#9b8cff",
foundation: "#09192a",
floor: "#1c4c67",
playfield: "#255f7c",
wall: "#24455f",
wallEmissive: "#0d4662",
boundary: "#d4fbff",
railA: "#6be9ff",
railB: "#b5a7ff",
midline: "#ffffff",
fixtureColor: "#5b9db9",
fixtureEmissive: "#9defff",
ambientIntensity: 0.66,
hemisphereIntensity: 2.05,
keyLightIntensity: 3.35,
fillLightIntensityA: 5.3,
fillLightIntensityB: 4.2,
wallEmissiveIntensity: 0.38,
floorRoughness: 0.42,
floorMetalness: 0.16,
bricks: {
cyan: "#38e4ff",
amber: "#ffd36f",
magenta: "#b47cff",
lime: "#6ef2c0",
},
},
{
id: "void-grid",
name: "Void Grid",
fixture: "monolith",
background: "#05020f",
fog: "#120526",
ambient: "#cebaff",
sky: "#8d65ff",
ground: "#0a0313",
keyLight: "#e8dcff",
fillLightA: "#784cff",
fillLightB: "#ff38c7",
foundation: "#07020e",
floor: "#160a2b",
playfield: "#211044",
wall: "#1d1030",
wallEmissive: "#280966",
boundary: "#b68cff",
railA: "#7d5cff",
railB: "#ff4ccc",
midline: "#d9c4ff",
fixtureColor: "#16082e",
fixtureEmissive: "#8d5cff",
ambientIntensity: 0.44,
hemisphereIntensity: 1.48,
keyLightIntensity: 2.45,
fillLightIntensityA: 6.2,
fillLightIntensityB: 5.7,
wallEmissiveIntensity: 0.64,
floorRoughness: 0.34,
floorMetalness: 0.28,
bricks: {
cyan: "#41d8ff",
amber: "#ffac45",
magenta: "#ff4bd0",
lime: "#a1ff4f",
},
},
{
id: "verdant-reactor",
name: "Verdant Reactor",
fixture: "reactor",
background: "#0d211a",
fog: "#193f31",
ambient: "#e1ffe3",
sky: "#b8ffc9",
ground: "#102217",
keyLight: "#f2ffe8",
fillLightA: "#63e6a3",
fillLightB: "#d8ff67",
foundation: "#0d1f18",
floor: "#25543d",
playfield: "#2d684a",
wall: "#294f3f",
wallEmissive: "#0d4b2d",
boundary: "#b9ffd2",
railA: "#67f4a7",
railB: "#e7ff73",
midline: "#f1ffd0",
fixtureColor: "#245642",
fixtureEmissive: "#76f7a9",
ambientIntensity: 0.62,
hemisphereIntensity: 2.02,
keyLightIntensity: 2.9,
fillLightIntensityA: 5.1,
fillLightIntensityB: 4.6,
wallEmissiveIntensity: 0.42,
floorRoughness: 0.68,
floorMetalness: 0.06,
bricks: {
cyan: "#4be3ff",
amber: "#ffca52",
magenta: "#ff6fb5",
lime: "#99f75d",
},
},
];
function mixSeed(seed: number) {
let mixed = Math.floor(Number(seed)) >>> 0;
mixed ^= mixed >>> 16;
mixed = Math.imul(mixed, 0x7feb352d);
mixed ^= mixed >>> 15;
mixed = Math.imul(mixed, 0x846ca68b);
mixed ^= mixed >>> 16;
return mixed >>> 0;
}
export function blockbreakerBiomeForSeed(seed: number) {
return BLOCKBREAKER_BIOMES[mixSeed(seed) % BLOCKBREAKER_BIOMES.length];
}

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