Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a8d5ad8c5 | |||
| a604569a2f |
@@ -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.
|
||||||
@@ -43,6 +43,41 @@ Keep the game server bound to `127.0.0.1`. Set `TRUST_PROXY=1` only when the
|
|||||||
server can be reached solely through your local reverse proxy. This lets account
|
server can be reached solely through your local reverse proxy. This lets account
|
||||||
limits use the visitor's public IP instead of the proxy's address.
|
limits use the visitor's public IP instead of the proxy's address.
|
||||||
|
|
||||||
|
## Separate auth server
|
||||||
|
|
||||||
|
The auth routes can run as their own Node process. This is useful when you want
|
||||||
|
`auth.phenomrom.com` to stay available while the game server is being rebuilt or
|
||||||
|
changed.
|
||||||
|
|
||||||
|
On the TrueNAS host, run the auth process against the same project data folder:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run db:init
|
||||||
|
AUTH_HOST=127.0.0.1 AUTH_PORT=4174 TRUST_PROXY=1 COOKIE_SECURE=1 AUTH_CORS_ORIGINS=https://phenomrom.com npm run auth:start
|
||||||
|
```
|
||||||
|
|
||||||
|
Point `auth.phenomrom.com` at that process through HTTPS:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
auth.phenomrom.com {
|
||||||
|
reverse_proxy 127.0.0.1:4174
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the web or mobile app with the auth base URL set separately from the game
|
||||||
|
API:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
VITE_AUTH_API_BASE_URL=https://auth.phenomrom.com npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
For a Capacitor wrapper, set `window.CAPACITOR_AUTH_API_BASE_URL` to
|
||||||
|
`https://auth.phenomrom.com` the same way `window.CAPACITOR_API_BASE_URL` is set.
|
||||||
|
The app stores the returned bearer token locally and sends it with later API
|
||||||
|
requests, so auth works across subdomains and inside the mobile WebView. Existing
|
||||||
|
same-origin cookie sessions still work when auth is served by the game server.
|
||||||
|
|
||||||
## Account limits
|
## Account limits
|
||||||
|
|
||||||
Registration permits one account per public IP by default. Login and API rate
|
Registration permits one account per public IP by default. Login and API rate
|
||||||
|
|||||||
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.
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 39
|
||||||
versionName "1.0"
|
versionName "1.0.23"
|
||||||
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
|
||||||
|
|||||||
+330
-19
@@ -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.'),
|
||||||
@@ -429,6 +429,168 @@ INSERT OR IGNORE INTO character_inventory (character_id, item_id, quantity, equi
|
|||||||
(3, 100, 1, 1), (3, 101, 1, 1), (3, 102, 1, 1), (3, 103, 1, 1),
|
(3, 100, 1, 1), (3, 101, 1, 1), (3, 102, 1, 1), (3, 103, 1, 1),
|
||||||
(3, 104, 1, 1), (3, 108, 1, 1), (3, 105, 1, 1), (3, 109, 1, 1), (3, 106, 1, 1), (3, 107, 1, 0);
|
(3, 104, 1, 1), (3, 108, 1, 1), (3, 105, 1, 1), (3, 109, 1, 1), (3, 106, 1, 1), (3, 107, 1, 0);
|
||||||
|
|
||||||
|
-- Coin gearing override: every boss/difficulty drops one boss coin, and each
|
||||||
|
-- craft costs the target item level in that source boss coin.
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 3
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 12
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 22
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET difficulty_id = CASE
|
||||||
|
(SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id)
|
||||||
|
WHEN 5 THEN 1
|
||||||
|
WHEN 10 THEN 2
|
||||||
|
WHEN 15 THEN 3
|
||||||
|
WHEN 20 THEN 4
|
||||||
|
WHEN 25 THEN 5
|
||||||
|
ELSE difficulty_id
|
||||||
|
END
|
||||||
|
WHERE id BETWEEN 1001 AND 1409;
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET rarity = CASE item_level
|
||||||
|
WHEN 5 THEN 'common'
|
||||||
|
WHEN 10 THEN 'uncommon'
|
||||||
|
WHEN 15 THEN 'rare'
|
||||||
|
WHEN 20 THEN 'epic'
|
||||||
|
WHEN 25 THEN 'legendary'
|
||||||
|
ELSE rarity
|
||||||
|
END
|
||||||
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET name = (
|
||||||
|
SELECT
|
||||||
|
CASE items.item_level
|
||||||
|
WHEN 5 THEN ''
|
||||||
|
WHEN 10 THEN 'Green '
|
||||||
|
WHEN 15 THEN 'Blue '
|
||||||
|
WHEN 20 THEN 'Purple '
|
||||||
|
WHEN 25 THEN 'Orange '
|
||||||
|
ELSE ''
|
||||||
|
END
|
||||||
|
|| encounters.name || ' '
|
||||||
|
|| CASE items.slot
|
||||||
|
WHEN 'weapon' THEN 'Weapon'
|
||||||
|
WHEN 'helmet' THEN 'Helmet'
|
||||||
|
WHEN 'chest' THEN 'Chest'
|
||||||
|
WHEN 'gloves' THEN 'Gloves'
|
||||||
|
WHEN 'boots' THEN 'Boots'
|
||||||
|
WHEN 'pants' THEN 'Pants'
|
||||||
|
WHEN 'ring' THEN 'Ring'
|
||||||
|
WHEN 'necklace' THEN 'Necklace'
|
||||||
|
WHEN 'trinket' THEN 'Trinket'
|
||||||
|
ELSE items.name
|
||||||
|
END
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
|
WHERE crafting_recipes.item_id = items.id
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
description = (
|
||||||
|
SELECT 'Crafted with ' || encounters.name || ' coins.'
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
|
WHERE crafting_recipes.item_id = items.id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
|
CREATE TEMP TABLE IF NOT EXISTS coin_sources (
|
||||||
|
item_id INTEGER PRIMARY KEY,
|
||||||
|
encounter_id INTEGER NOT NULL,
|
||||||
|
difficulty_id INTEGER NOT NULL,
|
||||||
|
item_level INTEGER NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
rarity TEXT NOT NULL,
|
||||||
|
glyph TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM coin_sources;
|
||||||
|
|
||||||
|
INSERT INTO coin_sources
|
||||||
|
SELECT
|
||||||
|
280000 + encounters.id * 100 + difficulties.dropped_item_level,
|
||||||
|
encounters.id,
|
||||||
|
difficulties.id,
|
||||||
|
difficulties.dropped_item_level,
|
||||||
|
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
|
||||||
|
CASE difficulties.dropped_item_level
|
||||||
|
WHEN 5 THEN ''
|
||||||
|
WHEN 10 THEN 'Green '
|
||||||
|
WHEN 15 THEN 'Blue '
|
||||||
|
WHEN 20 THEN 'Purple '
|
||||||
|
WHEN 25 THEN 'Orange '
|
||||||
|
ELSE ''
|
||||||
|
END || encounters.name || ' Coin',
|
||||||
|
CASE difficulties.dropped_item_level
|
||||||
|
WHEN 5 THEN 'common'
|
||||||
|
WHEN 10 THEN 'uncommon'
|
||||||
|
WHEN 15 THEN 'rare'
|
||||||
|
WHEN 20 THEN 'epic'
|
||||||
|
WHEN 25 THEN 'legendary'
|
||||||
|
ELSE 'common'
|
||||||
|
END,
|
||||||
|
'$',
|
||||||
|
'A boss coin from ' || encounters.name || ' used for item level '
|
||||||
|
|| difficulties.dropped_item_level || ' crafting.'
|
||||||
|
FROM encounters
|
||||||
|
JOIN dungeon_difficulties ON dungeon_difficulties.dungeon_id = encounters.dungeon_id
|
||||||
|
JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id
|
||||||
|
WHERE encounters.encounter_type = 'boss';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO items
|
||||||
|
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
|
||||||
|
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
|
||||||
|
FROM coin_sources;
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
slot = 'component',
|
||||||
|
rarity = (SELECT rarity FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
item_level = (SELECT item_level FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
healing_power = 0,
|
||||||
|
max_resource_bonus = 0,
|
||||||
|
glyph = (SELECT glyph FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
description = (SELECT description FROM coin_sources WHERE coin_sources.item_id = items.id)
|
||||||
|
WHERE id IN (SELECT item_id FROM coin_sources);
|
||||||
|
|
||||||
|
DELETE FROM encounter_loot;
|
||||||
|
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
|
||||||
|
SELECT encounter_id, item_id, difficulty_id, 100, 1.0
|
||||||
|
FROM coin_sources;
|
||||||
|
|
||||||
|
DELETE FROM crafting_recipe_components;
|
||||||
|
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
||||||
|
SELECT
|
||||||
|
crafting_recipes.id,
|
||||||
|
coin_sources.item_id,
|
||||||
|
items.item_level
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
|
JOIN coin_sources
|
||||||
|
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
||||||
|
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
|
||||||
|
|
||||||
INSERT OR IGNORE INTO talents
|
INSERT OR IGNORE INTO talents
|
||||||
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
||||||
VALUES
|
VALUES
|
||||||
@@ -611,7 +773,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 +782,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,
|
||||||
@@ -678,9 +840,9 @@ SET slug = CASE id
|
|||||||
END,
|
END,
|
||||||
encounter_type = CASE WHEN id IN (102, 105, 108) THEN 'boss' ELSE 'trash' END,
|
encounter_type = CASE WHEN id IN (102, 105, 108) THEN 'boss' ELSE 'trash' END,
|
||||||
description = CASE id
|
description = CASE id
|
||||||
WHEN 102 THEN 'Tigrex drops monster parts for item level 10 crafting.'
|
WHEN 102 THEN 'Tigrex drops boss coins for item level 10 crafting.'
|
||||||
WHEN 105 THEN 'Rathalos drops monster parts for item level 10 crafting.'
|
WHEN 105 THEN 'Rathalos drops boss coins for item level 10 crafting.'
|
||||||
WHEN 108 THEN 'Gypceros drops monster parts for item level 10 crafting.'
|
WHEN 108 THEN 'Gypceros drops boss coins for item level 10 crafting.'
|
||||||
ELSE 'Hunters clear the raid path.'
|
ELSE 'Hunters clear the raid path.'
|
||||||
END
|
END
|
||||||
WHERE id BETWEEN 100 AND 108;
|
WHERE id BETWEEN 100 AND 108;
|
||||||
@@ -702,7 +864,7 @@ SELECT
|
|||||||
offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
|
offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
|
||||||
offset.party_damage + generated_bosses.boss_index * 3,
|
offset.party_damage + generated_bosses.boss_index * 3,
|
||||||
CASE offset.encounter_type
|
CASE offset.encounter_type
|
||||||
WHEN 'boss' THEN generated_bosses.boss_name || ' drops monster parts for item level ' || generated_loot_tiers.item_level || ' crafting.'
|
WHEN 'boss' THEN generated_bosses.boss_name || ' drops boss coins for item level ' || generated_loot_tiers.item_level || ' crafting.'
|
||||||
ELSE 'Hunters clear the path before ' || generated_bosses.boss_name || '.'
|
ELSE 'Hunters clear the path before ' || generated_bosses.boss_name || '.'
|
||||||
END
|
END
|
||||||
FROM generated_loot_tiers
|
FROM generated_loot_tiers
|
||||||
@@ -730,7 +892,7 @@ SELECT
|
|||||||
offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
|
offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
|
||||||
offset.party_damage + generated_bosses.boss_index * 3 + 24,
|
offset.party_damage + generated_bosses.boss_index * 3 + 24,
|
||||||
CASE offset.encounter_type
|
CASE offset.encounter_type
|
||||||
WHEN 'boss' THEN generated_bosses.boss_name || ' drops monster parts for item level ' || generated_loot_tiers.item_level || ' crafting.'
|
WHEN 'boss' THEN generated_bosses.boss_name || ' drops boss coins for item level ' || generated_loot_tiers.item_level || ' crafting.'
|
||||||
ELSE 'Hunters clear the raid path before ' || generated_bosses.boss_name || '.'
|
ELSE 'Hunters clear the raid path before ' || generated_bosses.boss_name || '.'
|
||||||
END
|
END
|
||||||
FROM generated_loot_tiers
|
FROM generated_loot_tiers
|
||||||
@@ -1011,3 +1173,152 @@ INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
|||||||
(1007, 868, 5), (1007, 869, 3), (1007, 871, 1),
|
(1007, 868, 5), (1007, 869, 3), (1007, 871, 1),
|
||||||
(1008, 868, 5), (1008, 869, 3), (1008, 871, 1),
|
(1008, 868, 5), (1008, 869, 3), (1008, 871, 1),
|
||||||
(1009, 868, 5), (1009, 869, 3), (1009, 871, 1);
|
(1009, 868, 5), (1009, 869, 3), (1009, 871, 1);
|
||||||
|
|
||||||
|
-- Final coin gearing override. Keep this after legacy loot edits.
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 3
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 12
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET source_dungeon_id = 1,
|
||||||
|
source_encounter_id = 22
|
||||||
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
|
||||||
|
|
||||||
|
UPDATE crafting_recipes
|
||||||
|
SET difficulty_id = CASE
|
||||||
|
(SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id)
|
||||||
|
WHEN 5 THEN 1
|
||||||
|
WHEN 10 THEN 2
|
||||||
|
WHEN 15 THEN 3
|
||||||
|
WHEN 20 THEN 4
|
||||||
|
WHEN 25 THEN 5
|
||||||
|
ELSE difficulty_id
|
||||||
|
END
|
||||||
|
WHERE id BETWEEN 1001 AND 1409;
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET rarity = CASE item_level
|
||||||
|
WHEN 5 THEN 'common'
|
||||||
|
WHEN 10 THEN 'uncommon'
|
||||||
|
WHEN 15 THEN 'rare'
|
||||||
|
WHEN 20 THEN 'epic'
|
||||||
|
WHEN 25 THEN 'legendary'
|
||||||
|
ELSE rarity
|
||||||
|
END
|
||||||
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET name = (
|
||||||
|
SELECT
|
||||||
|
CASE items.item_level
|
||||||
|
WHEN 5 THEN ''
|
||||||
|
WHEN 10 THEN 'Green '
|
||||||
|
WHEN 15 THEN 'Blue '
|
||||||
|
WHEN 20 THEN 'Purple '
|
||||||
|
WHEN 25 THEN 'Orange '
|
||||||
|
ELSE ''
|
||||||
|
END
|
||||||
|
|| encounters.name || ' '
|
||||||
|
|| CASE items.slot
|
||||||
|
WHEN 'weapon' THEN 'Weapon'
|
||||||
|
WHEN 'helmet' THEN 'Helmet'
|
||||||
|
WHEN 'chest' THEN 'Chest'
|
||||||
|
WHEN 'gloves' THEN 'Gloves'
|
||||||
|
WHEN 'boots' THEN 'Boots'
|
||||||
|
WHEN 'pants' THEN 'Pants'
|
||||||
|
WHEN 'ring' THEN 'Ring'
|
||||||
|
WHEN 'necklace' THEN 'Necklace'
|
||||||
|
WHEN 'trinket' THEN 'Trinket'
|
||||||
|
ELSE items.name
|
||||||
|
END
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
|
WHERE crafting_recipes.item_id = items.id
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
description = (
|
||||||
|
SELECT 'Crafted with ' || encounters.name || ' coins.'
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
|
WHERE crafting_recipes.item_id = items.id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
|
DELETE FROM coin_sources;
|
||||||
|
|
||||||
|
INSERT INTO coin_sources
|
||||||
|
SELECT
|
||||||
|
280000 + encounters.id * 100 + difficulties.dropped_item_level,
|
||||||
|
encounters.id,
|
||||||
|
difficulties.id,
|
||||||
|
difficulties.dropped_item_level,
|
||||||
|
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
|
||||||
|
CASE difficulties.dropped_item_level
|
||||||
|
WHEN 5 THEN ''
|
||||||
|
WHEN 10 THEN 'Green '
|
||||||
|
WHEN 15 THEN 'Blue '
|
||||||
|
WHEN 20 THEN 'Purple '
|
||||||
|
WHEN 25 THEN 'Orange '
|
||||||
|
ELSE ''
|
||||||
|
END || encounters.name || ' Coin',
|
||||||
|
CASE difficulties.dropped_item_level
|
||||||
|
WHEN 5 THEN 'common'
|
||||||
|
WHEN 10 THEN 'uncommon'
|
||||||
|
WHEN 15 THEN 'rare'
|
||||||
|
WHEN 20 THEN 'epic'
|
||||||
|
WHEN 25 THEN 'legendary'
|
||||||
|
ELSE 'common'
|
||||||
|
END,
|
||||||
|
'$',
|
||||||
|
'A boss coin from ' || encounters.name || ' used for item level '
|
||||||
|
|| difficulties.dropped_item_level || ' crafting.'
|
||||||
|
FROM encounters
|
||||||
|
JOIN dungeon_difficulties ON dungeon_difficulties.dungeon_id = encounters.dungeon_id
|
||||||
|
JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id
|
||||||
|
WHERE encounters.encounter_type = 'boss';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO items
|
||||||
|
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
|
||||||
|
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
|
||||||
|
FROM coin_sources;
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
slot = 'component',
|
||||||
|
rarity = (SELECT rarity FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
item_level = (SELECT item_level FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
healing_power = 0,
|
||||||
|
max_resource_bonus = 0,
|
||||||
|
glyph = (SELECT glyph FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||||
|
description = (SELECT description FROM coin_sources WHERE coin_sources.item_id = items.id)
|
||||||
|
WHERE id IN (SELECT item_id FROM coin_sources);
|
||||||
|
|
||||||
|
DELETE FROM encounter_loot;
|
||||||
|
|
||||||
|
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
|
||||||
|
SELECT encounter_id, item_id, difficulty_id, 100, 1.0
|
||||||
|
FROM coin_sources;
|
||||||
|
|
||||||
|
DELETE FROM crafting_recipe_components;
|
||||||
|
|
||||||
|
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
||||||
|
SELECT
|
||||||
|
crafting_recipes.id,
|
||||||
|
coin_sources.item_id,
|
||||||
|
items.item_level
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
|
JOIN coin_sources
|
||||||
|
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
||||||
|
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Gearing System
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Gearing should move from boss-specific multi-item drop tables to one clear currency loop:
|
||||||
|
|
||||||
|
1. Kill bosses.
|
||||||
|
2. Earn boss coins.
|
||||||
|
3. Craft gear with those coins.
|
||||||
|
4. Upgrade that boss gear into the next item-level tier with higher-rarity coins.
|
||||||
|
|
||||||
|
This keeps boss loot readable, removes low-percentage frustration, and makes every boss kill progress a targeted gear goal.
|
||||||
|
|
||||||
|
## Coin Tiers
|
||||||
|
|
||||||
|
Coins are component items. Each coin is tied to a boss source and an item-level tier.
|
||||||
|
|
||||||
|
| Item Level | Display Color | Rarity Key | Example |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 5 | White | common | Bulldrome Coin |
|
||||||
|
| 10 | Green | uncommon | Green Bulldrome Coin |
|
||||||
|
| 15 | Blue | rare | Blue Bulldrome Coin |
|
||||||
|
| 20 | Purple | epic | Purple Bulldrome Coin |
|
||||||
|
| 25 | Orange | legendary | Orange Bulldrome Coin |
|
||||||
|
|
||||||
|
Implementation note: the current TypeScript rarity union supports `common`, `uncommon`, `rare`, and `epic`. Orange needs a new rarity key, recommended as `legendary`, plus UI color styling.
|
||||||
|
|
||||||
|
## Boss Loot
|
||||||
|
|
||||||
|
Each boss has one loot roll.
|
||||||
|
|
||||||
|
For now, each successful boss loot roll awards 1 to 3 coins:
|
||||||
|
|
||||||
|
| Roll Result | Coins Awarded |
|
||||||
|
| --- | --- |
|
||||||
|
| Low roll | 1 coin |
|
||||||
|
| Normal roll | 2 coins |
|
||||||
|
| High roll | 3 coins |
|
||||||
|
|
||||||
|
Recommended weighting:
|
||||||
|
|
||||||
|
| Coins | Chance |
|
||||||
|
| --- | --- |
|
||||||
|
| 1 | 50% |
|
||||||
|
| 2 | 35% |
|
||||||
|
| 3 | 15% |
|
||||||
|
|
||||||
|
The coin source comes from the defeated boss. Bulldrome drops Bulldrome coins, Rathian drops Rathian coins, and so on.
|
||||||
|
|
||||||
|
The coin tier comes from content difficulty or roguelike depth:
|
||||||
|
|
||||||
|
| Source | Coin Tier |
|
||||||
|
| --- | --- |
|
||||||
|
| Item level 5 content | White level 5 coins |
|
||||||
|
| Item level 10 content | Green level 10 coins |
|
||||||
|
| Item level 15 content | Blue level 15 coins |
|
||||||
|
| Item level 20 content | Purple level 20 coins |
|
||||||
|
| Item level 25 content | Orange level 25 coins |
|
||||||
|
|
||||||
|
## Crafting Costs
|
||||||
|
|
||||||
|
Gear is crafted with boss coins from the same boss and item-level tier.
|
||||||
|
|
||||||
|
| Gear Item Level | Cost |
|
||||||
|
| --- | --- |
|
||||||
|
| 5 | 5 white boss coins |
|
||||||
|
| 10 | 10 green boss coins |
|
||||||
|
| 15 | 15 blue boss coins |
|
||||||
|
| 20 | 20 purple boss coins |
|
||||||
|
| 25 | 25 orange boss coins |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
- Bulldrome item-level 5 helmet costs 5 white Bulldrome coins.
|
||||||
|
- Bulldrome item-level 10 helmet costs 10 green Bulldrome coins.
|
||||||
|
- Rathian item-level 20 gloves cost 20 purple Rathian coins.
|
||||||
|
|
||||||
|
## Gear Upgrades
|
||||||
|
|
||||||
|
Crafting can create gear directly, but upgrades should become the preferred long-term path.
|
||||||
|
|
||||||
|
Upgrade rule:
|
||||||
|
|
||||||
|
- Existing boss gear upgrades into the next item-level version of the same boss gear.
|
||||||
|
- Upgrade cost uses coins from the next tier.
|
||||||
|
- Required coin quantity equals the target item level.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Upgrade | Cost |
|
||||||
|
| --- | --- |
|
||||||
|
| Bulldrome item level 5 gear -> Bulldrome item level 10 gear | 10 green Bulldrome coins |
|
||||||
|
| Bulldrome item level 10 gear -> Bulldrome item level 15 gear | 15 blue Bulldrome coins |
|
||||||
|
| Bulldrome item level 15 gear -> Bulldrome item level 20 gear | 20 purple Bulldrome coins |
|
||||||
|
| Bulldrome item level 20 gear -> Bulldrome item level 25 gear | 25 orange Bulldrome coins |
|
||||||
|
|
||||||
|
Upgrade should consume the old item and award the upgraded item. This avoids duplicate clutter and keeps equipment identity clear.
|
||||||
|
|
||||||
|
## Roguelike Loot
|
||||||
|
|
||||||
|
Roguelike bosses should award coins when defeated, using the same 1 to 3 coin roll.
|
||||||
|
|
||||||
|
Roguelike coin tier should scale by wave band:
|
||||||
|
|
||||||
|
| Waves | Coin Tier |
|
||||||
|
| --- | --- |
|
||||||
|
| 1-4 | Level 5 white coins |
|
||||||
|
| 5-9 | Level 10 green coins |
|
||||||
|
| 10-14 | Level 15 blue coins |
|
||||||
|
| 15-19 | Level 20 purple coins |
|
||||||
|
| 20+ | Level 25 orange coins |
|
||||||
|
|
||||||
|
Boss identity can be handled two ways:
|
||||||
|
|
||||||
|
1. Boss-based coins: use the actual boss template selected for that roguelike boss.
|
||||||
|
2. Roguelike coins: use a generic roguelike coin per tier.
|
||||||
|
|
||||||
|
Recommended first pass: boss-based coins. It reuses the same crafting economy as dungeons and makes roguelike runs feel connected to the main gear chase.
|
||||||
|
|
||||||
|
## Roguelike Checkpoints
|
||||||
|
|
||||||
|
Checkpoints should unlock every 5 waves.
|
||||||
|
|
||||||
|
| Highest Cleared Wave | Future Start Wave |
|
||||||
|
| --- | --- |
|
||||||
|
| 0-4 | 1 |
|
||||||
|
| 5-9 | 5 |
|
||||||
|
| 10-14 | 10 |
|
||||||
|
| 15-19 | 15 |
|
||||||
|
| 20+ | Highest unlocked 5-wave checkpoint |
|
||||||
|
|
||||||
|
Checkpoint rule:
|
||||||
|
|
||||||
|
- Unlock a checkpoint after clearing its boss band.
|
||||||
|
- Starting from a checkpoint begins at that wave band with matching coin tier.
|
||||||
|
- Runs should still record leaderboard progress from the selected start wave so full runs and checkpoint runs can be ranked separately later.
|
||||||
|
|
||||||
|
Current implementation note: the roguelike screen always starts at stage 1 and only awards XP per boss. Checkpoints need saved character progress and a start-wave selector.
|
||||||
|
|
||||||
|
## Current Code Fit
|
||||||
|
|
||||||
|
The existing system already has most of the required foundation:
|
||||||
|
|
||||||
|
- `items.slot = 'component'` can represent coins.
|
||||||
|
- `character_inventory.quantity` already stacks components.
|
||||||
|
- `crafting_recipes` and `crafting_recipe_components` already support coin costs.
|
||||||
|
- `encounter_loot_rolls` and `encounter_loot_roll_items` already persist retry-safe loot awards.
|
||||||
|
- `completeRoguelike` is already called after each roguelike boss kill for XP, so coin awards can attach to that same flow.
|
||||||
|
|
||||||
|
Needed changes:
|
||||||
|
|
||||||
|
- Replace current 4-component boss drop tables with one boss coin per boss per tier.
|
||||||
|
- Change boss loot roll count from multiple chance slots to one 1-3 coin roll.
|
||||||
|
- Add orange/legendary rarity support.
|
||||||
|
- Add upgrade recipes or a dedicated upgrade endpoint.
|
||||||
|
- Add roguelike boss coin awards.
|
||||||
|
- Add roguelike checkpoint persistence and start-wave selection.
|
||||||
|
- Export updated offline starter data after seed changes.
|
||||||
|
|
||||||
|
## Suggestions
|
||||||
|
|
||||||
|
Use guaranteed coin drops for now. One to three coins per boss gives steady progress and makes craft timing easy to understand.
|
||||||
|
|
||||||
|
Keep coins boss-specific, not slot-specific. Slot-specific components add complexity without much decision value.
|
||||||
|
|
||||||
|
Use upgrade-first UI. Show the next upgrade for equipped gear before showing the full crafting catalog.
|
||||||
|
|
||||||
|
Keep direct crafting and upgrading at the same coin cost for the target tier. Direct crafting helps new slots catch up; upgrading preserves boss gear identity.
|
||||||
|
|
||||||
|
Add a pity floor only if needed later. If boss kills always award coins, the system already has deterministic progress.
|
||||||
|
|
||||||
|
Use one orange rarity key: `legendary`. Avoid storing display color names as rarity values; colors can change without data migration.
|
||||||
+3
-2
@@ -6,16 +6,17 @@
|
|||||||
"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",
|
||||||
"offline:export": "node scripts/export-offline-profile.mjs",
|
"offline:export": "node scripts/export-offline-profile.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"admin:start": "node server/admin.mjs",
|
"admin:start": "node server/admin.mjs",
|
||||||
|
"auth:start": "node server/auth.mjs",
|
||||||
"start": "node server/production.mjs",
|
"start": "node server/production.mjs",
|
||||||
"prepreview": "npm run db:init",
|
"prepreview": "npm run db:init",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { handleAuthApiRequest } from './game-api.mjs'
|
||||||
|
|
||||||
|
process.env.NODE_ENV = process.env.NODE_ENV ?? 'production'
|
||||||
|
process.env.CORS_ORIGINS = process.env.CORS_ORIGINS
|
||||||
|
?? process.env.AUTH_CORS_ORIGINS
|
||||||
|
?? '*'
|
||||||
|
|
||||||
|
const host = process.env.AUTH_HOST ?? process.env.HOST ?? '127.0.0.1'
|
||||||
|
const port = Number(process.env.AUTH_PORT ?? process.env.PORT ?? 4174)
|
||||||
|
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
handleAuthApiRequest(request, response)
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
console.log(`I want to Heal auth listening on http://${host}:${port}`)
|
||||||
|
})
|
||||||
+659
-60
@@ -33,6 +33,31 @@ function sendJson(response, status, body, headers = {}) {
|
|||||||
response.end(JSON.stringify(body))
|
response.end(JSON.stringify(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configuredCorsOrigins() {
|
||||||
|
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCorsHeaders(response, request) {
|
||||||
|
const origin = request.headers.origin
|
||||||
|
if (typeof origin !== 'string') return
|
||||||
|
const allowedOrigins = configuredCorsOrigins()
|
||||||
|
if (!allowedOrigins.includes('*') && !allowedOrigins.includes(origin)) return
|
||||||
|
response.setHeader('Access-Control-Allow-Origin', origin)
|
||||||
|
response.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,OPTIONS')
|
||||||
|
response.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
||||||
|
response.setHeader('Access-Control-Max-Age', '86400')
|
||||||
|
response.setHeader('Vary', 'Origin')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCorsPreflight(request, response) {
|
||||||
|
setCorsHeaders(response, request)
|
||||||
|
response.statusCode = 204
|
||||||
|
response.end()
|
||||||
|
}
|
||||||
|
|
||||||
async function readJson(request, maxSize = 16 * 1024) {
|
async function readJson(request, maxSize = 16 * 1024) {
|
||||||
const chunks = []
|
const chunks = []
|
||||||
let size = 0
|
let size = 0
|
||||||
@@ -260,6 +285,17 @@ function parseCookies(request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bearerToken(request) {
|
||||||
|
const authorization = request.headers.authorization
|
||||||
|
if (typeof authorization !== 'string') return ''
|
||||||
|
const match = authorization.match(/^Bearer\s+(.+)$/i)
|
||||||
|
return match ? match[1].trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSessionToken(request) {
|
||||||
|
return bearerToken(request) || parseCookies(request)[sessionCookieName] || ''
|
||||||
|
}
|
||||||
|
|
||||||
function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) {
|
function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) {
|
||||||
const secure = request.headers['x-forwarded-proto'] === 'https'
|
const secure = request.headers['x-forwarded-proto'] === 'https'
|
||||||
|| Boolean(request.socket.encrypted)
|
|| Boolean(request.socket.encrypted)
|
||||||
@@ -284,7 +320,7 @@ function createSession(database, accountId, ip, activeCharacterId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentSession(database, request) {
|
function currentSession(database, request) {
|
||||||
const token = parseCookies(request)[sessionCookieName]
|
const token = requestSessionToken(request)
|
||||||
if (!token) return null
|
if (!token) return null
|
||||||
return database.prepare(`
|
return database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -854,6 +890,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
|
||||||
@@ -937,11 +1304,57 @@ function formatLootRoll(database, context, record, dropChance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function componentDropQuantity(droppedItemLevel) {
|
function coinDropQuantity() {
|
||||||
const tier = Math.max(0, Math.floor((droppedItemLevel - 5) / 5))
|
const roll = Math.random()
|
||||||
const secondChance = Math.min(0.85, 0.35 + tier * 0.12)
|
if (roll < 0.15) return 3
|
||||||
const thirdChance = Math.min(0.6, 0.1 + tier * 0.1)
|
if (roll < 0.5) return 2
|
||||||
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function roguelikeCoinItemLevel(stage) {
|
||||||
|
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
function awardRoguelikeCoin(database, characterId, sourceEncounterId, stage) {
|
||||||
|
if (!sourceEncounterId || !stage) return null
|
||||||
|
const coin = 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
|
||||||
|
FROM encounter_loot
|
||||||
|
JOIN items ON items.id = encounter_loot.item_id
|
||||||
|
WHERE encounter_loot.encounter_id = ?
|
||||||
|
AND items.item_level = ?
|
||||||
|
ORDER BY encounter_loot.difficulty_id
|
||||||
|
LIMIT 1
|
||||||
|
`).get(sourceEncounterId, roguelikeCoinItemLevel(stage))
|
||||||
|
if (!coin) return null
|
||||||
|
const quantity = coinDropQuantity()
|
||||||
|
const previousQuantity = database.prepare(`
|
||||||
|
SELECT quantity
|
||||||
|
FROM character_inventory
|
||||||
|
WHERE character_id = ? AND item_id = ?
|
||||||
|
`).get(characterId, coin.id)?.quantity ?? 0
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||||
|
VALUES (?, ?, ?, 0)
|
||||||
|
ON CONFLICT(character_id, item_id)
|
||||||
|
DO UPDATE SET quantity = quantity + ?
|
||||||
|
`).run(characterId, coin.id, quantity, quantity)
|
||||||
|
return {
|
||||||
|
...coin,
|
||||||
|
quantity,
|
||||||
|
duplicate: previousQuantity > 0,
|
||||||
|
quantityAfter: previousQuantity + quantity,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rollWeightedLootEntry(entries) {
|
function rollWeightedLootEntry(entries) {
|
||||||
@@ -1044,13 +1457,11 @@ function rollEncounterLoot(database, characterId, encounterId, difficultyId, run
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedQuantities = new Map()
|
const selectedQuantities = new Map()
|
||||||
const lootChanceSlots = context.contentType === 'raid' ? 8 : 5
|
if (Math.random() < dropChance) {
|
||||||
for (let index = 0; index < lootChanceSlots; index += 1) {
|
|
||||||
if (Math.random() >= dropChance) continue
|
|
||||||
const selected = rollWeightedLootEntry(entries)
|
const selected = rollWeightedLootEntry(entries)
|
||||||
selectedQuantities.set(
|
selectedQuantities.set(
|
||||||
selected.id,
|
selected.id,
|
||||||
(selectedQuantities.get(selected.id) ?? 0) + componentDropQuantity(context.droppedItemLevel),
|
coinDropQuantity(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1334,6 +1745,102 @@ function craftItem(database, characterId, recipeId) {
|
|||||||
return getProfile(database, characterId)
|
return getProfile(database, characterId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upgradeItem(database, characterId, itemId) {
|
||||||
|
const item = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
items.id,
|
||||||
|
items.name,
|
||||||
|
items.slot,
|
||||||
|
items.item_level AS itemLevel,
|
||||||
|
character_inventory.quantity,
|
||||||
|
character_inventory.equipped
|
||||||
|
FROM character_inventory
|
||||||
|
JOIN items ON items.id = character_inventory.item_id
|
||||||
|
WHERE character_inventory.character_id = ?
|
||||||
|
AND items.id = ?
|
||||||
|
`).get(characterId, itemId)
|
||||||
|
if (!item) throw new Error('That item is not in the character inventory.')
|
||||||
|
if (item.slot === componentSlot) throw new Error('Components cannot be upgraded.')
|
||||||
|
|
||||||
|
const currentRecipe = database.prepare(`
|
||||||
|
SELECT source_encounter_id AS sourceEncounterId
|
||||||
|
FROM crafting_recipes
|
||||||
|
WHERE item_id = ?
|
||||||
|
`).get(itemId)
|
||||||
|
if (!currentRecipe) throw new Error('No upgrade is available for this item.')
|
||||||
|
|
||||||
|
const targetRecipe = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
crafting_recipes.id,
|
||||||
|
crafting_recipes.item_id AS itemId
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
|
WHERE crafting_recipes.source_encounter_id = ?
|
||||||
|
AND items.slot = ?
|
||||||
|
AND items.item_level = ?
|
||||||
|
`).get(currentRecipe.sourceEncounterId, item.slot, item.itemLevel + 5)
|
||||||
|
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
|
||||||
|
|
||||||
|
const components = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
crafting_recipe_components.item_id AS itemId,
|
||||||
|
crafting_recipe_components.quantity,
|
||||||
|
COALESCE(character_inventory.quantity, 0) AS owned
|
||||||
|
FROM crafting_recipe_components
|
||||||
|
LEFT JOIN character_inventory
|
||||||
|
ON character_inventory.item_id = crafting_recipe_components.item_id
|
||||||
|
AND character_inventory.character_id = ?
|
||||||
|
WHERE crafting_recipe_components.recipe_id = ?
|
||||||
|
`).all(characterId, targetRecipe.id)
|
||||||
|
const missing = components.find((component) => component.owned < component.quantity)
|
||||||
|
if (missing) {
|
||||||
|
const componentItem = itemById(database, missing.itemId)
|
||||||
|
throw new Error(`Need ${missing.quantity} ${componentItem?.name ?? 'component'} to upgrade this item.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
database.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
for (const component of components) {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE character_inventory
|
||||||
|
SET quantity = quantity - ?
|
||||||
|
WHERE character_id = ? AND item_id = ?
|
||||||
|
`).run(component.quantity, characterId, component.itemId)
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE character_inventory
|
||||||
|
SET quantity = quantity - 1,
|
||||||
|
equipped = 0
|
||||||
|
WHERE character_id = ? AND item_id = ?
|
||||||
|
`).run(characterId, itemId)
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_inventory
|
||||||
|
WHERE character_id = ? AND quantity <= 0
|
||||||
|
`).run(characterId)
|
||||||
|
if (item.equipped) {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE character_inventory
|
||||||
|
SET equipped = 0
|
||||||
|
WHERE character_id = ?
|
||||||
|
AND item_id IN (SELECT id FROM items WHERE slot = ?)
|
||||||
|
`).run(characterId, item.slot)
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||||
|
VALUES (?, ?, 1, ?)
|
||||||
|
ON CONFLICT(character_id, item_id)
|
||||||
|
DO UPDATE SET quantity = quantity + 1,
|
||||||
|
equipped = CASE WHEN excluded.equipped = 1 THEN 1 ELSE equipped END
|
||||||
|
`).run(characterId, targetRecipe.itemId, item.equipped ? 1 : 0)
|
||||||
|
database.exec('COMMIT')
|
||||||
|
} catch (error) {
|
||||||
|
database.exec('ROLLBACK')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return getProfile(database, characterId)
|
||||||
|
}
|
||||||
|
|
||||||
function allocateTalent(database, characterId, talentId) {
|
function allocateTalent(database, characterId, talentId) {
|
||||||
const character = database.prepare(`
|
const character = database.prepare(`
|
||||||
SELECT class_id AS classId, talent_points AS talentPoints
|
SELECT class_id AS classId, talent_points AS talentPoints
|
||||||
@@ -1622,7 +2129,7 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
|||||||
ON CONFLICT(character_id, item_id)
|
ON CONFLICT(character_id, item_id)
|
||||||
DO UPDATE SET quantity = quantity + 1
|
DO UPDATE SET quantity = quantity + 1
|
||||||
`).run(characterId, bonusItem.id)
|
`).run(characterId, bonusItem.id)
|
||||||
bonusItem = { ...bonusItem, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1777,6 +2284,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
SET experience = ?, level = ?, talent_points = ?
|
SET experience = ?, level = ?, talent_points = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(newExperience, newLevel, newTalentPoints, characterId)
|
`).run(newExperience, newLevel, newTalentPoints, characterId)
|
||||||
|
const bonusItem = awardRoguelikeCoin(
|
||||||
|
database,
|
||||||
|
characterId,
|
||||||
|
Number(runMetrics?.lootSourceEncounterId),
|
||||||
|
Number(runMetrics?.roguelikeStage),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dungeonName: `${dungeon.name} Roguelike`,
|
dungeonName: `${dungeon.name} Roguelike`,
|
||||||
@@ -1791,7 +2304,7 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
durationSeconds,
|
durationSeconds,
|
||||||
averageItemLevel,
|
averageItemLevel,
|
||||||
unlockedAbilities,
|
unlockedAbilities,
|
||||||
bonusItem: null,
|
bonusItem,
|
||||||
profile: getProfile(database, characterId, accountId),
|
profile: getProfile(database, characterId, accountId),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1880,12 +2393,124 @@ export function gameApiPlugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAuthApiRoute(database, request, response) {
|
||||||
|
if (request.url === '/api/auth/register' && request.method === 'POST') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
const result = registerAccount(database, request, payload)
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
201,
|
||||||
|
{ account: result.account, profile: result.profile, token: result.token },
|
||||||
|
{ 'Set-Cookie': sessionCookie(result.token, request) },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/auth/login' && request.method === 'POST') {
|
||||||
|
const payload = await readJson(request)
|
||||||
|
const result = loginAccount(database, request, payload)
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
{ account: result.account, profile: result.profile, token: result.token },
|
||||||
|
{ 'Set-Cookie': sessionCookie(result.token, request) },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/auth/session' && request.method === 'GET') {
|
||||||
|
const session = currentSession(database, request)
|
||||||
|
if (!session) {
|
||||||
|
sendJson(response, 200, { account: null, profile: null })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sendJson(response, 200, {
|
||||||
|
account: { id: session.accountId, username: session.username },
|
||||||
|
profile: getProfile(database, session.characterId, session.accountId),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.url === '/api/auth/logout' && request.method === 'POST') {
|
||||||
|
const token = requestSessionToken(request)
|
||||||
|
if (token) {
|
||||||
|
database.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash(token))
|
||||||
|
}
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
{ ok: true },
|
||||||
|
{ 'Set-Cookie': sessionCookie('', request, 0) },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAuthApiRequest(request, response, next = null) {
|
||||||
|
if (!request.url?.startsWith('/api/auth/')) {
|
||||||
|
if (next) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJson(response, 404, { error: 'API route not found.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
sendCorsPreflight(request, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCorsHeaders(response, request)
|
||||||
|
|
||||||
|
if (!existsSync(databasePath)) {
|
||||||
|
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = new DatabaseSync(databasePath)
|
||||||
|
database.exec('PRAGMA foreign_keys = ON')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ip = requestIp(request)
|
||||||
|
consumeRateLimit(`auth:${ip}`, 120, 60 * 1000)
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
||||||
|
`).run()
|
||||||
|
if (!(await handleAuthApiRoute(database, request, response))) {
|
||||||
|
sendJson(response, 404, { error: 'API route not found.' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const status = Number(error?.status) || 400
|
||||||
|
const headers = error?.retryAfter
|
||||||
|
? { 'Retry-After': String(error.retryAfter) }
|
||||||
|
: {}
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
status,
|
||||||
|
{ error: error instanceof Error ? error.message : 'Unable to process request.' },
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
database.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleApiRequest(request, response, next) {
|
export async function handleApiRequest(request, response, next) {
|
||||||
if (!request.url?.startsWith('/api/')) {
|
if (!request.url?.startsWith('/api/')) {
|
||||||
next()
|
next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
sendCorsPreflight(request, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCorsHeaders(response, request)
|
||||||
|
|
||||||
if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') {
|
if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') {
|
||||||
sendBossImage(request, response)
|
sendBossImage(request, response)
|
||||||
return
|
return
|
||||||
@@ -1911,59 +2536,23 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
||||||
`).run()
|
`).run()
|
||||||
|
|
||||||
if (request.url === '/api/auth/register' && request.method === 'POST') {
|
if (await handleAuthApiRoute(database, request, response)) {
|
||||||
const payload = await readJson(request)
|
|
||||||
const result = registerAccount(database, request, payload)
|
|
||||||
sendJson(
|
|
||||||
response,
|
|
||||||
201,
|
|
||||||
{ account: result.account, profile: result.profile },
|
|
||||||
{ 'Set-Cookie': sessionCookie(result.token, request) },
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.url === '/api/auth/login' && request.method === 'POST') {
|
|
||||||
const payload = await readJson(request)
|
|
||||||
const result = loginAccount(database, request, payload)
|
|
||||||
sendJson(
|
|
||||||
response,
|
|
||||||
200,
|
|
||||||
{ account: result.account, profile: result.profile },
|
|
||||||
{ 'Set-Cookie': sessionCookie(result.token, request) },
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.url === '/api/auth/session' && request.method === 'GET') {
|
|
||||||
const session = currentSession(database, request)
|
|
||||||
if (!session) {
|
|
||||||
sendJson(response, 200, { account: null, profile: null })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendJson(response, 200, {
|
|
||||||
account: { id: session.accountId, username: session.username },
|
|
||||||
profile: getProfile(database, session.characterId, session.accountId),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.url === '/api/auth/logout' && request.method === 'POST') {
|
|
||||||
const token = parseCookies(request)[sessionCookieName]
|
|
||||||
if (token) {
|
|
||||||
database.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash(token))
|
|
||||||
}
|
|
||||||
sendJson(
|
|
||||||
response,
|
|
||||||
200,
|
|
||||||
{ ok: true },
|
|
||||||
{ 'Set-Cookie': sessionCookie('', request, 0) },
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -2059,6 +2648,16 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
|
||||||
|
if (itemUpgrade && request.method === 'POST') {
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
|
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
|
||||||
if (encounterLootRoll && request.method === 'POST') {
|
if (encounterLootRoll && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
|
|||||||
+625
-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);
|
||||||
@@ -2698,6 +2973,10 @@ h2 {
|
|||||||
--rarity-color: #b584e3;
|
--rarity-color: #b584e3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rarity-legendary {
|
||||||
|
--rarity-color: #f2a13a;
|
||||||
|
}
|
||||||
|
|
||||||
.rarity-common {
|
.rarity-common {
|
||||||
--rarity-color: #a8a3ad;
|
--rarity-color: #a8a3ad;
|
||||||
}
|
}
|
||||||
@@ -3226,7 +3505,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 +3522,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 +3659,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 +4037,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 +4048,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 +4232,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 +4259,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 +4275,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 +4291,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 +4391,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 +4401,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 +4470,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+74
-8
@@ -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">
|
||||||
@@ -252,7 +287,8 @@ function App() {
|
|||||||
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
|
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
|
||||||
{ 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 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 +321,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 +515,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 +547,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -621,7 +681,7 @@ function App() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="section-note">
|
<p className="section-note">
|
||||||
Bosses roll {lootChanceSlots} loot chances against the listed table, 1-3 materials each
|
Bosses drop 1-3 boss coins from one loot roll
|
||||||
{activity.completionItemLevel
|
{activity.completionItemLevel
|
||||||
? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
|
? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -641,7 +701,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<strong>{encounter.enemyName}</strong>
|
<strong>{encounter.enemyName}</strong>
|
||||||
<small>{loot.length > 0 ? `${lootChanceSlots} weighted chances, 1-3 each` : 'No component table'}</small>
|
<small>{loot.length > 0 ? '1 loot roll, 1-3 coins' : 'No coin table'}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="loot-items">
|
<div className="loot-items">
|
||||||
@@ -663,6 +723,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 +743,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 +793,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ function ItemsTab({ data, setData, setSaving, saving }: {
|
|||||||
</label>
|
</label>
|
||||||
<label>Rarity
|
<label>Rarity
|
||||||
<select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
|
<select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
|
||||||
{['common', 'uncommon', 'rare', 'epic'].map((r) => (
|
{['common', 'uncommon', 'rare', 'epic', 'legendary'].map((r) => (
|
||||||
<option key={r} value={r}>{r}</option>
|
<option key={r} value={r}>{r}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1314,7 +1321,7 @@ export function CombatScreen({
|
|||||||
<div className="bonus-item-detail">
|
<div className="bonus-item-detail">
|
||||||
<span>{reward.bonusItem.glyph}</span>
|
<span>{reward.bonusItem.glyph}</span>
|
||||||
<strong className={`rarity-${reward.bonusItem.rarity}`}>{reward.bonusItem.name}</strong>
|
<strong className={`rarity-${reward.bonusItem.rarity}`}>{reward.bonusItem.name}</strong>
|
||||||
<small>Item Level {reward.bonusItem.itemLevel}</small>
|
<small>Item Level {reward.bonusItem.itemLevel} x{reward.bonusItem.quantity}</small>
|
||||||
{reward.bonusItem.duplicate && <small> (owned x{reward.bonusItem.quantityAfter})</small>}
|
{reward.bonusItem.duplicate && <small> (owned x{reward.bonusItem.quantityAfter})</small>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
craftItem,
|
craftItem,
|
||||||
equipItem,
|
equipItem,
|
||||||
loadProfile,
|
loadProfile,
|
||||||
|
upgradeItem,
|
||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
type EquipmentSlot,
|
type EquipmentSlot,
|
||||||
type Item,
|
type Item,
|
||||||
@@ -22,6 +23,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
|
||||||
@@ -43,8 +47,11 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
const [equipping, setEquipping] = useState(false)
|
const [equipping, setEquipping] = useState(false)
|
||||||
const [breakingDown, setBreakingDown] = useState(false)
|
const [breakingDown, setBreakingDown] = useState(false)
|
||||||
const [crafting, setCrafting] = useState(false)
|
const [crafting, setCrafting] = useState(false)
|
||||||
|
const [upgrading, setUpgrading] = 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)
|
||||||
@@ -54,6 +61,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
firstRecipe?.id ?? null,
|
firstRecipe?.id ?? null,
|
||||||
)
|
)
|
||||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
||||||
|
const selectedItemRecipe = selectedItem
|
||||||
|
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||||
|
: undefined
|
||||||
|
const upgradeRecipe = selectedItem && selectedItemRecipe
|
||||||
|
? profile.craftingRecipes.find((recipe) =>
|
||||||
|
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
|
||||||
|
&& recipe.item.slot === selectedItem.slot
|
||||||
|
&& recipe.item.itemLevel === selectedItem.itemLevel + 5,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
const equippedBySlot = useMemo(
|
const equippedBySlot = useMemo(
|
||||||
() => new Map(
|
() => new Map(
|
||||||
profile.inventory
|
profile.inventory
|
||||||
@@ -75,6 +92,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 +117,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(() => {})
|
||||||
@@ -160,6 +201,23 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function upgradeSelected() {
|
||||||
|
if (!selectedItem || !upgradeRecipe) return
|
||||||
|
saveScroll()
|
||||||
|
setUpgrading(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
const updated = await upgradeItem(selectedItem.id)
|
||||||
|
onUpdated(updated)
|
||||||
|
setSelectedItemId(upgradeRecipe.item.id)
|
||||||
|
setMessage(`${selectedItem.name} upgraded to ${upgradeRecipe.item.name}.`)
|
||||||
|
} catch (reason) {
|
||||||
|
setMessage(reason instanceof Error ? reason.message : 'Unable to upgrade item.')
|
||||||
|
} finally {
|
||||||
|
setUpgrading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
@@ -230,16 +288,26 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={selectedItem.equipped || equipping || breakingDown}
|
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
|
||||||
onClick={equipSelected}
|
onClick={equipSelected}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
||||||
</button>
|
</button>
|
||||||
|
{upgradeRecipe && (
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
|
||||||
|
onClick={upgradeSelected}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
|
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
|
||||||
<button
|
<button
|
||||||
className="breakdown-button"
|
className="breakdown-button"
|
||||||
disabled={equipping || breakingDown}
|
disabled={equipping || breakingDown || upgrading}
|
||||||
onClick={breakdownSelected}
|
onClick={breakdownSelected}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -270,6 +338,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 +371,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 +405,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 +428,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 +441,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 +458,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 +476,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 +562,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,10 +4,18 @@ 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 {
|
import {
|
||||||
|
DualScreenTopCombat,
|
||||||
|
useDualScreen,
|
||||||
|
useDualScreenPublisher,
|
||||||
|
type DualScreenCombatState,
|
||||||
|
} from '../dualScreen'
|
||||||
|
import {
|
||||||
|
loadPvpRoguelikeCheckpoint,
|
||||||
randomCpuDifficulty,
|
randomCpuDifficulty,
|
||||||
recordCpuPvpLeaderboard,
|
recordCpuPvpLeaderboard,
|
||||||
|
recordPvpRoguelikeCheckpoint,
|
||||||
type CpuDifficulty,
|
type CpuDifficulty,
|
||||||
type PvpContentType,
|
type PvpContentType,
|
||||||
} from '../pvpRoguelike'
|
} from '../pvpRoguelike'
|
||||||
@@ -23,6 +31,7 @@ type BossMechanic =
|
|||||||
|
|
||||||
type PvpEncounter = DungeonEncounter & {
|
type PvpEncounter = DungeonEncounter & {
|
||||||
bossMechanics?: BossMechanic[]
|
bossMechanics?: BossMechanic[]
|
||||||
|
sourceEncounterId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
||||||
@@ -238,7 +247,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))
|
||||||
@@ -255,6 +264,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
|
|||||||
const isBoss = index === 2
|
const isBoss = index === 2
|
||||||
return {
|
return {
|
||||||
...encounter,
|
...encounter,
|
||||||
|
sourceEncounterId: encounter.id,
|
||||||
id: 910000 + stage * 10 + index,
|
id: 910000 + stage * 10 + index,
|
||||||
sequence: (stage - 1) * 3 + index + 1,
|
sequence: (stage - 1) * 3 + index + 1,
|
||||||
isBoss,
|
isBoss,
|
||||||
@@ -366,7 +376,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],
|
||||||
@@ -375,6 +385,10 @@ export function PvPRoguelikeScreen({
|
|||||||
() => buildOpponentDebuffChoices(starterSpells, abilityLabelMode),
|
() => buildOpponentDebuffChoices(starterSpells, abilityLabelMode),
|
||||||
[abilityLabelMode, starterSpells],
|
[abilityLabelMode, starterSpells],
|
||||||
)
|
)
|
||||||
|
const [checkpointStage, setCheckpointStage] = useState(() =>
|
||||||
|
loadPvpRoguelikeCheckpoint(profile.character.id, contentType),
|
||||||
|
)
|
||||||
|
const [startStage, setStartStage] = useState(checkpointStage)
|
||||||
const maxResource = gameClass.maxResource
|
const maxResource = gameClass.maxResource
|
||||||
const partyTemplate = useMemo(
|
const partyTemplate = useMemo(
|
||||||
() => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
() => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
||||||
@@ -391,8 +405,8 @@ export function PvPRoguelikeScreen({
|
|||||||
[contentType],
|
[contentType],
|
||||||
)
|
)
|
||||||
const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
|
const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
|
||||||
const [stage, setStage] = useState(1)
|
const [stage, setStage] = useState(startStage)
|
||||||
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, 1, contentType))
|
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
|
||||||
const [encounterIndex, setEncounterIndex] = useState(0)
|
const [encounterIndex, setEncounterIndex] = useState(0)
|
||||||
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
|
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
|
||||||
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
||||||
@@ -410,10 +424,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 +449,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,31 +472,50 @@ export function PvPRoguelikeScreen({
|
|||||||
}, 900)
|
}, 900)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const finishRoguelikeRun = useCallback((cleared: number) => {
|
useEffect(() => {
|
||||||
if (rewardClaimedRef.current) return
|
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||||
rewardClaimedRef.current = true
|
setCheckpointStage(loadedCheckpoint)
|
||||||
const bossesCleared = Math.floor(cleared / 3)
|
setStartStage(loadedCheckpoint)
|
||||||
|
}, [contentType, profile.character.id])
|
||||||
|
|
||||||
|
const awardBossReward = useCallback((encounterIndexValue: number) => {
|
||||||
|
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
||||||
|
bossRewardClaimedRef.current.add(encounterIndexValue)
|
||||||
|
const rewardEncounter = encounters[encounterIndexValue]
|
||||||
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',
|
||||||
|
lootSourceEncounterId: rewardEncounter?.sourceEncounterId,
|
||||||
|
roguelikeStage: stage,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setReward(result)
|
setReward(result)
|
||||||
onProfileUpdated(result.profile)
|
onProfileUpdated(result.profile)
|
||||||
|
if (result.bonusItem) {
|
||||||
|
addLog(
|
||||||
|
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
|
||||||
|
'loot',
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
setRewardError(
|
setRewardError(
|
||||||
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
|
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [elapsedTicks, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])
|
}, [addLog, elapsedTicks, encounters, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id, stage])
|
||||||
|
|
||||||
|
const finishRoguelikeRun = useCallback(() => {
|
||||||
|
if (rewardClaimedRef.current) return
|
||||||
|
rewardClaimedRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPlayerBuffChoices((current) => current
|
setPlayerBuffChoices((current) => current
|
||||||
@@ -491,7 +533,7 @@ export function PvPRoguelikeScreen({
|
|||||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const firstSegment = buildEncounterSegment(encounterPool, 1, contentType)
|
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType)
|
||||||
const firstEncounter = firstSegment[0]
|
const firstEncounter = firstSegment[0]
|
||||||
const basePlayer = starterSide(partyTemplate, maxResource)
|
const basePlayer = starterSide(partyTemplate, maxResource)
|
||||||
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
|
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
|
||||||
@@ -501,9 +543,10 @@ 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(startStage)
|
||||||
setElapsedTicks(0)
|
setElapsedTicks(0)
|
||||||
setStatus('queueing')
|
setStatus('queueing')
|
||||||
setPlayerSide(basePlayer)
|
setPlayerSide(basePlayer)
|
||||||
@@ -514,6 +557,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,28 +566,29 @@ 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 at stage ${startStage}.`)
|
||||||
setCpuDifficulty(randomCpu)
|
setCpuDifficulty(randomCpu)
|
||||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters immediately.`, tone: 'system' }])
|
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }])
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`Round 1 begins against CPU ${randomCpu}.`, 'system')
|
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system')
|
||||||
}, 500)
|
}, 500)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}
|
}
|
||||||
setQueueMessage('Searching queue. No player found yet.')
|
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`)
|
||||||
setLog([{ id: 1, text: 'Searching queue. No player found yet.', tone: 'system' }])
|
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }])
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
const randomCpu = randomCpuDifficulty()
|
const randomCpu = randomCpuDifficulty()
|
||||||
setCpuDifficulty(randomCpu)
|
setCpuDifficulty(randomCpu)
|
||||||
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`No queued player found. CPU ${randomCpu} steps in.`, 'system')
|
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system')
|
||||||
}, 1400)
|
}, 1400)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate])
|
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage])
|
||||||
|
|
||||||
const applySpell = useCallback((
|
const applySpell = useCallback((
|
||||||
current: SideState,
|
current: SideState,
|
||||||
@@ -659,10 +705,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 +855,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 +864,18 @@ 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)
|
||||||
|
const nextCheckpoint = recordPvpRoguelikeCheckpoint(
|
||||||
|
profile.character.id,
|
||||||
|
contentType,
|
||||||
|
stage,
|
||||||
|
)
|
||||||
|
if (nextCheckpoint > checkpointStage) {
|
||||||
|
setCheckpointStage(nextCheckpoint)
|
||||||
|
addLog(`Stage ${nextCheckpoint} checkpoint unlocked.`, 'loot')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
playerRef.current = nextPlayer
|
playerRef.current = nextPlayer
|
||||||
cpuRef.current = nextCpu
|
cpuRef.current = nextCpu
|
||||||
@@ -791,28 +884,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, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, stage, status])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||||
@@ -828,6 +916,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 +1010,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 +1031,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 +1125,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 +1147,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 +1163,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 +1253,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 +1264,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 +1292,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 +1333,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 +1351,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 && (
|
||||||
<>
|
<>
|
||||||
@@ -1193,6 +1368,13 @@ export function PvPRoguelikeScreen({
|
|||||||
Ability Unlocked: {ability.name}
|
Ability Unlocked: {ability.name}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
{reward.bonusItem && (
|
||||||
|
<p className="ability-unlock">
|
||||||
|
<span>{reward.bonusItem.glyph}</span>
|
||||||
|
{reward.bonusItem.name} x{reward.bonusItem.quantity}
|
||||||
|
{reward.bonusItem.duplicate ? ` (owned x${reward.bonusItem.quantityAfter})` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+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[] = [
|
||||||
|
|||||||
+668
-154
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
|
|||||||
+1876
-5827
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -59,7 +59,7 @@ export type Item = {
|
|||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
slot: EquipmentSlot
|
slot: EquipmentSlot
|
||||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic'
|
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||||
itemLevel: number
|
itemLevel: number
|
||||||
healingPower: number
|
healingPower: number
|
||||||
maxResourceBonus: number
|
maxResourceBonus: number
|
||||||
@@ -234,6 +234,7 @@ export type Account = {
|
|||||||
export type AuthSession = {
|
export type AuthSession = {
|
||||||
account: Account | null
|
account: Account | null
|
||||||
profile: CharacterProfile | null
|
profile: CharacterProfile | null
|
||||||
|
token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BonusItem = {
|
export type BonusItem = {
|
||||||
@@ -247,6 +248,7 @@ export type BonusItem = {
|
|||||||
maxResourceBonus: number
|
maxResourceBonus: number
|
||||||
glyph: string
|
glyph: string
|
||||||
description: string
|
description: string
|
||||||
|
quantity: number
|
||||||
duplicate: boolean
|
duplicate: boolean
|
||||||
quantityAfter: number
|
quantityAfter: number
|
||||||
}
|
}
|
||||||
@@ -338,6 +340,8 @@ export async function completeRoguelike(
|
|||||||
options?: {
|
options?: {
|
||||||
bossesCleared?: number
|
bossesCleared?: number
|
||||||
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
||||||
|
lootSourceEncounterId?: number
|
||||||
|
roguelikeStage?: number
|
||||||
},
|
},
|
||||||
): Promise<DungeonReward> {
|
): Promise<DungeonReward> {
|
||||||
return activeGameRepository().completeRoguelike(
|
return activeGameRepository().completeRoguelike(
|
||||||
@@ -374,6 +378,10 @@ export async function craftItem(recipeId: number): Promise<CharacterProfile> {
|
|||||||
return activeGameRepository().craftItem(recipeId)
|
return activeGameRepository().craftItem(recipeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function upgradeItem(itemId: number): Promise<CharacterProfile> {
|
||||||
|
return activeGameRepository().upgradeItem(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
export async function rollEncounterLoot(
|
export async function rollEncounterLoot(
|
||||||
encounterId: number,
|
encounterId: number,
|
||||||
difficultyId: number,
|
difficultyId: number,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type CpuPvpLeaderboardEntry = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
|
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
|
||||||
|
const checkpointKey = 'chronicle.pvpRoguelikeCheckpoint.v1'
|
||||||
|
|
||||||
export function randomCpuDifficulty(): CpuDifficulty {
|
export function randomCpuDifficulty(): CpuDifficulty {
|
||||||
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
|
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
|
||||||
@@ -44,3 +45,24 @@ export function recordCpuPvpLeaderboard(entry: CpuPvpLeaderboardEntry) {
|
|||||||
.slice(0, 30)
|
.slice(0, 30)
|
||||||
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next))
|
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkpointStorageKey(characterId: number, contentType: PvpContentType) {
|
||||||
|
return `${checkpointKey}:${characterId}:${contentType}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPvpRoguelikeCheckpoint(characterId: number, contentType: PvpContentType) {
|
||||||
|
const value = Number(localStorage.getItem(checkpointStorageKey(characterId, contentType)) ?? 1)
|
||||||
|
return Number.isInteger(value) && value >= 5 ? value : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordPvpRoguelikeCheckpoint(
|
||||||
|
characterId: number,
|
||||||
|
contentType: PvpContentType,
|
||||||
|
stage: number,
|
||||||
|
) {
|
||||||
|
if (stage < 5 || stage % 5 !== 0) return loadPvpRoguelikeCheckpoint(characterId, contentType)
|
||||||
|
const current = loadPvpRoguelikeCheckpoint(characterId, contentType)
|
||||||
|
const next = Math.max(current, stage)
|
||||||
|
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"files": ["vite.config.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user