Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f5a957963 | |||
| f8a1fbc5e2 | |||
| bab2dce6c3 | |||
| cb38042eca | |||
| 753bba581a | |||
| 2300973164 | |||
| 1281be69d8 | |||
| 4fc15ebe9a | |||
| 7313c968e6 | |||
| 207fcd1a15 | |||
| fd6a1ce3c7 | |||
| f8b98e6b23 | |||
| fc7c6488ea | |||
| ba6d3b614e | |||
| 88874933c3 | |||
| bf12aefeeb | |||
| 814eb1998d | |||
| 7fe62d8c82 | |||
| 3a8d5ad8c5 | |||
| a604569a2f |
@@ -0,0 +1,9 @@
|
|||||||
|
# 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.
|
||||||
|
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
|
||||||
|
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
|
||||||
|
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
|
||||||
|
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||||
|
- Apply game changes to both web version and mobile app version.
|
||||||
+221
@@ -43,6 +43,227 @@ 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.
|
||||||
|
|
||||||
|
## TrueNAS single-container hosting
|
||||||
|
|
||||||
|
### TrueNAS SCALE runbook
|
||||||
|
|
||||||
|
This is the simplest TrueNAS setup. One container serves the browser game,
|
||||||
|
auth routes, game API routes, and one SQLite database. Use this when you want
|
||||||
|
`iwanttoheal.phenomrom.com` to host the playable browser version and you want
|
||||||
|
code updates to be a Git pull plus app restart.
|
||||||
|
|
||||||
|
Portainer is not required. Use TrueNAS **Apps > Discover > Install via YAML**.
|
||||||
|
|
||||||
|
Repository:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://git.whoagland.com/phenom/i-want-to-heal.git
|
||||||
|
```
|
||||||
|
|
||||||
|
TrueNAS paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
/mnt/usbssds/apps/iwanttoheal/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the app directory and clone the repo:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal
|
||||||
|
sudo git clone https://git.whoagland.com/phenom/i-want-to-heal.git app
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the clone was run with `sudo`, give the normal TrueNAS user ownership:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo chown -R truenas_admin:truenas_admin /mnt/usbssds/apps/iwanttoheal
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the persistent data folder:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p /mnt/usbssds/apps/iwanttoheal/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Check that the production server file exists:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls /mnt/usbssds/apps/iwanttoheal/app/server/production.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
If that file is missing, push the latest code to `git.whoagland.com` from the
|
||||||
|
development machine, then pull on TrueNAS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
If Git fails with `chmod ... Operation not permitted`, do not use a media or SMB
|
||||||
|
dataset for the repo. Git needs normal file locking and chmod behavior. Create or
|
||||||
|
use a dedicated apps dataset and clone under `/mnt/usbssds/apps/...`.
|
||||||
|
|
||||||
|
### TrueNAS app YAML
|
||||||
|
|
||||||
|
In TrueNAS:
|
||||||
|
|
||||||
|
1. Open **Apps**.
|
||||||
|
2. Open **Discover**.
|
||||||
|
3. Click the three-dot menu.
|
||||||
|
4. Choose **Install via YAML**.
|
||||||
|
5. Name the app `iwanttoheal`.
|
||||||
|
6. Paste this YAML:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
iwanttoheal:
|
||||||
|
image: node:24-bookworm-slim
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -lc "npm ci && npm run db:init && npm run build && npm start"
|
||||||
|
environment:
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: "4173"
|
||||||
|
TRUST_PROXY: "1"
|
||||||
|
COOKIE_SECURE: "1"
|
||||||
|
CORS_ORIGINS: "http://localhost,https://localhost,capacitor://localhost,https://iwanttoheal.phenomrom.com,https://auth.phenomrom.com"
|
||||||
|
ports:
|
||||||
|
- "4173:4173"
|
||||||
|
volumes:
|
||||||
|
- /mnt/usbssds/apps/iwanttoheal/app:/app
|
||||||
|
- /mnt/usbssds/apps/iwanttoheal/data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
The app listens inside Docker on port `4173`. The database lives at
|
||||||
|
`/mnt/usbssds/apps/iwanttoheal/data/game.db` because that host directory is
|
||||||
|
mounted into the container as `/app/data`. This is persistent runtime data, not
|
||||||
|
code. Do not commit it and do not copy the Mac `data/game.db` over it during
|
||||||
|
deploys.
|
||||||
|
|
||||||
|
The startup command installs dependencies, applies schema/static-content
|
||||||
|
updates, builds the web app, and starts the production server.
|
||||||
|
|
||||||
|
Test the local TrueNAS service:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://TRUENAS-IP:4173/api/auth/session
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"account":null,"profile":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reverse proxy
|
||||||
|
|
||||||
|
Point `iwanttoheal.phenomrom.com` at the TrueNAS app through HTTPS. Do not expose
|
||||||
|
port `4173` directly to the internet. Put Caddy or another reverse proxy in
|
||||||
|
front:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
iwanttoheal.phenomrom.com {
|
||||||
|
reverse_proxy TRUENAS-IP:4173
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.phenomrom.com {
|
||||||
|
reverse_proxy TRUENAS-IP:4173
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both hostnames can point at the same container. `iwanttoheal.phenomrom.com`
|
||||||
|
serves the browser game. `auth.phenomrom.com` stays available as an auth URL for
|
||||||
|
Android or other clients that need a dedicated auth hostname.
|
||||||
|
|
||||||
|
DNS should point both hostnames at the public IP or dynamic DNS name that reaches
|
||||||
|
the reverse proxy. Forward public ports `80` and `443` to the reverse proxy host.
|
||||||
|
|
||||||
|
Test the public game and auth URLs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl https://iwanttoheal.phenomrom.com
|
||||||
|
curl https://auth.phenomrom.com/api/auth/session
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected auth response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"account":null,"profile":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
### App build config
|
||||||
|
|
||||||
|
For the hosted browser game, no separate auth build setting is needed. The web
|
||||||
|
app can call same-origin routes like `/api/auth/login` and `/api/profile`.
|
||||||
|
|
||||||
|
For an Android build that should use the TrueNAS-hosted game API, build with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run android:apk:truenas
|
||||||
|
```
|
||||||
|
|
||||||
|
If you intentionally want Android auth calls to use `auth.phenomrom.com`, also
|
||||||
|
set `VITE_AUTH_API_BASE_URL=https://auth.phenomrom.com`. Otherwise, leave it
|
||||||
|
unset and auth uses the same base URL as the game API.
|
||||||
|
|
||||||
|
Android runs the bundled web app from a local Capacitor origin, not from
|
||||||
|
`iwanttoheal.phenomrom.com`. The hosted server must allow that origin through
|
||||||
|
CORS, which is why the TrueNAS YAML includes `http://localhost`,
|
||||||
|
`https://localhost`, and `capacitor://localhost`.
|
||||||
|
|
||||||
|
### Updating the TrueNAS game app
|
||||||
|
|
||||||
|
Push changes from the development machine to `git.whoagland.com`, then pull them
|
||||||
|
on TrueNAS:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Before restarting, back up the persistent database:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
|
||||||
|
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the `iwanttoheal` app in the TrueNAS Apps UI after pulling. The app
|
||||||
|
command runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start` on
|
||||||
|
startup, so dependency, schema, and browser bundle changes are applied each time
|
||||||
|
the container restarts.
|
||||||
|
|
||||||
|
`npm run db:init` updates schema and seeded static game content. It should not
|
||||||
|
erase accounts, characters, inventory, or save progress. Character resets are
|
||||||
|
separate manual operations and should only be run intentionally.
|
||||||
|
|
||||||
|
Normal update workflow:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# development machine
|
||||||
|
git add .
|
||||||
|
git commit -m "Update game"
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
# TrueNAS shell
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
|
||||||
|
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the TrueNAS app.
|
||||||
|
|
||||||
|
For the shorter checklist, see [docs/push-updates.md](docs/push-updates.md).
|
||||||
|
|
||||||
|
### Existing auth-only app
|
||||||
|
|
||||||
|
If `iwanttoheal-auth` was already created during earlier testing, the simplest
|
||||||
|
path is to stop that app and use the single `iwanttoheal` app above. The single
|
||||||
|
container serves both domains and avoids two processes sharing one SQLite file.
|
||||||
|
|
||||||
## 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.
@@ -57,13 +57,13 @@ For an online production build, see [DEPLOYMENT.md](DEPLOYMENT.md).
|
|||||||
- Dungeon XP rewards, level-up handling, talent-point awards, and ability unlocks
|
- Dungeon XP rewards, level-up handling, talent-point awards, and ability unlocks
|
||||||
- SQLite-backed class talent trees with ranks, tiers, prerequisites, refunds,
|
- SQLite-backed class talent trees with ranks, tiers, prerequisites, refunds,
|
||||||
and immediate persistence
|
and immediate persistence
|
||||||
- Seven equipment slots, starter item-level 1 gear, inventory comparison, and
|
- Nine equipment slots, empty starter inventory, craftable item-level 1 gear,
|
||||||
persistent equipping
|
inventory comparison, and persistent equipping
|
||||||
- Aggregate item level, healing power, and resource bonuses that affect combat
|
- Aggregate item level, healing power, and resource bonuses that affect combat
|
||||||
- Five SQLite-authored dungeon difficulty tiers with level gates, combat
|
- Four playable SQLite-authored content tiers at item levels 1, 10, 20, and 25
|
||||||
scaling, XP multipliers, and item-level reward bands
|
with level gates, combat scaling, XP multipliers, and reward bands
|
||||||
- Encounter-specific weighted loot tables for every difficulty, with authored
|
- Gear progression through item levels 1, 5, 10, 15, 20, and 25 with
|
||||||
drop chances, slot pools, and item-level 5 through 25 reward variants
|
boss-coin crafting and upgrade steps
|
||||||
- One live loot roll per defeated encounter, shown in the combat log and
|
- One live loot roll per defeated encounter, shown in the combat log and
|
||||||
dungeon-complete summary
|
dungeon-complete summary
|
||||||
- Atomic inventory awards with retry-safe roll records and stacked duplicate
|
- Atomic inventory awards with retry-safe roll records and stacked duplicate
|
||||||
|
|||||||
@@ -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 59
|
||||||
versionName "1.0"
|
versionName "1.0.39"
|
||||||
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 = 220;
|
||||||
|
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
|
||||||
|
|||||||
+964
-69
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
|||||||
|
# Gearing System
|
||||||
|
|
||||||
|
## Current Rule
|
||||||
|
|
||||||
|
The game uses fewer playable content tiers and more gear upgrade steps.
|
||||||
|
|
||||||
|
Content tiers:
|
||||||
|
|
||||||
|
| Content Tier | Unlock Level | Purpose |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| iLvl 1 | 1 | First real gear set |
|
||||||
|
| iLvl 10 | 10 | Midgame jump |
|
||||||
|
| iLvl 20 | 20 | Endgame jump |
|
||||||
|
| iLvl 25 | 25 | Hard endgame |
|
||||||
|
|
||||||
|
Gear tiers:
|
||||||
|
|
||||||
|
| Gear Tier | How It Is Used |
|
||||||
|
| ---: | --- |
|
||||||
|
| 1 | Crafted from iLvl 1 content |
|
||||||
|
| 5 | Upgrade tier from iLvl 1 content coins |
|
||||||
|
| 10 | Crafted/upgraded from iLvl 10 content coins |
|
||||||
|
| 15 | Gear-only upgrade tier from iLvl 10 content coins |
|
||||||
|
| 20 | Crafted/upgraded from iLvl 20 content coins |
|
||||||
|
| 25 | Crafted/upgraded from iLvl 25 content coins |
|
||||||
|
|
||||||
|
This keeps the dungeon/raid picker simple while still giving players steady gear goals.
|
||||||
|
|
||||||
|
## New Characters
|
||||||
|
|
||||||
|
New characters start with no gear equipped.
|
||||||
|
|
||||||
|
The first goal is to clear iLvl 1 content, earn raw boss coins, and craft the first iLvl 1 set. Starter gear exists as craftable gear, not automatic inventory.
|
||||||
|
|
||||||
|
## Coin Tiers
|
||||||
|
|
||||||
|
Coins are component items. Each coin is tied to a boss and a content tier.
|
||||||
|
|
||||||
|
| Content Tier | Coin Prefix | Rarity Key | Example |
|
||||||
|
| ---: | --- | --- | --- |
|
||||||
|
| 1 | Raw | common | Raw Bulldrome Coin |
|
||||||
|
| 10 | Green | uncommon | Green Bulldrome Coin |
|
||||||
|
| 20 | Purple | epic | Purple Bulldrome Coin |
|
||||||
|
| 25 | Orange | legendary | Orange Bulldrome Coin |
|
||||||
|
|
||||||
|
iLvl 5 and iLvl 15 gear do not have their own playable content tier. They use coins from the previous playable tier:
|
||||||
|
|
||||||
|
| Gear Tier | Coin Tier Used |
|
||||||
|
| ---: | ---: |
|
||||||
|
| 1 | 1 |
|
||||||
|
| 5 | 1 |
|
||||||
|
| 10 | 10 |
|
||||||
|
| 15 | 10 |
|
||||||
|
| 20 | 20 |
|
||||||
|
| 25 | 25 |
|
||||||
|
|
||||||
|
## Boss Loot
|
||||||
|
|
||||||
|
Each boss drops one boss coin for the selected content tier.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Bulldrome at iLvl 1 drops Raw Bulldrome Coins.
|
||||||
|
- Tigrex at iLvl 10 drops Green Tigrex Coins.
|
||||||
|
- Barroth at iLvl 20 drops Purple Barroth Coins.
|
||||||
|
- Anjanath at iLvl 25 drops Orange Anjanath Coins.
|
||||||
|
|
||||||
|
## Crafting And Upgrades
|
||||||
|
|
||||||
|
The first gear item in a boss/item line can be crafted directly. Higher versions should be reached through Upgrade.
|
||||||
|
|
||||||
|
Current rule:
|
||||||
|
|
||||||
|
- Craft iLvl 1 boss gear directly.
|
||||||
|
- Upgrade iLvl 1 -> 5 with iLvl 1 coins.
|
||||||
|
- Craft or upgrade iLvl 10 gear from iLvl 10 content.
|
||||||
|
- Upgrade iLvl 10 -> 15 with iLvl 10 coins.
|
||||||
|
- Craft or upgrade iLvl 20 gear from iLvl 20 content.
|
||||||
|
- Craft or upgrade iLvl 25 gear from iLvl 25 content.
|
||||||
|
|
||||||
|
Upgrade consumes the old item and awards the upgraded item. This avoids duplicate clutter and keeps item identity clear.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Upgrade | Cost Source |
|
||||||
|
| --- | --- |
|
||||||
|
| Raw Bulldrome Helmet iLvl 1 -> Honed Bulldrome Helmet iLvl 5 | Raw Bulldrome Coins |
|
||||||
|
| Green Tigrex Helmet iLvl 10 -> Blue Tigrex Helmet iLvl 15 | Green Tigrex Coins |
|
||||||
|
| Purple Bulldrome Helmet iLvl 20 -> Orange Bulldrome Helmet iLvl 25 | Orange Bulldrome Coins |
|
||||||
|
|
||||||
|
## UI Behavior
|
||||||
|
|
||||||
|
The dungeon and raid picker only shows playable content tiers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
iLvl 1 -> iLvl 10 -> iLvl 20 -> iLvl 25
|
||||||
|
```
|
||||||
|
|
||||||
|
The equipment screen still shows gear recipes for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
iLvl 1 -> iLvl 5 -> iLvl 10 -> iLvl 15 -> iLvl 20 -> iLvl 25
|
||||||
|
```
|
||||||
|
|
||||||
|
Direct crafting is blocked for recipes that have a lower item-level version in the same boss/item line. Use the selected item's Upgrade button for those.
|
||||||
|
|
||||||
|
## Roguelike Loot
|
||||||
|
|
||||||
|
Roguelike gear should follow the same tier brackets.
|
||||||
|
|
||||||
|
Recommended mapping:
|
||||||
|
|
||||||
|
| Stage Band | Coin Tier |
|
||||||
|
| --- | ---: |
|
||||||
|
| 1-4 | 1 |
|
||||||
|
| 5-9 | 10 |
|
||||||
|
| 10-14 | 10 |
|
||||||
|
| 15-19 | 20 |
|
||||||
|
| 20+ | 25 |
|
||||||
|
|
||||||
|
Boss-based coins are still preferred. A roguelike boss should award coins for its actual boss template when possible.
|
||||||
|
|
||||||
|
## Data Notes
|
||||||
|
|
||||||
|
Authoritative gearing data lives in SQLite seed data:
|
||||||
|
|
||||||
|
- `db/seed.sql`
|
||||||
|
- `src/offline-starter-profile.json`
|
||||||
|
|
||||||
|
Run this after changing seed data:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run db:init
|
||||||
|
npm run offline:export
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run build` already runs `offline:export`, so a production build also refreshes the offline starter profile.
|
||||||
|
|
||||||
|
TrueNAS keeps its own persistent `data/game.db`. Pushing code does not merge or replace that database. The TrueNAS app applies seed/schema changes when the container starts and runs `npm run db:init`.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Push Updates
|
||||||
|
|
||||||
|
Use this when pushing code from the Mac to `git.whoagland.com`, then updating the TrueNAS-hosted server.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Git deploys code only.
|
||||||
|
- TrueNAS save data lives in `/mnt/usbssds/apps/iwanttoheal/data/game.db`.
|
||||||
|
- Do not commit, copy, or replace `data/game.db`.
|
||||||
|
- Do not run character reset commands unless you intentionally want a wipe.
|
||||||
|
- Restarting the TrueNAS app runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start`.
|
||||||
|
|
||||||
|
## Step 1: Build Web Locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This validates TypeScript, builds the browser app, and refreshes `src/offline-starter-profile.json`.
|
||||||
|
|
||||||
|
## Step 2: Optional Android APK
|
||||||
|
|
||||||
|
Only run this when building a new APK.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
VERSION="1.0.27"
|
||||||
|
|
||||||
|
CURRENT_CODE=$(grep -E 'versionCode [0-9]+' android/app/build.gradle | awk '{print $2}')
|
||||||
|
NEXT_CODE=$((CURRENT_CODE + 1))
|
||||||
|
|
||||||
|
perl -0pi -e "s/versionCode\s+\d+/versionCode $NEXT_CODE/" android/app/build.gradle
|
||||||
|
perl -0pi -e "s/versionName\s+\"[^\"]+\"/versionName \"$VERSION\"/" android/app/build.gradle
|
||||||
|
|
||||||
|
VITE_API_BASE_URL="https://iwanttoheal.phenomrom.com" npm run android:sync
|
||||||
|
|
||||||
|
cd android
|
||||||
|
./gradlew clean assembleDebug
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
cp android/app/build/outputs/apk/debug/app-debug.apk "IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
ls -lh "IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Commit And Push Code
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
git add .
|
||||||
|
git commit -m "Update game 1.0.27"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Check before committing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: source/doc/build-output changes only. Do not stage `data/game.db`.
|
||||||
|
|
||||||
|
## Step 4: Optional Gitea Release For APK
|
||||||
|
|
||||||
|
Only run this when Step 2 created a new APK.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
export GITEA_URL="https://git.whoagland.com"
|
||||||
|
export GITEA_OWNER="phenom"
|
||||||
|
export GITEA_REPO="i-want-to-heal"
|
||||||
|
export GITEA_TOKEN="ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
||||||
|
|
||||||
|
VERSION="1.0.27"
|
||||||
|
APK="IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
|
||||||
|
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"v$VERSION\",\"target_commitish\":\"main\",\"name\":\"v$VERSION\",\"body\":\"I Want to Heal Android build v$VERSION\",\"draft\":false,\"prerelease\":false}")
|
||||||
|
|
||||||
|
RELEASE_ID=$(python3 -c 'import sys,json; data=json.load(sys.stdin); print(data.get("id") or data)' <<< "$RELEASE_JSON")
|
||||||
|
|
||||||
|
curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$APK" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-F "attachment=@$APK"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Update TrueNAS
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Before restarting, make a DB backup:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
|
||||||
|
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the `iwanttoheal` app in the TrueNAS Apps UI.
|
||||||
|
|
||||||
|
## What Happens On Restart
|
||||||
|
|
||||||
|
The app command runs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci && npm run db:init && npm run build && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- dependency changes apply
|
||||||
|
- schema changes apply
|
||||||
|
- seed/static-content updates apply
|
||||||
|
- browser files rebuild
|
||||||
|
- existing accounts and characters stay in `data/game.db`
|
||||||
|
|
||||||
|
`npm run db:init` should not wipe saves. Character wipes are separate manual reset operations.
|
||||||
|
|
||||||
|
## Resetting TrueNAS Characters
|
||||||
|
|
||||||
|
Only run a reset when intentionally starting everyone over.
|
||||||
|
|
||||||
|
Resetting the Mac database does not reset TrueNAS. TrueNAS has its own persistent DB at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/usbssds/apps/iwanttoheal/data/game.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Back it up first, then run the reset command or reset SQL on TrueNAS.
|
||||||
|
|
||||||
|
## If Something Looks Wrong
|
||||||
|
|
||||||
|
Check the mounted DB path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -lh /mnt/usbssds/apps/iwanttoheal/data/game.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the latest code:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the app API:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://127.0.0.1:4173/api/auth/session
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="1075" y="88" fill="#e5b95f" font-family="monospace" font-size="18">B Back</text>
|
||||||
|
|
||||||
|
<text x="78" y="158" fill="#e5b95f" font-family="monospace" font-size="18">Pick Dungeon</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="178" width="335" height="128" fill="#24262f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<rect x="94" y="194" width="72" height="72" fill="#481e29" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="116" y="241" fill="#ef6574" font-family="monospace" font-size="28" font-weight="700">AH</text>
|
||||||
|
<text x="184" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="184" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 1 • 6 Players</text>
|
||||||
|
<text x="184" y="276" fill="#e5b95f" font-family="monospace" font-size="16">Selected</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="436" y="178" width="335" height="128" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="452" y="194" width="72" height="72" fill="#2a263f" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="474" y="241" fill="#8ca9ff" font-family="monospace" font-size="28" font-weight="700">SC</text>
|
||||||
|
<text x="542" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt</text>
|
||||||
|
<text x="542" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 5 • 6 Players</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="794" y="178" width="335" height="128" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="810" y="194" width="72" height="72" fill="#223226" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="832" y="241" fill="#70d990" font-family="monospace" font-size="28" font-weight="700">GM</text>
|
||||||
|
<text x="900" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Grove Maw</text>
|
||||||
|
<text x="900" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 10 • 6 Players</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="356" fill="#e5b95f" font-family="monospace" font-size="18">Pick Part</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="376" width="282" height="82" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="112" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 1</text>
|
||||||
|
<text x="250" y="426" fill="#8f90a0" font-family="monospace" font-size="16">3 fights</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="382" y="376" width="282" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="416" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 2</text>
|
||||||
|
<text x="554" y="426" fill="#8f90a0" font-family="monospace" font-size="16">Locked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="686" y="376" width="282" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="720" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 3</text>
|
||||||
|
<text x="858" y="426" fill="#8f90a0" font-family="monospace" font-size="16">Locked</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="508" fill="#e5b95f" font-family="monospace" font-size="18">Pick Difficulty</text>
|
||||||
|
<rect x="78" y="528" width="240" height="64" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="116" y="568" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Normal</text>
|
||||||
|
<rect x="342" y="528" width="240" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="380" y="568" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Heroic</text>
|
||||||
|
<rect x="606" y="528" width="240" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="644" y="568" fill="#777988" font-family="monospace" font-size="20" font-weight="700">Mythic L10</text>
|
||||||
|
|
||||||
|
<rect x="914" y="508" width="238" height="86" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="982" y="559" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Start</text>
|
||||||
|
<text x="78" y="638" fill="#aaa9b7" font-family="monospace" font-size="17">Idea A: All choices are button grids. D-pad works everywhere. No native dropdown.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="1014" y="88" fill="#e5b95f" font-family="monospace" font-size="18">LB/RB Change</text>
|
||||||
|
|
||||||
|
<rect x="78" y="150" width="760" height="398" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="112" y="184" width="164" height="164" fill="#481e29" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="151" y="280" fill="#ef6574" font-family="monospace" font-size="48" font-weight="700">AH</text>
|
||||||
|
<text x="306" y="202" fill="#8f90a0" font-family="monospace" font-size="18">CURRENT DUNGEON</text>
|
||||||
|
<text x="306" y="248" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="306" y="286" fill="#aaa9b7" font-family="monospace" font-size="18">Guide a six-player party through burning halls.</text>
|
||||||
|
<text x="306" y="324" fill="#e5b95f" font-family="monospace" font-size="18">Level 1 • 6 Players • 100 XP</text>
|
||||||
|
<rect x="112" y="384" width="690" height="104" fill="#15161c" stroke="#33343d" stroke-width="3"/>
|
||||||
|
<text x="138" y="425" fill="#f2f0dc" font-family="monospace" font-size="19">Ashen Halls</text>
|
||||||
|
<text x="356" y="425" fill="#8f90a0" font-family="monospace" font-size="19">Sunken Crypt</text>
|
||||||
|
<text x="608" y="425" fill="#8f90a0" font-family="monospace" font-size="19">Grove Maw</text>
|
||||||
|
<rect x="128" y="448" width="146" height="8" fill="#e5b95f"/>
|
||||||
|
|
||||||
|
<rect x="874" y="150" width="278" height="398" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="906" y="194" fill="#e5b95f" font-family="monospace" font-size="18">Setup</text>
|
||||||
|
<text x="906" y="232" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Part</text>
|
||||||
|
<rect x="906" y="252" width="68" height="54" fill="#29291f" stroke="#e5b95f" stroke-width="4"/>
|
||||||
|
<text x="932" y="286" fill="#f2f0dc" font-family="monospace" font-size="20">1</text>
|
||||||
|
<rect x="990" y="252" width="68" height="54" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="1016" y="286" fill="#8f90a0" font-family="monospace" font-size="20">2</text>
|
||||||
|
<rect x="1074" y="252" width="68" height="54" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="1100" y="286" fill="#8f90a0" font-family="monospace" font-size="20">3</text>
|
||||||
|
|
||||||
|
<text x="906" y="350" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Difficulty</text>
|
||||||
|
<rect x="906" y="372" width="236" height="52" fill="#29291f" stroke="#e5b95f" stroke-width="4"/>
|
||||||
|
<text x="938" y="405" fill="#f2f0dc" font-family="monospace" font-size="18">Normal</text>
|
||||||
|
<rect x="906" y="436" width="236" height="52" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="938" y="469" fill="#aaa9b7" font-family="monospace" font-size="18">Heroic</text>
|
||||||
|
|
||||||
|
<rect x="78" y="580" width="238" height="70" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="120" y="624" fill="#e5b95f" font-family="monospace" font-size="22">Prev</text>
|
||||||
|
<rect x="342" y="580" width="238" height="70" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="392" y="624" fill="#e5b95f" font-family="monospace" font-size="22">Next</text>
|
||||||
|
<rect x="874" y="580" width="278" height="70" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="963" y="624" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Start</text>
|
||||||
|
<text x="78" y="684" fill="#aaa9b7" font-family="monospace" font-size="17">Idea B: One big focused dungeon. Shoulder buttons or side buttons cycle dungeon.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Mission Board</text>
|
||||||
|
<text x="1046" y="88" fill="#e5b95f" font-family="monospace" font-size="18">A Start</text>
|
||||||
|
|
||||||
|
<rect x="78" y="150" width="500" height="474" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="112" y="194" fill="#e5b95f" font-family="monospace" font-size="18">Available Runs</text>
|
||||||
|
<g>
|
||||||
|
<rect x="112" y="222" width="432" height="82" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="136" y="255" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 1</text>
|
||||||
|
<text x="136" y="283" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • iLvl 1 • 100 XP</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="112" y="318" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="351" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 1</text>
|
||||||
|
<text x="136" y="379" fill="#aaa9b7" font-family="monospace" font-size="16">Heroic • iLvl 5 • 140 XP</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="112" y="414" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="447" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt • Part 1</text>
|
||||||
|
<text x="136" y="475" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • Locked Level 5</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="112" y="510" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="543" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 2</text>
|
||||||
|
<text x="136" y="571" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • Locked until Part 1 clear</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect x="616" y="150" width="536" height="474" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="650" y="184" width="130" height="130" fill="#481e29" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="684" y="262" fill="#ef6574" font-family="monospace" font-size="42" font-weight="700">AH</text>
|
||||||
|
<text x="812" y="194" fill="#8f90a0" font-family="monospace" font-size="18">SELECTED RUN</text>
|
||||||
|
<text x="812" y="238" fill="#f2f0dc" font-family="monospace" font-size="30" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="812" y="278" fill="#e5b95f" font-family="monospace" font-size="20">Part 1 • Normal</text>
|
||||||
|
<text x="650" y="358" fill="#aaa9b7" font-family="monospace" font-size="17">Fastest path for controller play. One list item is the exact run.</text>
|
||||||
|
<text x="650" y="394" fill="#aaa9b7" font-family="monospace" font-size="17">No separate dungeon, phase, or difficulty controls.</text>
|
||||||
|
|
||||||
|
<rect x="650" y="440" width="470" height="64" fill="#15161c" stroke="#33343d" stroke-width="3"/>
|
||||||
|
<text x="680" y="480" fill="#f2f0dc" font-family="monospace" font-size="18">Health 1.00x Damage 1.00x XP 1.0x</text>
|
||||||
|
<rect x="650" y="532" width="220" height="62" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="716" y="570" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Start</text>
|
||||||
|
<rect x="900" y="532" width="220" height="62" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="955" y="570" fill="#e5b95f" font-family="monospace" font-size="22">Loot</text>
|
||||||
|
|
||||||
|
<text x="78" y="684" fill="#aaa9b7" font-family="monospace" font-size="17">Idea C: Flat mission list. Most controller-friendly, least setup flexibility on one screen.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,72 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="958" y="88" fill="#e5b95f" font-family="monospace" font-size="18">B Back • A Select</text>
|
||||||
|
|
||||||
|
<text x="78" y="158" fill="#e5b95f" font-family="monospace" font-size="18">1. Pick Item Level</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="125" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">5</text>
|
||||||
|
<text x="108" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Initiate</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="268" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="310" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">10</text>
|
||||||
|
<text x="300" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Veteran</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="458" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="500" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">15</text>
|
||||||
|
<text x="488" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Champion</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="648" y="178" width="170" height="72" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="690" y="222" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">20</text>
|
||||||
|
<text x="690" y="240" fill="#e5b95f" font-family="monospace" font-size="12">Mythic</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="838" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="880" y="222" fill="#777988" font-family="monospace" font-size="22">25</text>
|
||||||
|
<text x="864" y="240" fill="#777988" font-family="monospace" font-size="12">Level 20</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="304" fill="#e5b95f" font-family="monospace" font-size="18">2. Pick Run</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="324" width="335" height="154" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<rect x="96" y="346" width="64" height="64" fill="#481e29" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="114" y="387" fill="#ef6574" font-family="monospace" font-size="24" font-weight="700">AH</text>
|
||||||
|
<text x="178" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="178" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="96" y="446" fill="#e5b95f" font-family="monospace" font-size="16">Part 1 unlocked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="436" y="324" width="335" height="154" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="454" y="346" width="64" height="64" fill="#2a263f" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="472" y="387" fill="#8ca9ff" font-family="monospace" font-size="24" font-weight="700">SC</text>
|
||||||
|
<text x="536" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt</text>
|
||||||
|
<text x="536" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="454" y="446" fill="#aaa9b7" font-family="monospace" font-size="16">Part 1 unlocked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="794" y="324" width="335" height="154" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="812" y="346" width="64" height="64" fill="#223226" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="830" y="387" fill="#70d990" font-family="monospace" font-size="24" font-weight="700">GM</text>
|
||||||
|
<text x="894" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Grove Maw</text>
|
||||||
|
<text x="894" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="812" y="446" fill="#777988" font-family="monospace" font-size="16">Locked dungeon</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="532" fill="#e5b95f" font-family="monospace" font-size="18">3. Pick Part</text>
|
||||||
|
<rect x="78" y="552" width="250" height="64" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="124" y="592" fill="#f2f0dc" font-family="monospace" font-size="20">Part 1</text>
|
||||||
|
<rect x="352" y="552" width="250" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="398" y="592" fill="#aaa9b7" font-family="monospace" font-size="20">Part 2</text>
|
||||||
|
<rect x="626" y="552" width="250" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="672" y="592" fill="#aaa9b7" font-family="monospace" font-size="20">Part 3</text>
|
||||||
|
<rect x="926" y="552" width="226" height="64" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="991" y="592" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Start</text>
|
||||||
|
|
||||||
|
<text x="78" y="662" fill="#aaa9b7" font-family="monospace" font-size="17">Idea D: submenu flow. Pick item level first, then only compatible dungeon cards appear.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,67 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="540" viewBox="0 0 620 540" role="img" aria-label="Ayn Thor secondary display spell effect quick swap mockup">
|
||||||
|
<rect width="620" height="540" fill="#111219"/>
|
||||||
|
<rect x="16" y="16" width="588" height="48" fill="#20222d" stroke="#0a0b0e" stroke-width="3"/>
|
||||||
|
<text x="32" y="36" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">SPELL EFFECTS</text>
|
||||||
|
<text x="32" y="56" fill="#f5e6b2" font-family="monospace" font-size="18" font-weight="900">Quick Swap</text>
|
||||||
|
<text x="460" y="47" fill="#83d99b" font-family="monospace" font-size="13" font-weight="900">4/4 ACTIVE</text>
|
||||||
|
|
||||||
|
<g transform="translate(16 82)">
|
||||||
|
<rect width="588" height="118" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">ACTIVE SLOTS</text>
|
||||||
|
<g transform="translate(14 46)">
|
||||||
|
<rect width="130" height="54" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||||
|
<text x="10" y="20" fill="#e5b95f" font-family="monospace" font-size="11" font-weight="900">LV 5</text>
|
||||||
|
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Mend Renew</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(154 46)">
|
||||||
|
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="10" y="20" fill="#6da7df" font-family="monospace" font-size="11" font-weight="900">LV 10</text>
|
||||||
|
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Rad Shield</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(294 46)">
|
||||||
|
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="10" y="20" fill="#4fb978" font-family="monospace" font-size="11" font-weight="900">LV 15</text>
|
||||||
|
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Shield DR</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(434 46)">
|
||||||
|
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="10" y="20" fill="#b16dde" font-family="monospace" font-size="11" font-weight="900">LV 20</text>
|
||||||
|
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Mend CD</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(16 218)">
|
||||||
|
<rect width="278" height="286" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">POOL</text>
|
||||||
|
<g transform="translate(14 46)">
|
||||||
|
<rect width="250" height="42" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||||
|
<text x="12" y="26" fill="#f5e6b2" font-family="monospace" font-size="13" font-weight="900">Mend applies Renew</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(14 98)">
|
||||||
|
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Shield applies Renew</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(14 150)">
|
||||||
|
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Mend adds Shield</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(14 202)">
|
||||||
|
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Radiance Renew</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(310 218)">
|
||||||
|
<rect width="294" height="286" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">DETAIL</text>
|
||||||
|
<text x="16" y="58" fill="#f5e6b2" font-family="monospace" font-size="18" font-weight="900">Mend applies Renew</text>
|
||||||
|
<text x="16" y="92" fill="#d7dbe0" font-family="monospace" font-size="13">Mend also applies Renew to</text>
|
||||||
|
<text x="16" y="112" fill="#d7dbe0" font-family="monospace" font-size="13">the target.</text>
|
||||||
|
<text x="16" y="150" fill="#83d99b" font-family="monospace" font-size="12" font-weight="900">Rule: same HoT refreshes.</text>
|
||||||
|
<text x="16" y="172" fill="#83d99b" font-family="monospace" font-size="12" font-weight="900">Different HoTs coexist.</text>
|
||||||
|
<rect x="16" y="216" width="120" height="44" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||||
|
<text x="50" y="244" fill="#21180a" font-family="monospace" font-size="14" font-weight="900">Equip</text>
|
||||||
|
<rect x="154" y="216" width="120" height="44" rx="3" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="184" y="244" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Clear</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,90 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Ayn Thor main display talent effect planner mockup">
|
||||||
|
<rect width="960" height="540" fill="#111219"/>
|
||||||
|
<rect x="20" y="18" width="920" height="50" fill="#20222d" stroke="#0a0b0e" stroke-width="3"/>
|
||||||
|
<text x="38" y="39" fill="#8aa0b7" font-family="monospace" font-size="12" font-weight="700">CHARACTER WORKSHOP</text>
|
||||||
|
<text x="38" y="59" fill="#f5e6b2" font-family="monospace" font-size="20" font-weight="900">Spell Effects</text>
|
||||||
|
<rect x="764" y="28" width="154" height="30" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||||
|
<text x="786" y="49" fill="#21180a" font-family="monospace" font-size="13" font-weight="900">Save Loadout</text>
|
||||||
|
|
||||||
|
<g transform="translate(20 88)">
|
||||||
|
<rect width="294" height="420" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="18" y="28" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">UNLOCKED SLOTS</text>
|
||||||
|
<text x="18" y="53" fill="#f7f2d7" font-family="monospace" font-size="19" font-weight="900">4 active effects</text>
|
||||||
|
|
||||||
|
<g transform="translate(16 76)">
|
||||||
|
<rect width="262" height="58" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||||
|
<circle cx="29" cy="29" r="15" fill="#e5b95f"/>
|
||||||
|
<text x="22" y="34" fill="#21180a" font-family="monospace" font-size="13" font-weight="900">5</text>
|
||||||
|
<text x="56" y="25" fill="#f5e6b2" font-family="monospace" font-size="15" font-weight="900">Mend applies Renew</text>
|
||||||
|
<text x="56" y="45" fill="#83d99b" font-family="monospace" font-size="11">Selected</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(16 148)">
|
||||||
|
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<circle cx="29" cy="29" r="15" fill="#6da7df"/>
|
||||||
|
<text x="18" y="34" fill="#08111c" font-family="monospace" font-size="12" font-weight="900">10</text>
|
||||||
|
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Radiance adds shield</text>
|
||||||
|
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">30 percent strength</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(16 220)">
|
||||||
|
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<circle cx="29" cy="29" r="15" fill="#4fb978"/>
|
||||||
|
<text x="18" y="34" fill="#071408" font-family="monospace" font-size="12" font-weight="900">15</text>
|
||||||
|
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Shielded takes less</text>
|
||||||
|
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">20 percent damage cut</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(16 292)">
|
||||||
|
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<circle cx="29" cy="29" r="15" fill="#b16dde"/>
|
||||||
|
<text x="18" y="34" fill="#15071c" font-family="monospace" font-size="12" font-weight="900">20</text>
|
||||||
|
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Mend lowers Radiance</text>
|
||||||
|
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">-2 sec cooldown</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(334 88)">
|
||||||
|
<rect width="606" height="286" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="18" y="28" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">EFFECT POOL</text>
|
||||||
|
<text x="18" y="53" fill="#f7f2d7" font-family="monospace" font-size="19" font-weight="900">Pick effects, swap anytime</text>
|
||||||
|
|
||||||
|
<g transform="translate(18 76)">
|
||||||
|
<rect width="276" height="58" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||||
|
<text x="14" y="24" fill="#f5e6b2" font-family="monospace" font-size="14" font-weight="900">Mend applies Renew</text>
|
||||||
|
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">Direct heal also applies Renew.</text>
|
||||||
|
<text x="226" y="24" fill="#83d99b" font-family="monospace" font-size="10" font-weight="900">ON</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(312 76)">
|
||||||
|
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Shield applies Renew</text>
|
||||||
|
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">Sun Ward adds a Renew effect.</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(18 148)">
|
||||||
|
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Mend adds Shield</text>
|
||||||
|
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">50 percent shield strength.</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(312 148)">
|
||||||
|
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Radiance adds Shield</text>
|
||||||
|
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">30 percent to affected allies.</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(18 220)">
|
||||||
|
<rect width="276" height="46" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="14" y="20" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Radiance applies Renew</text>
|
||||||
|
<text x="14" y="38" fill="#b7c3d0" font-family="monospace" font-size="11">50 percent duration.</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(312 220)">
|
||||||
|
<rect width="276" height="46" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="14" y="20" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Shielded gets +healing</text>
|
||||||
|
<text x="14" y="38" fill="#b7c3d0" font-family="monospace" font-size="11">20 percent more healing.</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(334 392)">
|
||||||
|
<rect width="606" height="116" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="18" y="26" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">SELECTED EFFECT</text>
|
||||||
|
<text x="18" y="52" fill="#f5e6b2" font-family="monospace" font-size="20" font-weight="900">Mend applies Renew</text>
|
||||||
|
<text x="18" y="78" fill="#d7dbe0" font-family="monospace" font-size="13">Casting Mend also applies Renew to the same target. Renew refreshes itself; different HoTs coexist.</text>
|
||||||
|
<rect x="470" y="32" width="112" height="42" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||||
|
<text x="497" y="58" fill="#21180a" font-family="monospace" font-size="14" font-weight="900">Equip</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.6 KiB |
+5
-2
@@ -6,16 +6,19 @@
|
|||||||
"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:sync:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:sync",
|
||||||
"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",
|
||||||
|
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
|
||||||
"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}`)
|
||||||
|
})
|
||||||
+800
-77
File diff suppressed because it is too large
Load Diff
+3406
-38
File diff suppressed because it is too large
Load Diff
+325
-106
@@ -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,8 @@ 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
|
||||||
|
const ACTIVITY_PAGE_SIZE = 6
|
||||||
|
|
||||||
function activityInitials(name: string) {
|
function activityInitials(name: string) {
|
||||||
return name
|
return name
|
||||||
@@ -75,19 +82,23 @@ function App() {
|
|||||||
return Number.isFinite(saved) && saved > 0 ? saved : 1
|
return Number.isFinite(saved) && saved > 0 ? saved : 1
|
||||||
})
|
})
|
||||||
const [selectedDungeonId, setSelectedDungeonId] = useState(1)
|
const [selectedDungeonId, setSelectedDungeonId] = useState(1)
|
||||||
const [selectedRaidId, setSelectedRaidId] = useState(2)
|
const [selectedRaidId, setSelectedRaidId] = useState(20)
|
||||||
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
|
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
|
||||||
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
|
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
|
||||||
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
|
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
|
||||||
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
|
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
|
||||||
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
|
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
|
||||||
const [selectedPart, setSelectedPart] = useState(1)
|
const [selectedPart, setSelectedPart] = useState(1)
|
||||||
|
const [selectedMarathonMode, setSelectedMarathonMode] = useState(false)
|
||||||
|
const [activityPage, setActivityPage] = useState(0)
|
||||||
const [combatContentId, setCombatContentId] = useState(1)
|
const [combatContentId, setCombatContentId] = useState(1)
|
||||||
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
|
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
|
||||||
const [showLoot, setShowLoot] = useState(false)
|
const [showLoot, setShowLoot] = useState(false)
|
||||||
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 +116,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(() => {
|
||||||
@@ -112,6 +134,13 @@ function App() {
|
|||||||
})
|
})
|
||||||
}, [screen])
|
}, [screen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authChecked || !account || !profile || screen === 'combat') return
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
focusFirstControl()
|
||||||
|
})
|
||||||
|
}, [account, authChecked, profile, screen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
|
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
|
||||||
}, [selectedDifficultyId])
|
}, [selectedDifficultyId])
|
||||||
@@ -129,6 +158,9 @@ function App() {
|
|||||||
setScreen('menu')
|
setScreen('menu')
|
||||||
setError('')
|
setError('')
|
||||||
setServerMessage('')
|
setServerMessage('')
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
focusFirstControl()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
@@ -138,11 +170,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">
|
||||||
@@ -190,6 +238,8 @@ function App() {
|
|||||||
<CombatScreen
|
<CombatScreen
|
||||||
difficulty={difficulty}
|
difficulty={difficulty}
|
||||||
dungeon={dungeon}
|
dungeon={dungeon}
|
||||||
|
hardMode={false}
|
||||||
|
marathonMode={selectedMarathonMode && combatContentId > 0}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
||||||
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
||||||
@@ -237,22 +287,59 @@ function App() {
|
|||||||
?? dungeonOptions[0]!
|
?? dungeonOptions[0]!
|
||||||
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
||||||
?? raidOptions[0]
|
?? raidOptions[0]
|
||||||
const activity = screen === 'raids' && raid ? raid : dungeon
|
|
||||||
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
||||||
|
const startPveRoguelike = () => {
|
||||||
|
const baseDungeon = dungeonOptions[0]
|
||||||
|
const baseRaid = raidOptions[0]
|
||||||
|
if (roguelikeKind === 'raid') {
|
||||||
|
setCombatContentId(-2)
|
||||||
|
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
|
||||||
|
} else {
|
||||||
|
setCombatContentId(-1)
|
||||||
|
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
|
||||||
|
}
|
||||||
|
setSelectedPart(1)
|
||||||
|
setSelectedMarathonMode(false)
|
||||||
|
setScreen('combat')
|
||||||
|
}
|
||||||
|
const tierOptions = activityOptions
|
||||||
|
.flatMap((option) => option.difficulties)
|
||||||
|
.filter((difficulty, index, all) => (
|
||||||
|
all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index
|
||||||
|
))
|
||||||
|
.sort((a, b) => a.droppedItemLevel - b.droppedItemLevel)
|
||||||
|
const savedDifficulty = profile.dungeons
|
||||||
|
.flatMap((option) => option.difficulties)
|
||||||
|
.find((candidate) => candidate.id === selectedDifficultyId)
|
||||||
|
const selectedTier = tierOptions.find((candidate) => (
|
||||||
|
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
|
||||||
|
&& profile.character.level >= candidate.unlockLevel
|
||||||
|
))
|
||||||
|
?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel)
|
||||||
|
?? tierOptions[0]
|
||||||
|
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
|
||||||
|
const tierActivityOptions = activityOptions.filter((option) =>
|
||||||
|
option.difficulties.some((difficulty) => difficulty.droppedItemLevel === selectedTierItemLevel),
|
||||||
|
)
|
||||||
|
const activityPageCount = Math.max(1, Math.ceil(tierActivityOptions.length / ACTIVITY_PAGE_SIZE))
|
||||||
|
const currentActivityPage = Math.min(activityPage, activityPageCount - 1)
|
||||||
|
const pagedActivityOptions = tierActivityOptions.slice(
|
||||||
|
currentActivityPage * ACTIVITY_PAGE_SIZE,
|
||||||
|
currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE,
|
||||||
|
)
|
||||||
|
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
|
||||||
|
const activity = tierActivityOptions.find((candidate) => candidate.id === selectedActivityId)
|
||||||
|
?? tierActivityOptions[0]
|
||||||
|
?? (screen === 'raids' && raid ? raid : dungeon)
|
||||||
const selectedDifficulty = activity.difficulties.find(
|
const selectedDifficulty = activity.difficulties.find(
|
||||||
(candidate) => candidate.id === selectedDifficultyId,
|
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
|
||||||
) ?? activity.difficulties[0]
|
) ?? activity.difficulties[0]
|
||||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||||
const completedSections = activity.contentType === 'raid'
|
|
||||||
? profile.completedRaidPhases
|
|
||||||
: profile.completedDungeonParts
|
|
||||||
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
|
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
|
||||||
const parts = [
|
const activityCompletionCount = activity.completionCount ?? 0
|
||||||
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true },
|
const marathonUnlocked = activityCompletionCount >= 10
|
||||||
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
|
const cloudSync = getCloudSyncStatus()
|
||||||
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
|
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
||||||
]
|
|
||||||
const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5
|
|
||||||
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'
|
||||||
@@ -260,7 +347,7 @@ function App() {
|
|||||||
: a.sequence - b.sequence)
|
: a.sequence - b.sequence)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="game-shell">
|
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}>
|
||||||
<header className="topbar app-header">
|
<header className="topbar app-header">
|
||||||
<button
|
<button
|
||||||
className="brand-button"
|
className="brand-button"
|
||||||
@@ -285,6 +372,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"
|
||||||
@@ -335,6 +444,28 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
{roguelikeVariant === 'pve' && (
|
{roguelikeVariant === 'pve' && (
|
||||||
<>
|
<>
|
||||||
|
<div className="roguelike-option-panel">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Run Type</p>
|
||||||
|
<h2>PvE Roguelike</h2>
|
||||||
|
</div>
|
||||||
|
<div className="roguelike-timing-row">
|
||||||
|
<button
|
||||||
|
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
|
||||||
|
onClick={() => setRoguelikeKind('dungeon')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Dungeon
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
|
||||||
|
onClick={() => setRoguelikeKind('raid')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Raid
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="roguelike-option-panel">
|
<div className="roguelike-option-panel">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Upgrade Timing</p>
|
<p className="eyebrow">Upgrade Timing</p>
|
||||||
@@ -379,38 +510,22 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="roguelike-mode-grid">
|
<div className="menu-card pvp-queue-panel">
|
||||||
|
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
|
||||||
|
<small>
|
||||||
|
{roguelikeKind === 'raid'
|
||||||
|
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
|
||||||
|
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="menu-card"
|
className="text-button"
|
||||||
onClick={() => {
|
onClick={startPveRoguelike}
|
||||||
const baseDungeon = dungeonOptions[0]
|
|
||||||
setRoguelikeKind('dungeon')
|
|
||||||
setCombatContentId(-1)
|
|
||||||
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
|
|
||||||
setSelectedPart(1)
|
|
||||||
setScreen('combat')
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>D</span>
|
Start Run
|
||||||
<strong>Dungeon Roguelike</strong>
|
|
||||||
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="menu-card"
|
|
||||||
onClick={() => {
|
|
||||||
const baseRaid = raidOptions[0]
|
|
||||||
setRoguelikeKind('raid')
|
|
||||||
setCombatContentId(-2)
|
|
||||||
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
|
|
||||||
setSelectedPart(1)
|
|
||||||
setScreen('combat')
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span>R</span>
|
|
||||||
<strong>Raid Roguelike</strong>
|
|
||||||
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -457,6 +572,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,100 +604,194 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(screen === 'dungeons' || screen === 'raids') && (
|
{(screen === 'dungeons' || screen === 'raids') && (
|
||||||
<section className="content-screen">
|
<section className="content-screen dungeon-run-screen">
|
||||||
<ScreenHeading
|
<ScreenHeading
|
||||||
eyebrow="Adventure"
|
eyebrow="Adventure"
|
||||||
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
|
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
/>
|
/>
|
||||||
<article className="dungeon-card">
|
<div className="dungeon-run-board">
|
||||||
|
<div className="dungeon-run-main">
|
||||||
|
<article className="run-summary-card dungeon-focus-card">
|
||||||
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
|
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||||
{activityInitials(activity.name)}
|
{activityInitials(activity.name)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="run-summary-copy">
|
||||||
<p className="eyebrow">{activity.locationName}</p>
|
<p className="eyebrow">Selected Run</p>
|
||||||
<h2>{activity.name}</h2>
|
<h2>{activity.name}</h2>
|
||||||
<p>{activity.description}</p>
|
<p>{activity.description}</p>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span>Level {activity.recommendedLevel}</span>
|
<span>Level {activity.recommendedLevel}</span>
|
||||||
<span>{activity.partySize} Players</span>
|
<span>{activity.partySize} Players</span>
|
||||||
<span>{selectedDifficulty.name}</span>
|
<span>{selectedDifficulty.name}</span>
|
||||||
<span>Component Level {selectedDifficulty.droppedItemLevel}</span>
|
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
|
||||||
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
|
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activityOptions.length > 1 && (
|
</article>
|
||||||
<label className="activity-select">
|
|
||||||
<span>{activity.contentType === 'raid' ? 'Raid' : 'Dungeon'}</span>
|
<section className="run-setup-panel dungeon-choice-panel">
|
||||||
<select
|
<div className="run-setup-heading">
|
||||||
value={activity.id}
|
<div>
|
||||||
onChange={(event) => {
|
<p className="eyebrow">Pick Run</p>
|
||||||
const nextActivityId = Number(event.target.value)
|
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
|
||||||
const nextActivity = activityOptions.find((candidate) => candidate.id === nextActivityId)
|
</div>
|
||||||
if (screen === 'raids') setSelectedRaidId(nextActivityId)
|
{activityPageCount > 1 ? (
|
||||||
else setSelectedDungeonId(nextActivityId)
|
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
|
||||||
if (nextActivity?.difficulties[0]) {
|
<button
|
||||||
setSelectedDifficultyId(nextActivity.difficulties[0].id)
|
disabled={currentActivityPage === 0}
|
||||||
|
onClick={() => setActivityPage((page) => Math.max(0, page - 1))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span>{currentActivityPage + 1}/{activityPageCount}</span>
|
||||||
|
<button
|
||||||
|
disabled={currentActivityPage >= activityPageCount - 1}
|
||||||
|
onClick={() => setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="activity-card-grid dungeon-choice-grid">
|
||||||
|
{pagedActivityOptions.map((candidate) => {
|
||||||
|
const difficulty = candidate.difficulties.find(
|
||||||
|
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||||
|
) ?? candidate.difficulties[0]
|
||||||
|
const locked = profile.character.level < difficulty.unlockLevel
|
||||||
|
const selected = candidate.id === activity.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||||
|
disabled={locked}
|
||||||
|
key={candidate.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||||
|
else setSelectedDungeonId(candidate.id)
|
||||||
|
setSelectedDifficultyId(difficulty.id)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||||
|
{activityInitials(candidate.name)}
|
||||||
|
</span>
|
||||||
|
<strong>{candidate.name}</strong>
|
||||||
|
<small>{candidate.locationName}</small>
|
||||||
|
<i>
|
||||||
|
Level {candidate.recommendedLevel} | {candidate.partySize} Players
|
||||||
|
</i>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="dungeon-setup-rail">
|
||||||
|
<section className="run-setup-panel tier-setup-panel">
|
||||||
|
<div className="run-setup-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Item Level</p>
|
||||||
|
<h2>Tier</h2>
|
||||||
|
</div>
|
||||||
|
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
|
||||||
|
</div>
|
||||||
|
<div className="tier-grid">
|
||||||
|
{tierOptions.map((difficulty) => {
|
||||||
|
const locked = profile.character.level < difficulty.unlockLevel
|
||||||
|
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||||
|
disabled={locked}
|
||||||
|
key={difficulty.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActivityPage(0)
|
||||||
|
const nextActivity = activity.difficulties.some(
|
||||||
|
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
|
)
|
||||||
|
? activity
|
||||||
|
: activityOptions.find((option) =>
|
||||||
|
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
|
||||||
|
)
|
||||||
|
if (nextActivity) {
|
||||||
|
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
|
||||||
|
else setSelectedDungeonId(nextActivity.id)
|
||||||
|
const nextDifficulty = nextActivity.difficulties.find(
|
||||||
|
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
|
)
|
||||||
|
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{activityOptions.map((candidate) => (
|
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
||||||
<option key={candidate.id} value={candidate.id}>{candidate.name}</option>
|
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
|
||||||
))}
|
</button>
|
||||||
</select>
|
)
|
||||||
</label>
|
})}
|
||||||
)}
|
</div>
|
||||||
<div className="part-buttons">
|
</section>
|
||||||
{parts.map((p) => (
|
|
||||||
|
<section className="run-setup-panel part-setup-panel">
|
||||||
|
<div className="run-setup-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Start</p>
|
||||||
|
<h2>Run</h2>
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
{difficultyLocked
|
||||||
|
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
|
||||||
|
: marathonUnlocked
|
||||||
|
? 'Marathon keeps health and mana between boss kills.'
|
||||||
|
: `Marathon unlocks after 10 clears (${activityCompletionCount}/10).`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="part-picker">
|
||||||
<button
|
<button
|
||||||
key={p.part}
|
className="primary-button selected-part"
|
||||||
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
disabled={difficultyLocked}
|
||||||
disabled={difficultyLocked || !p.unlocked}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedPart(p.part)
|
setSelectedPart(1)
|
||||||
|
setSelectedMarathonMode(false)
|
||||||
setCombatContentId(activity.id)
|
setCombatContentId(activity.id)
|
||||||
|
setSelectedDifficultyId(selectedDifficulty.id)
|
||||||
setScreen('combat')
|
setScreen('combat')
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{p.name}
|
Start Hunt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${!marathonUnlocked ? 'locked' : ''}`}
|
||||||
|
disabled={difficultyLocked || !marathonUnlocked}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPart(1)
|
||||||
|
setSelectedMarathonMode(true)
|
||||||
|
setCombatContentId(activity.id)
|
||||||
|
setSelectedDifficultyId(selectedDifficulty.id)
|
||||||
|
setScreen('combat')
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Marathon
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</section>
|
||||||
|
|
||||||
<div className="difficulty-section compact-difficulty-section">
|
<div className="difficulty-section compact-difficulty-section">
|
||||||
<div className="difficulty-select-row">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Challenge Tier</p>
|
|
||||||
<h2>Difficulty</h2>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>Select</span>
|
|
||||||
<select
|
|
||||||
value={selectedDifficulty.id}
|
|
||||||
onChange={(event) => setSelectedDifficultyId(Number(event.target.value))}
|
|
||||||
>
|
|
||||||
{activity.difficulties.map((difficulty, index) => (
|
|
||||||
<option
|
|
||||||
disabled={profile.character.level < difficulty.unlockLevel}
|
|
||||||
key={difficulty.id}
|
|
||||||
value={difficulty.id}
|
|
||||||
>
|
|
||||||
{index + 1}. {difficulty.name}
|
|
||||||
{profile.character.level < difficulty.unlockLevel
|
|
||||||
? ` - Level ${difficulty.unlockLevel}`
|
|
||||||
: ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
|
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{selectedDifficulty.name}</strong>
|
<strong>{selectedDifficulty.name}</strong>
|
||||||
@@ -591,10 +801,11 @@ function App() {
|
|||||||
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
|
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
|
||||||
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
|
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
|
||||||
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
|
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
|
||||||
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
|
<div><dt>Loot</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="loot-preview-section">
|
<div className="loot-preview-section">
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -621,7 +832,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 +852,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 +874,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,6 +894,8 @@ function App() {
|
|||||||
<p className="section-note">
|
<p className="section-note">
|
||||||
{gameMode === 'offline'
|
{gameMode === 'offline'
|
||||||
? 'Offline runs are not submitted'
|
? 'Offline runs are not submitted'
|
||||||
|
: canShowCloudSync
|
||||||
|
? 'Manual save sync updates your cloud profile.'
|
||||||
: 'Lowest resource spent ranks first'}
|
: 'Lowest resource spent ranks first'}
|
||||||
</p>
|
</p>
|
||||||
<div className="leaderboard-tabs">
|
<div className="leaderboard-tabs">
|
||||||
@@ -730,6 +944,8 @@ 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.'
|
||||||
|
: canShowCloudSync
|
||||||
|
? 'No leaderboard entries yet.'
|
||||||
: 'Complete this difficulty to claim the first ranking.'}
|
: 'Complete this difficulty to claim the first ranking.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -737,6 +953,9 @@ function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</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 && (
|
||||||
|
|||||||
+619
-191
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import {
|
|||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
type GameClass,
|
type GameClass,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
import { EquipmentScreen } from './EquipmentScreen'
|
import { EquipmentScreen } from './EquipmentScreen'
|
||||||
import { TalentScreen } from './TalentScreen'
|
import { TalentScreen } from './TalentScreen'
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||||
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
|
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [classId, setClassId] = useState(profile.character.classId)
|
const [classId, setClassId] = useState(profile.character.classId)
|
||||||
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
||||||
const [selectedSlot, setSelectedSlot] = useState(0)
|
const [selectedSlot, setSelectedSlot] = useState(0)
|
||||||
@@ -38,7 +40,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
function chooseClass(nextClass: GameClass) {
|
function chooseClass(nextClass: GameClass) {
|
||||||
const starterAbilities = nextClass.spells
|
const starterAbilities = nextClass.spells
|
||||||
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
||||||
.slice(0, 5)
|
.slice(0, 6)
|
||||||
.map((ability) => ability.id)
|
.map((ability) => ability.id)
|
||||||
setClassId(nextClass.id)
|
setClassId(nextClass.id)
|
||||||
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
||||||
@@ -63,6 +65,29 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||||
|
if (activeTab !== 'class') return null
|
||||||
|
return {
|
||||||
|
mode: 'class',
|
||||||
|
title: 'Ability Library',
|
||||||
|
subtitle: gameClass.name,
|
||||||
|
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
|
||||||
|
items: gameClass.spells.map((ability) => {
|
||||||
|
const locked = ability.unlockLevel > profile.character.level
|
||||||
|
const equipped = slots.includes(ability.id)
|
||||||
|
return {
|
||||||
|
glyph: locked ? 'L' : ability.glyph,
|
||||||
|
title: ability.name,
|
||||||
|
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
|
||||||
|
detail: ability.description,
|
||||||
|
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
|
||||||
|
|
||||||
|
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
|
||||||
|
|
||||||
async function persistChanges() {
|
async function persistChanges() {
|
||||||
saveScroll()
|
saveScroll()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -80,7 +105,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-screen customize-screen">
|
<section className="content-screen customize-screen">
|
||||||
<div className="screen-heading">
|
<div className="screen-heading customize-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Character Workshop</p>
|
<p className="eyebrow">Character Workshop</p>
|
||||||
<h1>Customize Character</h1>
|
<h1>Customize Character</h1>
|
||||||
@@ -89,8 +114,10 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
||||||
|
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
|
||||||
{([
|
{([
|
||||||
{ key: 'equipment', label: 'Equipment' },
|
{ key: 'equipment', label: 'Equipment' },
|
||||||
|
{ key: 'crafting', label: 'Crafting' },
|
||||||
{ key: 'talents', label: 'Talents' },
|
{ key: 'talents', label: 'Talents' },
|
||||||
{ key: 'class', label: 'Class' },
|
{ key: 'class', label: 'Class' },
|
||||||
] as const).map((tab) => (
|
] as const).map((tab) => (
|
||||||
@@ -110,6 +137,18 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
{activeTab === 'equipment' && (
|
{activeTab === 'equipment' && (
|
||||||
<EquipmentScreen
|
<EquipmentScreen
|
||||||
embedded
|
embedded
|
||||||
|
mode="equipment"
|
||||||
|
showModeTabs={false}
|
||||||
|
profile={profile}
|
||||||
|
onUpdated={onSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'crafting' && (
|
||||||
|
<EquipmentScreen
|
||||||
|
embedded
|
||||||
|
mode="crafting"
|
||||||
|
showModeTabs={false}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
onUpdated={onSaved}
|
onUpdated={onSaved}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
craftItem,
|
craftItem,
|
||||||
equipItem,
|
equipItem,
|
||||||
loadProfile,
|
loadProfile,
|
||||||
|
upgradeItem,
|
||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
type EquipmentSlot,
|
type EquipmentSlot,
|
||||||
type Item,
|
type Item,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
|
|
||||||
const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
||||||
weapon: 'Weapon',
|
weapon: 'Weapon',
|
||||||
@@ -22,14 +24,30 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
|||||||
component: 'Component',
|
component: 'Component',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EQUIPMENT_LIST_PAGE_SIZE = 3
|
||||||
|
const CRAFTING_LIST_PAGE_SIZE = 3
|
||||||
|
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
|
||||||
|
.filter((slot) => slot !== 'component')
|
||||||
|
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
onUpdated: (profile: CharacterProfile) => void
|
onUpdated: (profile: CharacterProfile) => void
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
|
mode?: 'equipment' | 'crafting'
|
||||||
|
showModeTabs?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
export function EquipmentScreen({
|
||||||
|
profile,
|
||||||
|
onBack,
|
||||||
|
onUpdated,
|
||||||
|
embedded = false,
|
||||||
|
mode,
|
||||||
|
showModeTabs = true,
|
||||||
|
}: Props) {
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const totalItemCount = profile.inventory.reduce(
|
const totalItemCount = profile.inventory.reduce(
|
||||||
(total, item) => total + item.quantity,
|
(total, item) => total + item.quantity,
|
||||||
0,
|
0,
|
||||||
@@ -43,17 +61,38 @@ 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'>(mode ?? '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)
|
||||||
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
|
const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
|
||||||
?? profile.craftingRecipes[0]
|
DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
||||||
|
)
|
||||||
|
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
|
||||||
|
?? craftableRecipes[0]
|
||||||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
||||||
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 selectedRecipeRequiresUpgrade = selectedRecipe
|
||||||
|
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
|
||||||
|
: false
|
||||||
|
const selectedItemRecipe = selectedItem
|
||||||
|
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||||
|
: undefined
|
||||||
|
const upgradeRecipe = selectedItem && selectedItemRecipe
|
||||||
|
? profile.craftingRecipes
|
||||||
|
.filter((recipe) =>
|
||||||
|
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
|
||||||
|
&& recipe.item.slot === selectedItem.slot
|
||||||
|
&& recipe.item.itemLevel > selectedItem.itemLevel,
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
|
||||||
|
: undefined
|
||||||
const equippedBySlot = useMemo(
|
const equippedBySlot = useMemo(
|
||||||
() => new Map(
|
() => new Map(
|
||||||
profile.inventory
|
profile.inventory
|
||||||
@@ -75,16 +114,26 @@ 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)
|
||||||
const availableLevels = useMemo(
|
const availableLevels = useMemo(
|
||||||
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
() => [...new Set(profile.craftingRecipes
|
||||||
|
.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
||||||
|
.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
||||||
[profile.craftingRecipes],
|
[profile.craftingRecipes],
|
||||||
)
|
)
|
||||||
const filteredRecipes = useMemo(
|
const filteredRecipes = useMemo(
|
||||||
() => {
|
() => {
|
||||||
let result = [...profile.craftingRecipes]
|
let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
||||||
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
|
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
|
||||||
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
|
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
|
||||||
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
|
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
|
||||||
@@ -92,17 +141,60 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
},
|
},
|
||||||
[profile.craftingRecipes, slotFilter, levelFilter],
|
[profile.craftingRecipes, slotFilter, levelFilter],
|
||||||
)
|
)
|
||||||
|
const readyRecipeCount = filteredRecipes.filter((recipe) => recipe.canCraft).length
|
||||||
|
const slotRecipeCounts = useMemo(
|
||||||
|
() => new Map(
|
||||||
|
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
|
||||||
|
slot,
|
||||||
|
profile.craftingRecipes.filter((recipe) =>
|
||||||
|
recipe.item.slot === slot
|
||||||
|
&& DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
||||||
|
).length,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
[profile.craftingRecipes],
|
||||||
|
)
|
||||||
|
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(() => {
|
||||||
|
if (filteredRecipes.length === 0) {
|
||||||
|
setSelectedRecipeId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) {
|
||||||
|
setSelectedRecipeId(filteredRecipes[0].id)
|
||||||
|
}
|
||||||
|
}, [filteredRecipes, selectedRecipeId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (equipmentTab === 'crafting') {
|
if (equipmentTab === 'crafting') {
|
||||||
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [equipmentTab])
|
}, [equipmentTab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode) setEquipmentTab(mode)
|
||||||
|
}, [mode])
|
||||||
|
|
||||||
function saveScroll() {
|
function saveScroll() {
|
||||||
scrollRef.current = window.scrollY
|
scrollRef.current = window.scrollY
|
||||||
}
|
}
|
||||||
@@ -160,6 +252,160 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEquipmentActions() {
|
||||||
|
if (!selectedItem) {
|
||||||
|
return <p>Select an item to inspect it.</p>
|
||||||
|
}
|
||||||
|
if (selectedItem.slot === 'component') {
|
||||||
|
return <p className="component-note">Used in crafting.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
|
||||||
|
onClick={equipSelected}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
||||||
|
</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) && (
|
||||||
|
<button
|
||||||
|
className="breakdown-button"
|
||||||
|
disabled={equipping || breakingDown || upgrading}
|
||||||
|
onClick={breakdownSelected}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{breakingDown
|
||||||
|
? 'Breaking Down...'
|
||||||
|
: selectedItem.quantity > 1
|
||||||
|
? 'Break Down Duplicate'
|
||||||
|
: 'Break Down'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workshopState = useMemo<DualScreenWorkshopState>(() => {
|
||||||
|
if (equipmentTab === 'crafting') {
|
||||||
|
if (!selectedRecipe) {
|
||||||
|
return {
|
||||||
|
mode: 'crafting',
|
||||||
|
title: 'Craft Output',
|
||||||
|
subtitle: 'No recipe selected',
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: 'crafting',
|
||||||
|
title: selectedRecipe.item.name,
|
||||||
|
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
|
||||||
|
summary: selectedRecipe.item.description,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
glyph: selectedRecipe.item.glyph,
|
||||||
|
title: 'Craft Output',
|
||||||
|
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
|
||||||
|
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
|
||||||
|
},
|
||||||
|
...selectedRecipe.components.map((component) => ({
|
||||||
|
glyph: component.item.glyph,
|
||||||
|
title: component.item.name,
|
||||||
|
meta: `Item Level ${component.item.itemLevel}`,
|
||||||
|
status: `${component.owned}/${component.quantity}`,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!selectedItem) {
|
||||||
|
return {
|
||||||
|
mode: 'equipment',
|
||||||
|
title: 'Equipment Detail',
|
||||||
|
subtitle: 'No item selected',
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: 'equipment',
|
||||||
|
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
|
||||||
|
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
|
||||||
|
summary: selectedItem.description,
|
||||||
|
items: selectedItem.slot === 'component'
|
||||||
|
? [{
|
||||||
|
glyph: selectedItem.glyph,
|
||||||
|
title: selectedItem.name,
|
||||||
|
meta: `Owned: ${selectedItem.quantity}`,
|
||||||
|
status: 'Component',
|
||||||
|
}]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
glyph: selectedItem.glyph,
|
||||||
|
title: selectedItem.name,
|
||||||
|
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
|
||||||
|
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
|
||||||
|
},
|
||||||
|
...(comparisonItem && comparisonItem.id !== selectedItem.id
|
||||||
|
? [{
|
||||||
|
glyph: comparisonItem.glyph,
|
||||||
|
title: comparisonItem.name,
|
||||||
|
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
|
||||||
|
status: 'Currently Equipped',
|
||||||
|
}]
|
||||||
|
: [{
|
||||||
|
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
|
||||||
|
status: 'Comparison',
|
||||||
|
}]),
|
||||||
|
...(upgradeRecipe
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
glyph: upgradeRecipe.item.glyph,
|
||||||
|
title: `Upgrade to ${upgradeRecipe.item.name}`,
|
||||||
|
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
|
||||||
|
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
|
||||||
|
},
|
||||||
|
...upgradeRecipe.components.map((component) => ({
|
||||||
|
glyph: component.item.glyph,
|
||||||
|
title: component.item.name,
|
||||||
|
meta: `Required for upgrade`,
|
||||||
|
status: `${component.owned}/${component.quantity}`,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
|
||||||
|
|
||||||
|
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
@@ -186,6 +432,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
|
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showModeTabs && (
|
||||||
<nav className="equipment-tabs">
|
<nav className="equipment-tabs">
|
||||||
<button
|
<button
|
||||||
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
||||||
@@ -202,6 +449,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
Crafting
|
Crafting
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
{equipmentTab === 'equipment' ? (
|
{equipmentTab === 'equipment' ? (
|
||||||
<>
|
<>
|
||||||
@@ -210,9 +458,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
selectedItem.slot === 'component' ? (
|
selectedItem.slot === 'component' ? (
|
||||||
<>
|
<>
|
||||||
<ItemDetail title="Crafting Component" item={selectedItem} />
|
<ItemDetail title="Crafting Component" item={selectedItem} />
|
||||||
<div className="equip-action">
|
|
||||||
<p className="component-note">Used in crafting.</p>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -226,31 +471,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
|
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="equip-action">
|
|
||||||
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
disabled={selectedItem.equipped || equipping || breakingDown}
|
|
||||||
onClick={equipSelected}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
|
||||||
</button>
|
|
||||||
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
|
|
||||||
<button
|
|
||||||
className="breakdown-button"
|
|
||||||
disabled={equipping || breakingDown}
|
|
||||||
onClick={breakdownSelected}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{breakingDown
|
|
||||||
? 'Breaking Down...'
|
|
||||||
: selectedItem.quantity > 1
|
|
||||||
? 'Break Down Duplicate'
|
|
||||||
: 'Break Down'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -258,6 +478,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="equipment-action-strip">
|
||||||
|
{renderEquipmentActions()}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="equipment-layout">
|
<div className="equipment-layout">
|
||||||
<section className="equipped-panel">
|
<section className="equipped-panel">
|
||||||
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
|
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
|
||||||
@@ -270,6 +494,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 +527,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 +561,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>
|
||||||
</>
|
</>
|
||||||
@@ -340,38 +577,83 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
<section className="crafting-panel">
|
<section className="crafting-panel">
|
||||||
<EquipmentHeading
|
<EquipmentHeading
|
||||||
eyebrow="Crafting"
|
eyebrow="Crafting"
|
||||||
title="Recipes"
|
title="Workbench"
|
||||||
detail={`${filteredRecipes.filter((recipe) => recipe.canCraft).length} ready`}
|
detail={`${readyRecipeCount} ready / ${filteredRecipes.length} shown`}
|
||||||
/>
|
/>
|
||||||
<div className="crafting-filter-bar">
|
|
||||||
<select
|
|
||||||
className="filter-select"
|
|
||||||
value={slotFilter}
|
|
||||||
onChange={(e) => setSlotFilter(e.target.value as EquipmentSlot | 'all')}
|
|
||||||
>
|
|
||||||
<option value="all">All Slots</option>
|
|
||||||
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
|
|
||||||
<option key={slot} value={slot}>{label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="filter-select"
|
|
||||||
value={levelFilter ?? ''}
|
|
||||||
onChange={(e) => setLevelFilter(e.target.value === '' ? null : Number(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="">All Levels</option>
|
|
||||||
{availableLevels.map((level) => (
|
|
||||||
<option key={level} value={level}>Item Level {level}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{filteredRecipes.length === 0 && (
|
|
||||||
<p className="inventory-empty">No crafting recipes match filters.</p>
|
|
||||||
)}
|
|
||||||
{filteredRecipes.length > 0 && (
|
|
||||||
<div className="crafting-layout">
|
<div className="crafting-layout">
|
||||||
|
<aside className="crafting-filters">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Slot</p>
|
||||||
|
<div className="crafting-filter-grid">
|
||||||
|
<button
|
||||||
|
className={slotFilter === 'all' ? 'active' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setSlotFilter('all')
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<strong>All</strong>
|
||||||
|
<span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
|
||||||
|
</button>
|
||||||
|
{CRAFTING_FILTER_SLOTS.map((slot) => (
|
||||||
|
<button
|
||||||
|
className={slotFilter === slot ? 'active' : ''}
|
||||||
|
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
|
||||||
|
key={slot}
|
||||||
|
onClick={() => {
|
||||||
|
setSlotFilter(slot)
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<strong>{SLOT_LABELS[slot]}</strong>
|
||||||
|
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Item Level</p>
|
||||||
|
<div className="crafting-level-row">
|
||||||
|
<button
|
||||||
|
className={levelFilter === null ? 'active' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setLevelFilter(null)
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{availableLevels.map((level) => (
|
||||||
|
<button
|
||||||
|
className={levelFilter === level ? 'active' : ''}
|
||||||
|
key={level}
|
||||||
|
onClick={() => {
|
||||||
|
setLevelFilter(level)
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{level}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="crafting-list-panel">
|
||||||
|
<EquipmentHeading
|
||||||
|
eyebrow="Recipes"
|
||||||
|
title={slotFilter === 'all' ? 'Available' : SLOT_LABELS[slotFilter]}
|
||||||
|
detail={`Page ${recipePage + 1}/${recipePageCount}`}
|
||||||
|
/>
|
||||||
|
{filteredRecipes.length === 0 ? (
|
||||||
|
<p className="inventory-empty">No recipes match filters.</p>
|
||||||
|
) : (
|
||||||
<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}
|
||||||
@@ -386,13 +668,42 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
|
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
|
<i className={recipe.canCraft ? 'ready' : 'missing'}>
|
||||||
|
{recipe.canCraft ? 'Ready' : 'Needs materials'}
|
||||||
|
</i>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{selectedRecipe && (
|
)}
|
||||||
|
{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 className="crafting-action-row">
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
|
||||||
|
onClick={craftSelected}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="crafting-detail-panel">
|
||||||
|
{selectedRecipe ? (
|
||||||
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
|
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
|
||||||
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
|
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
|
||||||
|
<div className="crafting-detail-heading">
|
||||||
|
<p className="eyebrow">Materials</p>
|
||||||
|
<span>{selectedRecipe.canCraft ? 'Ready' : 'Missing components'}</span>
|
||||||
|
</div>
|
||||||
<div className="crafting-components">
|
<div className="crafting-components">
|
||||||
{selectedRecipe.components.map((component) => (
|
{selectedRecipe.components.map((component) => (
|
||||||
<div
|
<div
|
||||||
@@ -405,22 +716,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
disabled={!selectedRecipe.canCraft || crafting}
|
|
||||||
onClick={craftSelected}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{crafting ? 'Crafting...' : 'Craft Item'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="inventory-empty">Select a recipe.</p>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{profile.setBonuses.length > 0 && (
|
{equipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
|
||||||
<section className="set-bonus-panel">
|
<section className="set-bonus-panel">
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -456,16 +761,38 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return <div className="equipment-screen embedded-screen">{content}</div>
|
return <div className={`equipment-screen embedded-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-screen equipment-screen">
|
<section className={`content-screen equipment-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
|
||||||
{content}
|
{content}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,7 +97,27 @@ 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">
|
||||||
|
{([
|
||||||
|
{ key: 'display', label: 'Display' },
|
||||||
|
{ key: 'input', label: 'Input' },
|
||||||
|
{ key: 'bindings', label: 'Bindings' },
|
||||||
|
] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
aria-selected={settingsTab === tab.key}
|
||||||
|
className={settingsTab === tab.key ? 'selected' : ''}
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setSettingsTab(tab.key)}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{settingsTab === 'display' && (
|
||||||
|
<section className="dual-screen-settings settings-tab-panel">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Display</p>
|
<p className="eyebrow">Display</p>
|
||||||
<h2>AYN Thor Dual-Screen Mode</h2>
|
<h2>AYN Thor Dual-Screen Mode</h2>
|
||||||
@@ -131,29 +153,23 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
{androidDisplays.map((display) => (
|
{androidDisplays.map((display) => (
|
||||||
<span key={display.id}>
|
<span key={display.id}>
|
||||||
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
|
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
|
||||||
{display.width}×{display.height} at {Math.round(display.refreshRate)} Hz
|
{display.width}x{display.height} at {Math.round(display.refreshRate)} Hz
|
||||||
{display.isPresentation ? ' - Presentation' : ''}
|
{display.isPresentation ? ' - Presentation' : ''}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="settings-heading">
|
{settingsTab === 'input' && (
|
||||||
<div>
|
<section className="controller-preferences settings-tab-panel">
|
||||||
<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>
|
<div>
|
||||||
<p className="eyebrow">Targeting</p>
|
<p className="eyebrow">Targeting</p>
|
||||||
<h3>Direct Party Keybinds</h3>
|
<h3>Direct Party Keybinds</h3>
|
||||||
<p>
|
<p>
|
||||||
Assign party slots directly. In raids, use the group-switch binding
|
Assign party slots directly. In raids, use the group-switch binding
|
||||||
to alternate between members 1-5 and 6-10.
|
to alternate between members 1-6, 7-12, and 13-18.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -180,6 +196,17 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div className="binding-tabs">
|
||||||
<button
|
<button
|
||||||
@@ -227,6 +254,8 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
|
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{capture && (
|
{capture && (
|
||||||
<div className="binding-capture" role="dialog" aria-modal="true">
|
<div className="binding-capture" role="dialog" aria-modal="true">
|
||||||
|
|||||||
+190
-81
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
allocateTalent,
|
allocateTalent,
|
||||||
resetTalents,
|
resetTalents,
|
||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
type Talent,
|
type Talent,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
@@ -13,178 +14,286 @@ type Props = {
|
|||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const
|
||||||
|
const EFFECT_CLASS_ID = 1
|
||||||
|
const EFFECTS_PER_PAGE = 8
|
||||||
|
const EFFECT_SOURCE_LABELS: Record<string, string> = {
|
||||||
|
mend: 'Mend',
|
||||||
|
radiance: 'Radiance',
|
||||||
|
shield: 'Shield',
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectSource(effectType: string) {
|
||||||
|
if (effectType.startsWith('mend_')) return 'mend'
|
||||||
|
if (effectType.startsWith('radiance_')) return 'radiance'
|
||||||
|
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'shield'
|
||||||
|
return effectType
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectCapacity(level: number) {
|
||||||
|
return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeEffects(talents: Talent[]) {
|
||||||
|
return talents.filter((talent) => talent.rank > 0)
|
||||||
|
}
|
||||||
|
|
||||||
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
||||||
const [resetting, setResetting] = useState(false)
|
const [resetting, setResetting] = useState(false)
|
||||||
|
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
|
||||||
|
const [effectPage, setEffectPage] = useState(0)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const scrollRef = useRef<number>(0)
|
const scrollRef = useRef<number>(0)
|
||||||
const gameClass = profile.classes.find(
|
const gameClass = profile.classes.find(
|
||||||
(candidate) => candidate.id === profile.character.classId,
|
(candidate) => candidate.id === profile.character.classId,
|
||||||
)!
|
)!
|
||||||
const classPointsSpent = gameClass.talents.reduce(
|
const isEffectClass = gameClass.id === EFFECT_CLASS_ID
|
||||||
(total, talent) => total + talent.rank,
|
const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0
|
||||||
0,
|
const selectedEffects = activeEffects(gameClass.talents)
|
||||||
|
const selectedTalent = gameClass.talents.find((talent) => talent.id === selectedTalentId)
|
||||||
|
?? selectedEffects[0]
|
||||||
|
?? gameClass.talents[0]
|
||||||
|
?? null
|
||||||
|
const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE))
|
||||||
|
const visibleTalents = gameClass.talents.slice(
|
||||||
|
effectPage * EFFECTS_PER_PAGE,
|
||||||
|
effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
|
||||||
)
|
)
|
||||||
const tiers = Array.from(
|
|
||||||
new Set(gameClass.talents.map((talent) => talent.tier)),
|
|
||||||
).sort((a, b) => a - b)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.scrollTo(0, scrollRef.current)
|
window.scrollTo(0, scrollRef.current)
|
||||||
}, [profile])
|
}, [profile])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return
|
||||||
|
setSelectedTalentId(selectedTalent?.id ?? null)
|
||||||
|
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEffectPage((page) => Math.min(page, effectPageCount - 1))
|
||||||
|
}, [effectPageCount])
|
||||||
|
|
||||||
function saveScroll() {
|
function saveScroll() {
|
||||||
scrollRef.current = window.scrollY
|
scrollRef.current = window.scrollY
|
||||||
}
|
}
|
||||||
|
|
||||||
function lowerTierPoints(talent: Talent) {
|
|
||||||
return gameClass.talents
|
|
||||||
.filter((candidate) => candidate.tier < talent.tier)
|
|
||||||
.reduce((total, candidate) => total + candidate.rank, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function lockReason(talent: Talent) {
|
function lockReason(talent: Talent) {
|
||||||
if (talent.rank >= talent.maxRank) return 'Maximum rank'
|
if (!isEffectClass) return 'Coming soon'
|
||||||
|
if (talent.rank > 0) return ''
|
||||||
const requiredTierPoints = (talent.tier - 1) * 5
|
const source = effectSource(talent.effectType)
|
||||||
if (lowerTierPoints(talent) < requiredTierPoints) {
|
const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source)
|
||||||
return `Requires ${requiredTierPoints} earlier-tier points`
|
if (sourceConflict) return `${EFFECT_SOURCE_LABELS[source] ?? source} already selected`
|
||||||
|
if (capacity <= 0) return 'Unlocks at level 5'
|
||||||
|
if (selectedEffects.length >= capacity) {
|
||||||
|
return `Active slots full (${capacity}/${capacity})`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (talent.prerequisiteTalentId) {
|
|
||||||
const prerequisite = gameClass.talents.find(
|
|
||||||
(candidate) => candidate.id === talent.prerequisiteTalentId,
|
|
||||||
)
|
|
||||||
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
|
|
||||||
return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile.character.talentPoints <= 0) return 'No points available'
|
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purchaseRank(talent: Talent) {
|
async function toggleEffect(talent: Talent) {
|
||||||
saveScroll()
|
saveScroll()
|
||||||
setBusyTalentId(talent.id)
|
setBusyTalentId(talent.id)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
try {
|
try {
|
||||||
const updated = await allocateTalent(talent.id)
|
const updated = await allocateTalent(talent.id)
|
||||||
onUpdated(updated)
|
onUpdated(updated)
|
||||||
setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`)
|
setSelectedTalentId(talent.id)
|
||||||
|
setMessage(talent.rank > 0 ? `${talent.name} removed.` : `${talent.name} activated.`)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.')
|
setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.')
|
||||||
} finally {
|
} finally {
|
||||||
setBusyTalentId(null)
|
setBusyTalentId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refundTree() {
|
async function clearEffects() {
|
||||||
saveScroll()
|
saveScroll()
|
||||||
setResetting(true)
|
setResetting(true)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
try {
|
try {
|
||||||
const updated = await resetTalents()
|
const updated = await resetTalents()
|
||||||
onUpdated(updated)
|
onUpdated(updated)
|
||||||
setMessage('All points in this talent tree were refunded.')
|
setMessage('Spell effects cleared.')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.')
|
setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.')
|
||||||
} finally {
|
} finally {
|
||||||
setResetting(false)
|
setResetting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||||
|
if (!isEffectClass) return null
|
||||||
|
return {
|
||||||
|
mode: 'talents',
|
||||||
|
title: 'Spell Effects',
|
||||||
|
subtitle: `${selectedEffects.length}/${capacity} active`,
|
||||||
|
summary: selectedTalent
|
||||||
|
? `${selectedTalent.name}: ${selectedTalent.description}`
|
||||||
|
: 'Choose effects to modify your spells.',
|
||||||
|
items: gameClass.talents.map((talent) => ({
|
||||||
|
glyph: talent.glyph,
|
||||||
|
title: talent.name,
|
||||||
|
meta: talent.rank > 0 ? 'Active' : lockReason(talent) || 'Available',
|
||||||
|
detail: talent.description,
|
||||||
|
status: talent.rank > 0 ? 'Selected' : '',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent])
|
||||||
|
|
||||||
|
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
<div className="screen-heading">
|
<div className="screen-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Character Growth</p>
|
<p className="eyebrow">Character Growth</p>
|
||||||
<h1>Talents</h1>
|
<h1>Spell Effects</h1>
|
||||||
</div>
|
</div>
|
||||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="talent-toolbar">
|
<div className="talent-toolbar spell-effect-toolbar">
|
||||||
<div className="talent-class-summary">
|
<div className="talent-class-summary">
|
||||||
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
||||||
{gameClass.name[0]}
|
{gameClass.name[0]}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{gameClass.name} Tree</p>
|
<p className="eyebrow">{gameClass.name} Effects</p>
|
||||||
<h2>Shape Your Healing Style</h2>
|
<h2>Modify Your Spells</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="talent-points">
|
<div className="talent-points">
|
||||||
<strong>{profile.character.talentPoints}</strong>
|
<strong>{selectedEffects.length}/{capacity}</strong>
|
||||||
<span>Available</span>
|
<span>Active</span>
|
||||||
<small>{classPointsSpent} spent in this tree</small>
|
<small>Slots unlock at levels 5, 10, 15, 20</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="talent-tree">
|
{!isEffectClass ? (
|
||||||
{tiers.map((tier) => {
|
<div className="talent-empty-state">
|
||||||
const requiredPoints = (tier - 1) * 5
|
<h2>Spell effects coming soon for {gameClass.name}.</h2>
|
||||||
return (
|
<p>This replacement system starts with the first class.</p>
|
||||||
<section className="talent-tier" key={tier}>
|
|
||||||
<div className="tier-label">
|
|
||||||
<span>Tier {tier}</span>
|
|
||||||
<small>
|
|
||||||
{tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="tier-talents">
|
) : (
|
||||||
{gameClass.talents
|
<div className="spell-effect-layout">
|
||||||
.filter((talent) => talent.tier === tier)
|
<section className="effect-slots-panel">
|
||||||
.sort((a, b) => a.branch - b.branch)
|
<p className="eyebrow">Active Slots</p>
|
||||||
.map((talent) => {
|
{EFFECT_SLOT_LEVELS.map((level, index) => {
|
||||||
|
const effect = selectedEffects[index]
|
||||||
|
const unlocked = profile.character.level >= level
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`effect-slot ${effect ? 'filled' : ''} ${unlocked ? '' : 'locked'}`}
|
||||||
|
disabled={!effect}
|
||||||
|
key={level}
|
||||||
|
onClick={() => effect && setSelectedTalentId(effect.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>Lv {level}</span>
|
||||||
|
<strong>{effect?.name ?? (unlocked ? 'Empty Slot' : 'Locked')}</strong>
|
||||||
|
<small>{effect?.description ?? (unlocked ? 'Choose an effect from the pool.' : `Reach level ${level}.`)}</small>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="effect-pool-panel">
|
||||||
|
<div className="effect-panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Effect Pool</p>
|
||||||
|
<h2>Choose and Swap</h2>
|
||||||
|
</div>
|
||||||
|
<span>{selectedEffects.length}/{capacity} active</span>
|
||||||
|
</div>
|
||||||
|
<div className="selected-effect-strip">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Selected Effect</p>
|
||||||
|
{selectedTalent ? (
|
||||||
|
<>
|
||||||
|
<strong>{selectedTalent.name}</strong>
|
||||||
|
<small>{selectedTalent.description}</small>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<small>No effect selected.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedTalent && (
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={Boolean(lockReason(selectedTalent)) || busyTalentId === selectedTalent.id}
|
||||||
|
onClick={() => toggleEffect(selectedTalent)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{busyTalentId === selectedTalent.id
|
||||||
|
? 'Saving...'
|
||||||
|
: selectedTalent.rank > 0
|
||||||
|
? 'Remove'
|
||||||
|
: 'Activate'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="effect-pool">
|
||||||
|
{visibleTalents.map((talent) => {
|
||||||
const reason = lockReason(talent)
|
const reason = lockReason(talent)
|
||||||
|
const active = talent.rank > 0
|
||||||
|
const selected = selectedTalent?.id === talent.id
|
||||||
const isBusy = busyTalentId === talent.id
|
const isBusy = busyTalentId === talent.id
|
||||||
return (
|
return (
|
||||||
<article
|
<button
|
||||||
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
|
className={`${active ? 'active' : ''} ${selected ? 'selected' : ''}`}
|
||||||
|
disabled={Boolean(reason) || isBusy}
|
||||||
key={talent.id}
|
key={talent.id}
|
||||||
style={{ gridColumn: talent.branch }}
|
onClick={() => {
|
||||||
|
setSelectedTalentId(talent.id)
|
||||||
|
void toggleEffect(talent)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="talent-node-header">
|
|
||||||
<span>{talent.glyph}</span>
|
<span>{talent.glyph}</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{talent.name}</strong>
|
<strong>{talent.name}</strong>
|
||||||
<small>Rank {talent.rank}/{talent.maxRank}</small>
|
<small>{EFFECT_SOURCE_LABELS[effectSource(talent.effectType)] ?? 'Spell'}</small>
|
||||||
</div>
|
</div>
|
||||||
|
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p>{talent.description}</p>
|
{effectPageCount > 1 && (
|
||||||
<div className="rank-pips">
|
<div className="effect-pager">
|
||||||
{Array.from({ length: talent.maxRank }, (_, index) => (
|
|
||||||
<i className={index < talent.rank ? 'filled' : ''} key={index} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
disabled={Boolean(reason) || isBusy}
|
disabled={effectPage === 0}
|
||||||
onClick={() => purchaseRank(talent)}
|
onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{isBusy ? 'Saving...' : reason || 'Add Rank'}
|
Prev
|
||||||
|
</button>
|
||||||
|
<span>{effectPage + 1}/{effectPageCount}</span>
|
||||||
|
<button
|
||||||
|
disabled={effectPage >= effectPageCount - 1}
|
||||||
|
onClick={() => setEffectPage((page) => Math.min(effectPageCount - 1, page + 1))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Next
|
||||||
</button>
|
</button>
|
||||||
</article>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<footer className="talent-footer">
|
<footer className="talent-footer">
|
||||||
<span>{message || 'Talent changes are saved immediately.'}</span>
|
<span>{message || 'Spell effect changes are saved immediately.'}</span>
|
||||||
<button
|
<button
|
||||||
className="text-button"
|
className="text-button"
|
||||||
disabled={classPointsSpent === 0 || resetting}
|
disabled={selectedEffects.length === 0 || resetting}
|
||||||
onClick={refundTree}
|
onClick={clearEffects}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{resetting ? 'Refunding...' : 'Reset Tree'}
|
{resetting ? 'Clearing...' : 'Clear Effects'}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+193
-16
@@ -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 = {
|
||||||
@@ -40,7 +42,7 @@ export type DualScreenCombatState = {
|
|||||||
partySize: number
|
partySize: number
|
||||||
selectedId: string
|
selectedId: string
|
||||||
log: CombatLogEntry[]
|
log: CombatLogEntry[]
|
||||||
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'
|
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'
|
||||||
resource: number
|
resource: number
|
||||||
maxResource: number
|
maxResource: number
|
||||||
resourceName: string
|
resourceName: string
|
||||||
@@ -51,15 +53,32 @@ export type DualScreenCombatState = {
|
|||||||
controllerIconStyle: ControllerIconStyle
|
controllerIconStyle: ControllerIconStyle
|
||||||
directPartyTargeting: boolean
|
directPartyTargeting: boolean
|
||||||
paused: boolean
|
paused: boolean
|
||||||
targetGroup: 0 | 1
|
targetGroup: 0 | 1 | 2
|
||||||
|
speedMultiplier: 1 | 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DualScreenWorkshopState = {
|
||||||
|
mode: 'class' | 'equipment' | 'crafting' | 'talents'
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
summary?: string
|
||||||
|
items: Array<{
|
||||||
|
glyph?: string
|
||||||
|
title: string
|
||||||
|
meta?: string
|
||||||
|
detail?: string
|
||||||
|
status?: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
type DualScreenMessage =
|
type DualScreenMessage =
|
||||||
| { type: 'combat-state'; state: DualScreenCombatState }
|
| { type: 'combat-state'; state: DualScreenCombatState }
|
||||||
|
| { type: 'workshop-state'; state: DualScreenWorkshopState }
|
||||||
| { type: 'companion-ready' }
|
| { type: 'companion-ready' }
|
||||||
| { type: 'companion-heartbeat' }
|
| { type: 'companion-heartbeat' }
|
||||||
| { type: 'control-action'; action: InputAction }
|
| { type: 'control-action'; action: InputAction }
|
||||||
| { type: 'combat-ended' }
|
| { type: 'combat-ended' }
|
||||||
|
| { type: 'workshop-ended' }
|
||||||
|
|
||||||
type DualScreenContextValue = {
|
type DualScreenContextValue = {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -100,6 +119,13 @@ function loadRecentSnapshot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function memberHotEffects(member: PartyMember) {
|
||||||
|
if (member.hotEffects?.length) return member.hotEffects
|
||||||
|
return member.hotTicks > 0
|
||||||
|
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||||
const [enabled, setEnabledState] = useState(
|
const [enabled, setEnabledState] = useState(
|
||||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||||
@@ -172,6 +198,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,
|
||||||
@@ -211,16 +304,64 @@ export function useDualScreenPublisher(
|
|||||||
}, [enabled, state])
|
}, [enabled, state])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDualScreenWorkshopPublisher(
|
||||||
|
state: DualScreenWorkshopState | null,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const stateRef = useRef(state)
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = state
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !state) return
|
||||||
|
const channel = createChannel()
|
||||||
|
if (!channel) return
|
||||||
|
const publish = () => {
|
||||||
|
if (stateRef.current) {
|
||||||
|
channel.postMessage({
|
||||||
|
type: 'workshop-state',
|
||||||
|
state: stateRef.current,
|
||||||
|
} satisfies DualScreenMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||||
|
if (event.data.type === 'companion-ready') publish()
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
return () => {
|
||||||
|
channel.postMessage({ type: 'workshop-ended' } satisfies DualScreenMessage)
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
}, [enabled, state])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !state) return
|
||||||
|
const channel = createChannel()
|
||||||
|
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
|
||||||
|
channel?.close()
|
||||||
|
}, [enabled, state])
|
||||||
|
}
|
||||||
|
|
||||||
export function DualScreenBottomDisplay() {
|
export function DualScreenBottomDisplay() {
|
||||||
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
||||||
|
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const channel = createChannel()
|
const channel = createChannel()
|
||||||
if (!channel) return
|
if (!channel) return
|
||||||
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
|
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
|
||||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||||
if (event.data.type === 'combat-state') setState(event.data.state)
|
if (event.data.type === 'combat-state') {
|
||||||
|
setState(event.data.state)
|
||||||
|
setWorkshopState(null)
|
||||||
|
}
|
||||||
|
if (event.data.type === 'workshop-state') {
|
||||||
|
setWorkshopState(event.data.state)
|
||||||
|
setState(null)
|
||||||
|
}
|
||||||
if (event.data.type === 'combat-ended') setState(null)
|
if (event.data.type === 'combat-ended') setState(null)
|
||||||
|
if (event.data.type === 'workshop-ended') setWorkshopState(null)
|
||||||
}
|
}
|
||||||
announce()
|
announce()
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
@@ -238,6 +379,40 @@ export function DualScreenBottomDisplay() {
|
|||||||
channel?.close()
|
channel?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!state && workshopState) {
|
||||||
|
return (
|
||||||
|
<main className="dual-bottom-display workshop-bottom-display">
|
||||||
|
<header className="dual-controls-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{workshopState.mode}</p>
|
||||||
|
<h1>{workshopState.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="dual-controls-progress">
|
||||||
|
<span>{workshopState.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{workshopState.summary && (
|
||||||
|
<section className="workshop-bottom-summary">
|
||||||
|
{workshopState.summary}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<section className="workshop-bottom-grid">
|
||||||
|
{workshopState.items.map((item, index) => (
|
||||||
|
<article key={`${item.title}-${index}`}>
|
||||||
|
{item.glyph && <span>{item.glyph}</span>}
|
||||||
|
<div>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{item.meta && <small>{item.meta}</small>}
|
||||||
|
{item.detail && <p>{item.detail}</p>}
|
||||||
|
</div>
|
||||||
|
{item.status && <i>{item.status}</i>}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return (
|
return (
|
||||||
<main className="dual-bottom-display dual-bottom-waiting">
|
<main className="dual-bottom-display dual-bottom-waiting">
|
||||||
@@ -271,6 +446,7 @@ export function DualScreenBottomDisplay() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="dual-controls-mana">
|
<div className="dual-controls-mana">
|
||||||
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
||||||
|
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||||
<div className="bar mana-bar">
|
<div className="bar mana-bar">
|
||||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -280,9 +456,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 +469,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,14 +572,17 @@ 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' : ''}`}
|
||||||
|
data-party-member-id={member.id}
|
||||||
key={member.id}
|
key={member.id}
|
||||||
onClick={() => onSelectTarget(member.id)}
|
onClick={() => onSelectTarget(member.id)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -418,6 +597,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">
|
||||||
@@ -428,7 +608,9 @@ export function DualScreenTopCombat({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="member-effects">
|
<div className="member-effects">
|
||||||
{member.hotTicks > 0 && <span className="buff">Renew</span>}
|
{memberHotEffects(member).map((effect) => (
|
||||||
|
<span className="buff" key={effect.id}>{effect.label}</span>
|
||||||
|
))}
|
||||||
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -437,11 +619,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-2
@@ -8,6 +8,20 @@ export type PartyMember = {
|
|||||||
maxHealth: number
|
maxHealth: number
|
||||||
shield: number
|
shield: number
|
||||||
hotTicks: number
|
hotTicks: number
|
||||||
|
hotEffects?: Array<{
|
||||||
|
id: string
|
||||||
|
spellId: string
|
||||||
|
label: string
|
||||||
|
ticks: number
|
||||||
|
power: number
|
||||||
|
}>
|
||||||
|
bounceHeals?: Array<{
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
charges: number
|
||||||
|
power: number
|
||||||
|
}>
|
||||||
|
damageReductionTicks?: number
|
||||||
debuff?: string
|
debuff?: string
|
||||||
debuffTicks?: number
|
debuffTicks?: number
|
||||||
poisonStacks?: number
|
poisonStacks?: number
|
||||||
@@ -24,7 +38,8 @@ export type Spell = {
|
|||||||
cooldown: number
|
cooldown: number
|
||||||
power: number
|
power: number
|
||||||
glyph: string
|
glyph: string
|
||||||
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse'
|
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' | 'damage_reduction' | 'bounce_heal'
|
||||||
|
effectType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Encounter = {
|
export type Encounter = {
|
||||||
@@ -44,12 +59,16 @@ export type CombatLogEntry = {
|
|||||||
tone: 'system' | 'heal' | 'danger' | 'loot'
|
tone: 'system' | 'heal' | 'danger' | 'loot'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TANKLESS_DAMAGE_MULTIPLIER = 1.35
|
||||||
|
export const DEFAULT_GROUP_HEAL_TARGETS = 4
|
||||||
|
|
||||||
export const INITIAL_PARTY: PartyMember[] = [
|
export const INITIAL_PARTY: PartyMember[] = [
|
||||||
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
|
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
|
||||||
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
|
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
|
||||||
{ 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 +82,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[] = [
|
||||||
@@ -92,7 +119,7 @@ export const SPELLS: Spell[] = [
|
|||||||
id: 'radiance',
|
id: 'radiance',
|
||||||
key: '3',
|
key: '3',
|
||||||
name: 'Radiance',
|
name: 'Radiance',
|
||||||
description: 'Restores health to every living party member.',
|
description: 'Restores health to up to 4 injured party members.',
|
||||||
cost: 12,
|
cost: 12,
|
||||||
cooldown: 8,
|
cooldown: 8,
|
||||||
power: 18,
|
power: 18,
|
||||||
@@ -155,3 +182,28 @@ export const ENCOUNTERS: Encounter[] = [
|
|||||||
isBoss: true,
|
isBoss: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export function partyDamageOutput(party: PartyMember[], baseDamage: number) {
|
||||||
|
const livingCount = party.filter((member) => member.health > 0).length
|
||||||
|
return Math.round(baseDamage * (livingCount / Math.max(1, party.length)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tankPressureTargets(party: PartyMember[]) {
|
||||||
|
const living = party.filter((member) => member.health > 0)
|
||||||
|
const tanks = living.filter((member) => member.role === 'Tank')
|
||||||
|
if (tanks.length > 0) return { targets: tanks, multiplier: 1 }
|
||||||
|
const damageDealer = living
|
||||||
|
.filter((member) => member.role === 'Damage')
|
||||||
|
.sort((left, right) => right.health - left.health)[0]
|
||||||
|
return {
|
||||||
|
targets: damageDealer ? [damageDealer] : [],
|
||||||
|
multiplier: TANKLESS_DAMAGE_MULTIPLIER,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) {
|
||||||
|
return party
|
||||||
|
.filter((member) => member.health > 0)
|
||||||
|
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))
|
||||||
|
.slice(0, targetCount)
|
||||||
|
}
|
||||||
|
|||||||
+763
-130
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-10
@@ -33,7 +33,9 @@ export const INPUT_ACTIONS = [
|
|||||||
'targetParty3',
|
'targetParty3',
|
||||||
'targetParty4',
|
'targetParty4',
|
||||||
'targetParty5',
|
'targetParty5',
|
||||||
|
'targetParty6',
|
||||||
'toggleTargetGroup',
|
'toggleTargetGroup',
|
||||||
|
'toggleSpeed',
|
||||||
'pause',
|
'pause',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -60,7 +62,9 @@ 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',
|
||||||
|
toggleSpeed: 'Toggle 2x Speed',
|
||||||
pause: 'Pause Menu',
|
pause: 'Pause Menu',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +89,9 @@ 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',
|
||||||
|
toggleSpeed: 'Backquote',
|
||||||
pause: 'Escape',
|
pause: 'Escape',
|
||||||
},
|
},
|
||||||
controller: {
|
controller: {
|
||||||
@@ -108,7 +114,9 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
|||||||
targetParty3: 'Button15',
|
targetParty3: 'Button15',
|
||||||
targetParty4: 'Button13',
|
targetParty4: 'Button13',
|
||||||
targetParty5: 'Button4',
|
targetParty5: 'Button4',
|
||||||
|
targetParty6: 'Button10',
|
||||||
toggleTargetGroup: 'Button6',
|
toggleTargetGroup: 'Button6',
|
||||||
|
toggleSpeed: 'Button11',
|
||||||
pause: 'Button9',
|
pause: 'Button9',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -141,7 +149,8 @@ const InputContext = createContext<InputContextValue | null>(null)
|
|||||||
function loadBindings(): Record<InputDevice, InputBindings> {
|
function loadBindings(): Record<InputDevice, InputBindings> {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
|
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
|
||||||
const controller = { ...DEFAULT_BINDINGS.controller, ...saved.controller }
|
const savedController = saved.controller
|
||||||
|
const controller = { ...DEFAULT_BINDINGS.controller, ...savedController }
|
||||||
const usesLegacyAbilityDefaults = [
|
const usesLegacyAbilityDefaults = [
|
||||||
'Button2',
|
'Button2',
|
||||||
'Button3',
|
'Button3',
|
||||||
@@ -162,6 +171,15 @@ function loadBindings(): Record<InputDevice, InputBindings> {
|
|||||||
ability6: DEFAULT_BINDINGS.controller.ability6,
|
ability6: DEFAULT_BINDINGS.controller.ability6,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (savedController?.toggleSpeed === 'Button7') {
|
||||||
|
controller.toggleSpeed = DEFAULT_BINDINGS.controller.toggleSpeed
|
||||||
|
}
|
||||||
|
if (savedController?.ability6 === 'Button10') {
|
||||||
|
controller.ability6 = DEFAULT_BINDINGS.controller.ability6
|
||||||
|
}
|
||||||
|
if (savedController?.targetParty6 === 'Button11') {
|
||||||
|
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
||||||
controller,
|
controller,
|
||||||
@@ -202,13 +220,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 +280,14 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUTTON_LABELS: Record<number, string> = {
|
const BUTTON_LABELS: Record<number, string> = {
|
||||||
@@ -416,18 +447,24 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
||||||
setLastDevice(device)
|
const uiOverlay = hasUiOverlay()
|
||||||
document.documentElement.dataset.inputDevice = device
|
|
||||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||||
|
|
||||||
|
setLastDevice(device)
|
||||||
|
document.documentElement.dataset.inputDevice = device
|
||||||
|
|
||||||
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,22 +495,34 @@ 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',
|
||||||
|
'toggleSpeed',
|
||||||
] satisfies InputAction[]
|
] satisfies InputAction[]
|
||||||
const combatPriority = [
|
const combatPriority = [
|
||||||
'pause',
|
'pause',
|
||||||
|
'toggleSpeed',
|
||||||
'ability1',
|
'ability1',
|
||||||
'ability2',
|
'ability2',
|
||||||
'ability3',
|
'ability3',
|
||||||
@@ -487,7 +536,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 +594,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 +611,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.
|
||||||
|
|||||||
+4144
-7107
File diff suppressed because it is too large
Load Diff
+12
-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
|
||||||
@@ -168,6 +168,7 @@ export type Dungeon = {
|
|||||||
difficulties: Difficulty[]
|
difficulties: Difficulty[]
|
||||||
encounters: DungeonEncounter[]
|
encounters: DungeonEncounter[]
|
||||||
completionLoot: Array<Omit<Item, 'quantity' | 'equipped'>>
|
completionLoot: Array<Omit<Item, 'quantity' | 'equipped'>>
|
||||||
|
completionCount?: number
|
||||||
leaderboard: LeaderboardEntry[]
|
leaderboard: LeaderboardEntry[]
|
||||||
leaderboards: {
|
leaderboards: {
|
||||||
part_1: LeaderboardEntry[]
|
part_1: LeaderboardEntry[]
|
||||||
@@ -234,6 +235,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 +249,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
|
||||||
}
|
}
|
||||||
@@ -317,6 +320,7 @@ export async function completeDungeon(
|
|||||||
completedPart?: number,
|
completedPart?: number,
|
||||||
startPart?: number,
|
startPart?: number,
|
||||||
partDurationSeconds?: [number, number, number],
|
partDurationSeconds?: [number, number, number],
|
||||||
|
hardMode?: boolean,
|
||||||
): Promise<DungeonReward> {
|
): Promise<DungeonReward> {
|
||||||
return activeGameRepository().completeDungeon(
|
return activeGameRepository().completeDungeon(
|
||||||
dungeonId,
|
dungeonId,
|
||||||
@@ -326,6 +330,7 @@ export async function completeDungeon(
|
|||||||
completedPart,
|
completedPart,
|
||||||
startPart,
|
startPart,
|
||||||
partDurationSeconds,
|
partDurationSeconds,
|
||||||
|
hardMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +343,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 +381,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