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