Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a849cd2c69 | ||
|
|
a121b2ef74 | ||
|
|
b7cb572631 | ||
|
|
c6e2c61d0a |
@@ -11,6 +11,7 @@
|
||||
## Input Requirements
|
||||
|
||||
- Every game screen, menu, dialog, overlay, and gameplay interaction must be fully navigable by controller at all times.
|
||||
- Any time a screen, menu, dialog, overlay, or gameplay interaction is added or changed, verify controller navigation still works for that surface before considering the work complete.
|
||||
- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts.
|
||||
- Focus state must always be visible and predictable when controller navigation is active.
|
||||
- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input.
|
||||
@@ -22,6 +23,20 @@
|
||||
- Keep shared game logic independent from platform-specific web or mobile wrappers whenever practical.
|
||||
- Apply game changes to both web version and mobile app version.
|
||||
|
||||
## I Want To Heal 1 vs I Want To Heal 2
|
||||
|
||||
- I Want To Heal 1 is the existing healer-first menu/combat game. Preserve its current progression, saves, inventories, collection logs, combat screens, and content unless the user explicitly asks to change IWT1.
|
||||
- I Want To Heal 2 is the new 2D boss-arena version. Keep IWT2 code, content, save data, inventories, character levels, and collection logs separate from IWT1 so the two modes are not confused.
|
||||
- The app should offer a game-select screen before the main menu: `I Want To Heal 1` launches the old version, and `I Want To Heal 2` launches the new 2D version.
|
||||
- Both games must remain usable from the same Android APK and from `iwanttoheal.phenomrom.com`.
|
||||
- Prefer an explicit folder split for IWT2, such as `src/modes/iwt2/`, with subfolders for screens, simulation, rendering, content, and save/repository code.
|
||||
- Keep IWT2 simulation state independent from rendering. Arena movement, AI, boss attacks, collisions, damage, stun/knockdown, progression, inventory, and collection logs should live in pure TypeScript modules where practical.
|
||||
- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state.
|
||||
- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement.
|
||||
- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements.
|
||||
- First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds.
|
||||
- IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme.
|
||||
|
||||
## Performance Requirements
|
||||
|
||||
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 89
|
||||
versionName "1.1.10"
|
||||
versionCode 93
|
||||
versionName "1.1.14"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -169,7 +169,8 @@ CREATE TABLE IF NOT EXISTS accounts (
|
||||
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
|
||||
completed_raid_phases INTEGER NOT NULL DEFAULT 0,
|
||||
created_ip TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_ip_allowances (
|
||||
|
||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -241,6 +241,12 @@ addColumnIfMissing('encounters', 'image_url', "TEXT NOT NULL DEFAULT '/boss-plac
|
||||
|
||||
addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
|
||||
addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0')
|
||||
addColumnIfMissing('accounts', 'last_saved_at', 'TEXT')
|
||||
database.prepare(`
|
||||
UPDATE accounts
|
||||
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
|
||||
WHERE last_saved_at IS NULL OR last_saved_at = ''
|
||||
`).run()
|
||||
addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
|
||||
|
||||
migrateCharacterAccountConstraint()
|
||||
|
||||
@@ -37,6 +37,35 @@ const pvpMatches = new Map()
|
||||
const pvpQueueTtlMs = 15 * 1000
|
||||
const pvpMatchTtlMs = 60 * 60 * 1000
|
||||
|
||||
function addColumnIfMissing(database, table, column, definition) {
|
||||
const columns = database.prepare(`PRAGMA table_info(${table})`).all()
|
||||
if (!columns.some((candidate) => candidate.name === column)) {
|
||||
database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureRuntimeMigrations(database) {
|
||||
addColumnIfMissing(database, 'accounts', 'last_saved_at', 'TEXT')
|
||||
database.prepare(`
|
||||
UPDATE accounts
|
||||
SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
|
||||
WHERE last_saved_at IS NULL OR last_saved_at = ''
|
||||
`).run()
|
||||
}
|
||||
|
||||
function sqliteDateFromMs(value) {
|
||||
const timestamp = Number(value)
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
return new Date(timestamp).toISOString()
|
||||
}
|
||||
|
||||
function epochMsFromSqliteDate(value) {
|
||||
const timestamp = Date.parse(String(value ?? ''))
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
function sendJson(response, status, body, headers = {}) {
|
||||
response.statusCode = status
|
||||
response.setHeader('Content-Type', 'application/json')
|
||||
@@ -44,6 +73,19 @@ function sendJson(response, status, body, headers = {}) {
|
||||
response.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
function touchAccountSave(database, accountId) {
|
||||
database.prepare(`
|
||||
UPDATE accounts
|
||||
SET last_saved_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(accountId)
|
||||
}
|
||||
|
||||
function sendMutatingResult(response, database, accountId, body) {
|
||||
touchAccountSave(database, accountId)
|
||||
sendJson(response, 200, body)
|
||||
}
|
||||
|
||||
function configuredCorsOrigins() {
|
||||
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
|
||||
.split(',')
|
||||
@@ -1027,7 +1069,8 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
||||
const account = database.prepare(`
|
||||
SELECT
|
||||
completed_dungeon_parts AS completedDungeonParts,
|
||||
completed_raid_phases AS completedRaidPhases
|
||||
completed_raid_phases AS completedRaidPhases,
|
||||
last_saved_at AS lastSavedAt
|
||||
FROM accounts
|
||||
WHERE id = ?
|
||||
`).get(accountId)
|
||||
@@ -1058,6 +1101,7 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
||||
`).get(activeCharacterId)
|
||||
return {
|
||||
version: 4,
|
||||
updatedAt: epochMsFromSqliteDate(account?.lastSavedAt),
|
||||
characterName,
|
||||
activeClassId,
|
||||
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
||||
@@ -1164,11 +1208,12 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
||||
|
||||
database.prepare(`
|
||||
UPDATE accounts
|
||||
SET completed_dungeon_parts = ?, completed_raid_phases = ?
|
||||
SET completed_dungeon_parts = ?, completed_raid_phases = ?, last_saved_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
clampInteger(save.completedDungeonParts, 0, 0, 3),
|
||||
clampInteger(save.completedRaidPhases, 0, 0, 3),
|
||||
sqliteDateFromMs(save.updatedAt),
|
||||
accountId,
|
||||
)
|
||||
|
||||
@@ -3004,6 +3049,7 @@ export async function handleAuthApiRequest(request, response, next = null) {
|
||||
|
||||
const database = new DatabaseSync(databasePath)
|
||||
database.exec('PRAGMA foreign_keys = ON')
|
||||
ensureRuntimeMigrations(database)
|
||||
|
||||
try {
|
||||
const ip = requestIp(request)
|
||||
@@ -3060,6 +3106,7 @@ export async function handleApiRequest(request, response, next) {
|
||||
|
||||
const database = new DatabaseSync(databasePath)
|
||||
database.exec('PRAGMA foreign_keys = ON')
|
||||
ensureRuntimeMigrations(database)
|
||||
|
||||
try {
|
||||
const ip = requestIp(request)
|
||||
@@ -3098,7 +3145,7 @@ export async function handleApiRequest(request, response, next) {
|
||||
if (request.url === '/api/profile' && request.method === 'PUT') {
|
||||
const payload = await readJson(request)
|
||||
const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload)
|
||||
sendJson(response, 200, getProfile(database, newCharacterId, session.accountId))
|
||||
sendMutatingResult(response, database, session.accountId, getProfile(database, newCharacterId, session.accountId))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3148,9 +3195,10 @@ export async function handleApiRequest(request, response, next) {
|
||||
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
||||
if (dungeonCompletion && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
sendJson(
|
||||
sendMutatingResult(
|
||||
response,
|
||||
200,
|
||||
database,
|
||||
session.accountId,
|
||||
completeDungeon(
|
||||
database,
|
||||
session.characterId,
|
||||
@@ -3165,9 +3213,10 @@ export async function handleApiRequest(request, response, next) {
|
||||
|
||||
if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
sendJson(
|
||||
sendMutatingResult(
|
||||
response,
|
||||
200,
|
||||
database,
|
||||
session.accountId,
|
||||
completeRoguelike(database, session.characterId, session.accountId, payload),
|
||||
)
|
||||
return
|
||||
@@ -3175,24 +3224,26 @@ export async function handleApiRequest(request, response, next) {
|
||||
|
||||
const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
|
||||
if (talentAllocation && request.method === 'POST') {
|
||||
sendJson(
|
||||
sendMutatingResult(
|
||||
response,
|
||||
200,
|
||||
database,
|
||||
session.accountId,
|
||||
allocateTalent(database, session.characterId, Number(talentAllocation[1])),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url === '/api/talents/reset' && request.method === 'POST') {
|
||||
sendJson(response, 200, resetTalents(database, session.characterId))
|
||||
sendMutatingResult(response, database, session.accountId, resetTalents(database, session.characterId))
|
||||
return
|
||||
}
|
||||
|
||||
const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
|
||||
if (itemEquip && request.method === 'POST') {
|
||||
sendJson(
|
||||
sendMutatingResult(
|
||||
response,
|
||||
200,
|
||||
database,
|
||||
session.accountId,
|
||||
equipItem(database, session.characterId, Number(itemEquip[1])),
|
||||
)
|
||||
return
|
||||
@@ -3200,9 +3251,10 @@ export async function handleApiRequest(request, response, next) {
|
||||
|
||||
const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
|
||||
if (itemDiscard && request.method === 'POST') {
|
||||
sendJson(
|
||||
sendMutatingResult(
|
||||
response,
|
||||
200,
|
||||
database,
|
||||
session.accountId,
|
||||
discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
|
||||
)
|
||||
return
|
||||
@@ -3210,9 +3262,10 @@ export async function handleApiRequest(request, response, next) {
|
||||
|
||||
const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
|
||||
if (itemBreakdown && request.method === 'POST') {
|
||||
sendJson(
|
||||
sendMutatingResult(
|
||||
response,
|
||||
200,
|
||||
database,
|
||||
session.accountId,
|
||||
breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
|
||||
)
|
||||
return
|
||||
@@ -3220,9 +3273,10 @@ export async function handleApiRequest(request, response, next) {
|
||||
|
||||
const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
|
||||
if (recipeCraft && request.method === 'POST') {
|
||||
sendJson(
|
||||
sendMutatingResult(
|
||||
response,
|
||||
200,
|
||||
database,
|
||||
session.accountId,
|
||||
craftItem(database, session.characterId, Number(recipeCraft[1])),
|
||||
)
|
||||
return
|
||||
@@ -3230,9 +3284,10 @@ export async function handleApiRequest(request, response, next) {
|
||||
|
||||
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
|
||||
if (itemUpgrade && request.method === 'POST') {
|
||||
sendJson(
|
||||
sendMutatingResult(
|
||||
response,
|
||||
200,
|
||||
database,
|
||||
session.accountId,
|
||||
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
|
||||
)
|
||||
return
|
||||
@@ -3241,9 +3296,10 @@ export async function handleApiRequest(request, response, next) {
|
||||
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
|
||||
if (encounterLootRoll && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
sendJson(
|
||||
sendMutatingResult(
|
||||
response,
|
||||
200,
|
||||
database,
|
||||
session.accountId,
|
||||
rollEncounterLoot(
|
||||
database,
|
||||
session.characterId,
|
||||
|
||||
@@ -70,6 +70,11 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.game-selected {
|
||||
outline: 3px solid #fff4a8 !important;
|
||||
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65) !important;
|
||||
}
|
||||
|
||||
.settings-heading {
|
||||
align-items: end;
|
||||
border-bottom: 2px solid #34343d;
|
||||
@@ -1037,6 +1042,8 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
.dual-controls-resource,
|
||||
.dual-controls-targets,
|
||||
.dual-controls-spells,
|
||||
.setup-bottom-summary,
|
||||
.setup-bottom-stats,
|
||||
.dual-bottom-waiting > section {
|
||||
background: var(--panel);
|
||||
border: 3px solid #0c0d11;
|
||||
@@ -1161,8 +1168,69 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
|
||||
.dual-controls-progress {
|
||||
color: var(--muted);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-family: var(--pixel-font);
|
||||
font-size: 7px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.setup-bottom-display {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.setup-bottom-summary {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 82px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.setup-bottom-summary .dungeon-art {
|
||||
height: 78px;
|
||||
width: 78px;
|
||||
}
|
||||
|
||||
.setup-bottom-summary h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.setup-bottom-summary p:not(.eyebrow),
|
||||
.setup-bottom-summary small {
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
line-height: 1.15;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.setup-bottom-stats {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.setup-bottom-stats > div {
|
||||
background: #111217;
|
||||
border: 2px solid #090a0d;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.setup-bottom-stats dt {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.setup-bottom-stats dd {
|
||||
color: var(--ink);
|
||||
font-family: var(--pixel-font);
|
||||
font-size: 9px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.dual-controls-resource {
|
||||
@@ -1251,7 +1319,9 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
.dual-controls-header,
|
||||
.dual-controls-resource,
|
||||
.dual-controls-targets,
|
||||
.dual-controls-spells {
|
||||
.dual-controls-spells,
|
||||
.setup-bottom-summary,
|
||||
.setup-bottom-stats {
|
||||
border-width: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1279,6 +1349,47 @@ html[data-input-device='controller'] [data-game-nav-active='true'] [tabindex]:fo
|
||||
font-size: 6px;
|
||||
}
|
||||
|
||||
.setup-bottom-summary {
|
||||
gap: 8px;
|
||||
grid-template-columns: 58px minmax(0, 1fr);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.setup-bottom-summary .dungeon-art {
|
||||
font-size: 13px;
|
||||
height: 54px;
|
||||
width: 54px;
|
||||
}
|
||||
|
||||
.setup-bottom-summary h2 {
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.setup-bottom-summary p:not(.eyebrow),
|
||||
.setup-bottom-summary small {
|
||||
font-size: 13px;
|
||||
line-height: 1.1;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.setup-bottom-stats {
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.setup-bottom-stats > div {
|
||||
padding: 5px 4px;
|
||||
}
|
||||
|
||||
.setup-bottom-stats dt {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.setup-bottom-stats dd {
|
||||
font-size: 7px;
|
||||
}
|
||||
|
||||
.dual-controls-resource {
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(110px, 0.8fr) minmax(150px, 1.2fr);
|
||||
@@ -1808,13 +1919,19 @@ h2 {
|
||||
}
|
||||
|
||||
.dungeon-run-main {
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dungeon-setup-rail {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dungeon-heading-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-panel {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -1873,6 +1990,12 @@ h2 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-menu-grid.has-cloud-sync {
|
||||
grid-auto-rows: minmax(72px, auto);
|
||||
grid-template-rows: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.roguelike-mode-grid {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
@@ -1886,11 +2009,31 @@ h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.roguelike-screen {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.roguelike-screen .screen-heading {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.roguelike-screen > .roguelike-variant-row,
|
||||
.roguelike-screen > .roguelike-option-panel,
|
||||
.roguelike-screen > .pvp-queue-panel {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.roguelike-option-panel {
|
||||
align-items: center;
|
||||
background: #111319;
|
||||
border: 2px solid #090a0d;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
outline: 2px solid #41404a;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.roguelike-timing-row {
|
||||
@@ -1912,6 +2055,14 @@ h2 {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.roguelike-screen .pvp-queue-panel {
|
||||
min-height: 104px;
|
||||
}
|
||||
|
||||
.roguelike-screen .text-button {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
align-items: center;
|
||||
background: var(--panel-light);
|
||||
@@ -1942,10 +2093,12 @@ h2 {
|
||||
}
|
||||
|
||||
.cloud-sync-card {
|
||||
align-items: start;
|
||||
cursor: default;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(180px, 220px);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@@ -1961,8 +2114,14 @@ h2 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cloud-sync-card .text-button {
|
||||
grid-column: 1 / -1;
|
||||
.cloud-sync-control-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.cloud-sync-control-stack .text-button {
|
||||
grid-column: auto;
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
@@ -1971,6 +2130,34 @@ h2 {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cloud-sync-times {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cloud-sync-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cloud-sync-actions .text-button {
|
||||
grid-column: auto;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.cloud-sync-card {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.cloud-sync-card .text-button,
|
||||
.cloud-sync-control-stack {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.cloud-sync-message {
|
||||
color: var(--gold);
|
||||
}
|
||||
@@ -2072,6 +2259,17 @@ h2 {
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.settings-back-button.game-selected,
|
||||
.settings-tabs button.game-selected,
|
||||
.dual-screen-actions button.game-selected,
|
||||
.controller-preferences button.game-selected,
|
||||
.binding-tabs button.game-selected,
|
||||
.binding-list button.game-selected,
|
||||
.settings-footer .text-button.game-selected,
|
||||
.binding-capture button.game-selected {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.settings-screen,
|
||||
.equipment-screen,
|
||||
.talent-screen,
|
||||
@@ -2696,7 +2894,7 @@ h2 {
|
||||
}
|
||||
|
||||
.dungeon-run-board {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(214px, 0.45fr);
|
||||
grid-template-columns: minmax(0, 1fr) 292px;
|
||||
}
|
||||
|
||||
.dungeon-run-screen .run-setup-panel,
|
||||
@@ -2761,20 +2959,20 @@ h2 {
|
||||
}
|
||||
|
||||
.dungeon-choice-grid {
|
||||
grid-auto-rows: minmax(52px, max-content);
|
||||
grid-auto-rows: minmax(72px, max-content);
|
||||
}
|
||||
|
||||
.dungeon-choice-grid .activity-card {
|
||||
gap: 4px 6px;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
min-height: 52px;
|
||||
padding: 5px;
|
||||
gap: 5px 7px;
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
min-height: 72px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.dungeon-choice-grid .activity-card .dungeon-art {
|
||||
font-size: 10px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
}
|
||||
|
||||
.dungeon-choice-grid .activity-card small,
|
||||
@@ -2785,7 +2983,7 @@ h2 {
|
||||
}
|
||||
|
||||
.tier-setup-panel .tier-grid button {
|
||||
min-height: 32px;
|
||||
min-height: 34px;
|
||||
padding: 4px 5px;
|
||||
}
|
||||
|
||||
@@ -2795,7 +2993,7 @@ h2 {
|
||||
|
||||
.part-setup-panel .primary-button {
|
||||
font-size: 7px;
|
||||
min-height: 32px;
|
||||
min-height: 36px;
|
||||
padding: 4px 5px;
|
||||
}
|
||||
|
||||
@@ -2830,11 +3028,46 @@ h2 {
|
||||
font-size: 12px;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
.dungeon-heading-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.roguelike-screen {
|
||||
gap: 7px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.roguelike-screen .screen-heading {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.roguelike-screen .roguelike-option-panel {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.roguelike-screen .pvp-queue-panel {
|
||||
min-height: 76px;
|
||||
}
|
||||
|
||||
.roguelike-screen .menu-card > span {
|
||||
flex-basis: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.roguelike-screen .menu-card small {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.roguelike-screen .text-button {
|
||||
min-height: 30px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) and (max-height: 620px) {
|
||||
.dungeon-run-board {
|
||||
grid-template-columns: minmax(0, 1fr) 214px;
|
||||
grid-template-columns: minmax(0, 1fr) 292px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -5165,6 +5398,15 @@ h2 {
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.customize-tabs button.game-selected,
|
||||
.class-picker > button.game-selected,
|
||||
.ability-slots button.game-selected,
|
||||
.ability-library-actions button.game-selected,
|
||||
.ability-library > button.game-selected,
|
||||
.save-row .primary-button.game-selected {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.customize-tab-back {
|
||||
display: none;
|
||||
}
|
||||
@@ -5260,6 +5502,24 @@ h2 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.ability-library-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ability-library-pager {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ability-library-pager > span {
|
||||
color: var(--muted);
|
||||
font-family: var(--pixel-font);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.loadout-heading > span,
|
||||
.save-row > span {
|
||||
color: var(--muted);
|
||||
@@ -6317,6 +6577,15 @@ h2 {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pvp-match-queue-panel {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pvp-match-queue-panel .secondary-result-button {
|
||||
margin: 0 auto;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.pvp-screen-tools {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -6591,10 +6860,77 @@ h2 {
|
||||
box-shadow: inset 0 0 0 2px #6e5727;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog .upgrade-choice-grid button.game-selected,
|
||||
.pvp-upgrade-dialog .secondary-result-button.game-selected,
|
||||
.pause-screen button.game-selected,
|
||||
.pvp-match-queue-panel button.game-selected {
|
||||
box-shadow: 0 0 0 3px rgba(245, 196, 83, 0.45), inset 0 0 0 2px var(--gold);
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog > .secondary-result-button {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-top: 10px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog button:focus {
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
@media (max-height: 620px) {
|
||||
.pvp-upgrade-dialog {
|
||||
max-height: calc(100dvh - 32px);
|
||||
overflow: hidden;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.pvp-upgrade-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pvp-upgrade-header h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pvp-upgrade-header > strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog .pvp-choice-columns {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pvp-choice-columns > div > strong {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pvp-choice-columns .upgrade-choice-grid {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.pvp-choice-columns .upgrade-choice-grid button {
|
||||
min-height: 54px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog .upgrade-choice-grid strong {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog .upgrade-choice-grid small {
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.pvp-upgrade-dialog .secondary-result-button {
|
||||
margin-left: auto;
|
||||
margin-top: 8px;
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.stadium-screen {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
@@ -6770,6 +7106,13 @@ h2 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.result-screen .pvp-upgrade-dialog > .secondary-result-button {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-top: 10px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.pause-screen button:focus-visible {
|
||||
outline: 3px solid #fff4a8 !important;
|
||||
box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65);
|
||||
@@ -7113,6 +7456,7 @@ h2 {
|
||||
}
|
||||
|
||||
.settings-screen {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@@ -7140,21 +7484,26 @@ h2 {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.settings-screen .settings-tab-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dual-screen-settings,
|
||||
.controller-preferences {
|
||||
gap: 10px;
|
||||
margin: 8px 0 0;
|
||||
padding: 10px;
|
||||
gap: 16px;
|
||||
margin: 10px 0 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dual-screen-settings.settings-tab-panel,
|
||||
.controller-preferences.settings-tab-panel {
|
||||
flex: 0 0 auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.dual-screen-settings {
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(214px, auto);
|
||||
align-content: start;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(238px, auto);
|
||||
}
|
||||
|
||||
.dual-screen-settings h2,
|
||||
@@ -7176,20 +7525,20 @@ h2 {
|
||||
.dual-screen-actions {
|
||||
align-content: start;
|
||||
align-self: start;
|
||||
gap: 7px;
|
||||
min-width: 214px;
|
||||
gap: 10px;
|
||||
min-width: 238px;
|
||||
}
|
||||
|
||||
.dual-screen-actions button,
|
||||
.controller-preferences button {
|
||||
min-height: 32px;
|
||||
padding: 5px 8px;
|
||||
min-height: 46px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.controller-preferences {
|
||||
align-items: stretch;
|
||||
align-content: start;
|
||||
grid-template-columns: minmax(0, 1fr) repeat(2, minmax(158px, auto));
|
||||
grid-template-columns: minmax(0, 1fr) repeat(2, minmax(176px, auto));
|
||||
}
|
||||
|
||||
.controller-preferences h3 {
|
||||
@@ -7201,9 +7550,9 @@ h2 {
|
||||
}
|
||||
|
||||
.controller-icon-options {
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(112px, 1fr) repeat(3, minmax(104px, auto));
|
||||
padding: 6px;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(130px, 1fr) repeat(3, minmax(120px, auto));
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.controller-icon-options > span {
|
||||
@@ -7213,6 +7562,7 @@ h2 {
|
||||
.controller-icon-options button {
|
||||
font-size: 6px;
|
||||
gap: 5px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.controller-style-preview .controller-face-icon {
|
||||
@@ -7248,6 +7598,7 @@ h2 {
|
||||
.binding-list {
|
||||
gap: 4px;
|
||||
padding-right: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.binding-list button {
|
||||
@@ -9397,6 +9748,25 @@ h2 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library-heading {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library-actions,
|
||||
.workshop-shell .ability-library-pager {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library-actions .text-button {
|
||||
font-size: 7px;
|
||||
min-height: 28px;
|
||||
padding: 4px 7px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library-pager > span {
|
||||
font-size: 7px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-slots {
|
||||
gap: 5px;
|
||||
margin-top: 5px;
|
||||
@@ -9708,6 +10078,15 @@ h2 {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.hunter-profile-screen .back-button.game-selected,
|
||||
.hunter-profile-screen .equipment-tab.game-selected,
|
||||
.hunter-stat-tile.game-selected,
|
||||
.collection-boss-button.game-selected,
|
||||
.collection-drop-row.game-selected {
|
||||
color: var(--ink);
|
||||
outline-color: #fff4a8;
|
||||
}
|
||||
|
||||
.hunter-profile-heading {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -9750,6 +10129,7 @@ h2 {
|
||||
|
||||
.collection-log-layout {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: 250px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
@@ -9767,6 +10147,8 @@ h2 {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.collection-boss-list {
|
||||
@@ -9828,6 +10210,7 @@ h2 {
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(229, 185, 95, 0.2);
|
||||
border-radius: 8px;
|
||||
@@ -9905,6 +10288,10 @@ h2 {
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.collection-drop-row.missing.game-selected {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.collection-drop-row.missing > span {
|
||||
background: rgba(238, 232, 210, 0.08);
|
||||
color: rgba(238, 232, 210, 0.54);
|
||||
@@ -10000,3 +10387,80 @@ h2 {
|
||||
max-height: 190px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) and (max-height: 620px) {
|
||||
.hunter-profile-screen {
|
||||
gap: 10px;
|
||||
margin-top: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.hunter-profile-heading {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.hunter-profile-tabs {
|
||||
gap: 6px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hunter-profile-screen .equipment-tab,
|
||||
.hunter-profile-screen .back-button {
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.hunter-stat-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hunter-stat-tile {
|
||||
min-height: 92px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.hunter-stat-tile strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.collection-log-layout {
|
||||
gap: 10px;
|
||||
grid-template-columns: 198px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.collection-boss-list {
|
||||
gap: 6px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.collection-boss-button {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
min-height: 46px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.collection-boss-button > span {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.collection-boss-detail {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.collection-drop-list {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.collection-drop-row {
|
||||
grid-template-rows: 20px minmax(24px, 1fr) auto auto;
|
||||
min-height: 72px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.collection-drop-row > span {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import {
|
||||
saveProfile,
|
||||
type CharacterProfile,
|
||||
type GameClass,
|
||||
} from '../profile'
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||
import { useGameAction, type InputAction } from '../input'
|
||||
import { EquipmentScreen } from './EquipmentScreen'
|
||||
import { TalentScreen } from './TalentScreen'
|
||||
|
||||
@@ -14,20 +15,121 @@ type Props = {
|
||||
onSaved: (profile: CharacterProfile) => void
|
||||
}
|
||||
|
||||
type CustomizeTab = 'equipment' | 'crafting' | 'talents' | 'class'
|
||||
|
||||
type CustomizeNavEntry =
|
||||
| { kind: 'back'; key: string; row: number; column: number }
|
||||
| { kind: 'tab'; key: string; row: number; column: number; tab: CustomizeTab }
|
||||
| { kind: 'class'; key: string; row: number; column: number; classId: number }
|
||||
| { kind: 'slot'; key: string; row: number; column: number; slotIndex: number }
|
||||
| { kind: 'clear'; key: string; row: number; column: number }
|
||||
| { kind: 'abilityPagePrev'; key: string; row: number; column: number }
|
||||
| { kind: 'abilityPageNext'; key: string; row: number; column: number }
|
||||
| { kind: 'ability'; key: string; row: number; column: number; abilityId: number }
|
||||
| { kind: 'save'; key: string; row: number; column: number }
|
||||
|
||||
const CUSTOMIZE_TABS: Array<{ key: CustomizeTab; label: string }> = [
|
||||
{ key: 'equipment', label: 'Equipment' },
|
||||
{ key: 'crafting', label: 'Crafting' },
|
||||
{ key: 'talents', label: 'Talents' },
|
||||
{ key: 'class', label: 'Class' },
|
||||
]
|
||||
|
||||
const CLASS_PICKER_COLUMN = 0
|
||||
const CLASS_CONTENT_COLUMN = 2
|
||||
const ABILITY_LIBRARY_COLUMNS = 5
|
||||
const ABILITY_LIBRARY_PAGE_SIZE = 10
|
||||
|
||||
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
|
||||
const [activeTab, setActiveTab] = useState<CustomizeTab>('class')
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const [classId, setClassId] = useState(profile.character.classId)
|
||||
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
||||
const [selectedSlot, setSelectedSlot] = useState(0)
|
||||
const [selectedNavKey, setSelectedNavKey] = useState('tab:class')
|
||||
const [abilityPage, setAbilityPage] = useState(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const scrollRef = useRef<number>(0)
|
||||
const navRefs = useRef<Record<string, HTMLElement | null>>({})
|
||||
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
|
||||
const abilityMap = useMemo(
|
||||
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
|
||||
[gameClass],
|
||||
)
|
||||
const abilityPageCount = Math.max(1, Math.ceil(gameClass.spells.length / ABILITY_LIBRARY_PAGE_SIZE))
|
||||
const currentAbilityPage = Math.min(abilityPage, abilityPageCount - 1)
|
||||
const visibleAbilities = useMemo(
|
||||
() => gameClass.spells.slice(
|
||||
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE,
|
||||
currentAbilityPage * ABILITY_LIBRARY_PAGE_SIZE + ABILITY_LIBRARY_PAGE_SIZE,
|
||||
),
|
||||
[currentAbilityPage, gameClass.spells],
|
||||
)
|
||||
const classNavActive = activeTab === 'class'
|
||||
const navEntries = useMemo<CustomizeNavEntry[]>(() => {
|
||||
const entries: CustomizeNavEntry[] = [
|
||||
{ kind: 'back', key: 'back', row: 0, column: 0 },
|
||||
...CUSTOMIZE_TABS.map((tab, index) => ({
|
||||
kind: 'tab' as const,
|
||||
key: `tab:${tab.key}`,
|
||||
row: 0,
|
||||
column: index + 1,
|
||||
tab: tab.key,
|
||||
})),
|
||||
]
|
||||
if (activeTab !== 'class') return entries
|
||||
|
||||
entries.push(
|
||||
...profile.classes.map((candidate, index) => ({
|
||||
kind: 'class' as const,
|
||||
key: `class:${candidate.id}`,
|
||||
row: index + 1,
|
||||
column: CLASS_PICKER_COLUMN,
|
||||
classId: candidate.id,
|
||||
})),
|
||||
...slots.map((_, index) => ({
|
||||
kind: 'slot' as const,
|
||||
key: `slot:${index}`,
|
||||
row: 1,
|
||||
column: CLASS_CONTENT_COLUMN + index,
|
||||
slotIndex: index,
|
||||
})),
|
||||
{ kind: 'clear', key: 'clear-slot', row: 2, column: CLASS_CONTENT_COLUMN + 4 },
|
||||
...(abilityPageCount > 1 && currentAbilityPage > 0
|
||||
? [{ kind: 'abilityPagePrev' as const, key: 'ability-page-prev', row: 2, column: CLASS_CONTENT_COLUMN + 2 }]
|
||||
: []),
|
||||
...(abilityPageCount > 1 && currentAbilityPage < abilityPageCount - 1
|
||||
? [{ kind: 'abilityPageNext' as const, key: 'ability-page-next', row: 2, column: CLASS_CONTENT_COLUMN + 3 }]
|
||||
: []),
|
||||
...visibleAbilities
|
||||
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
||||
.map((ability, index) => ({
|
||||
kind: 'ability' as const,
|
||||
key: `ability:${ability.id}`,
|
||||
row: Math.floor(index / ABILITY_LIBRARY_COLUMNS) + 3,
|
||||
column: CLASS_CONTENT_COLUMN + (index % ABILITY_LIBRARY_COLUMNS),
|
||||
abilityId: ability.id,
|
||||
})),
|
||||
)
|
||||
if (!saving) {
|
||||
entries.push({ kind: 'save', key: 'save', row: 99, column: CLASS_CONTENT_COLUMN + 4 })
|
||||
}
|
||||
return entries
|
||||
}, [abilityPageCount, activeTab, currentAbilityPage, profile.character.level, profile.classes, saving, slots, visibleAbilities])
|
||||
const activeEntry = navEntries.find((entry) => entry.key === selectedNavKey)
|
||||
?? navEntries.find((entry) => entry.key === `tab:${activeTab}`)
|
||||
?? navEntries[0]
|
||||
|
||||
function selected(entryKey: string) {
|
||||
return classNavActive && activeEntry?.key === entryKey
|
||||
}
|
||||
|
||||
function navRef(entryKey: string) {
|
||||
return (node: HTMLElement | null) => {
|
||||
navRefs.current[entryKey] = node
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
@@ -45,9 +147,15 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
setClassId(nextClass.id)
|
||||
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
||||
setSelectedSlot(0)
|
||||
setAbilityPage(0)
|
||||
setMessage('')
|
||||
}
|
||||
|
||||
function selectTab(tab: CustomizeTab) {
|
||||
setActiveTab(tab)
|
||||
setSelectedNavKey(`tab:${tab}`)
|
||||
}
|
||||
|
||||
function equipAbility(abilityId: number) {
|
||||
if (slots.includes(abilityId)) {
|
||||
setMessage('That ability is already equipped.')
|
||||
@@ -65,6 +173,66 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
function moveSelection(action: InputAction) {
|
||||
if (!action.startsWith('navigate') || navEntries.length === 0 || !activeEntry) return
|
||||
const activeIndex = Math.max(0, navEntries.findIndex((entry) => entry.key === activeEntry.key))
|
||||
const candidates = navEntries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(({ index }) => index !== activeIndex)
|
||||
.filter(({ entry }) => {
|
||||
if (action === 'navigateLeft') return entry.column < activeEntry.column
|
||||
if (action === 'navigateRight') return entry.column > activeEntry.column
|
||||
if (action === 'navigateUp') return entry.row < activeEntry.row
|
||||
return entry.row > activeEntry.row
|
||||
})
|
||||
if (candidates.length === 0) return
|
||||
candidates.sort((a, b) => {
|
||||
const aPrimary = Math.abs(a.entry.row - activeEntry.row) + Math.abs(a.entry.column - activeEntry.column)
|
||||
const bPrimary = Math.abs(b.entry.row - activeEntry.row) + Math.abs(b.entry.column - activeEntry.column)
|
||||
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||
? Math.abs(a.entry.row - activeEntry.row)
|
||||
: Math.abs(a.entry.column - activeEntry.column)
|
||||
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||
? Math.abs(b.entry.row - activeEntry.row)
|
||||
: Math.abs(b.entry.column - activeEntry.column)
|
||||
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
||||
})
|
||||
setSelectedNavKey(candidates[0]?.entry.key ?? activeEntry.key)
|
||||
}
|
||||
|
||||
function openEntry(entry: CustomizeNavEntry | undefined) {
|
||||
if (!entry) return
|
||||
if (entry.kind === 'back') onBack()
|
||||
else if (entry.kind === 'tab') selectTab(entry.tab)
|
||||
else if (entry.kind === 'class') {
|
||||
const nextClass = profile.classes.find((candidate) => candidate.id === entry.classId)
|
||||
if (nextClass) chooseClass(nextClass)
|
||||
} else if (entry.kind === 'slot') setSelectedSlot(entry.slotIndex)
|
||||
else if (entry.kind === 'clear') clearSlot()
|
||||
else if (entry.kind === 'abilityPagePrev') setAbilityPage((page) => Math.max(0, page - 1))
|
||||
else if (entry.kind === 'abilityPageNext') setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))
|
||||
else if (entry.kind === 'ability') equipAbility(entry.abilityId)
|
||||
else if (entry.kind === 'save') void persistChanges()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!classNavActive) return
|
||||
navRefs.current[activeEntry?.key ?? '']?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}, [activeEntry, classNavActive])
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (device !== 'controller' || !classNavActive) return
|
||||
if (action === 'back') {
|
||||
onBack()
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
openEntry(activeEntry)
|
||||
return
|
||||
}
|
||||
moveSelection(action)
|
||||
})
|
||||
|
||||
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||
if (activeTab !== 'class') return null
|
||||
return {
|
||||
@@ -104,28 +272,46 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="content-screen customize-screen">
|
||||
<section className="content-screen customize-screen" data-game-nav-active={classNavActive ? 'true' : undefined}>
|
||||
<div className="screen-heading customize-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Character Workshop</p>
|
||||
<h1>Customize Character</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
<button
|
||||
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
|
||||
data-controller-nav={classNavActive ? 'skip' : undefined}
|
||||
data-game-selected={selected('back') ? 'true' : undefined}
|
||||
onClick={onBack}
|
||||
onPointerDown={() => setSelectedNavKey('back')}
|
||||
ref={navRef('back')}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
||||
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
|
||||
{([
|
||||
{ key: 'equipment', label: 'Equipment' },
|
||||
{ key: 'crafting', label: 'Crafting' },
|
||||
{ key: 'talents', label: 'Talents' },
|
||||
{ key: 'class', label: 'Class' },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
className={`back-button customize-tab-back ${selected('back') ? 'game-selected' : ''}`}
|
||||
data-controller-nav={classNavActive ? 'skip' : undefined}
|
||||
data-game-selected={selected('back') ? 'true' : undefined}
|
||||
onClick={onBack}
|
||||
onPointerDown={() => setSelectedNavKey('back')}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{CUSTOMIZE_TABS.map((tab) => (
|
||||
<button
|
||||
aria-selected={activeTab === tab.key}
|
||||
className={activeTab === tab.key ? 'active' : ''}
|
||||
className={`${activeTab === tab.key ? 'active' : ''} ${selected(`tab:${tab.key}`) ? 'game-selected' : ''}`}
|
||||
data-controller-nav={classNavActive ? 'skip' : undefined}
|
||||
data-game-selected={selected(`tab:${tab.key}`) ? 'true' : undefined}
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
onClick={() => selectTab(tab.key)}
|
||||
onPointerDown={() => setSelectedNavKey(`tab:${tab.key}`)}
|
||||
ref={navRef(`tab:${tab.key}`)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
@@ -168,10 +354,14 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
<p className="eyebrow">Healing Class</p>
|
||||
{profile.classes.map((candidate) => (
|
||||
<button
|
||||
className={candidate.id === classId ? 'active' : ''}
|
||||
className={`${candidate.id === classId ? 'active' : ''} ${selected(`class:${candidate.id}`) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected(`class:${candidate.id}`) ? 'true' : undefined}
|
||||
key={candidate.id}
|
||||
onClick={() => chooseClass(candidate)}
|
||||
style={{ '--class-color': candidate.themeColor } as React.CSSProperties}
|
||||
onPointerDown={() => setSelectedNavKey(`class:${candidate.id}`)}
|
||||
ref={navRef(`class:${candidate.id}`)}
|
||||
style={{ '--class-color': candidate.themeColor } as CSSProperties}
|
||||
type="button"
|
||||
>
|
||||
<span>{candidate.name[0]}</span>
|
||||
@@ -211,9 +401,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
const ability = abilityId ? abilityMap.get(abilityId) : undefined
|
||||
return (
|
||||
<button
|
||||
className={selectedSlot === index ? 'selected' : ''}
|
||||
className={`${selectedSlot === index ? 'selected' : ''} ${selected(`slot:${index}`) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected(`slot:${index}`) ? 'true' : undefined}
|
||||
key={index}
|
||||
onClick={() => setSelectedSlot(index)}
|
||||
onPointerDown={() => setSelectedNavKey(`slot:${index}`)}
|
||||
ref={navRef(`slot:${index}`)}
|
||||
type="button"
|
||||
>
|
||||
<kbd>{index + 1}</kbd>
|
||||
@@ -229,19 +423,64 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
<p className="eyebrow">Class Abilities</p>
|
||||
<h2>Ability Library</h2>
|
||||
</div>
|
||||
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button>
|
||||
<div className="ability-library-actions">
|
||||
{abilityPageCount > 1 && (
|
||||
<div className="ability-library-pager">
|
||||
<button
|
||||
className={`text-button ${selected('ability-page-prev') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('ability-page-prev') ? 'true' : undefined}
|
||||
disabled={currentAbilityPage === 0}
|
||||
onClick={() => setAbilityPage((page) => Math.max(0, page - 1))}
|
||||
onPointerDown={() => setSelectedNavKey('ability-page-prev')}
|
||||
ref={navRef('ability-page-prev')}
|
||||
type="button"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span>{currentAbilityPage + 1}/{abilityPageCount}</span>
|
||||
<button
|
||||
className={`text-button ${selected('ability-page-next') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('ability-page-next') ? 'true' : undefined}
|
||||
disabled={currentAbilityPage >= abilityPageCount - 1}
|
||||
onClick={() => setAbilityPage((page) => Math.min(abilityPageCount - 1, page + 1))}
|
||||
onPointerDown={() => setSelectedNavKey('ability-page-next')}
|
||||
ref={navRef('ability-page-next')}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={`text-button ${selected('clear-slot') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('clear-slot') ? 'true' : undefined}
|
||||
onClick={clearSlot}
|
||||
onPointerDown={() => setSelectedNavKey('clear-slot')}
|
||||
ref={navRef('clear-slot')}
|
||||
type="button"
|
||||
>
|
||||
Clear Selected Slot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ability-library">
|
||||
{gameClass.spells.map((ability) => {
|
||||
{visibleAbilities.map((ability) => {
|
||||
const locked = ability.unlockLevel > profile.character.level
|
||||
const equipped = slots.includes(ability.id)
|
||||
return (
|
||||
<button
|
||||
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`}
|
||||
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''} ${selected(`ability:${ability.id}`) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected(`ability:${ability.id}`) ? 'true' : undefined}
|
||||
disabled={locked}
|
||||
key={ability.id}
|
||||
onClick={() => equipAbility(ability.id)}
|
||||
onPointerDown={() => setSelectedNavKey(`ability:${ability.id}`)}
|
||||
ref={navRef(`ability:${ability.id}`)}
|
||||
type="button"
|
||||
>
|
||||
<span>{locked ? 'L' : ability.glyph}</span>
|
||||
@@ -258,9 +497,13 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
<div className="save-row">
|
||||
<span>{message}</span>
|
||||
<button
|
||||
className="primary-button"
|
||||
className={`primary-button ${selected('save') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('save') ? 'true' : undefined}
|
||||
disabled={saving}
|
||||
onClick={persistChanges}
|
||||
onPointerDown={() => setSelectedNavKey('save')}
|
||||
ref={navRef('save')}
|
||||
type="button"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Character'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||
import { useGameAction, type InputAction } from '../input'
|
||||
|
||||
type HunterProfileScreenProps = {
|
||||
profile: CharacterProfile
|
||||
@@ -24,6 +25,17 @@ type CollectionItem = {
|
||||
source: string
|
||||
}
|
||||
|
||||
type HunterProfileTab = 'stats' | 'collection'
|
||||
|
||||
type HunterProfileNavEntry =
|
||||
| { kind: 'back'; key: string; row: number; column: number }
|
||||
| { kind: 'tab'; key: string; row: number; column: number; tab: HunterProfileTab }
|
||||
| { kind: 'stat'; key: string; row: number; column: number; index: number }
|
||||
| { kind: 'boss'; key: string; row: number; column: number; bossId: number }
|
||||
| { kind: 'item'; key: string; row: number; column: number; itemKey: string }
|
||||
|
||||
const HUNTER_PROFILE_DROP_COLUMNS = 6
|
||||
|
||||
function bossInitials(name: string) {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
@@ -45,7 +57,9 @@ function purplePetKey(encounterId: number) {
|
||||
}
|
||||
|
||||
export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) {
|
||||
const [activeTab, setActiveTab] = useState<'stats' | 'collection'>('stats')
|
||||
const [activeTab, setActiveTab] = useState<HunterProfileTab>('stats')
|
||||
const [selectedIndex, setSelectedIndex] = useState(1)
|
||||
const screenRef = useRef<HTMLElement | null>(null)
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const bosses = useMemo<BossEntry[]>(() => profile.dungeons.flatMap((dungeon) =>
|
||||
dungeon.encounters
|
||||
@@ -60,8 +74,8 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
|
||||
const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0)
|
||||
const [focusedItemKey, setFocusedItemKey] = useState<string | null>(null)
|
||||
const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0]
|
||||
const bossKills = profile.hunterStats?.bossKills ?? {}
|
||||
const bossPets = profile.hunterStats?.bossPets ?? {}
|
||||
const bossKills = useMemo(() => profile.hunterStats?.bossKills ?? {}, [profile.hunterStats?.bossKills])
|
||||
const bossPets = useMemo(() => profile.hunterStats?.bossPets ?? {}, [profile.hunterStats?.bossPets])
|
||||
const inventoryQuantities = useMemo(
|
||||
() => new Map(profile.inventory.map((item) => [item.id, item.quantity])),
|
||||
[profile.inventory],
|
||||
@@ -117,7 +131,71 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
|
||||
},
|
||||
]
|
||||
}, [bossKills, bossPets, inventoryQuantities, selectedBoss])
|
||||
const statTiles = useMemo(() => [
|
||||
{
|
||||
key: 'stat:total-kills',
|
||||
label: 'Total Boss Kills',
|
||||
value: totalBossKills,
|
||||
},
|
||||
{
|
||||
key: 'stat:most-killed',
|
||||
label: 'Most Killed Boss',
|
||||
value: totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None',
|
||||
detail: totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills',
|
||||
},
|
||||
{
|
||||
key: 'stat:pvp-matches',
|
||||
label: 'PvP Matches',
|
||||
value: matchesPlayed,
|
||||
},
|
||||
{
|
||||
key: 'stat:pvp-wins',
|
||||
label: 'PvP Wins',
|
||||
value: matchesWon,
|
||||
},
|
||||
{
|
||||
key: 'stat:pvp-win-rate',
|
||||
label: 'PvP Win Rate',
|
||||
value: `${winRate}%`,
|
||||
},
|
||||
], [bossKills, matchesPlayed, matchesWon, mostKilledBoss, totalBossKills, winRate])
|
||||
const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null
|
||||
const navEntries = useMemo<HunterProfileNavEntry[]>(() => {
|
||||
const entries: HunterProfileNavEntry[] = [
|
||||
{ kind: 'back', key: 'back', row: 0, column: 0 },
|
||||
{ kind: 'tab', key: 'tab:stats', row: 0, column: 1, tab: 'stats' },
|
||||
{ kind: 'tab', key: 'tab:collection', row: 0, column: 2, tab: 'collection' },
|
||||
]
|
||||
if (activeTab === 'stats') {
|
||||
entries.push(...statTiles.map((tile, index) => ({
|
||||
kind: 'stat' as const,
|
||||
key: tile.key,
|
||||
row: 1,
|
||||
column: index,
|
||||
index,
|
||||
})))
|
||||
} else {
|
||||
entries.push(
|
||||
...bosses.map((boss, index) => ({
|
||||
kind: 'boss' as const,
|
||||
key: `boss:${boss.encounter.id}`,
|
||||
row: index + 1,
|
||||
column: 0,
|
||||
bossId: boss.encounter.id,
|
||||
})),
|
||||
...collectionItems.map((item, index) => ({
|
||||
kind: 'item' as const,
|
||||
key: `item:${item.key}`,
|
||||
row: Math.floor(index / HUNTER_PROFILE_DROP_COLUMNS) + 1,
|
||||
column: (index % HUNTER_PROFILE_DROP_COLUMNS) + 1,
|
||||
itemKey: item.key,
|
||||
})),
|
||||
)
|
||||
}
|
||||
return entries
|
||||
}, [activeTab, bosses, collectionItems, statTiles])
|
||||
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
|
||||
const activeEntry = navEntries[activeIndex]
|
||||
const itemDetailState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||
if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null
|
||||
return {
|
||||
@@ -143,13 +221,94 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
|
||||
}, [activeTab, bossKills, focusedItem, selectedBoss])
|
||||
useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled)
|
||||
|
||||
function selected(entryKey: string) {
|
||||
return activeEntry?.key === entryKey
|
||||
}
|
||||
|
||||
function selectKey(entryKey: string) {
|
||||
const index = navEntries.findIndex((entry) => entry.key === entryKey)
|
||||
if (index >= 0) setSelectedIndex(index)
|
||||
}
|
||||
|
||||
function selectTab(tab: HunterProfileTab) {
|
||||
setActiveTab(tab)
|
||||
setSelectedIndex(tab === 'stats' ? 1 : 2)
|
||||
}
|
||||
|
||||
function selectBoss(bossId: number) {
|
||||
setSelectedBossId(bossId)
|
||||
setFocusedItemKey(null)
|
||||
}
|
||||
|
||||
function moveSelection(action: InputAction) {
|
||||
if (!action.startsWith('navigate') || navEntries.length === 0) return
|
||||
setSelectedIndex((current) => {
|
||||
const bounded = Math.min(current, navEntries.length - 1)
|
||||
const active = navEntries[bounded]
|
||||
if (!active) return 0
|
||||
const candidates = navEntries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(({ index }) => index !== bounded)
|
||||
.filter(({ entry }) => {
|
||||
if (action === 'navigateLeft') return entry.column < active.column
|
||||
if (action === 'navigateRight') return entry.column > active.column
|
||||
if (action === 'navigateUp') return entry.row < active.row
|
||||
return entry.row > active.row
|
||||
})
|
||||
if (candidates.length === 0) return bounded
|
||||
candidates.sort((a, b) => {
|
||||
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
||||
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
||||
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||
? Math.abs(a.entry.row - active.row)
|
||||
: Math.abs(a.entry.column - active.column)
|
||||
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||
? Math.abs(b.entry.row - active.row)
|
||||
: Math.abs(b.entry.column - active.column)
|
||||
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
||||
})
|
||||
return candidates[0]?.index ?? bounded
|
||||
})
|
||||
}
|
||||
|
||||
function openEntry(entry: HunterProfileNavEntry | undefined) {
|
||||
if (!entry) return
|
||||
if (entry.kind === 'back') onBack()
|
||||
else if (entry.kind === 'tab') selectTab(entry.tab)
|
||||
else if (entry.kind === 'boss') selectBoss(entry.bossId)
|
||||
else if (entry.kind === 'item') setFocusedItemKey(entry.itemKey)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeEntry) return
|
||||
screenRef.current
|
||||
?.querySelector<HTMLElement>('[data-game-selected="true"]')
|
||||
?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}, [activeEntry])
|
||||
|
||||
useGameAction((action, inputDevice) => {
|
||||
if (inputDevice !== 'controller') return
|
||||
if (action === 'back') {
|
||||
onBack()
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
openEntry(activeEntry)
|
||||
return
|
||||
}
|
||||
moveSelection(action)
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="content-screen hunter-profile-screen">
|
||||
<section className="content-screen hunter-profile-screen" data-game-nav-active="true" ref={screenRef}>
|
||||
<div className="screen-heading hunter-profile-heading">
|
||||
<div className="equipment-tabs hunter-profile-tabs" role="tablist" aria-label="Hunter profile tabs">
|
||||
<button
|
||||
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
className={`equipment-tab ${activeTab === 'stats' ? 'active' : ''} ${selected('tab:stats') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('tab:stats') ? 'true' : undefined}
|
||||
onClick={() => selectTab('stats')}
|
||||
onPointerDown={() => selectKey('tab:stats')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'stats'}
|
||||
@@ -157,8 +316,11 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
|
||||
Stats
|
||||
</button>
|
||||
<button
|
||||
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('collection')}
|
||||
className={`equipment-tab ${activeTab === 'collection' ? 'active' : ''} ${selected('tab:collection') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('tab:collection') ? 'true' : undefined}
|
||||
onClick={() => selectTab('collection')}
|
||||
onPointerDown={() => selectKey('tab:collection')}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'collection'}
|
||||
@@ -166,32 +328,32 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
|
||||
Collection Log
|
||||
</button>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
<button
|
||||
className={`back-button ${selected('back') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('back') ? 'true' : undefined}
|
||||
onClick={onBack}
|
||||
onPointerDown={() => selectKey('back')}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'stats' && (
|
||||
<div className="hunter-stat-grid">
|
||||
<article className="hunter-stat-tile">
|
||||
<span>Total Boss Kills</span>
|
||||
<strong>{totalBossKills}</strong>
|
||||
</article>
|
||||
<article className="hunter-stat-tile">
|
||||
<span>Most Killed Boss</span>
|
||||
<strong>{totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None'}</strong>
|
||||
<small>{totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills'}</small>
|
||||
</article>
|
||||
<article className="hunter-stat-tile">
|
||||
<span>PvP Matches</span>
|
||||
<strong>{matchesPlayed}</strong>
|
||||
</article>
|
||||
<article className="hunter-stat-tile">
|
||||
<span>PvP Wins</span>
|
||||
<strong>{matchesWon}</strong>
|
||||
</article>
|
||||
<article className="hunter-stat-tile">
|
||||
<span>PvP Win Rate</span>
|
||||
<strong>{winRate}%</strong>
|
||||
{statTiles.map((tile) => (
|
||||
<article
|
||||
className={`hunter-stat-tile ${selected(tile.key) ? 'game-selected' : ''}`}
|
||||
data-game-selected={selected(tile.key) ? 'true' : undefined}
|
||||
key={tile.key}
|
||||
onPointerDown={() => selectKey(tile.key)}
|
||||
>
|
||||
<span>{tile.label}</span>
|
||||
<strong>{tile.value}</strong>
|
||||
{tile.detail && <small>{tile.detail}</small>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -201,9 +363,12 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
|
||||
{bosses.map((boss) => {
|
||||
return (
|
||||
<button
|
||||
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''}`}
|
||||
className={`collection-boss-button ${selectedBoss?.encounter.id === boss.encounter.id ? 'selected' : ''} ${selected(`boss:${boss.encounter.id}`) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected(`boss:${boss.encounter.id}`) ? 'true' : undefined}
|
||||
key={boss.encounter.id}
|
||||
onClick={() => setSelectedBossId(boss.encounter.id)}
|
||||
onClick={() => selectBoss(boss.encounter.id)}
|
||||
onPointerDown={() => selectKey(`boss:${boss.encounter.id}`)}
|
||||
type="button"
|
||||
>
|
||||
<span>{bossInitials(boss.encounter.enemyName)}</span>
|
||||
@@ -218,10 +383,13 @@ export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProp
|
||||
<div className="collection-drop-list">
|
||||
{collectionItems.map((item) => (
|
||||
<button
|
||||
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'}`}
|
||||
className={`collection-drop-row collection-rarity-${item.rarity} ${item.quantity <= 0 ? 'missing' : 'owned'} ${selected(`item:${item.key}`) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected(`item:${item.key}`) ? 'true' : undefined}
|
||||
key={item.key}
|
||||
onClick={() => setFocusedItemKey(item.key)}
|
||||
onFocus={() => setFocusedItemKey(item.key)}
|
||||
onPointerDown={() => selectKey(`item:${item.key}`)}
|
||||
type="button"
|
||||
>
|
||||
<span>{item.glyph}</span>
|
||||
|
||||
@@ -152,6 +152,14 @@ type LivePvpMatch = {
|
||||
opponentClassName: string
|
||||
}
|
||||
|
||||
type PvpOverlayNavEntry =
|
||||
| { kind: 'queueBack'; row: number; column: number }
|
||||
| { kind: 'pauseResume'; row: number; column: number }
|
||||
| { kind: 'pauseLeave'; row: number; column: number }
|
||||
| { kind: 'upgradeBuff'; index: number; row: number; column: number }
|
||||
| { kind: 'upgradeDebuff'; index: number; row: number; column: number }
|
||||
| { kind: 'upgradeContinue'; row: number; column: number; disabled?: boolean }
|
||||
|
||||
const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
|
||||
id: 'revive-party-members',
|
||||
name: 'Revive Party Members',
|
||||
@@ -356,6 +364,7 @@ export function PvPRoguelikeScreen({
|
||||
const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
|
||||
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
|
||||
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
|
||||
const [overlaySelectedIndex, setOverlaySelectedIndex] = useState(0)
|
||||
const [encountersCleared, setEncountersCleared] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||
@@ -695,6 +704,7 @@ export function PvPRoguelikeScreen({
|
||||
setStartStage(setup.startStage)
|
||||
setStage(setup.startStage)
|
||||
setStatus('queueing')
|
||||
setOverlaySelectedIndex(0)
|
||||
setPlayerSide(setup.playerSide)
|
||||
setCpuSide(setup.opponentSide)
|
||||
setSelectedTargetId(partyTemplate[0].id)
|
||||
@@ -1025,6 +1035,7 @@ export function PvPRoguelikeScreen({
|
||||
setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
|
||||
setSelectedBuff(null)
|
||||
setSelectedDebuff(null)
|
||||
setOverlaySelectedIndex(0)
|
||||
setStatus('upgrade-choice')
|
||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer])
|
||||
|
||||
@@ -1312,13 +1323,140 @@ export function PvPRoguelikeScreen({
|
||||
addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
||||
}, [addLog, beginRoundCountdown, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||
|
||||
function pvpOverlayEntries(): PvpOverlayNavEntry[] {
|
||||
if (status === 'queueing') return [{ kind: 'queueBack', row: 0, column: 0 }]
|
||||
if (paused) {
|
||||
return [
|
||||
{ kind: 'pauseResume', row: 0, column: 0 },
|
||||
{ kind: 'pauseLeave', row: 1, column: 0 },
|
||||
]
|
||||
}
|
||||
if (status !== 'upgrade-choice') return []
|
||||
const entries: PvpOverlayNavEntry[] = [
|
||||
...playerBuffChoices.map((_, index) => ({
|
||||
kind: 'upgradeBuff' as const,
|
||||
index,
|
||||
row: index,
|
||||
column: 0,
|
||||
})),
|
||||
...playerDebuffChoices.map((_, index) => ({
|
||||
kind: 'upgradeDebuff' as const,
|
||||
index,
|
||||
row: index,
|
||||
column: 1,
|
||||
})),
|
||||
]
|
||||
entries.push({
|
||||
kind: 'upgradeContinue',
|
||||
row: Math.max(playerBuffChoices.length, playerDebuffChoices.length),
|
||||
column: 1,
|
||||
disabled: !selectedBuff || !selectedDebuff || liveUpgradePending,
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
function pvpOverlayEntryDisabled(entry: PvpOverlayNavEntry) {
|
||||
return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
|
||||
}
|
||||
|
||||
function activePvpOverlayEntry(entries = pvpOverlayEntries()) {
|
||||
const bounded = Math.min(overlaySelectedIndex, entries.length - 1)
|
||||
const active = entries[bounded]
|
||||
if (active && !pvpOverlayEntryDisabled(active)) return active
|
||||
return entries.find((entry) => !pvpOverlayEntryDisabled(entry))
|
||||
}
|
||||
|
||||
function pvpOverlayEntrySelected(kind: PvpOverlayNavEntry['kind'], index?: number) {
|
||||
const active = activePvpOverlayEntry()
|
||||
if (!active || active.kind !== kind) return false
|
||||
if ('index' in active || index !== undefined) return 'index' in active && active.index === index
|
||||
return true
|
||||
}
|
||||
|
||||
function setPvpOverlayCursor(kind: PvpOverlayNavEntry['kind'], index?: number) {
|
||||
const entries = pvpOverlayEntries()
|
||||
const nextIndex = entries.findIndex((entry) => {
|
||||
if (entry.kind !== kind) return false
|
||||
if ('index' in entry || index !== undefined) return 'index' in entry && entry.index === index
|
||||
return true
|
||||
})
|
||||
if (nextIndex >= 0) setOverlaySelectedIndex(nextIndex)
|
||||
}
|
||||
|
||||
function movePvpOverlaySelection(action: string) {
|
||||
const entries = pvpOverlayEntries()
|
||||
const enabledEntries = entries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(({ entry }) => !pvpOverlayEntryDisabled(entry))
|
||||
if (enabledEntries.length === 0) return
|
||||
setOverlaySelectedIndex((current) => {
|
||||
const candidate = entries[Math.min(current, entries.length - 1)]
|
||||
const active = candidate && !pvpOverlayEntryDisabled(candidate)
|
||||
? candidate
|
||||
: enabledEntries[0]?.entry
|
||||
if (!active) return current
|
||||
const candidates = enabledEntries.filter(({ entry }) => {
|
||||
if (entry === active) return false
|
||||
if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
|
||||
if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column
|
||||
if (action === 'navigateUp') return entry.row < active.row
|
||||
return entry.row > active.row
|
||||
})
|
||||
if (candidates.length === 0) return entries.findIndex((entry) => entry === active)
|
||||
candidates.sort((a, b) => {
|
||||
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
||||
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
||||
return aPrimary - bPrimary || a.index - b.index
|
||||
})
|
||||
return candidates[0]?.index ?? current
|
||||
})
|
||||
}
|
||||
|
||||
function openPvpOverlayEntry(entry: PvpOverlayNavEntry | undefined) {
|
||||
if (!entry || pvpOverlayEntryDisabled(entry)) return
|
||||
if (entry.kind === 'queueBack') onExit()
|
||||
else if (entry.kind === 'pauseResume') setPaused(false)
|
||||
else if (entry.kind === 'pauseLeave') onExit()
|
||||
else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
|
||||
else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
|
||||
else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
|
||||
}
|
||||
|
||||
useGameAction((action) => {
|
||||
if (status === 'queueing' || status === 'round-countdown') {
|
||||
if (action === 'back' || action === 'pause') onExit()
|
||||
if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
|
||||
return
|
||||
}
|
||||
if (paused) {
|
||||
if (action === 'back' || action === 'pause') {
|
||||
setPaused(false)
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
openPvpOverlayEntry(activePvpOverlayEntry())
|
||||
return
|
||||
}
|
||||
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
|
||||
return
|
||||
}
|
||||
if (status === 'upgrade-choice') {
|
||||
if (action === 'confirm') {
|
||||
openPvpOverlayEntry(activePvpOverlayEntry())
|
||||
return
|
||||
}
|
||||
if (action.startsWith('navigate')) movePvpOverlaySelection(action)
|
||||
return
|
||||
}
|
||||
if (action === 'toggleSpeed') {
|
||||
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||
return
|
||||
}
|
||||
if (action === 'pause' || action === 'back') {
|
||||
if (status === 'playing') setPaused((value) => !value)
|
||||
if (status === 'playing') {
|
||||
setOverlaySelectedIndex(0)
|
||||
setPaused((value) => !value)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (paused || status !== 'playing') return
|
||||
@@ -1434,9 +1572,18 @@ export function PvPRoguelikeScreen({
|
||||
>
|
||||
<section className="content-screen pvp-match-screen">
|
||||
{status === 'queueing' && (
|
||||
<div className="placeholder-panel">
|
||||
<div className="placeholder-panel pvp-match-queue-panel" data-game-nav-active="true">
|
||||
<div className="placeholder-runes">P V P</div>
|
||||
<p>{queueMessage}</p>
|
||||
<button
|
||||
className={`secondary-result-button ${pvpOverlayEntrySelected('queueBack') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
onClick={onExit}
|
||||
onPointerDown={() => setPvpOverlayCursor('queueBack')}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1552,7 +1699,7 @@ export function PvPRoguelikeScreen({
|
||||
)}
|
||||
|
||||
{status === 'upgrade-choice' && (
|
||||
<div className="result-screen">
|
||||
<div className="result-screen" data-game-nav-active="true">
|
||||
<div className="pvp-upgrade-dialog">
|
||||
<div className="pvp-upgrade-header">
|
||||
<div>
|
||||
@@ -1567,9 +1714,11 @@ export function PvPRoguelikeScreen({
|
||||
<div className="upgrade-choice-grid">
|
||||
{playerBuffChoices.map((choice) => (
|
||||
<button
|
||||
className={selectedBuff?.id === choice.id ? 'selected-upgrade' : ''}
|
||||
className={`${selectedBuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeBuff', playerBuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
key={choice.id}
|
||||
onClick={() => setSelectedBuff(choice)}
|
||||
onPointerDown={() => setPvpOverlayCursor('upgradeBuff', playerBuffChoices.indexOf(choice))}
|
||||
type="button"
|
||||
>
|
||||
<strong>{choice.name}</strong>
|
||||
@@ -1583,9 +1732,11 @@ export function PvPRoguelikeScreen({
|
||||
<div className="upgrade-choice-grid">
|
||||
{playerDebuffChoices.map((choice) => (
|
||||
<button
|
||||
className={selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''}
|
||||
className={`${selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeDebuff', playerDebuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
key={choice.id}
|
||||
onClick={() => setSelectedDebuff(choice)}
|
||||
onPointerDown={() => setPvpOverlayCursor('upgradeDebuff', playerDebuffChoices.indexOf(choice))}
|
||||
type="button"
|
||||
>
|
||||
<strong>{choice.name}</strong>
|
||||
@@ -1596,7 +1747,14 @@ export function PvPRoguelikeScreen({
|
||||
</div>
|
||||
</div>
|
||||
{liveUpgradePending && <p>Waiting for opponent choice...</p>}
|
||||
<button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff || liveUpgradePending} onClick={() => confirmUpgradeChoices()} type="button">
|
||||
<button
|
||||
className={`secondary-result-button ${pvpOverlayEntrySelected('upgradeContinue') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
disabled={!selectedBuff || !selectedDebuff || liveUpgradePending}
|
||||
onClick={() => confirmUpgradeChoices()}
|
||||
onPointerDown={() => setPvpOverlayCursor('upgradeContinue')}
|
||||
type="button"
|
||||
>
|
||||
{liveUpgradePending ? 'Waiting' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1604,12 +1762,28 @@ export function PvPRoguelikeScreen({
|
||||
)}
|
||||
|
||||
{paused && (
|
||||
<div className="pause-screen">
|
||||
<div className="pause-screen" data-game-nav-active="true">
|
||||
<div>
|
||||
<p className="eyebrow">Paused</p>
|
||||
<h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
|
||||
<button onClick={() => setPaused(false)} type="button">Resume</button>
|
||||
<button className="secondary-result-button" onClick={onExit} type="button">Leave</button>
|
||||
<button
|
||||
className={pvpOverlayEntrySelected('pauseResume') ? 'game-selected' : ''}
|
||||
data-controller-nav="skip"
|
||||
onClick={() => setPaused(false)}
|
||||
onPointerDown={() => setPvpOverlayCursor('pauseResume')}
|
||||
type="button"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
onClick={onExit}
|
||||
onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
|
||||
type="button"
|
||||
>
|
||||
Leave
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -944,24 +944,23 @@ export function PvpStadiumScreen({
|
||||
|
||||
const finishShop = useCallback(() => {
|
||||
if (shopReady || status !== 'shop') return
|
||||
const liveMatch = liveMatchRef.current
|
||||
if (!liveMatch) {
|
||||
startNextRound()
|
||||
return
|
||||
}
|
||||
setShopReady(true)
|
||||
submittedShopRef.current = true
|
||||
setPlayerSide((current) => {
|
||||
const next = { ...current, shopReady: true }
|
||||
playerRef.current = next
|
||||
return next
|
||||
})
|
||||
if (liveMatchRef.current) {
|
||||
submitPvpUpgradeChoice(liveMatchRef.current.id, {
|
||||
const readyPlayer = { ...playerRef.current, shopReady: true }
|
||||
playerRef.current = readyPlayer
|
||||
setPlayerSide(readyPlayer)
|
||||
submitPvpUpgradeChoice(liveMatch.id, {
|
||||
encounterIndex: roundIndex,
|
||||
buffId: 'stadium-shop',
|
||||
debuffId: '',
|
||||
purchases: playerRef.current.buffs,
|
||||
shopReady: true,
|
||||
}).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
startNextRound()
|
||||
}, [roundIndex, shopReady, startNextRound, status])
|
||||
|
||||
const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync<StadiumSideState, LivePvpMatch>({
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ACTION_LABELS,
|
||||
INPUT_ACTIONS,
|
||||
useInput,
|
||||
useGameAction,
|
||||
type InputAction,
|
||||
type InputDevice,
|
||||
} from '../input'
|
||||
import {
|
||||
ControllerBindingLabel,
|
||||
ControllerStylePreview,
|
||||
} from './ControllerIcons'
|
||||
|
||||
const CONTROLLER_STYLE_LABELS = {
|
||||
xbox: 'Xbox',
|
||||
playstation: 'PlayStation',
|
||||
nintendo: 'Nintendo',
|
||||
} as const
|
||||
import { useDualScreen } from '../dualScreen'
|
||||
import {
|
||||
getNativeDisplays,
|
||||
@@ -22,11 +18,50 @@ import {
|
||||
type AndroidDisplay,
|
||||
} from '../nativeDualScreen'
|
||||
|
||||
type SettingsTab = 'display' | 'input' | 'bindings'
|
||||
|
||||
type SettingsNavEntry =
|
||||
| { kind: 'back'; key: string; row: number; column: number }
|
||||
| { kind: 'tab'; key: string; row: number; column: number; tab: SettingsTab }
|
||||
| { kind: 'dualToggle'; key: string; row: number; column: number }
|
||||
| { kind: 'openCompanion'; key: string; row: number; column: number }
|
||||
| { kind: 'directTargeting'; key: string; row: number; column: number }
|
||||
| { kind: 'touchLock'; key: string; row: number; column: number }
|
||||
| { kind: 'iconStyle'; key: string; row: number; column: number; style: keyof typeof CONTROLLER_STYLE_LABELS }
|
||||
| { kind: 'device'; key: string; row: number; column: number; device: InputDevice }
|
||||
| { kind: 'binding'; key: string; row: number; column: number; action: InputAction }
|
||||
| { kind: 'resetBindings'; key: string; row: number; column: number }
|
||||
|
||||
const CONTROLLER_STYLE_LABELS = {
|
||||
xbox: 'Xbox',
|
||||
playstation: 'PlayStation',
|
||||
nintendo: 'Nintendo',
|
||||
} as const
|
||||
|
||||
const SETTINGS_TAB_ORDER = ['display', 'input', 'bindings'] as const
|
||||
const SETTINGS_TAB_LABELS: Record<SettingsTab, string> = {
|
||||
display: 'Display',
|
||||
input: 'Input',
|
||||
bindings: 'Bindings',
|
||||
}
|
||||
const SETTINGS_BINDING_COLUMNS = 2
|
||||
const DIRECT_TARGET_ACTIONS = new Set<InputAction>([
|
||||
'targetParty1',
|
||||
'targetParty2',
|
||||
'targetParty3',
|
||||
'targetParty4',
|
||||
'targetParty5',
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
])
|
||||
|
||||
export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
const [device, setDevice] = useState<InputDevice>('controller')
|
||||
const [settingsTab, setSettingsTab] = useState<'display' | 'input' | 'bindings'>('display')
|
||||
const [settingsTab, setSettingsTab] = useState<SettingsTab>('display')
|
||||
const [selectedIndex, setSelectedIndex] = useState(1)
|
||||
const [displayMessage, setDisplayMessage] = useState('')
|
||||
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
|
||||
const navRefs = useRef<Record<string, HTMLElement | null>>({})
|
||||
const {
|
||||
bindings,
|
||||
capture,
|
||||
@@ -47,20 +82,141 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
openTopDisplay,
|
||||
} = useDualScreen()
|
||||
const nativeDualScreen = hasNativeDualScreenBridge()
|
||||
const directTargetActions = new Set([
|
||||
'targetParty1',
|
||||
'targetParty2',
|
||||
'targetParty3',
|
||||
'targetParty4',
|
||||
'targetParty5',
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
])
|
||||
const visibleActions = INPUT_ACTIONS.filter((action) => (
|
||||
const visibleActions = useMemo(() => INPUT_ACTIONS.filter((action) => (
|
||||
directPartyTargeting
|
||||
? action !== 'previousTarget' && action !== 'nextTarget'
|
||||
: !directTargetActions.has(action)
|
||||
))
|
||||
: !DIRECT_TARGET_ACTIONS.has(action)
|
||||
)), [directPartyTargeting])
|
||||
const navEntries = useMemo<SettingsNavEntry[]>(() => {
|
||||
const entries: SettingsNavEntry[] = [
|
||||
{ kind: 'back', key: 'back', row: 0, column: 0 },
|
||||
...SETTINGS_TAB_ORDER.map((tab, index) => ({
|
||||
kind: 'tab' as const,
|
||||
key: `tab:${tab}`,
|
||||
row: 0,
|
||||
column: index + 1,
|
||||
tab,
|
||||
})),
|
||||
]
|
||||
if (settingsTab === 'display') {
|
||||
entries.push(
|
||||
{ kind: 'dualToggle', key: 'display:dual-toggle', row: 1, column: 1 },
|
||||
{ kind: 'openCompanion', key: 'display:open-companion', row: 1, column: 2 },
|
||||
)
|
||||
} else if (settingsTab === 'input') {
|
||||
entries.push(
|
||||
{ kind: 'directTargeting', key: 'input:direct-targeting', row: 1, column: 1 },
|
||||
{ kind: 'touchLock', key: 'input:touch-lock', row: 1, column: 2 },
|
||||
{ kind: 'iconStyle', key: 'input:icons:xbox', row: 2, column: 0, style: 'xbox' },
|
||||
{ kind: 'iconStyle', key: 'input:icons:playstation', row: 2, column: 1, style: 'playstation' },
|
||||
{ kind: 'iconStyle', key: 'input:icons:nintendo', row: 2, column: 2, style: 'nintendo' },
|
||||
)
|
||||
} else {
|
||||
entries.push(
|
||||
{ kind: 'device', key: 'bindings:device:controller', row: 1, column: 0, device: 'controller' },
|
||||
{ kind: 'device', key: 'bindings:device:pc', row: 1, column: 1, device: 'pc' },
|
||||
...visibleActions.map((action, index) => ({
|
||||
kind: 'binding' as const,
|
||||
key: `bindings:action:${action}`,
|
||||
row: Math.floor(index / SETTINGS_BINDING_COLUMNS) + 2,
|
||||
column: index % SETTINGS_BINDING_COLUMNS,
|
||||
action,
|
||||
})),
|
||||
{
|
||||
kind: 'resetBindings',
|
||||
key: 'bindings:reset',
|
||||
row: Math.floor((visibleActions.length - 1) / SETTINGS_BINDING_COLUMNS) + 3,
|
||||
column: 1,
|
||||
},
|
||||
)
|
||||
}
|
||||
return entries
|
||||
}, [settingsTab, visibleActions])
|
||||
const activeIndex = Math.min(selectedIndex, Math.max(0, navEntries.length - 1))
|
||||
const activeEntry = navEntries[activeIndex]
|
||||
|
||||
function selected(entryKey: string) {
|
||||
return activeEntry?.key === entryKey
|
||||
}
|
||||
|
||||
function navRef(entryKey: string) {
|
||||
return (node: HTMLElement | null) => {
|
||||
navRefs.current[entryKey] = node
|
||||
}
|
||||
}
|
||||
|
||||
function selectKey(entryKey: string) {
|
||||
const index = navEntries.findIndex((entry) => entry.key === entryKey)
|
||||
if (index >= 0) setSelectedIndex(index)
|
||||
}
|
||||
|
||||
function selectTab(tab: SettingsTab) {
|
||||
setSettingsTab(tab)
|
||||
const tabIndex = SETTINGS_TAB_ORDER.indexOf(tab)
|
||||
setSelectedIndex(tabIndex + 1)
|
||||
}
|
||||
|
||||
function firstContentKey(tab: SettingsTab) {
|
||||
if (tab === 'display') return 'display:dual-toggle'
|
||||
if (tab === 'input') return 'input:direct-targeting'
|
||||
return 'bindings:device:controller'
|
||||
}
|
||||
|
||||
function moveSelection(action: InputAction) {
|
||||
if (!action.startsWith('navigate') || navEntries.length === 0) return
|
||||
setSelectedIndex((current) => {
|
||||
const bounded = Math.min(current, navEntries.length - 1)
|
||||
const active = navEntries[bounded]
|
||||
if (!active) return 0
|
||||
if (action === 'navigateDown' && active.row === 0) {
|
||||
const nextIndex = navEntries.findIndex((entry) => entry.key === firstContentKey(settingsTab))
|
||||
if (nextIndex >= 0) return nextIndex
|
||||
}
|
||||
if (action === 'navigateUp' && active.row === 1) {
|
||||
const tabIndex = navEntries.findIndex((entry) => entry.key === `tab:${settingsTab}`)
|
||||
if (tabIndex >= 0) return tabIndex
|
||||
}
|
||||
const candidates = navEntries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(({ index }) => index !== bounded)
|
||||
.filter(({ entry }) => {
|
||||
if (action === 'navigateLeft') return entry.column < active.column
|
||||
if (action === 'navigateRight') return entry.column > active.column
|
||||
if (action === 'navigateUp') return entry.row < active.row
|
||||
return entry.row > active.row
|
||||
})
|
||||
if (candidates.length === 0) return bounded
|
||||
candidates.sort((a, b) => {
|
||||
const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
|
||||
const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
|
||||
const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||
? Math.abs(a.entry.row - active.row)
|
||||
: Math.abs(a.entry.column - active.column)
|
||||
const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
|
||||
? Math.abs(b.entry.row - active.row)
|
||||
: Math.abs(b.entry.column - active.column)
|
||||
return aPrimary - bPrimary || aSecondary - bSecondary || a.index - b.index
|
||||
})
|
||||
return candidates[0]?.index ?? bounded
|
||||
})
|
||||
}
|
||||
|
||||
function openEntry(entry: SettingsNavEntry | undefined) {
|
||||
if (!entry) return
|
||||
if (entry.kind === 'back') onBack()
|
||||
else if (entry.kind === 'tab') selectTab(entry.tab)
|
||||
else if (entry.kind === 'dualToggle') {
|
||||
setDualScreenEnabled(!dualScreenEnabled)
|
||||
setDisplayMessage('')
|
||||
} else if (entry.kind === 'openCompanion') {
|
||||
void launchTopDisplay()
|
||||
} else if (entry.kind === 'directTargeting') setDirectPartyTargeting(!directPartyTargeting)
|
||||
else if (entry.kind === 'touchLock') setCombatTouchLocked(!combatTouchLocked)
|
||||
else if (entry.kind === 'iconStyle') setControllerIconStyle(entry.style)
|
||||
else if (entry.kind === 'device') setDevice(entry.device)
|
||||
else if (entry.kind === 'binding') beginCapture(device, entry.action)
|
||||
else if (entry.kind === 'resetBindings') resetBindings(device)
|
||||
}
|
||||
|
||||
async function refreshNativeDisplays() {
|
||||
if (!nativeDualScreen) return
|
||||
@@ -79,6 +235,24 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
.catch(() => setAndroidDisplays([]))
|
||||
}, [nativeDualScreen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeEntry) return
|
||||
navRefs.current[activeEntry.key]?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}, [activeEntry])
|
||||
|
||||
useGameAction((action, inputDevice) => {
|
||||
if (inputDevice !== 'controller' || capture) return
|
||||
if (action === 'back') {
|
||||
onBack()
|
||||
return
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
openEntry(activeEntry)
|
||||
return
|
||||
}
|
||||
moveSelection(action)
|
||||
})
|
||||
|
||||
async function launchTopDisplay() {
|
||||
const opened = await openTopDisplay()
|
||||
setDisplayMessage(opened
|
||||
@@ -90,24 +264,34 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="content-screen settings-screen">
|
||||
<section className="content-screen settings-screen" data-game-nav-active={capture ? undefined : 'true'}>
|
||||
<div className="settings-nav">
|
||||
<button className="back-button settings-back-button" onClick={onBack} type="button">Back</button>
|
||||
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||
{([
|
||||
{ key: 'display', label: 'Display' },
|
||||
{ key: 'input', label: 'Input' },
|
||||
{ key: 'bindings', label: 'Bindings' },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
aria-selected={settingsTab === tab.key}
|
||||
className={settingsTab === tab.key ? 'selected' : ''}
|
||||
key={tab.key}
|
||||
onClick={() => setSettingsTab(tab.key)}
|
||||
className={`back-button settings-back-button ${selected('back') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('back') ? 'true' : undefined}
|
||||
onClick={onBack}
|
||||
onPointerDown={() => selectKey('back')}
|
||||
ref={navRef('back')}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<nav className="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||
{SETTINGS_TAB_ORDER.map((tab) => (
|
||||
<button
|
||||
aria-selected={settingsTab === tab}
|
||||
className={`${settingsTab === tab ? 'selected' : ''} ${selected(`tab:${tab}`) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected(`tab:${tab}`) ? 'true' : undefined}
|
||||
key={tab}
|
||||
onClick={() => selectTab(tab)}
|
||||
onPointerDown={() => selectKey(`tab:${tab}`)}
|
||||
ref={navRef(`tab:${tab}`)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
{SETTINGS_TAB_LABELS[tab]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -125,16 +309,28 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
</div>
|
||||
<div className="dual-screen-actions">
|
||||
<button
|
||||
className={dualScreenEnabled ? 'selected' : ''}
|
||||
className={`${dualScreenEnabled ? 'selected' : ''} ${selected('display:dual-toggle') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('display:dual-toggle') ? 'true' : undefined}
|
||||
onClick={() => {
|
||||
setDualScreenEnabled(!dualScreenEnabled)
|
||||
setDisplayMessage('')
|
||||
}}
|
||||
onPointerDown={() => selectKey('display:dual-toggle')}
|
||||
ref={navRef('display:dual-toggle')}
|
||||
type="button"
|
||||
>
|
||||
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
|
||||
</button>
|
||||
<button onClick={launchTopDisplay} type="button">
|
||||
<button
|
||||
className={selected('display:open-companion') ? 'game-selected' : ''}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('display:open-companion') ? 'true' : undefined}
|
||||
onClick={launchTopDisplay}
|
||||
onPointerDown={() => selectKey('display:open-companion')}
|
||||
ref={navRef('display:open-companion')}
|
||||
type="button"
|
||||
>
|
||||
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -171,16 +367,24 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
</div>
|
||||
<button
|
||||
aria-pressed={directPartyTargeting}
|
||||
className={directPartyTargeting ? 'selected' : ''}
|
||||
className={`${directPartyTargeting ? 'selected' : ''} ${selected('input:direct-targeting') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('input:direct-targeting') ? 'true' : undefined}
|
||||
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
|
||||
onPointerDown={() => selectKey('input:direct-targeting')}
|
||||
ref={navRef('input:direct-targeting')}
|
||||
type="button"
|
||||
>
|
||||
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={combatTouchLocked}
|
||||
className={combatTouchLocked ? 'selected' : ''}
|
||||
className={`${combatTouchLocked ? 'selected' : ''} ${selected('input:touch-lock') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('input:touch-lock') ? 'true' : undefined}
|
||||
onClick={() => setCombatTouchLocked(!combatTouchLocked)}
|
||||
onPointerDown={() => selectKey('input:touch-lock')}
|
||||
ref={navRef('input:touch-lock')}
|
||||
type="button"
|
||||
>
|
||||
{combatTouchLocked ? 'Combat Touch Locked' : 'Combat Touch Unlocked'}
|
||||
@@ -190,9 +394,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
|
||||
<button
|
||||
aria-pressed={controllerIconStyle === style}
|
||||
className={controllerIconStyle === style ? 'selected' : ''}
|
||||
className={`${controllerIconStyle === style ? 'selected' : ''} ${selected(`input:icons:${style}`) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected(`input:icons:${style}`) ? 'true' : undefined}
|
||||
key={style}
|
||||
onClick={() => setControllerIconStyle(style)}
|
||||
onPointerDown={() => selectKey(`input:icons:${style}`)}
|
||||
ref={navRef(`input:icons:${style}`)}
|
||||
type="button"
|
||||
>
|
||||
<ControllerStylePreview iconStyle={style} />
|
||||
@@ -215,15 +423,23 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
|
||||
<div className="binding-tabs">
|
||||
<button
|
||||
className={device === 'controller' ? 'selected' : ''}
|
||||
className={`${device === 'controller' ? 'selected' : ''} ${selected('bindings:device:controller') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('bindings:device:controller') ? 'true' : undefined}
|
||||
onClick={() => setDevice('controller')}
|
||||
onPointerDown={() => selectKey('bindings:device:controller')}
|
||||
ref={navRef('bindings:device:controller')}
|
||||
type="button"
|
||||
>
|
||||
Controller
|
||||
</button>
|
||||
<button
|
||||
className={device === 'pc' ? 'selected' : ''}
|
||||
className={`${device === 'pc' ? 'selected' : ''} ${selected('bindings:device:pc') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('bindings:device:pc') ? 'true' : undefined}
|
||||
onClick={() => setDevice('pc')}
|
||||
onPointerDown={() => selectKey('bindings:device:pc')}
|
||||
ref={navRef('bindings:device:pc')}
|
||||
type="button"
|
||||
>
|
||||
PC
|
||||
@@ -233,9 +449,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
<div className="binding-list">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
className={capture?.device === device && capture.action === action ? 'listening' : ''}
|
||||
className={`${capture?.device === device && capture.action === action ? 'listening' : ''} ${selected(`bindings:action:${action}`) ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected(`bindings:action:${action}`) ? 'true' : undefined}
|
||||
key={action}
|
||||
onClick={() => beginCapture(device, action)}
|
||||
onPointerDown={() => selectKey(`bindings:action:${action}`)}
|
||||
ref={navRef(`bindings:action:${action}`)}
|
||||
type="button"
|
||||
>
|
||||
<span>{ACTION_LABELS[action]}</span>
|
||||
@@ -255,7 +475,15 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
|
||||
<footer className="settings-footer">
|
||||
<span>Bindings are saved automatically on this device.</span>
|
||||
<button className="text-button" onClick={() => resetBindings(device)} type="button">
|
||||
<button
|
||||
className={`text-button ${selected('bindings:reset') ? 'game-selected' : ''}`}
|
||||
data-controller-nav="skip"
|
||||
data-game-selected={selected('bindings:reset') ? 'true' : undefined}
|
||||
onClick={() => resetBindings(device)}
|
||||
onPointerDown={() => selectKey('bindings:reset')}
|
||||
ref={navRef('bindings:reset')}
|
||||
type="button"
|
||||
>
|
||||
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
@@ -97,14 +97,34 @@ export type DualScreenWorkshopState = {
|
||||
}>
|
||||
}
|
||||
|
||||
export type DualScreenSetupState = {
|
||||
contentType: 'dungeon' | 'raid'
|
||||
title: string
|
||||
subtitle: string
|
||||
description: string
|
||||
initials: string
|
||||
difficultyName: string
|
||||
itemLevel: number
|
||||
experience: number
|
||||
lockedReason?: string
|
||||
stats: {
|
||||
health: string
|
||||
damage: string
|
||||
xp: string
|
||||
loot: string
|
||||
}
|
||||
}
|
||||
|
||||
type DualScreenMessage =
|
||||
| { type: 'combat-state'; state: DualScreenCombatState }
|
||||
| { type: 'workshop-state'; state: DualScreenWorkshopState }
|
||||
| { type: 'setup-state'; state: DualScreenSetupState }
|
||||
| { type: 'companion-ready' }
|
||||
| { type: 'companion-heartbeat' }
|
||||
| { type: 'control-action'; action: InputAction }
|
||||
| { type: 'combat-ended' }
|
||||
| { type: 'workshop-ended' }
|
||||
| { type: 'setup-ended' }
|
||||
|
||||
type DualScreenContextValue = {
|
||||
enabled: boolean
|
||||
@@ -160,6 +180,14 @@ function shouldRelayBottomAction(
|
||||
return action === 'pause' || action === 'back'
|
||||
}
|
||||
|
||||
function shouldRelaySetupAction(
|
||||
action: InputAction,
|
||||
state: DualScreenSetupState | null,
|
||||
) {
|
||||
if (!state) return false
|
||||
return action.startsWith('navigate') || action === 'confirm' || action === 'back'
|
||||
}
|
||||
|
||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||
const [enabled, setEnabledState] = useState(
|
||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||
@@ -361,6 +389,9 @@ export function useDualScreenWorkshopPublisher(
|
||||
}
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === 'companion-ready') publish()
|
||||
if (event.data.type === 'control-action') {
|
||||
dispatchExternalGameAction(event.data.action, 'controller')
|
||||
}
|
||||
}
|
||||
publish()
|
||||
return () => {
|
||||
@@ -377,9 +408,53 @@ export function useDualScreenWorkshopPublisher(
|
||||
}, [enabled, state])
|
||||
}
|
||||
|
||||
export function useDualScreenSetupPublisher(
|
||||
state: DualScreenSetupState | null,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const stateRef = useRef(state)
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !state) return
|
||||
const channel = createChannel()
|
||||
if (!channel) return
|
||||
const publish = () => {
|
||||
if (stateRef.current) {
|
||||
channel.postMessage({
|
||||
type: 'setup-state',
|
||||
state: stateRef.current,
|
||||
} satisfies DualScreenMessage)
|
||||
}
|
||||
}
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === 'companion-ready') publish()
|
||||
if (event.data.type === 'control-action') {
|
||||
dispatchExternalGameAction(event.data.action, 'controller')
|
||||
}
|
||||
}
|
||||
publish()
|
||||
return () => {
|
||||
channel.postMessage({ type: 'setup-ended' } satisfies DualScreenMessage)
|
||||
channel.close()
|
||||
}
|
||||
}, [enabled, state])
|
||||
|
||||
useEffect(() => {
|
||||
const channel = createChannel()
|
||||
if (!enabled || !channel) return
|
||||
if (state) channel.postMessage({ type: 'setup-state', state } satisfies DualScreenMessage)
|
||||
else channel.postMessage({ type: 'setup-ended' } satisfies DualScreenMessage)
|
||||
channel.close()
|
||||
}, [enabled, state])
|
||||
}
|
||||
|
||||
export function DualScreenBottomDisplay() {
|
||||
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
||||
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
|
||||
const [setupState, setSetupState] = useState<DualScreenSetupState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const channel = createChannel()
|
||||
@@ -389,13 +464,21 @@ export function DualScreenBottomDisplay() {
|
||||
if (event.data.type === 'combat-state') {
|
||||
setState(event.data.state)
|
||||
setWorkshopState(null)
|
||||
setSetupState(null)
|
||||
}
|
||||
if (event.data.type === 'workshop-state') {
|
||||
setWorkshopState(event.data.state)
|
||||
setState(null)
|
||||
setSetupState(null)
|
||||
}
|
||||
if (event.data.type === 'setup-state') {
|
||||
setSetupState(event.data.state)
|
||||
setState(null)
|
||||
setWorkshopState(null)
|
||||
}
|
||||
if (event.data.type === 'combat-ended') setState(null)
|
||||
if (event.data.type === 'workshop-ended') setWorkshopState(null)
|
||||
if (event.data.type === 'setup-ended') setSetupState(null)
|
||||
}
|
||||
announce()
|
||||
const timer = window.setInterval(() => {
|
||||
@@ -414,7 +497,10 @@ export function DualScreenBottomDisplay() {
|
||||
}
|
||||
|
||||
useGameAction((action) => {
|
||||
if (!shouldRelayBottomAction(action, state)) return
|
||||
if (
|
||||
!shouldRelayBottomAction(action, state)
|
||||
&& !shouldRelaySetupAction(action, setupState)
|
||||
) return
|
||||
sendAction(action)
|
||||
})
|
||||
|
||||
@@ -452,6 +538,42 @@ export function DualScreenBottomDisplay() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!state && setupState) {
|
||||
return (
|
||||
<main className="dual-bottom-display setup-bottom-display">
|
||||
<header className="dual-controls-header">
|
||||
<div>
|
||||
<p className="eyebrow">{setupState.contentType}</p>
|
||||
<h1>{setupState.title}</h1>
|
||||
<small>{setupState.subtitle}</small>
|
||||
</div>
|
||||
<div className="dual-controls-progress">
|
||||
<span>{setupState.difficultyName}</span>
|
||||
<span>iLvl {setupState.itemLevel}</span>
|
||||
<span>{setupState.experience} XP</span>
|
||||
</div>
|
||||
</header>
|
||||
<section className="setup-bottom-summary">
|
||||
<span className={`dungeon-art ${setupState.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||
{setupState.initials}
|
||||
</span>
|
||||
<div>
|
||||
<p className="eyebrow">Selected Run</p>
|
||||
<h2>{setupState.title}</h2>
|
||||
<p>{setupState.description}</p>
|
||||
{setupState.lockedReason && <small>{setupState.lockedReason}</small>}
|
||||
</div>
|
||||
</section>
|
||||
<dl className="setup-bottom-stats">
|
||||
<div><dt>Health</dt><dd>{setupState.stats.health}</dd></div>
|
||||
<div><dt>Damage</dt><dd>{setupState.stats.damage}</dd></div>
|
||||
<div><dt>XP</dt><dd>{setupState.stats.xp}</dd></div>
|
||||
<div><dt>Loot</dt><dd>{setupState.stats.loot}</dd></div>
|
||||
</dl>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<main className="dual-bottom-display dual-bottom-waiting">
|
||||
|
||||
@@ -86,6 +86,7 @@ type CharacterData = {
|
||||
|
||||
type OfflineSave = {
|
||||
version: 4
|
||||
updatedAt: number
|
||||
characterName: string
|
||||
activeClassId: number
|
||||
completedDungeonParts: number
|
||||
@@ -117,6 +118,22 @@ export type CloudSyncStatus = {
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
export type CloudSaveAge = 'server-newer' | 'local-newer' | 'same' | 'unknown'
|
||||
|
||||
export type CloudSaveSummary = {
|
||||
updatedAt: number | null
|
||||
characterName: string
|
||||
activeClassId: number
|
||||
}
|
||||
|
||||
export type CloudSyncComparison = {
|
||||
relation: CloudSaveAge
|
||||
local: CloudSaveSummary
|
||||
server: CloudSaveSummary
|
||||
}
|
||||
|
||||
export type CloudSyncChoice = 'local' | 'server'
|
||||
|
||||
type RepositoryMode = 'online' | 'offline-local' | 'offline-cached'
|
||||
|
||||
type NetworkError = Error & {
|
||||
@@ -144,6 +161,30 @@ function clone<T>(value: T): T {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
function normalizedSaveTimestamp(value: unknown): number {
|
||||
const timestamp = Number(value)
|
||||
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0
|
||||
}
|
||||
|
||||
function stampSave(save: OfflineSave, updatedAt = Date.now()): OfflineSave {
|
||||
save.updatedAt = updatedAt
|
||||
return save
|
||||
}
|
||||
|
||||
function saveSummary(save: OfflineSave): CloudSaveSummary {
|
||||
return {
|
||||
updatedAt: save.updatedAt > 0 ? save.updatedAt : null,
|
||||
characterName: save.characterName,
|
||||
activeClassId: save.activeClassId,
|
||||
}
|
||||
}
|
||||
|
||||
function compareSaveAge(local: OfflineSave, server: OfflineSave): CloudSaveAge {
|
||||
if (local.updatedAt <= 0 || server.updatedAt <= 0) return 'unknown'
|
||||
if (Math.abs(local.updatedAt - server.updatedAt) < 1000) return 'same'
|
||||
return server.updatedAt > local.updatedAt ? 'server-newer' : 'local-newer'
|
||||
}
|
||||
|
||||
function toGameMode(mode: RepositoryMode): GameMode {
|
||||
return mode === 'online' ? 'online' : 'offline'
|
||||
}
|
||||
@@ -190,6 +231,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
||||
}
|
||||
return {
|
||||
version: 4,
|
||||
updatedAt: 0,
|
||||
characterName: p.character.name,
|
||||
activeClassId: p.character.classId,
|
||||
completedDungeonParts: p.completedDungeonParts,
|
||||
@@ -210,6 +252,7 @@ function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases' |
|
||||
return normalizeSaveAbilitySlots({
|
||||
...v2,
|
||||
version: 4,
|
||||
updatedAt: normalizedSaveTimestamp((v2 as { updatedAt?: unknown }).updatedAt),
|
||||
completedRaidPhases: 0,
|
||||
bossKills: {},
|
||||
bossPets: {},
|
||||
@@ -222,6 +265,7 @@ function upgradeV3Save(v3: Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets
|
||||
return normalizeSaveAbilitySlots({
|
||||
...v3,
|
||||
version: 4,
|
||||
updatedAt: normalizedSaveTimestamp((v3 as { updatedAt?: unknown }).updatedAt),
|
||||
bossKills: {},
|
||||
bossPets: {},
|
||||
pvpMatchesPlayed: 0,
|
||||
@@ -244,6 +288,7 @@ function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
|
||||
}
|
||||
|
||||
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
|
||||
save.updatedAt = normalizedSaveTimestamp(save.updatedAt)
|
||||
for (const character of Object.values(save.characters)) {
|
||||
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
|
||||
}
|
||||
@@ -590,6 +635,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
||||
}
|
||||
return {
|
||||
version: 4,
|
||||
updatedAt: existingSave?.updatedAt ?? Date.now(),
|
||||
characterName: profile.character.name,
|
||||
activeClassId: profile.character.classId,
|
||||
completedDungeonParts: profile.completedDungeonParts,
|
||||
@@ -1008,6 +1054,10 @@ function requireStoredSave(store: LocalSaveStore): OfflineSave {
|
||||
return save
|
||||
}
|
||||
|
||||
function writeStoreSave(store: LocalSaveStore, save: OfflineSave) {
|
||||
store.writeSave(stampSave(save))
|
||||
}
|
||||
|
||||
function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
return {
|
||||
async loadSession() {
|
||||
@@ -1055,7 +1105,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
}
|
||||
save.characters[classId].abilitySlots = slots
|
||||
save.activeClassId = classId
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
|
||||
@@ -1161,7 +1211,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
}
|
||||
}
|
||||
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
const updatedProfile = buildProfile(save)
|
||||
|
||||
return {
|
||||
@@ -1284,7 +1334,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
)
|
||||
cd.inventory = profile.inventory
|
||||
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
const updatedProfile = buildProfile(save)
|
||||
|
||||
return {
|
||||
@@ -1339,7 +1389,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
}
|
||||
cd.talentRanks[String(talentId)] = 1
|
||||
}
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return buildProfile(save)
|
||||
}
|
||||
if (cd.talentPoints <= 0) {
|
||||
@@ -1367,7 +1417,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
}
|
||||
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
|
||||
cd.talentPoints -= 1
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async resetTalents() {
|
||||
@@ -1390,7 +1440,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
cd.talentPoints + refunded,
|
||||
)
|
||||
}
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async equipItem(itemId) {
|
||||
@@ -1402,7 +1452,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
|
||||
}
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async discardExtraItem(itemId) {
|
||||
@@ -1413,7 +1463,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
|
||||
item.quantity -= 1
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async breakdownItem(itemId) {
|
||||
@@ -1456,7 +1506,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
}
|
||||
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async craftItem(recipeId) {
|
||||
@@ -1484,7 +1534,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
|
||||
addInventoryItem(profile.inventory, recipe.item, 1)
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async upgradeItem(itemId) {
|
||||
@@ -1527,7 +1577,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
if (upgraded) upgraded.equipped = true
|
||||
}
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async rollEncounterLoot(encounterId, difficultyId, runToken) {
|
||||
@@ -1610,7 +1660,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
}
|
||||
save.lootRolls[rollKey] = result
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return clone(result)
|
||||
},
|
||||
async recordBossKill(encounterId, options) {
|
||||
@@ -1623,7 +1673,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const petAwarded = recordBossKillInSave(save, encounter, {
|
||||
petVariant: options?.petVariant ?? 'normal',
|
||||
})
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return {
|
||||
profile: buildProfile(save),
|
||||
petAwarded,
|
||||
@@ -1633,7 +1683,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const save = requireStoredSave(store)
|
||||
save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1
|
||||
if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1
|
||||
store.writeSave(save)
|
||||
writeStoreSave(store, save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
}
|
||||
@@ -1750,6 +1800,49 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
|
||||
return buildProfile(synced.save)
|
||||
}
|
||||
|
||||
export async function previewCloudSaveSync(): Promise<CloudSyncComparison> {
|
||||
const cache = readOnlineCache()
|
||||
if (!cache) {
|
||||
throw new Error('No signed-in save is available for cloud sync.')
|
||||
}
|
||||
await refreshCatalogFromServer()
|
||||
const serverSave = await loadServerSyncSave()
|
||||
return {
|
||||
relation: compareSaveAge(cache.save, serverSave),
|
||||
local: saveSummary(cache.save),
|
||||
server: saveSummary(serverSave),
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCloudSaveSync(choice: CloudSyncChoice): Promise<CharacterProfile> {
|
||||
const cache = readOnlineCache()
|
||||
if (!cache) {
|
||||
throw new Error('No signed-in save is available for cloud sync.')
|
||||
}
|
||||
await refreshCatalogFromServer()
|
||||
if (choice === 'server') {
|
||||
const serverSave = await loadServerSyncSave()
|
||||
writeOnlineCache({
|
||||
version: 1,
|
||||
account: cache.account,
|
||||
save: serverSave,
|
||||
dirty: false,
|
||||
})
|
||||
writeMode('online')
|
||||
return buildProfile(serverSave)
|
||||
}
|
||||
const synced = await pushServerSyncSave(cache.save)
|
||||
await refreshCatalogFromServer()
|
||||
writeOnlineCache({
|
||||
version: 1,
|
||||
account: cache.account,
|
||||
save: synced.save,
|
||||
dirty: false,
|
||||
})
|
||||
writeMode('online')
|
||||
return buildProfile(synced.save)
|
||||
}
|
||||
|
||||
export function selectOnlineMode() {
|
||||
writeMode('online')
|
||||
}
|
||||
@@ -1765,6 +1858,7 @@ export function createOfflineCharacter(characterName: string): AuthSession {
|
||||
}
|
||||
const save: OfflineSave = {
|
||||
version: 4,
|
||||
updatedAt: Date.now(),
|
||||
characterName: name,
|
||||
activeClassId: 1,
|
||||
completedDungeonParts: 0,
|
||||
|
||||
@@ -349,6 +349,7 @@ function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) {
|
||||
}
|
||||
|
||||
export function focusFirstControl() {
|
||||
if (hasDedicatedGameNavigation()) return null
|
||||
const first = focusableElements()[0]
|
||||
if (first) focusControl(first)
|
||||
return first
|
||||
@@ -611,6 +612,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
document.documentElement.dataset.inputDevice = device
|
||||
|
||||
if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
dispatchGameAction(action, device)
|
||||
return
|
||||
}
|
||||
@@ -810,6 +812,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
focusFrame = 0
|
||||
const combatActive = document.querySelector('[data-combat-active="true"]')
|
||||
if (combatActive) return
|
||||
if (hasDedicatedGameNavigation()) return
|
||||
const candidates = focusableElements()
|
||||
const activeControl = currentFocusableControl(candidates)
|
||||
if (
|
||||
|
||||