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