diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..71b2ba3 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/IWantToHeal-Thor-debug.apk b/IWantToHeal-Thor-debug.apk new file mode 100644 index 0000000..f5b3507 Binary files /dev/null and b/IWantToHeal-Thor-debug.apk differ diff --git a/IWantToHeal-Thor-v1.0.10.apk b/IWantToHeal-Thor-v1.0.10.apk new file mode 100644 index 0000000..ec70ee9 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.10.apk differ diff --git a/IWantToHeal-Thor-v1.0.11.apk b/IWantToHeal-Thor-v1.0.11.apk new file mode 100644 index 0000000..e02bbda Binary files /dev/null and b/IWantToHeal-Thor-v1.0.11.apk differ diff --git a/IWantToHeal-Thor-v1.0.12 2.apk b/IWantToHeal-Thor-v1.0.12 2.apk new file mode 100644 index 0000000..e88cdca Binary files /dev/null and b/IWantToHeal-Thor-v1.0.12 2.apk differ diff --git a/IWantToHeal-Thor-v1.0.12.apk b/IWantToHeal-Thor-v1.0.12.apk new file mode 100644 index 0000000..b92fe7b Binary files /dev/null and b/IWantToHeal-Thor-v1.0.12.apk differ diff --git a/IWantToHeal-Thor-v1.0.13.apk b/IWantToHeal-Thor-v1.0.13.apk new file mode 100644 index 0000000..5d9c300 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.13.apk differ diff --git a/IWantToHeal-Thor-v1.0.14.apk b/IWantToHeal-Thor-v1.0.14.apk new file mode 100644 index 0000000..26e1a2d Binary files /dev/null and b/IWantToHeal-Thor-v1.0.14.apk differ diff --git a/IWantToHeal-Thor-v1.0.15.apk b/IWantToHeal-Thor-v1.0.15.apk new file mode 100644 index 0000000..4fc2224 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.15.apk differ diff --git a/IWantToHeal-Thor-v1.0.16.apk b/IWantToHeal-Thor-v1.0.16.apk new file mode 100644 index 0000000..a602124 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.16.apk differ diff --git a/IWantToHeal-Thor-v1.0.17 2.apk b/IWantToHeal-Thor-v1.0.17 2.apk new file mode 100644 index 0000000..ecc0552 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.17 2.apk differ diff --git a/IWantToHeal-Thor-v1.0.17.apk b/IWantToHeal-Thor-v1.0.17.apk new file mode 100644 index 0000000..f0bbdcb Binary files /dev/null and b/IWantToHeal-Thor-v1.0.17.apk differ diff --git a/IWantToHeal-Thor-v1.0.18.apk b/IWantToHeal-Thor-v1.0.18.apk new file mode 100644 index 0000000..9061417 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.18.apk differ diff --git a/IWantToHeal-Thor-v1.0.19.apk b/IWantToHeal-Thor-v1.0.19.apk new file mode 100644 index 0000000..ec65c30 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.19.apk differ diff --git a/IWantToHeal-Thor-v1.0.2.apk b/IWantToHeal-Thor-v1.0.2.apk new file mode 100644 index 0000000..9250bed Binary files /dev/null and b/IWantToHeal-Thor-v1.0.2.apk differ diff --git a/IWantToHeal-Thor-v1.0.20.apk b/IWantToHeal-Thor-v1.0.20.apk new file mode 100644 index 0000000..53ab602 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.20.apk differ diff --git a/IWantToHeal-Thor-v1.0.21.apk b/IWantToHeal-Thor-v1.0.21.apk new file mode 100644 index 0000000..52f253d Binary files /dev/null and b/IWantToHeal-Thor-v1.0.21.apk differ diff --git a/IWantToHeal-Thor-v1.0.3.apk b/IWantToHeal-Thor-v1.0.3.apk new file mode 100644 index 0000000..d06d652 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.3.apk differ diff --git a/IWantToHeal-Thor-v1.0.4.apk b/IWantToHeal-Thor-v1.0.4.apk new file mode 100644 index 0000000..d5c8f33 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.4.apk differ diff --git a/IWantToHeal-Thor-v1.0.5.apk b/IWantToHeal-Thor-v1.0.5.apk new file mode 100644 index 0000000..d398685 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.5.apk differ diff --git a/IWantToHeal-Thor-v1.0.8.apk b/IWantToHeal-Thor-v1.0.8.apk new file mode 100644 index 0000000..2c285b7 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.8.apk differ diff --git a/IWantToHeal-Thor-v1.0.9.apk b/IWantToHeal-Thor-v1.0.9.apk new file mode 100644 index 0000000..78ec0c3 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.9.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 6a7af21..91958b6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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' diff --git a/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java b/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java index 1d95ac2..33be3c6 100644 --- a/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java +++ b/android/app/src/main/java/com/warren/iwanttoheal/ControllerBridgeActivity.java @@ -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: diff --git a/android/app/src/main/java/com/warren/iwanttoheal/DualScreenPlugin.java b/android/app/src/main/java/com/warren/iwanttoheal/DualScreenPlugin.java index 8fd2782..111de01 100644 --- a/android/app/src/main/java/com/warren/iwanttoheal/DualScreenPlugin.java +++ b/android/app/src/main/java/com/warren/iwanttoheal/DualScreenPlugin.java @@ -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( diff --git a/db/schema.sql b/db/schema.sql index d6f4a26..5f682e1 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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 diff --git a/db/seed.sql b/db/seed.sql index dda95b2..b45122c 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -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, diff --git a/package.json b/package.json index 94cdb0d..f7372ce 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/game-api.mjs b/server/game-api.mjs index d2b3822..0e982f1 100644 --- a/server/game-api.mjs +++ b/server/game-api.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 diff --git a/src/App.css b/src/App.css index bb1ca7b..e6dfabb 100644 --- a/src/App.css +++ b/src/App.css @@ -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)); } diff --git a/src/App.tsx b/src/App.tsx index b9ea69d..fac5965 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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).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 (
@@ -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' && (
+ {canShowCloudSync && ( +
+ {cloudSync.dirty ? 'S' : 'C'} +
+ Cloud Save + + {cloudSync.dirty + ? 'Local progress waiting. Upload when you want to refresh the server copy.' + : 'Server copy matches this device.'} + + {syncMessage && {syncMessage}} +
+ +
+ )} {MENU_ITEMS.map((item) => (
+ {SHOW_LEADERBOARDS && (
@@ -488,6 +548,7 @@ function App() { )}
+ )} )}
@@ -663,6 +724,7 @@ function App() { )} + {SHOW_LEADERBOARDS && (
@@ -682,7 +744,9 @@ function App() {

{gameMode === 'offline' ? 'Offline runs are not submitted' - : 'Lowest resource spent ranks first'} + : canShowCloudSync + ? 'Manual save sync updates your cloud profile.' + : 'Lowest resource spent ranks first'}

{([ @@ -730,13 +794,16 @@ function App() {
{gameMode === 'offline' ? 'Connect with an online character to compete in rankings.' - : 'Complete this difficulty to claim the first ranking.'} + : canShowCloudSync + ? 'No leaderboard entries yet.' + : 'Complete this difficulty to claim the first ranking.'}
)}
)}
+ )} )} diff --git a/src/components/AuthScreen.tsx b/src/components/AuthScreen.tsx index d523e81..2b15af3 100644 --- a/src/components/AuthScreen.tsx +++ b/src/components/AuthScreen.tsx @@ -156,7 +156,7 @@ export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {

Play Offline

No account or connection required. Offline progress stays on - this device and is excluded from online leaderboards. + this device.

{offlineCharacterExists && ( diff --git a/src/components/CombatScreen.tsx b/src/components/CombatScreen.tsx index 13ba36b..5042f31 100644 --- a/src/components/CombatScreen.tsx +++ b/src/components/CombatScreen.tsx @@ -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>({}) - 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([ { 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({
-
+
= 10 ? 'raid-party-grid' : ''}`}> {party.map((member) => (
@@ -347,7 +389,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false } setLevelFilter(e.target.value === '' ? null : Number(e.target.value))} + onChange={(e) => { + setLevelFilter(e.target.value === '' ? null : Number(e.target.value)) + setRecipePage(0) + }} > {availableLevels.map((level) => ( @@ -371,7 +419,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false } {filteredRecipes.length > 0 && (
- {filteredRecipes.map((recipe) => ( + {recipePageItems.map((recipe) => ( ))} + {filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && ( + setRecipePage((current) => Math.min(recipePageCount - 1, current + 1))} + onPrevious={() => setRecipePage((current) => Math.max(0, current - 1))} + nextDisabled={recipePage >= recipePageCount - 1} + previousDisabled={recipePage <= 0} + /> + )}
{selectedRecipe && (
@@ -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 ( +
+ + {label} + +
+ ) +} + function GearStat({ value, label }: { value: string; label: string }) { return (
diff --git a/src/components/PvpRoguelikeScreen.tsx b/src/components/PvpRoguelikeScreen.tsx index f5d5576..cd048a8 100644 --- a/src/components/PvpRoguelikeScreen.tsx +++ b/src/components/PvpRoguelikeScreen.tsx @@ -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('ability') + const [abilityLabelMode] = useState('ability') const selfBuffChoicesCatalog = useMemo( () => buildSelfBuffChoices(starterSpells, abilityLabelMode), [abilityLabelMode, starterSpells], @@ -410,10 +416,14 @@ export function PvPRoguelikeScreen({ const [selectedBuff, setSelectedBuff] = useState | null>(null) const [selectedDebuff, setSelectedDebuff] = useState | 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()) + 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 ( -
-
-
-
-

PvP Roguelike

-

{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}

-
-
-
- - -
- -
-
+ const dualScreenState = useMemo(() => ({ + 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 ( +
+
{status === 'queueing' && (
P V P
@@ -967,7 +1091,14 @@ export function PvPRoguelikeScreen({
)} - {status !== 'queueing' && ( + {dualScreenEnabled && status !== 'queueing' && ( + + )} + + {!dualScreenEnabled && status !== 'queueing' && (
@@ -982,7 +1113,7 @@ export function PvPRoguelikeScreen({
-
+
{playerSide.party.map((member) => (
-
+
{cpuSide.party.map((member) => (
@@ -1098,6 +1230,7 @@ export function PvPRoguelikeScreen({
{member.shield > 0 && } + {Math.floor(member.health)} / {effectiveMaxHealth(member)}