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" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 32
versionName "1.0" versionName "1.0.21"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // 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 { repositories {
flatDir{ flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
@@ -2,17 +2,25 @@ package com.warren.iwanttoheal;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.os.SystemClock;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.View; import android.view.View;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import java.io.File;
public abstract class ControllerBridgeActivity extends BridgeActivity { public abstract class ControllerBridgeActivity extends BridgeActivity {
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL"; 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 @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
clearWebViewServiceWorkers();
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
if (bridge != null) {
bridge.getWebView().clearCache(true);
}
loadIntentUrl(); 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 @Override
public boolean dispatchKeyEvent(KeyEvent event) { public boolean dispatchKeyEvent(KeyEvent event) {
String token = controllerToken(event.getKeyCode()); String token = controllerToken(event.getKeyCode());
@@ -54,6 +81,7 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (event.getAction() == KeyEvent.ACTION_DOWN) { if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0; boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad()) return true;
String script = String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller'," "window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));"; + "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
@@ -64,6 +92,20 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
return true; 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) { private String controllerToken(int keyCode) {
switch (keyCode) { switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A: case KeyEvent.KEYCODE_BUTTON_A:
@@ -66,9 +66,10 @@ public class DualScreenPlugin extends Plugin {
} }
String gameUrl = bridge.getLocalUrl(); String gameUrl = bridge.getLocalUrl();
String topGameUrl = gameUrl + "/?display=top";
String controlsUrl = gameUrl + "/?display=bottom"; String controlsUrl = gameUrl + "/?display=bottom";
String targetUrl = launchPlan.targetIsTopDisplay ? gameUrl : controlsUrl; String targetUrl = launchPlan.targetIsTopDisplay ? topGameUrl : controlsUrl;
String currentUrl = launchPlan.currentIsTopDisplay ? gameUrl : controlsUrl; String currentUrl = launchPlan.currentIsTopDisplay ? topGameUrl : controlsUrl;
closePresentation(); closePresentation();
presentation = new TopDisplayPresentation( presentation = new TopDisplayPresentation(
+1 -1
View File
@@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS dungeons (
name TEXT NOT NULL, name TEXT NOT NULL,
recommended_level INTEGER NOT NULL DEFAULT 1, recommended_level INTEGER NOT NULL DEFAULT 1,
content_type TEXT NOT NULL DEFAULT 'dungeon' CHECK (content_type IN ('dungeon', 'raid')), 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, completion_item_level INTEGER,
experience_reward INTEGER NOT NULL DEFAULT 100, experience_reward INTEGER NOT NULL DEFAULT 100,
description TEXT NOT NULL 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 INSERT OR IGNORE INTO dungeons
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description) (id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
VALUES VALUES
(1, 1, 'ashen-halls', 'The Ashen Halls', 1, 'dungeon', 5, NULL, 125, 'Break the cinder cult before the old furnace awakens.'), (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', 10, 10, 175, 'Lead ten allies through the caldera and break the Ember Crown across three phases.'); (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 UPDATE dungeons
SET slug = 'bulldrome-hunting-ground', SET slug = 'bulldrome-hunting-ground',
@@ -14,12 +14,12 @@ SET slug = 'bulldrome-hunting-ground',
location_id = 1, location_id = 1,
recommended_level = 1, recommended_level = 1,
content_type = 'dungeon', content_type = 'dungeon',
party_size = 5, party_size = 6,
completion_item_level = NULL, completion_item_level = NULL,
experience_reward = 125, experience_reward = 125,
description = 'A three-boss hunt featuring Bulldrome, Yian Kut-Ku, and Rathian.' description = 'A three-boss hunt featuring Bulldrome, Yian Kut-Ku, and Rathian.'
WHERE id = 1; 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'; WHERE slug = 'citadel-of-the-ember-crown';
INSERT OR IGNORE INTO difficulties 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.'), (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.'), (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.'), (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 UPDATE difficulties SET
dropped_item_level = CASE slug 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.'), (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.'), (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.'), (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.'), (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.'), (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.'), (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, location_id = 3,
recommended_level = 5, recommended_level = 5,
content_type = 'raid', content_type = 'raid',
party_size = 10, party_size = 18,
completion_item_level = NULL, completion_item_level = NULL,
experience_reward = 275, experience_reward = 275,
description = 'A raid-scale hunt against Tigrex, Rathalos, and Gypceros.' description = 'A raid-scale hunt against Tigrex, Rathalos, and Gypceros.'
@@ -620,13 +620,13 @@ WHERE id = 2;
INSERT OR IGNORE INTO dungeons INSERT OR IGNORE INTO dungeons
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description) (id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
VALUES VALUES
(3, 3, 'tigrex-hunting-ground', 'Tigrex Hunting Ground', 5, 'dungeon', 5, NULL, 205, 'A three-boss hunt featuring Tigrex, Rathalos, and Gypceros.'), (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', 5, NULL, 245, 'A three-boss hunt featuring Nargacuga, Azuros, and Diablos.'), (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', 10, NULL, 325, 'A raid-scale hunt against 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', 5, NULL, 285, 'A three-boss hunt featuring Barroth, Tobi Kadachi, and Monoblos.'), (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', 10, NULL, 375, 'A raid-scale hunt against 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', 5, NULL, 325, 'A three-boss hunt featuring Anjanath, Bazelgeuse, and Odogaron.'), (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', 10, NULL, 425, 'A raid-scale hunt against 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 UPDATE difficulties
SET dropped_item_level = 10, SET dropped_item_level = 10,
+2 -2
View File
@@ -6,10 +6,10 @@
"scripts": { "scripts": {
"predev": "npm run db:init", "predev": "npm run db:init",
"dev": "vite", "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:sync": "npm run build && cap sync android",
"android:open": "cap open 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", "accounts:ip": "node scripts/manage-ip-allowance.mjs",
"db:backup": "node scripts/backup-db.mjs", "db:backup": "node scripts/backup-db.mjs",
"db:init": "node scripts/init-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) { function itemById(database, itemId) {
return database.prepare(` return database.prepare(`
SELECT SELECT
@@ -1964,6 +2295,17 @@ export async function handleApiRequest(request, response, next) {
const session = requireSession(database, request) 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') { if (request.url === '/api/profile' && request.method === 'GET') {
sendJson(response, 200, getProfile(database, session.characterId, session.accountId)) sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
return return
+621 -30
View File
@@ -310,6 +310,7 @@ textarea:focus-visible,
} }
.binding-capture, .binding-capture,
.dual-startup-prompt,
.controller-keyboard-backdrop { .controller-keyboard-backdrop {
align-items: center; align-items: center;
background: rgba(5, 6, 9, 0.88); background: rgba(5, 6, 9, 0.88);
@@ -320,7 +321,8 @@ textarea:focus-visible,
z-index: 100; z-index: 100;
} }
.binding-capture > div { .binding-capture > div,
.dual-startup-prompt > section {
background: var(--panel); background: var(--panel);
border: 3px solid #090a0d; border: 3px solid #090a0d;
box-shadow: 8px 8px 0 #050609; box-shadow: 8px 8px 0 #050609;
@@ -337,7 +339,29 @@ textarea:focus-visible,
margin: 18px 0; 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, .binding-capture button,
.dual-startup-prompt button,
.controller-keyboard button { .controller-keyboard button {
background: #242630; background: #242630;
border: 2px solid #090a0d; border: 2px solid #090a0d;
@@ -351,6 +375,17 @@ textarea:focus-visible,
padding: 8px 24px; 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 { .controller-keyboard {
background: var(--panel); background: var(--panel);
border: 3px solid #090a0d; border: 3px solid #090a0d;
@@ -572,11 +607,12 @@ textarea:focus-visible,
.dual-top-party-grid { .dual-top-party-grid {
display: grid; display: grid;
gap: 10px; gap: 10px;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.dual-top-party-grid.raid { .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 { .dual-top-member {
@@ -638,8 +674,34 @@ textarea:focus-visible,
vertical-align: middle; 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 { .dual-top-log {
display: flex; display: none;
gap: 14px; gap: 14px;
min-height: 36px; min-height: 36px;
overflow: hidden; overflow: hidden;
@@ -711,7 +773,7 @@ textarea:focus-visible,
display: grid; display: grid;
gap: 10px; gap: 10px;
height: calc(100dvh - 20px); height: calc(100dvh - 20px);
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr;
min-height: 0; min-height: 0;
} }
@@ -727,7 +789,7 @@ textarea:focus-visible,
} }
.dual-top-main .dual-top-party-grid.raid { .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 { .dual-top-main .dual-top-member {
@@ -980,8 +1042,13 @@ textarea:focus-visible,
} }
.game-shell { .game-shell {
display: flex;
flex-direction: column;
height: 100dvh;
width: min(1180px, calc(100% - 28px)); width: min(1180px, calc(100% - 28px));
margin: 22px auto; margin: 0 auto;
overflow: hidden;
padding: 12px 0;
position: relative; position: relative;
} }
@@ -1289,11 +1356,27 @@ h2 {
.menu-screen, .menu-screen,
.content-screen, .content-screen,
.message-panel { .message-panel {
margin-top: 18px; flex: 1;
margin-top: 12px;
min-height: 0; min-height: 0;
overflow: hidden;
padding: 28px; 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 { .message-panel {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -1411,6 +1494,30 @@ h2 {
transform: translateY(-2px); 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, .menu-card > span,
.class-portrait { .class-portrait {
align-items: center; align-items: center;
@@ -1466,6 +1573,130 @@ h2 {
outline-color: var(--gold); 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 { .combat-header-actions {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -2341,6 +2572,13 @@ h2 {
padding: 15px; padding: 15px;
} }
.equipped-panel,
.inventory-panel {
display: flex;
flex-direction: column;
min-height: 0;
}
.equipment-tabs { .equipment-tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -2382,6 +2620,8 @@ h2 {
display: grid; display: grid;
gap: 8px; gap: 8px;
margin-top: 13px; margin-top: 13px;
min-height: 0;
overflow: hidden;
} }
.equipment-slots > button { .equipment-slots > button {
@@ -2437,10 +2677,12 @@ h2 {
.inventory-list { .inventory-list {
display: grid; display: grid;
flex: 1;
gap: 8px; gap: 8px;
margin-top: 13px; margin-top: 13px;
max-height: 442px; max-height: 442px;
overflow-y: auto; min-height: 0;
overflow: hidden;
padding: 2px; padding: 2px;
} }
@@ -2482,10 +2724,43 @@ h2 {
display: grid; display: grid;
gap: 8px; gap: 8px;
max-height: 360px; max-height: 360px;
overflow-y: auto; overflow: hidden;
padding: 2px; 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 { .crafting-list > button {
align-items: center; align-items: center;
background: var(--panel-light); background: var(--panel-light);
@@ -3226,7 +3501,7 @@ h2 {
.party-grid { .party-grid {
display: grid; display: grid;
gap: 11px; gap: 11px;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 17px; margin-top: 17px;
} }
@@ -3243,11 +3518,12 @@ h2 {
} }
.party-member:first-child { .party-member:first-child {
grid-column: 1 / -1; grid-column: auto;
} }
.raid-party-grid { .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 { .raid-party-grid .party-member:first-child {
@@ -3379,6 +3655,39 @@ h2 {
top: 0; 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 { .member-effects {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -3724,6 +4033,8 @@ h2 {
display: flex; display: flex;
inset: 0; inset: 0;
justify-content: center; justify-content: center;
overflow-y: auto;
padding: 16px;
position: fixed; position: fixed;
z-index: 10; z-index: 10;
} }
@@ -3733,8 +4044,10 @@ h2 {
background: var(--panel); background: var(--panel);
border: 3px solid #0b0c0f; border: 3px solid #0b0c0f;
box-shadow: 8px 8px 0 #050507; box-shadow: 8px 8px 0 #050507;
max-height: calc(100dvh - 32px);
max-width: 520px; max-width: 520px;
outline: 2px solid var(--gold); outline: 2px solid var(--gold);
overflow-y: auto;
padding: 32px; padding: 32px;
text-align: center; text-align: center;
} }
@@ -3915,18 +4228,25 @@ h2 {
} }
.pvp-match-screen { .pvp-match-screen {
gap: 16px; gap: 0;
height: calc(100dvh - 24px);
margin-top: 0;
overflow: hidden;
padding: 8px;
} }
.pvp-board { .pvp-board {
display: grid; display: grid;
gap: 16px; gap: 8px;
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.8fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(210px, 0.68fr) minmax(0, 1fr);
min-height: 0;
} }
.pvp-side, .pvp-side,
.pvp-middle-panel { .pvp-middle-panel {
gap: 12px; gap: 8px;
min-height: 0;
padding: 8px;
} }
.pvp-vertical-spell-bar, .pvp-vertical-spell-bar,
@@ -3935,7 +4255,8 @@ h2 {
} }
.pvp-vertical-spell-bar .spell { .pvp-vertical-spell-bar .spell {
min-height: 86px; min-height: 58px;
padding: 6px;
} }
.pvp-screen-tools { .pvp-screen-tools {
@@ -3950,9 +4271,9 @@ h2 {
.pvp-resource-wrap { .pvp-resource-wrap {
color: #82bfff; color: #82bfff;
min-width: 220px; min-width: 150px;
text-align: right; text-align: right;
width: min(240px, 100%); width: min(170px, 100%);
} }
.pvp-resource-wrap > span { .pvp-resource-wrap > span {
@@ -3966,16 +4287,93 @@ h2 {
min-width: 0; 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 { .pvp-enemy-race {
display: grid; 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 { .pvp-choice-columns {
display: grid; display: grid;
gap: 16px; gap: 10px;
grid-template-columns: 1fr; grid-template-columns: 1fr;
margin-top: 16px; margin-top: 0;
} }
.pvp-choice-columns > div > strong { .pvp-choice-columns > div > strong {
@@ -3989,8 +4387,8 @@ h2 {
.pvp-choice-columns .upgrade-choice-grid button { .pvp-choice-columns .upgrade-choice-grid button {
background: #252833; background: #252833;
min-height: 120px; min-height: 70px;
padding: 14px; padding: 8px;
} }
.pvp-leaderboard-row { .pvp-leaderboard-row {
@@ -3999,20 +4397,31 @@ h2 {
.pvp-upgrade-dialog { .pvp-upgrade-dialog {
max-width: 1120px !important; max-width: 1120px !important;
padding: 12px !important;
text-align: left !important; text-align: left !important;
width: min(1120px, calc(100vw - 32px)); 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 { .pvp-upgrade-dialog .upgrade-choice-grid strong {
color: #ffe8a5; color: #ffe8a5;
font-size: 11px; font-size: 9px;
line-height: 1.6; line-height: 1.25;
} }
.pvp-upgrade-dialog .upgrade-choice-grid small { .pvp-upgrade-dialog .upgrade-choice-grid small {
color: #d3d9e6; color: #d3d9e6;
font-size: 16px; font-size: 12px;
line-height: 1.35; line-height: 1.15;
} }
.pvp-upgrade-dialog .upgrade-choice-grid button.selected-upgrade { .pvp-upgrade-dialog .upgrade-choice-grid button.selected-upgrade {
@@ -4057,9 +4466,191 @@ h2 {
z-index: 1; 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) { @media (max-height: 720px) {
.game-shell { .game-shell {
margin: 6px auto; padding: 6px 0;
width: min(1180px, calc(100% - 20px)); width: min(1180px, calc(100% - 20px));
} }
+72 -5
View File
@@ -19,7 +19,12 @@ import {
type AuthSession, type AuthSession,
type CharacterProfile, type CharacterProfile,
} from './profile' } from './profile'
import { getGameMode, type GameMode } from './gameRepository' import {
getCloudSyncStatus,
getGameMode,
syncCloudSave,
type GameMode,
} from './gameRepository'
import { focusFirstControl } from './input.tsx' import { focusFirstControl } from './input.tsx'
type Screen = type Screen =
@@ -40,8 +45,8 @@ const MENU_ITEMS: Array<{
glyph: string glyph: string
description: string description: string
}> = [ }> = [
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a five-player party through dangerous encounters.' }, { screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a six-player party through dangerous encounters.' },
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide a ten-player party through three-phase challenges.' }, { 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: '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: '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.' }, { 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 LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
const SHOW_LEADERBOARDS = false
function activityInitials(name: string) { function activityInitials(name: string) {
return name return name
@@ -88,6 +94,8 @@ function App() {
const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence') const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence')
const [showLeaderboard, setShowLeaderboard] = useState(false) const [showLeaderboard, setShowLeaderboard] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [syncingCloud, setSyncingCloud] = useState(false)
const [syncMessage, setSyncMessage] = useState('')
useEffect(() => { useEffect(() => {
loadAuthSession() loadAuthSession()
@@ -105,6 +113,17 @@ function App() {
.finally(() => setAuthChecked(true)) .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(() => { useEffect(() => {
if (screen === 'combat') return if (screen === 'combat') return
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
@@ -138,11 +157,27 @@ function App() {
setProfile(null) setProfile(null)
setGameMode(getGameMode()) setGameMode(getGameMode())
setScreen('menu') setScreen('menu')
setSyncMessage('')
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to sign out.') 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) { if (error) {
return ( return (
<main className="game-shell"> <main className="game-shell">
@@ -253,6 +288,8 @@ function App() {
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 }, { part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
] ]
const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5 const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5
const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available
const lootPreviewEncounters = [...activity.encounters] const lootPreviewEncounters = [...activity.encounters]
.filter((encounter) => encounter.isBoss) .filter((encounter) => encounter.isBoss)
.sort((a, b) => lootSort === 'boss' .sort((a, b) => lootSort === 'boss'
@@ -285,6 +322,28 @@ function App() {
{screen === 'menu' && ( {screen === 'menu' && (
<section className="menu-screen"> <section className="menu-screen">
<div className="main-menu-grid"> <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) => ( {MENU_ITEMS.map((item) => (
<button <button
className="menu-card" className="menu-card"
@@ -457,6 +516,7 @@ function App() {
Start Match Start Match
</button> </button>
</div> </div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section"> <div className="leaderboard-section">
<div className="equipment-heading toggle-heading"> <div className="equipment-heading toggle-heading">
<div> <div>
@@ -488,6 +548,7 @@ function App() {
)} )}
</div> </div>
</div> </div>
)}
</> </>
)} )}
</section> </section>
@@ -663,6 +724,7 @@ function App() {
</> </>
)} )}
</div> </div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section"> <div className="leaderboard-section">
<div className="equipment-heading toggle-heading"> <div className="equipment-heading toggle-heading">
<div> <div>
@@ -682,7 +744,9 @@ function App() {
<p className="section-note"> <p className="section-note">
{gameMode === 'offline' {gameMode === 'offline'
? 'Offline runs are not submitted' ? 'Offline runs are not submitted'
: 'Lowest resource spent ranks first'} : canShowCloudSync
? 'Manual save sync updates your cloud profile.'
: 'Lowest resource spent ranks first'}
</p> </p>
<div className="leaderboard-tabs"> <div className="leaderboard-tabs">
{([ {([
@@ -730,13 +794,16 @@ function App() {
<div className="leaderboard-empty"> <div className="leaderboard-empty">
{gameMode === 'offline' {gameMode === 'offline'
? 'Connect with an online character to compete in rankings.' ? 'Connect with an online character to compete in rankings.'
: 'Complete this difficulty to claim the first ranking.'} : canShowCloudSync
? 'No leaderboard entries yet.'
: 'Complete this difficulty to claim the first ranking.'}
</div> </div>
)} )}
</div> </div>
</> </>
)} )}
</div> </div>
)}
</section> </section>
)} )}
+1 -1
View File
@@ -156,7 +156,7 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
<h2>Play Offline</h2> <h2>Play Offline</h2>
<p> <p>
No account or connection required. Offline progress stays on No account or connection required. Offline progress stays on
this device and is excluded from online leaderboards. this device.
</p> </p>
</div> </div>
{offlineCharacterExists && ( {offlineCharacterExists && (
+27 -19
View File
@@ -241,7 +241,7 @@ function makeRoguelikeSegment(
encounter.maxHealth encounter.maxHealth
+ encounter.damage * 18 + encounter.damage * 18
+ encounter.tankDamage * 10 + encounter.tankDamage * 10
+ encounter.partyDamage * 12 + encounter.partyDamage * 18
) )
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)] const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
.sort((left, right) => encounterThreat(left) - encounterThreat(right)) .sort((left, right) => encounterThreat(left) - encounterThreat(right))
@@ -331,7 +331,7 @@ export function CombatScreen({
) )
const maxResource = gameClass.maxResource + (isRoguelike ? 0 : profile.gearStats.maxResourceBonus) const maxResource = gameClass.maxResource + (isRoguelike ? 0 : profile.gearStats.maxResourceBonus)
const partyTemplate = useMemo( const partyTemplate = useMemo(
() => (dungeon.partySize === 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({ () => (dungeon.partySize >= 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
...member, ...member,
name: member.id === 'mira' ? profile.character.name : member.name, name: member.id === 'mira' ? profile.character.name : member.name,
})), })),
@@ -346,10 +346,10 @@ export function CombatScreen({
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex) const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
const [enemyHealth, setEnemyHealth] = useState(encounters[initialEncounterIndex].maxHealth) const [enemyHealth, setEnemyHealth] = useState(encounters[initialEncounterIndex].maxHealth)
const [cooldowns, setCooldowns] = useState<Record<string, number>>({}) 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 [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
const [paused, setPaused] = useState(false) 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[]>([ const [log, setLog] = useState<CombatLogEntry[]>([
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' }, { id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
]) ])
@@ -373,6 +373,7 @@ export function CombatScreen({
const nextFloatingTextId = useRef(1) const nextFloatingTextId = useRef(1)
const partyRef = useRef(partyTemplate) const partyRef = useRef(partyTemplate)
const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth) const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth)
const elapsedTicksRef = useRef(0)
const encounter = encounters[encounterIndex] const encounter = encounters[encounterIndex]
const currentPart = getCurrentPart(encounterIndex) const currentPart = getCurrentPart(encounterIndex)
const firstEncounterIndex = (startPart - 1) * 3 const firstEncounterIndex = (startPart - 1) * 3
@@ -471,6 +472,7 @@ export function CombatScreen({
setEncounterIndex(initialEncounterIndex) setEncounterIndex(initialEncounterIndex)
setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth) setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth)
setCooldowns({}) setCooldowns({})
elapsedTicksRef.current = 0
setElapsedTicks(0) setElapsedTicks(0)
setStatus('playing') setStatus('playing')
setPaused(false) setPaused(false)
@@ -670,7 +672,7 @@ export function CombatScreen({
}, [selectedId]) }, [selectedId])
const selectDirectionalTarget = useCallback((action: InputAction) => { 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) const currentIndex = partyRef.current.findIndex((member) => member.id === selectedId)
if (currentIndex < 0) { if (currentIndex < 0) {
setSelectedId(partyRef.current[0].id) setSelectedId(partyRef.current[0].id)
@@ -711,7 +713,7 @@ export function CombatScreen({
}, [dungeon.partySize, selectedId]) }, [dungeon.partySize, selectedId])
const selectDirectTarget = useCallback((slot: number) => { 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] const member = partyRef.current[index]
if (member) setSelectedId(member.id) if (member) setSelectedId(member.id)
}, [dungeon.partySize, targetGroup]) }, [dungeon.partySize, targetGroup])
@@ -748,6 +750,7 @@ export function CombatScreen({
setParty(recoveredParty) setParty(recoveredParty)
setEncounterIndex((current) => current + 1) setEncounterIndex((current) => current + 1)
setEnemyHealth(nextEncounter.maxHealth) setEnemyHealth(nextEncounter.maxHealth)
elapsedTicksRef.current = 0
setElapsedTicks(0) setElapsedTicks(0)
setCooldowns({}) setCooldowns({})
setResource((value) => clamp(value + Math.round(maxResource * 0.25), 0, maxResource)) setResource((value) => clamp(value + Math.round(maxResource * 0.25), 0, maxResource))
@@ -771,11 +774,12 @@ export function CombatScreen({
return return
} }
if (action === 'toggleTargetGroup') { if (action === 'toggleTargetGroup') {
if (dungeon.partySize !== 10) return if (dungeon.partySize <= 6) return
setTargetGroup((current) => { 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 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) if (nextMember) setSelectedId(nextMember.id)
return next return next
}) })
@@ -798,7 +802,9 @@ export function CombatScreen({
useEffect(() => { useEffect(() => {
if (status !== 'playing' || paused) return if (status !== 'playing' || paused) return
const timer = window.setInterval(() => { 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)) setResource((value) => clamp(value + 2.4, 0, maxResource))
setCooldowns((current) => setCooldowns((current) =>
Object.fromEntries( Object.fromEntries(
@@ -820,19 +826,19 @@ export function CombatScreen({
const primaryTarget = living[Math.floor(Math.random() * living.length)] const primaryTarget = living[Math.floor(Math.random() * living.length)]
const mechanics = (encounter as RoguelikeEncounter).roguelikeMechanics ?? [] const mechanics = (encounter as RoguelikeEncounter).roguelikeMechanics ?? []
const useDefaultBossMechanics = encounter.isBoss && mechanics.length === 0 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')) && (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')) && (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') && 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') && 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') && 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') && 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') && mechanics.includes('ramping-poison')
if (bossPulse) addLog(`${encounter.enemyName} unleashes party-wide damage.`, 'danger') if (bossPulse) addLog(`${encounter.enemyName} unleashes party-wide damage.`, 'danger')
if (appliesDebuff) addLog(`${primaryTarget.name} is afflicted by Searing Mark.`, 'danger') if (appliesDebuff) addLog(`${primaryTarget.name} is afflicted by Searing Mark.`, 'danger')
@@ -957,6 +963,7 @@ export function CombatScreen({
setParty(recoveredParty) setParty(recoveredParty)
setEncounterIndex((value) => value + 1) setEncounterIndex((value) => value + 1)
setEnemyHealth(nextEncounter.maxHealth) setEnemyHealth(nextEncounter.maxHealth)
elapsedTicksRef.current = 0
setElapsedTicks(0) setElapsedTicks(0)
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system') addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
}, TICK_MS) }, TICK_MS)
@@ -965,7 +972,6 @@ export function CombatScreen({
addLog, addLog,
addFloatingHeal, addFloatingHeal,
difficulty.damageMultiplier, difficulty.damageMultiplier,
elapsedTicks,
encounter, encounter,
encounterIndex, encounterIndex,
encounters, encounters,
@@ -1123,7 +1129,7 @@ export function CombatScreen({
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div> <div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
</div> </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) => ( {party.map((member) => (
<button <button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`} className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
@@ -1146,6 +1152,7 @@ export function CombatScreen({
<div className="bar member-health"> <div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} /> <span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / 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>
<div className="floating-combat-texts" aria-hidden="true"> <div className="floating-combat-texts" aria-hidden="true">
{floatingTexts {floatingTexts
@@ -1395,6 +1402,7 @@ export function CombatScreen({
setParty(recoveredParty) setParty(recoveredParty)
setEncounterIndex(nextIndex) setEncounterIndex(nextIndex)
setEnemyHealth(nextEncounter.maxHealth) setEnemyHealth(nextEncounter.maxHealth)
elapsedTicksRef.current = 0
setElapsedTicks(0) setElapsedTicks(0)
setStatus('playing') setStatus('playing')
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system') 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', component: 'Component',
} }
const EQUIPMENT_LIST_PAGE_SIZE = 3
const CRAFTING_LIST_PAGE_SIZE = 6
type Props = { type Props = {
profile: CharacterProfile profile: CharacterProfile
onBack?: () => void onBack?: () => void
@@ -45,6 +48,8 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [crafting, setCrafting] = useState(false) const [crafting, setCrafting] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false) const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment') const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
const [inventoryPage, setInventoryPage] = useState(0)
const [recipePage, setRecipePage] = useState(0)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0) const scrollRef = useRef<number>(0)
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId) 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, (total, item) => total + item.quantity,
0, 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 [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
const [levelFilter, setLevelFilter] = useState<number | null>(null) const [levelFilter, setLevelFilter] = useState<number | null>(null)
@@ -92,11 +105,27 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
}, },
[profile.craftingRecipes, slotFilter, levelFilter], [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(() => { useEffect(() => {
window.scrollTo(0, scrollRef.current) window.scrollTo(0, scrollRef.current)
}, [profile]) }, [profile])
useEffect(() => {
setInventoryPage((current) => Math.min(current, inventoryPageCount - 1))
}, [inventoryPageCount])
useEffect(() => {
setRecipePage((current) => Math.min(current, recipePageCount - 1))
}, [recipePageCount])
useEffect(() => { useEffect(() => {
if (equipmentTab === 'crafting') { if (equipmentTab === 'crafting') {
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {}) loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
@@ -270,6 +299,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
key={slot} key={slot}
onClick={() => { onClick={() => {
setSelectedSlot(slot) setSelectedSlot(slot)
setInventoryPage(0)
const firstSlotItem = profile.inventory.find( const firstSlotItem = profile.inventory.find(
(candidate) => candidate.slot === slot, (candidate) => candidate.slot === slot,
) )
@@ -302,14 +332,17 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
{selectedSlot && ( {selectedSlot && (
<button <button
className="inventory-filter-clear" className="inventory-filter-clear"
onClick={() => setSelectedSlot(null)} onClick={() => {
setSelectedSlot(null)
setInventoryPage(0)
}}
type="button" type="button"
> >
Show All Items Show All Items
</button> </button>
)} )}
<div className="inventory-list"> <div className="inventory-list">
{visibleInventory.map((item) => ( {inventoryPageItems.map((item) => (
<button <button
className={`${selectedItemId === item.id ? 'selected' : ''} rarity-${item.rarity}`} className={`${selectedItemId === item.id ? 'selected' : ''} rarity-${item.rarity}`}
key={item.id} key={item.id}
@@ -333,6 +366,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
</p> </p>
)} )}
</div> </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> </section>
</div> </div>
</> </>
@@ -347,7 +389,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<select <select
className="filter-select" className="filter-select"
value={slotFilter} 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> <option value="all">All Slots</option>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => ( {(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
@@ -357,7 +402,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<select <select
className="filter-select" className="filter-select"
value={levelFilter ?? ''} 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> <option value="">All Levels</option>
{availableLevels.map((level) => ( {availableLevels.map((level) => (
@@ -371,7 +419,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
{filteredRecipes.length > 0 && ( {filteredRecipes.length > 0 && (
<div className="crafting-layout"> <div className="crafting-layout">
<div className="crafting-list"> <div className="crafting-list">
{filteredRecipes.map((recipe) => ( {recipePageItems.map((recipe) => (
<button <button
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`} className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
key={recipe.id} key={recipe.id}
@@ -389,6 +437,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i> <i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
</button> </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> </div>
{selectedRecipe && ( {selectedRecipe && (
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}> <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 }) { function GearStat({ value, label }: { value: string; label: string }) {
return ( return (
<div className="gear-stat"> <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 { Ability, CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository' import type { GameMode } from '../gameRepository'
import { ControllerBindingLabel } from './ControllerIcons' 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 { import {
randomCpuDifficulty, randomCpuDifficulty,
recordCpuPvpLeaderboard, recordCpuPvpLeaderboard,
@@ -238,7 +244,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
encounter.maxHealth encounter.maxHealth
+ encounter.damage * 18 + encounter.damage * 18
+ encounter.tankDamage * 10 + encounter.tankDamage * 10
+ encounter.partyDamage * 12 + encounter.partyDamage * 18
) )
const trashPool = [...pool.filter((encounter) => !encounter.isBoss)] const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
.sort((left, right) => encounterThreat(left) - encounterThreat(right)) .sort((left, right) => encounterThreat(left) - encounterThreat(right))
@@ -366,7 +372,7 @@ export function PvPRoguelikeScreen({
.filter((spell) => spell.unlockLevel === 1) .filter((spell) => spell.unlockLevel === 1)
.slice(0, 5) .slice(0, 5)
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells]) .map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
const [abilityLabelMode, setAbilityLabelMode] = useState<AbilityLabelMode>('ability') const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
const selfBuffChoicesCatalog = useMemo( const selfBuffChoicesCatalog = useMemo(
() => buildSelfBuffChoices(starterSpells, abilityLabelMode), () => buildSelfBuffChoices(starterSpells, abilityLabelMode),
[abilityLabelMode, starterSpells], [abilityLabelMode, starterSpells],
@@ -410,10 +416,14 @@ export function PvPRoguelikeScreen({
const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null) const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null) const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
const [encountersCleared, setEncountersCleared] = useState(0) const [encountersCleared, setEncountersCleared] = useState(0)
const [paused, setPaused] = useState(false)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const nextLogId = useRef(2) const nextLogId = useRef(2)
const nextFloatingTextId = useRef(1) const nextFloatingTextId = useRef(1)
const recordedRunRef = useRef(false) const recordedRunRef = useRef(false)
const rewardClaimedRef = useRef(false) const rewardClaimedRef = useRef(false)
const bossRewardClaimedRef = useRef(new Set<number>())
const cpuDefeatedRef = useRef(false)
const playerClearedEncounterRef = useRef(-1) const playerClearedEncounterRef = useRef(-1)
const playerRef = useRef(playerSide) const playerRef = useRef(playerSide)
const cpuRef = useRef(cpuSide) const cpuRef = useRef(cpuSide)
@@ -431,11 +441,16 @@ export function PvPRoguelikeScreen({
const cpuDone = cpuSide.enemyHealth <= 0 const cpuDone = cpuSide.enemyHealth <= 0
const playerAlive = playerSide.party.some((member) => member.health > 0) const playerAlive = playerSide.party.some((member) => member.health > 0)
const cpuAlive = cpuSide.party.some((member) => member.health > 0) const cpuAlive = cpuSide.party.some((member) => member.health > 0)
const partyColumns = contentType === 'raid' ? 6 : 3
const { const {
bindings, bindings,
controllerIconStyle, controllerIconStyle,
directPartyTargeting,
lastDevice, lastDevice,
} = useInput() } = useInput()
const {
enabled: dualScreenEnabled,
} = useDualScreen()
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60)) setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
}, []) }, [])
@@ -449,18 +464,17 @@ export function PvPRoguelikeScreen({
}, 900) }, 900)
}, []) }, [])
const finishRoguelikeRun = useCallback((cleared: number) => { const awardBossReward = useCallback((encounterIndexValue: number) => {
if (rewardClaimedRef.current) return if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
rewardClaimedRef.current = true bossRewardClaimedRef.current.add(encounterIndexValue)
const bossesCleared = Math.floor(cleared / 3)
completeRoguelike( completeRoguelike(
rewardDungeon.id, rewardDungeon.id,
rewardDifficulty.id, rewardDifficulty.id,
cleared, 0,
0, 0,
Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)), Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
{ {
bossesCleared, bossesCleared: 1,
experienceMode: 'pvp-boss-quarter-level', experienceMode: 'pvp-boss-quarter-level',
}, },
) )
@@ -475,6 +489,11 @@ export function PvPRoguelikeScreen({
}) })
}, [elapsedTicks, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id]) }, [elapsedTicks, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])
const finishRoguelikeRun = useCallback(() => {
if (rewardClaimedRef.current) return
rewardClaimedRef.current = true
}, [])
useEffect(() => { useEffect(() => {
setPlayerBuffChoices((current) => current setPlayerBuffChoices((current) => current
.map((choice) => selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id)) .map((choice) => selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
@@ -501,6 +520,7 @@ export function PvPRoguelikeScreen({
cpuRef.current = baseCpu cpuRef.current = baseCpu
nextLogId.current = 2 nextLogId.current = 2
playerClearedEncounterRef.current = -1 playerClearedEncounterRef.current = -1
bossRewardClaimedRef.current = new Set()
setEncounters(firstSegment) setEncounters(firstSegment)
setEncounterIndex(0) setEncounterIndex(0)
setStage(1) setStage(1)
@@ -514,6 +534,8 @@ export function PvPRoguelikeScreen({
setSelectedBuff(null) setSelectedBuff(null)
setSelectedDebuff(null) setSelectedDebuff(null)
setEncountersCleared(0) setEncountersCleared(0)
setPaused(false)
setTargetGroup(0)
setReward(null) setReward(null)
setRewardError('') setRewardError('')
setShowEndLog(false) setShowEndLog(false)
@@ -521,6 +543,7 @@ export function PvPRoguelikeScreen({
setCpuDifficulty(null) setCpuDifficulty(null)
recordedRunRef.current = false recordedRunRef.current = false
rewardClaimedRef.current = false rewardClaimedRef.current = false
cpuDefeatedRef.current = false
if (gameMode === 'offline') { if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty() const randomCpu = randomCpuDifficulty()
setQueueMessage(`Offline mode. CPU ${randomCpu} enters immediately.`) setQueueMessage(`Offline mode. CPU ${randomCpu} enters immediately.`)
@@ -659,10 +682,45 @@ export function PvPRoguelikeScreen({
setSelectedId(living[nextIndex].id) setSelectedId(living[nextIndex].id)
}, [selectedId]) }, [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 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) if (member?.health > 0) setSelectedId(member.id)
}, []) }, [contentType, targetGroup])
const cpuTakeTurn = useCallback(() => { const cpuTakeTurn = useCallback(() => {
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
@@ -774,7 +832,7 @@ export function PvPRoguelikeScreen({
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
useEffect(() => { useEffect(() => {
if (status !== 'playing' || !encounter) return if (status !== 'playing' || paused || !encounter) return
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
setElapsedTicks((value) => value + 1) setElapsedTicks((value) => value + 1)
cpuTakeTurn() cpuTakeTurn()
@@ -783,6 +841,7 @@ export function PvPRoguelikeScreen({
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) { if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
playerClearedEncounterRef.current = encounterIndex playerClearedEncounterRef.current = encounterIndex
setEncountersCleared((value) => value + 1) setEncountersCleared((value) => value + 1)
if (encounter.isBoss) awardBossReward(encounterIndex)
} }
playerRef.current = nextPlayer playerRef.current = nextPlayer
cpuRef.current = nextCpu cpuRef.current = nextCpu
@@ -791,28 +850,23 @@ export function PvPRoguelikeScreen({
const nextPlayerAlive = nextPlayer.party.some((member) => member.health > 0) const nextPlayerAlive = nextPlayer.party.some((member) => member.health > 0)
const nextCpuAlive = nextCpu.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) { if (!nextPlayerAlive) {
finishRoguelikeRun(clearedCount) finishRoguelikeRun()
setStatus('lost') setStatus('lost')
addLog('Your party fell first.', 'danger') addLog('Your party fell first.', 'danger')
return return
} }
if (!nextCpuAlive) { if (!nextCpuAlive && !cpuDefeatedRef.current) {
finishRoguelikeRun(clearedCount) cpuDefeatedRef.current = true
setStatus('won') addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
addLog(`CPU ${cpuDifficulty ?? 1} fell first.`, 'loot')
return
} }
if (nextPlayer.enemyHealth <= 0 && nextCpu.enemyHealth <= 0) { if (nextPlayer.enemyHealth <= 0) {
addLog(`${encounter.enemyName} cleared by both teams. Choose your next edge.`, 'loot') addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
beginUpgradePhase() beginUpgradePhase()
} }
}, TICK_MS) }, TICK_MS)
return () => window.clearInterval(timer) 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(() => { useEffect(() => {
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return 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]) }, [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(() => { const confirmUpgradeChoices = useCallback(() => {
if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return if (!selectedBuff || !selectedDebuff || !cpuDifficulty) return
const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3) 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]) }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
useGameAction((action) => { 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') { if (action === 'previousTarget') {
selectRelativeTarget(-1) selectRelativeTarget(-1)
return return
@@ -925,41 +997,93 @@ export function PvPRoguelikeScreen({
selectDirectTarget(Number(action.slice('targetParty'.length)) - 1) selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
return 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')) { if (action.startsWith('ability')) {
const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length)) const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
if (spell) castPlayerSpell(spell) if (spell) castPlayerSpell(spell)
} }
}) })
return ( const dualScreenState = useMemo<DualScreenCombatState>(() => ({
<main className="game-shell" data-combat-active={status === 'playing' ? 'true' : 'false'}> difficultyName: `Stage ${stage}`,
<section className="content-screen pvp-match-screen"> dungeonName: encounter.enemyName,
<div className="screen-heading"> contentName: 'PvP Roguelike',
<div> encounterName: encounter.enemyName,
<p className="eyebrow">PvP Roguelike</p> encounterDescription: encounter.description,
<h1>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h1> encounterHealth: playerSide.enemyHealth,
</div> encounterMaxHealth: encounter.maxHealth,
<div className="pvp-screen-tools"> encounterIsBoss: encounter.isBoss,
<div className="roguelike-timing-row"> encounterIndex,
<button encounterCount: encounters.length,
className={`text-button ${abilityLabelMode === 'ability' ? 'active' : ''}`} party: playerSide.party,
onClick={() => setAbilityLabelMode('ability')} partySize: playerSide.party.length,
type="button" selectedId,
> log,
Ability Names status: status === 'queueing' ? 'playing' : status,
</button> resource: playerSide.resource,
<button maxResource,
className={`text-button ${abilityLabelMode === 'slot' ? 'active' : ''}`} resourceName: gameClass.resourceName,
onClick={() => setAbilityLabelMode('slot')} playerIsAlive: playerAlive,
type="button" spells: starterSpells.map((spell, slotIndex) => ({
> ...spell,
Slot Names cost: spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady),
</button> slotIndex,
</div> remaining: playerSide.cooldowns[spell.id] ?? 0,
<button className="back-button" onClick={onExit} type="button">Leave</button> })),
</div> activeDevice: lastDevice,
</div> 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' && ( {status === 'queueing' && (
<div className="placeholder-panel"> <div className="placeholder-panel">
<div className="placeholder-runes">P V P</div> <div className="placeholder-runes">P V P</div>
@@ -967,7 +1091,14 @@ export function PvPRoguelikeScreen({
</div> </div>
)} )}
{status !== 'queueing' && ( {dualScreenEnabled && status !== 'queueing' && (
<DualScreenTopCombat
state={dualScreenState}
onSelectTarget={setSelectedId}
/>
)}
{!dualScreenEnabled && status !== 'queueing' && (
<div className="pvp-board"> <div className="pvp-board">
<section className="combat-panel pvp-side"> <section className="combat-panel pvp-side">
<div className="encounter-header"> <div className="encounter-header">
@@ -982,7 +1113,7 @@ export function PvPRoguelikeScreen({
</div> </div>
</div> </div>
</div> </div>
<div className="party-grid"> <div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
{playerSide.party.map((member) => ( {playerSide.party.map((member) => (
<button <button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`} className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
@@ -998,6 +1129,7 @@ export function PvPRoguelikeScreen({
<div className="bar member-health"> <div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} /> <span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / 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>
<div className="floating-combat-texts" aria-hidden="true"> <div className="floating-combat-texts" aria-hidden="true">
{floatingTexts {floatingTexts
@@ -1087,7 +1219,7 @@ export function PvPRoguelikeScreen({
</div> </div>
</div> </div>
</div> </div>
<div className="party-grid"> <div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
{cpuSide.party.map((member) => ( {cpuSide.party.map((member) => (
<div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={`cpu-${member.id}`}> <div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={`cpu-${member.id}`}>
<div className="member-header"> <div className="member-header">
@@ -1098,6 +1230,7 @@ export function PvPRoguelikeScreen({
<div className="bar member-health"> <div className="bar member-health">
<span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} /> <span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / 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>
<div className="floating-combat-texts" aria-hidden="true"> <div className="floating-combat-texts" aria-hidden="true">
{floatingTexts {floatingTexts
@@ -1125,9 +1258,6 @@ export function PvPRoguelikeScreen({
{status === 'upgrade-choice' && ( {status === 'upgrade-choice' && (
<div className="result-screen"> <div className="result-screen">
<div className="pvp-upgrade-dialog"> <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 className="pvp-choice-columns">
<div> <div>
<strong>Self Buff</strong> <strong>Self Buff</strong>
@@ -1169,6 +1299,17 @@ export function PvPRoguelikeScreen({
</div> </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') && ( {(status === 'won' || status === 'lost') && (
<div className="result-screen"> <div className="result-screen">
<div> <div>
@@ -1176,7 +1317,7 @@ export function PvPRoguelikeScreen({
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2> <h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
<p>{finalEncountersCleared} encounters cleared.</p> <p>{finalEncountersCleared} encounters cleared.</p>
<div className="reward-summary"> <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>} {rewardError && <p className="reward-error">{rewardError}</p>}
{reward && ( {reward && (
<> <>
+155 -126
View File
@@ -24,6 +24,7 @@ import {
export function SettingsScreen({ onBack }: { onBack: () => void }) { export function SettingsScreen({ onBack }: { onBack: () => void }) {
const [device, setDevice] = useState<InputDevice>('controller') const [device, setDevice] = useState<InputDevice>('controller')
const [settingsTab, setSettingsTab] = useState<'display' | 'input' | 'bindings'>('display')
const [displayMessage, setDisplayMessage] = useState('') const [displayMessage, setDisplayMessage] = useState('')
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([]) const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
const { const {
@@ -50,6 +51,7 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
'targetParty3', 'targetParty3',
'targetParty4', 'targetParty4',
'targetParty5', 'targetParty5',
'targetParty6',
'toggleTargetGroup', 'toggleTargetGroup',
]) ])
const visibleActions = INPUT_ACTIONS.filter((action) => ( const visibleActions = INPUT_ACTIONS.filter((action) => (
@@ -95,138 +97,165 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
<button className="back-button" onClick={onBack} type="button">Back</button> <button className="back-button" onClick={onBack} type="button">Back</button>
</div> </div>
<section className="dual-screen-settings"> <nav className="settings-tabs" role="tablist" aria-label="Settings sections">
<div> {([
<p className="eyebrow">Display</p> { key: 'display', label: 'Display' },
<h2>AYN Thor Dual-Screen Mode</h2> { key: 'input', label: 'Input' },
<p> { key: 'bindings', label: 'Bindings' },
The upper display shows enemy and party health. The lower display ] as const).map((tab) => (
keeps targeting, resources, skills, and cooldowns.
</p>
</div>
<div className="dual-screen-actions">
<button <button
className={dualScreenEnabled ? 'selected' : ''} aria-selected={settingsTab === tab.key}
onClick={() => { className={settingsTab === tab.key ? 'selected' : ''}
setDualScreenEnabled(!dualScreenEnabled) key={tab.key}
setDisplayMessage('') onClick={() => setSettingsTab(tab.key)}
}} role="tab"
type="button" type="button"
> >
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'} {tab.label}
</button>
<button onClick={launchTopDisplay} type="button">
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
</button>
</div>
<small>
{displayMessage || (
topDisplayConnected
? 'The companion display is connected and receiving live combat data.'
: 'Open the companion display before starting combat.'
)}
</small>
{nativeDualScreen && androidDisplays.length > 0 && (
<div className="android-display-list">
{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.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">
<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.
</p>
</div>
<button
aria-pressed={directPartyTargeting}
className={directPartyTargeting ? 'selected' : ''}
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
type="button"
>
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button>
<div className="controller-icon-options">
<span>Controller Icons</span>
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
<button
aria-pressed={controllerIconStyle === style}
className={controllerIconStyle === style ? 'selected' : ''}
key={style}
onClick={() => setControllerIconStyle(style)}
type="button"
>
<ControllerStylePreview iconStyle={style} />
<span className="controller-style-name">{CONTROLLER_STYLE_LABELS[style]}</span>
</button>
))}
</div>
</section>
<div className="binding-tabs">
<button
className={device === 'controller' ? 'selected' : ''}
onClick={() => setDevice('controller')}
type="button"
>
Controller
</button>
<button
className={device === 'pc' ? 'selected' : ''}
onClick={() => setDevice('pc')}
type="button"
>
PC
</button>
</div>
<div className="binding-list">
{visibleActions.map((action) => (
<button
className={capture?.device === device && capture.action === action ? 'listening' : ''}
key={action}
onClick={() => beginCapture(device, action)}
type="button"
>
<span>{ACTION_LABELS[action]}</span>
<kbd>
{capture?.device === device && capture.action === action
? 'Press a control...'
: (
<ControllerBindingLabel
binding={bindings[device][action]}
iconStyle={controllerIconStyle}
/>
)}
</kbd>
</button> </button>
))} ))}
</div> </nav>
<footer className="settings-footer"> {settingsTab === 'display' && (
<span>Bindings are saved automatically on this device.</span> <section className="dual-screen-settings settings-tab-panel">
<button className="text-button" onClick={() => resetBindings(device)} type="button"> <div>
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults <p className="eyebrow">Display</p>
</button> <h2>AYN Thor Dual-Screen Mode</h2>
</footer> <p>
The upper display shows enemy and party health. The lower display
keeps targeting, resources, skills, and cooldowns.
</p>
</div>
<div className="dual-screen-actions">
<button
className={dualScreenEnabled ? 'selected' : ''}
onClick={() => {
setDualScreenEnabled(!dualScreenEnabled)
setDisplayMessage('')
}}
type="button"
>
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
</button>
<button onClick={launchTopDisplay} type="button">
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
</button>
</div>
<small>
{displayMessage || (
topDisplayConnected
? 'The companion display is connected and receiving live combat data.'
: 'Open the companion display before starting combat.'
)}
</small>
{nativeDualScreen && androidDisplays.length > 0 && (
<div className="android-display-list">
{androidDisplays.map((display) => (
<span key={display.id}>
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
{display.width}x{display.height} at {Math.round(display.refreshRate)} Hz
{display.isPresentation ? ' - Presentation' : ''}
</span>
))}
</div>
)}
</section>
)}
{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-6, 7-12, and 13-18.
</p>
</div>
<button
aria-pressed={directPartyTargeting}
className={directPartyTargeting ? 'selected' : ''}
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
type="button"
>
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</button>
<div className="controller-icon-options">
<span>Controller Icons</span>
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
<button
aria-pressed={controllerIconStyle === style}
className={controllerIconStyle === style ? 'selected' : ''}
key={style}
onClick={() => setControllerIconStyle(style)}
type="button"
>
<ControllerStylePreview iconStyle={style} />
<span className="controller-style-name">{CONTROLLER_STYLE_LABELS[style]}</span>
</button>
))}
</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
className={device === 'controller' ? 'selected' : ''}
onClick={() => setDevice('controller')}
type="button"
>
Controller
</button>
<button
className={device === 'pc' ? 'selected' : ''}
onClick={() => setDevice('pc')}
type="button"
>
PC
</button>
</div>
<div className="binding-list">
{visibleActions.map((action) => (
<button
className={capture?.device === device && capture.action === action ? 'listening' : ''}
key={action}
onClick={() => beginCapture(device, action)}
type="button"
>
<span>{ACTION_LABELS[action]}</span>
<kbd>
{capture?.device === device && capture.action === action
? 'Press a control...'
: (
<ControllerBindingLabel
binding={bindings[device][action]}
iconStyle={controllerIconStyle}
/>
)}
</kbd>
</button>
))}
</div>
<footer className="settings-footer">
<span>Bindings are saved automatically on this device.</span>
<button className="text-button" onClick={() => resetBindings(device)} type="button">
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
</button>
</footer>
</section>
)}
{capture && ( {capture && (
<div className="binding-capture" role="dialog" aria-modal="true"> <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) { export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
const [busyTalentId, setBusyTalentId] = useState<number | null>(null) const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
const [talentPage, setTalentPage] = useState(0)
const [resetting, setResetting] = useState(false) const [resetting, setResetting] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0) const scrollRef = useRef<number>(0)
@@ -28,6 +29,11 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
const tiers = Array.from( const tiers = Array.from(
new Set(gameClass.talents.map((talent) => talent.tier)), new Set(gameClass.talents.map((talent) => talent.tier)),
).sort((a, b) => a - b) ).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(() => { useEffect(() => {
window.scrollTo(0, scrollRef.current) window.scrollTo(0, scrollRef.current)
@@ -123,8 +129,23 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
</div> </div>
</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"> <div className="talent-tree">
{tiers.map((tier) => { {visibleTiers.map((tier) => {
const requiredPoints = (tier - 1) * 5 const requiredPoints = (tier - 1) * 5
return ( return (
<section className="talent-tier" key={tier}> <section className="talent-tier" key={tier}>
+80 -13
View File
@@ -11,6 +11,7 @@ import {
} from 'react' } from 'react'
import type { CombatLogEntry, PartyMember, Spell } from './game' import type { CombatLogEntry, PartyMember, Spell } from './game'
import { import {
getNativeDisplays,
hasNativeDualScreenBridge, hasNativeDualScreenBridge,
openNativeTopDisplay, openNativeTopDisplay,
} from './nativeDualScreen' } from './nativeDualScreen'
@@ -23,6 +24,7 @@ import { ControllerBindingLabel } from './components/ControllerIcons'
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled' const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
const SNAPSHOT_KEY = 'ashen-halls-dual-screen-snapshot' 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' const CHANNEL_NAME = 'ashen-halls-dual-screen'
export type DualScreenCombatState = { export type DualScreenCombatState = {
@@ -51,7 +53,7 @@ export type DualScreenCombatState = {
controllerIconStyle: ControllerIconStyle controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean directPartyTargeting: boolean
paused: boolean paused: boolean
targetGroup: 0 | 1 targetGroup: 0 | 1 | 2
} }
type DualScreenMessage = type DualScreenMessage =
@@ -172,6 +174,73 @@ export function useDualScreen() {
return context 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( export function useDualScreenPublisher(
state: DualScreenCombatState, state: DualScreenCombatState,
enabled: boolean, enabled: boolean,
@@ -280,9 +349,9 @@ export function DualScreenBottomDisplay() {
<div className={`dual-controls-targets ${state.directPartyTargeting ? 'direct' : ''}`}> <div className={`dual-controls-targets ${state.directPartyTargeting ? 'direct' : ''}`}>
{state.directPartyTargeting ? ( {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 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 ( return (
<button onClick={() => sendAction(action)} type="button" key={action}> <button onClick={() => sendAction(action)} type="button" key={action}>
<ControllerBindingLabel <ControllerBindingLabel
@@ -293,13 +362,13 @@ export function DualScreenBottomDisplay() {
</button> </button>
) )
})} })}
{state.partySize === 10 && ( {state.partySize > 6 && (
<button onClick={() => sendAction('toggleTargetGroup')} type="button"> <button onClick={() => sendAction('toggleTargetGroup')} type="button">
<ControllerBindingLabel <ControllerBindingLabel
binding={state.bindings.toggleTargetGroup} binding={state.bindings.toggleTargetGroup}
iconStyle={state.controllerIconStyle} iconStyle={state.controllerIconStyle}
/>{' '} />{' '}
Party Group {state.targetGroup + 1} Party Group {state.targetGroup + 1}/{Math.ceil(state.partySize / 6)}
</button> </button>
)} )}
</> </>
@@ -396,11 +465,13 @@ export function DualScreenTopCombat({
</section> </section>
<section className="dual-top-party"> <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) => { {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 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 ( return (
<button <button
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`} className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
@@ -418,6 +489,7 @@ export function DualScreenTopCombat({
{member.shield > 0 && ( {member.shield > 0 && (
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} /> <i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
)} )}
<em className="health-text">{Math.ceil(member.health)} / {member.maxHealth}</em>
</div> </div>
{state.directPartyTargeting && targetBinding && ( {state.directPartyTargeting && targetBinding && (
<div className="member-target-key"> <div className="member-target-key">
@@ -437,11 +509,6 @@ export function DualScreenTopCombat({
</div> </div>
</section> </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> </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: '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: '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: '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[] = [ 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: '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: '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: '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[] = [ export const SPELLS: Spell[] = [
+508 -135
View File
@@ -1,5 +1,6 @@
import starterProfile from './offline-starter-profile.json' import starterProfile from './offline-starter-profile.json'
import type { import type {
Account,
AuthSession, AuthSession,
CharacterProfile, CharacterProfile,
DungeonReward, DungeonReward,
@@ -69,37 +70,65 @@ type OfflineSave = {
lootRolls: Record<string, LootRoll> 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 offlineSaveKey = 'chronicle.offlineSave.v1'
const onlineCacheKey = 'chronicle.onlineCache.v1'
const offlineAccount = { id: -1, username: 'Offline' } const offlineAccount = { id: -1, username: 'Offline' }
function clone<T>(value: T): T { function clone<T>(value: T): T {
return structuredClone(value) return structuredClone(value)
} }
function readMode(): GameMode { function toGameMode(mode: RepositoryMode): GameMode {
return localStorage.getItem(modeKey) === 'offline' ? 'offline' : 'online' return mode === 'online' ? 'online' : 'offline'
} }
function writeMode(mode: GameMode) { function dispatchModeChange(mode: RepositoryMode) {
localStorage.setItem(modeKey, mode) if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent<GameMode>('chronicle:mode-changed', {
detail: toGameMode(mode),
}))
} }
function readOfflineSave(): OfflineSave | null { function readMode(): RepositoryMode {
const serialized = localStorage.getItem(offlineSaveKey) const stored = localStorage.getItem(modeKey)
if (!serialized) return null if (stored === 'offline-cached' || stored === 'offline-local' || stored === 'online') {
try { return stored
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
} }
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 p = v1.profile
const classes = [1, 2, 3] const classes = [1, 2, 3]
const characters: Record<number, CharacterData> = {} 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) : [], inventory: cid === p.character.classId ? clone(p.inventory) : [],
} }
} }
const v2: OfflineSave = { return {
version: 3, version: 3,
characterName: p.character.name, characterName: p.character.name,
activeClassId: p.character.classId, activeClassId: p.character.classId,
@@ -127,28 +156,98 @@ function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string
characters, characters,
lootRolls: v1.lootRolls ?? {}, lootRolls: v1.lootRolls ?? {},
} }
localStorage.setItem(offlineSaveKey, JSON.stringify(v2))
return v2
} }
function migrateV2ToV3(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave { function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
const v3: OfflineSave = { return {
...v2, ...v2,
version: 3, version: 3,
completedRaidPhases: 0, 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) { function writeOfflineSave(save: OfflineSave) {
localStorage.setItem(offlineSaveKey, JSON.stringify(save)) localStorage.setItem(offlineSaveKey, JSON.stringify(save))
} }
function requireOfflineSave(): OfflineSave { function readOnlineCache(): OnlineCache | null {
const save = readOfflineSave() const serialized = localStorage.getItem(onlineCacheKey)
if (!save) throw new Error('No offline character exists yet.') if (!serialized) return null
return save 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 { 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) 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 { function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]): T {
const totalWeight = entries.reduce((total, entry) => total + entry.dropWeight, 0) const totalWeight = entries.reduce((total, entry) => total + entry.dropWeight, 0)
let weightedRoll = Math.random() * totalWeight let weightedRoll = Math.random() * totalWeight
@@ -335,66 +468,197 @@ function getApiBaseUrl(): string {
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> { async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = getApiBaseUrl() const baseUrl = getApiBaseUrl()
const url = baseUrl ? `${baseUrl}${path}` : path 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() const body = await response.json()
if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.') if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.')
return body 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 = { const serverRepository: GameRepository = {
loadSession: () => requestJson('/api/auth/session'), loadSession: async () => {
register: (username, password, characterName) => try {
requestJson('/api/auth/register', { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, characterName }), body: JSON.stringify({ username, password, characterName }),
}), })),
login: (username, password) => login: async (username, password) =>
requestJson('/api/auth/login', { finalizeOnlineSession(await requestJson('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}), })),
logout: async () => { logout: async () => {
await requestJson('/api/auth/logout', { method: 'POST' }) 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) => saveProfile: (classId, abilitySlots) =>
requestJson('/api/profile', { cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ classId, abilitySlots }),
}),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
requestJson(`/api/dungeons/${dungeonId}/complete`, { cachedOnlineLocalRepository.completeDungeon(
method: 'POST', dungeonId,
headers: { 'Content-Type': 'application/json' }, difficultyId,
body: JSON.stringify({ difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds }), resourceSpent,
}), durationSeconds,
completedPart,
startPart,
partDurationSeconds,
),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
requestJson('/api/roguelike/complete', { cachedOnlineLocalRepository.completeRoguelike(
method: 'POST', dungeonId,
headers: { 'Content-Type': 'application/json' }, difficultyId,
body: JSON.stringify({ dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, ...options }), encountersCleared,
}), resourceSpent,
durationSeconds,
options,
),
allocateTalent: (talentId) => allocateTalent: (talentId) =>
requestJson(`/api/talents/${talentId}/allocate`, { method: 'POST' }), cachedOnlineLocalRepository.allocateTalent(talentId),
resetTalents: () => resetTalents: () =>
requestJson('/api/talents/reset', { method: 'POST' }), cachedOnlineLocalRepository.resetTalents(),
equipItem: (itemId) => equipItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/equip`, { method: 'POST' }), cachedOnlineLocalRepository.equipItem(itemId),
discardExtraItem: (itemId) => discardExtraItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/discard-extra`, { method: 'POST' }), cachedOnlineLocalRepository.discardExtraItem(itemId),
breakdownItem: (itemId) => breakdownItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/breakdown`, { method: 'POST' }), cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) => craftItem: (recipeId) =>
requestJson(`/api/crafting/recipes/${recipeId}/craft`, { method: 'POST' }), cachedOnlineLocalRepository.craftItem(recipeId),
rollEncounterLoot: (encounterId, difficultyId, runToken) => rollEncounterLoot: (encounterId, difficultyId, runToken) =>
requestJson(`/api/encounters/${encounterId}/loot-roll`, { cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, runToken }),
}),
} }
function emptyCharacterData(classId: number): CharacterData { function emptyCharacterData(classId: number): CharacterData {
@@ -419,28 +683,35 @@ function emptyCharacterData(classId: number): CharacterData {
} }
} }
const offlineRepository: GameRepository = { function requireStoredSave(store: LocalSaveStore): OfflineSave {
async loadSession() { const save = store.readSave()
const save = readOfflineSave() if (!save) throw new Error('No local character exists yet.')
return { return save
account: save ? offlineAccount : null, }
profile: save ? buildProfile(save) : null,
} function createLocalRepository(store: LocalSaveStore): GameRepository {
}, return {
async register() { async loadSession() {
throw new Error('Account registration requires online mode.') const save = store.readSave()
}, return {
async login() { account: save ? (store.readAccount() ?? offlineAccount) : null,
throw new Error('Account login requires online mode.') profile: save ? buildProfile(save) : null,
}, }
async logout() { },
writeMode('online') async register() {
}, throw new Error('Account registration requires online mode.')
async loadProfile() { },
return buildProfile(requireOfflineSave()) async login() {
}, throw new Error('Account login requires online mode.')
async saveProfile(classId, abilitySlots) { },
const save = requireOfflineSave() async logout() {
writeMode('online')
},
async loadProfile() {
return buildProfile(requireStoredSave(store))
},
async saveProfile(classId, abilitySlots) {
const save = requireStoredSave(store)
const static_ = clone(starterProfile) as CharacterProfile const static_ = clone(starterProfile) as CharacterProfile
const gameClass = static_.classes.find((candidate) => candidate.id === classId) const gameClass = static_.classes.find((candidate) => candidate.id === classId)
if (!gameClass) throw new Error('Selected class does not exist.') if (!gameClass) throw new Error('Selected class does not exist.')
@@ -466,10 +737,10 @@ const offlineRepository: GameRepository = {
} }
save.characters[classId].abilitySlots = slots save.characters[classId].abilitySlots = slots
save.activeClassId = classId save.activeClassId = classId
writeOfflineSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) { async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
void startPart void startPart
void partDurationSeconds void partDurationSeconds
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) { if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
@@ -478,7 +749,7 @@ const offlineRepository: GameRepository = {
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) { if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.') throw new Error('The run duration is invalid.')
} }
const save = requireOfflineSave() const save = requireStoredSave(store)
const profile = buildProfile(save) const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId) const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find( const difficulty = dungeon?.difficulties.find(
@@ -560,7 +831,7 @@ const offlineRepository: GameRepository = {
} }
} }
writeOfflineSave(save) store.writeSave(save)
const updatedProfile = buildProfile(save) const updatedProfile = buildProfile(save)
return { return {
@@ -579,8 +850,8 @@ const offlineRepository: GameRepository = {
bonusItem, bonusItem,
profile: updatedProfile, profile: updatedProfile,
} }
}, },
async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) { async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) {
if (!Number.isInteger(encountersCleared) || encountersCleared < 0) { if (!Number.isInteger(encountersCleared) || encountersCleared < 0) {
throw new Error('The roguelike progress total is invalid.') throw new Error('The roguelike progress total is invalid.')
} }
@@ -590,7 +861,7 @@ const offlineRepository: GameRepository = {
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) { if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.') throw new Error('The run duration is invalid.')
} }
const save = requireOfflineSave() const save = requireStoredSave(store)
const profile = buildProfile(save) const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId) const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find( const difficulty = dungeon?.difficulties.find(
@@ -644,7 +915,7 @@ const offlineRepository: GameRepository = {
cd.talentPoints + levelsGained, cd.talentPoints + levelsGained,
) )
writeOfflineSave(save) store.writeSave(save)
const updatedProfile = buildProfile(save) const updatedProfile = buildProfile(save)
return { return {
@@ -663,9 +934,9 @@ const offlineRepository: GameRepository = {
bonusItem: null, bonusItem: null,
profile: updatedProfile, profile: updatedProfile,
} }
}, },
async allocateTalent(talentId) { async allocateTalent(talentId) {
const save = requireOfflineSave() const save = requireStoredSave(store)
const profile = buildProfile(save) const profile = buildProfile(save)
const cd = save.characters[save.activeClassId] const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find( const gameClass = profile.classes.find(
@@ -698,11 +969,11 @@ const offlineRepository: GameRepository = {
} }
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1 cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
cd.talentPoints -= 1 cd.talentPoints -= 1
writeOfflineSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
async resetTalents() { async resetTalents() {
const save = requireOfflineSave() const save = requireStoredSave(store)
const profile = buildProfile(save) const profile = buildProfile(save)
const cd = save.characters[save.activeClassId] const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find( const gameClass = profile.classes.find(
@@ -719,11 +990,11 @@ const offlineRepository: GameRepository = {
profile.maxTalentPoints, profile.maxTalentPoints,
cd.talentPoints + refunded, cd.talentPoints + refunded,
) )
writeOfflineSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
async equipItem(itemId) { async equipItem(itemId) {
const save = requireOfflineSave() const save = requireStoredSave(store)
const profile = buildProfile(save) const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId) const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.') 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 if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
} }
save.characters[save.activeClassId].inventory = profile.inventory save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
async discardExtraItem(itemId) { async discardExtraItem(itemId) {
const save = requireOfflineSave() const save = requireStoredSave(store)
const profile = buildProfile(save) const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId) const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.') 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.') if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
item.quantity -= 1 item.quantity -= 1
save.characters[save.activeClassId].inventory = profile.inventory save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
async breakdownItem(itemId) { async breakdownItem(itemId) {
const save = requireOfflineSave() const save = requireStoredSave(store)
const profile = buildProfile(save) const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId) const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.') 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 save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
async craftItem(recipeId) { async craftItem(recipeId) {
const save = requireOfflineSave() const save = requireStoredSave(store)
const profile = buildProfile(save) const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId) const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.') if (!recipe) throw new Error('That crafting recipe does not exist.')
@@ -809,14 +1080,14 @@ const offlineRepository: GameRepository = {
addInventoryItem(profile.inventory, recipe.item, 1) addInventoryItem(profile.inventory, recipe.item, 1)
save.characters[save.activeClassId].inventory = profile.inventory save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
async rollEncounterLoot(encounterId, difficultyId, runToken) { async rollEncounterLoot(encounterId, difficultyId, runToken) {
if (runToken.length < 8 || runToken.length > 100) { if (runToken.length < 8 || runToken.length > 100) {
throw new Error('A valid dungeon run token is required.') throw new Error('A valid dungeon run token is required.')
} }
const save = requireOfflineSave() const save = requireStoredSave(store)
const rollKey = `${runToken}:${encounterId}:${difficultyId}` const rollKey = `${runToken}:${encounterId}:${difficultyId}`
if (save.lootRolls[rollKey]) return clone(save.lootRolls[rollKey]) if (save.lootRolls[rollKey]) return clone(save.lootRolls[rollKey])
@@ -894,13 +1165,112 @@ const offlineRepository: GameRepository = {
} }
save.lootRolls[rollKey] = result save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save) store.writeSave(save)
return clone(result) 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 { 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() { export function selectOnlineMode() {
@@ -926,14 +1296,14 @@ export function createOfflineCharacter(characterName: string): AuthSession {
lootRolls: {}, lootRolls: {},
} }
writeOfflineSave(save) writeOfflineSave(save)
writeMode('offline') writeMode('offline-local')
return { account: offlineAccount, profile: buildProfile(save) } return { account: offlineAccount, profile: buildProfile(save) }
} }
export function resumeOfflineCharacter(): AuthSession | null { export function resumeOfflineCharacter(): AuthSession | null {
const save = readOfflineSave() const save = readOfflineSave()
if (!save) return null if (!save) return null
writeMode('offline') writeMode('offline-local')
return { account: offlineAccount, profile: buildProfile(save) } return { account: offlineAccount, profile: buildProfile(save) }
} }
@@ -942,5 +1312,8 @@ export function hasOfflineCharacter(): boolean {
} }
export function activeGameRepository(): GameRepository { 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; margin: 0;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
overflow: hidden;
}
#root {
height: 100dvh;
overflow: hidden;
} }
+66 -8
View File
@@ -33,6 +33,7 @@ export const INPUT_ACTIONS = [
'targetParty3', 'targetParty3',
'targetParty4', 'targetParty4',
'targetParty5', 'targetParty5',
'targetParty6',
'toggleTargetGroup', 'toggleTargetGroup',
'pause', 'pause',
] as const ] as const
@@ -60,6 +61,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
targetParty3: 'Target Party Member 3', targetParty3: 'Target Party Member 3',
targetParty4: 'Target Party Member 4', targetParty4: 'Target Party Member 4',
targetParty5: 'Target Party Member 5', targetParty5: 'Target Party Member 5',
targetParty6: 'Target Party Member 6',
toggleTargetGroup: 'Switch Raid Target Group', toggleTargetGroup: 'Switch Raid Target Group',
pause: 'Pause Menu', pause: 'Pause Menu',
} }
@@ -85,6 +87,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'F3', targetParty3: 'F3',
targetParty4: 'F4', targetParty4: 'F4',
targetParty5: 'F5', targetParty5: 'F5',
targetParty6: 'F6',
toggleTargetGroup: 'Tab', toggleTargetGroup: 'Tab',
pause: 'Escape', pause: 'Escape',
}, },
@@ -108,6 +111,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'Button15', targetParty3: 'Button15',
targetParty4: 'Button13', targetParty4: 'Button13',
targetParty5: 'Button4', targetParty5: 'Button4',
targetParty6: 'Button11',
toggleTargetGroup: 'Button6', toggleTargetGroup: 'Button6',
pause: 'Button9', pause: 'Button9',
}, },
@@ -202,13 +206,19 @@ function bindingGroup(action: InputAction) {
} }
function isVisible(element: HTMLElement) { function isVisible(element: HTMLElement) {
if (element.hidden || element.getAttribute('aria-hidden') === 'true') return false
return element.getClientRects().length > 0 return element.getClientRects().length > 0
} }
function focusableElements() { function focusableElements() {
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard') const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen') 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( return Array.from(
scope.querySelectorAll<HTMLElement>( scope.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])', '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 const next = ranked[0]?.candidate
if (!next) return if (!next) return
next.focus({ preventScroll: true }) 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> = { const BUTTON_LABELS: Record<number, string> = {
@@ -372,6 +397,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const keyboardInputRef = useRef(keyboardInput) const keyboardInputRef = useRef(keyboardInput)
const previousTokensRef = useRef(new Set<string>()) const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({}) const repeatRef = useRef<Record<string, number>>({})
const lastCombatNavigationRef = useRef(0)
useEffect(() => { useEffect(() => {
bindingsRef.current = bindings bindingsRef.current = bindings
@@ -416,18 +442,29 @@ export function InputProvider({ children }: { children: ReactNode }) {
}, []) }, [])
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => { 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) setLastDevice(device)
document.documentElement.dataset.inputDevice = device document.documentElement.dataset.inputDevice = device
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
if (action.startsWith('navigate')) { if (action.startsWith('navigate')) {
if (!combatActive) moveFocus(action) if (uiOverlay || !combatActive) moveFocus(action)
} else if (action === 'confirm') { } else if (action === 'confirm') {
const active = document.activeElement const active = document.activeElement
if (isTextInput(active)) { if (isTextInput(active)) {
setKeyboardInput(active) setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl()) 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() active.click()
} else { } else {
focusFirstControl() focusFirstControl()
@@ -435,7 +472,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
} else if (action === 'back') { } else if (action === 'back') {
if (keyboardInputRef.current) { if (keyboardInputRef.current) {
closeKeyboard() closeKeyboard()
} else if (!combatActive) { } else if (uiOverlay || !combatActive) {
const backButton = Array.from( const backButton = Array.from(
document.querySelectorAll<HTMLButtonElement>('.back-button:not(:disabled)'), document.querySelectorAll<HTMLButtonElement>('.back-button:not(:disabled)'),
).find(isVisible) ).find(isVisible)
@@ -458,18 +495,28 @@ export function InputProvider({ children }: { children: ReactNode }) {
const combatActive = Boolean( const combatActive = Boolean(
document.querySelector('[data-combat-active="true"]'), document.querySelector('[data-combat-active="true"]'),
) )
const uiOverlay = hasUiOverlay()
const menuDpadActions: Partial<Record<string, InputAction>> = { const menuDpadActions: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp', Button12: 'navigateUp',
Button13: 'navigateDown', Button13: 'navigateDown',
Button14: 'navigateLeft', Button14: 'navigateLeft',
Button15: 'navigateRight', Button15: 'navigateRight',
} }
const uiPriority = [
'navigateUp',
'navigateDown',
'navigateLeft',
'navigateRight',
'confirm',
'back',
] satisfies InputAction[]
const directTargetActions = [ const directTargetActions = [
'targetParty1', 'targetParty1',
'targetParty2', 'targetParty2',
'targetParty3', 'targetParty3',
'targetParty4', 'targetParty4',
'targetParty5', 'targetParty5',
'targetParty6',
'toggleTargetGroup', 'toggleTargetGroup',
] satisfies InputAction[] ] satisfies InputAction[]
const combatPriority = [ const combatPriority = [
@@ -487,7 +534,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft', 'navigateLeft',
'navigateRight', 'navigateRight',
] satisfies InputAction[] ] 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( ? [...directTargetActions, ...combatPriority].find(
(candidate) => bindingsRef.current.controller[candidate] === token, (candidate) => bindingsRef.current.controller[candidate] === token,
) )
@@ -541,8 +592,13 @@ export function InputProvider({ children }: { children: ReactNode }) {
const ensureFocus = () => { const ensureFocus = () => {
const combatActive = document.querySelector('[data-combat-active="true"]') const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return if (combatActive) return
const candidates = focusableElements()
const active = document.activeElement
const activeIsUsable = active instanceof HTMLElement
&& candidates.includes(active)
&& isVisible(active)
if ( if (
document.activeElement === document.body (!activeIsUsable || document.activeElement === document.body)
&& !keyboardInputRef.current && !keyboardInputRef.current
&& !captureRef.current && !captureRef.current
) { ) {
@@ -553,6 +609,8 @@ export function InputProvider({ children }: { children: ReactNode }) {
window.requestAnimationFrame(ensureFocus) window.requestAnimationFrame(ensureFocus)
}) })
observer.observe(document.getElementById('root') ?? document.body, { observer.observe(document.getElementById('root') ?? document.body, {
attributes: true,
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'],
childList: true, childList: true,
subtree: true, subtree: true,
}) })
+16 -2
View File
@@ -1,9 +1,10 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { Capacitor } from '@capacitor/core'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { InputProvider } from './input.tsx' import { InputProvider } from './input.tsx'
import { DualScreenBottomDisplay, DualScreenProvider } from './dualScreen.tsx' import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt } from './dualScreen.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
@@ -11,6 +12,7 @@ createRoot(document.getElementById('root')!).render(
<DualScreenBottomDisplay /> <DualScreenBottomDisplay />
) : ( ) : (
<DualScreenProvider> <DualScreenProvider>
<DualScreenStartupPrompt />
<InputProvider> <InputProvider>
<App /> <App />
</InputProvider> </InputProvider>
@@ -19,7 +21,19 @@ createRoot(document.getElementById('root')!).render(
</StrictMode>, </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', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js').catch(() => { navigator.serviceWorker.register('/service-worker.js').catch(() => {
// Offline launch remains optional when registration is unavailable. // Offline launch remains optional when registration is unavailable.
+9 -9
View File
@@ -4918,7 +4918,7 @@
"name": "Bulldrome Hunting Ground", "name": "Bulldrome Hunting Ground",
"recommendedLevel": 1, "recommendedLevel": 1,
"contentType": "dungeon", "contentType": "dungeon",
"partySize": 5, "partySize": 6,
"completionItemLevel": null, "completionItemLevel": null,
"experienceReward": 125, "experienceReward": 125,
"description": "A three-boss hunt featuring Bulldrome, Yian Kut-Ku, and Rathian.", "description": "A three-boss hunt featuring Bulldrome, Yian Kut-Ku, and Rathian.",
@@ -6289,7 +6289,7 @@
"name": "Tigrex Raid", "name": "Tigrex Raid",
"recommendedLevel": 5, "recommendedLevel": 5,
"contentType": "raid", "contentType": "raid",
"partySize": 10, "partySize": 18,
"completionItemLevel": null, "completionItemLevel": null,
"experienceReward": 275, "experienceReward": 275,
"description": "A raid-scale hunt against Tigrex, Rathalos, and Gypceros.", "description": "A raid-scale hunt against Tigrex, Rathalos, and Gypceros.",
@@ -6700,7 +6700,7 @@
"name": "Tigrex Hunting Ground", "name": "Tigrex Hunting Ground",
"recommendedLevel": 5, "recommendedLevel": 5,
"contentType": "dungeon", "contentType": "dungeon",
"partySize": 5, "partySize": 6,
"completionItemLevel": null, "completionItemLevel": null,
"experienceReward": 205, "experienceReward": 205,
"description": "A three-boss hunt featuring Tigrex, Rathalos, and Gypceros.", "description": "A three-boss hunt featuring Tigrex, Rathalos, and Gypceros.",
@@ -7111,7 +7111,7 @@
"name": "Nargacuga Hunting Ground", "name": "Nargacuga Hunting Ground",
"recommendedLevel": 10, "recommendedLevel": 10,
"contentType": "dungeon", "contentType": "dungeon",
"partySize": 5, "partySize": 6,
"completionItemLevel": null, "completionItemLevel": null,
"experienceReward": 245, "experienceReward": 245,
"description": "A three-boss hunt featuring Nargacuga, Azuros, and Diablos.", "description": "A three-boss hunt featuring Nargacuga, Azuros, and Diablos.",
@@ -7522,7 +7522,7 @@
"name": "Nargacuga Raid", "name": "Nargacuga Raid",
"recommendedLevel": 10, "recommendedLevel": 10,
"contentType": "raid", "contentType": "raid",
"partySize": 10, "partySize": 18,
"completionItemLevel": null, "completionItemLevel": null,
"experienceReward": 325, "experienceReward": 325,
"description": "A raid-scale hunt against Nargacuga, Azuros, and Diablos.", "description": "A raid-scale hunt against Nargacuga, Azuros, and Diablos.",
@@ -7933,7 +7933,7 @@
"name": "Barroth Hunting Ground", "name": "Barroth Hunting Ground",
"recommendedLevel": 15, "recommendedLevel": 15,
"contentType": "dungeon", "contentType": "dungeon",
"partySize": 5, "partySize": 6,
"completionItemLevel": null, "completionItemLevel": null,
"experienceReward": 285, "experienceReward": 285,
"description": "A three-boss hunt featuring Barroth, Tobi Kadachi, and Monoblos.", "description": "A three-boss hunt featuring Barroth, Tobi Kadachi, and Monoblos.",
@@ -8344,7 +8344,7 @@
"name": "Barroth Raid", "name": "Barroth Raid",
"recommendedLevel": 15, "recommendedLevel": 15,
"contentType": "raid", "contentType": "raid",
"partySize": 10, "partySize": 18,
"completionItemLevel": null, "completionItemLevel": null,
"experienceReward": 375, "experienceReward": 375,
"description": "A raid-scale hunt against Barroth, Tobi Kadachi, and Monoblos.", "description": "A raid-scale hunt against Barroth, Tobi Kadachi, and Monoblos.",
@@ -8755,7 +8755,7 @@
"name": "Anjanath Hunting Ground", "name": "Anjanath Hunting Ground",
"recommendedLevel": 20, "recommendedLevel": 20,
"contentType": "dungeon", "contentType": "dungeon",
"partySize": 5, "partySize": 6,
"completionItemLevel": null, "completionItemLevel": null,
"experienceReward": 325, "experienceReward": 325,
"description": "A three-boss hunt featuring Anjanath, Bazelgeuse, and Odogaron.", "description": "A three-boss hunt featuring Anjanath, Bazelgeuse, and Odogaron.",
@@ -9166,7 +9166,7 @@
"name": "Anjanath Raid", "name": "Anjanath Raid",
"recommendedLevel": 20, "recommendedLevel": 20,
"contentType": "raid", "contentType": "raid",
"partySize": 10, "partySize": 18,
"completionItemLevel": null, "completionItemLevel": null,
"experienceReward": 425, "experienceReward": 425,
"description": "A raid-scale hunt against Anjanath, Bazelgeuse, and Odogaron.", "description": "A raid-scale hunt against Anjanath, Bazelgeuse, and Odogaron.",