made some changes to the UI, removed leaderboards. updated gamesaves

This commit is contained in:
Warren H
2026-06-18 13:00:29 -04:00
parent 3c90998a61
commit a604569a2f
44 changed files with 2301 additions and 435 deletions
+6
View File
@@ -0,0 +1,6 @@
# Project Notes
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
- User rebuilds app; do not rebuild APK unless explicitly requested.
- Apply game changes to both web version and mobile app version.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode 32
versionName "1.0.21"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -24,6 +24,18 @@ android {
}
}
def invalidAndroidResCopies = tasks.register('removeInvalidAndroidResCopies', Delete) {
delete fileTree("${projectDir}/src/main/res") {
include '**/* *.*'
}
}
tasks.matching { task ->
task.name.startsWith('merge') && task.name.endsWith('Resources')
}.configureEach {
dependsOn(invalidAndroidResCopies)
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
@@ -2,17 +2,25 @@ package com.warren.iwanttoheal;
import android.content.Intent;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.KeyEvent;
import android.view.View;
import com.getcapacitor.BridgeActivity;
import java.io.File;
public abstract class ControllerBridgeActivity extends BridgeActivity {
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
private static final long DPAD_THROTTLE_MS = 125;
private long lastDpadDispatchAt = 0;
@Override
public void onCreate(Bundle savedInstanceState) {
clearWebViewServiceWorkers();
super.onCreate(savedInstanceState);
if (bridge != null) {
bridge.getWebView().clearCache(true);
}
loadIntentUrl();
}
@@ -47,6 +55,25 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
);
}
private void clearWebViewServiceWorkers() {
File webViewData = new File(getApplicationInfo().dataDir, "app_webview");
deleteIfExists(new File(webViewData, "Default/Service Worker"));
deleteIfExists(new File(webViewData, "Service Worker"));
}
private void deleteIfExists(File file) {
if (!file.exists()) return;
if (file.isDirectory()) {
File[] children = file.listFiles();
if (children != null) {
for (File child : children) {
deleteIfExists(child);
}
}
}
file.delete();
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
String token = controllerToken(event.getKeyCode());
@@ -54,6 +81,7 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad()) return true;
String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
@@ -64,6 +92,20 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
return true;
}
private boolean shouldThrottleDpad() {
long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt = now;
return false;
}
private boolean isDpadToken(String token) {
return token.equals("Button12")
|| token.equals("Button13")
|| token.equals("Button14")
|| token.equals("Button15");
}
private String controllerToken(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A:
@@ -66,9 +66,10 @@ public class DualScreenPlugin extends Plugin {
}
String gameUrl = bridge.getLocalUrl();
String topGameUrl = gameUrl + "/?display=top";
String controlsUrl = gameUrl + "/?display=bottom";
String targetUrl = launchPlan.targetIsTopDisplay ? gameUrl : controlsUrl;
String currentUrl = launchPlan.currentIsTopDisplay ? gameUrl : controlsUrl;
String targetUrl = launchPlan.targetIsTopDisplay ? topGameUrl : controlsUrl;
String currentUrl = launchPlan.currentIsTopDisplay ? topGameUrl : controlsUrl;
closePresentation();
presentation = new TopDisplayPresentation(
+1 -1
View File
@@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS dungeons (
name TEXT NOT NULL,
recommended_level INTEGER NOT NULL DEFAULT 1,
content_type TEXT NOT NULL DEFAULT 'dungeon' CHECK (content_type IN ('dungeon', 'raid')),
party_size INTEGER NOT NULL DEFAULT 5,
party_size INTEGER NOT NULL DEFAULT 6,
completion_item_level INTEGER,
experience_reward INTEGER NOT NULL DEFAULT 100,
description TEXT NOT NULL
+14 -14
View File
@@ -5,8 +5,8 @@ INSERT OR IGNORE INTO locations (id, slug, name, description) VALUES
INSERT OR IGNORE INTO dungeons
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
VALUES
(1, 1, 'ashen-halls', 'The Ashen Halls', 1, 'dungeon', 5, NULL, 125, 'Break the cinder cult before the old furnace awakens.'),
(2, 2, 'citadel-of-the-ember-crown', 'Citadel of the Ember Crown', 1, 'raid', 10, 10, 175, 'Lead ten allies through the caldera and break the Ember Crown across three phases.');
(1, 1, 'ashen-halls', 'The Ashen Halls', 1, 'dungeon', 6, NULL, 125, 'Break the cinder cult before the old furnace awakens.'),
(2, 2, 'citadel-of-the-ember-crown', 'Citadel of the Ember Crown', 1, 'raid', 18, 10, 175, 'Lead eighteen allies through the caldera and break the Ember Crown across three phases.');
UPDATE dungeons
SET slug = 'bulldrome-hunting-ground',
@@ -14,12 +14,12 @@ SET slug = 'bulldrome-hunting-ground',
location_id = 1,
recommended_level = 1,
content_type = 'dungeon',
party_size = 5,
party_size = 6,
completion_item_level = NULL,
experience_reward = 125,
description = 'A three-boss hunt featuring Bulldrome, Yian Kut-Ku, and Rathian.'
WHERE id = 1;
UPDATE dungeons SET party_size = 10, completion_item_level = NULL, experience_reward = 175
UPDATE dungeons SET party_size = 18, completion_item_level = NULL, experience_reward = 175
WHERE slug = 'citadel-of-the-ember-crown';
INSERT OR IGNORE INTO difficulties
@@ -30,7 +30,7 @@ VALUES
(3, 'champion', 'Champion', 15, 10, 1.7, 1.45, 2.2, 'Demanding encounters for developed characters.'),
(4, 'mythic', 'Mythic', 20, 15, 2.1, 1.75, 3.0, 'Endgame dungeon difficulty.'),
(5, 'ascendant', 'Ascendant', 25, 20, 2.6, 2.1, 4.0, 'The current pinnacle difficulty.'),
(101, 'raid-normal', 'Normal', 7, 1, 1.0, 1.0, 1.25, 'The opening raid difficulty, tuned for a ten-player party.');
(101, 'raid-normal', 'Normal', 7, 1, 1.0, 1.0, 1.25, 'The opening raid difficulty, tuned for an eighteen-player party.');
UPDATE difficulties SET
dropped_item_level = CASE slug
@@ -108,7 +108,7 @@ VALUES
(11, 12, 'Brand of Sacrifice', 'dispellable_dot', 8.4, 9, 'Brands a target with burning sigils.'),
(20, 22, 'Furnace Blast', 'party_damage', 6.3, 16, 'The furnace vents superheated air across the party.'),
(21, 22, 'Melting Touch', 'dispellable_dot', 9.1, 11, 'A target begins to melt from intense heat.'),
(100, 102, 'Caldera Shockwave', 'party_damage', 5.6, 14, 'A shockwave rolls across all ten raiders.'),
(100, 102, 'Caldera Shockwave', 'party_damage', 5.6, 14, 'A shockwave rolls across all eighteen raiders.'),
(101, 102, 'Gatebrand', 'dispellable_dot', 8.2, 9, 'Brands one raider with a seal of living fire.'),
(102, 105, 'Pillar of Judgment', 'party_damage', 5.2, 16, 'Columns of flame erupt beneath the raid.'),
(103, 105, 'Inquisitor''s Brand', 'dispellable_dot', 7.8, 10, 'A burning brand persists until cleansed.'),
@@ -611,7 +611,7 @@ SET slug = 'tigrex-raid',
location_id = 3,
recommended_level = 5,
content_type = 'raid',
party_size = 10,
party_size = 18,
completion_item_level = NULL,
experience_reward = 275,
description = 'A raid-scale hunt against Tigrex, Rathalos, and Gypceros.'
@@ -620,13 +620,13 @@ WHERE id = 2;
INSERT OR IGNORE INTO dungeons
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
VALUES
(3, 3, 'tigrex-hunting-ground', 'Tigrex Hunting Ground', 5, 'dungeon', 5, NULL, 205, 'A three-boss hunt featuring Tigrex, Rathalos, and Gypceros.'),
(4, 3, 'nargacuga-hunting-ground', 'Nargacuga Hunting Ground', 10, 'dungeon', 5, NULL, 245, 'A three-boss hunt featuring Nargacuga, Azuros, and Diablos.'),
(5, 3, 'nargacuga-raid', 'Nargacuga Raid', 10, 'raid', 10, NULL, 325, 'A raid-scale hunt against Nargacuga, Azuros, and Diablos.'),
(6, 3, 'barroth-hunting-ground', 'Barroth Hunting Ground', 15, 'dungeon', 5, NULL, 285, 'A three-boss hunt featuring Barroth, Tobi Kadachi, and Monoblos.'),
(7, 3, 'barroth-raid', 'Barroth Raid', 15, 'raid', 10, NULL, 375, 'A raid-scale hunt against Barroth, Tobi Kadachi, and Monoblos.'),
(8, 3, 'anjanath-hunting-ground', 'Anjanath Hunting Ground', 20, 'dungeon', 5, NULL, 325, 'A three-boss hunt featuring Anjanath, Bazelgeuse, and Odogaron.'),
(9, 3, 'anjanath-raid', 'Anjanath Raid', 20, 'raid', 10, NULL, 425, 'A raid-scale hunt against Anjanath, Bazelgeuse, and Odogaron.');
(3, 3, 'tigrex-hunting-ground', 'Tigrex Hunting Ground', 5, 'dungeon', 6, NULL, 205, 'A three-boss hunt featuring Tigrex, Rathalos, and Gypceros.'),
(4, 3, 'nargacuga-hunting-ground', 'Nargacuga Hunting Ground', 10, 'dungeon', 6, NULL, 245, 'A three-boss hunt featuring Nargacuga, Azuros, and Diablos.'),
(5, 3, 'nargacuga-raid', 'Nargacuga Raid', 10, 'raid', 18, NULL, 325, 'A raid-scale hunt against Nargacuga, Azuros, and Diablos.'),
(6, 3, 'barroth-hunting-ground', 'Barroth Hunting Ground', 15, 'dungeon', 6, NULL, 285, 'A three-boss hunt featuring Barroth, Tobi Kadachi, and Monoblos.'),
(7, 3, 'barroth-raid', 'Barroth Raid', 15, 'raid', 18, NULL, 375, 'A raid-scale hunt against Barroth, Tobi Kadachi, and Monoblos.'),
(8, 3, 'anjanath-hunting-ground', 'Anjanath Hunting Ground', 20, 'dungeon', 6, NULL, 325, 'A three-boss hunt featuring Anjanath, Bazelgeuse, and Odogaron.'),
(9, 3, 'anjanath-raid', 'Anjanath Raid', 20, 'raid', 18, NULL, 425, 'A raid-scale hunt against Anjanath, Bazelgeuse, and Odogaron.');
UPDATE difficulties
SET dropped_item_level = 10,
+2 -2
View File
@@ -6,10 +6,10 @@
"scripts": {
"predev": "npm run db:init",
"dev": "vite",
"build": "npx tsc -b --noEmit && npx vite build && node scripts/generate-service-worker.mjs",
"build": "npm run offline:export && npx tsc -b --noEmit && npx vite build && node scripts/generate-service-worker.mjs",
"android:sync": "npm run build && cap sync android",
"android:open": "cap open android",
"android:apk": "npm run android:sync && cd android && ./gradlew assembleDebug",
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
"db:backup": "node scripts/backup-db.mjs",
"db:init": "node scripts/init-db.mjs",
+342
View File
@@ -854,6 +854,337 @@ export function getProfile(database, characterId, accountId) {
}
}
function exportCharacterData(database, characterId, classId) {
const character = database.prepare(`
SELECT
level,
experience,
talent_points AS talentPoints
FROM characters
WHERE id = ?
`).get(characterId)
const slots = database.prepare(`
SELECT slot_number AS slotNumber, spell_id AS spellId
FROM character_ability_slots
WHERE character_id = ?
ORDER BY slot_number
`).all(characterId)
const talents = database.prepare(`
SELECT
talents.id,
COALESCE(character_talents.rank, 0) AS rank
FROM talents
LEFT JOIN character_talents
ON character_talents.talent_id = talents.id
AND character_talents.character_id = ?
WHERE talents.class_id = ?
ORDER BY talents.id
`).all(characterId, classId)
const inventory = database.prepare(`
SELECT
items.id,
items.slug,
items.name,
items.slot,
items.rarity,
items.item_level AS itemLevel,
items.healing_power AS healingPower,
items.max_resource_bonus AS maxResourceBonus,
items.glyph,
items.description,
item_sets.id AS setId,
item_sets.slug AS setSlug,
item_sets.name AS setName,
character_inventory.quantity,
character_inventory.equipped
FROM character_inventory
JOIN items ON items.id = character_inventory.item_id
LEFT JOIN item_set_items ON item_set_items.item_id = items.id
LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id
WHERE character_inventory.character_id = ?
ORDER BY items.slot, items.item_level DESC, items.id
`).all(characterId).map((item) => ({ ...item, equipped: Boolean(item.equipped) }))
const talentRanks = {}
for (const talent of talents) {
if (talent.rank > 0) {
talentRanks[String(talent.id)] = talent.rank
}
}
return {
level: character.level,
experience: character.experience,
talentPoints: character.talentPoints,
abilitySlots: Array.from({ length: 6 }, (_, index) => {
const slot = slots.find((candidate) => candidate.slotNumber === index + 1)
return slot?.spellId ?? null
}),
talentRanks,
inventory,
}
}
function buildSyncSave(database, accountId, activeCharacterId) {
const account = database.prepare(`
SELECT
completed_dungeon_parts AS completedDungeonParts,
completed_raid_phases AS completedRaidPhases
FROM accounts
WHERE id = ?
`).get(accountId)
const characters = database.prepare(`
SELECT
id,
class_id AS classId,
name
FROM characters
WHERE account_id = ?
ORDER BY class_id
`).all(accountId)
const activeClassId = characters.find((candidate) => candidate.id === activeCharacterId)?.classId
?? characters[0]?.classId
?? 1
const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
?? characters[0]?.name
?? 'Mira'
return {
version: 3,
characterName,
activeClassId,
completedDungeonParts: account?.completedDungeonParts ?? 0,
completedRaidPhases: account?.completedRaidPhases ?? 0,
characters: Object.fromEntries(
characters.map((character) => [
character.classId,
exportCharacterData(database, character.id, character.classId),
]),
),
lootRolls: {},
}
}
function clampInteger(value, fallback, min, max) {
const numeric = Number(value)
if (!Number.isInteger(numeric)) return fallback
return Math.min(max, Math.max(min, numeric))
}
function importSyncSave(database, accountId, activeCharacterId, payload) {
const save = payload?.save
if (
!save
|| typeof save !== 'object'
|| Number(save.version) !== 3
|| typeof save.characterName !== 'string'
|| !save.characters
|| typeof save.characters !== 'object'
) {
throw new Error('The local save snapshot is invalid.')
}
const maxLevel = Number(
database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
)
const maxTalentPoints = Number(
database.prepare("SELECT value FROM game_settings WHERE key = 'max_talent_points'").get()?.value ?? 25,
)
const maxExperience = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(maxLevel).experienceRequired
const classIds = database.prepare('SELECT id FROM classes ORDER BY id').all().map((row) => row.id)
const existingCharacters = database.prepare(`
SELECT
id,
class_id AS classId,
name
FROM characters
WHERE account_id = ?
ORDER BY class_id
`).all(accountId)
if (existingCharacters.length === 0) {
throw new Error('No character found for this account.')
}
const baseCharacterName = existingCharacters.find((candidate) => candidate.id === activeCharacterId)?.name
?? existingCharacters[0].name
const characterName = normalizeCharacterName(save.characterName, baseCharacterName)
const itemRows = database.prepare(`
SELECT id, slot
FROM items
`).all()
const itemSlots = new Map(itemRows.map((item) => [item.id, item.slot]))
const spellIdsByClass = new Map(
classIds.map((classId) => [
classId,
new Set(
database.prepare(`
SELECT id
FROM spells
WHERE class_id = ?
`).all(classId).map((spell) => spell.id),
),
]),
)
const talentRowsByClass = new Map(
classIds.map((classId) => [
classId,
database.prepare(`
SELECT
id,
max_rank AS maxRank
FROM talents
WHERE class_id = ?
`).all(classId),
]),
)
const charactersByClass = new Map(existingCharacters.map((character) => [character.classId, character]))
database.exec('BEGIN')
try {
for (const classId of classIds) {
if (!charactersByClass.has(classId)) {
const characterId = initializeCharacter(database, accountId, characterName, classId)
charactersByClass.set(classId, { id: characterId, classId, name: characterName })
}
}
database.prepare(`
UPDATE accounts
SET completed_dungeon_parts = ?, completed_raid_phases = ?
WHERE id = ?
`).run(
clampInteger(save.completedDungeonParts, 0, 0, 3),
clampInteger(save.completedRaidPhases, 0, 0, 3),
accountId,
)
const replaceSlot = database.prepare(`
INSERT INTO character_ability_slots (character_id, slot_number, spell_id)
VALUES (?, ?, ?)
`)
const insertTalent = database.prepare(`
INSERT INTO character_talents (character_id, talent_id, rank)
VALUES (?, ?, ?)
`)
const insertInventory = database.prepare(`
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, ?, ?)
`)
for (const classId of classIds) {
const local = save.characters[classId]
if (!local || typeof local !== 'object') continue
const characterId = charactersByClass.get(classId).id
database.prepare(`
UPDATE characters
SET name = ?, level = ?, experience = ?, talent_points = ?
WHERE id = ?
`).run(
characterName,
clampInteger(local.level, 1, 1, maxLevel),
clampInteger(local.experience, 0, 0, maxExperience),
clampInteger(local.talentPoints, 1, 0, maxTalentPoints),
characterId,
)
const rawSlots = Array.isArray(local.abilitySlots)
? local.abilitySlots.slice(0, 6)
: []
while (rawSlots.length < 6) rawSlots.push(null)
const validSpellIds = spellIdsByClass.get(classId) ?? new Set()
const seenSpellIds = new Set()
const normalizedSlots = rawSlots.map((value) => {
if (value === null) return null
const spellId = Number(value)
if (
!Number.isInteger(spellId)
|| !validSpellIds.has(spellId)
|| seenSpellIds.has(spellId)
) {
return null
}
seenSpellIds.add(spellId)
return spellId
})
database.prepare(`
DELETE FROM character_ability_slots
WHERE character_id = ?
`).run(characterId)
normalizedSlots.forEach((spellId, index) => {
replaceSlot.run(characterId, index + 1, spellId)
})
database.prepare(`
DELETE FROM character_talents
WHERE character_id = ?
AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
`).run(characterId, classId)
const localTalentRanks = local.talentRanks && typeof local.talentRanks === 'object'
? local.talentRanks
: {}
for (const talent of talentRowsByClass.get(classId) ?? []) {
const rank = clampInteger(localTalentRanks[String(talent.id)], 0, 0, talent.maxRank)
if (rank > 0) {
insertTalent.run(characterId, talent.id, rank)
}
}
database.prepare(`
DELETE FROM character_inventory
WHERE character_id = ?
`).run(characterId)
const inventoryByItemId = new Map()
const equippedSlots = new Set()
for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
const itemId = Number(item?.id)
const slot = itemSlots.get(itemId)
const quantity = clampInteger(item?.quantity, 0, 0, 9999)
if (!slot || quantity <= 0) continue
const current = inventoryByItemId.get(itemId) ?? { quantity: 0, equipped: false }
current.quantity = Math.min(9999, current.quantity + quantity)
if (
Boolean(item?.equipped)
&& slot !== 'component'
&& !equippedSlots.has(slot)
) {
current.equipped = true
equippedSlots.add(slot)
}
inventoryByItemId.set(itemId, current)
}
for (const [itemId, itemState] of inventoryByItemId) {
insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
}
}
let syncedClassId = clampInteger(
save.activeClassId,
existingCharacters[0]?.classId ?? 1,
classIds[0] ?? 1,
classIds[classIds.length - 1] ?? 1,
)
if (!charactersByClass.has(syncedClassId)) {
syncedClassId = existingCharacters[0]?.classId ?? 1
}
const syncedCharacterId = charactersByClass.get(syncedClassId)?.id ?? activeCharacterId
database.prepare(`
UPDATE sessions
SET active_character_id = ?
WHERE account_id = ?
`).run(syncedCharacterId, accountId)
database.exec('COMMIT')
return {
profile: getProfile(database, syncedCharacterId, accountId),
save: buildSyncSave(database, accountId, syncedCharacterId),
}
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
function itemById(database, itemId) {
return database.prepare(`
SELECT
@@ -1964,6 +2295,17 @@ export async function handleApiRequest(request, response, next) {
const session = requireSession(database, request)
if (request.url === '/api/profile/sync-save' && request.method === 'GET') {
sendJson(response, 200, buildSyncSave(database, session.accountId, session.characterId))
return
}
if (request.url === '/api/profile/sync-save' && request.method === 'PUT') {
const payload = await readJson(request, 512 * 1024)
sendJson(response, 200, importSyncSave(database, session.accountId, session.characterId, payload))
return
}
if (request.url === '/api/profile' && request.method === 'GET') {
sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
return
+621 -30
View File
@@ -310,6 +310,7 @@ textarea:focus-visible,
}
.binding-capture,
.dual-startup-prompt,
.controller-keyboard-backdrop {
align-items: center;
background: rgba(5, 6, 9, 0.88);
@@ -320,7 +321,8 @@ textarea:focus-visible,
z-index: 100;
}
.binding-capture > div {
.binding-capture > div,
.dual-startup-prompt > section {
background: var(--panel);
border: 3px solid #090a0d;
box-shadow: 8px 8px 0 #050609;
@@ -337,7 +339,29 @@ textarea:focus-visible,
margin: 18px 0;
}
.dual-startup-prompt p:not(.eyebrow) {
color: var(--muted);
font-size: 20px;
line-height: 1.2;
margin-top: 12px;
}
.dual-startup-prompt small {
color: var(--muted);
display: block;
font-size: 16px;
margin-top: 12px;
}
.dual-startup-prompt div {
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr;
margin-top: 22px;
}
.binding-capture button,
.dual-startup-prompt button,
.controller-keyboard button {
background: #242630;
border: 2px solid #090a0d;
@@ -351,6 +375,17 @@ textarea:focus-visible,
padding: 8px 24px;
}
.dual-startup-prompt button {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
min-height: 48px;
padding: 10px 14px;
}
.dual-startup-prompt button:first-child {
outline-color: var(--green);
}
.controller-keyboard {
background: var(--panel);
border: 3px solid #090a0d;
@@ -572,11 +607,12 @@ textarea:focus-visible,
.dual-top-party-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dual-top-party-grid.raid {
grid-template-rows: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-rows: repeat(3, minmax(0, 1fr));
}
.dual-top-member {
@@ -638,8 +674,34 @@ textarea:focus-visible,
vertical-align: middle;
}
.dual-top-party-grid.raid .dual-top-member {
min-height: 72px;
padding: 8px;
}
.dual-top-party-grid.raid .member-header {
gap: 5px;
}
.dual-top-party-grid.raid .member-header strong {
font-size: 16px;
}
.dual-top-party-grid.raid .member-header small {
display: none;
}
.dual-top-party-grid.raid .dual-top-member .bar {
height: 18px;
margin-top: 7px;
}
.dual-top-party-grid.raid .member-effects {
margin-top: 5px;
}
.dual-top-log {
display: flex;
display: none;
gap: 14px;
min-height: 36px;
overflow: hidden;
@@ -711,7 +773,7 @@ textarea:focus-visible,
display: grid;
gap: 10px;
height: calc(100dvh - 20px);
grid-template-rows: auto 1fr auto;
grid-template-rows: auto 1fr;
min-height: 0;
}
@@ -727,7 +789,7 @@ textarea:focus-visible,
}
.dual-top-main .dual-top-party-grid.raid {
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.dual-top-main .dual-top-member {
@@ -980,8 +1042,13 @@ textarea:focus-visible,
}
.game-shell {
display: flex;
flex-direction: column;
height: 100dvh;
width: min(1180px, calc(100% - 28px));
margin: 22px auto;
margin: 0 auto;
overflow: hidden;
padding: 12px 0;
position: relative;
}
@@ -1289,11 +1356,27 @@ h2 {
.menu-screen,
.content-screen,
.message-panel {
margin-top: 18px;
flex: 1;
margin-top: 12px;
min-height: 0;
overflow: hidden;
padding: 28px;
}
.content-screen {
display: flex;
flex-direction: column;
}
.menu-screen {
align-items: center;
display: flex;
}
.content-screen > .screen-heading {
flex: 0 0 auto;
}
.message-panel {
align-items: center;
display: flex;
@@ -1411,6 +1494,30 @@ h2 {
transform: translateY(-2px);
}
.cloud-sync-card {
cursor: default;
justify-content: space-between;
}
.cloud-sync-card:hover {
outline-color: #42414c;
transform: none;
}
.cloud-sync-card > div {
display: grid;
flex: 1;
gap: 6px;
}
.cloud-sync-card .text-button:disabled {
opacity: 0.7;
}
.cloud-sync-message {
color: var(--gold);
}
.menu-card > span,
.class-portrait {
align-items: center;
@@ -1466,6 +1573,130 @@ h2 {
outline-color: var(--gold);
}
.settings-tabs,
.talent-page-tabs {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 14px;
}
.settings-tabs button,
.talent-page-tabs button {
background: #15161c;
border: 2px solid #090a0d;
color: var(--muted);
cursor: pointer;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
min-height: 42px;
outline: 2px solid #41404a;
padding: 8px 10px;
text-transform: uppercase;
}
.settings-tabs button.selected,
.settings-tabs button:hover,
.talent-page-tabs button.active,
.talent-page-tabs button:hover {
color: var(--gold);
outline-color: var(--gold);
}
.settings-screen,
.equipment-screen,
.talent-screen,
.customize-screen {
height: calc(100dvh - 92px);
}
.settings-tab-panel {
flex: 1;
min-height: 0;
}
.settings-bindings-panel {
display: flex;
flex-direction: column;
overflow: hidden;
}
.settings-bindings-panel .settings-heading,
.settings-bindings-panel .binding-tabs,
.settings-bindings-panel .settings-footer {
flex: 0 0 auto;
}
.settings-bindings-panel .binding-list {
flex: 1;
min-height: 0;
}
.equipment-screen .gear-summary,
.equipment-screen .equipment-tabs,
.equipment-screen .item-comparison,
.equipment-screen .equipment-footer,
.talent-screen .talent-toolbar,
.talent-screen .talent-page-tabs,
.talent-screen .talent-footer {
flex: 0 0 auto;
}
.equipment-screen .equipment-layout,
.equipment-screen .crafting-panel,
.talent-screen .talent-tree {
flex: 1;
min-height: 0;
}
.talent-screen .talent-tree {
display: grid;
gap: 12px;
grid-template-rows: repeat(2, minmax(0, 1fr));
overflow: hidden;
}
.embedded-screen {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.customize-screen > .customize-tabs {
flex: 0 0 auto;
}
.customize-screen > .customize-layout,
.customize-screen > .embedded-screen {
flex: 1;
min-height: 0;
overflow: hidden;
}
.customize-screen .loadout-editor {
display: flex;
flex-direction: column;
min-height: 0;
}
.customize-screen .ability-library {
flex: 1;
min-height: 0;
}
.customize-screen .class-picker {
min-height: 0;
overflow: hidden;
}
.loot-preview-grid,
.leaderboard-table {
max-height: 360px;
overflow-y: auto;
}
.combat-header-actions {
align-items: center;
display: flex;
@@ -2341,6 +2572,13 @@ h2 {
padding: 15px;
}
.equipped-panel,
.inventory-panel {
display: flex;
flex-direction: column;
min-height: 0;
}
.equipment-tabs {
display: flex;
gap: 8px;
@@ -2382,6 +2620,8 @@ h2 {
display: grid;
gap: 8px;
margin-top: 13px;
min-height: 0;
overflow: hidden;
}
.equipment-slots > button {
@@ -2437,10 +2677,12 @@ h2 {
.inventory-list {
display: grid;
flex: 1;
gap: 8px;
margin-top: 13px;
max-height: 442px;
overflow-y: auto;
min-height: 0;
overflow: hidden;
padding: 2px;
}
@@ -2482,10 +2724,43 @@ h2 {
display: grid;
gap: 8px;
max-height: 360px;
overflow-y: auto;
overflow: hidden;
padding: 2px;
}
.list-pager {
align-items: center;
display: grid;
gap: 8px;
grid-template-columns: auto 1fr auto;
margin-top: 4px;
}
.list-pager button {
background: #15161c;
border: 2px solid #090a0d;
color: var(--gold);
cursor: pointer;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
min-height: 34px;
outline: 2px solid #41404a;
padding: 6px 10px;
}
.list-pager button:disabled {
color: var(--muted);
cursor: not-allowed;
opacity: 0.55;
}
.list-pager span {
color: var(--muted);
font-family: 'Press Start 2P', monospace;
font-size: 7px;
text-align: center;
}
.crafting-list > button {
align-items: center;
background: var(--panel-light);
@@ -3226,7 +3501,7 @@ h2 {
.party-grid {
display: grid;
gap: 11px;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 17px;
}
@@ -3243,11 +3518,12 @@ h2 {
}
.party-member:first-child {
grid-column: 1 / -1;
grid-column: auto;
}
.raid-party-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.raid-party-grid .party-member:first-child {
@@ -3379,6 +3655,39 @@ h2 {
top: 0;
}
.member-health .health-text {
color: var(--ink);
font-family: 'Press Start 2P', monospace;
font-size: 7px;
font-style: normal;
left: 50%;
line-height: 1;
pointer-events: none;
position: absolute;
text-shadow: 1px 1px #08090c;
top: 50%;
transform: translate(-50%, -50%);
white-space: nowrap;
z-index: 2;
}
.raid-party-grid .party-member {
min-height: 66px;
padding: 7px;
}
.raid-party-grid .member-header {
gap: 5px;
}
.raid-party-grid .member-header strong {
font-size: 18px;
}
.raid-party-grid .member-header small {
display: none;
}
.member-effects {
display: flex;
flex-wrap: wrap;
@@ -3724,6 +4033,8 @@ h2 {
display: flex;
inset: 0;
justify-content: center;
overflow-y: auto;
padding: 16px;
position: fixed;
z-index: 10;
}
@@ -3733,8 +4044,10 @@ h2 {
background: var(--panel);
border: 3px solid #0b0c0f;
box-shadow: 8px 8px 0 #050507;
max-height: calc(100dvh - 32px);
max-width: 520px;
outline: 2px solid var(--gold);
overflow-y: auto;
padding: 32px;
text-align: center;
}
@@ -3915,18 +4228,25 @@ h2 {
}
.pvp-match-screen {
gap: 16px;
gap: 0;
height: calc(100dvh - 24px);
margin-top: 0;
overflow: hidden;
padding: 8px;
}
.pvp-board {
display: grid;
gap: 16px;
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.8fr) minmax(0, 1fr);
gap: 8px;
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.68fr) minmax(0, 1fr);
min-height: 0;
}
.pvp-side,
.pvp-middle-panel {
gap: 12px;
gap: 8px;
min-height: 0;
padding: 8px;
}
.pvp-vertical-spell-bar,
@@ -3935,7 +4255,8 @@ h2 {
}
.pvp-vertical-spell-bar .spell {
min-height: 86px;
min-height: 58px;
padding: 6px;
}
.pvp-screen-tools {
@@ -3950,9 +4271,9 @@ h2 {
.pvp-resource-wrap {
color: #82bfff;
min-width: 220px;
min-width: 150px;
text-align: right;
width: min(240px, 100%);
width: min(170px, 100%);
}
.pvp-resource-wrap > span {
@@ -3966,16 +4287,93 @@ h2 {
min-width: 0;
}
.pvp-side .party-grid {
gap: 6px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 8px;
}
.pvp-side .pvp-party-grid.raid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.pvp-side .pvp-party-grid.raid .party-member {
min-height: 62px;
padding: 6px;
}
.pvp-side .pvp-party-grid.raid .member-header strong {
font-size: 16px;
}
.pvp-side .pvp-party-grid.raid .member-header small {
display: none;
}
.pvp-side .party-member {
min-height: 76px;
padding: 8px;
}
.pvp-side .party-member:first-child {
grid-column: auto;
}
.pvp-side .member-header {
gap: 5px;
}
.pvp-side .member-header strong {
font-size: 19px;
}
.pvp-side .member-header small {
font-size: 14px;
}
.pvp-side .bar {
height: 14px;
}
.pvp-side .member-effects {
margin-top: 4px;
}
.pvp-side .member-effects span {
font-size: 11px;
padding: 2px 4px;
}
.pvp-side .encounter-header .eyebrow {
display: none;
}
.pvp-enemy-race {
display: grid;
gap: 12px;
gap: 8px;
}
.pvp-middle-panel .encounter-header h2 {
font-size: 20px;
}
.pvp-middle-panel .encounter-header small,
.pvp-enemy-race small {
font-size: 14px;
}
.pvp-middle-panel .roguelike-upgrade-list,
.pvp-side .roguelike-upgrade-list {
font-size: 12px;
line-height: 1.1;
margin-top: 4px;
}
.pvp-choice-columns {
display: grid;
gap: 16px;
gap: 10px;
grid-template-columns: 1fr;
margin-top: 16px;
margin-top: 0;
}
.pvp-choice-columns > div > strong {
@@ -3989,8 +4387,8 @@ h2 {
.pvp-choice-columns .upgrade-choice-grid button {
background: #252833;
min-height: 120px;
padding: 14px;
min-height: 70px;
padding: 8px;
}
.pvp-leaderboard-row {
@@ -3999,20 +4397,31 @@ h2 {
.pvp-upgrade-dialog {
max-width: 1120px !important;
padding: 12px !important;
text-align: left !important;
width: min(1120px, calc(100vw - 32px));
}
.pvp-upgrade-dialog > p:not(.eyebrow) {
font-size: 18px !important;
margin-top: 8px !important;
}
.pvp-upgrade-dialog .pvp-choice-columns {
gap: 10px;
margin-top: 0;
}
.pvp-upgrade-dialog .upgrade-choice-grid strong {
color: #ffe8a5;
font-size: 11px;
line-height: 1.6;
font-size: 9px;
line-height: 1.25;
}
.pvp-upgrade-dialog .upgrade-choice-grid small {
color: #d3d9e6;
font-size: 16px;
line-height: 1.35;
font-size: 12px;
line-height: 1.15;
}
.pvp-upgrade-dialog .upgrade-choice-grid button.selected-upgrade {
@@ -4057,9 +4466,191 @@ h2 {
z-index: 1;
}
@media (min-width: 1000px) and (min-height: 900px) {
.game-shell {
width: min(1220px, calc(100% - 20px));
}
}
@media (min-width: 1000px) and (max-height: 1120px) {
.settings-screen,
.equipment-screen,
.talent-screen,
.customize-screen {
padding: 16px;
}
.settings-heading {
padding: 8px 0 12px;
}
.settings-heading > p,
.controller-preferences p:not(.eyebrow),
.dual-screen-settings p:not(.eyebrow) {
font-size: 16px;
}
.binding-tabs {
margin: 12px 0;
}
.binding-tabs button {
min-height: 38px;
}
.binding-list {
gap: 7px;
}
.binding-list button {
min-height: 39px;
padding: 6px 9px;
}
.binding-list button > span {
font-size: 16px;
}
.settings-footer {
margin-top: 12px;
padding-top: 10px;
}
.controller-preferences,
.dual-screen-settings {
margin-top: 14px;
padding: 14px;
}
.controller-icon-options {
grid-template-columns: minmax(120px, 1fr) repeat(3, minmax(118px, auto));
}
.gear-summary,
.talent-toolbar {
margin-top: 12px;
padding: 10px 12px;
}
.equipment-tabs,
.talent-page-tabs {
margin-top: 10px;
}
.equipment-tab {
min-height: 38px;
}
.item-comparison {
grid-template-columns: 1fr auto 1fr minmax(132px, 0.45fr);
margin-top: 10px;
min-height: 122px;
padding: 9px;
}
.item-detail {
padding: 9px;
}
.item-detail > p:not(.eyebrow),
.item-detail ul {
margin-top: 6px;
}
.equipment-layout {
gap: 12px;
margin-top: 10px;
}
.equipped-panel,
.inventory-panel,
.crafting-panel,
.set-bonus-panel {
padding: 10px;
}
.equipment-slots,
.inventory-list,
.crafting-list {
gap: 6px;
margin-top: 9px;
}
.equipment-slots > button,
.inventory-list > button,
.crafting-list > button {
min-height: 46px;
padding: 5px 7px;
}
.equipment-slots > button > span,
.inventory-list > button > span,
.crafting-list > button > span,
.item-title > span {
height: 31px;
}
.inventory-list,
.crafting-list {
max-height: none;
}
.crafting-panel {
display: flex;
flex-direction: column;
}
.crafting-filter-bar {
flex: 0 0 auto;
}
.crafting-layout {
flex: 1;
min-height: 0;
}
.crafting-list {
min-height: 0;
overflow: hidden;
}
.crafting-detail {
align-content: start;
overflow: hidden;
}
.talent-tree {
margin-top: 10px;
}
.talent-tier {
gap: 10px;
padding: 8px 0;
}
.talent-node {
min-height: 0;
padding: 8px;
}
.talent-node > p {
font-size: 14px;
line-height: 1;
margin-top: 6px;
}
.rank-pips {
margin: 6px 0;
}
.talent-footer {
padding-top: 10px;
}
}
@media (max-height: 720px) {
.game-shell {
margin: 6px auto;
padding: 6px 0;
width: min(1180px, calc(100% - 20px));
}
+70 -3
View File
@@ -19,7 +19,12 @@ import {
type AuthSession,
type CharacterProfile,
} from './profile'
import { getGameMode, type GameMode } from './gameRepository'
import {
getCloudSyncStatus,
getGameMode,
syncCloudSave,
type GameMode,
} from './gameRepository'
import { focusFirstControl } from './input.tsx'
type Screen =
@@ -40,8 +45,8 @@ const MENU_ITEMS: Array<{
glyph: string
description: string
}> = [
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a five-player party through dangerous encounters.' },
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide a ten-player party through three-phase challenges.' },
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a six-player party through dangerous encounters.' },
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' },
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
@@ -49,6 +54,7 @@ const MENU_ITEMS: Array<{
]
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
const SHOW_LEADERBOARDS = false
function activityInitials(name: string) {
return name
@@ -88,6 +94,8 @@ function App() {
const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence')
const [showLeaderboard, setShowLeaderboard] = useState(false)
const [error, setError] = useState('')
const [syncingCloud, setSyncingCloud] = useState(false)
const [syncMessage, setSyncMessage] = useState('')
useEffect(() => {
loadAuthSession()
@@ -105,6 +113,17 @@ function App() {
.finally(() => setAuthChecked(true))
}, [])
useEffect(() => {
const handleModeChange = (event: Event) => {
const nextMode = (event as CustomEvent<GameMode>).detail
setGameMode(nextMode)
}
window.addEventListener('chronicle:mode-changed', handleModeChange as EventListener)
return () => {
window.removeEventListener('chronicle:mode-changed', handleModeChange as EventListener)
}
}, [])
useEffect(() => {
if (screen === 'combat') return
window.requestAnimationFrame(() => {
@@ -138,11 +157,27 @@ function App() {
setProfile(null)
setGameMode(getGameMode())
setScreen('menu')
setSyncMessage('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
}
}
async function syncSaveNow() {
setSyncingCloud(true)
setSyncMessage('')
try {
const updated = await syncCloudSave()
setProfile(updated)
setGameMode(getGameMode())
setSyncMessage('Cloud save updated.')
} catch (reason) {
setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.')
} finally {
setSyncingCloud(false)
}
}
if (error) {
return (
<main className="game-shell">
@@ -253,6 +288,8 @@ function App() {
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
]
const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5
const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available
const lootPreviewEncounters = [...activity.encounters]
.filter((encounter) => encounter.isBoss)
.sort((a, b) => lootSort === 'boss'
@@ -285,6 +322,28 @@ function App() {
{screen === 'menu' && (
<section className="menu-screen">
<div className="main-menu-grid">
{canShowCloudSync && (
<div className="menu-card cloud-sync-card">
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
<div>
<strong>Cloud Save</strong>
<small>
{cloudSync.dirty
? 'Local progress waiting. Upload when you want to refresh the server copy.'
: 'Server copy matches this device.'}
</small>
{syncMessage && <small className="cloud-sync-message">{syncMessage}</small>}
</div>
<button
className="text-button"
disabled={syncingCloud || !cloudSync.dirty}
onClick={syncSaveNow}
type="button"
>
{syncingCloud ? 'Syncing...' : cloudSync.dirty ? 'Sync Save To Server' : 'Already Synced'}
</button>
</div>
)}
{MENU_ITEMS.map((item) => (
<button
className="menu-card"
@@ -457,6 +516,7 @@ function App() {
Start Match
</button>
</div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section">
<div className="equipment-heading toggle-heading">
<div>
@@ -488,6 +548,7 @@ function App() {
)}
</div>
</div>
)}
</>
)}
</section>
@@ -663,6 +724,7 @@ function App() {
</>
)}
</div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section">
<div className="equipment-heading toggle-heading">
<div>
@@ -682,6 +744,8 @@ function App() {
<p className="section-note">
{gameMode === 'offline'
? 'Offline runs are not submitted'
: canShowCloudSync
? 'Manual save sync updates your cloud profile.'
: 'Lowest resource spent ranks first'}
</p>
<div className="leaderboard-tabs">
@@ -730,6 +794,8 @@ function App() {
<div className="leaderboard-empty">
{gameMode === 'offline'
? 'Connect with an online character to compete in rankings.'
: canShowCloudSync
? 'No leaderboard entries yet.'
: 'Complete this difficulty to claim the first ranking.'}
</div>
)}
@@ -737,6 +803,7 @@ function App() {
</>
)}
</div>
)}
</section>
)}
+1 -1
View File
@@ -156,7 +156,7 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
<h2>Play Offline</h2>
<p>
No account or connection required. Offline progress stays on
this device and is excluded from online leaderboards.
this device.
</p>
</div>
{offlineCharacterExists && (
+27 -19
View File
@@ -241,7 +241,7 @@ function makeRoguelikeSegment(
encounter.maxHealth
+ encounter.damage * 18
+ encounter.tankDamage * 10
+ encounter.partyDamage * 12
+ encounter.partyDamage * 18
)
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
@@ -331,7 +331,7 @@ export function CombatScreen({
)
const maxResource = gameClass.maxResource + (isRoguelike ? 0 : profile.gearStats.maxResourceBonus)
const partyTemplate = useMemo(
() => (dungeon.partySize === 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
() => (dungeon.partySize >= 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
...member,
name: member.id === 'mira' ? profile.character.name : member.name,
})),
@@ -346,10 +346,10 @@ export function CombatScreen({
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
const [enemyHealth, setEnemyHealth] = useState(encounters[initialEncounterIndex].maxHealth)
const [cooldowns, setCooldowns] = useState<Record<string, number>>({})
const [elapsedTicks, setElapsedTicks] = useState(0)
const [, setElapsedTicks] = useState(0)
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1>(0)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const [log, setLog] = useState<CombatLogEntry[]>([
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
])
@@ -373,6 +373,7 @@ export function CombatScreen({
const nextFloatingTextId = useRef(1)
const partyRef = useRef(partyTemplate)
const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth)
const elapsedTicksRef = useRef(0)
const encounter = encounters[encounterIndex]
const currentPart = getCurrentPart(encounterIndex)
const firstEncounterIndex = (startPart - 1) * 3
@@ -471,6 +472,7 @@ export function CombatScreen({
setEncounterIndex(initialEncounterIndex)
setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth)
setCooldowns({})
elapsedTicksRef.current = 0
setElapsedTicks(0)
setStatus('playing')
setPaused(false)
@@ -670,7 +672,7 @@ export function CombatScreen({
}, [selectedId])
const selectDirectionalTarget = useCallback((action: InputAction) => {
const columns = dungeon.partySize === 10 ? 5 : 3
const columns = dungeon.partySize >= 10 ? 6 : 3
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedId)
if (currentIndex < 0) {
setSelectedId(partyRef.current[0].id)
@@ -711,7 +713,7 @@ export function CombatScreen({
}, [dungeon.partySize, selectedId])
const selectDirectTarget = useCallback((slot: number) => {
const index = slot + (dungeon.partySize === 10 ? targetGroup * 5 : 0)
const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
const member = partyRef.current[index]
if (member) setSelectedId(member.id)
}, [dungeon.partySize, targetGroup])
@@ -748,6 +750,7 @@ export function CombatScreen({
setParty(recoveredParty)
setEncounterIndex((current) => current + 1)
setEnemyHealth(nextEncounter.maxHealth)
elapsedTicksRef.current = 0
setElapsedTicks(0)
setCooldowns({})
setResource((value) => clamp(value + Math.round(maxResource * 0.25), 0, maxResource))
@@ -771,11 +774,12 @@ export function CombatScreen({
return
}
if (action === 'toggleTargetGroup') {
if (dungeon.partySize !== 10) return
if (dungeon.partySize <= 6) return
setTargetGroup((current) => {
const next = current === 0 ? 1 : 0
const groupCount = Math.max(1, Math.ceil(partyRef.current.length / 6))
const next = ((current + 1) % groupCount) as 0 | 1 | 2
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedId)
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 5) + next * 5]
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
if (nextMember) setSelectedId(nextMember.id)
return next
})
@@ -798,7 +802,9 @@ export function CombatScreen({
useEffect(() => {
if (status !== 'playing' || paused) return
const timer = window.setInterval(() => {
setElapsedTicks((value) => value + 1)
const nextElapsedTicks = elapsedTicksRef.current + 1
elapsedTicksRef.current = nextElapsedTicks
setElapsedTicks(nextElapsedTicks)
setResource((value) => clamp(value + 2.4, 0, maxResource))
setCooldowns((current) =>
Object.fromEntries(
@@ -820,19 +826,19 @@ export function CombatScreen({
const primaryTarget = living[Math.floor(Math.random() * living.length)]
const mechanics = (encounter as RoguelikeEncounter).roguelikeMechanics ?? []
const useDefaultBossMechanics = encounter.isBoss && mechanics.length === 0
const bossPulse = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 7 === 0
const bossPulse = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 7 === 0
&& (useDefaultBossMechanics || mechanics.includes('party-pulse'))
const appliesDebuff = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 11 === 0
const appliesDebuff = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 11 === 0
&& (useDefaultBossMechanics || mechanics.includes('searing-mark'))
const appliesMaxHealthCut = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 13 === 0
const appliesMaxHealthCut = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 13 === 0
&& mechanics.includes('max-health-cut')
const appliesHealingReduction = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0
const appliesHealingReduction = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 9 === 0
&& mechanics.includes('healing-reduction')
const tankBuster = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 8 === 0
const tankBuster = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 8 === 0
&& mechanics.includes('tank-buster')
const resourceDrain = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 10 === 0
const resourceDrain = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 10 === 0
&& mechanics.includes('resource-drain')
const appliesPoison = encounter.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0
const appliesPoison = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 12 === 0
&& mechanics.includes('ramping-poison')
if (bossPulse) addLog(`${encounter.enemyName} unleashes party-wide damage.`, 'danger')
if (appliesDebuff) addLog(`${primaryTarget.name} is afflicted by Searing Mark.`, 'danger')
@@ -957,6 +963,7 @@ export function CombatScreen({
setParty(recoveredParty)
setEncounterIndex((value) => value + 1)
setEnemyHealth(nextEncounter.maxHealth)
elapsedTicksRef.current = 0
setElapsedTicks(0)
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
}, TICK_MS)
@@ -965,7 +972,6 @@ export function CombatScreen({
addLog,
addFloatingHeal,
difficulty.damageMultiplier,
elapsedTicks,
encounter,
encounterIndex,
encounters,
@@ -1123,7 +1129,7 @@ export function CombatScreen({
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
</div>
</div>
<div className={`party-grid ${dungeon.partySize === 10 ? 'raid-party-grid' : ''}`}>
<div className={`party-grid ${dungeon.partySize >= 10 ? 'raid-party-grid' : ''}`}>
{party.map((member) => (
<button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
@@ -1146,6 +1152,7 @@ export function CombatScreen({
<div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</em>
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts
@@ -1395,6 +1402,7 @@ export function CombatScreen({
setParty(recoveredParty)
setEncounterIndex(nextIndex)
setEnemyHealth(nextEncounter.maxHealth)
elapsedTicksRef.current = 0
setElapsedTicks(0)
setStatus('playing')
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
+84 -5
View File
@@ -22,6 +22,9 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
component: 'Component',
}
const EQUIPMENT_LIST_PAGE_SIZE = 3
const CRAFTING_LIST_PAGE_SIZE = 6
type Props = {
profile: CharacterProfile
onBack?: () => void
@@ -45,6 +48,8 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [crafting, setCrafting] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
const [inventoryPage, setInventoryPage] = useState(0)
const [recipePage, setRecipePage] = useState(0)
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
@@ -75,6 +80,14 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
(total, item) => total + item.quantity,
0,
)
const inventoryPageCount = Math.max(
1,
Math.ceil(visibleInventory.length / EQUIPMENT_LIST_PAGE_SIZE),
)
const inventoryPageItems = visibleInventory.slice(
inventoryPage * EQUIPMENT_LIST_PAGE_SIZE,
(inventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE,
)
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
const [levelFilter, setLevelFilter] = useState<number | null>(null)
@@ -92,11 +105,27 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
},
[profile.craftingRecipes, slotFilter, levelFilter],
)
const recipePageCount = Math.max(
1,
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
)
const recipePageItems = filteredRecipes.slice(
recipePage * CRAFTING_LIST_PAGE_SIZE,
(recipePage + 1) * CRAFTING_LIST_PAGE_SIZE,
)
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
useEffect(() => {
setInventoryPage((current) => Math.min(current, inventoryPageCount - 1))
}, [inventoryPageCount])
useEffect(() => {
setRecipePage((current) => Math.min(current, recipePageCount - 1))
}, [recipePageCount])
useEffect(() => {
if (equipmentTab === 'crafting') {
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
@@ -270,6 +299,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
key={slot}
onClick={() => {
setSelectedSlot(slot)
setInventoryPage(0)
const firstSlotItem = profile.inventory.find(
(candidate) => candidate.slot === slot,
)
@@ -302,14 +332,17 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
{selectedSlot && (
<button
className="inventory-filter-clear"
onClick={() => setSelectedSlot(null)}
onClick={() => {
setSelectedSlot(null)
setInventoryPage(0)
}}
type="button"
>
Show All Items
</button>
)}
<div className="inventory-list">
{visibleInventory.map((item) => (
{inventoryPageItems.map((item) => (
<button
className={`${selectedItemId === item.id ? 'selected' : ''} rarity-${item.rarity}`}
key={item.id}
@@ -333,6 +366,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
</p>
)}
</div>
{visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && (
<ListPager
label={`Page ${inventoryPage + 1} / ${inventoryPageCount}`}
onNext={() => setInventoryPage((current) => Math.min(inventoryPageCount - 1, current + 1))}
onPrevious={() => setInventoryPage((current) => Math.max(0, current - 1))}
nextDisabled={inventoryPage >= inventoryPageCount - 1}
previousDisabled={inventoryPage <= 0}
/>
)}
</section>
</div>
</>
@@ -347,7 +389,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<select
className="filter-select"
value={slotFilter}
onChange={(e) => setSlotFilter(e.target.value as EquipmentSlot | 'all')}
onChange={(e) => {
setSlotFilter(e.target.value as EquipmentSlot | 'all')
setRecipePage(0)
}}
>
<option value="all">All Slots</option>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
@@ -357,7 +402,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<select
className="filter-select"
value={levelFilter ?? ''}
onChange={(e) => setLevelFilter(e.target.value === '' ? null : Number(e.target.value))}
onChange={(e) => {
setLevelFilter(e.target.value === '' ? null : Number(e.target.value))
setRecipePage(0)
}}
>
<option value="">All Levels</option>
{availableLevels.map((level) => (
@@ -371,7 +419,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
{filteredRecipes.length > 0 && (
<div className="crafting-layout">
<div className="crafting-list">
{filteredRecipes.map((recipe) => (
{recipePageItems.map((recipe) => (
<button
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
key={recipe.id}
@@ -389,6 +437,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
</button>
))}
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
<ListPager
label={`Page ${recipePage + 1} / ${recipePageCount}`}
onNext={() => setRecipePage((current) => Math.min(recipePageCount - 1, current + 1))}
onPrevious={() => setRecipePage((current) => Math.max(0, current - 1))}
nextDisabled={recipePage >= recipePageCount - 1}
previousDisabled={recipePage <= 0}
/>
)}
</div>
{selectedRecipe && (
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
@@ -466,6 +523,28 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
)
}
function ListPager({
label,
nextDisabled,
previousDisabled,
onNext,
onPrevious,
}: {
label: string
nextDisabled: boolean
previousDisabled: boolean
onNext: () => void
onPrevious: () => void
}) {
return (
<div className="list-pager">
<button disabled={previousDisabled} onClick={onPrevious} type="button">Prev</button>
<span>{label}</span>
<button disabled={nextDisabled} onClick={onNext} type="button">Next</button>
</div>
)
}
function GearStat({ value, label }: { value: string; label: string }) {
return (
<div className="gear-stat">
+201 -60
View File
@@ -4,7 +4,13 @@ import { completeRoguelike, type DungeonReward } from '../profile'
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
import { ControllerBindingLabel } from './ControllerIcons'
import { useGameAction, useInput, type InputAction } from '../input'
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
import {
DualScreenTopCombat,
useDualScreen,
useDualScreenPublisher,
type DualScreenCombatState,
} from '../dualScreen'
import {
randomCpuDifficulty,
recordCpuPvpLeaderboard,
@@ -238,7 +244,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
encounter.maxHealth
+ encounter.damage * 18
+ encounter.tankDamage * 10
+ encounter.partyDamage * 12
+ encounter.partyDamage * 18
)
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
.sort((left, right) => encounterThreat(left) - encounterThreat(right))
@@ -366,7 +372,7 @@ export function PvPRoguelikeScreen({
.filter((spell) => spell.unlockLevel === 1)
.slice(0, 5)
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
const [abilityLabelMode, setAbilityLabelMode] = useState<AbilityLabelMode>('ability')
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
const selfBuffChoicesCatalog = useMemo(
() => buildSelfBuffChoices(starterSpells, abilityLabelMode),
[abilityLabelMode, starterSpells],
@@ -410,10 +416,14 @@ export function PvPRoguelikeScreen({
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const nextLogId = useRef(2)
const nextFloatingTextId = useRef(1)
const recordedRunRef = useRef(false)
const rewardClaimedRef = useRef(false)
const bossRewardClaimedRef = useRef(new Set<number>())
const cpuDefeatedRef = useRef(false)
const playerClearedEncounterRef = useRef(-1)
const playerRef = useRef(playerSide)
const cpuRef = useRef(cpuSide)
@@ -431,11 +441,16 @@ export function PvPRoguelikeScreen({
const cpuDone = cpuSide.enemyHealth <= 0
const playerAlive = playerSide.party.some((member) => member.health > 0)
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
const partyColumns = contentType === 'raid' ? 6 : 3
const {
bindings,
controllerIconStyle,
directPartyTargeting,
lastDevice,
} = useInput()
const {
enabled: dualScreenEnabled,
} = useDualScreen()
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
}, [])
@@ -449,18 +464,17 @@ export function PvPRoguelikeScreen({
}, 900)
}, [])
const finishRoguelikeRun = useCallback((cleared: number) => {
if (rewardClaimedRef.current) return
rewardClaimedRef.current = true
const bossesCleared = Math.floor(cleared / 3)
const awardBossReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue)
completeRoguelike(
rewardDungeon.id,
rewardDifficulty.id,
cleared,
0,
0,
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
{
bossesCleared,
bossesCleared: 1,
experienceMode: 'pvp-boss-quarter-level',
},
)
@@ -475,6 +489,11 @@ export function PvPRoguelikeScreen({
})
}, [elapsedTicks, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])
const finishRoguelikeRun = useCallback(() => {
if (rewardClaimedRef.current) return
rewardClaimedRef.current = true
}, [])
useEffect(() => {
setPlayerBuffChoices((current) => current
.map((choice) => selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
@@ -501,6 +520,7 @@ export function PvPRoguelikeScreen({
cpuRef.current = baseCpu
nextLogId.current = 2
playerClearedEncounterRef.current = -1
bossRewardClaimedRef.current = new Set()
setEncounters(firstSegment)
setEncounterIndex(0)
setStage(1)
@@ -514,6 +534,8 @@ export function PvPRoguelikeScreen({
setSelectedBuff(null)
setSelectedDebuff(null)
setEncountersCleared(0)
setPaused(false)
setTargetGroup(0)
setReward(null)
setRewardError('')
setShowEndLog(false)
@@ -521,6 +543,7 @@ export function PvPRoguelikeScreen({
setCpuDifficulty(null)
recordedRunRef.current = false
rewardClaimedRef.current = false
cpuDefeatedRef.current = false
if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty()
setQueueMessage(`Offline mode. CPU ${randomCpu} enters immediately.`)
@@ -659,10 +682,45 @@ export function PvPRoguelikeScreen({
setSelectedId(living[nextIndex].id)
}, [selectedId])
const selectDirectionalTarget = useCallback((action: InputAction) => {
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
if (currentIndex < 0) {
const firstLiving = playerRef.current.party.find((member) => member.health > 0)
if (firstLiving) setSelectedId(firstLiving.id)
return
}
const currentRow = Math.floor(currentIndex / partyColumns)
const currentColumn = currentIndex % partyColumns
const candidates = playerRef.current.party
.map((member, index) => ({
member,
index,
row: Math.floor(index / partyColumns),
column: index % partyColumns,
}))
.filter(({ member, index, row, column }) => {
if (member.health <= 0 || index === currentIndex) return false
if (action === 'navigateLeft') return row === currentRow && column < currentColumn
if (action === 'navigateRight') return row === currentRow && column > currentColumn
if (action === 'navigateUp') return row < currentRow
return row > currentRow
})
.sort((a, b) => {
const horizontal = action === 'navigateLeft' || action === 'navigateRight'
const aPrimary = horizontal ? Math.abs(a.column - currentColumn) : Math.abs(a.row - currentRow)
const bPrimary = horizontal ? Math.abs(b.column - currentColumn) : Math.abs(b.row - currentRow)
const aSecondary = horizontal ? 0 : Math.abs(a.column - currentColumn)
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
return aPrimary - bPrimary || aSecondary - bSecondary
})
if (candidates[0]) setSelectedId(candidates[0].member.id)
}, [partyColumns, selectedId])
const selectDirectTarget = useCallback((slot: number) => {
const member = playerRef.current.party[slot]
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
const member = playerRef.current.party[index]
if (member?.health > 0) setSelectedId(member.id)
}, [])
}, [contentType, targetGroup])
const cpuTakeTurn = useCallback(() => {
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
@@ -774,7 +832,7 @@ export function PvPRoguelikeScreen({
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
useEffect(() => {
if (status !== 'playing' || !encounter) return
if (status !== 'playing' || paused || !encounter) return
const timer = window.setInterval(() => {
setElapsedTicks((value) => value + 1)
cpuTakeTurn()
@@ -783,6 +841,7 @@ export function PvPRoguelikeScreen({
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
playerClearedEncounterRef.current = encounterIndex
setEncountersCleared((value) => value + 1)
if (encounter.isBoss) awardBossReward(encounterIndex)
}
playerRef.current = nextPlayer
cpuRef.current = nextCpu
@@ -791,28 +850,23 @@ export function PvPRoguelikeScreen({
const nextPlayerAlive = nextPlayer.party.some((member) => member.health > 0)
const nextCpuAlive = nextCpu.party.some((member) => member.health > 0)
const clearedCount = nextPlayer.enemyHealth <= 0
? Math.max(encountersCleared, encounterIndex + 1)
: encountersCleared
if (!nextPlayerAlive) {
finishRoguelikeRun(clearedCount)
finishRoguelikeRun()
setStatus('lost')
addLog('Your party fell first.', 'danger')
return
}
if (!nextCpuAlive) {
finishRoguelikeRun(clearedCount)
setStatus('won')
addLog(`CPU ${cpuDifficulty ?? 1} fell first.`, 'loot')
return
if (!nextCpuAlive && !cpuDefeatedRef.current) {
cpuDefeatedRef.current = true
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
}
if (nextPlayer.enemyHealth <= 0 && nextCpu.enemyHealth <= 0) {
addLog(`${encounter.enemyName} cleared by both teams. Choose your next edge.`, 'loot')
if (nextPlayer.enemyHealth <= 0) {
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
beginUpgradePhase()
}
}, TICK_MS)
return () => window.clearInterval(timer)
}, [addLog, advanceSide, beginUpgradePhase, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, status])
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, status])
useEffect(() => {
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
@@ -828,6 +882,16 @@ export function PvPRoguelikeScreen({
})
}, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])
useEffect(() => {
if (status !== 'upgrade-choice') return
window.requestAnimationFrame(() => focusFirstControl())
}, [status])
useEffect(() => {
if (!paused) return
window.requestAnimationFrame(() => focusFirstControl())
}, [paused])
const confirmUpgradeChoices = useCallback(() => {
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
@@ -912,7 +976,15 @@ export function PvPRoguelikeScreen({
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
useGameAction((action) => {
if (status !== 'playing') return
if (action === 'pause' || action === 'back') {
if (status === 'playing') setPaused((value) => !value)
return
}
if (paused || status !== 'playing') return
if (action.startsWith('navigate')) {
selectDirectionalTarget(action)
return
}
if (action === 'previousTarget') {
selectRelativeTarget(-1)
return
@@ -925,41 +997,93 @@ export function PvPRoguelikeScreen({
selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
return
}
if (action === 'toggleTargetGroup') {
if (contentType !== 'raid') return
setTargetGroup((current) => {
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
const next = ((current + 1) % groupCount) as 0 | 1 | 2
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
if (nextMember?.health > 0) setSelectedId(nextMember.id)
return next
})
return
}
if (action.startsWith('ability')) {
const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
if (spell) castPlayerSpell(spell)
}
})
return (
<main className="game-shell" data-combat-active={status === 'playing' ? 'true' : 'false'}>
<section className="content-screen pvp-match-screen">
<div className="screen-heading">
<div>
<p className="eyebrow">PvP Roguelike</p>
<h1>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h1>
</div>
<div className="pvp-screen-tools">
<div className="roguelike-timing-row">
<button
className={`text-button ${abilityLabelMode === 'ability' ? 'active' : ''}`}
onClick={() => setAbilityLabelMode('ability')}
type="button"
>
Ability Names
</button>
<button
className={`text-button ${abilityLabelMode === 'slot' ? 'active' : ''}`}
onClick={() => setAbilityLabelMode('slot')}
type="button"
>
Slot Names
</button>
</div>
<button className="back-button" onClick={onExit} type="button">Leave</button>
</div>
</div>
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
difficultyName: `Stage ${stage}`,
dungeonName: encounter.enemyName,
contentName: 'PvP Roguelike',
encounterName: encounter.enemyName,
encounterDescription: encounter.description,
encounterHealth: playerSide.enemyHealth,
encounterMaxHealth: encounter.maxHealth,
encounterIsBoss: encounter.isBoss,
encounterIndex,
encounterCount: encounters.length,
party: playerSide.party,
partySize: playerSide.party.length,
selectedId,
log,
status: status === 'queueing' ? 'playing' : status,
resource: playerSide.resource,
maxResource,
resourceName: gameClass.resourceName,
playerIsAlive: playerAlive,
spells: starterSpells.map((spell, slotIndex) => ({
...spell,
cost: spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady),
slotIndex,
remaining: playerSide.cooldowns[spell.id] ?? 0,
})),
activeDevice: lastDevice,
bindings: bindings[lastDevice],
controllerIconStyle,
directPartyTargeting,
paused,
targetGroup,
}), [
bindings,
controllerIconStyle,
directPartyTargeting,
encounter.description,
encounter.enemyName,
encounter.isBoss,
encounter.maxHealth,
encounterIndex,
encounters.length,
gameClass.resourceName,
lastDevice,
log,
maxResource,
paused,
playerAlive,
playerSide.buffs,
playerSide.cooldowns,
playerSide.debuffs,
playerSide.enemyHealth,
playerSide.freeCastReady,
playerSide.party,
playerSide.resource,
selectedId,
stage,
starterSpells,
status,
targetGroup,
])
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
return (
<main
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
>
<section className="content-screen pvp-match-screen">
{status === 'queueing' && (
<div className="placeholder-panel">
<div className="placeholder-runes">P V P</div>
@@ -967,7 +1091,14 @@ export function PvPRoguelikeScreen({
</div>
)}
{status !== 'queueing' && (
{dualScreenEnabled && status !== 'queueing' && (
<DualScreenTopCombat
state={dualScreenState}
onSelectTarget={setSelectedId}
/>
)}
{!dualScreenEnabled && status !== 'queueing' && (
<div className="pvp-board">
<section className="combat-panel pvp-side">
<div className="encounter-header">
@@ -982,7 +1113,7 @@ export function PvPRoguelikeScreen({
</div>
</div>
</div>
<div className="party-grid">
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
{playerSide.party.map((member) => (
<button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
@@ -998,6 +1129,7 @@ export function PvPRoguelikeScreen({
<div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts
@@ -1087,7 +1219,7 @@ export function PvPRoguelikeScreen({
</div>
</div>
</div>
<div className="party-grid">
<div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
{cpuSide.party.map((member) => (
<div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={`cpu-${member.id}`}>
<div className="member-header">
@@ -1098,6 +1230,7 @@ export function PvPRoguelikeScreen({
<div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
<em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts
@@ -1125,9 +1258,6 @@ export function PvPRoguelikeScreen({
{status === 'upgrade-choice' && (
<div className="result-screen">
<div className="pvp-upgrade-dialog">
<p className="eyebrow">Round Cleared</p>
<h2>Choose Your Edge</h2>
<p>Take 1 buff for yourself and 1 debuff for the CPU.</p>
<div className="pvp-choice-columns">
<div>
<strong>Self Buff</strong>
@@ -1169,6 +1299,17 @@ export function PvPRoguelikeScreen({
</div>
)}
{paused && (
<div className="pause-screen">
<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>
</div>
</div>
)}
{(status === 'won' || status === 'lost') && (
<div className="result-screen">
<div>
@@ -1176,7 +1317,7 @@ export function PvPRoguelikeScreen({
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
<p>{finalEncountersCleared} encounters cleared.</p>
<div className="reward-summary">
{!reward && !rewardError && <p>Recording roguelike progress...</p>}
{!reward && !rewardError && <p>Boss kills grant XP immediately.</p>}
{rewardError && <p className="reward-error">{rewardError}</p>}
{reward && (
<>
+41 -12
View File
@@ -24,6 +24,7 @@ import {
export function SettingsScreen({ onBack }: { onBack: () => void }) {
const [device, setDevice] = useState<InputDevice>('controller')
const [settingsTab, setSettingsTab] = useState<'display' | 'input' | 'bindings'>('display')
const [displayMessage, setDisplayMessage] = useState('')
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
const {
@@ -50,6 +51,7 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
])
const visibleActions = INPUT_ACTIONS.filter((action) => (
@@ -95,7 +97,27 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
<section className="dual-screen-settings">
<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)}
role="tab"
type="button"
>
{tab.label}
</button>
))}
</nav>
{settingsTab === 'display' && (
<section className="dual-screen-settings settings-tab-panel">
<div>
<p className="eyebrow">Display</p>
<h2>AYN Thor Dual-Screen Mode</h2>
@@ -131,29 +153,23 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
{androidDisplays.map((display) => (
<span key={display.id}>
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
{display.width}×{display.height} at {Math.round(display.refreshRate)} Hz
{display.width}x{display.height} at {Math.round(display.refreshRate)} Hz
{display.isPresentation ? ' - Presentation' : ''}
</span>
))}
</div>
)}
</section>
)}
<div className="settings-heading">
<div>
<p className="eyebrow">Input</p>
<h2>Keybindings</h2>
</div>
<p>Select an action, then press the new key or controller control.</p>
</div>
<section className="controller-preferences">
{settingsTab === 'input' && (
<section className="controller-preferences settings-tab-panel">
<div>
<p className="eyebrow">Targeting</p>
<h3>Direct Party Keybinds</h3>
<p>
Assign party slots directly. In raids, use the group-switch binding
to alternate between members 1-5 and 6-10.
to alternate between members 1-6, 7-12, and 13-18.
</p>
</div>
<button
@@ -180,6 +196,17 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
))}
</div>
</section>
)}
{settingsTab === 'bindings' && (
<section className="settings-bindings-panel settings-tab-panel">
<div className="settings-heading">
<div>
<p className="eyebrow">Input</p>
<h2>Keybindings</h2>
</div>
<p>Select an action, then press the new key or controller control.</p>
</div>
<div className="binding-tabs">
<button
@@ -227,6 +254,8 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
</button>
</footer>
</section>
)}
{capture && (
<div className="binding-capture" role="dialog" aria-modal="true">
+22 -1
View File
@@ -15,6 +15,7 @@ type Props = {
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
const [talentPage, setTalentPage] = useState(0)
const [resetting, setResetting] = useState(false)
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
@@ -28,6 +29,11 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
const tiers = Array.from(
new Set(gameClass.talents.map((talent) => talent.tier)),
).sort((a, b) => a - b)
const tierPages = Array.from(
{ length: Math.ceil(tiers.length / 2) },
(_, index) => tiers.slice(index * 2, index * 2 + 2),
)
const visibleTiers = tierPages[talentPage] ?? tierPages[0] ?? []
useEffect(() => {
window.scrollTo(0, scrollRef.current)
@@ -123,8 +129,23 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
</div>
</div>
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages">
{tierPages.map((pageTiers, index) => (
<button
aria-selected={talentPage === index}
className={talentPage === index ? 'active' : ''}
key={pageTiers.join('-')}
onClick={() => setTalentPage(index)}
role="tab"
type="button"
>
Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]}
</button>
))}
</nav>
<div className="talent-tree">
{tiers.map((tier) => {
{visibleTiers.map((tier) => {
const requiredPoints = (tier - 1) * 5
return (
<section className="talent-tier" key={tier}>
+80 -13
View File
@@ -11,6 +11,7 @@ import {
} from 'react'
import type { CombatLogEntry, PartyMember, Spell } from './game'
import {
getNativeDisplays,
hasNativeDualScreenBridge,
openNativeTopDisplay,
} from './nativeDualScreen'
@@ -23,6 +24,7 @@ import { ControllerBindingLabel } from './components/ControllerIcons'
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
const SNAPSHOT_KEY = 'ashen-halls-dual-screen-snapshot'
const STARTUP_CHOICE_KEY = 'ashen-halls-dual-screen-startup-choice'
const CHANNEL_NAME = 'ashen-halls-dual-screen'
export type DualScreenCombatState = {
@@ -51,7 +53,7 @@ export type DualScreenCombatState = {
controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean
paused: boolean
targetGroup: 0 | 1
targetGroup: 0 | 1 | 2
}
type DualScreenMessage =
@@ -172,6 +174,73 @@ export function useDualScreen() {
return context
}
export function DualScreenStartupPrompt() {
const { openTopDisplay, setEnabled } = useDualScreen()
const [visible, setVisible] = useState(false)
const [displayCount, setDisplayCount] = useState<number | null>(null)
const [message, setMessage] = useState('')
const autoOpenedRef = useRef(false)
useEffect(() => {
if (!hasNativeDualScreenBridge()) return
if (new URLSearchParams(window.location.search).has('display')) return
const choice = localStorage.getItem(STARTUP_CHOICE_KEY)
if (choice === 'yes') {
if (autoOpenedRef.current) return
autoOpenedRef.current = true
openTopDisplay().catch(() => {
// Settings can still launch the display manually if Android rejects startup launch.
})
return
}
if (choice === 'no') return
getNativeDisplays()
.then((result) => setDisplayCount(result.displays.length))
.catch(() => setDisplayCount(null))
.finally(() => setVisible(true))
}, [openTopDisplay])
async function enableDualScreen() {
localStorage.setItem(STARTUP_CHOICE_KEY, 'yes')
setMessage('Opening second display...')
const opened = await openTopDisplay()
if (opened) {
setVisible(false)
return
}
setMessage('No second display found. Check Thor display mode, then try again.')
}
function skipDualScreen() {
localStorage.setItem(STARTUP_CHOICE_KEY, 'no')
setEnabled(false)
setVisible(false)
}
if (!visible) return null
return (
<div className="dual-startup-prompt" role="dialog" aria-modal="true">
<section>
<p className="eyebrow">Display Setup</p>
<h2>Use Dual-Screen Mode?</h2>
<p>
Choose yes on AYN Thor. The game opens the combat view on the upper
display and keeps controls on the lower display.
</p>
{displayCount !== null && (
<small>{displayCount} Android display{displayCount === 1 ? '' : 's'} detected.</small>
)}
{message && <small>{message}</small>}
<div>
<button onClick={enableDualScreen} type="button">Yes, Enable</button>
<button onClick={skipDualScreen} type="button">No</button>
</div>
</section>
</div>
)
}
export function useDualScreenPublisher(
state: DualScreenCombatState,
enabled: boolean,
@@ -280,9 +349,9 @@ export function DualScreenBottomDisplay() {
<div className={`dual-controls-targets ${state.directPartyTargeting ? 'direct' : ''}`}>
{state.directPartyTargeting ? (
<>
{([1, 2, 3, 4, 5] as const).map((slot) => {
{([1, 2, 3, 4, 5, 6] as const).map((slot) => {
const action = `targetParty${slot}` as InputAction
const memberIndex = slot - 1 + (state.partySize === 10 ? state.targetGroup * 5 : 0)
const memberIndex = slot - 1 + (state.partySize > 6 ? state.targetGroup * 6 : 0)
return (
<button onClick={() => sendAction(action)} type="button" key={action}>
<ControllerBindingLabel
@@ -293,13 +362,13 @@ export function DualScreenBottomDisplay() {
</button>
)
})}
{state.partySize === 10 && (
{state.partySize > 6 && (
<button onClick={() => sendAction('toggleTargetGroup')} type="button">
<ControllerBindingLabel
binding={state.bindings.toggleTargetGroup}
iconStyle={state.controllerIconStyle}
/>{' '}
Party Group {state.targetGroup + 1}
Party Group {state.targetGroup + 1}/{Math.ceil(state.partySize / 6)}
</button>
)}
</>
@@ -396,11 +465,13 @@ export function DualScreenTopCombat({
</section>
<section className="dual-top-party">
<div className={`dual-top-party-grid ${state.partySize === 10 ? 'raid' : ''}`}>
<div className={`dual-top-party-grid ${state.partySize > 6 ? 'raid' : ''}`}>
{state.party.map((member, index) => {
const partySlot = index + 1 + (state.directPartyTargeting && state.partySize === 10 ? state.targetGroup * 5 : 0)
const partySlot = (index % 6) + 1
const targetAction = `targetParty${partySlot}` as InputAction
const targetBinding = state.directPartyTargeting ? state.bindings[targetAction] : null
const groupStart = state.partySize > 6 ? state.targetGroup * 6 : 0
const inCurrentTargetGroup = index >= groupStart && index < groupStart + 6
const targetBinding = state.directPartyTargeting && inCurrentTargetGroup ? state.bindings[targetAction] : null
return (
<button
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
@@ -418,6 +489,7 @@ export function DualScreenTopCombat({
{member.shield > 0 && (
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
)}
<em className="health-text">{Math.ceil(member.health)} / {member.maxHealth}</em>
</div>
{state.directPartyTargeting && targetBinding && (
<div className="member-target-key">
@@ -437,11 +509,6 @@ export function DualScreenTopCombat({
</div>
</section>
<footer className="dual-top-log">
{state.log.slice(0, 3).map((entry) => (
<span className={entry.tone} key={entry.id}>{entry.text}</span>
))}
</footer>
</div>
)
}
+9
View File
@@ -50,6 +50,7 @@ export const INITIAL_PARTY: PartyMember[] = [
{ id: 'kael', name: 'Kael', role: 'Damage', health: 105, maxHealth: 105, shield: 0, hotTicks: 0 },
{ id: 'ves', name: 'Ves', role: 'Damage', health: 95, maxHealth: 95, shield: 0, hotTicks: 0 },
{ id: 'orin', name: 'Orin', role: 'Damage', health: 110, maxHealth: 110, shield: 0, hotTicks: 0 },
{ id: 'lyra', name: 'Lyra', role: 'Damage', health: 98, maxHealth: 98, shield: 0, hotTicks: 0 },
]
export const RAID_PARTY: PartyMember[] = [
@@ -63,6 +64,14 @@ export const RAID_PARTY: PartyMember[] = [
{ id: 'lyra', name: 'Lyra', role: 'Damage', health: 98, maxHealth: 98, shield: 0, hotTicks: 0 },
{ id: 'dax', name: 'Dax', role: 'Damage', health: 108, maxHealth: 108, shield: 0, hotTicks: 0 },
{ id: 'nyx', name: 'Nyx', role: 'Damage', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
{ id: 'ren', name: 'Ren', role: 'Damage', health: 104, maxHealth: 104, shield: 0, hotTicks: 0 },
{ id: 'iona', name: 'Iona', role: 'Damage', health: 96, maxHealth: 96, shield: 0, hotTicks: 0 },
{ id: 'sol', name: 'Sol', role: 'Damage', health: 106, maxHealth: 106, shield: 0, hotTicks: 0 },
{ id: 'nari', name: 'Nari', role: 'Damage', health: 99, maxHealth: 99, shield: 0, hotTicks: 0 },
{ id: 'joss', name: 'Joss', role: 'Damage', health: 103, maxHealth: 103, shield: 0, hotTicks: 0 },
{ id: 'eira', name: 'Eira', role: 'Damage', health: 97, maxHealth: 97, shield: 0, hotTicks: 0 },
{ id: 'cato', name: 'Cato', role: 'Damage', health: 107, maxHealth: 107, shield: 0, hotTicks: 0 },
{ id: 'rhea', name: 'Rhea', role: 'Damage', health: 101, maxHealth: 101, shield: 0, hotTicks: 0 },
]
export const SPELLS: Spell[] = [
+465 -92
View File
@@ -1,5 +1,6 @@
import starterProfile from './offline-starter-profile.json'
import type {
Account,
AuthSession,
CharacterProfile,
DungeonReward,
@@ -69,37 +70,65 @@ type OfflineSave = {
lootRolls: Record<string, LootRoll>
}
const modeKey = 'chronicle.gameMode'
type OnlineCache = {
version: 1
account: Account
save: OfflineSave
dirty: boolean
}
export type CloudSyncStatus = {
available: boolean
dirty: boolean
}
type RepositoryMode = 'online' | 'offline-local' | 'offline-cached'
type NetworkError = Error & {
network?: boolean
}
type LocalSaveStore = {
readSave: () => OfflineSave | null
writeSave: (save: OfflineSave) => void
readAccount: () => Account | null
}
const modeKey = 'chronicle.repositoryMode'
const offlineSaveKey = 'chronicle.offlineSave.v1'
const onlineCacheKey = 'chronicle.onlineCache.v1'
const offlineAccount = { id: -1, username: 'Offline' }
function clone<T>(value: T): T {
return structuredClone(value)
}
function readMode(): GameMode {
return localStorage.getItem(modeKey) === 'offline' ? 'offline' : 'online'
function toGameMode(mode: RepositoryMode): GameMode {
return mode === 'online' ? 'online' : 'offline'
}
function writeMode(mode: GameMode) {
localStorage.setItem(modeKey, mode)
function dispatchModeChange(mode: RepositoryMode) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent<GameMode>('chronicle:mode-changed', {
detail: toGameMode(mode),
}))
}
function readOfflineSave(): OfflineSave | null {
const serialized = localStorage.getItem(offlineSaveKey)
if (!serialized) return null
try {
const raw = JSON.parse(serialized)
if (raw.version === 3) return raw as OfflineSave
if (raw.version === 2) return migrateV2ToV3(raw)
if (raw.version === 1) return migrateV1ToV2(raw)
return null
} catch {
return null
function readMode(): RepositoryMode {
const stored = localStorage.getItem(modeKey)
if (stored === 'offline-cached' || stored === 'offline-local' || stored === 'online') {
return stored
}
if (stored === 'offline') return 'offline-local'
return 'online'
}
function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }): OfflineSave {
function writeMode(mode: RepositoryMode) {
localStorage.setItem(modeKey, mode)
dispatchModeChange(mode)
}
function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }): OfflineSave {
const p = v1.profile
const classes = [1, 2, 3]
const characters: Record<number, CharacterData> = {}
@@ -118,7 +147,7 @@ function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string
inventory: cid === p.character.classId ? clone(p.inventory) : [],
}
}
const v2: OfflineSave = {
return {
version: 3,
characterName: p.character.name,
activeClassId: p.character.classId,
@@ -127,28 +156,98 @@ function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string
characters,
lootRolls: v1.lootRolls ?? {},
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v2))
return v2
}
function migrateV2ToV3(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
const v3: OfflineSave = {
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
return {
...v2,
version: 3,
completedRaidPhases: 0,
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v3))
return v3
}
function normalizeOfflineSave(raw: unknown): OfflineSave | null {
if (!raw || typeof raw !== 'object') return null
const candidate = raw as {
version?: number
profile?: CharacterProfile
lootRolls?: Record<string, LootRoll>
}
if (candidate.version === 3) return candidate as OfflineSave
if (candidate.version === 2) {
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
}
if (candidate.version === 1 && candidate.profile) {
return upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> })
}
return null
}
function readSaveKey(key: string): OfflineSave | null {
const serialized = localStorage.getItem(key)
if (!serialized) return null
try {
const raw = JSON.parse(serialized)
const save = normalizeOfflineSave(raw)
if (!save) return null
if (raw.version !== 3) {
localStorage.setItem(key, JSON.stringify(save))
}
return save
} catch {
return null
}
}
function readOfflineSave(): OfflineSave | null {
return readSaveKey(offlineSaveKey)
}
function writeOfflineSave(save: OfflineSave) {
localStorage.setItem(offlineSaveKey, JSON.stringify(save))
}
function requireOfflineSave(): OfflineSave {
const save = readOfflineSave()
if (!save) throw new Error('No offline character exists yet.')
return save
function readOnlineCache(): OnlineCache | null {
const serialized = localStorage.getItem(onlineCacheKey)
if (!serialized) return null
try {
const raw = JSON.parse(serialized) as {
version?: number
account?: Account
save?: unknown
dirty?: boolean
}
if (
raw.version !== 1
|| !raw.account
|| typeof raw.account.id !== 'number'
|| typeof raw.account.username !== 'string'
) {
return null
}
const save = normalizeOfflineSave(raw.save)
if (!save) return null
const cache: OnlineCache = {
version: 1,
account: raw.account,
save,
dirty: Boolean(raw.dirty),
}
if ((raw.save as { version?: number } | undefined)?.version !== 3) {
writeOnlineCache(cache)
}
return cache
} catch {
return null
}
}
function writeOnlineCache(cache: OnlineCache) {
localStorage.setItem(onlineCacheKey, JSON.stringify(cache))
}
function clearOnlineCache() {
localStorage.removeItem(onlineCacheKey)
}
function buildProfile(save: OfflineSave): CharacterProfile {
@@ -309,6 +408,40 @@ function componentDropQuantity(itemLevel: number) {
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
}
function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineSave): OfflineSave {
const characters = clone(existingSave?.characters ?? {})
for (const gameClass of profile.classes) {
if (!characters[gameClass.id]) {
characters[gameClass.id] = emptyCharacterData(gameClass.id)
}
}
const activeClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)
if (!activeClass) {
throw new Error('The active class does not exist in the cached profile.')
}
const talentRanks: Record<string, number> = {}
for (const talent of activeClass.talents) {
talentRanks[String(talent.id)] = talent.rank
}
characters[profile.character.classId] = {
level: profile.character.level,
experience: profile.character.experience,
talentPoints: profile.character.talentPoints,
abilitySlots: [...profile.abilitySlots],
talentRanks,
inventory: clone(profile.inventory),
}
return {
version: 3,
characterName: profile.character.name,
activeClassId: profile.character.classId,
completedDungeonParts: profile.completedDungeonParts,
completedRaidPhases: profile.completedRaidPhases,
characters,
lootRolls: clone(existingSave?.lootRolls ?? {}),
}
}
function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]): T {
const totalWeight = entries.reduce((total, entry) => total + entry.dropWeight, 0)
let weightedRoll = Math.random() * totalWeight
@@ -335,66 +468,197 @@ function getApiBaseUrl(): string {
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = getApiBaseUrl()
const url = baseUrl ? `${baseUrl}${path}` : path
const response = await fetch(url, init)
let response: Response
try {
response = await fetch(url, init)
} catch (reason) {
const networkError = new Error('Unable to reach the game server.') as NetworkError
networkError.network = true
networkError.cause = reason
throw networkError
}
const body = await response.json()
if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.')
return body
}
function isNetworkError(reason: unknown): reason is NetworkError {
return reason instanceof Error && Boolean((reason as NetworkError).network)
}
function cachedOnlineSession(): AuthSession | null {
const cache = readOnlineCache()
if (!cache) return null
return {
account: cache.account,
profile: buildProfile(cache.save),
}
}
function resumeCachedOnlineSession(): AuthSession | null {
const session = cachedOnlineSession()
if (!session) return null
writeMode('offline-cached')
return session
}
function cacheActiveOnlineProfile(profile: CharacterProfile, dirty = false) {
const cache = readOnlineCache()
if (!cache) return
writeOnlineCache({
...cache,
save: mergeProfileIntoSave(profile, cache.save),
dirty,
})
}
async function loadServerSyncSave(): Promise<OfflineSave> {
return requestJson('/api/profile/sync-save')
}
async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: CharacterProfile; save: OfflineSave }> {
return requestJson('/api/profile/sync-save', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ save }),
})
}
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
if (!session.account || !session.profile) return session
const cache = readOnlineCache()
if (cache?.account.id === session.account.id && cache.dirty) {
writeOnlineCache({
...cache,
account: session.account,
})
return {
account: session.account,
profile: buildProfile(cache.save),
}
}
try {
const save = await loadServerSyncSave()
writeOnlineCache({
version: 1,
account: session.account,
save,
dirty: false,
})
} catch {
writeOnlineCache({
version: 1,
account: session.account,
save: mergeProfileIntoSave(session.profile, cache?.account.id === session.account.id ? cache.save : undefined),
dirty: false,
})
}
return session
}
async function fallbackToCachedOnline<T>(reason: unknown, localAction: () => Promise<T>): Promise<T> {
if (!isNetworkError(reason) || !readOnlineCache()) throw reason
writeMode('offline-cached')
return localAction()
}
async function withOnlineFallback<T>(
onlineAction: () => Promise<T>,
localAction: () => Promise<T>,
onSuccess?: (result: T) => void | Promise<void>,
): Promise<T> {
try {
const result = await onlineAction()
await onSuccess?.(result)
return result
} catch (reason) {
return fallbackToCachedOnline(reason, localAction)
}
}
async function loadOnlineSessionFromServer(): Promise<AuthSession> {
const session = await requestJson<AuthSession>('/api/auth/session')
return finalizeOnlineSession(session)
}
const serverRepository: GameRepository = {
loadSession: () => requestJson('/api/auth/session'),
register: (username, password, characterName) =>
requestJson('/api/auth/register', {
loadSession: async () => {
try {
return await loadOnlineSessionFromServer()
} catch (reason) {
if (isNetworkError(reason)) {
const fallback = resumeCachedOnlineSession()
if (fallback) return fallback
}
throw reason
}
},
register: async (username, password, characterName) =>
finalizeOnlineSession(await requestJson('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, characterName }),
}),
login: (username, password) =>
requestJson('/api/auth/login', {
})),
login: async (username, password) =>
finalizeOnlineSession(await requestJson('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}),
})),
logout: async () => {
try {
await requestJson('/api/auth/logout', { method: 'POST' })
} catch (reason) {
if (!isNetworkError(reason)) throw reason
}
clearOnlineCache()
writeMode('online')
},
loadProfile: () => requestJson('/api/profile'),
loadProfile: () =>
readOnlineCache()
? cachedOnlineLocalRepository.loadProfile()
: withOnlineFallback(
() => requestJson('/api/profile'),
() => cachedOnlineLocalRepository.loadProfile(),
(profile) => {
cacheActiveOnlineProfile(profile)
},
),
saveProfile: (classId, abilitySlots) =>
requestJson('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ classId, abilitySlots }),
}),
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
requestJson(`/api/dungeons/${dungeonId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds }),
}),
cachedOnlineLocalRepository.completeDungeon(
dungeonId,
difficultyId,
resourceSpent,
durationSeconds,
completedPart,
startPart,
partDurationSeconds,
),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
requestJson('/api/roguelike/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, ...options }),
}),
cachedOnlineLocalRepository.completeRoguelike(
dungeonId,
difficultyId,
encountersCleared,
resourceSpent,
durationSeconds,
options,
),
allocateTalent: (talentId) =>
requestJson(`/api/talents/${talentId}/allocate`, { method: 'POST' }),
cachedOnlineLocalRepository.allocateTalent(talentId),
resetTalents: () =>
requestJson('/api/talents/reset', { method: 'POST' }),
cachedOnlineLocalRepository.resetTalents(),
equipItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/equip`, { method: 'POST' }),
cachedOnlineLocalRepository.equipItem(itemId),
discardExtraItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/discard-extra`, { method: 'POST' }),
cachedOnlineLocalRepository.discardExtraItem(itemId),
breakdownItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/breakdown`, { method: 'POST' }),
cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) =>
requestJson(`/api/crafting/recipes/${recipeId}/craft`, { method: 'POST' }),
cachedOnlineLocalRepository.craftItem(recipeId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
requestJson(`/api/encounters/${encounterId}/loot-roll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, runToken }),
}),
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
}
function emptyCharacterData(classId: number): CharacterData {
@@ -419,11 +683,18 @@ function emptyCharacterData(classId: number): CharacterData {
}
}
const offlineRepository: GameRepository = {
async loadSession() {
const save = readOfflineSave()
function requireStoredSave(store: LocalSaveStore): OfflineSave {
const save = store.readSave()
if (!save) throw new Error('No local character exists yet.')
return save
}
function createLocalRepository(store: LocalSaveStore): GameRepository {
return {
account: save ? offlineAccount : null,
async loadSession() {
const save = store.readSave()
return {
account: save ? (store.readAccount() ?? offlineAccount) : null,
profile: save ? buildProfile(save) : null,
}
},
@@ -437,10 +708,10 @@ const offlineRepository: GameRepository = {
writeMode('online')
},
async loadProfile() {
return buildProfile(requireOfflineSave())
return buildProfile(requireStoredSave(store))
},
async saveProfile(classId, abilitySlots) {
const save = requireOfflineSave()
const save = requireStoredSave(store)
const static_ = clone(starterProfile) as CharacterProfile
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
if (!gameClass) throw new Error('Selected class does not exist.')
@@ -466,7 +737,7 @@ const offlineRepository: GameRepository = {
}
save.characters[classId].abilitySlots = slots
save.activeClassId = classId
writeOfflineSave(save)
store.writeSave(save)
return buildProfile(save)
},
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
@@ -478,7 +749,7 @@ const offlineRepository: GameRepository = {
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
@@ -560,7 +831,7 @@ const offlineRepository: GameRepository = {
}
}
writeOfflineSave(save)
store.writeSave(save)
const updatedProfile = buildProfile(save)
return {
@@ -590,7 +861,7 @@ const offlineRepository: GameRepository = {
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
@@ -644,7 +915,7 @@ const offlineRepository: GameRepository = {
cd.talentPoints + levelsGained,
)
writeOfflineSave(save)
store.writeSave(save)
const updatedProfile = buildProfile(save)
return {
@@ -665,7 +936,7 @@ const offlineRepository: GameRepository = {
}
},
async allocateTalent(talentId) {
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find(
@@ -698,11 +969,11 @@ const offlineRepository: GameRepository = {
}
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
cd.talentPoints -= 1
writeOfflineSave(save)
store.writeSave(save)
return buildProfile(save)
},
async resetTalents() {
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find(
@@ -719,11 +990,11 @@ const offlineRepository: GameRepository = {
profile.maxTalentPoints,
cd.talentPoints + refunded,
)
writeOfflineSave(save)
store.writeSave(save)
return buildProfile(save)
},
async equipItem(itemId) {
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
@@ -731,22 +1002,22 @@ const offlineRepository: GameRepository = {
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
store.writeSave(save)
return buildProfile(save)
},
async discardExtraItem(itemId) {
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
item.quantity -= 1
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
store.writeSave(save)
return buildProfile(save)
},
async breakdownItem(itemId) {
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
@@ -785,11 +1056,11 @@ const offlineRepository: GameRepository = {
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
store.writeSave(save)
return buildProfile(save)
},
async craftItem(recipeId) {
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.')
@@ -809,14 +1080,14 @@ const offlineRepository: GameRepository = {
addInventoryItem(profile.inventory, recipe.item, 1)
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
store.writeSave(save)
return buildProfile(save)
},
async rollEncounterLoot(encounterId, difficultyId, runToken) {
if (runToken.length < 8 || runToken.length > 100) {
throw new Error('A valid dungeon run token is required.')
}
const save = requireOfflineSave()
const save = requireStoredSave(store)
const rollKey = `${runToken}:${encounterId}:${difficultyId}`
if (save.lootRolls[rollKey]) return clone(save.lootRolls[rollKey])
@@ -894,13 +1165,112 @@ const offlineRepository: GameRepository = {
}
save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
store.writeSave(save)
return clone(result)
},
}
}
const offlineRepository = createLocalRepository({
readSave: readOfflineSave,
writeSave: writeOfflineSave,
readAccount: () => offlineAccount,
})
const cachedOnlineLocalRepository = createLocalRepository({
readSave: () => readOnlineCache()?.save ?? null,
writeSave: (save) => {
const cache = readOnlineCache()
if (!cache) throw new Error('No cached account save exists yet.')
writeOnlineCache({
...cache,
save,
dirty: true,
})
},
readAccount: () => readOnlineCache()?.account ?? null,
})
const cachedOnlineRepository: GameRepository = {
async loadSession() {
try {
const session = await loadOnlineSessionFromServer()
if (session.account && session.profile) {
writeMode('online')
return session
}
} catch {
// Fall through to local cached mirror.
}
return cachedOnlineLocalRepository.loadSession()
},
async register() {
throw new Error('Account registration requires online mode.')
},
async login() {
throw new Error('Account login requires online mode.')
},
async logout() {
clearOnlineCache()
writeMode('online')
},
loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
cachedOnlineLocalRepository.completeDungeon(
dungeonId,
difficultyId,
resourceSpent,
durationSeconds,
completedPart,
startPart,
partDurationSeconds,
),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike(
dungeonId,
difficultyId,
encountersCleared,
resourceSpent,
durationSeconds,
options,
),
allocateTalent: (talentId) => cachedOnlineLocalRepository.allocateTalent(talentId),
resetTalents: () => cachedOnlineLocalRepository.resetTalents(),
equipItem: (itemId) => cachedOnlineLocalRepository.equipItem(itemId),
discardExtraItem: (itemId) => cachedOnlineLocalRepository.discardExtraItem(itemId),
breakdownItem: (itemId) => cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) => cachedOnlineLocalRepository.craftItem(recipeId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
}
export function getGameMode(): GameMode {
return readMode()
return toGameMode(readMode())
}
export function getCloudSyncStatus(): CloudSyncStatus {
const cache = readOnlineCache()
return {
available: Boolean(cache),
dirty: Boolean(cache?.dirty),
}
}
export async function syncCloudSave(): Promise<CharacterProfile> {
const cache = readOnlineCache()
if (!cache) {
throw new Error('No signed-in save is available for cloud sync.')
}
const synced = await pushServerSyncSave(cache.save)
writeOnlineCache({
version: 1,
account: cache.account,
save: synced.save,
dirty: false,
})
writeMode('online')
return synced.profile
}
export function selectOnlineMode() {
@@ -926,14 +1296,14 @@ export function createOfflineCharacter(characterName: string): AuthSession {
lootRolls: {},
}
writeOfflineSave(save)
writeMode('offline')
writeMode('offline-local')
return { account: offlineAccount, profile: buildProfile(save) }
}
export function resumeOfflineCharacter(): AuthSession | null {
const save = readOfflineSave()
if (!save) return null
writeMode('offline')
writeMode('offline-local')
return { account: offlineAccount, profile: buildProfile(save) }
}
@@ -942,5 +1312,8 @@ export function hasOfflineCharacter(): boolean {
}
export function activeGameRepository(): GameRepository {
return readMode() === 'offline' ? offlineRepository : serverRepository
const mode = readMode()
if (mode === 'offline-local') return offlineRepository
if (mode === 'offline-cached') return cachedOnlineRepository
return serverRepository
}
+6
View File
@@ -12,4 +12,10 @@ body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow: hidden;
}
#root {
height: 100dvh;
overflow: hidden;
}
+66 -8
View File
@@ -33,6 +33,7 @@ export const INPUT_ACTIONS = [
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
'pause',
] as const
@@ -60,6 +61,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
targetParty3: 'Target Party Member 3',
targetParty4: 'Target Party Member 4',
targetParty5: 'Target Party Member 5',
targetParty6: 'Target Party Member 6',
toggleTargetGroup: 'Switch Raid Target Group',
pause: 'Pause Menu',
}
@@ -85,6 +87,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'F3',
targetParty4: 'F4',
targetParty5: 'F5',
targetParty6: 'F6',
toggleTargetGroup: 'Tab',
pause: 'Escape',
},
@@ -108,6 +111,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'Button15',
targetParty4: 'Button13',
targetParty5: 'Button4',
targetParty6: 'Button11',
toggleTargetGroup: 'Button6',
pause: 'Button9',
},
@@ -202,13 +206,19 @@ function bindingGroup(action: InputAction) {
}
function isVisible(element: HTMLElement) {
if (element.hidden || element.getAttribute('aria-hidden') === 'true') return false
return element.getClientRects().length > 0
}
function focusableElements() {
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
const scope: ParentNode = keyboard ?? pauseMenu ?? document
const dialog = Array.from(
document.querySelectorAll<HTMLElement>(
'.result-screen, .binding-capture, .dual-startup-prompt',
),
).find(isVisible)
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
return Array.from(
scope.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
@@ -256,7 +266,22 @@ function moveFocus(action: InputAction) {
const next = ranked[0]?.candidate
if (!next) return
next.focus({ preventScroll: true })
next.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
function hasUiOverlay() {
return Array.from(
document.querySelectorAll<HTMLElement>(
'.pause-screen, .result-screen, .binding-capture, .dual-startup-prompt, .controller-keyboard',
),
).some(isVisible)
}
function isCombatTargetAction(action: InputAction) {
return action.startsWith('navigate')
|| action.startsWith('targetParty')
|| action === 'previousTarget'
|| action === 'nextTarget'
|| action === 'toggleTargetGroup'
}
const BUTTON_LABELS: Record<number, string> = {
@@ -372,6 +397,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const keyboardInputRef = useRef(keyboardInput)
const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({})
const lastCombatNavigationRef = useRef(0)
useEffect(() => {
bindingsRef.current = bindings
@@ -416,18 +442,29 @@ export function InputProvider({ children }: { children: ReactNode }) {
}, [])
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
const uiOverlay = hasUiOverlay()
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
const now = performance.now()
if (now - lastCombatNavigationRef.current < 125) return
lastCombatNavigationRef.current = now
}
setLastDevice(device)
document.documentElement.dataset.inputDevice = device
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
if (action.startsWith('navigate')) {
if (!combatActive) moveFocus(action)
if (uiOverlay || !combatActive) moveFocus(action)
} else if (action === 'confirm') {
const active = document.activeElement
if (isTextInput(active)) {
setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl())
} else if (active instanceof HTMLElement && active.matches('button, [role="button"]')) {
} else if (
active instanceof HTMLElement
&& active.matches('button:not(:disabled), [role="button"]')
&& isVisible(active)
) {
active.click()
} else {
focusFirstControl()
@@ -435,7 +472,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
} else if (action === 'back') {
if (keyboardInputRef.current) {
closeKeyboard()
} else if (!combatActive) {
} else if (uiOverlay || !combatActive) {
const backButton = Array.from(
document.querySelectorAll<HTMLButtonElement>('.back-button:not(:disabled)'),
).find(isVisible)
@@ -458,18 +495,28 @@ export function InputProvider({ children }: { children: ReactNode }) {
const combatActive = Boolean(
document.querySelector('[data-combat-active="true"]'),
)
const uiOverlay = hasUiOverlay()
const menuDpadActions: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
const uiPriority = [
'navigateUp',
'navigateDown',
'navigateLeft',
'navigateRight',
'confirm',
'back',
] satisfies InputAction[]
const directTargetActions = [
'targetParty1',
'targetParty2',
'targetParty3',
'targetParty4',
'targetParty5',
'targetParty6',
'toggleTargetGroup',
] satisfies InputAction[]
const combatPriority = [
@@ -487,7 +534,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft',
'navigateRight',
] satisfies InputAction[]
const action = combatActive && preferencesRef.current.directPartyTargeting
const action = menuDpadActions[token] && (!combatActive || uiOverlay)
? menuDpadActions[token]
: uiOverlay
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
: combatActive && preferencesRef.current.directPartyTargeting
? [...directTargetActions, ...combatPriority].find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
@@ -541,8 +592,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
const ensureFocus = () => {
const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return
const candidates = focusableElements()
const active = document.activeElement
const activeIsUsable = active instanceof HTMLElement
&& candidates.includes(active)
&& isVisible(active)
if (
document.activeElement === document.body
(!activeIsUsable || document.activeElement === document.body)
&& !keyboardInputRef.current
&& !captureRef.current
) {
@@ -553,6 +609,8 @@ export function InputProvider({ children }: { children: ReactNode }) {
window.requestAnimationFrame(ensureFocus)
})
observer.observe(document.getElementById('root') ?? document.body, {
attributes: true,
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'],
childList: true,
subtree: true,
})
+16 -2
View File
@@ -1,9 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Capacitor } from '@capacitor/core'
import './index.css'
import App from './App.tsx'
import { InputProvider } from './input.tsx'
import { DualScreenBottomDisplay, DualScreenProvider } from './dualScreen.tsx'
import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt } from './dualScreen.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
@@ -11,6 +12,7 @@ createRoot(document.getElementById('root')!).render(
<DualScreenBottomDisplay />
) : (
<DualScreenProvider>
<DualScreenStartupPrompt />
<InputProvider>
<App />
</InputProvider>
@@ -19,7 +21,19 @@ createRoot(document.getElementById('root')!).render(
</StrictMode>,
)
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
const isNativeApp = Capacitor.isNativePlatform()
if (import.meta.env.PROD && isNativeApp && 'serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations()
.then((registrations) => Promise.all(registrations.map((registration) => registration.unregister())))
.then(() => caches.keys())
.then((keys) => Promise.all(keys.filter((key) => key.startsWith('chronicle-')).map((key) => caches.delete(key))))
.catch(() => {
// Native app assets should come directly from the APK when cache cleanup is unavailable.
})
}
if (import.meta.env.PROD && !isNativeApp && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js').catch(() => {
// Offline launch remains optional when registration is unavailable.
+9 -9
View File
@@ -4918,7 +4918,7 @@
"name": "Bulldrome Hunting Ground",
"recommendedLevel": 1,
"contentType": "dungeon",
"partySize": 5,
"partySize": 6,
"completionItemLevel": null,
"experienceReward": 125,
"description": "A three-boss hunt featuring Bulldrome, Yian Kut-Ku, and Rathian.",
@@ -6289,7 +6289,7 @@
"name": "Tigrex Raid",
"recommendedLevel": 5,
"contentType": "raid",
"partySize": 10,
"partySize": 18,
"completionItemLevel": null,
"experienceReward": 275,
"description": "A raid-scale hunt against Tigrex, Rathalos, and Gypceros.",
@@ -6700,7 +6700,7 @@
"name": "Tigrex Hunting Ground",
"recommendedLevel": 5,
"contentType": "dungeon",
"partySize": 5,
"partySize": 6,
"completionItemLevel": null,
"experienceReward": 205,
"description": "A three-boss hunt featuring Tigrex, Rathalos, and Gypceros.",
@@ -7111,7 +7111,7 @@
"name": "Nargacuga Hunting Ground",
"recommendedLevel": 10,
"contentType": "dungeon",
"partySize": 5,
"partySize": 6,
"completionItemLevel": null,
"experienceReward": 245,
"description": "A three-boss hunt featuring Nargacuga, Azuros, and Diablos.",
@@ -7522,7 +7522,7 @@
"name": "Nargacuga Raid",
"recommendedLevel": 10,
"contentType": "raid",
"partySize": 10,
"partySize": 18,
"completionItemLevel": null,
"experienceReward": 325,
"description": "A raid-scale hunt against Nargacuga, Azuros, and Diablos.",
@@ -7933,7 +7933,7 @@
"name": "Barroth Hunting Ground",
"recommendedLevel": 15,
"contentType": "dungeon",
"partySize": 5,
"partySize": 6,
"completionItemLevel": null,
"experienceReward": 285,
"description": "A three-boss hunt featuring Barroth, Tobi Kadachi, and Monoblos.",
@@ -8344,7 +8344,7 @@
"name": "Barroth Raid",
"recommendedLevel": 15,
"contentType": "raid",
"partySize": 10,
"partySize": 18,
"completionItemLevel": null,
"experienceReward": 375,
"description": "A raid-scale hunt against Barroth, Tobi Kadachi, and Monoblos.",
@@ -8755,7 +8755,7 @@
"name": "Anjanath Hunting Ground",
"recommendedLevel": 20,
"contentType": "dungeon",
"partySize": 5,
"partySize": 6,
"completionItemLevel": null,
"experienceReward": 325,
"description": "A three-boss hunt featuring Anjanath, Bazelgeuse, and Odogaron.",
@@ -9166,7 +9166,7 @@
"name": "Anjanath Raid",
"recommendedLevel": 20,
"contentType": "raid",
"partySize": 10,
"partySize": 18,
"completionItemLevel": null,
"experienceReward": 425,
"description": "A raid-scale hunt against Anjanath, Bazelgeuse, and Odogaron.",