Initial I Want to Heal app
@@ -0,0 +1,28 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
backups/*.db
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,87 @@
|
||||
# Self-Hosting
|
||||
|
||||
The game can run on your own Windows PC and use router port forwarding. Account
|
||||
registration, sessions, characters, progression, inventory, loot history, and
|
||||
leaderboards are stored in the SQLite database at `data/game.db`.
|
||||
|
||||
That database file is the persistent storage. It remains after the server or PC
|
||||
restarts. If the file is deleted or lost, all accounts and save data are lost,
|
||||
so keep regular backups.
|
||||
|
||||
## Build and run
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
npm run db:init
|
||||
npm run build
|
||||
$env:HOST = "127.0.0.1"
|
||||
$env:PORT = "4173"
|
||||
$env:TRUST_PROXY = "1"
|
||||
npm start
|
||||
```
|
||||
|
||||
Run `npm run db:init` after pulling schema changes. It creates missing tables and
|
||||
seed content without erasing existing player data.
|
||||
|
||||
## Internet access
|
||||
|
||||
Do not expose the Node port directly over unencrypted HTTP. Passwords would
|
||||
travel across the internet without encryption. Put Caddy or another HTTPS
|
||||
reverse proxy in front of the game:
|
||||
|
||||
```caddyfile
|
||||
game.example.com {
|
||||
reverse_proxy 127.0.0.1:4173
|
||||
}
|
||||
```
|
||||
|
||||
Point a domain or dynamic-DNS name to your public IP, forward router ports 80
|
||||
and 443 to the PC running Caddy, and allow those ports through Windows Firewall.
|
||||
Caddy obtains and renews the HTTPS certificate.
|
||||
|
||||
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
|
||||
limits use the visitor's public IP instead of the proxy's address.
|
||||
|
||||
## Account limits
|
||||
|
||||
Registration permits one account per public IP by default. Login and API rate
|
||||
limits also apply. To allow a shared household to create more accounts, run a
|
||||
local administrator command:
|
||||
|
||||
```powershell
|
||||
npm run accounts:ip -- set 203.0.113.42 4 Smith-household
|
||||
npm run accounts:ip -- list
|
||||
npm run accounts:ip -- remove 203.0.113.42
|
||||
```
|
||||
|
||||
`set` changes the total number of accounts that address may create. `remove`
|
||||
returns it to the default one-account limit. These commands must be run on the
|
||||
host PC and are not exposed through the website.
|
||||
|
||||
An IP account limit reduces casual account spam, but it is not DDoS protection.
|
||||
Your router and internet connection can still be overwhelmed before requests
|
||||
reach the game. Keep Windows patched, expose only required ports, and use router
|
||||
or ISP protections where available.
|
||||
|
||||
## Backups
|
||||
|
||||
Create a consistent live backup with:
|
||||
|
||||
```powershell
|
||||
npm run db:backup
|
||||
```
|
||||
|
||||
Backups are written to `backups/game-<timestamp>.db`. Copy them to another disk
|
||||
or cloud backup location; backups left only on the game PC will not help if its
|
||||
drive fails. Test restoration occasionally by stopping the server, preserving
|
||||
the current `data/game.db`, and placing a backup at that path.
|
||||
|
||||
## Current scaling boundary
|
||||
|
||||
SQLite is a good fit for a small private server running one game process. Do not
|
||||
run multiple Node server instances against this setup.
|
||||
|
||||
Combat currently runs in the browser. It is suitable for a friend group, but a
|
||||
modified client could submit false completion metrics. Move combat simulation
|
||||
and run validation to the server before treating rankings as cheat-resistant.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Game Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/admin.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -0,0 +1,54 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace = "com.warren.iwanttoheal"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".GameActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:launchMode="standard"
|
||||
android:resizeableActivity="true"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.warren.iwanttoheal;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
|
||||
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
loadIntentUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
loadIntentUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) enableImmersiveMode();
|
||||
}
|
||||
|
||||
private void loadIntentUrl() {
|
||||
if (bridge == null || getIntent() == null) return;
|
||||
String initialUrl = getIntent().getStringExtra(EXTRA_INITIAL_URL);
|
||||
if (initialUrl == null || initialUrl.isEmpty()) return;
|
||||
bridge.getWebView().post(() -> bridge.getWebView().loadUrl(initialUrl));
|
||||
}
|
||||
|
||||
private void enableImmersiveMode() {
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
String token = controllerToken(event.getKeyCode());
|
||||
if (token == null || bridge == null) return super.dispatchKeyEvent(event);
|
||||
|
||||
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
||||
boolean repeat = event.getRepeatCount() > 0;
|
||||
String script =
|
||||
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
|
||||
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
||||
bridge.getWebView().post(
|
||||
() -> bridge.getWebView().evaluateJavascript(script, null)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String controllerToken(int keyCode) {
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A:
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
return "Button0";
|
||||
case KeyEvent.KEYCODE_BUTTON_B:
|
||||
case KeyEvent.KEYCODE_BACK:
|
||||
return "Button1";
|
||||
case KeyEvent.KEYCODE_BUTTON_X:
|
||||
return "Button2";
|
||||
case KeyEvent.KEYCODE_BUTTON_Y:
|
||||
return "Button3";
|
||||
case KeyEvent.KEYCODE_BUTTON_L1:
|
||||
return "Button4";
|
||||
case KeyEvent.KEYCODE_BUTTON_R1:
|
||||
return "Button5";
|
||||
case KeyEvent.KEYCODE_BUTTON_L2:
|
||||
return "Button6";
|
||||
case KeyEvent.KEYCODE_BUTTON_R2:
|
||||
return "Button7";
|
||||
case KeyEvent.KEYCODE_BUTTON_SELECT:
|
||||
return "Button8";
|
||||
case KeyEvent.KEYCODE_BUTTON_START:
|
||||
return "Button9";
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBL:
|
||||
return "Button10";
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR:
|
||||
return "Button11";
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
return "Button12";
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
return "Button13";
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||||
return "Button14";
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||||
return "Button15";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package com.warren.iwanttoheal;
|
||||
|
||||
import android.app.Presentation;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.hardware.display.DisplayManager;
|
||||
import android.os.Bundle;
|
||||
import android.view.Display;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import com.getcapacitor.JSArray;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
@CapacitorPlugin(name = "DualScreen")
|
||||
public class DualScreenPlugin extends Plugin {
|
||||
|
||||
private TopDisplayPresentation presentation;
|
||||
|
||||
@PluginMethod
|
||||
public void getDisplays(PluginCall call) {
|
||||
DisplayManager displayManager =
|
||||
(DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
|
||||
Display currentDisplay = getActivity().getDisplay();
|
||||
JSArray displays = new JSArray();
|
||||
|
||||
for (Display display : displayManager.getDisplays()) {
|
||||
displays.put(describeDisplay(display, currentDisplay));
|
||||
}
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("currentDisplayId", currentDisplay == null ? -1 : currentDisplay.getDisplayId());
|
||||
result.put("displays", displays);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void openTopDisplay(PluginCall call) {
|
||||
getActivity().runOnUiThread(() -> {
|
||||
try {
|
||||
DisplayLaunchPlan launchPlan = createLaunchPlan(call.getInt("displayId"));
|
||||
if (launchPlan.targetDisplay == null) {
|
||||
call.reject("No secondary Android display is available.");
|
||||
return;
|
||||
}
|
||||
|
||||
Display currentDisplay = getActivity().getDisplay();
|
||||
if (
|
||||
currentDisplay != null
|
||||
&& currentDisplay.getDisplayId() == launchPlan.targetDisplay.getDisplayId()
|
||||
) {
|
||||
JSObject result = describeDisplay(currentDisplay, currentDisplay);
|
||||
result.put("opened", true);
|
||||
result.put("alreadyOnMainDisplay", true);
|
||||
call.resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
String gameUrl = bridge.getLocalUrl();
|
||||
String controlsUrl = gameUrl + "/?display=bottom";
|
||||
String targetUrl = launchPlan.targetIsTopDisplay ? gameUrl : controlsUrl;
|
||||
String currentUrl = launchPlan.currentIsTopDisplay ? gameUrl : controlsUrl;
|
||||
|
||||
closePresentation();
|
||||
presentation = new TopDisplayPresentation(
|
||||
getActivity(),
|
||||
launchPlan.targetDisplay,
|
||||
targetUrl
|
||||
);
|
||||
presentation.setOnDismissListener(dialog -> {
|
||||
presentation = null;
|
||||
notifyListeners("displayDisconnected", new JSObject());
|
||||
});
|
||||
presentation.show();
|
||||
bridge.getWebView().loadUrl(currentUrl);
|
||||
|
||||
JSObject result = describeDisplay(launchPlan.targetDisplay, getActivity().getDisplay());
|
||||
result.put("opened", true);
|
||||
result.put("presentationMode", true);
|
||||
result.put("targetIsTopDisplay", launchPlan.targetIsTopDisplay);
|
||||
result.put("currentIsTopDisplay", launchPlan.currentIsTopDisplay);
|
||||
call.resolve(result);
|
||||
} catch (Exception exception) {
|
||||
closePresentation();
|
||||
call.reject("Unable to open the upper display.", exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void closeTopDisplay(PluginCall call) {
|
||||
getActivity().runOnUiThread(() -> {
|
||||
closePresentation();
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleOnDestroy() {
|
||||
closePresentation();
|
||||
}
|
||||
|
||||
private DisplayLaunchPlan createLaunchPlan(Integer requestedDisplayId) {
|
||||
DisplayManager displayManager =
|
||||
(DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
|
||||
Display currentDisplay = getActivity().getDisplay();
|
||||
int currentDisplayId = currentDisplay == null ? -1 : currentDisplay.getDisplayId();
|
||||
Display topDisplay = largestDisplay(displayManager.getDisplays());
|
||||
boolean currentIsTopDisplay =
|
||||
topDisplay != null && topDisplay.getDisplayId() == currentDisplayId;
|
||||
Display targetDisplay = null;
|
||||
|
||||
if (requestedDisplayId != null) {
|
||||
Display requested = displayManager.getDisplay(requestedDisplayId);
|
||||
if (
|
||||
requested != null
|
||||
&& requested.getDisplayId() != currentDisplayId
|
||||
&& requested.getState() != Display.STATE_OFF
|
||||
) {
|
||||
targetDisplay = requested;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetDisplay == null && currentIsTopDisplay) {
|
||||
targetDisplay = smallestOtherDisplay(displayManager.getDisplays(), currentDisplayId);
|
||||
}
|
||||
|
||||
if (targetDisplay == null && topDisplay != null && topDisplay.getDisplayId() != currentDisplayId) {
|
||||
targetDisplay = topDisplay;
|
||||
}
|
||||
|
||||
if (targetDisplay == null) {
|
||||
targetDisplay = largestOtherDisplay(
|
||||
displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION),
|
||||
currentDisplayId
|
||||
);
|
||||
}
|
||||
|
||||
if (targetDisplay == null) {
|
||||
targetDisplay = largestOtherDisplay(displayManager.getDisplays(), currentDisplayId);
|
||||
}
|
||||
|
||||
boolean targetIsTopDisplay =
|
||||
topDisplay != null && targetDisplay != null
|
||||
&& targetDisplay.getDisplayId() == topDisplay.getDisplayId();
|
||||
return new DisplayLaunchPlan(targetDisplay, targetIsTopDisplay, currentIsTopDisplay);
|
||||
}
|
||||
|
||||
private Display largestDisplay(Display[] displays) {
|
||||
Display selected = null;
|
||||
long selectedPixels = -1;
|
||||
for (Display display : displays) {
|
||||
if (display.getState() == Display.STATE_OFF) continue;
|
||||
long pixels = pixelCount(display);
|
||||
if (pixels > selectedPixels) {
|
||||
selected = display;
|
||||
selectedPixels = pixels;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private Display largestOtherDisplay(Display[] displays, int currentDisplayId) {
|
||||
Display selected = null;
|
||||
long selectedPixels = -1;
|
||||
for (Display display : displays) {
|
||||
if (
|
||||
display.getDisplayId() == currentDisplayId
|
||||
|| display.getState() == Display.STATE_OFF
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
long pixels = pixelCount(display);
|
||||
if (pixels > selectedPixels) {
|
||||
selected = display;
|
||||
selectedPixels = pixels;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private Display smallestOtherDisplay(Display[] displays, int currentDisplayId) {
|
||||
Display selected = null;
|
||||
long selectedPixels = Long.MAX_VALUE;
|
||||
for (Display display : displays) {
|
||||
if (
|
||||
display.getDisplayId() == currentDisplayId
|
||||
|| display.getState() == Display.STATE_OFF
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
long pixels = pixelCount(display);
|
||||
if (pixels < selectedPixels) {
|
||||
selected = display;
|
||||
selectedPixels = pixels;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private long pixelCount(Display display) {
|
||||
Display.Mode mode = display.getMode();
|
||||
return (long) mode.getPhysicalWidth() * mode.getPhysicalHeight();
|
||||
}
|
||||
|
||||
private JSObject describeDisplay(Display display, Display currentDisplay) {
|
||||
Display.Mode mode = display.getMode();
|
||||
JSObject result = new JSObject();
|
||||
result.put("id", display.getDisplayId());
|
||||
result.put("name", display.getName());
|
||||
result.put("width", mode.getPhysicalWidth());
|
||||
result.put("height", mode.getPhysicalHeight());
|
||||
result.put("refreshRate", mode.getRefreshRate());
|
||||
result.put(
|
||||
"isCurrent",
|
||||
currentDisplay != null && currentDisplay.getDisplayId() == display.getDisplayId()
|
||||
);
|
||||
result.put(
|
||||
"isPresentation",
|
||||
(display.getFlags() & Display.FLAG_PRESENTATION) == Display.FLAG_PRESENTATION
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void closePresentation() {
|
||||
if (presentation == null) return;
|
||||
presentation.setOnDismissListener(null);
|
||||
presentation.dismiss();
|
||||
presentation = null;
|
||||
}
|
||||
|
||||
private final class DisplayLaunchPlan {
|
||||
|
||||
private final Display targetDisplay;
|
||||
private final boolean targetIsTopDisplay;
|
||||
private final boolean currentIsTopDisplay;
|
||||
|
||||
DisplayLaunchPlan(
|
||||
Display targetDisplay,
|
||||
boolean targetIsTopDisplay,
|
||||
boolean currentIsTopDisplay
|
||||
) {
|
||||
this.targetDisplay = targetDisplay;
|
||||
this.targetIsTopDisplay = targetIsTopDisplay;
|
||||
this.currentIsTopDisplay = currentIsTopDisplay;
|
||||
}
|
||||
}
|
||||
|
||||
private final class TopDisplayPresentation extends Presentation {
|
||||
|
||||
private final String initialUrl;
|
||||
|
||||
TopDisplayPresentation(Context context, Display display, String initialUrl) {
|
||||
super(context, display);
|
||||
this.initialUrl = initialUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
getWindow().setBackgroundDrawableResource(android.R.color.black);
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
);
|
||||
|
||||
FrameLayout container = new FrameLayout(getContext());
|
||||
container.setBackgroundColor(Color.BLACK);
|
||||
WebView webView = new WebView(getContext());
|
||||
configureWebView(webView);
|
||||
container.addView(
|
||||
webView,
|
||||
new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
);
|
||||
setContentView(container);
|
||||
webView.loadUrl(initialUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private void configureWebView(WebView webView) {
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setDatabaseEnabled(true);
|
||||
settings.setMediaPlaybackRequiresUserGesture(false);
|
||||
settings.setAllowFileAccess(true);
|
||||
settings.setAllowContentAccess(true);
|
||||
webView.setBackgroundColor(Color.BLACK);
|
||||
webView.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(
|
||||
WebView view,
|
||||
WebResourceRequest request
|
||||
) {
|
||||
return bridge.getLocalServer().shouldInterceptRequest(request);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.warren.iwanttoheal;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
public class GameActivity extends ControllerBridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(DualScreenPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.warren.iwanttoheal;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
public class MainActivity extends ControllerBridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(DualScreenPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">I want to Heal</string>
|
||||
<string name="title_activity_main">I want to Heal</string>
|
||||
<string name="package_name">com.warren.iwanttoheal</string>
|
||||
<string name="custom_url_scheme">com.warren.iwanttoheal</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
@@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
@@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.10.1'
|
||||
androidxAppCompatVersion = '1.7.1'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.16.0'
|
||||
androidxFragmentVersion = '1.8.9'
|
||||
coreSplashScreenVersion = '1.2.0'
|
||||
androidxWebkitVersion = '1.14.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.warren.iwanttoheal',
|
||||
appName: 'I want to Heal',
|
||||
webDir: 'dist'
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,290 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dungeons (
|
||||
id INTEGER PRIMARY KEY,
|
||||
location_id INTEGER NOT NULL REFERENCES locations(id),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
recommended_level INTEGER NOT NULL DEFAULT 1,
|
||||
content_type TEXT NOT NULL DEFAULT 'dungeon' CHECK (content_type IN ('dungeon', 'raid')),
|
||||
party_size INTEGER NOT NULL DEFAULT 5,
|
||||
completion_item_level INTEGER,
|
||||
experience_reward INTEGER NOT NULL DEFAULT 100,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS difficulties (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
dropped_item_level INTEGER NOT NULL,
|
||||
unlock_level INTEGER NOT NULL DEFAULT 1,
|
||||
health_multiplier REAL NOT NULL DEFAULT 1,
|
||||
damage_multiplier REAL NOT NULL DEFAULT 1,
|
||||
experience_multiplier REAL NOT NULL DEFAULT 1,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dungeon_difficulties (
|
||||
dungeon_id INTEGER NOT NULL REFERENCES dungeons(id) ON DELETE CASCADE,
|
||||
difficulty_id INTEGER NOT NULL REFERENCES difficulties(id),
|
||||
PRIMARY KEY (dungeon_id, difficulty_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS encounters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
dungeon_id INTEGER NOT NULL REFERENCES dungeons(id),
|
||||
sequence INTEGER NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
encounter_type TEXT NOT NULL CHECK (encounter_type IN ('trash', 'boss')),
|
||||
max_health INTEGER NOT NULL,
|
||||
base_damage INTEGER NOT NULL,
|
||||
tank_damage INTEGER NOT NULL,
|
||||
party_damage INTEGER NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image_url TEXT NOT NULL DEFAULT '/boss-placeholder.svg',
|
||||
UNIQUE (dungeon_id, sequence)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mechanics (
|
||||
id INTEGER PRIMARY KEY,
|
||||
encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
mechanic_type TEXT NOT NULL,
|
||||
interval_seconds REAL,
|
||||
power INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
resource_name TEXT NOT NULL,
|
||||
max_resource INTEGER NOT NULL DEFAULT 100,
|
||||
theme_color TEXT NOT NULL DEFAULT '#e5b95f',
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spells (
|
||||
id INTEGER PRIMARY KEY,
|
||||
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
spell_type TEXT NOT NULL,
|
||||
resource_cost INTEGER NOT NULL,
|
||||
cooldown_seconds REAL NOT NULL DEFAULT 0,
|
||||
power INTEGER NOT NULL,
|
||||
unlock_level INTEGER NOT NULL DEFAULT 1,
|
||||
glyph TEXT NOT NULL DEFAULT '+',
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
slot TEXT NOT NULL CHECK (slot IN ('weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket', 'component')),
|
||||
rarity TEXT NOT NULL,
|
||||
item_level INTEGER NOT NULL,
|
||||
healing_power INTEGER NOT NULL DEFAULT 0,
|
||||
max_resource_bonus INTEGER NOT NULL DEFAULT 0,
|
||||
glyph TEXT NOT NULL DEFAULT '?',
|
||||
image_url TEXT NOT NULL DEFAULT '/equipment-placeholder.svg',
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS encounter_loot (
|
||||
encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
difficulty_id INTEGER REFERENCES difficulties(id),
|
||||
drop_weight INTEGER NOT NULL DEFAULT 100,
|
||||
drop_chance REAL NOT NULL DEFAULT 0.65 CHECK (drop_chance BETWEEN 0 AND 1),
|
||||
PRIMARY KEY (encounter_id, difficulty_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dungeon_completion_loot (
|
||||
dungeon_id INTEGER NOT NULL REFERENCES dungeons(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (dungeon_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crafting_recipes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE,
|
||||
difficulty_id INTEGER REFERENCES difficulties(id),
|
||||
source_dungeon_id INTEGER REFERENCES dungeons(id) ON DELETE CASCADE,
|
||||
source_encounter_id INTEGER REFERENCES encounters(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crafting_recipe_components (
|
||||
recipe_id INTEGER NOT NULL REFERENCES crafting_recipes(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
PRIMARY KEY (recipe_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_sets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_set_items (
|
||||
set_id INTEGER NOT NULL REFERENCES item_sets(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (set_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_set_bonuses (
|
||||
id INTEGER PRIMARY KEY,
|
||||
set_id INTEGER NOT NULL REFERENCES item_sets(id) ON DELETE CASCADE,
|
||||
required_pieces INTEGER NOT NULL CHECK (required_pieces > 0),
|
||||
effect_type TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
UNIQUE (set_id, required_pieces)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
password_salt TEXT NOT NULL,
|
||||
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
|
||||
completed_raid_phases INTEGER NOT NULL DEFAULT 0,
|
||||
created_ip TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_ip_allowances (
|
||||
ip_address TEXT PRIMARY KEY,
|
||||
max_accounts INTEGER NOT NULL CHECK (max_accounts >= 1),
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS characters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
class_id INTEGER NOT NULL REFERENCES classes(id),
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
experience INTEGER NOT NULL DEFAULT 0,
|
||||
talent_points INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (account_id, class_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
active_character_id INTEGER REFERENCES characters(id),
|
||||
expires_at TEXT NOT NULL,
|
||||
created_ip TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_token_hash_idx ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS character_ability_slots (
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
slot_number INTEGER NOT NULL CHECK (slot_number BETWEEN 1 AND 6),
|
||||
spell_id INTEGER REFERENCES spells(id),
|
||||
PRIMARY KEY (character_id, slot_number),
|
||||
UNIQUE (character_id, spell_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS talents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
max_rank INTEGER NOT NULL DEFAULT 1,
|
||||
tier INTEGER NOT NULL DEFAULT 1,
|
||||
branch INTEGER NOT NULL DEFAULT 1,
|
||||
prerequisite_talent_id INTEGER REFERENCES talents(id),
|
||||
prerequisite_rank INTEGER NOT NULL DEFAULT 0,
|
||||
effect_type TEXT NOT NULL DEFAULT 'placeholder',
|
||||
effect_value_per_rank REAL NOT NULL DEFAULT 0,
|
||||
glyph TEXT NOT NULL DEFAULT '+',
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS character_talents (
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
talent_id INTEGER NOT NULL REFERENCES talents(id) ON DELETE CASCADE,
|
||||
rank INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (character_id, talent_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS level_progression (
|
||||
level INTEGER PRIMARY KEY CHECK (level BETWEEN 1 AND 25),
|
||||
experience_required INTEGER NOT NULL,
|
||||
talent_points_total INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS game_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS character_inventory (
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
equipped INTEGER NOT NULL DEFAULT 0 CHECK (equipped IN (0, 1)),
|
||||
PRIMARY KEY (character_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS encounter_loot_rolls (
|
||||
id INTEGER PRIMARY KEY,
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
run_token TEXT NOT NULL,
|
||||
encounter_id INTEGER NOT NULL REFERENCES encounters(id),
|
||||
difficulty_id INTEGER NOT NULL REFERENCES difficulties(id),
|
||||
item_id INTEGER REFERENCES items(id),
|
||||
dropped INTEGER NOT NULL CHECK (dropped IN (0, 1)),
|
||||
was_duplicate INTEGER NOT NULL DEFAULT 0 CHECK (was_duplicate IN (0, 1)),
|
||||
quantity_after INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (character_id, run_token, encounter_id, difficulty_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS encounter_loot_roll_items (
|
||||
roll_id INTEGER NOT NULL REFERENCES encounter_loot_rolls(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
was_duplicate INTEGER NOT NULL DEFAULT 0 CHECK (was_duplicate IN (0, 1)),
|
||||
quantity_after INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (roll_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dungeon_runs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id),
|
||||
dungeon_id INTEGER NOT NULL REFERENCES dungeons(id),
|
||||
difficulty_id INTEGER REFERENCES difficulties(id),
|
||||
result TEXT NOT NULL CHECK (result IN ('active', 'victory', 'defeat', 'abandoned')),
|
||||
character_name TEXT NOT NULL DEFAULT '',
|
||||
class_name TEXT NOT NULL DEFAULT '',
|
||||
character_level INTEGER NOT NULL DEFAULT 1,
|
||||
average_item_level REAL NOT NULL DEFAULT 1,
|
||||
resource_spent INTEGER NOT NULL DEFAULT 0,
|
||||
duration_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
leaderboard_eligible INTEGER NOT NULL DEFAULT 0 CHECK (leaderboard_eligible IN (0, 1)),
|
||||
start_part INTEGER NOT NULL DEFAULT 1,
|
||||
completed_parts INTEGER NOT NULL DEFAULT 1,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TEXT
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'android/app/build', 'android/build']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>testgame</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "ashen-halls",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "npm run db:init",
|
||||
"dev": "vite",
|
||||
"build": "npx tsc -b --noEmit && npx vite build && node scripts/generate-service-worker.mjs",
|
||||
"android:sync": "npm run build && cap sync android",
|
||||
"android:open": "cap open android",
|
||||
"android:apk": "npm run android:sync && cd android && ./gradlew assembleDebug",
|
||||
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
|
||||
"db:backup": "node scripts/backup-db.mjs",
|
||||
"db:init": "node scripts/init-db.mjs",
|
||||
"offline:export": "node scripts/export-offline-profile.mjs",
|
||||
"lint": "eslint .",
|
||||
"admin:start": "node server/admin.mjs",
|
||||
"start": "node server/production.mjs",
|
||||
"prepreview": "npm run db:init",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.4.0",
|
||||
"@capacitor/core": "^8.4.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.4.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Boss placeholder</title>
|
||||
<desc id="desc">A placeholder boss icon with a monster silhouette.</desc>
|
||||
<rect width="160" height="160" fill="#481e29"/>
|
||||
<path fill="#1a0b10" d="M28 118h104v14H28z"/>
|
||||
<path fill="#ef6b7a" d="M44 74c0-26 15-42 36-42s36 16 36 42v12c0 22-15 38-36 38S44 108 44 86z"/>
|
||||
<path fill="#f0cb79" d="M40 55 18 31l32 8zm80 0 22-24-32 8z"/>
|
||||
<path fill="#1a0b10" d="M58 77h17v13H58zm27 0h17v13H85zm-25 29h40v9H60z"/>
|
||||
<path fill="#ffffff" opacity=".35" d="M55 47c8-10 20-14 33-11-18 3-29 12-35 27-3-5-3-10 2-16z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 669 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Equipment placeholder</title>
|
||||
<desc id="desc">A placeholder equipment icon with an item silhouette.</desc>
|
||||
<rect width="160" height="160" fill="#1c1e25"/>
|
||||
<path fill="#090a0d" d="M30 128h100v12H30z"/>
|
||||
<path fill="#e5b95f" d="M80 20 116 50v50l-36 28-36-28V50z"/>
|
||||
<path fill="#15161c" d="M58 58h44v12H58zm0 22h44v12H58zm12 22h20v10H70z"/>
|
||||
<path fill="#fff" opacity=".28" d="M55 50 80 30l25 20-25 13z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 543 B |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,20 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
const sourcePath = resolve('data/game.db')
|
||||
const backupDirectory = resolve('backups')
|
||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')
|
||||
const backupPath = resolve(backupDirectory, `game-${timestamp}.db`)
|
||||
|
||||
mkdirSync(backupDirectory, { recursive: true })
|
||||
|
||||
const database = new DatabaseSync(sourcePath)
|
||||
|
||||
try {
|
||||
const escapedPath = backupPath.replaceAll("'", "''")
|
||||
database.exec(`VACUUM INTO '${escapedPath}'`)
|
||||
console.log(`SQLite backup created: ${backupPath}`)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { getProfile } from '../server/game-api.mjs'
|
||||
|
||||
const database = new DatabaseSync(':memory:')
|
||||
|
||||
try {
|
||||
database.exec(readFileSync('db/schema.sql', 'utf8'))
|
||||
database.exec(readFileSync('db/seed.sql', 'utf8'))
|
||||
const profile = getProfile(database, 1)
|
||||
writeFileSync(
|
||||
'src/offline-starter-profile.json',
|
||||
`${JSON.stringify(profile, null, 2)}\n`,
|
||||
)
|
||||
console.log('Offline starter profile exported from SQLite.')
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readdirSync, writeFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
|
||||
const distDirectory = resolve('dist')
|
||||
|
||||
function listFiles(directory) {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = resolve(directory, entry.name)
|
||||
return entry.isDirectory() ? listFiles(path) : [path]
|
||||
})
|
||||
}
|
||||
|
||||
const assets = listFiles(distDirectory)
|
||||
.map((path) => `/${relative(distDirectory, path).replaceAll('\\', '/')}`)
|
||||
.filter((path) => path !== '/service-worker.js')
|
||||
.sort()
|
||||
const cacheName = `chronicle-${Date.now()}`
|
||||
const source = `const CACHE_NAME = ${JSON.stringify(cacheName)}
|
||||
const APP_ASSETS = ${JSON.stringify(assets, null, 2)}
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(APP_ASSETS))
|
||||
.then(() => self.skipWaiting()),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(
|
||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)),
|
||||
))
|
||||
.then(() => self.clients.claim()),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const requestUrl = new URL(event.request.url)
|
||||
if (
|
||||
event.request.method !== 'GET'
|
||||
|| requestUrl.origin !== self.location.origin
|
||||
|| requestUrl.pathname.startsWith('/api/')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) return cached
|
||||
return fetch(event.request).catch(() => (
|
||||
event.request.mode === 'navigate'
|
||||
? caches.match('/index.html')
|
||||
: Response.error()
|
||||
))
|
||||
}),
|
||||
)
|
||||
})
|
||||
`
|
||||
|
||||
writeFileSync(resolve(distDirectory, 'service-worker.js'), source)
|
||||
console.log(`Offline app shell generated with ${assets.length} files.`)
|
||||
@@ -0,0 +1,262 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
mkdirSync('data', { recursive: true })
|
||||
|
||||
const database = new DatabaseSync('data/game.db')
|
||||
const schema = await readFile(new URL('../db/schema.sql', import.meta.url), 'utf8')
|
||||
const seed = await readFile(new URL('../db/seed.sql', import.meta.url), 'utf8')
|
||||
|
||||
database.exec(schema)
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS crafting_recipes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE,
|
||||
difficulty_id INTEGER REFERENCES difficulties(id),
|
||||
source_dungeon_id INTEGER REFERENCES dungeons(id) ON DELETE CASCADE,
|
||||
source_encounter_id INTEGER REFERENCES encounters(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crafting_recipe_components (
|
||||
recipe_id INTEGER NOT NULL REFERENCES crafting_recipes(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
PRIMARY KEY (recipe_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_sets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_set_items (
|
||||
set_id INTEGER NOT NULL REFERENCES item_sets(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (set_id, item_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_set_bonuses (
|
||||
id INTEGER PRIMARY KEY,
|
||||
set_id INTEGER NOT NULL REFERENCES item_sets(id) ON DELETE CASCADE,
|
||||
required_pieces INTEGER NOT NULL CHECK (required_pieces > 0),
|
||||
effect_type TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
UNIQUE (set_id, required_pieces)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS encounter_loot_roll_items (
|
||||
roll_id INTEGER NOT NULL REFERENCES encounter_loot_rolls(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
was_duplicate INTEGER NOT NULL DEFAULT 0 CHECK (was_duplicate IN (0, 1)),
|
||||
quantity_after INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (roll_id, item_id)
|
||||
);
|
||||
`)
|
||||
|
||||
function addColumnIfMissing(table, column, definition) {
|
||||
const columns = database.prepare(`PRAGMA table_info(${table})`).all()
|
||||
if (!columns.some((candidate) => candidate.name === column)) {
|
||||
database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
|
||||
}
|
||||
}
|
||||
|
||||
function migrateCharacterAccountConstraint() {
|
||||
const tableSql = database.prepare(`
|
||||
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'characters'
|
||||
`).get()?.sql ?? ''
|
||||
const hasLegacyAccountUnique = /account_id\s+INTEGER\s+UNIQUE/i.test(tableSql)
|
||||
if (!hasLegacyAccountUnique) return
|
||||
|
||||
const columns = database.prepare('PRAGMA table_info(characters)').all()
|
||||
const hasCompletedDungeonParts = columns.some(
|
||||
(candidate) => candidate.name === 'completed_dungeon_parts',
|
||||
)
|
||||
|
||||
database.exec('PRAGMA foreign_keys = OFF')
|
||||
database.exec('BEGIN')
|
||||
try {
|
||||
database.exec(`
|
||||
CREATE TABLE characters_migrated (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
class_id INTEGER NOT NULL REFERENCES classes(id),
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
experience INTEGER NOT NULL DEFAULT 0,
|
||||
talent_points INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (account_id, class_id)
|
||||
)
|
||||
`)
|
||||
database.exec(`
|
||||
INSERT INTO characters_migrated
|
||||
(id, account_id, class_id, name, level, experience, talent_points, created_at)
|
||||
SELECT
|
||||
id, account_id, class_id, name, level, experience, talent_points, created_at
|
||||
FROM characters
|
||||
`)
|
||||
if (hasCompletedDungeonParts) {
|
||||
database.exec(`
|
||||
UPDATE accounts
|
||||
SET completed_dungeon_parts = MAX(
|
||||
completed_dungeon_parts,
|
||||
COALESCE((
|
||||
SELECT MAX(completed_dungeon_parts)
|
||||
FROM characters
|
||||
WHERE characters.account_id = accounts.id
|
||||
), 0)
|
||||
)
|
||||
`)
|
||||
}
|
||||
database.exec('DROP TABLE characters')
|
||||
database.exec('ALTER TABLE characters_migrated RENAME TO characters')
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
} finally {
|
||||
database.exec('PRAGMA foreign_keys = ON')
|
||||
}
|
||||
}
|
||||
|
||||
function migrateItemSlotConstraint() {
|
||||
const tableSql = database.prepare(`
|
||||
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'items'
|
||||
`).get()?.sql ?? ''
|
||||
if (tableSql.includes("'pants'") && tableSql.includes("'necklace'")) return
|
||||
|
||||
database.exec('PRAGMA foreign_keys = OFF')
|
||||
database.exec('BEGIN')
|
||||
try {
|
||||
database.exec(`
|
||||
CREATE TABLE items_migrated (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
slot TEXT NOT NULL CHECK (slot IN ('weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket', 'component')),
|
||||
rarity TEXT NOT NULL,
|
||||
item_level INTEGER NOT NULL,
|
||||
healing_power INTEGER NOT NULL DEFAULT 0,
|
||||
max_resource_bonus INTEGER NOT NULL DEFAULT 0,
|
||||
glyph TEXT NOT NULL DEFAULT '?',
|
||||
image_url TEXT NOT NULL DEFAULT '/equipment-placeholder.svg',
|
||||
description TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
database.exec(`
|
||||
INSERT INTO items_migrated
|
||||
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, image_url, description)
|
||||
SELECT id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, image_url, description
|
||||
FROM items
|
||||
`)
|
||||
database.exec('DROP TABLE items')
|
||||
database.exec('ALTER TABLE items_migrated RENAME TO items')
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
} finally {
|
||||
database.exec('PRAGMA foreign_keys = ON')
|
||||
}
|
||||
}
|
||||
|
||||
function migrateEncounterLootPrimaryKey() {
|
||||
const tableSql = database.prepare(`
|
||||
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'encounter_loot'
|
||||
`).get()?.sql ?? ''
|
||||
if (/PRIMARY KEY\s*\(\s*encounter_id\s*,\s*difficulty_id\s*,\s*item_id\s*\)/i.test(tableSql)) return
|
||||
|
||||
database.exec('PRAGMA foreign_keys = OFF')
|
||||
database.exec('BEGIN')
|
||||
try {
|
||||
database.exec(`
|
||||
CREATE TABLE encounter_loot_migrated (
|
||||
encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
difficulty_id INTEGER REFERENCES difficulties(id),
|
||||
drop_weight INTEGER NOT NULL DEFAULT 100,
|
||||
drop_chance REAL NOT NULL DEFAULT 0.65 CHECK (drop_chance BETWEEN 0 AND 1),
|
||||
PRIMARY KEY (encounter_id, difficulty_id, item_id)
|
||||
)
|
||||
`)
|
||||
database.exec(`
|
||||
INSERT OR IGNORE INTO encounter_loot_migrated
|
||||
(encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
|
||||
SELECT encounter_id, item_id, difficulty_id, drop_weight, drop_chance
|
||||
FROM encounter_loot
|
||||
`)
|
||||
database.exec('DROP TABLE encounter_loot')
|
||||
database.exec('ALTER TABLE encounter_loot_migrated RENAME TO encounter_loot')
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
} finally {
|
||||
database.exec('PRAGMA foreign_keys = ON')
|
||||
}
|
||||
}
|
||||
|
||||
addColumnIfMissing('dungeons', 'content_type', "TEXT NOT NULL DEFAULT 'dungeon'")
|
||||
addColumnIfMissing('dungeons', 'party_size', 'INTEGER NOT NULL DEFAULT 5')
|
||||
addColumnIfMissing('dungeons', 'completion_item_level', 'INTEGER')
|
||||
addColumnIfMissing('dungeons', 'experience_reward', 'INTEGER NOT NULL DEFAULT 100')
|
||||
addColumnIfMissing('classes', 'theme_color', "TEXT NOT NULL DEFAULT '#e5b95f'")
|
||||
addColumnIfMissing('spells', 'unlock_level', 'INTEGER NOT NULL DEFAULT 1')
|
||||
addColumnIfMissing('spells', 'glyph', "TEXT NOT NULL DEFAULT '+'")
|
||||
addColumnIfMissing('encounter_loot', 'difficulty_id', 'INTEGER REFERENCES difficulties(id)')
|
||||
addColumnIfMissing('characters', 'talent_points', 'INTEGER NOT NULL DEFAULT 1')
|
||||
addColumnIfMissing('characters', 'account_id', 'INTEGER REFERENCES accounts(id)')
|
||||
addColumnIfMissing('characters', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
|
||||
addColumnIfMissing('talents', 'branch', 'INTEGER NOT NULL DEFAULT 1')
|
||||
addColumnIfMissing('talents', 'prerequisite_talent_id', 'INTEGER REFERENCES talents(id)')
|
||||
addColumnIfMissing('talents', 'prerequisite_rank', 'INTEGER NOT NULL DEFAULT 0')
|
||||
addColumnIfMissing('talents', 'effect_type', "TEXT NOT NULL DEFAULT 'placeholder'")
|
||||
addColumnIfMissing('talents', 'effect_value_per_rank', 'REAL NOT NULL DEFAULT 0')
|
||||
addColumnIfMissing('talents', 'glyph', "TEXT NOT NULL DEFAULT '+'")
|
||||
addColumnIfMissing('items', 'glyph', "TEXT NOT NULL DEFAULT '?'")
|
||||
addColumnIfMissing('items', 'image_url', "TEXT NOT NULL DEFAULT '/equipment-placeholder.svg'")
|
||||
addColumnIfMissing('difficulties', 'unlock_level', 'INTEGER NOT NULL DEFAULT 1')
|
||||
addColumnIfMissing('difficulties', 'health_multiplier', 'REAL NOT NULL DEFAULT 1')
|
||||
addColumnIfMissing('difficulties', 'damage_multiplier', 'REAL NOT NULL DEFAULT 1')
|
||||
addColumnIfMissing('difficulties', 'experience_multiplier', 'REAL NOT NULL DEFAULT 1')
|
||||
addColumnIfMissing('dungeon_runs', 'difficulty_id', 'INTEGER REFERENCES difficulties(id)')
|
||||
addColumnIfMissing('dungeon_runs', 'character_name', "TEXT NOT NULL DEFAULT ''")
|
||||
addColumnIfMissing('dungeon_runs', 'class_name', "TEXT NOT NULL DEFAULT ''")
|
||||
addColumnIfMissing('dungeon_runs', 'character_level', 'INTEGER NOT NULL DEFAULT 1')
|
||||
addColumnIfMissing('dungeon_runs', 'average_item_level', 'REAL NOT NULL DEFAULT 1')
|
||||
addColumnIfMissing('dungeon_runs', 'resource_spent', 'INTEGER NOT NULL DEFAULT 0')
|
||||
addColumnIfMissing('dungeon_runs', 'duration_seconds', 'INTEGER NOT NULL DEFAULT 0')
|
||||
addColumnIfMissing('dungeon_runs', 'leaderboard_eligible', 'INTEGER NOT NULL DEFAULT 0')
|
||||
addColumnIfMissing('dungeon_runs', 'start_part', 'INTEGER NOT NULL DEFAULT 1')
|
||||
addColumnIfMissing('dungeon_runs', 'completed_parts', 'INTEGER NOT NULL DEFAULT 1')
|
||||
addColumnIfMissing('encounter_loot', 'drop_chance', 'REAL NOT NULL DEFAULT 0.65')
|
||||
addColumnIfMissing('encounters', 'image_url', "TEXT NOT NULL DEFAULT '/boss-placeholder.svg'")
|
||||
|
||||
addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
|
||||
addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0')
|
||||
addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
|
||||
|
||||
migrateCharacterAccountConstraint()
|
||||
migrateItemSlotConstraint()
|
||||
migrateEncounterLootPrimaryKey()
|
||||
|
||||
addColumnIfMissing('items', 'image_url', "TEXT NOT NULL DEFAULT '/equipment-placeholder.svg'")
|
||||
|
||||
database.exec(seed)
|
||||
|
||||
const counts = database
|
||||
.prepare(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM classes) AS classes,
|
||||
(SELECT COUNT(*) FROM encounters) AS encounters,
|
||||
(SELECT COUNT(*) FROM items) AS items
|
||||
`)
|
||||
.get()
|
||||
|
||||
database.close()
|
||||
console.log(`Database ready: ${counts.classes} class, ${counts.encounters} encounters, ${counts.items} items.`)
|
||||
@@ -0,0 +1,78 @@
|
||||
import { isIP } from 'node:net'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
const [command, rawIp, rawLimit, ...noteParts] = process.argv.slice(2)
|
||||
const database = new DatabaseSync('data/game.db')
|
||||
|
||||
function normalizeIp(value) {
|
||||
const address = String(value ?? '').trim()
|
||||
if (address.startsWith('::ffff:') && isIP(address.slice(7)) === 4) {
|
||||
return address.slice(7)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
function requireIp(value) {
|
||||
const address = normalizeIp(value)
|
||||
if (!isIP(address)) {
|
||||
throw new Error('Provide a valid IPv4 or IPv6 address.')
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
try {
|
||||
if (command === 'set') {
|
||||
const ip = requireIp(rawIp)
|
||||
const maxAccounts = Number(rawLimit)
|
||||
if (!Number.isInteger(maxAccounts) || maxAccounts < 2 || maxAccounts > 100) {
|
||||
throw new Error('The allowed account count must be an integer from 2 to 100.')
|
||||
}
|
||||
const note = noteParts.join(' ').slice(0, 200)
|
||||
database.prepare(`
|
||||
INSERT INTO account_ip_allowances
|
||||
(ip_address, max_accounts, note, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(ip_address) DO UPDATE SET
|
||||
max_accounts = excluded.max_accounts,
|
||||
note = excluded.note,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`).run(ip, maxAccounts, note)
|
||||
console.log(`${ip} may create up to ${maxAccounts} accounts${note ? ` (${note})` : ''}.`)
|
||||
} else if (command === 'remove') {
|
||||
const ip = requireIp(rawIp)
|
||||
const result = database.prepare(`
|
||||
DELETE FROM account_ip_allowances WHERE ip_address = ?
|
||||
`).run(ip)
|
||||
console.log(
|
||||
result.changes
|
||||
? `${ip} returned to the default one-account limit.`
|
||||
: `${ip} had no custom allowance.`,
|
||||
)
|
||||
} else if (command === 'list') {
|
||||
const rows = database.prepare(`
|
||||
SELECT
|
||||
account_ip_allowances.ip_address AS ip,
|
||||
account_ip_allowances.max_accounts AS maxAccounts,
|
||||
account_ip_allowances.note,
|
||||
account_ip_allowances.updated_at AS updatedAt,
|
||||
COUNT(accounts.id) AS existingAccounts
|
||||
FROM account_ip_allowances
|
||||
LEFT JOIN accounts
|
||||
ON accounts.created_ip = account_ip_allowances.ip_address
|
||||
GROUP BY account_ip_allowances.ip_address
|
||||
ORDER BY account_ip_allowances.updated_at DESC
|
||||
`).all()
|
||||
console.table(rows)
|
||||
} else {
|
||||
console.log('Usage:')
|
||||
console.log(' npm run accounts:ip -- set <ip> <max-accounts> [note]')
|
||||
console.log(' npm run accounts:ip -- remove <ip>')
|
||||
console.log(' npm run accounts:ip -- list')
|
||||
process.exitCode = command ? 1 : 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { extname, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
const host = '127.0.0.1'
|
||||
const port = Number(process.env.ADMIN_PORT ?? 4174)
|
||||
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
|
||||
const distPath = fileURLToPath(new URL('../dist', import.meta.url))
|
||||
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
|
||||
const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', import.meta.url))
|
||||
|
||||
const bossImageContentTypes = {
|
||||
'.gif': 'image/gif',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
|
||||
function sendJson(response, status, body) {
|
||||
response.statusCode = status
|
||||
response.setHeader('Content-Type', 'application/json')
|
||||
response.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
async function readJson(request, maxSize = 16 * 1024) {
|
||||
const chunks = []
|
||||
let size = 0
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length
|
||||
if (size > maxSize) {
|
||||
const error = new Error('Request body is too large.')
|
||||
error.status = 413
|
||||
throw error
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
}
|
||||
|
||||
function sendBossImage(request, response) {
|
||||
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
|
||||
const filename = pathname.replace('/api/boss-images/', '')
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(filename)) {
|
||||
sendJson(response, 404, { error: 'Image not found.' })
|
||||
return
|
||||
}
|
||||
const imagePath = resolve(bossImageDirectory, filename)
|
||||
const insideDirectory = imagePath.startsWith(resolve(bossImageDirectory) + sep)
|
||||
const extension = extname(imagePath).toLowerCase()
|
||||
if (!insideDirectory || !bossImageContentTypes[extension] || !existsSync(imagePath) || !statSync(imagePath).isFile()) {
|
||||
sendJson(response, 404, { error: 'Image not found.' })
|
||||
return
|
||||
}
|
||||
response.statusCode = 200
|
||||
response.setHeader('Content-Type', bossImageContentTypes[extension])
|
||||
response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
createReadStream(imagePath).pipe(response)
|
||||
}
|
||||
|
||||
function sendItemImage(request, response) {
|
||||
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
|
||||
const filename = pathname.replace('/api/item-images/', '')
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(filename)) {
|
||||
sendJson(response, 404, { error: 'Image not found.' })
|
||||
return
|
||||
}
|
||||
const imagePath = resolve(itemImageDirectory, filename)
|
||||
const insideDirectory = imagePath.startsWith(resolve(itemImageDirectory) + sep)
|
||||
const extension = extname(imagePath).toLowerCase()
|
||||
if (!insideDirectory || !bossImageContentTypes[extension] || !existsSync(imagePath) || !statSync(imagePath).isFile()) {
|
||||
sendJson(response, 404, { error: 'Image not found.' })
|
||||
return
|
||||
}
|
||||
response.statusCode = 200
|
||||
response.setHeader('Content-Type', bossImageContentTypes[extension])
|
||||
response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
createReadStream(imagePath).pipe(response)
|
||||
}
|
||||
|
||||
function saveBossImage(database, encounterId, payload) {
|
||||
const encounter = database.prepare(`
|
||||
SELECT id, slug, encounter_type AS encounterType
|
||||
FROM encounters WHERE id = ?
|
||||
`).get(encounterId)
|
||||
if (!encounter || encounter.encounterType !== 'boss') {
|
||||
throw new Error('Boss encounter not found.')
|
||||
}
|
||||
const dataUrl = String(payload.imageData ?? '')
|
||||
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/)
|
||||
if (!match) throw new Error('Upload a PNG, JPG, WebP, or GIF image.')
|
||||
const extensionByType = { 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }
|
||||
const bytes = Buffer.from(match[2], 'base64')
|
||||
if (bytes.length === 0 || bytes.length > 4 * 1024 * 1024) {
|
||||
throw new Error('Boss image must be 1 byte to 4 MB.')
|
||||
}
|
||||
mkdirSync(bossImageDirectory, { recursive: true })
|
||||
const filename = `${encounter.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}`
|
||||
writeFileSync(resolve(bossImageDirectory, filename), bytes, { mode: 0o644 })
|
||||
const imageUrl = `/api/boss-images/${filename}`
|
||||
database.prepare(`UPDATE encounters SET image_url = ? WHERE id = ?`).run(imageUrl, encounterId)
|
||||
return imageUrl
|
||||
}
|
||||
|
||||
function saveItemImage(database, itemId, payload) {
|
||||
const item = database.prepare(`SELECT id, slug, slot FROM items WHERE id = ?`).get(itemId)
|
||||
if (!item || item.slot === 'component') throw new Error('Equipment item not found.')
|
||||
const dataUrl = String(payload.imageData ?? '')
|
||||
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/)
|
||||
if (!match) throw new Error('Upload a PNG, JPG, WebP, or GIF image.')
|
||||
const extensionByType = { 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }
|
||||
const bytes = Buffer.from(match[2], 'base64')
|
||||
if (bytes.length === 0 || bytes.length > 4 * 1024 * 1024) {
|
||||
throw new Error('Equipment image must be 1 byte to 4 MB.')
|
||||
}
|
||||
mkdirSync(itemImageDirectory, { recursive: true })
|
||||
const filename = `${item.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}`
|
||||
writeFileSync(resolve(itemImageDirectory, filename), bytes, { mode: 0o644 })
|
||||
const imageUrl = `/api/item-images/${filename}`
|
||||
database.prepare(`UPDATE items SET image_url = ? WHERE id = ?`).run(imageUrl, itemId)
|
||||
return imageUrl
|
||||
}
|
||||
|
||||
function sendFile(response, filePath) {
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
response.statusCode = 200
|
||||
response.setHeader('Content-Type', contentTypes[extname(filePath).toLowerCase()] ?? 'application/octet-stream')
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
response.setHeader('Referrer-Policy', 'same-origin')
|
||||
response.setHeader('X-Frame-Options', 'DENY')
|
||||
createReadStream(filePath).pipe(response)
|
||||
}
|
||||
|
||||
function serveStatic(request, response) {
|
||||
const requestPath = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
|
||||
const candidate = resolve(distPath, `.${requestPath}`)
|
||||
const insideDist = candidate === distPath || candidate.startsWith(`${distPath}${sep}`)
|
||||
if (insideDist && existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
sendFile(response, candidate)
|
||||
return
|
||||
}
|
||||
const adminIndexPath = resolve(distPath, 'admin.html')
|
||||
if (!existsSync(adminIndexPath)) {
|
||||
response.statusCode = 503
|
||||
response.end('Admin build missing. Run npm run build.')
|
||||
return
|
||||
}
|
||||
sendFile(response, adminIndexPath)
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
if (!request.url?.startsWith('/api/')) {
|
||||
serveStatic(request, response)
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') {
|
||||
sendBossImage(request, response)
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url.startsWith('/api/item-images/') && request.method === 'GET') {
|
||||
sendItemImage(request, response)
|
||||
return
|
||||
}
|
||||
|
||||
if (!existsSync(databasePath)) {
|
||||
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
|
||||
return
|
||||
}
|
||||
|
||||
const database = new DatabaseSync(databasePath)
|
||||
database.exec('PRAGMA foreign_keys = ON')
|
||||
database.exec('PRAGMA journal_mode = WAL')
|
||||
database.exec('PRAGMA busy_timeout = 5000')
|
||||
|
||||
try {
|
||||
if (request.url === '/api/admin/data' && request.method === 'GET') {
|
||||
const items = database.prepare(`
|
||||
SELECT id, slug, name, slot, rarity, item_level AS itemLevel,
|
||||
healing_power AS healingPower, max_resource_bonus AS maxResourceBonus,
|
||||
glyph, image_url AS imageUrl, description
|
||||
FROM items ORDER BY slot, item_level, id
|
||||
`).all()
|
||||
const encounters = database.prepare(`
|
||||
SELECT id, dungeon_id AS dungeonId, sequence, slug, name AS enemyName,
|
||||
encounter_type AS encounterType, image_url AS imageUrl
|
||||
FROM encounters ORDER BY dungeon_id, sequence
|
||||
`).all()
|
||||
const difficulties = database.prepare(`
|
||||
SELECT id, slug, name, dropped_item_level AS droppedItemLevel
|
||||
FROM difficulties ORDER BY id
|
||||
`).all()
|
||||
const encounterLoot = database.prepare(`
|
||||
SELECT encounter_id AS encounterId, item_id AS itemId,
|
||||
difficulty_id AS difficultyId, drop_weight AS dropWeight, drop_chance AS dropChance
|
||||
FROM encounter_loot ORDER BY encounter_id, difficulty_id, item_id
|
||||
`).all()
|
||||
const craftingRecipes = database.prepare(`
|
||||
SELECT cr.id, cr.item_id AS itemId, cr.difficulty_id AS difficultyId,
|
||||
cr.source_dungeon_id AS sourceDungeonId,
|
||||
cr.source_encounter_id AS sourceEncounterId,
|
||||
crc.item_id AS componentId, crc.quantity
|
||||
FROM crafting_recipes cr
|
||||
LEFT JOIN crafting_recipe_components crc ON crc.recipe_id = cr.id
|
||||
ORDER BY cr.id, crc.item_id
|
||||
`).all()
|
||||
const recipes = new Map()
|
||||
for (const row of craftingRecipes) {
|
||||
if (!recipes.has(row.id)) {
|
||||
recipes.set(row.id, {
|
||||
id: row.id, itemId: row.itemId, difficultyId: row.difficultyId,
|
||||
sourceDungeonId: row.sourceDungeonId, sourceEncounterId: row.sourceEncounterId,
|
||||
components: [],
|
||||
})
|
||||
}
|
||||
if (row.componentId) {
|
||||
recipes.get(row.id).components.push({ itemId: row.componentId, quantity: row.quantity })
|
||||
}
|
||||
}
|
||||
const dungeons = database.prepare(`SELECT id, slug, name FROM dungeons ORDER BY id`).all()
|
||||
sendJson(response, 200, { items, encounters, difficulties, encounterLoot, craftingRecipes: [...recipes.values()], dungeons })
|
||||
return
|
||||
}
|
||||
|
||||
const bossImageMatch = request.url.match(/^\/api\/admin\/encounters\/(\d+)\/image$/)
|
||||
if (bossImageMatch && request.method === 'PUT') {
|
||||
const payload = await readJson(request, 6 * 1024 * 1024)
|
||||
const imageUrl = saveBossImage(database, Number(bossImageMatch[1]), payload)
|
||||
sendJson(response, 200, { ok: true, imageUrl })
|
||||
return
|
||||
}
|
||||
|
||||
const itemImageMatch = request.url.match(/^\/api\/admin\/items\/(\d+)\/image$/)
|
||||
if (itemImageMatch && request.method === 'PUT') {
|
||||
const payload = await readJson(request, 6 * 1024 * 1024)
|
||||
const imageUrl = saveItemImage(database, Number(itemImageMatch[1]), payload)
|
||||
sendJson(response, 200, { ok: true, imageUrl })
|
||||
return
|
||||
}
|
||||
|
||||
const itemMatch = request.url.match(/^\/api\/admin\/items\/(\d+)$/)
|
||||
if (itemMatch && request.method === 'PUT') {
|
||||
const payload = await readJson(request)
|
||||
const itemId = Number(itemMatch[1])
|
||||
const fields = []
|
||||
const values = []
|
||||
for (const key of ['name', 'slug', 'slot', 'rarity', 'glyph', 'description']) {
|
||||
if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(payload[key]) }
|
||||
}
|
||||
for (const key of ['item_level', 'healing_power', 'max_resource_bonus']) {
|
||||
if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(Number(payload[key])) }
|
||||
}
|
||||
if (fields.length === 0) { sendJson(response, 400, { error: 'No fields to update.' }); return }
|
||||
values.push(itemId)
|
||||
database.prepare(`UPDATE items SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||
sendJson(response, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url === '/api/admin/encounter-loot' && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
database.prepare(`
|
||||
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(encounter_id, difficulty_id, item_id)
|
||||
DO UPDATE SET drop_weight = excluded.drop_weight, drop_chance = excluded.drop_chance
|
||||
`).run(payload.encounterId, payload.itemId, payload.difficultyId, payload.dropWeight ?? 100, payload.dropChance ?? 0.65)
|
||||
sendJson(response, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
const lootDelete = request.url.match(/^\/api\/admin\/encounter-loot\/(\d+)\/(\d+)\/(\d+)$/)
|
||||
if (lootDelete && request.method === 'DELETE') {
|
||||
database.prepare(`DELETE FROM encounter_loot WHERE encounter_id = ? AND difficulty_id = ? AND item_id = ?`)
|
||||
.run(Number(lootDelete[1]), Number(lootDelete[2]), Number(lootDelete[3]))
|
||||
sendJson(response, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
const recipeComponents = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components$/)
|
||||
if (recipeComponents && request.method === 'POST') {
|
||||
const payload = await readJson(request)
|
||||
database.prepare(`
|
||||
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(recipe_id, item_id)
|
||||
DO UPDATE SET quantity = excluded.quantity
|
||||
`).run(Number(recipeComponents[1]), payload.itemId, payload.quantity)
|
||||
sendJson(response, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
const recipeComponentDelete = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components\/(\d+)$/)
|
||||
if (recipeComponentDelete && request.method === 'DELETE') {
|
||||
database.prepare(`DELETE FROM crafting_recipe_components WHERE recipe_id = ? AND item_id = ?`)
|
||||
.run(Number(recipeComponentDelete[1]), Number(recipeComponentDelete[2]))
|
||||
sendJson(response, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
sendJson(response, 404, { error: 'Admin API route not found.' })
|
||||
} catch (error) {
|
||||
const status = Number(error?.status) || 400
|
||||
sendJson(response, status, { error: error instanceof Error ? error.message : 'Unable to process request.' })
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
} catch (error) {
|
||||
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Server error.' })
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`Admin panel listening on http://${host}:${port}`)
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
export function gameApiPlugin(): Plugin
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { extname, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { handleApiRequest } from './game-api.mjs'
|
||||
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
const distPath = fileURLToPath(new URL('../dist', import.meta.url))
|
||||
const indexPath = resolve(distPath, 'index.html')
|
||||
const host = process.env.HOST ?? '127.0.0.1'
|
||||
const port = Number(process.env.PORT ?? 4173)
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
|
||||
function sendFile(response, filePath) {
|
||||
response.statusCode = 200
|
||||
response.setHeader(
|
||||
'Content-Type',
|
||||
contentTypes[extname(filePath).toLowerCase()] ?? 'application/octet-stream',
|
||||
)
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
response.setHeader('Referrer-Policy', 'same-origin')
|
||||
response.setHeader('X-Frame-Options', 'DENY')
|
||||
createReadStream(filePath).pipe(response)
|
||||
}
|
||||
|
||||
async function serveStatic(request, response) {
|
||||
const requestPath = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
|
||||
const candidate = resolve(distPath, `.${requestPath}`)
|
||||
const insideDist = candidate === distPath || candidate.startsWith(`${distPath}${sep}`)
|
||||
|
||||
if (requestPath === '/admin.html') {
|
||||
response.statusCode = 404
|
||||
response.end('Not found')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
insideDist
|
||||
&& existsSync(candidate)
|
||||
&& statSync(candidate).isFile()
|
||||
) {
|
||||
sendFile(response, candidate)
|
||||
return
|
||||
}
|
||||
|
||||
if (!existsSync(indexPath)) {
|
||||
response.statusCode = 503
|
||||
response.end('Build missing. Run npm run build.')
|
||||
return
|
||||
}
|
||||
sendFile(response, indexPath)
|
||||
}
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
handleApiRequest(request, response, () => {
|
||||
serveStatic(request, response).catch(() => {
|
||||
response.statusCode = 500
|
||||
response.end('Unable to serve the application.')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`I want to Heal listening on http://${host}:${port}`)
|
||||
})
|
||||
@@ -0,0 +1,795 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import './App.css'
|
||||
import { CombatScreen } from './components/CombatScreen'
|
||||
import { AuthScreen } from './components/AuthScreen'
|
||||
import { CustomizeScreen } from './components/CustomizeScreen'
|
||||
import { EquipmentScreen } from './components/EquipmentScreen'
|
||||
import { PvPRoguelikeScreen } from './components/PvpRoguelikeScreen'
|
||||
import { TalentScreen } from './components/TalentScreen'
|
||||
import { SettingsScreen } from './components/SettingsScreen'
|
||||
import {
|
||||
loadCpuPvpLeaderboard,
|
||||
type CpuPvpLeaderboardEntry,
|
||||
type PvpContentType,
|
||||
} from './pvpRoguelike'
|
||||
import {
|
||||
loadAuthSession,
|
||||
logoutAccount,
|
||||
type Account,
|
||||
type AuthSession,
|
||||
type CharacterProfile,
|
||||
} from './profile'
|
||||
import { getGameMode, type GameMode } from './gameRepository'
|
||||
import { focusFirstControl } from './input.tsx'
|
||||
|
||||
type Screen =
|
||||
| 'menu'
|
||||
| 'dungeons'
|
||||
| 'combat'
|
||||
| 'raids'
|
||||
| 'roguelike'
|
||||
| 'pvp'
|
||||
| 'customize'
|
||||
| 'equipment'
|
||||
| 'talents'
|
||||
| 'settings'
|
||||
|
||||
const MENU_ITEMS: Array<{
|
||||
screen: Screen
|
||||
label: string
|
||||
glyph: string
|
||||
description: string
|
||||
}> = [
|
||||
{ screen: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Guide a five-player party through dangerous encounters.' },
|
||||
{ screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide a ten-player party through three-phase challenges.' },
|
||||
{ screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' },
|
||||
{ screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' },
|
||||
{ screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' },
|
||||
{ screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' },
|
||||
]
|
||||
|
||||
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
||||
|
||||
function activityInitials(name: string) {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter((word) => /^[A-Za-z0-9]/.test(word))
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0].toUpperCase())
|
||||
.join('')
|
||||
}
|
||||
|
||||
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
||||
type RoguelikeVariant = 'pve' | 'pvp'
|
||||
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
|
||||
|
||||
function App() {
|
||||
const [screen, setScreen] = useState<Screen>('menu')
|
||||
const [account, setAccount] = useState<Account | null>(null)
|
||||
const [profile, setProfile] = useState<CharacterProfile | null>(null)
|
||||
const [authChecked, setAuthChecked] = useState(false)
|
||||
const [gameMode, setGameMode] = useState<GameMode>(getGameMode())
|
||||
const [serverMessage, setServerMessage] = useState('')
|
||||
const [selectedDifficultyId, setSelectedDifficultyId] = useState(() => {
|
||||
const saved = Number(window.localStorage.getItem(LAST_DIFFICULTY_KEY))
|
||||
return Number.isFinite(saved) && saved > 0 ? saved : 1
|
||||
})
|
||||
const [selectedDungeonId, setSelectedDungeonId] = useState(1)
|
||||
const [selectedRaidId, setSelectedRaidId] = useState(2)
|
||||
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
|
||||
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
|
||||
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
|
||||
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
|
||||
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
|
||||
const [selectedPart, setSelectedPart] = useState(1)
|
||||
const [combatContentId, setCombatContentId] = useState(1)
|
||||
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
|
||||
const [showLoot, setShowLoot] = useState(false)
|
||||
const [lootSort, setLootSort] = useState<'sequence' | 'boss'>('sequence')
|
||||
const [showLeaderboard, setShowLeaderboard] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadAuthSession()
|
||||
.then((session) => {
|
||||
setAccount(session.account)
|
||||
setProfile(session.profile)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setServerMessage(
|
||||
reason instanceof Error
|
||||
? `${reason.message} Offline play is still available.`
|
||||
: 'Unable to reach the server. Offline play is still available.',
|
||||
)
|
||||
})
|
||||
.finally(() => setAuthChecked(true))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (screen === 'combat') return
|
||||
window.requestAnimationFrame(() => {
|
||||
focusFirstControl()
|
||||
})
|
||||
}, [screen])
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(LAST_DIFFICULTY_KEY, String(selectedDifficultyId))
|
||||
}, [selectedDifficultyId])
|
||||
|
||||
const [cpuLeaderboard, setCpuLeaderboard] = useState<CpuPvpLeaderboardEntry[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
|
||||
}, [pvpContentType, screen, roguelikeVariant])
|
||||
|
||||
function acceptSession(session: AuthSession) {
|
||||
setAccount(session.account)
|
||||
setProfile(session.profile)
|
||||
setGameMode(getGameMode())
|
||||
setScreen('menu')
|
||||
setError('')
|
||||
setServerMessage('')
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await logoutAccount()
|
||||
setAccount(null)
|
||||
setProfile(null)
|
||||
setGameMode(getGameMode())
|
||||
setScreen('menu')
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="game-shell">
|
||||
<section className="message-panel">
|
||||
<p className="eyebrow">Database Error</p>
|
||||
<h1>Character Unavailable</h1>
|
||||
<p>{error}</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!authChecked) {
|
||||
return (
|
||||
<main className="game-shell">
|
||||
<section className="message-panel">
|
||||
<p className="eyebrow">Opening Chronicle</p>
|
||||
<h1>Loading...</h1>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!account || !profile) {
|
||||
return (
|
||||
<AuthScreen
|
||||
onAuthenticated={acceptSession}
|
||||
serverMessage={serverMessage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'combat') {
|
||||
const dungeon = combatContentId < 0
|
||||
? profile.dungeons.find((candidate) => candidate.contentType === roguelikeKind) ?? profile.dungeons[0]
|
||||
: profile.dungeons.find((candidate) => candidate.id === combatContentId) ?? profile.dungeons[0]
|
||||
const difficulty = dungeon.difficulties.find(
|
||||
(candidate) => candidate.id === selectedDifficultyId,
|
||||
) ?? dungeon.difficulties[0]
|
||||
const roguelikePool = profile.dungeons
|
||||
.filter((candidate) => candidate.contentType === roguelikeKind)
|
||||
.flatMap((candidate) => candidate.encounters)
|
||||
const startPart = selectedPart
|
||||
return (
|
||||
<CombatScreen
|
||||
difficulty={difficulty}
|
||||
dungeon={dungeon}
|
||||
profile={profile}
|
||||
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
||||
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
||||
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
|
||||
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
|
||||
startPart={startPart}
|
||||
onExit={() => {
|
||||
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
|
||||
}}
|
||||
onProfileUpdated={setProfile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'pvp') {
|
||||
const pvpPool = profile.dungeons
|
||||
.filter((candidate) => candidate.contentType === pvpContentType)
|
||||
.flatMap((candidate) => candidate.encounters)
|
||||
return (
|
||||
<PvPRoguelikeScreen
|
||||
contentType={pvpContentType}
|
||||
encounterPool={pvpPool}
|
||||
gameMode={gameMode}
|
||||
onExit={() => {
|
||||
setCpuLeaderboard(loadCpuPvpLeaderboard(pvpContentType))
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
}}
|
||||
onProfileUpdated={setProfile}
|
||||
profile={profile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const levelStart = profile.character.currentLevelExperience
|
||||
const levelEnd = profile.character.nextLevelExperience
|
||||
const experienceIntoLevel = profile.character.experience - levelStart
|
||||
const experienceForLevel = Math.max(1, levelEnd - levelStart)
|
||||
const experiencePercent = profile.character.level >= profile.maxLevel
|
||||
? 100
|
||||
: Math.min(100, (experienceIntoLevel / experienceForLevel) * 100)
|
||||
const dungeonOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'dungeon')
|
||||
const raidOptions = profile.dungeons.filter((candidate) => candidate.contentType === 'raid')
|
||||
const dungeon = dungeonOptions.find((candidate) => candidate.id === selectedDungeonId)
|
||||
?? dungeonOptions[0]!
|
||||
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
||||
?? raidOptions[0]
|
||||
const activity = screen === 'raids' && raid ? raid : dungeon
|
||||
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
||||
const selectedDifficulty = activity.difficulties.find(
|
||||
(candidate) => candidate.id === selectedDifficultyId,
|
||||
) ?? activity.difficulties[0]
|
||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||
const completedSections = activity.contentType === 'raid'
|
||||
? profile.completedRaidPhases
|
||||
: profile.completedDungeonParts
|
||||
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
|
||||
const parts = [
|
||||
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true },
|
||||
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
|
||||
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
|
||||
]
|
||||
const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5
|
||||
const lootPreviewEncounters = [...activity.encounters]
|
||||
.filter((encounter) => encounter.isBoss)
|
||||
.sort((a, b) => lootSort === 'boss'
|
||||
? a.enemyName.localeCompare(b.enemyName) || a.sequence - b.sequence
|
||||
: a.sequence - b.sequence)
|
||||
|
||||
return (
|
||||
<main className="game-shell">
|
||||
<header className="topbar app-header">
|
||||
<button
|
||||
className="brand-button"
|
||||
onClick={() => setScreen('menu')}
|
||||
type="button"
|
||||
>
|
||||
<strong>Menu</strong>
|
||||
</button>
|
||||
<div className="character-summary">
|
||||
<strong>{profile.character.name}</strong>
|
||||
<small>Level {profile.character.level}</small>
|
||||
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
|
||||
<div className="header-xp" title={`${profile.character.experience} total experience`}>
|
||||
<span style={{ width: `${experiencePercent}%` }} />
|
||||
</div>
|
||||
<button className="logout-button" onClick={signOut} type="button">
|
||||
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{screen === 'menu' && (
|
||||
<section className="menu-screen">
|
||||
<div className="main-menu-grid">
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<button
|
||||
className="menu-card"
|
||||
key={item.screen}
|
||||
onClick={() => {
|
||||
if (item.screen === 'pvp') {
|
||||
setRoguelikeVariant('pvp')
|
||||
setScreen('roguelike')
|
||||
return
|
||||
}
|
||||
setScreen(item.screen)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>{item.glyph}</span>
|
||||
<div>
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.description}</small>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{screen === 'roguelike' && (
|
||||
<section className="content-screen">
|
||||
<ScreenHeading
|
||||
eyebrow="Endless Draft"
|
||||
title="Roguelike"
|
||||
onBack={() => setScreen('menu')}
|
||||
/>
|
||||
<div className="roguelike-variant-row">
|
||||
<button
|
||||
className={`text-button ${roguelikeVariant === 'pve' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeVariant('pve')}
|
||||
type="button"
|
||||
>
|
||||
PvE
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${roguelikeVariant === 'pvp' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeVariant('pvp')}
|
||||
type="button"
|
||||
>
|
||||
PvP
|
||||
</button>
|
||||
</div>
|
||||
{roguelikeVariant === 'pve' && (
|
||||
<>
|
||||
<div className="roguelike-option-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Upgrade Timing</p>
|
||||
<h2>Buff Drafts</h2>
|
||||
</div>
|
||||
<div className="roguelike-timing-row">
|
||||
<button
|
||||
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeUpgradeTiming('encounter')}
|
||||
type="button"
|
||||
>
|
||||
Every Encounter
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeUpgradeTiming('boss')}
|
||||
type="button"
|
||||
>
|
||||
Boss Only
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="roguelike-option-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Upgrade Labels</p>
|
||||
<h2>Display Mode</h2>
|
||||
</div>
|
||||
<div className="roguelike-timing-row">
|
||||
<button
|
||||
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeAbilityLabelMode('ability')}
|
||||
type="button"
|
||||
>
|
||||
Ability Names
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeAbilityLabelMode('slot')}
|
||||
type="button"
|
||||
>
|
||||
Slot Names
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="roguelike-mode-grid">
|
||||
<button
|
||||
className="menu-card"
|
||||
onClick={() => {
|
||||
const baseDungeon = dungeonOptions[0]
|
||||
setRoguelikeKind('dungeon')
|
||||
setCombatContentId(-1)
|
||||
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
|
||||
setSelectedPart(1)
|
||||
setScreen('combat')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>D</span>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{roguelikeVariant === 'pvp' && (
|
||||
<>
|
||||
<div className="roguelike-option-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Match Type</p>
|
||||
<h2>PvP Roguelike</h2>
|
||||
</div>
|
||||
<div className="roguelike-timing-row">
|
||||
<button
|
||||
className={`text-button ${pvpContentType === 'dungeon' ? 'active' : ''}`}
|
||||
onClick={() => setPvpContentType('dungeon')}
|
||||
type="button"
|
||||
>
|
||||
Dungeon
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${pvpContentType === 'raid' ? 'active' : ''}`}
|
||||
onClick={() => setPvpContentType('raid')}
|
||||
type="button"
|
||||
>
|
||||
Raid
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="menu-card pvp-queue-panel">
|
||||
<span>{gameMode === 'offline' ? 'C' : 'Q'}</span>
|
||||
<div>
|
||||
<strong>{gameMode === 'offline' ? 'Offline CPU Match' : 'Queue Then CPU Fallback'}</strong>
|
||||
<small>
|
||||
{gameMode === 'offline'
|
||||
? 'Offline mode always places you against a random CPU 1-5.'
|
||||
: 'Online mode searches briefly. If nobody is queued, a random CPU 1-5 takes the slot.'}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="text-button"
|
||||
onClick={() => setScreen('pvp')}
|
||||
type="button"
|
||||
>
|
||||
Start Match
|
||||
</button>
|
||||
</div>
|
||||
<div className="leaderboard-section">
|
||||
<div className="equipment-heading toggle-heading">
|
||||
<div>
|
||||
<p className="eyebrow">CPU Leaderboard</p>
|
||||
<h2>{pvpContentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="leaderboard-table">
|
||||
<div className="leaderboard-header pvp-leaderboard-row">
|
||||
<strong>Rank</strong>
|
||||
<strong>Player</strong>
|
||||
<strong>CPU</strong>
|
||||
<strong>Clears</strong>
|
||||
<strong>Result</strong>
|
||||
</div>
|
||||
{cpuLeaderboard.map((entry, index) => (
|
||||
<div className="leaderboard-row pvp-leaderboard-row" key={`${entry.characterName}-${entry.completedAt}`}>
|
||||
<strong>#{index + 1}</strong>
|
||||
<span>{entry.characterName}</span>
|
||||
<span>CPU {entry.cpuDifficulty}</span>
|
||||
<span>{entry.encountersCleared}</span>
|
||||
<span>{entry.result}</span>
|
||||
</div>
|
||||
))}
|
||||
{cpuLeaderboard.length === 0 && (
|
||||
<div className="leaderboard-empty">
|
||||
No CPU runs recorded yet for this mode.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(screen === 'dungeons' || screen === 'raids') && (
|
||||
<section className="content-screen">
|
||||
<ScreenHeading
|
||||
eyebrow="Adventure"
|
||||
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
|
||||
onBack={() => setScreen('menu')}
|
||||
/>
|
||||
<article className="dungeon-card">
|
||||
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||
{activityInitials(activity.name)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="eyebrow">{activity.locationName}</p>
|
||||
<h2>{activity.name}</h2>
|
||||
<p>{activity.description}</p>
|
||||
<div className="tag-row">
|
||||
<span>Level {activity.recommendedLevel}</span>
|
||||
<span>{activity.partySize} Players</span>
|
||||
<span>{selectedDifficulty.name}</span>
|
||||
<span>Component Level {selectedDifficulty.droppedItemLevel}</span>
|
||||
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
|
||||
</div>
|
||||
</div>
|
||||
{activityOptions.length > 1 && (
|
||||
<label className="activity-select">
|
||||
<span>{activity.contentType === 'raid' ? 'Raid' : 'Dungeon'}</span>
|
||||
<select
|
||||
value={activity.id}
|
||||
onChange={(event) => {
|
||||
const nextActivityId = Number(event.target.value)
|
||||
const nextActivity = activityOptions.find((candidate) => candidate.id === nextActivityId)
|
||||
if (screen === 'raids') setSelectedRaidId(nextActivityId)
|
||||
else setSelectedDungeonId(nextActivityId)
|
||||
if (nextActivity?.difficulties[0]) {
|
||||
setSelectedDifficultyId(nextActivity.difficulties[0].id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activityOptions.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>{candidate.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="part-buttons">
|
||||
{parts.map((p) => (
|
||||
<button
|
||||
key={p.part}
|
||||
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
||||
disabled={difficultyLocked || !p.unlocked}
|
||||
onClick={() => {
|
||||
setSelectedPart(p.part)
|
||||
setCombatContentId(activity.id)
|
||||
setScreen('combat')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
<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>
|
||||
<strong>{selectedDifficulty.name}</strong>
|
||||
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description}</small>
|
||||
</div>
|
||||
<dl>
|
||||
<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>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
|
||||
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div className="loot-preview-section">
|
||||
<div className="equipment-heading toggle-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Encounter Rewards</p>
|
||||
<h2>{selectedDifficulty.name} Loot Tables</h2>
|
||||
</div>
|
||||
<button
|
||||
className="text-button"
|
||||
onClick={() => setShowLoot((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{showLoot ? 'Hide Loot' : 'View Loot'}
|
||||
</button>
|
||||
</div>
|
||||
{showLoot && (
|
||||
<>
|
||||
<div className="loot-toolbar">
|
||||
<label>
|
||||
<span>Sort</span>
|
||||
<select value={lootSort} onChange={(event) => setLootSort(event.target.value as 'sequence' | 'boss')}>
|
||||
<option value="sequence">Encounter order</option>
|
||||
<option value="boss">Boss name</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="section-note">
|
||||
Bosses roll {lootChanceSlots} loot chances against the listed table, 1-3 materials each
|
||||
{activity.completionItemLevel
|
||||
? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
|
||||
: ''}
|
||||
</p>
|
||||
<div className="loot-preview-grid">
|
||||
{lootPreviewEncounters.map((encounter) => {
|
||||
const loot = encounter.lootTables.filter(
|
||||
(entry) => entry.difficultyId === selectedDifficulty.id,
|
||||
)
|
||||
return (
|
||||
<article key={encounter.id}>
|
||||
<div className="loot-encounter-title">
|
||||
{encounter.isBoss ? (
|
||||
<img className="loot-boss-icon" src={encounter.imageUrl} alt={`${encounter.enemyName} icon`} />
|
||||
) : (
|
||||
<span>{encounter.sequence}</span>
|
||||
)}
|
||||
<div>
|
||||
<strong>{encounter.enemyName}</strong>
|
||||
<small>{loot.length > 0 ? `${lootChanceSlots} weighted chances, 1-3 each` : 'No component table'}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="loot-items">
|
||||
{loot.map((item) => (
|
||||
<div className={`rarity-${item.rarity}`} key={item.id}>
|
||||
<span>{item.glyph}</span>
|
||||
<div>
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.slot} - iLvl {item.itemLevel}</small>
|
||||
</div>
|
||||
<i>{item.dropWeight}% weight</i>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="leaderboard-section">
|
||||
<div className="equipment-heading toggle-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Efficiency Rankings</p>
|
||||
<h2>{selectedDifficulty.name} Leaderboard</h2>
|
||||
</div>
|
||||
<button
|
||||
className="text-button"
|
||||
onClick={() => setShowLeaderboard((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{showLeaderboard ? 'Hide Leaderboard' : 'View Leaderboard'}
|
||||
</button>
|
||||
</div>
|
||||
{showLeaderboard && (
|
||||
<>
|
||||
<p className="section-note">
|
||||
{gameMode === 'offline'
|
||||
? 'Offline runs are not submitted'
|
||||
: 'Lowest resource spent ranks first'}
|
||||
</p>
|
||||
<div className="leaderboard-tabs">
|
||||
{([
|
||||
{ key: 'part_1', label: `${sectionName} 1` },
|
||||
{ key: 'part_2', label: `${sectionName} 2` },
|
||||
{ key: 'part_3', label: `${sectionName} 3` },
|
||||
{ key: 'full_run', label: 'Full Run' },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`leaderboard-tab ${leaderboardCategory === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setLeaderboardCategory(tab.key)}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="leaderboard-table">
|
||||
<div className="leaderboard-header">
|
||||
<span>Rank</span>
|
||||
<span>Healer</span>
|
||||
<span>Class</span>
|
||||
<span>Level</span>
|
||||
<span>Item Level</span>
|
||||
<span>Resource</span>
|
||||
<span>Time</span>
|
||||
</div>
|
||||
{activity.leaderboards[leaderboardCategory]
|
||||
.filter((entry) => entry.difficultyId === selectedDifficulty.id)
|
||||
.map((entry) => (
|
||||
<div className="leaderboard-row" key={`${entry.rank}-${entry.completedAt}`}>
|
||||
<strong>#{entry.rank}</strong>
|
||||
<span>{entry.characterName}</span>
|
||||
<span>{entry.className}</span>
|
||||
<span>{entry.characterLevel}</span>
|
||||
<span>{entry.averageItemLevel.toFixed(1)}</span>
|
||||
<strong>{entry.resourceSpent}</strong>
|
||||
<span>{entry.durationSeconds}s</span>
|
||||
</div>
|
||||
))}
|
||||
{activity.leaderboards[leaderboardCategory].filter(
|
||||
(entry) => entry.difficultyId === selectedDifficulty.id,
|
||||
).length === 0 && (
|
||||
<div className="leaderboard-empty">
|
||||
{gameMode === 'offline'
|
||||
? 'Connect with an online character to compete in rankings.'
|
||||
: 'Complete this difficulty to claim the first ranking.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{screen === 'customize' && (
|
||||
<CustomizeScreen
|
||||
profile={profile}
|
||||
onBack={() => setScreen('menu')}
|
||||
onSaved={setProfile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === 'talents' && (
|
||||
<TalentScreen
|
||||
profile={profile}
|
||||
onBack={() => setScreen('menu')}
|
||||
onUpdated={setProfile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === 'equipment' && (
|
||||
<EquipmentScreen
|
||||
profile={profile}
|
||||
onBack={() => setScreen('menu')}
|
||||
onUpdated={setProfile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === 'settings' && (
|
||||
<SettingsScreen onBack={() => setScreen('menu')} />
|
||||
)}
|
||||
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function ScreenHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
onBack,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
onBack: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './App.css'
|
||||
import { AdminScreen } from './components/AdminScreen'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AdminScreen onBack={() => window.close()} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,766 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type AdminItem = {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
slot: string
|
||||
rarity: string
|
||||
itemLevel: number
|
||||
healingPower: number
|
||||
maxResourceBonus: number
|
||||
glyph: string
|
||||
imageUrl: string
|
||||
description: string
|
||||
}
|
||||
|
||||
type AdminEncounter = {
|
||||
id: number
|
||||
dungeonId: number
|
||||
sequence: number
|
||||
slug: string
|
||||
enemyName: string
|
||||
encounterType: string
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
type AdminDifficulty = {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
droppedItemLevel: number
|
||||
}
|
||||
|
||||
type AdminLootEntry = {
|
||||
encounterId: number
|
||||
itemId: number
|
||||
difficultyId: number
|
||||
dropWeight: number
|
||||
dropChance: number
|
||||
}
|
||||
|
||||
type AdminRecipeComponent = {
|
||||
itemId: number
|
||||
quantity: number
|
||||
}
|
||||
|
||||
type AdminRecipe = {
|
||||
id: number
|
||||
itemId: number
|
||||
difficultyId: number | null
|
||||
sourceDungeonId: number | null
|
||||
sourceEncounterId: number | null
|
||||
components: AdminRecipeComponent[]
|
||||
}
|
||||
|
||||
type AdminDungeon = {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type AdminData = {
|
||||
items: AdminItem[]
|
||||
encounters: AdminEncounter[]
|
||||
difficulties: AdminDifficulty[]
|
||||
encounterLoot: AdminLootEntry[]
|
||||
craftingRecipes: AdminRecipe[]
|
||||
dungeons: AdminDungeon[]
|
||||
}
|
||||
|
||||
const API = '/api/admin'
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init)
|
||||
const body = await res.json()
|
||||
if (!res.ok) throw new Error(body.error ?? 'Request failed')
|
||||
return body
|
||||
}
|
||||
|
||||
export function AdminScreen({ onBack }: { onBack: () => void }) {
|
||||
const [data, setData] = useState<AdminData | null>(null)
|
||||
const [tab, setTab] = useState<'items' | 'bosses' | 'loot' | 'crafting'>('items')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
fetchJson<AdminData>(`${API}/data`)
|
||||
.then(setData)
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load'))
|
||||
}, [])
|
||||
|
||||
if (error) return <section className="content-screen"><p className="error-message">{error}</p></section>
|
||||
if (!data) return <section className="content-screen"><p>Loading admin data...</p></section>
|
||||
|
||||
return (
|
||||
<section className="content-screen admin-screen">
|
||||
<div className="screen-heading">
|
||||
<div><p className="eyebrow">Developer Tools</p><h1>Admin Panel</h1></div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
</div>
|
||||
<nav className="admin-tabs">
|
||||
{(['items', 'bosses', 'loot', 'crafting'] as const).map((t) => (
|
||||
<button key={t} className={`admin-tab ${tab === t ? 'active' : ''}`}
|
||||
onClick={() => setTab(t)} type="button">
|
||||
{t === 'items' ? 'Items' : t === 'bosses' ? 'Boss Images' : t === 'loot' ? 'Boss Loot' : 'Crafting'}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{tab === 'items' && <ItemsTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
||||
{tab === 'bosses' && <BossImagesTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
||||
{tab === 'loot' && <LootTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
||||
{tab === 'crafting' && <CraftingTab data={data} setData={setData} setSaving={setSaving} saving={saving} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemsTab({ data, setData, setSaving, saving }: {
|
||||
data: AdminData | null
|
||||
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
|
||||
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
|
||||
saving: Record<string, boolean>
|
||||
}) {
|
||||
if (!data) return null
|
||||
const [filter, setFilter] = useState('')
|
||||
const [editId, setEditId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState<Partial<AdminItem>>({})
|
||||
|
||||
const groups = groupBy(data.items.filter((i) =>
|
||||
i.name.toLowerCase().includes(filter.toLowerCase()) || i.slug.includes(filter)),
|
||||
(i) => i.slot,
|
||||
)
|
||||
|
||||
async function saveItem(id: number) {
|
||||
setSaving((prev) => ({ ...prev, [`item-${id}`]: true }))
|
||||
try {
|
||||
const body: Record<string, string | number> = {}
|
||||
if (form.name !== undefined) body.name = form.name
|
||||
if (form.glyph !== undefined) body.glyph = form.glyph
|
||||
if (form.description !== undefined) body.description = form.description
|
||||
if (form.rarity !== undefined) body.rarity = form.rarity
|
||||
if (form.slot !== undefined) body.slot = form.slot
|
||||
if (form.itemLevel !== undefined) body.item_level = form.itemLevel
|
||||
if (form.healingPower !== undefined) body.healing_power = form.healingPower
|
||||
if (form.maxResourceBonus !== undefined) body.max_resource_bonus = form.maxResourceBonus
|
||||
await fetchJson(`${API}/items/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
items: prev.items.map((i) => i.id === id ? { ...i, ...form } : i),
|
||||
} : prev)
|
||||
setEditId(null)
|
||||
setForm({})
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Save failed')
|
||||
} finally {
|
||||
setSaving((prev) => ({ ...prev, [`item-${id}`]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-panel">
|
||||
<input className="admin-search" placeholder="Search items..." value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)} />
|
||||
{Object.entries(groups).map(([slot, items]) => (
|
||||
<details key={slot} open>
|
||||
<summary className="admin-group-header">{slot} ({items.length})</summary>
|
||||
<div className="admin-grid">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="admin-card">
|
||||
{editId === item.id ? (
|
||||
<div className="admin-edit-form">
|
||||
<label>Glyph <input value={form.glyph ?? item.glyph} onChange={(e) => setForm({ ...form, glyph: e.target.value })} /></label>
|
||||
<label>Name <input value={form.name ?? item.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label>
|
||||
<label>Slot
|
||||
<select value={form.slot ?? item.slot} onChange={(e) => setForm({ ...form, slot: e.target.value })}>
|
||||
{['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket', 'component'].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>Rarity
|
||||
<select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
|
||||
{['common', 'uncommon', 'rare', 'epic'].map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>iLvl <input type="number" value={form.itemLevel ?? item.itemLevel} onChange={(e) => setForm({ ...form, itemLevel: Number(e.target.value) })} /></label>
|
||||
<label>Healing <input type="number" value={form.healingPower ?? item.healingPower} onChange={(e) => setForm({ ...form, healingPower: Number(e.target.value) })} /></label>
|
||||
<label>Resource <input type="number" value={form.maxResourceBonus ?? item.maxResourceBonus} onChange={(e) => setForm({ ...form, maxResourceBonus: Number(e.target.value) })} /></label>
|
||||
<label>Description <textarea value={form.description ?? item.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
|
||||
<div className="admin-edit-actions">
|
||||
<button className="primary-button" onClick={() => saveItem(item.id)} disabled={saving[`item-${item.id}`]} type="button">
|
||||
{saving[`item-${item.id}`] ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" onClick={() => { setEditId(null); setForm({}) }} type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-item-header">
|
||||
<span className={`admin-glyph rarity-${item.rarity}`}>{item.glyph}</span>
|
||||
<div>
|
||||
<strong>{item.name}</strong>
|
||||
<small className="admin-item-meta">{item.slot} · iLvl {item.itemLevel} · {item.rarity}</small>
|
||||
</div>
|
||||
</div>
|
||||
<p className="admin-item-desc">{item.description}</p>
|
||||
<div className="admin-item-stats">
|
||||
<span>+{item.healingPower} healing</span>
|
||||
<span>+{item.maxResourceBonus} resource</span>
|
||||
</div>
|
||||
<button className="text-button" onClick={() => { setEditId(item.id); setForm({}) }} type="button">Edit</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BossImagesTab({ data, setData, setSaving, saving }: {
|
||||
data: AdminData | null
|
||||
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
|
||||
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
|
||||
saving: Record<string, boolean>
|
||||
}) {
|
||||
if (!data) return null
|
||||
|
||||
async function uploadBossImage(encounterId: number, file: File | undefined) {
|
||||
if (!file) return
|
||||
setSaving((prev) => ({ ...prev, [`boss-image-${encounterId}`]: true }))
|
||||
try {
|
||||
const imageData = await fileToDataUrl(file)
|
||||
const result = await fetchJson<{ imageUrl: string }>(`${API}/encounters/${encounterId}/image`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageData }),
|
||||
})
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
encounters: prev.encounters.map((encounter) => (
|
||||
encounter.id === encounterId
|
||||
? { ...encounter, imageUrl: result.imageUrl }
|
||||
: encounter
|
||||
)),
|
||||
} : prev)
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Upload failed')
|
||||
} finally {
|
||||
setSaving((prev) => ({ ...prev, [`boss-image-${encounterId}`]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const bosses = data.encounters.filter((encounter) => encounter.encounterType === 'boss')
|
||||
|
||||
return (
|
||||
<div className="admin-panel">
|
||||
<div className="admin-grid boss-image-grid">
|
||||
{bosses.map((boss) => (
|
||||
<div key={boss.id} className="admin-card boss-image-card">
|
||||
<img src={boss.imageUrl} alt={`${boss.enemyName} icon`} />
|
||||
<div>
|
||||
<strong>{boss.enemyName}</strong>
|
||||
<small className="admin-item-meta">Dungeon {boss.dungeonId} · Encounter {boss.sequence}</small>
|
||||
</div>
|
||||
<label className="boss-upload-button">
|
||||
{saving[`boss-image-${boss.id}`] ? 'Uploading...' : 'Upload Image'}
|
||||
<input
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
disabled={saving[`boss-image-${boss.id}`]}
|
||||
onChange={(event) => uploadBossImage(boss.id, event.target.files?.[0])}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LootTab({ data, setData, setSaving, saving }: {
|
||||
data: AdminData | null
|
||||
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
|
||||
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
|
||||
saving: Record<string, boolean>
|
||||
}) {
|
||||
const [encounterId, setEncounterId] = useState(data?.encounters.filter(e => e.encounterType === 'boss')[0]?.id ?? 0)
|
||||
const [difficultyId, setDifficultyId] = useState(data?.difficulties[0]?.id ?? 0)
|
||||
const [addItemId, setAddItemId] = useState(0)
|
||||
const [addDropWeight, setAddDropWeight] = useState(100)
|
||||
const [addDropChance, setAddDropChance] = useState(1)
|
||||
const [renameItemId, setRenameItemId] = useState<number | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [bossSort, setBossSort] = useState<'dungeon' | 'boss'>('dungeon')
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const enc = data.encounters.find(e => e.id === encounterId)
|
||||
const bossOptions = data.encounters
|
||||
.filter((e) => e.encounterType === 'boss')
|
||||
.sort((a, b) => bossSort === 'boss'
|
||||
? a.enemyName.localeCompare(b.enemyName) || a.dungeonId - b.dungeonId || a.sequence - b.sequence
|
||||
: a.dungeonId - b.dungeonId || a.sequence - b.sequence || a.enemyName.localeCompare(b.enemyName))
|
||||
const bossLoot = data.encounterLoot.filter(
|
||||
(l) => l.encounterId === encounterId && l.difficultyId === difficultyId,
|
||||
)
|
||||
|
||||
const items = data.items
|
||||
function itemName(id: number) { return items.find(i => i.id === id)?.name ?? `#${id}` }
|
||||
function itemGlyph(id: number) { return items.find(i => i.id === id)?.glyph ?? '?' }
|
||||
|
||||
async function renameItem(itemId: number) {
|
||||
if (renameValue.trim() === '' || renameValue === itemName(itemId)) return
|
||||
setSaving((prev) => ({ ...prev, [`loot-rename-${itemId}`]: true }))
|
||||
try {
|
||||
await fetchJson(`${API}/items/${itemId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: renameValue.trim() }),
|
||||
})
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === itemId ? { ...item, name: renameValue.trim() } : item,
|
||||
),
|
||||
} : prev)
|
||||
setRenameItemId(null)
|
||||
setRenameValue('')
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Rename failed')
|
||||
} finally {
|
||||
setSaving((prev) => ({ ...prev, [`loot-rename-${itemId}`]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLoot(eId: number, dId: number, iId: number) {
|
||||
try {
|
||||
await fetchJson(`${API}/encounter-loot/${eId}/${dId}/${iId}`, { method: 'DELETE' })
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
encounterLoot: prev.encounterLoot.filter(
|
||||
(l) => !(l.encounterId === eId && l.difficultyId === dId && l.itemId === iId),
|
||||
),
|
||||
} : prev)
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function addLoot() {
|
||||
if (!addItemId) return
|
||||
try {
|
||||
await fetchJson(`${API}/encounter-loot`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ encounterId, itemId: addItemId, difficultyId, dropWeight: addDropWeight, dropChance: addDropChance }),
|
||||
})
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
encounterLoot: [
|
||||
...prev.encounterLoot.filter(
|
||||
(l) => !(l.encounterId === encounterId && l.difficultyId === difficultyId && l.itemId === addItemId),
|
||||
),
|
||||
{ encounterId, itemId: addItemId, difficultyId, dropWeight: addDropWeight, dropChance: addDropChance },
|
||||
],
|
||||
} : prev)
|
||||
setAddItemId(0)
|
||||
setAddDropWeight(100)
|
||||
setAddDropChance(1)
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Add failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-panel">
|
||||
<div className="admin-loot-selectors">
|
||||
<label>Boss
|
||||
<select value={encounterId} onChange={(e) => setEncounterId(Number(e.target.value))}>
|
||||
{bossOptions.map((e) => (
|
||||
<option key={e.id} value={e.id}>{e.enemyName} (dungeon {e.dungeonId})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>Sort Bosses
|
||||
<select value={bossSort} onChange={(e) => setBossSort(e.target.value as 'dungeon' | 'boss')}>
|
||||
<option value="dungeon">Dungeon order</option>
|
||||
<option value="boss">Boss name</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Difficulty
|
||||
<select value={difficultyId} onChange={(e) => setDifficultyId(Number(e.target.value))}>
|
||||
{data.difficulties.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.name} (iLvl {d.droppedItemLevel})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3 className="admin-loot-title">
|
||||
{enc?.enemyName ?? 'Unknown'} — {data.difficulties.find(d => d.id === difficultyId)?.name ?? '?'}
|
||||
</h3>
|
||||
|
||||
{bossLoot.length === 0 && <p className="admin-empty">No loot entries for this boss + difficulty.</p>}
|
||||
|
||||
<div className="admin-loot-list">
|
||||
{bossLoot.map((entry) => (
|
||||
<div key={`${entry.itemId}`} className="admin-loot-row">
|
||||
<span className={`admin-glyph rarity-${data.items.find(i => i.id === entry.itemId)?.rarity ?? 'common'}`}>
|
||||
{itemGlyph(entry.itemId)}
|
||||
</span>
|
||||
{renameItemId === entry.itemId ? (
|
||||
<span className="admin-loot-name">
|
||||
<input
|
||||
className="admin-rename-input"
|
||||
ref={(node) => {
|
||||
if (node) window.requestAnimationFrame(() => node.focus({ preventScroll: true }))
|
||||
}}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') renameItem(entry.itemId); if (e.key === 'Escape') { setRenameItemId(null); setRenameValue('') } }}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="admin-loot-name">{itemName(entry.itemId)}</span>
|
||||
)}
|
||||
<span className="admin-loot-weight">Weight: {entry.dropWeight}</span>
|
||||
<span className="admin-loot-chance">Chance: {(entry.dropChance * 100).toFixed(0)}%</span>
|
||||
{renameItemId === entry.itemId ? (
|
||||
<>
|
||||
<button className="primary-button" disabled={saving[`loot-rename-${entry.itemId}`]} onClick={() => renameItem(entry.itemId)} type="button">
|
||||
{saving[`loot-rename-${entry.itemId}`] ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" onClick={() => { setRenameItemId(null); setRenameValue('') }} type="button">Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="text-button" onClick={() => { setRenameItemId(entry.itemId); setRenameValue(itemName(entry.itemId)) }} type="button">Rename</button>
|
||||
<button className="danger-button" onClick={() => deleteLoot(entry.encounterId, entry.difficultyId, entry.itemId)} type="button">X</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<details className="admin-add-section">
|
||||
<summary>Add Item to Loot Table</summary>
|
||||
<div className="admin-add-form">
|
||||
<label>Item
|
||||
<select value={addItemId} onChange={(e) => setAddItemId(Number(e.target.value))}>
|
||||
<option value={0}>Select item...</option>
|
||||
{data.items.filter((i) => !bossLoot.some((l) => l.itemId === i.id)).map((i) => (
|
||||
<option key={i.id} value={i.id}>[{i.glyph}] {i.name} ({i.slot})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>Drop Weight <input type="number" value={addDropWeight} onChange={(e) => setAddDropWeight(Number(e.target.value))} /></label>
|
||||
<label>Drop Chance <input type="number" min="0" max="1" step="0.05" value={addDropChance} onChange={(e) => setAddDropChance(Number(e.target.value))} /></label>
|
||||
<button className="primary-button" onClick={addLoot} disabled={!addItemId} type="button">Add</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CraftingTab({ data, setData, setSaving, saving }: {
|
||||
data: AdminData | null
|
||||
setData: React.Dispatch<React.SetStateAction<AdminData | null>>
|
||||
setSaving: (s: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void
|
||||
saving: Record<string, boolean>
|
||||
}) {
|
||||
const [recipeId, setRecipeId] = useState(data?.craftingRecipes[0]?.id ?? 0)
|
||||
const [itemLevelFilter, setItemLevelFilter] = useState('all')
|
||||
const [bossFilterId, setBossFilterId] = useState(0)
|
||||
const [addItemId, setAddItemId] = useState(0)
|
||||
const [addQty, setAddQty] = useState(1)
|
||||
const [outputNameByItem, setOutputNameByItem] = useState<Record<number, string>>({})
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const bossEncounters = data.encounters.filter((encounter) => encounter.encounterType === 'boss')
|
||||
const bossLootItemIds = new Set(
|
||||
data.encounterLoot
|
||||
.filter((entry) => bossEncounters.some((boss) => boss.id === entry.encounterId))
|
||||
.map((entry) => entry.itemId),
|
||||
)
|
||||
const itemLevels = Array.from(new Set(
|
||||
data.craftingRecipes
|
||||
.map((candidate) => data.items.find((item) => item.id === candidate.itemId)?.itemLevel)
|
||||
.filter((itemLevel): itemLevel is number => itemLevel !== undefined),
|
||||
)).sort((a, b) => a - b)
|
||||
const filteredRecipes = data.craftingRecipes.filter((candidate) => {
|
||||
const item = data.items.find((i) => i.id === candidate.itemId)
|
||||
if (!item) return false
|
||||
if (itemLevelFilter !== 'all' && item.itemLevel !== Number(itemLevelFilter)) return false
|
||||
if (bossFilterId === 0) return true
|
||||
return candidate.sourceEncounterId === bossFilterId
|
||||
|| candidate.components.some((component) => data.encounterLoot.some(
|
||||
(entry) => entry.encounterId === bossFilterId && entry.itemId === component.itemId,
|
||||
))
|
||||
})
|
||||
const recipe = filteredRecipes.find((r) => r.id === recipeId) ?? filteredRecipes[0] ?? null
|
||||
const outputItem = recipe ? data.items.find((i) => i.id === recipe.itemId) : null
|
||||
const outputName = outputItem ? outputNameByItem[outputItem.id] ?? outputItem.name : ''
|
||||
const bossComponentOptions = data.items.filter((item) => (
|
||||
item.slot === 'component'
|
||||
&& bossLootItemIds.has(item.id)
|
||||
&& !recipe?.components.some((component) => component.itemId === item.id)
|
||||
))
|
||||
const addComponentIsValid = bossComponentOptions.some((item) => item.id === addItemId)
|
||||
|
||||
const items = data.items
|
||||
function itemName(id: number) { return items.find(i => i.id === id)?.name ?? `#${id}` }
|
||||
function itemGlyph(id: number) { return items.find(i => i.id === id)?.glyph ?? '?' }
|
||||
function bossName(id: number | null) {
|
||||
return id ? bossEncounters.find((boss) => boss.id === id)?.enemyName ?? `Boss #${id}` : 'Any boss'
|
||||
}
|
||||
function componentBossNames(itemId: number) {
|
||||
return (data!).encounterLoot
|
||||
.filter((entry) => entry.itemId === itemId)
|
||||
.map((entry) => bossEncounters.find((boss) => boss.id === entry.encounterId)?.enemyName ?? `Boss #${entry.encounterId}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
async function saveOutputName() {
|
||||
if (!outputItem || outputName.trim() === '' || outputName === outputItem.name) return
|
||||
setSaving((prev) => ({ ...prev, [`item-name-${outputItem.id}`]: true }))
|
||||
try {
|
||||
await fetchJson(`${API}/items/${outputItem.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: outputName.trim() }),
|
||||
})
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
items: prev.items.map((item) => (
|
||||
item.id === outputItem.id ? { ...item, name: outputName.trim() } : item
|
||||
)),
|
||||
} : prev)
|
||||
setOutputNameByItem((prev) => ({ ...prev, [outputItem.id]: outputName.trim() }))
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Rename failed')
|
||||
} finally {
|
||||
setSaving((prev) => ({ ...prev, [`item-name-${outputItem.id}`]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadItemImage(itemId: number, file: File | undefined) {
|
||||
if (!file) return
|
||||
setSaving((prev) => ({ ...prev, [`item-image-${itemId}`]: true }))
|
||||
try {
|
||||
const imageData = await fileToDataUrl(file)
|
||||
const result = await fetchJson<{ imageUrl: string }>(`${API}/items/${itemId}/image`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageData }),
|
||||
})
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
items: prev.items.map((item) => (
|
||||
item.id === itemId ? { ...item, imageUrl: result.imageUrl } : item
|
||||
)),
|
||||
} : prev)
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Upload failed')
|
||||
} finally {
|
||||
setSaving((prev) => ({ ...prev, [`item-image-${itemId}`]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComponent(rId: number, iId: number) {
|
||||
try {
|
||||
await fetchJson(`${API}/crafting-recipes/${rId}/components/${iId}`, { method: 'DELETE' })
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
craftingRecipes: prev.craftingRecipes.map((r) =>
|
||||
r.id === rId ? { ...r, components: r.components.filter((c) => c.itemId !== iId) } : r,
|
||||
),
|
||||
} : prev)
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function addComponent() {
|
||||
if (!addComponentIsValid || !recipe) return
|
||||
try {
|
||||
await fetchJson(`${API}/crafting-recipes/${recipe.id}/components`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemId: addItemId, quantity: addQty }),
|
||||
})
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
craftingRecipes: prev.craftingRecipes.map((r) =>
|
||||
r.id === recipe!.id
|
||||
? { ...r, components: [...r.components.filter((c) => c.itemId !== addItemId), { itemId: addItemId, quantity: addQty }] }
|
||||
: r,
|
||||
),
|
||||
} : prev)
|
||||
setAddItemId(0)
|
||||
setAddQty(1)
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Add failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateQty(rId: number, iId: number, quantity: number) {
|
||||
try {
|
||||
await fetchJson(`${API}/crafting-recipes/${rId}/components`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemId: iId, quantity }),
|
||||
})
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
craftingRecipes: prev.craftingRecipes.map((r) =>
|
||||
r.id === rId ? {
|
||||
...r,
|
||||
components: r.components.map((c) => c.itemId === iId ? { ...c, quantity } : c),
|
||||
} : r,
|
||||
),
|
||||
} : prev)
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Update failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-panel">
|
||||
<div className="admin-crafting-filters">
|
||||
<label>Item Level
|
||||
<select value={itemLevelFilter} onChange={(e) => setItemLevelFilter(e.target.value)}>
|
||||
<option value="all">All levels</option>
|
||||
{itemLevels.map((itemLevel) => (
|
||||
<option key={itemLevel} value={itemLevel}>iLvl {itemLevel}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>Boss
|
||||
<select value={bossFilterId} onChange={(e) => setBossFilterId(Number(e.target.value))}>
|
||||
<option value={0}>All bosses</option>
|
||||
{bossEncounters.map((boss) => (
|
||||
<option key={boss.id} value={boss.id}>{boss.enemyName}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>Recipe
|
||||
<select value={recipe?.id ?? 0} onChange={(e) => setRecipeId(Number(e.target.value))}>
|
||||
{filteredRecipes.length === 0 && <option value={0}>No matching recipes</option>}
|
||||
{filteredRecipes.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
[{itemGlyph(r.itemId)}] {itemName(r.itemId)} - {bossName(r.sourceEncounterId)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{outputItem && (
|
||||
<div className="admin-recipe-header">
|
||||
<img className="admin-recipe-image" src={outputItem.imageUrl || '/equipment-placeholder.svg'} alt={`${outputItem.name} icon`} />
|
||||
<div>
|
||||
<label className="admin-inline-field">Name
|
||||
<input
|
||||
value={outputName}
|
||||
onChange={(e) => {
|
||||
if (!outputItem) return
|
||||
setOutputNameByItem((prev) => ({ ...prev, [outputItem.id]: e.target.value }))
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<small className="admin-item-meta">{outputItem.slot} · iLvl {outputItem.itemLevel} · {outputItem.rarity}</small>
|
||||
</div>
|
||||
<div className="admin-recipe-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={saving[`item-name-${outputItem.id}`] || outputName.trim() === '' || outputName === outputItem.name}
|
||||
onClick={saveOutputName}
|
||||
type="button"
|
||||
>
|
||||
{saving[`item-name-${outputItem.id}`] ? 'Saving...' : 'Rename'}
|
||||
</button>
|
||||
<label className="boss-upload-button">
|
||||
{saving[`item-image-${outputItem.id}`] ? 'Uploading...' : 'Upload Image'}
|
||||
<input
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
disabled={saving[`item-image-${outputItem.id}`]}
|
||||
onChange={(event) => uploadItemImage(outputItem.id, event.target.files?.[0])}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="admin-loot-title">Required Components</h3>
|
||||
|
||||
{(!recipe || recipe.components.length === 0) && (
|
||||
<p className="admin-empty">No component requirements.</p>
|
||||
)}
|
||||
|
||||
<div className="admin-loot-list">
|
||||
{recipe?.components.map((comp) => (
|
||||
<div key={comp.itemId} className="admin-loot-row">
|
||||
<span className={`admin-glyph rarity-${data.items.find(i => i.id === comp.itemId)?.rarity ?? 'common'}`}>
|
||||
{itemGlyph(comp.itemId)}
|
||||
</span>
|
||||
<span className="admin-loot-name">{itemName(comp.itemId)}</span>
|
||||
<span className="admin-loot-weight">Qty:
|
||||
<input className="admin-qty-input" type="number" min="1" value={comp.quantity}
|
||||
onChange={(e) => updateQty(recipe.id, comp.itemId, Number(e.target.value))} />
|
||||
</span>
|
||||
<button className="danger-button" onClick={() => deleteComponent(recipe.id, comp.itemId)} type="button">X</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<details className="admin-add-section">
|
||||
<summary>Add Component</summary>
|
||||
<div className="admin-add-form">
|
||||
<label>Component
|
||||
<select value={addItemId} onChange={(e) => setAddItemId(Number(e.target.value))}>
|
||||
<option value={0}>Select component...</option>
|
||||
{bossComponentOptions.map((i) => (
|
||||
<option key={i.id} value={i.id}>[{i.glyph}] {i.name} ({componentBossNames(i.id)})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>Quantity <input type="number" min="1" value={addQty} onChange={(e) => setAddQty(Number(e.target.value))} /></label>
|
||||
<button className="primary-button" onClick={addComponent} disabled={!recipe || !addComponentIsValid} type="button">Add</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fileToDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () => {
|
||||
if (typeof reader.result === 'string') resolve(reader.result)
|
||||
else reject(new Error('Unable to read image.'))
|
||||
})
|
||||
reader.addEventListener('error', () => reject(reader.error ?? new Error('Unable to read image.')))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function groupBy<T>(items: T[], keyFn: (item: T) => string): Record<string, T[]> {
|
||||
const groups: Record<string, T[]> = {}
|
||||
for (const item of items) {
|
||||
const key = keyFn(item)
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(item)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
loginAccount,
|
||||
registerAccount,
|
||||
type AuthSession,
|
||||
} from '../profile'
|
||||
import {
|
||||
createOfflineCharacter,
|
||||
hasOfflineCharacter,
|
||||
resumeOfflineCharacter,
|
||||
selectOnlineMode,
|
||||
} from '../gameRepository'
|
||||
|
||||
type Props = {
|
||||
onAuthenticated: (session: AuthSession) => void
|
||||
serverMessage?: string
|
||||
}
|
||||
|
||||
export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [characterName, setCharacterName] = useState('')
|
||||
const [offlineName, setOfflineName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const offlineCharacterExists = hasOfflineCharacter()
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setMessage('')
|
||||
try {
|
||||
selectOnlineMode()
|
||||
const session = mode === 'login'
|
||||
? await loginAccount(username, password)
|
||||
: await registerAccount(username, password, characterName)
|
||||
onAuthenticated(session)
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to authenticate.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function beginOffline() {
|
||||
setMessage('')
|
||||
try {
|
||||
onAuthenticated(createOfflineCharacter(offlineName))
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to create an offline character.')
|
||||
}
|
||||
}
|
||||
|
||||
function resumeOffline() {
|
||||
const session = resumeOfflineCharacter()
|
||||
if (session) onAuthenticated(session)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-shell">
|
||||
<section className="auth-panel">
|
||||
<div className="auth-brand">
|
||||
<p className="eyebrow">Healer RPG</p>
|
||||
<h1>I want to Heal</h1>
|
||||
<p>
|
||||
Build your healer, master each dungeon, and compete for the most
|
||||
efficient clears.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-card">
|
||||
<div className="auth-tabs">
|
||||
<button
|
||||
className={mode === 'login' ? 'selected' : ''}
|
||||
onClick={() => {
|
||||
setMode('login')
|
||||
setMessage('')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
className={mode === 'register' ? 'selected' : ''}
|
||||
onClick={() => {
|
||||
setMode('register')
|
||||
setMessage('')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
autoComplete="username"
|
||||
maxLength={20}
|
||||
minLength={3}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
pattern="[A-Za-z0-9_]+"
|
||||
required
|
||||
value={username}
|
||||
/>
|
||||
</label>
|
||||
{mode === 'register' && (
|
||||
<label>
|
||||
Character Name
|
||||
<input
|
||||
autoComplete="nickname"
|
||||
maxLength={20}
|
||||
minLength={2}
|
||||
onChange={(event) => setCharacterName(event.target.value)}
|
||||
required
|
||||
value={characterName}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
maxLength={128}
|
||||
minLength={10}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-button" disabled={busy} type="submit">
|
||||
{busy
|
||||
? 'Working...'
|
||||
: mode === 'login'
|
||||
? 'Enter Chronicle'
|
||||
: 'Begin Adventure'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={`auth-message ${message ? 'error' : ''}`}>
|
||||
{message || serverMessage || (
|
||||
mode === 'register'
|
||||
? 'The first account keeps the current local character and save.'
|
||||
: 'Sign in to continue your character.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="offline-divider"><span>or</span></div>
|
||||
|
||||
<section className="offline-entry">
|
||||
<div>
|
||||
<p className="eyebrow">Local Save</p>
|
||||
<h2>Play Offline</h2>
|
||||
<p>
|
||||
No account or connection required. Offline progress stays on
|
||||
this device and is excluded from online leaderboards.
|
||||
</p>
|
||||
</div>
|
||||
{offlineCharacterExists && (
|
||||
<button
|
||||
className="offline-resume-button"
|
||||
onClick={resumeOffline}
|
||||
type="button"
|
||||
>
|
||||
Continue Offline Character
|
||||
</button>
|
||||
)}
|
||||
<label>
|
||||
{offlineCharacterExists ? 'New Character Name' : 'Character Name'}
|
||||
<input
|
||||
maxLength={20}
|
||||
minLength={2}
|
||||
onChange={(event) => setOfflineName(event.target.value)}
|
||||
placeholder="Mira"
|
||||
value={offlineName}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="text-button offline-new-button"
|
||||
onClick={beginOffline}
|
||||
type="button"
|
||||
>
|
||||
{offlineCharacterExists ? 'Replace Offline Character' : 'Begin Offline Adventure'}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import {
|
||||
bindingLabel,
|
||||
compactBindingLabel,
|
||||
type ControllerIconStyle,
|
||||
} from '../input'
|
||||
|
||||
const FACE_BUTTONS: Record<ControllerIconStyle, Partial<Record<number, { color: string; label: string }>>> = {
|
||||
xbox: {
|
||||
0: { color: '#107c10', label: 'A' },
|
||||
1: { color: '#d13438', label: 'B' },
|
||||
2: { color: '#0078d4', label: 'X' },
|
||||
3: { color: '#ffb900', label: 'Y' },
|
||||
},
|
||||
playstation: {
|
||||
0: { color: '#0070d1', label: '×' },
|
||||
1: { color: '#df0024', label: '○' },
|
||||
2: { color: '#f27ab8', label: '□' },
|
||||
3: { color: '#00a35a', label: '△' },
|
||||
},
|
||||
nintendo: {
|
||||
0: { color: '#e60012', label: 'B' },
|
||||
1: { color: '#e60012', label: 'A' },
|
||||
2: { color: '#e60012', label: 'Y' },
|
||||
3: { color: '#e60012', label: 'X' },
|
||||
},
|
||||
}
|
||||
|
||||
function faceButtonFor(binding: string, iconStyle: ControllerIconStyle) {
|
||||
if (!binding.startsWith('Button')) return null
|
||||
return FACE_BUTTONS[iconStyle][Number(binding.slice(6))] ?? null
|
||||
}
|
||||
|
||||
function FaceIcon({
|
||||
color,
|
||||
iconStyle,
|
||||
label,
|
||||
title,
|
||||
}: {
|
||||
color: string
|
||||
iconStyle: ControllerIconStyle
|
||||
label: string
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-label={title}
|
||||
className={`controller-face-icon controller-face-${iconStyle}`}
|
||||
role="img"
|
||||
style={{ '--button-color': color } as CSSProperties}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ControllerBindingLabel({
|
||||
binding,
|
||||
compact = false,
|
||||
iconStyle,
|
||||
}: {
|
||||
binding: string
|
||||
compact?: boolean
|
||||
iconStyle: ControllerIconStyle
|
||||
}) {
|
||||
const faceButton = faceButtonFor(binding, iconStyle)
|
||||
const title = bindingLabel(binding, iconStyle)
|
||||
|
||||
if (faceButton) {
|
||||
return (
|
||||
<FaceIcon
|
||||
color={faceButton.color}
|
||||
iconStyle={iconStyle}
|
||||
label={faceButton.label}
|
||||
title={title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{compact ? compactBindingLabel(binding, iconStyle) : title}</>
|
||||
}
|
||||
|
||||
export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) {
|
||||
return (
|
||||
<span className="controller-style-preview" aria-hidden="true">
|
||||
{[0, 1, 2, 3].map((button) => {
|
||||
const faceButton = FACE_BUTTONS[iconStyle][button]
|
||||
if (!faceButton) return null
|
||||
|
||||
return (
|
||||
<FaceIcon
|
||||
color={faceButton.color}
|
||||
iconStyle={iconStyle}
|
||||
key={button}
|
||||
label={faceButton.label}
|
||||
title=""
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
saveProfile,
|
||||
type CharacterProfile,
|
||||
type GameClass,
|
||||
} from '../profile'
|
||||
import { EquipmentScreen } from './EquipmentScreen'
|
||||
import { TalentScreen } from './TalentScreen'
|
||||
|
||||
type Props = {
|
||||
profile: CharacterProfile
|
||||
onBack: () => void
|
||||
onSaved: (profile: CharacterProfile) => void
|
||||
}
|
||||
|
||||
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
|
||||
const [classId, setClassId] = useState(profile.character.classId)
|
||||
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
||||
const [selectedSlot, setSelectedSlot] = useState(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const scrollRef = useRef<number>(0)
|
||||
const gameClass = profile.classes.find((candidate) => candidate.id === classId)!
|
||||
const abilityMap = useMemo(
|
||||
() => new Map(gameClass.spells.map((ability) => [ability.id, ability])),
|
||||
[gameClass],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
}, [profile])
|
||||
|
||||
function saveScroll() {
|
||||
scrollRef.current = window.scrollY
|
||||
}
|
||||
|
||||
function chooseClass(nextClass: GameClass) {
|
||||
const starterAbilities = nextClass.spells
|
||||
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
||||
.slice(0, 5)
|
||||
.map((ability) => ability.id)
|
||||
setClassId(nextClass.id)
|
||||
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
||||
setSelectedSlot(0)
|
||||
setMessage('')
|
||||
}
|
||||
|
||||
function equipAbility(abilityId: number) {
|
||||
if (slots.includes(abilityId)) {
|
||||
setMessage('That ability is already equipped.')
|
||||
return
|
||||
}
|
||||
setSlots((current) =>
|
||||
current.map((spellId, index) => index === selectedSlot ? abilityId : spellId),
|
||||
)
|
||||
setMessage('')
|
||||
}
|
||||
|
||||
function clearSlot() {
|
||||
setSlots((current) =>
|
||||
current.map((spellId, index) => index === selectedSlot ? null : spellId),
|
||||
)
|
||||
}
|
||||
|
||||
async function persistChanges() {
|
||||
saveScroll()
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await saveProfile(classId, slots)
|
||||
onSaved(updated)
|
||||
setMessage('Character saved.')
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to save character.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="content-screen customize-screen">
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Character Workshop</p>
|
||||
<h1>Customize Character</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
</div>
|
||||
|
||||
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
||||
{([
|
||||
{ key: 'equipment', label: 'Equipment' },
|
||||
{ key: 'talents', label: 'Talents' },
|
||||
{ key: 'class', label: 'Class' },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
aria-selected={activeTab === tab.key}
|
||||
className={activeTab === tab.key ? 'active' : ''}
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'equipment' && (
|
||||
<EquipmentScreen
|
||||
embedded
|
||||
profile={profile}
|
||||
onUpdated={onSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'talents' && (
|
||||
<TalentScreen
|
||||
embedded
|
||||
profile={profile}
|
||||
onUpdated={onSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'class' && (
|
||||
<div className="customize-layout">
|
||||
<aside className="class-picker">
|
||||
<p className="eyebrow">Healing Class</p>
|
||||
{profile.classes.map((candidate) => (
|
||||
<button
|
||||
className={candidate.id === classId ? 'active' : ''}
|
||||
key={candidate.id}
|
||||
onClick={() => chooseClass(candidate)}
|
||||
style={{ '--class-color': candidate.themeColor } as React.CSSProperties}
|
||||
type="button"
|
||||
>
|
||||
<span>{candidate.name[0]}</span>
|
||||
<div>
|
||||
<strong>{candidate.name}</strong>
|
||||
<small>{candidate.resourceName}</small>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div className="loadout-editor">
|
||||
<div className="class-detail">
|
||||
<div
|
||||
className="class-portrait"
|
||||
style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}
|
||||
>
|
||||
{gameClass.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="eyebrow">Level {profile.character.level} Healer</p>
|
||||
<h2>{gameClass.name}</h2>
|
||||
<p>{gameClass.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="loadout-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Active Loadout</p>
|
||||
<h2>Ability Bar</h2>
|
||||
</div>
|
||||
<span>Select a slot, then choose an ability.</span>
|
||||
</div>
|
||||
|
||||
<div className="ability-slots">
|
||||
{slots.map((abilityId, index) => {
|
||||
const ability = abilityId ? abilityMap.get(abilityId) : undefined
|
||||
return (
|
||||
<button
|
||||
className={selectedSlot === index ? 'selected' : ''}
|
||||
key={index}
|
||||
onClick={() => setSelectedSlot(index)}
|
||||
type="button"
|
||||
>
|
||||
<kbd>{index + 1}</kbd>
|
||||
<span>{ability?.glyph ?? '-'}</span>
|
||||
<strong>{ability?.name ?? 'Empty Slot'}</strong>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ability-library-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Class Abilities</p>
|
||||
<h2>Ability Library</h2>
|
||||
</div>
|
||||
<button className="text-button" onClick={clearSlot} type="button">Clear Selected Slot</button>
|
||||
</div>
|
||||
|
||||
<div className="ability-library">
|
||||
{gameClass.spells.map((ability) => {
|
||||
const locked = ability.unlockLevel > profile.character.level
|
||||
const equipped = slots.includes(ability.id)
|
||||
return (
|
||||
<button
|
||||
className={`${locked ? 'locked' : ''} ${equipped ? 'equipped' : ''}`}
|
||||
disabled={locked}
|
||||
key={ability.id}
|
||||
onClick={() => equipAbility(ability.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{locked ? 'L' : ability.glyph}</span>
|
||||
<div>
|
||||
<strong>{ability.name}</strong>
|
||||
<small>{ability.description}</small>
|
||||
</div>
|
||||
<i>{locked ? `Level ${ability.unlockLevel}` : equipped ? 'Equipped' : `${ability.cost} ${gameClass.resourceName}`}</i>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="save-row">
|
||||
<span>{message}</span>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={saving}
|
||||
onClick={persistChanges}
|
||||
type="button"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Character'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
breakdownItem,
|
||||
craftItem,
|
||||
equipItem,
|
||||
loadProfile,
|
||||
type CharacterProfile,
|
||||
type EquipmentSlot,
|
||||
type Item,
|
||||
} from '../profile'
|
||||
|
||||
const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
||||
weapon: 'Weapon',
|
||||
helmet: 'Helmet',
|
||||
chest: 'Chest',
|
||||
gloves: 'Gloves',
|
||||
boots: 'Boots',
|
||||
pants: 'Pants',
|
||||
ring: 'Ring',
|
||||
necklace: 'Necklace',
|
||||
trinket: 'Trinket',
|
||||
component: 'Component',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
profile: CharacterProfile
|
||||
onBack?: () => void
|
||||
onUpdated: (profile: CharacterProfile) => void
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
||||
const totalItemCount = profile.inventory.reduce(
|
||||
(total, item) => total + item.quantity,
|
||||
0,
|
||||
)
|
||||
const firstItem = profile.inventory.find((item) => !item.equipped)
|
||||
?? profile.inventory[0]
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(
|
||||
firstItem?.id ?? null,
|
||||
)
|
||||
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null)
|
||||
const [equipping, setEquipping] = useState(false)
|
||||
const [breakingDown, setBreakingDown] = useState(false)
|
||||
const [crafting, setCrafting] = useState(false)
|
||||
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
||||
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
|
||||
const [message, setMessage] = useState('')
|
||||
const scrollRef = useRef<number>(0)
|
||||
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
||||
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
|
||||
?? profile.craftingRecipes[0]
|
||||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
||||
firstRecipe?.id ?? null,
|
||||
)
|
||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
||||
const equippedBySlot = useMemo(
|
||||
() => new Map(
|
||||
profile.inventory
|
||||
.filter((item) => item.equipped)
|
||||
.map((item) => [item.slot, item]),
|
||||
),
|
||||
[profile.inventory],
|
||||
)
|
||||
const comparisonItem = selectedItem
|
||||
? equippedBySlot.get(selectedItem.slot)
|
||||
: undefined
|
||||
const visibleInventory = useMemo(
|
||||
() => selectedSlot
|
||||
? profile.inventory.filter((item) => item.slot === selectedSlot)
|
||||
: profile.inventory,
|
||||
[profile.inventory, selectedSlot],
|
||||
)
|
||||
const visibleItemCount = visibleInventory.reduce(
|
||||
(total, item) => total + item.quantity,
|
||||
0,
|
||||
)
|
||||
|
||||
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
||||
const [levelFilter, setLevelFilter] = useState<number | null>(null)
|
||||
const availableLevels = useMemo(
|
||||
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
||||
[profile.craftingRecipes],
|
||||
)
|
||||
const filteredRecipes = useMemo(
|
||||
() => {
|
||||
let result = [...profile.craftingRecipes]
|
||||
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
|
||||
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
|
||||
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
|
||||
return result
|
||||
},
|
||||
[profile.craftingRecipes, slotFilter, levelFilter],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
}, [profile])
|
||||
|
||||
useEffect(() => {
|
||||
if (equipmentTab === 'crafting') {
|
||||
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
||||
}
|
||||
}, [equipmentTab])
|
||||
|
||||
function saveScroll() {
|
||||
scrollRef.current = window.scrollY
|
||||
}
|
||||
|
||||
async function equipSelected() {
|
||||
if (!selectedItem || selectedItem.equipped) return
|
||||
saveScroll()
|
||||
setEquipping(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await equipItem(selectedItem.id)
|
||||
onUpdated(updated)
|
||||
setMessage(`${selectedItem.name} equipped.`)
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to equip item.')
|
||||
} finally {
|
||||
setEquipping(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function breakdownSelected() {
|
||||
if (!selectedItem) return
|
||||
saveScroll()
|
||||
setBreakingDown(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await breakdownItem(selectedItem.id)
|
||||
onUpdated(updated)
|
||||
setMessage(
|
||||
selectedItem.quantity > 1
|
||||
? `One duplicate ${selectedItem.name} broken down into components.`
|
||||
: `${selectedItem.name} broken down into components.`,
|
||||
)
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to break down item.')
|
||||
} finally {
|
||||
setBreakingDown(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function craftSelected() {
|
||||
if (!selectedRecipe) return
|
||||
saveScroll()
|
||||
setCrafting(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await craftItem(selectedRecipe.id)
|
||||
onUpdated(updated)
|
||||
setSelectedItemId(selectedRecipe.item.id)
|
||||
setMessage(`${selectedRecipe.item.name} crafted.`)
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to craft item.')
|
||||
} finally {
|
||||
setCrafting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && (
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Character Loadout</p>
|
||||
<h1>Equipment</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="gear-summary">
|
||||
<div className="gear-character">
|
||||
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
|
||||
{profile.character.className[0]}
|
||||
</span>
|
||||
<div>
|
||||
<p className="eyebrow">{profile.character.className}</p>
|
||||
<h2>{profile.character.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
|
||||
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
|
||||
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
|
||||
</div>
|
||||
|
||||
<nav className="equipment-tabs">
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('equipment')}
|
||||
type="button"
|
||||
>
|
||||
Equipment
|
||||
</button>
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('crafting')}
|
||||
type="button"
|
||||
>
|
||||
Crafting
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{equipmentTab === 'equipment' ? (
|
||||
<>
|
||||
<section className="item-comparison">
|
||||
{selectedItem ? (
|
||||
selectedItem.slot === 'component' ? (
|
||||
<>
|
||||
<ItemDetail title="Crafting Component" item={selectedItem} />
|
||||
<div className="equip-action">
|
||||
<p className="component-note">Used in crafting.</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
|
||||
<div className="comparison-arrow">vs</div>
|
||||
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
|
||||
<ItemDetail title="Currently Equipped" item={comparisonItem} />
|
||||
) : (
|
||||
<div className="item-detail empty-comparison">
|
||||
<p className="eyebrow">Comparison</p>
|
||||
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<p>Select an item to inspect it.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="equipment-layout">
|
||||
<section className="equipped-panel">
|
||||
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
|
||||
<div className="equipment-slots">
|
||||
{profile.equipmentSlots.map((slot) => {
|
||||
const item = equippedBySlot.get(slot)
|
||||
return (
|
||||
<button
|
||||
className={`${item ? `rarity-${item.rarity}` : 'empty'} ${selectedSlot === slot ? 'selected-slot' : ''}`}
|
||||
key={slot}
|
||||
onClick={() => {
|
||||
setSelectedSlot(slot)
|
||||
const firstSlotItem = profile.inventory.find(
|
||||
(candidate) => candidate.slot === slot,
|
||||
)
|
||||
setSelectedItemId(item?.id ?? firstSlotItem?.id ?? null)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>{item?.glyph ?? '-'}</span>
|
||||
<div>
|
||||
<strong>{item?.name ?? SLOT_LABELS[slot]}</strong>
|
||||
<small>{SLOT_LABELS[slot]}{item ? ` - iLvl ${item.itemLevel}` : ' - Empty'}</small>
|
||||
</div>
|
||||
<div className="item-status">
|
||||
{item && <i>Equipped</i>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="inventory-panel">
|
||||
<EquipmentHeading
|
||||
eyebrow="Owned Items"
|
||||
title={selectedSlot ? `${SLOT_LABELS[selectedSlot]} Inventory` : 'Inventory'}
|
||||
detail={selectedSlot
|
||||
? `${visibleItemCount} items - ${visibleInventory.length} types`
|
||||
: `${totalItemCount} items - ${profile.inventory.length} types`}
|
||||
/>
|
||||
{selectedSlot && (
|
||||
<button
|
||||
className="inventory-filter-clear"
|
||||
onClick={() => setSelectedSlot(null)}
|
||||
type="button"
|
||||
>
|
||||
Show All Items
|
||||
</button>
|
||||
)}
|
||||
<div className="inventory-list">
|
||||
{visibleInventory.map((item) => (
|
||||
<button
|
||||
className={`${selectedItemId === item.id ? 'selected' : ''} rarity-${item.rarity}`}
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{item.glyph}</span>
|
||||
<div>
|
||||
<strong>{item.name}</strong>
|
||||
<small>{SLOT_LABELS[item.slot]} - Item Level {item.itemLevel}</small>
|
||||
</div>
|
||||
<div className="item-status">
|
||||
{item.equipped && <i>Equipped</i>}
|
||||
{item.quantity > 1 && <i className="item-quantity">x{item.quantity}</i>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{visibleInventory.length === 0 && (
|
||||
<p className="inventory-empty">
|
||||
No {SLOT_LABELS[selectedSlot ?? 'component'].toLowerCase()} items owned.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<section className="crafting-panel">
|
||||
<EquipmentHeading
|
||||
eyebrow="Crafting"
|
||||
title="Recipes"
|
||||
detail={`${filteredRecipes.filter((recipe) => recipe.canCraft).length} ready`}
|
||||
/>
|
||||
<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-list">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
<button
|
||||
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
||||
key={recipe.id}
|
||||
onClick={() => setSelectedRecipeId(recipe.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{recipe.item.glyph}</span>
|
||||
<div>
|
||||
<strong>{recipe.item.name}</strong>
|
||||
<small>
|
||||
{SLOT_LABELS[recipe.item.slot]} - Item Level {recipe.item.itemLevel}
|
||||
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
|
||||
</small>
|
||||
</div>
|
||||
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedRecipe && (
|
||||
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
|
||||
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
|
||||
<div className="crafting-components">
|
||||
{selectedRecipe.components.map((component) => (
|
||||
<div
|
||||
className={component.owned >= component.quantity ? 'ready' : 'missing'}
|
||||
key={component.item.id}
|
||||
>
|
||||
<span>{component.item.glyph}</span>
|
||||
<strong>{component.item.name}</strong>
|
||||
<i>{component.owned}/{component.quantity}</i>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={!selectedRecipe.canCraft || crafting}
|
||||
onClick={craftSelected}
|
||||
type="button"
|
||||
>
|
||||
{crafting ? 'Crafting...' : 'Craft Item'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{profile.setBonuses.length > 0 && (
|
||||
<section className="set-bonus-panel">
|
||||
<div className="equipment-heading toggle-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Set Bonuses</p>
|
||||
<h2>Raid Sets</h2>
|
||||
</div>
|
||||
<button
|
||||
className="text-button"
|
||||
onClick={() => setShowSetBonuses((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{showSetBonuses ? 'Hide Raid Sets' : 'Show Raid Sets'}
|
||||
</button>
|
||||
</div>
|
||||
{showSetBonuses && (
|
||||
<div className="set-bonus-list">
|
||||
{profile.setBonuses.map((bonus) => (
|
||||
<div className={bonus.active ? 'active' : ''} key={`${bonus.setId}-${bonus.requiredPieces}`}>
|
||||
<strong>{bonus.requiredPieces} pieces</strong>
|
||||
<span>{bonus.description}</span>
|
||||
<i>{bonus.equippedPieces}/{bonus.requiredPieces}</i>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className="equipment-footer">
|
||||
{message || 'Equipment changes are saved immediately.'}
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return <div className="equipment-screen embedded-screen">{content}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="content-screen equipment-screen">
|
||||
{content}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function GearStat({ value, label }: { value: string; label: string }) {
|
||||
return (
|
||||
<div className="gear-stat">
|
||||
<strong>{value}</strong>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EquipmentHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
detail,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
detail?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="equipment-heading">
|
||||
<div><p className="eyebrow">{eyebrow}</p><h2>{title}</h2></div>
|
||||
{detail && <span>{detail}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDetail({ title, item }: { title: string; item: Item }) {
|
||||
return (
|
||||
<article className={`item-detail rarity-${item.rarity}`}>
|
||||
<p className="eyebrow">{title}</p>
|
||||
<div className="item-title">
|
||||
<span>{item.glyph}</span>
|
||||
<div>
|
||||
<h2>{item.name}</h2>
|
||||
<small>{SLOT_LABELS[item.slot]} - Item Level {item.itemLevel}</small>
|
||||
</div>
|
||||
</div>
|
||||
<p>{item.description}</p>
|
||||
{item.quantity > 1 && <p className="owned-quantity">Owned: {item.quantity}</p>}
|
||||
{item.slot !== 'component' && (
|
||||
<ul>
|
||||
<li>+{item.healingPower} Healing Power</li>
|
||||
<li>+{item.maxResourceBonus} Max Resource</li>
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonDelta({
|
||||
selected,
|
||||
equipped,
|
||||
}: {
|
||||
selected: Item
|
||||
equipped?: Item
|
||||
}) {
|
||||
const healingDelta = selected.healingPower - (equipped?.healingPower ?? 0)
|
||||
const resourceDelta = selected.maxResourceBonus - (equipped?.maxResourceBonus ?? 0)
|
||||
return (
|
||||
<div className="comparison-delta">
|
||||
<span className={healingDelta >= 0 ? 'positive' : 'negative'}>
|
||||
{healingDelta >= 0 ? '+' : ''}{healingDelta} Healing
|
||||
</span>
|
||||
<span className={resourceDelta >= 0 ? 'positive' : 'negative'}>
|
||||
{resourceDelta >= 0 ? '+' : ''}{resourceDelta} Resource
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
ACTION_LABELS,
|
||||
INPUT_ACTIONS,
|
||||
useInput,
|
||||
type InputDevice,
|
||||
} from '../input'
|
||||
import {
|
||||
ControllerBindingLabel,
|
||||
ControllerStylePreview,
|
||||
} from './ControllerIcons'
|
||||
|
||||
const CONTROLLER_STYLE_LABELS = {
|
||||
xbox: 'Xbox',
|
||||
playstation: 'PlayStation',
|
||||
nintendo: 'Nintendo',
|
||||
} as const
|
||||
import { useDualScreen } from '../dualScreen'
|
||||
import {
|
||||
getNativeDisplays,
|
||||
hasNativeDualScreenBridge,
|
||||
type AndroidDisplay,
|
||||
} from '../nativeDualScreen'
|
||||
|
||||
export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
||||
const [device, setDevice] = useState<InputDevice>('controller')
|
||||
const [displayMessage, setDisplayMessage] = useState('')
|
||||
const [androidDisplays, setAndroidDisplays] = useState<AndroidDisplay[]>([])
|
||||
const {
|
||||
bindings,
|
||||
capture,
|
||||
controllerIconStyle,
|
||||
directPartyTargeting,
|
||||
beginCapture,
|
||||
cancelCapture,
|
||||
resetBindings,
|
||||
setControllerIconStyle,
|
||||
setDirectPartyTargeting,
|
||||
} = useInput()
|
||||
const {
|
||||
enabled: dualScreenEnabled,
|
||||
connected: topDisplayConnected,
|
||||
setEnabled: setDualScreenEnabled,
|
||||
openTopDisplay,
|
||||
} = useDualScreen()
|
||||
const nativeDualScreen = hasNativeDualScreenBridge()
|
||||
const directTargetActions = new Set([
|
||||
'targetParty1',
|
||||
'targetParty2',
|
||||
'targetParty3',
|
||||
'targetParty4',
|
||||
'targetParty5',
|
||||
'toggleTargetGroup',
|
||||
])
|
||||
const visibleActions = INPUT_ACTIONS.filter((action) => (
|
||||
directPartyTargeting
|
||||
? action !== 'previousTarget' && action !== 'nextTarget'
|
||||
: !directTargetActions.has(action)
|
||||
))
|
||||
|
||||
async function refreshNativeDisplays() {
|
||||
if (!nativeDualScreen) return
|
||||
try {
|
||||
const result = await getNativeDisplays()
|
||||
setAndroidDisplays(result.displays)
|
||||
} catch {
|
||||
setAndroidDisplays([])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!nativeDualScreen) return
|
||||
getNativeDisplays()
|
||||
.then((result) => setAndroidDisplays(result.displays))
|
||||
.catch(() => setAndroidDisplays([]))
|
||||
}, [nativeDualScreen])
|
||||
|
||||
async function launchTopDisplay() {
|
||||
const opened = await openTopDisplay()
|
||||
setDisplayMessage(opened
|
||||
? nativeDualScreen
|
||||
? 'Android placed the game on the larger display and controls on the smaller display.'
|
||||
: 'Companion display opened. Move it to the Thor screen you want and select Fullscreen.'
|
||||
: 'No usable second display was found. Check the Thor display mode and try again.')
|
||||
await refreshNativeDisplays()
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="content-screen settings-screen">
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Game Options</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
</div>
|
||||
|
||||
<section className="dual-screen-settings">
|
||||
<div>
|
||||
<p className="eyebrow">Display</p>
|
||||
<h2>AYN Thor Dual-Screen Mode</h2>
|
||||
<p>
|
||||
The upper display shows enemy and party health. The lower display
|
||||
keeps targeting, resources, skills, and cooldowns.
|
||||
</p>
|
||||
</div>
|
||||
<div className="dual-screen-actions">
|
||||
<button
|
||||
className={dualScreenEnabled ? 'selected' : ''}
|
||||
onClick={() => {
|
||||
setDualScreenEnabled(!dualScreenEnabled)
|
||||
setDisplayMessage('')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{dualScreenEnabled ? 'Dual-Screen Enabled' : 'Enable Dual-Screen'}
|
||||
</button>
|
||||
<button onClick={launchTopDisplay} type="button">
|
||||
{topDisplayConnected ? 'Companion Connected' : 'Open Companion Display'}
|
||||
</button>
|
||||
</div>
|
||||
<small>
|
||||
{displayMessage || (
|
||||
topDisplayConnected
|
||||
? 'The companion display is connected and receiving live combat data.'
|
||||
: 'Open the companion display before starting combat.'
|
||||
)}
|
||||
</small>
|
||||
{nativeDualScreen && androidDisplays.length > 0 && (
|
||||
<div className="android-display-list">
|
||||
{androidDisplays.map((display) => (
|
||||
<span key={display.id}>
|
||||
<strong>{display.isCurrent ? 'Current' : 'Secondary'} #{display.id}</strong>
|
||||
{display.width}×{display.height} at {Math.round(display.refreshRate)} Hz
|
||||
{display.isPresentation ? ' - Presentation' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="settings-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Input</p>
|
||||
<h2>Keybindings</h2>
|
||||
</div>
|
||||
<p>Select an action, then press the new key or controller control.</p>
|
||||
</div>
|
||||
|
||||
<section className="controller-preferences">
|
||||
<div>
|
||||
<p className="eyebrow">Targeting</p>
|
||||
<h3>Direct Party Keybinds</h3>
|
||||
<p>
|
||||
Assign party slots directly. In raids, use the group-switch binding
|
||||
to alternate between members 1-5 and 6-10.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-pressed={directPartyTargeting}
|
||||
className={directPartyTargeting ? 'selected' : ''}
|
||||
onClick={() => setDirectPartyTargeting(!directPartyTargeting)}
|
||||
type="button"
|
||||
>
|
||||
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
|
||||
</button>
|
||||
<div className="controller-icon-options">
|
||||
<span>Controller Icons</span>
|
||||
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
|
||||
<button
|
||||
aria-pressed={controllerIconStyle === style}
|
||||
className={controllerIconStyle === style ? 'selected' : ''}
|
||||
key={style}
|
||||
onClick={() => setControllerIconStyle(style)}
|
||||
type="button"
|
||||
>
|
||||
<ControllerStylePreview iconStyle={style} />
|
||||
<span className="controller-style-name">{CONTROLLER_STYLE_LABELS[style]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="binding-tabs">
|
||||
<button
|
||||
className={device === 'controller' ? 'selected' : ''}
|
||||
onClick={() => setDevice('controller')}
|
||||
type="button"
|
||||
>
|
||||
Controller
|
||||
</button>
|
||||
<button
|
||||
className={device === 'pc' ? 'selected' : ''}
|
||||
onClick={() => setDevice('pc')}
|
||||
type="button"
|
||||
>
|
||||
PC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="binding-list">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
className={capture?.device === device && capture.action === action ? 'listening' : ''}
|
||||
key={action}
|
||||
onClick={() => beginCapture(device, action)}
|
||||
type="button"
|
||||
>
|
||||
<span>{ACTION_LABELS[action]}</span>
|
||||
<kbd>
|
||||
{capture?.device === device && capture.action === action
|
||||
? 'Press a control...'
|
||||
: (
|
||||
<ControllerBindingLabel
|
||||
binding={bindings[device][action]}
|
||||
iconStyle={controllerIconStyle}
|
||||
/>
|
||||
)}
|
||||
</kbd>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="settings-footer">
|
||||
<span>Bindings are saved automatically on this device.</span>
|
||||
<button className="text-button" onClick={() => resetBindings(device)} type="button">
|
||||
Reset {device === 'pc' ? 'PC' : 'Controller'} Defaults
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
{capture && (
|
||||
<div className="binding-capture" role="dialog" aria-modal="true">
|
||||
<div>
|
||||
<p className="eyebrow">Remapping</p>
|
||||
<h2>{ACTION_LABELS[capture.action]}</h2>
|
||||
<p>
|
||||
Press any {capture.device === 'pc' ? 'keyboard key' : 'controller button or move a stick'}.
|
||||
</p>
|
||||
<button onClick={cancelCapture} type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
allocateTalent,
|
||||
resetTalents,
|
||||
type CharacterProfile,
|
||||
type Talent,
|
||||
} from '../profile'
|
||||
|
||||
type Props = {
|
||||
profile: CharacterProfile
|
||||
onBack?: () => void
|
||||
onUpdated: (profile: CharacterProfile) => void
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
||||
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
||||
const [resetting, setResetting] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const scrollRef = useRef<number>(0)
|
||||
const gameClass = profile.classes.find(
|
||||
(candidate) => candidate.id === profile.character.classId,
|
||||
)!
|
||||
const classPointsSpent = gameClass.talents.reduce(
|
||||
(total, talent) => total + talent.rank,
|
||||
0,
|
||||
)
|
||||
const tiers = Array.from(
|
||||
new Set(gameClass.talents.map((talent) => talent.tier)),
|
||||
).sort((a, b) => a - b)
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
}, [profile])
|
||||
|
||||
function saveScroll() {
|
||||
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) {
|
||||
if (talent.rank >= talent.maxRank) return 'Maximum rank'
|
||||
|
||||
const requiredTierPoints = (talent.tier - 1) * 5
|
||||
if (lowerTierPoints(talent) < requiredTierPoints) {
|
||||
return `Requires ${requiredTierPoints} earlier-tier points`
|
||||
}
|
||||
|
||||
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 ''
|
||||
}
|
||||
|
||||
async function purchaseRank(talent: Talent) {
|
||||
saveScroll()
|
||||
setBusyTalentId(talent.id)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await allocateTalent(talent.id)
|
||||
onUpdated(updated)
|
||||
setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`)
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.')
|
||||
} finally {
|
||||
setBusyTalentId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function refundTree() {
|
||||
saveScroll()
|
||||
setResetting(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await resetTalents()
|
||||
onUpdated(updated)
|
||||
setMessage('All points in this talent tree were refunded.')
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.')
|
||||
} finally {
|
||||
setResetting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && (
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Character Growth</p>
|
||||
<h1>Talents</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="talent-toolbar">
|
||||
<div className="talent-class-summary">
|
||||
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
||||
{gameClass.name[0]}
|
||||
</span>
|
||||
<div>
|
||||
<p className="eyebrow">{gameClass.name} Tree</p>
|
||||
<h2>Shape Your Healing Style</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="talent-points">
|
||||
<strong>{profile.character.talentPoints}</strong>
|
||||
<span>Available</span>
|
||||
<small>{classPointsSpent} spent in this tree</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="talent-tree">
|
||||
{tiers.map((tier) => {
|
||||
const requiredPoints = (tier - 1) * 5
|
||||
return (
|
||||
<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 className="tier-talents">
|
||||
{gameClass.talents
|
||||
.filter((talent) => talent.tier === tier)
|
||||
.sort((a, b) => a.branch - b.branch)
|
||||
.map((talent) => {
|
||||
const reason = lockReason(talent)
|
||||
const isBusy = busyTalentId === talent.id
|
||||
return (
|
||||
<article
|
||||
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
|
||||
key={talent.id}
|
||||
style={{ gridColumn: talent.branch }}
|
||||
>
|
||||
<div className="talent-node-header">
|
||||
<span>{talent.glyph}</span>
|
||||
<div>
|
||||
<strong>{talent.name}</strong>
|
||||
<small>Rank {talent.rank}/{talent.maxRank}</small>
|
||||
</div>
|
||||
</div>
|
||||
<p>{talent.description}</p>
|
||||
<div className="rank-pips">
|
||||
{Array.from({ length: talent.maxRank }, (_, index) => (
|
||||
<i className={index < talent.rank ? 'filled' : ''} key={index} />
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
disabled={Boolean(reason) || isBusy}
|
||||
onClick={() => purchaseRank(talent)}
|
||||
type="button"
|
||||
>
|
||||
{isBusy ? 'Saving...' : reason || 'Add Rank'}
|
||||
</button>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<footer className="talent-footer">
|
||||
<span>{message || 'Talent changes are saved immediately.'}</span>
|
||||
<button
|
||||
className="text-button"
|
||||
disabled={classPointsSpent === 0 || resetting}
|
||||
onClick={refundTree}
|
||||
type="button"
|
||||
>
|
||||
{resetting ? 'Refunding...' : 'Reset Tree'}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return <div className="talent-screen embedded-screen">{content}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="content-screen talent-screen">
|
||||
{content}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { CombatLogEntry, PartyMember, Spell } from './game'
|
||||
import {
|
||||
hasNativeDualScreenBridge,
|
||||
openNativeTopDisplay,
|
||||
} from './nativeDualScreen'
|
||||
import {
|
||||
dispatchExternalGameAction,
|
||||
type ControllerIconStyle,
|
||||
type InputAction,
|
||||
} from './input'
|
||||
import { ControllerBindingLabel } from './components/ControllerIcons'
|
||||
|
||||
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
|
||||
const SNAPSHOT_KEY = 'ashen-halls-dual-screen-snapshot'
|
||||
const CHANNEL_NAME = 'ashen-halls-dual-screen'
|
||||
|
||||
export type DualScreenCombatState = {
|
||||
difficultyName: string
|
||||
dungeonName: string
|
||||
contentName: string
|
||||
encounterName: string
|
||||
encounterDescription: string
|
||||
encounterHealth: number
|
||||
encounterMaxHealth: number
|
||||
encounterIsBoss: boolean
|
||||
encounterIndex: number
|
||||
encounterCount: number
|
||||
party: PartyMember[]
|
||||
partySize: number
|
||||
selectedId: string
|
||||
log: CombatLogEntry[]
|
||||
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'
|
||||
resource: number
|
||||
maxResource: number
|
||||
resourceName: string
|
||||
playerIsAlive: boolean
|
||||
spells: Array<(Spell & { slotIndex: number; remaining: number }) | null>
|
||||
activeDevice: 'pc' | 'controller'
|
||||
bindings: Record<InputAction, string>
|
||||
controllerIconStyle: ControllerIconStyle
|
||||
directPartyTargeting: boolean
|
||||
paused: boolean
|
||||
targetGroup: 0 | 1
|
||||
}
|
||||
|
||||
type DualScreenMessage =
|
||||
| { type: 'combat-state'; state: DualScreenCombatState }
|
||||
| { type: 'companion-ready' }
|
||||
| { type: 'companion-heartbeat' }
|
||||
| { type: 'control-action'; action: InputAction }
|
||||
| { type: 'combat-ended' }
|
||||
|
||||
type DualScreenContextValue = {
|
||||
enabled: boolean
|
||||
connected: boolean
|
||||
setEnabled: (enabled: boolean) => void
|
||||
openTopDisplay: () => Promise<boolean>
|
||||
}
|
||||
|
||||
const DualScreenContext = createContext<DualScreenContextValue | null>(null)
|
||||
|
||||
function createChannel() {
|
||||
return typeof BroadcastChannel === 'undefined'
|
||||
? null
|
||||
: new BroadcastChannel(CHANNEL_NAME)
|
||||
}
|
||||
|
||||
function saveSnapshot(state: DualScreenCombatState) {
|
||||
try {
|
||||
localStorage.setItem(SNAPSHOT_KEY, JSON.stringify({
|
||||
savedAt: Date.now(),
|
||||
state,
|
||||
}))
|
||||
} catch {
|
||||
// Live BroadcastChannel updates still work if storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function loadRecentSnapshot() {
|
||||
try {
|
||||
const snapshot = JSON.parse(localStorage.getItem(SNAPSHOT_KEY) ?? 'null') as {
|
||||
savedAt: number
|
||||
state: DualScreenCombatState
|
||||
} | null
|
||||
if (!snapshot || Date.now() - snapshot.savedAt > 15000) return null
|
||||
return snapshot.state
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||
const [enabled, setEnabledState] = useState(
|
||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||
)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const heartbeatRef = useRef(0)
|
||||
|
||||
const setEnabled = useCallback((nextEnabled: boolean) => {
|
||||
localStorage.setItem(STORAGE_KEY, String(nextEnabled))
|
||||
setEnabledState(nextEnabled)
|
||||
if (!nextEnabled) setConnected(false)
|
||||
}, [])
|
||||
|
||||
const openTopDisplay = useCallback(async () => {
|
||||
setEnabled(true)
|
||||
if (hasNativeDualScreenBridge()) {
|
||||
try {
|
||||
await openNativeTopDisplay()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('display', 'bottom')
|
||||
const companion = window.open(
|
||||
url.toString(),
|
||||
'ashen-halls-top-display',
|
||||
'popup=yes,width=1280,height=720',
|
||||
)
|
||||
companion?.focus()
|
||||
return Boolean(companion)
|
||||
}, [setEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const channel = createChannel()
|
||||
if (!channel) return
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (
|
||||
event.data.type !== 'companion-ready'
|
||||
&& event.data.type !== 'companion-heartbeat'
|
||||
) return
|
||||
heartbeatRef.current = Date.now()
|
||||
setConnected(true)
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
if (Date.now() - heartbeatRef.current > 3500) setConnected(false)
|
||||
}, 1000)
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
channel.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled, connected, setEnabled, openTopDisplay }),
|
||||
[connected, enabled, openTopDisplay, setEnabled],
|
||||
)
|
||||
|
||||
return (
|
||||
<DualScreenContext.Provider value={value}>
|
||||
{children}
|
||||
</DualScreenContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDualScreen() {
|
||||
const context = useContext(DualScreenContext)
|
||||
if (!context) throw new Error('useDualScreen must be used inside DualScreenProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
export function useDualScreenPublisher(
|
||||
state: DualScreenCombatState,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const stateRef = useRef(state)
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
const channel = createChannel()
|
||||
if (!channel) return
|
||||
const publish = () => channel.postMessage({
|
||||
type: 'combat-state',
|
||||
state: stateRef.current,
|
||||
} satisfies DualScreenMessage)
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === 'companion-ready') publish()
|
||||
if (event.data.type === 'control-action') {
|
||||
dispatchExternalGameAction(event.data.action, 'controller')
|
||||
}
|
||||
}
|
||||
publish()
|
||||
return () => {
|
||||
channel.postMessage({ type: 'combat-ended' } satisfies DualScreenMessage)
|
||||
channel.close()
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
saveSnapshot(state)
|
||||
const channel = createChannel()
|
||||
channel?.postMessage({ type: 'combat-state', state } satisfies DualScreenMessage)
|
||||
channel?.close()
|
||||
}, [enabled, state])
|
||||
}
|
||||
|
||||
export function DualScreenBottomDisplay() {
|
||||
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
||||
|
||||
useEffect(() => {
|
||||
const channel = createChannel()
|
||||
if (!channel) return
|
||||
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === 'combat-state') setState(event.data.state)
|
||||
if (event.data.type === 'combat-ended') setState(null)
|
||||
}
|
||||
announce()
|
||||
const timer = window.setInterval(() => {
|
||||
channel.postMessage({ type: 'companion-heartbeat' } satisfies DualScreenMessage)
|
||||
}, 1500)
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
channel.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
function sendAction(action: InputAction) {
|
||||
const channel = createChannel()
|
||||
channel?.postMessage({ type: 'control-action', action } satisfies DualScreenMessage)
|
||||
channel?.close()
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<main className="dual-bottom-display dual-bottom-waiting">
|
||||
<section>
|
||||
<p className="eyebrow">Dual-Screen HUD</p>
|
||||
<h1>Waiting for Combat</h1>
|
||||
<p>Choose a dungeon or raid on the upper screen.</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="dual-bottom-display">
|
||||
<header className="dual-controls-header">
|
||||
<div>
|
||||
<p className="eyebrow">{state.difficultyName} {state.contentName}</p>
|
||||
<h1>{state.dungeonName}</h1>
|
||||
</div>
|
||||
<div className="dual-controls-progress">
|
||||
<span>Encounter {state.encounterIndex + 1}/{state.encounterCount}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="dual-controls-resource">
|
||||
<div>
|
||||
<p className="eyebrow">Active Target</p>
|
||||
<strong>
|
||||
{state.party.find((member) => member.id === state.selectedId)?.name ?? 'No Target'}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="dual-controls-mana">
|
||||
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={`dual-controls-targets ${state.directPartyTargeting ? 'direct' : ''}`}>
|
||||
{state.directPartyTargeting ? (
|
||||
<>
|
||||
{([1, 2, 3, 4, 5] as const).map((slot) => {
|
||||
const action = `targetParty${slot}` as InputAction
|
||||
const memberIndex = slot - 1 + (state.partySize === 10 ? state.targetGroup * 5 : 0)
|
||||
return (
|
||||
<button onClick={() => sendAction(action)} type="button" key={action}>
|
||||
<ControllerBindingLabel
|
||||
binding={state.bindings[action]}
|
||||
iconStyle={state.controllerIconStyle}
|
||||
/>{' '}
|
||||
{state.party[memberIndex]?.name ?? `Party ${slot}`}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{state.partySize === 10 && (
|
||||
<button onClick={() => sendAction('toggleTargetGroup')} type="button">
|
||||
<ControllerBindingLabel
|
||||
binding={state.bindings.toggleTargetGroup}
|
||||
iconStyle={state.controllerIconStyle}
|
||||
/>{' '}
|
||||
Party Group {state.targetGroup + 1}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => sendAction('previousTarget')} type="button">
|
||||
<ControllerBindingLabel
|
||||
binding={state.bindings.previousTarget}
|
||||
iconStyle={state.controllerIconStyle}
|
||||
/> Previous Target
|
||||
</button>
|
||||
<button onClick={() => sendAction('nextTarget')} type="button">
|
||||
Next Target <ControllerBindingLabel
|
||||
binding={state.bindings.nextTarget}
|
||||
iconStyle={state.controllerIconStyle}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="dual-controls-spells">
|
||||
{state.spells.map((spell, slotIndex) => {
|
||||
if (!spell) {
|
||||
return (
|
||||
<div className="spell empty-spell" key={`empty-${slotIndex}`}>
|
||||
<kbd>{slotIndex + 1}</kbd>
|
||||
<strong>Empty</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const action = `ability${slotIndex + 1}` as InputAction
|
||||
return (
|
||||
<button
|
||||
className="spell"
|
||||
disabled={
|
||||
!state.playerIsAlive
|
||||
|| state.resource < spell.cost
|
||||
|| spell.remaining > 0
|
||||
|| state.status !== 'playing'
|
||||
|| state.paused
|
||||
}
|
||||
key={spell.id}
|
||||
onClick={() => sendAction(action)}
|
||||
type="button"
|
||||
>
|
||||
<kbd>
|
||||
<ControllerBindingLabel
|
||||
binding={state.bindings[action]}
|
||||
compact
|
||||
iconStyle={state.controllerIconStyle}
|
||||
/>
|
||||
</kbd>
|
||||
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||
<strong>{spell.name}</strong>
|
||||
<small>{spell.cost} {state.resourceName}</small>
|
||||
{spell.remaining > 0 && <i>{spell.remaining.toFixed(1)}</i>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function DualScreenTopCombat({
|
||||
state,
|
||||
onSelectTarget,
|
||||
}: {
|
||||
state: DualScreenCombatState
|
||||
onSelectTarget: (id: string) => void
|
||||
}) {
|
||||
const enemyPercent = Math.max(
|
||||
0,
|
||||
(state.encounterHealth / state.encounterMaxHealth) * 100,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="dual-top-main">
|
||||
<section className="dual-top-enemy">
|
||||
<div className="enemy-portrait" aria-hidden="true">
|
||||
{state.encounterIsBoss ? 'B' : 'M'}
|
||||
</div>
|
||||
<div className="enemy-info">
|
||||
<div className="bar-label">
|
||||
<strong>{state.encounterName}</strong>
|
||||
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
|
||||
</div>
|
||||
<div className="bar enemy-health">
|
||||
<span style={{ width: `${enemyPercent}%` }} />
|
||||
</div>
|
||||
<p>{state.encounterDescription}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dual-top-party">
|
||||
<div className={`dual-top-party-grid ${state.partySize === 10 ? 'raid' : ''}`}>
|
||||
{state.party.map((member, index) => {
|
||||
const partySlot = index + 1 + (state.directPartyTargeting && state.partySize === 10 ? state.targetGroup * 5 : 0)
|
||||
const targetAction = `targetParty${partySlot}` as InputAction
|
||||
const targetBinding = state.directPartyTargeting ? state.bindings[targetAction] : null
|
||||
return (
|
||||
<button
|
||||
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
||||
key={member.id}
|
||||
onClick={() => onSelectTarget(member.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="member-header">
|
||||
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
||||
<strong>{member.name}</strong>
|
||||
<small>{Math.ceil(member.health)} / {member.maxHealth}</small>
|
||||
</div>
|
||||
<div className="bar member-health">
|
||||
<span style={{ width: `${(member.health / member.maxHealth) * 100}%` }} />
|
||||
{member.shield > 0 && (
|
||||
<i style={{ width: `${(member.shield / member.maxHealth) * 100}%` }} />
|
||||
)}
|
||||
</div>
|
||||
{state.directPartyTargeting && targetBinding && (
|
||||
<div className="member-target-key">
|
||||
<ControllerBindingLabel
|
||||
binding={targetBinding}
|
||||
iconStyle={state.controllerIconStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="member-effects">
|
||||
{member.hotTicks > 0 && <span className="buff">Renew</span>}
|
||||
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="dual-top-log">
|
||||
{state.log.slice(0, 3).map((entry) => (
|
||||
<span className={entry.tone} key={entry.id}>{entry.text}</span>
|
||||
))}
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
export type Role = 'Tank' | 'Healer' | 'Damage'
|
||||
|
||||
export type PartyMember = {
|
||||
id: string
|
||||
name: string
|
||||
role: Role
|
||||
health: number
|
||||
maxHealth: number
|
||||
shield: number
|
||||
hotTicks: number
|
||||
debuff?: string
|
||||
debuffTicks?: number
|
||||
poisonStacks?: number
|
||||
maxHealthPenaltyTicks?: number
|
||||
healingReductionTicks?: number
|
||||
}
|
||||
|
||||
export type Spell = {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
description: string
|
||||
cost: number
|
||||
cooldown: number
|
||||
power: number
|
||||
glyph: string
|
||||
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse'
|
||||
}
|
||||
|
||||
export type Encounter = {
|
||||
id: string
|
||||
enemyName: string
|
||||
description: string
|
||||
maxHealth: number
|
||||
damage: number
|
||||
tankDamage: number
|
||||
partyDamage: number
|
||||
isBoss: boolean
|
||||
}
|
||||
|
||||
export type CombatLogEntry = {
|
||||
id: number
|
||||
text: string
|
||||
tone: 'system' | 'heal' | 'danger' | 'loot'
|
||||
}
|
||||
|
||||
export const INITIAL_PARTY: PartyMember[] = [
|
||||
{ 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: 'kael', name: 'Kael', role: 'Damage', health: 105, maxHealth: 105, shield: 0, hotTicks: 0 },
|
||||
{ id: 'ves', name: 'Ves', role: 'Damage', health: 95, maxHealth: 95, shield: 0, hotTicks: 0 },
|
||||
{ id: 'orin', name: 'Orin', role: 'Damage', health: 110, maxHealth: 110, shield: 0, hotTicks: 0 },
|
||||
]
|
||||
|
||||
export const RAID_PARTY: PartyMember[] = [
|
||||
{ id: 'brann', name: 'Brann', role: 'Tank', health: 165, maxHealth: 165, shield: 0, hotTicks: 0 },
|
||||
{ id: 'tala', name: 'Tala', role: 'Tank', health: 155, maxHealth: 155, shield: 0, hotTicks: 0 },
|
||||
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
|
||||
{ id: 'seren', name: 'Seren', role: 'Healer', health: 102, maxHealth: 102, 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: '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 },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
export const SPELLS: Spell[] = [
|
||||
{
|
||||
id: 'mend',
|
||||
key: '1',
|
||||
name: 'Mend',
|
||||
description: 'A fast, efficient single-target heal.',
|
||||
cost: 5,
|
||||
cooldown: 0.5,
|
||||
power: 30,
|
||||
glyph: '+',
|
||||
kind: 'direct',
|
||||
},
|
||||
{
|
||||
id: 'renew',
|
||||
key: '2',
|
||||
name: 'Renew',
|
||||
description: 'Heals now and continues healing over time.',
|
||||
cost: 7,
|
||||
cooldown: 0.5,
|
||||
power: 12,
|
||||
glyph: '~',
|
||||
kind: 'hot',
|
||||
},
|
||||
{
|
||||
id: 'radiance',
|
||||
key: '3',
|
||||
name: 'Radiance',
|
||||
description: 'Restores health to every living party member.',
|
||||
cost: 12,
|
||||
cooldown: 8,
|
||||
power: 18,
|
||||
glyph: '*',
|
||||
kind: 'group',
|
||||
},
|
||||
{
|
||||
id: 'ward',
|
||||
key: '4',
|
||||
name: 'Sun Ward',
|
||||
description: 'Places a damage-absorbing shield on your target.',
|
||||
cost: 8,
|
||||
cooldown: 7,
|
||||
power: 36,
|
||||
glyph: 'O',
|
||||
kind: 'shield',
|
||||
},
|
||||
{
|
||||
id: 'purify',
|
||||
key: '5',
|
||||
name: 'Purify',
|
||||
description: 'Removes a harmful effect and restores a little health.',
|
||||
cost: 5,
|
||||
cooldown: 5,
|
||||
power: 10,
|
||||
glyph: 'x',
|
||||
kind: 'cleanse',
|
||||
},
|
||||
]
|
||||
|
||||
export const ENCOUNTERS: Encounter[] = [
|
||||
{
|
||||
id: 'ashfang-pack',
|
||||
enemyName: 'Ashfang Pack',
|
||||
description: 'Three beasts snap at random party members.',
|
||||
maxHealth: 390,
|
||||
damage: 13,
|
||||
tankDamage: 7,
|
||||
partyDamage: 24,
|
||||
isBoss: false,
|
||||
},
|
||||
{
|
||||
id: 'cinder-adepts',
|
||||
enemyName: 'Cinder Adepts',
|
||||
description: 'Cultists pressure the tank while throwing embers into the group.',
|
||||
maxHealth: 470,
|
||||
damage: 16,
|
||||
tankDamage: 10,
|
||||
partyDamage: 25,
|
||||
isBoss: false,
|
||||
},
|
||||
{
|
||||
id: 'warden-vhal',
|
||||
enemyName: 'Warden Vhal',
|
||||
description: 'Boss: cleanse Searing Mark and prepare group healing for Cinder Pulse.',
|
||||
maxHealth: 820,
|
||||
damage: 18,
|
||||
tankDamage: 13,
|
||||
partyDamage: 27,
|
||||
isBoss: true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,946 @@
|
||||
import starterProfile from './offline-starter-profile.json'
|
||||
import type {
|
||||
AuthSession,
|
||||
CharacterProfile,
|
||||
DungeonReward,
|
||||
LootRoll,
|
||||
Item,
|
||||
EquipmentSlot,
|
||||
} from './profile'
|
||||
|
||||
export type GameMode = 'online' | 'offline'
|
||||
|
||||
export interface GameRepository {
|
||||
loadSession(): Promise<AuthSession>
|
||||
register(username: string, password: string, characterName: string): Promise<AuthSession>
|
||||
login(username: string, password: string): Promise<AuthSession>
|
||||
logout(): Promise<void>
|
||||
loadProfile(): Promise<CharacterProfile>
|
||||
saveProfile(classId: number, abilitySlots: Array<number | null>): Promise<CharacterProfile>
|
||||
completeDungeon(
|
||||
dungeonId: number,
|
||||
difficultyId: number,
|
||||
resourceSpent: number,
|
||||
durationSeconds: number,
|
||||
completedPart?: number,
|
||||
startPart?: number,
|
||||
partDurationSeconds?: [number, number, number],
|
||||
): Promise<DungeonReward>
|
||||
completeRoguelike(
|
||||
dungeonId: number,
|
||||
difficultyId: number,
|
||||
encountersCleared: number,
|
||||
resourceSpent: number,
|
||||
durationSeconds: number,
|
||||
options?: {
|
||||
bossesCleared?: number
|
||||
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
||||
},
|
||||
): Promise<DungeonReward>
|
||||
allocateTalent(talentId: number): Promise<CharacterProfile>
|
||||
resetTalents(): Promise<CharacterProfile>
|
||||
equipItem(itemId: number): Promise<CharacterProfile>
|
||||
discardExtraItem(itemId: number): Promise<CharacterProfile>
|
||||
breakdownItem(itemId: number): Promise<CharacterProfile>
|
||||
craftItem(recipeId: number): Promise<CharacterProfile>
|
||||
rollEncounterLoot(
|
||||
encounterId: number,
|
||||
difficultyId: number,
|
||||
runToken: string,
|
||||
): Promise<LootRoll>
|
||||
}
|
||||
|
||||
type CharacterData = {
|
||||
level: number
|
||||
experience: number
|
||||
talentPoints: number
|
||||
abilitySlots: Array<number | null>
|
||||
talentRanks: Record<string, number>
|
||||
inventory: Item[]
|
||||
}
|
||||
|
||||
type OfflineSave = {
|
||||
version: 3
|
||||
characterName: string
|
||||
activeClassId: number
|
||||
completedDungeonParts: number
|
||||
completedRaidPhases: number
|
||||
characters: Record<number, CharacterData>
|
||||
lootRolls: Record<string, LootRoll>
|
||||
}
|
||||
|
||||
const modeKey = 'chronicle.gameMode'
|
||||
const offlineSaveKey = 'chronicle.offlineSave.v1'
|
||||
const offlineAccount = { id: -1, username: 'Offline' }
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
function readMode(): GameMode {
|
||||
return localStorage.getItem(modeKey) === 'offline' ? 'offline' : 'online'
|
||||
}
|
||||
|
||||
function writeMode(mode: GameMode) {
|
||||
localStorage.setItem(modeKey, mode)
|
||||
}
|
||||
|
||||
function readOfflineSave(): OfflineSave | null {
|
||||
const serialized = localStorage.getItem(offlineSaveKey)
|
||||
if (!serialized) return null
|
||||
try {
|
||||
const raw = JSON.parse(serialized)
|
||||
if (raw.version === 3) return raw as OfflineSave
|
||||
if (raw.version === 2) return migrateV2ToV3(raw)
|
||||
if (raw.version === 1) return migrateV1ToV2(raw)
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }): OfflineSave {
|
||||
const p = v1.profile
|
||||
const classes = [1, 2, 3]
|
||||
const characters: Record<number, CharacterData> = {}
|
||||
for (const cid of classes) {
|
||||
const gameClass = p.classes.find((c) => c.id === cid)!
|
||||
const talentRanks: Record<string, number> = {}
|
||||
for (const t of gameClass.talents) {
|
||||
talentRanks[String(t.id)] = t.rank
|
||||
}
|
||||
characters[cid] = {
|
||||
level: cid === p.character.classId ? p.character.level : 1,
|
||||
experience: cid === p.character.classId ? p.character.experience : 0,
|
||||
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
|
||||
abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [],
|
||||
talentRanks,
|
||||
inventory: cid === p.character.classId ? clone(p.inventory) : [],
|
||||
}
|
||||
}
|
||||
const v2: OfflineSave = {
|
||||
version: 3,
|
||||
characterName: p.character.name,
|
||||
activeClassId: p.character.classId,
|
||||
completedDungeonParts: p.completedDungeonParts,
|
||||
completedRaidPhases: p.completedRaidPhases ?? 0,
|
||||
characters,
|
||||
lootRolls: v1.lootRolls ?? {},
|
||||
}
|
||||
localStorage.setItem(offlineSaveKey, JSON.stringify(v2))
|
||||
return v2
|
||||
}
|
||||
|
||||
function migrateV2ToV3(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
|
||||
const v3: OfflineSave = {
|
||||
...v2,
|
||||
version: 3,
|
||||
completedRaidPhases: 0,
|
||||
}
|
||||
localStorage.setItem(offlineSaveKey, JSON.stringify(v3))
|
||||
return v3
|
||||
}
|
||||
|
||||
function writeOfflineSave(save: OfflineSave) {
|
||||
localStorage.setItem(offlineSaveKey, JSON.stringify(save))
|
||||
}
|
||||
|
||||
function requireOfflineSave(): OfflineSave {
|
||||
const save = readOfflineSave()
|
||||
if (!save) throw new Error('No offline character exists yet.')
|
||||
return save
|
||||
}
|
||||
|
||||
function buildProfile(save: OfflineSave): CharacterProfile {
|
||||
const static_ = clone(starterProfile) as CharacterProfile
|
||||
const cd = save.characters[save.activeClassId]
|
||||
const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!
|
||||
|
||||
static_.character.name = save.characterName
|
||||
static_.character.level = cd.level
|
||||
static_.character.experience = cd.experience
|
||||
static_.character.talentPoints = cd.talentPoints
|
||||
static_.character.classId = gameClass.id
|
||||
static_.character.classSlug = gameClass.slug
|
||||
static_.character.className = gameClass.name
|
||||
static_.character.resourceName = gameClass.resourceName
|
||||
static_.character.maxResource = gameClass.maxResource
|
||||
static_.character.themeColor = gameClass.themeColor
|
||||
static_.character.classDescription = gameClass.description
|
||||
static_.character.currentLevelExperience = experienceForLevel(cd.level)
|
||||
static_.character.nextLevelExperience = cd.level >= static_.maxLevel
|
||||
? experienceForLevel(static_.maxLevel)
|
||||
: experienceForLevel(cd.level + 1)
|
||||
|
||||
static_.abilitySlots = cd.abilitySlots
|
||||
|
||||
for (const c of static_.classes) {
|
||||
for (const t of c.talents) {
|
||||
t.rank = cd.talentRanks[String(t.id)] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
static_.allocatedTalentPoints = gameClass.talents.reduce((s, t) => s + t.rank, 0)
|
||||
|
||||
static_.inventory = cd.inventory
|
||||
updateGearStats(static_)
|
||||
updateSetBonuses(static_)
|
||||
updateCraftingRecipes(static_)
|
||||
static_.completedDungeonParts = save.completedDungeonParts
|
||||
static_.completedRaidPhases = save.completedRaidPhases
|
||||
|
||||
return static_
|
||||
}
|
||||
|
||||
function updateGearStats(profile: CharacterProfile) {
|
||||
const equipped = profile.inventory.filter((item) => item.equipped)
|
||||
profile.gearStats = {
|
||||
averageItemLevel: profile.equipmentSlots.length === 0
|
||||
? 0
|
||||
: equipped.reduce((total, item) => total + item.itemLevel, 0)
|
||||
/ profile.equipmentSlots.length,
|
||||
healingPower: equipped.reduce((total, item) => total + item.healingPower, 0),
|
||||
maxResourceBonus: equipped.reduce(
|
||||
(total, item) => total + item.maxResourceBonus,
|
||||
0,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function updateSetBonuses(profile: CharacterProfile) {
|
||||
const equippedSetCounts = new Map<number, number>()
|
||||
for (const item of profile.inventory) {
|
||||
if (!item.equipped || !item.setId) continue
|
||||
equippedSetCounts.set(item.setId, (equippedSetCounts.get(item.setId) ?? 0) + 1)
|
||||
}
|
||||
profile.setBonuses = (profile.setBonuses ?? []).map((bonus) => {
|
||||
const equippedPieces = equippedSetCounts.get(bonus.setId) ?? 0
|
||||
return {
|
||||
...bonus,
|
||||
equippedPieces,
|
||||
active: equippedPieces >= bonus.requiredPieces,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateCraftingRecipes(profile: CharacterProfile) {
|
||||
const owned = new Map(profile.inventory.map((item) => [item.id, item.quantity]))
|
||||
profile.craftingRecipes = (profile.craftingRecipes ?? []).map((recipe) => {
|
||||
const components = recipe.components.map((component) => ({
|
||||
...component,
|
||||
owned: owned.get(component.item.id) ?? 0,
|
||||
}))
|
||||
return {
|
||||
...recipe,
|
||||
components,
|
||||
canCraft: components.every((component) => component.owned >= component.quantity),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function addInventoryItem(inventory: Item[], item: Omit<Item, 'quantity' | 'equipped'>, quantity: number) {
|
||||
const existing = inventory.find((candidate) => candidate.id === item.id)
|
||||
if (existing) {
|
||||
existing.quantity += quantity
|
||||
return { duplicate: true, quantityAfter: existing.quantity }
|
||||
}
|
||||
inventory.push({
|
||||
...item,
|
||||
quantity,
|
||||
equipped: false,
|
||||
})
|
||||
return { duplicate: false, quantityAfter: quantity }
|
||||
}
|
||||
|
||||
function experienceForLevel(level: number) {
|
||||
return (level - 1) * (level - 1) * 100
|
||||
}
|
||||
|
||||
function scaledPvpBossExperience(
|
||||
startingExperience: number,
|
||||
startingLevel: number,
|
||||
bossesCleared: number,
|
||||
maxLevel: number,
|
||||
) {
|
||||
let experience = startingExperience
|
||||
let level = startingLevel
|
||||
const maxExperience = experienceForLevel(maxLevel)
|
||||
for (let bossIndex = 0; bossIndex < bossesCleared && experience < maxExperience; bossIndex += 1) {
|
||||
const currentLevelFloor = experienceForLevel(level)
|
||||
const nextLevelExperience = level >= maxLevel
|
||||
? maxExperience
|
||||
: experienceForLevel(level + 1)
|
||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25))
|
||||
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
|
||||
level += 1
|
||||
}
|
||||
}
|
||||
return { experience, level }
|
||||
}
|
||||
|
||||
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
|
||||
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
||||
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
|
||||
5: { id: 601, slug: 'basic-component', name: 'Basic Component', itemLevel: 5, glyph: '◇', description: 'A standard crafting component.' },
|
||||
10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' },
|
||||
15: { id: 603, slug: 'advanced-component', name: 'Advanced Component', itemLevel: 15, glyph: '◉', description: 'An advanced crafting component.' },
|
||||
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
|
||||
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
|
||||
}
|
||||
|
||||
type WindowWithApiBase = Window & {
|
||||
CAPACITOR_API_BASE_URL?: string
|
||||
}
|
||||
|
||||
function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined {
|
||||
const levels = Object.keys(COMPONENT_ITEMS).map(Number).sort((a, b) => a - b)
|
||||
let best = levels[0]
|
||||
for (const level of levels) {
|
||||
if (level <= itemLevel) best = level
|
||||
}
|
||||
return COMPONENT_ITEMS[best]
|
||||
}
|
||||
|
||||
function componentDropQuantity(itemLevel: number) {
|
||||
const tier = Math.max(0, Math.floor((itemLevel - 5) / 5))
|
||||
const secondChance = Math.min(0.85, 0.35 + tier * 0.12)
|
||||
const thirdChance = Math.min(0.6, 0.1 + tier * 0.1)
|
||||
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
|
||||
}
|
||||
|
||||
function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]): T {
|
||||
const totalWeight = entries.reduce((total, entry) => total + entry.dropWeight, 0)
|
||||
let weightedRoll = Math.random() * totalWeight
|
||||
for (const entry of entries) {
|
||||
weightedRoll -= entry.dropWeight
|
||||
if (weightedRoll < 0) return entry
|
||||
}
|
||||
return entries[entries.length - 1]
|
||||
}
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
const browserWindow = typeof window === 'undefined'
|
||||
? undefined
|
||||
: window as WindowWithApiBase
|
||||
if (browserWindow?.CAPACITOR_API_BASE_URL) {
|
||||
return browserWindow.CAPACITOR_API_BASE_URL
|
||||
}
|
||||
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) {
|
||||
return import.meta.env.VITE_API_BASE_URL
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const baseUrl = getApiBaseUrl()
|
||||
const url = baseUrl ? `${baseUrl}${path}` : path
|
||||
const response = await fetch(url, init)
|
||||
const body = await response.json()
|
||||
if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.')
|
||||
return body
|
||||
}
|
||||
|
||||
const serverRepository: GameRepository = {
|
||||
loadSession: () => requestJson('/api/auth/session'),
|
||||
register: (username, password, characterName) =>
|
||||
requestJson('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, characterName }),
|
||||
}),
|
||||
login: (username, password) =>
|
||||
requestJson('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
logout: async () => {
|
||||
await requestJson('/api/auth/logout', { method: 'POST' })
|
||||
},
|
||||
loadProfile: () => requestJson('/api/profile'),
|
||||
saveProfile: (classId, abilitySlots) =>
|
||||
requestJson('/api/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ classId, abilitySlots }),
|
||||
}),
|
||||
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
|
||||
requestJson(`/api/dungeons/${dungeonId}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds }),
|
||||
}),
|
||||
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
||||
requestJson('/api/roguelike/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, ...options }),
|
||||
}),
|
||||
allocateTalent: (talentId) =>
|
||||
requestJson(`/api/talents/${talentId}/allocate`, { method: 'POST' }),
|
||||
resetTalents: () =>
|
||||
requestJson('/api/talents/reset', { method: 'POST' }),
|
||||
equipItem: (itemId) =>
|
||||
requestJson(`/api/equipment/${itemId}/equip`, { method: 'POST' }),
|
||||
discardExtraItem: (itemId) =>
|
||||
requestJson(`/api/equipment/${itemId}/discard-extra`, { method: 'POST' }),
|
||||
breakdownItem: (itemId) =>
|
||||
requestJson(`/api/equipment/${itemId}/breakdown`, { method: 'POST' }),
|
||||
craftItem: (recipeId) =>
|
||||
requestJson(`/api/crafting/recipes/${recipeId}/craft`, { method: 'POST' }),
|
||||
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
|
||||
requestJson(`/api/encounters/${encounterId}/loot-roll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ difficultyId, runToken }),
|
||||
}),
|
||||
}
|
||||
|
||||
function emptyCharacterData(classId: number): CharacterData {
|
||||
const static_ = clone(starterProfile) as CharacterProfile
|
||||
const gc = static_.classes.find((c) => c.id === classId)!
|
||||
const talentRanks: Record<string, number> = {}
|
||||
for (const t of gc.talents) talentRanks[String(t.id)] = 0
|
||||
const starterItemIds = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109]
|
||||
const inventory: Item[] = static_.inventory.filter((i) => starterItemIds.includes(i.id)).map((i) => ({ ...i }))
|
||||
const startingAbilitySlots: Array<number | null> = gc.spells
|
||||
.filter((s) => s.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.map((s) => s.id)
|
||||
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null)
|
||||
return {
|
||||
level: 1,
|
||||
experience: 0,
|
||||
talentPoints: 1,
|
||||
abilitySlots: startingAbilitySlots,
|
||||
talentRanks,
|
||||
inventory,
|
||||
}
|
||||
}
|
||||
|
||||
const offlineRepository: GameRepository = {
|
||||
async loadSession() {
|
||||
const save = readOfflineSave()
|
||||
return {
|
||||
account: save ? offlineAccount : null,
|
||||
profile: save ? buildProfile(save) : null,
|
||||
}
|
||||
},
|
||||
async register() {
|
||||
throw new Error('Account registration requires online mode.')
|
||||
},
|
||||
async login() {
|
||||
throw new Error('Account login requires online mode.')
|
||||
},
|
||||
async logout() {
|
||||
writeMode('online')
|
||||
},
|
||||
async loadProfile() {
|
||||
return buildProfile(requireOfflineSave())
|
||||
},
|
||||
async saveProfile(classId, abilitySlots) {
|
||||
const save = requireOfflineSave()
|
||||
const static_ = clone(starterProfile) as CharacterProfile
|
||||
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
|
||||
if (!gameClass) throw new Error('Selected class does not exist.')
|
||||
|
||||
const slots = abilitySlots.slice(0, 6)
|
||||
while (slots.length < 6) slots.push(null)
|
||||
const selectedIds = slots.filter((id): id is number => id !== null)
|
||||
if (new Set(selectedIds).size !== selectedIds.length) {
|
||||
throw new Error('The same ability cannot be equipped twice.')
|
||||
}
|
||||
const activeChar = save.characters[save.activeClassId]
|
||||
const validIds = new Set(
|
||||
gameClass.spells
|
||||
.filter((spell) => spell.unlockLevel <= activeChar.level)
|
||||
.map((spell) => spell.id),
|
||||
)
|
||||
if (selectedIds.some((id) => !validIds.has(id))) {
|
||||
throw new Error('One or more abilities are locked or belong to another class.')
|
||||
}
|
||||
|
||||
if (!save.characters[classId]) {
|
||||
save.characters[classId] = emptyCharacterData(classId)
|
||||
}
|
||||
save.characters[classId].abilitySlots = slots
|
||||
save.activeClassId = classId
|
||||
writeOfflineSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
|
||||
void startPart
|
||||
void partDurationSeconds
|
||||
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
|
||||
throw new Error('The run resource total is invalid.')
|
||||
}
|
||||
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
|
||||
throw new Error('The run duration is invalid.')
|
||||
}
|
||||
const save = requireOfflineSave()
|
||||
const profile = buildProfile(save)
|
||||
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
|
||||
const difficulty = dungeon?.difficulties.find(
|
||||
(candidate) => candidate.id === difficultyId,
|
||||
)
|
||||
if (!dungeon || !difficulty) {
|
||||
throw new Error('That difficulty is not available for this dungeon.')
|
||||
}
|
||||
const cd = save.characters[save.activeClassId]
|
||||
if (cd.level < difficulty.unlockLevel) {
|
||||
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
|
||||
}
|
||||
|
||||
const previousLevel = cd.level
|
||||
const previousExperience = cd.experience
|
||||
const partCount = completedPart ?? 1
|
||||
const experienceReward = Math.round(
|
||||
dungeon.experienceReward * difficulty.experienceMultiplier * partCount,
|
||||
)
|
||||
const maxExperience = experienceForLevel(profile.maxLevel)
|
||||
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
|
||||
let newLevel = previousLevel
|
||||
while (
|
||||
newLevel < profile.maxLevel
|
||||
&& experienceForLevel(newLevel + 1) <= newExperience
|
||||
) {
|
||||
newLevel += 1
|
||||
}
|
||||
const levelsGained = newLevel - previousLevel
|
||||
|
||||
const gameClass = profile.classes.find(
|
||||
(candidate) => candidate.id === save.activeClassId,
|
||||
)!
|
||||
const unlockedAbilities = gameClass.spells
|
||||
.filter(
|
||||
(spell) =>
|
||||
spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel,
|
||||
)
|
||||
.map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph }))
|
||||
|
||||
cd.experience = newExperience
|
||||
cd.level = newLevel
|
||||
cd.talentPoints = Math.min(
|
||||
profile.maxTalentPoints,
|
||||
cd.talentPoints + levelsGained,
|
||||
)
|
||||
|
||||
if (dungeon.contentType === 'raid') {
|
||||
save.completedRaidPhases = Math.max(save.completedRaidPhases, partCount)
|
||||
} else {
|
||||
save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount)
|
||||
}
|
||||
|
||||
let bonusItem: DungeonReward['bonusItem'] = null
|
||||
if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) {
|
||||
const targetItemLevel = dungeon.completionItemLevel ?? difficulty.droppedItemLevel + 3
|
||||
const eligibleLoot = dungeon.completionLoot.filter(
|
||||
(item) => item.itemLevel >= targetItemLevel,
|
||||
)
|
||||
if (eligibleLoot.length > 0) {
|
||||
const rewardItemLevel = Math.min(...eligibleLoot.map((item) => item.itemLevel))
|
||||
const rewardPool = eligibleLoot.filter((item) => item.itemLevel === rewardItemLevel)
|
||||
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
|
||||
const existing = profile.inventory.find((item) => item.id === selected.id)
|
||||
const duplicate = Boolean(existing)
|
||||
let quantityAfter = 1
|
||||
if (existing) {
|
||||
existing.quantity += 1
|
||||
quantityAfter = existing.quantity
|
||||
} else {
|
||||
profile.inventory.push({
|
||||
...selected,
|
||||
quantity: 1,
|
||||
equipped: false,
|
||||
})
|
||||
}
|
||||
cd.inventory = profile.inventory
|
||||
bonusItem = { ...selected, duplicate, quantityAfter }
|
||||
}
|
||||
}
|
||||
|
||||
writeOfflineSave(save)
|
||||
const updatedProfile = buildProfile(save)
|
||||
|
||||
return {
|
||||
dungeonName: dungeon.name,
|
||||
difficultyName: difficulty.name,
|
||||
droppedItemLevel: difficulty.droppedItemLevel,
|
||||
experienceGained: newExperience - previousExperience,
|
||||
previousLevel,
|
||||
newLevel,
|
||||
levelsGained,
|
||||
talentPointsGained: levelsGained,
|
||||
resourceSpent,
|
||||
durationSeconds,
|
||||
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
|
||||
unlockedAbilities,
|
||||
bonusItem,
|
||||
profile: updatedProfile,
|
||||
}
|
||||
},
|
||||
async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) {
|
||||
if (!Number.isInteger(encountersCleared) || encountersCleared < 0) {
|
||||
throw new Error('The roguelike progress total is invalid.')
|
||||
}
|
||||
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
|
||||
throw new Error('The run resource total is invalid.')
|
||||
}
|
||||
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
|
||||
throw new Error('The run duration is invalid.')
|
||||
}
|
||||
const save = requireOfflineSave()
|
||||
const profile = buildProfile(save)
|
||||
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
|
||||
const difficulty = dungeon?.difficulties.find(
|
||||
(candidate) => candidate.id === difficultyId,
|
||||
)
|
||||
if (!dungeon || !difficulty) {
|
||||
throw new Error('That difficulty is not available for this roguelike.')
|
||||
}
|
||||
const cd = save.characters[save.activeClassId]
|
||||
if (cd.level < difficulty.unlockLevel) {
|
||||
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
|
||||
}
|
||||
|
||||
const previousLevel = cd.level
|
||||
const previousExperience = cd.experience
|
||||
const maxExperience = experienceForLevel(profile.maxLevel)
|
||||
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
||||
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
||||
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel)
|
||||
: null
|
||||
const newExperience = scaledReward
|
||||
? scaledReward.experience
|
||||
: Math.min(
|
||||
previousExperience
|
||||
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)),
|
||||
maxExperience,
|
||||
)
|
||||
let newLevel = scaledReward?.level ?? previousLevel
|
||||
while (
|
||||
newLevel < profile.maxLevel
|
||||
&& experienceForLevel(newLevel + 1) <= newExperience
|
||||
) {
|
||||
newLevel += 1
|
||||
}
|
||||
const levelsGained = newLevel - previousLevel
|
||||
|
||||
const gameClass = profile.classes.find(
|
||||
(candidate) => candidate.id === save.activeClassId,
|
||||
)!
|
||||
const unlockedAbilities = gameClass.spells
|
||||
.filter(
|
||||
(spell) =>
|
||||
spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel,
|
||||
)
|
||||
.map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph }))
|
||||
|
||||
cd.experience = newExperience
|
||||
cd.level = newLevel
|
||||
cd.talentPoints = Math.min(
|
||||
profile.maxTalentPoints,
|
||||
cd.talentPoints + levelsGained,
|
||||
)
|
||||
|
||||
writeOfflineSave(save)
|
||||
const updatedProfile = buildProfile(save)
|
||||
|
||||
return {
|
||||
dungeonName: `${dungeon.name} Roguelike`,
|
||||
difficultyName: difficulty.name,
|
||||
droppedItemLevel: difficulty.droppedItemLevel,
|
||||
experienceGained: newExperience - previousExperience,
|
||||
previousLevel,
|
||||
newLevel,
|
||||
levelsGained,
|
||||
talentPointsGained: levelsGained,
|
||||
resourceSpent,
|
||||
durationSeconds,
|
||||
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
|
||||
unlockedAbilities,
|
||||
bonusItem: null,
|
||||
profile: updatedProfile,
|
||||
}
|
||||
},
|
||||
async allocateTalent(talentId) {
|
||||
const save = requireOfflineSave()
|
||||
const profile = buildProfile(save)
|
||||
const cd = save.characters[save.activeClassId]
|
||||
const gameClass = profile.classes.find(
|
||||
(candidate) => candidate.id === save.activeClassId,
|
||||
)!
|
||||
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
|
||||
if (!talent) throw new Error('That talent does not belong to the active class.')
|
||||
if (cd.talentPoints <= 0) {
|
||||
throw new Error('No talent points are available.')
|
||||
}
|
||||
if (talent.rank >= talent.maxRank) {
|
||||
throw new Error('That talent is already at maximum rank.')
|
||||
}
|
||||
const lowerTierPoints = gameClass.talents
|
||||
.filter((candidate) => candidate.tier < talent.tier)
|
||||
.reduce((total, candidate) => total + candidate.rank, 0)
|
||||
const requiredTierPoints = (talent.tier - 1) * 5
|
||||
if (lowerTierPoints < requiredTierPoints) {
|
||||
throw new Error(`Spend ${requiredTierPoints} points in earlier tiers first.`)
|
||||
}
|
||||
if (talent.prerequisiteTalentId) {
|
||||
const prerequisite = gameClass.talents.find(
|
||||
(candidate) => candidate.id === talent.prerequisiteTalentId,
|
||||
)
|
||||
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
|
||||
throw new Error(
|
||||
`The prerequisite talent requires rank ${talent.prerequisiteRank}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
|
||||
cd.talentPoints -= 1
|
||||
writeOfflineSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async resetTalents() {
|
||||
const save = requireOfflineSave()
|
||||
const profile = buildProfile(save)
|
||||
const cd = save.characters[save.activeClassId]
|
||||
const gameClass = profile.classes.find(
|
||||
(candidate) => candidate.id === save.activeClassId,
|
||||
)!
|
||||
const refunded = gameClass.talents.reduce(
|
||||
(total, talent) => total + talent.rank,
|
||||
0,
|
||||
)
|
||||
for (const talent of gameClass.talents) {
|
||||
cd.talentRanks[String(talent.id)] = 0
|
||||
}
|
||||
cd.talentPoints = Math.min(
|
||||
profile.maxTalentPoints,
|
||||
cd.talentPoints + refunded,
|
||||
)
|
||||
writeOfflineSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async equipItem(itemId) {
|
||||
const save = requireOfflineSave()
|
||||
const profile = buildProfile(save)
|
||||
const item = profile.inventory.find((candidate) => candidate.id === itemId)
|
||||
if (!item) throw new Error('That item is not in the character inventory.')
|
||||
for (const candidate of profile.inventory) {
|
||||
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
|
||||
}
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
writeOfflineSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async discardExtraItem(itemId) {
|
||||
const save = requireOfflineSave()
|
||||
const profile = buildProfile(save)
|
||||
const item = profile.inventory.find((candidate) => candidate.id === itemId)
|
||||
if (!item) throw new Error('That item is not in the character inventory.')
|
||||
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
|
||||
item.quantity -= 1
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
writeOfflineSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async breakdownItem(itemId) {
|
||||
const save = requireOfflineSave()
|
||||
const profile = buildProfile(save)
|
||||
const item = profile.inventory.find((candidate) => candidate.id === itemId)
|
||||
if (!item) throw new Error('That item is not in the character inventory.')
|
||||
if (item.slot === 'component') throw new Error('Components cannot be broken down.')
|
||||
if (item.equipped && item.quantity <= 1) {
|
||||
throw new Error('Equipped items cannot be broken down.')
|
||||
}
|
||||
const componentTemplate = componentForItemLevel(item.itemLevel)
|
||||
if (!componentTemplate) throw new Error('No component type exists for this item level.')
|
||||
|
||||
if (item.quantity <= 1) {
|
||||
profile.inventory.splice(profile.inventory.indexOf(item), 1)
|
||||
} else {
|
||||
item.quantity -= 1
|
||||
}
|
||||
|
||||
const existing = profile.inventory.find((c) => c.id === componentTemplate.id)
|
||||
const count = Math.floor(Math.random() * 3) + 1
|
||||
if (existing) {
|
||||
existing.quantity += count
|
||||
} else {
|
||||
profile.inventory.push({
|
||||
id: componentTemplate.id,
|
||||
slug: componentTemplate.slug,
|
||||
name: componentTemplate.name,
|
||||
slot: 'component' as EquipmentSlot,
|
||||
rarity: 'common' as const,
|
||||
itemLevel: componentTemplate.itemLevel,
|
||||
healingPower: 0,
|
||||
maxResourceBonus: 0,
|
||||
glyph: componentTemplate.glyph,
|
||||
description: componentTemplate.description,
|
||||
quantity: count,
|
||||
equipped: false,
|
||||
})
|
||||
}
|
||||
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
writeOfflineSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async craftItem(recipeId) {
|
||||
const save = requireOfflineSave()
|
||||
const profile = buildProfile(save)
|
||||
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
|
||||
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
||||
const missing = recipe.components.find((component) => component.owned < component.quantity)
|
||||
if (missing) {
|
||||
throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`)
|
||||
}
|
||||
|
||||
for (const component of recipe.components) {
|
||||
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
|
||||
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to craft this item.`)
|
||||
owned.quantity -= component.quantity
|
||||
}
|
||||
for (let index = profile.inventory.length - 1; index >= 0; index -= 1) {
|
||||
if (profile.inventory[index].quantity <= 0) profile.inventory.splice(index, 1)
|
||||
}
|
||||
|
||||
addInventoryItem(profile.inventory, recipe.item, 1)
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
writeOfflineSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async rollEncounterLoot(encounterId, difficultyId, runToken) {
|
||||
if (runToken.length < 8 || runToken.length > 100) {
|
||||
throw new Error('A valid dungeon run token is required.')
|
||||
}
|
||||
const save = requireOfflineSave()
|
||||
const rollKey = `${runToken}:${encounterId}:${difficultyId}`
|
||||
if (save.lootRolls[rollKey]) return clone(save.lootRolls[rollKey])
|
||||
|
||||
const profile = buildProfile(save)
|
||||
const encounter = profile.dungeons
|
||||
.flatMap((dungeon) => dungeon.encounters)
|
||||
.find((candidate) => candidate.id === encounterId)
|
||||
const difficulty = profile.dungeons
|
||||
.flatMap((dungeon) => dungeon.difficulties)
|
||||
.find((candidate) => candidate.id === difficultyId)
|
||||
const entries = encounter?.lootTables.filter(
|
||||
(entry) => entry.difficultyId === difficultyId,
|
||||
) ?? []
|
||||
if (!encounter || !difficulty || entries.length === 0) {
|
||||
throw new Error('This encounter has no configured loot.')
|
||||
}
|
||||
const dropChance = entries[0].dropChance
|
||||
const items: LootRoll['items'] = []
|
||||
|
||||
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
|
||||
const dungeon = profile.dungeons.find((candidate) =>
|
||||
candidate.encounters.some((dungeonEncounter) => dungeonEncounter.id === encounterId),
|
||||
)
|
||||
const lootChanceSlots = dungeon?.contentType === 'raid' ? 8 : 5
|
||||
for (let index = 0; index < lootChanceSlots; index += 1) {
|
||||
if (Math.random() >= dropChance) continue
|
||||
const selected = rollWeightedLootEntry(entries)
|
||||
const current = selectedQuantities.get(selected.id)
|
||||
selectedQuantities.set(selected.id, {
|
||||
entry: selected,
|
||||
quantity: (current?.quantity ?? 0) + componentDropQuantity(difficulty.droppedItemLevel),
|
||||
})
|
||||
}
|
||||
|
||||
for (const { entry, quantity } of selectedQuantities.values()) {
|
||||
const {
|
||||
encounterId: _encounterId,
|
||||
difficultyId: _difficultyId,
|
||||
dropWeight: _dropWeight,
|
||||
dropChance: _dropChance,
|
||||
...rolledItem
|
||||
} = entry
|
||||
void _encounterId
|
||||
void _difficultyId
|
||||
void _dropWeight
|
||||
void _dropChance
|
||||
const added = addInventoryItem(profile.inventory, {
|
||||
...rolledItem,
|
||||
slot: rolledItem.slot as EquipmentSlot,
|
||||
rarity: rolledItem.rarity as Item['rarity'],
|
||||
}, quantity)
|
||||
items.push({
|
||||
...rolledItem,
|
||||
slot: rolledItem.slot as EquipmentSlot,
|
||||
rarity: rolledItem.rarity as Item['rarity'],
|
||||
quantity,
|
||||
duplicate: added.duplicate,
|
||||
quantityAfter: added.quantityAfter,
|
||||
})
|
||||
}
|
||||
|
||||
const item = items[0] ?? null
|
||||
const result: LootRoll = {
|
||||
encounterId,
|
||||
encounterName: encounter.enemyName,
|
||||
difficultyId,
|
||||
difficultyName: difficulty.name,
|
||||
dropChance,
|
||||
dropped: items.length > 0,
|
||||
item,
|
||||
items,
|
||||
awarded: Boolean(item),
|
||||
duplicate: items.some((candidate) => candidate.duplicate),
|
||||
quantityAfter: item?.quantityAfter ?? 0,
|
||||
}
|
||||
save.lootRolls[rollKey] = result
|
||||
save.characters[save.activeClassId].inventory = profile.inventory
|
||||
writeOfflineSave(save)
|
||||
return clone(result)
|
||||
},
|
||||
}
|
||||
|
||||
export function getGameMode(): GameMode {
|
||||
return readMode()
|
||||
}
|
||||
|
||||
export function selectOnlineMode() {
|
||||
writeMode('online')
|
||||
}
|
||||
|
||||
export function createOfflineCharacter(characterName: string): AuthSession {
|
||||
const name = characterName.trim() || 'Mira'
|
||||
if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
|
||||
throw new Error('Character name must be 2-20 characters and start with a letter.')
|
||||
}
|
||||
const characters: Record<number, CharacterData> = {}
|
||||
for (const cid of [1, 2, 3]) {
|
||||
characters[cid] = emptyCharacterData(cid)
|
||||
}
|
||||
const save: OfflineSave = {
|
||||
version: 3,
|
||||
characterName: name,
|
||||
activeClassId: 1,
|
||||
completedDungeonParts: 0,
|
||||
completedRaidPhases: 0,
|
||||
characters,
|
||||
lootRolls: {},
|
||||
}
|
||||
writeOfflineSave(save)
|
||||
writeMode('offline')
|
||||
return { account: offlineAccount, profile: buildProfile(save) }
|
||||
}
|
||||
|
||||
export function resumeOfflineCharacter(): AuthSession | null {
|
||||
const save = readOfflineSave()
|
||||
if (!save) return null
|
||||
writeMode('offline')
|
||||
return { account: offlineAccount, profile: buildProfile(save) }
|
||||
}
|
||||
|
||||
export function hasOfflineCharacter(): boolean {
|
||||
return readOfflineSave() !== null
|
||||
}
|
||||
|
||||
export function activeGameRepository(): GameRepository {
|
||||
return readMode() === 'offline' ? offlineRepository : serverRepository
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
:root {
|
||||
background:
|
||||
linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)),
|
||||
repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px);
|
||||
color: #f4eed8;
|
||||
font-family: 'VT323', Consolas, monospace;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
export type InputDevice = 'pc' | 'controller'
|
||||
export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo'
|
||||
|
||||
export const INPUT_ACTIONS = [
|
||||
'navigateUp',
|
||||
'navigateDown',
|
||||
'navigateLeft',
|
||||
'navigateRight',
|
||||
'confirm',
|
||||
'back',
|
||||
'ability1',
|
||||
'ability2',
|
||||
'ability3',
|
||||
'ability4',
|
||||
'ability5',
|
||||
'ability6',
|
||||
'previousTarget',
|
||||
'nextTarget',
|
||||
'targetParty1',
|
||||
'targetParty2',
|
||||
'targetParty3',
|
||||
'targetParty4',
|
||||
'targetParty5',
|
||||
'toggleTargetGroup',
|
||||
'pause',
|
||||
] as const
|
||||
|
||||
export type InputAction = typeof INPUT_ACTIONS[number]
|
||||
export type InputBindings = Record<InputAction, string>
|
||||
|
||||
export const ACTION_LABELS: Record<InputAction, string> = {
|
||||
navigateUp: 'Navigate Up',
|
||||
navigateDown: 'Navigate Down',
|
||||
navigateLeft: 'Navigate Left',
|
||||
navigateRight: 'Navigate Right',
|
||||
confirm: 'Confirm / Select',
|
||||
back: 'Back',
|
||||
ability1: 'Ability Slot 1',
|
||||
ability2: 'Ability Slot 2',
|
||||
ability3: 'Ability Slot 3',
|
||||
ability4: 'Ability Slot 4',
|
||||
ability5: 'Ability Slot 5',
|
||||
ability6: 'Ability Slot 6',
|
||||
previousTarget: 'Previous Party Target',
|
||||
nextTarget: 'Next Party Target',
|
||||
targetParty1: 'Target Party Member 1',
|
||||
targetParty2: 'Target Party Member 2',
|
||||
targetParty3: 'Target Party Member 3',
|
||||
targetParty4: 'Target Party Member 4',
|
||||
targetParty5: 'Target Party Member 5',
|
||||
toggleTargetGroup: 'Switch Raid Target Group',
|
||||
pause: 'Pause Menu',
|
||||
}
|
||||
|
||||
export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
pc: {
|
||||
navigateUp: 'ArrowUp',
|
||||
navigateDown: 'ArrowDown',
|
||||
navigateLeft: 'ArrowLeft',
|
||||
navigateRight: 'ArrowRight',
|
||||
confirm: 'Enter',
|
||||
back: 'Escape',
|
||||
ability1: 'Digit1',
|
||||
ability2: 'Digit2',
|
||||
ability3: 'Digit3',
|
||||
ability4: 'Digit4',
|
||||
ability5: 'Digit5',
|
||||
ability6: 'Digit6',
|
||||
previousTarget: 'KeyQ',
|
||||
nextTarget: 'KeyE',
|
||||
targetParty1: 'F1',
|
||||
targetParty2: 'F2',
|
||||
targetParty3: 'F3',
|
||||
targetParty4: 'F4',
|
||||
targetParty5: 'F5',
|
||||
toggleTargetGroup: 'Tab',
|
||||
pause: 'Escape',
|
||||
},
|
||||
controller: {
|
||||
navigateUp: 'Axis1-',
|
||||
navigateDown: 'Axis1+',
|
||||
navigateLeft: 'Axis0-',
|
||||
navigateRight: 'Axis0+',
|
||||
confirm: 'Button0',
|
||||
back: 'Button1',
|
||||
ability1: 'Button3',
|
||||
ability2: 'Button2',
|
||||
ability3: 'Button0',
|
||||
ability4: 'Button1',
|
||||
ability5: 'Button5',
|
||||
ability6: 'Button7',
|
||||
previousTarget: 'Button14',
|
||||
nextTarget: 'Button15',
|
||||
targetParty1: 'Button14',
|
||||
targetParty2: 'Button12',
|
||||
targetParty3: 'Button15',
|
||||
targetParty4: 'Button13',
|
||||
targetParty5: 'Button4',
|
||||
toggleTargetGroup: 'Button6',
|
||||
pause: 'Button9',
|
||||
},
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
||||
|
||||
type CaptureState = {
|
||||
device: InputDevice
|
||||
action: InputAction
|
||||
} | null
|
||||
|
||||
type InputContextValue = {
|
||||
bindings: Record<InputDevice, InputBindings>
|
||||
capture: CaptureState
|
||||
lastDevice: InputDevice
|
||||
controllerIconStyle: ControllerIconStyle
|
||||
directPartyTargeting: boolean
|
||||
beginCapture: (device: InputDevice, action: InputAction) => void
|
||||
cancelCapture: () => void
|
||||
resetBindings: (device: InputDevice) => void
|
||||
setControllerIconStyle: (style: ControllerIconStyle) => void
|
||||
setDirectPartyTargeting: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const InputContext = createContext<InputContextValue | null>(null)
|
||||
|
||||
function loadBindings(): Record<InputDevice, InputBindings> {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
|
||||
const controller = { ...DEFAULT_BINDINGS.controller, ...saved.controller }
|
||||
const usesLegacyAbilityDefaults = [
|
||||
'Button2',
|
||||
'Button3',
|
||||
'Button4',
|
||||
'Button5',
|
||||
'Button6',
|
||||
'Button7',
|
||||
].every((binding, index) => (
|
||||
controller[`ability${index + 1}` as InputAction] === binding
|
||||
))
|
||||
if (usesLegacyAbilityDefaults) {
|
||||
Object.assign(controller, {
|
||||
ability1: DEFAULT_BINDINGS.controller.ability1,
|
||||
ability2: DEFAULT_BINDINGS.controller.ability2,
|
||||
ability3: DEFAULT_BINDINGS.controller.ability3,
|
||||
ability4: DEFAULT_BINDINGS.controller.ability4,
|
||||
ability5: DEFAULT_BINDINGS.controller.ability5,
|
||||
ability6: DEFAULT_BINDINGS.controller.ability6,
|
||||
})
|
||||
}
|
||||
return {
|
||||
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
||||
controller,
|
||||
}
|
||||
} catch {
|
||||
return structuredClone(DEFAULT_BINDINGS)
|
||||
}
|
||||
}
|
||||
|
||||
function loadPreferences() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
|
||||
controllerIconStyle?: ControllerIconStyle
|
||||
directPartyTargeting?: boolean
|
||||
}
|
||||
return {
|
||||
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
|
||||
directPartyTargeting: saved.directPartyTargeting ?? false,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
controllerIconStyle: 'xbox' as ControllerIconStyle,
|
||||
directPartyTargeting: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isTextInput(element: Element | null): element is HTMLInputElement | HTMLTextAreaElement {
|
||||
return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
|
||||
}
|
||||
|
||||
function bindingGroup(action: InputAction) {
|
||||
if (action.startsWith('ability')) return 'abilities'
|
||||
if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
|
||||
if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
|
||||
if (action === 'pause') return 'pause'
|
||||
return 'navigation'
|
||||
}
|
||||
|
||||
function isVisible(element: HTMLElement) {
|
||||
return element.getClientRects().length > 0
|
||||
}
|
||||
|
||||
function focusableElements() {
|
||||
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
|
||||
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
|
||||
const scope: ParentNode = keyboard ?? pauseMenu ?? document
|
||||
return Array.from(
|
||||
scope.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter(isVisible)
|
||||
}
|
||||
|
||||
export function focusFirstControl() {
|
||||
const first = focusableElements()[0]
|
||||
first?.focus({ preventScroll: true })
|
||||
return first
|
||||
}
|
||||
|
||||
function moveFocus(action: InputAction) {
|
||||
const candidates = focusableElements()
|
||||
if (candidates.length === 0) return
|
||||
const current = document.activeElement instanceof HTMLElement
|
||||
&& candidates.includes(document.activeElement)
|
||||
? document.activeElement
|
||||
: null
|
||||
if (!current) {
|
||||
focusFirstControl()
|
||||
return
|
||||
}
|
||||
|
||||
const currentRect = current.getBoundingClientRect()
|
||||
const currentX = currentRect.left + currentRect.width / 2
|
||||
const currentY = currentRect.top + currentRect.height / 2
|
||||
const vertical = action === 'navigateUp' || action === 'navigateDown'
|
||||
const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1
|
||||
|
||||
const ranked = candidates
|
||||
.filter((candidate) => candidate !== current)
|
||||
.map((candidate) => {
|
||||
const rect = candidate.getBoundingClientRect()
|
||||
const x = rect.left + rect.width / 2
|
||||
const y = rect.top + rect.height / 2
|
||||
const primary = vertical ? y - currentY : x - currentX
|
||||
const secondary = vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)
|
||||
return { candidate, primary, score: Math.abs(primary) + secondary * 2.5 }
|
||||
})
|
||||
.filter(({ primary }) => Math.sign(primary) === direction)
|
||||
.sort((a, b) => a.score - b.score)
|
||||
|
||||
const next = ranked[0]?.candidate
|
||||
if (!next) return
|
||||
next.focus({ preventScroll: true })
|
||||
next.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
|
||||
const BUTTON_LABELS: Record<number, string> = {
|
||||
0: 'A / Cross',
|
||||
1: 'B / Circle',
|
||||
2: 'X / Square',
|
||||
3: 'Y / Triangle',
|
||||
4: 'Left Bumper',
|
||||
5: 'Right Bumper',
|
||||
6: 'Left Trigger',
|
||||
7: 'Right Trigger',
|
||||
8: 'View / Select',
|
||||
9: 'Menu / Start',
|
||||
10: 'Left Stick',
|
||||
11: 'Right Stick',
|
||||
12: 'D-Pad Up',
|
||||
13: 'D-Pad Down',
|
||||
14: 'D-Pad Left',
|
||||
15: 'D-Pad Right',
|
||||
16: 'Home',
|
||||
}
|
||||
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
ArrowUp: 'Up Arrow',
|
||||
ArrowDown: 'Down Arrow',
|
||||
ArrowLeft: 'Left Arrow',
|
||||
ArrowRight: 'Right Arrow',
|
||||
Enter: 'Enter',
|
||||
Escape: 'Escape',
|
||||
Space: 'Space',
|
||||
}
|
||||
|
||||
export function bindingLabel(binding: string, iconStyle: ControllerIconStyle = 'xbox') {
|
||||
if (binding.startsWith('Button')) {
|
||||
const button = Number(binding.slice(6))
|
||||
const faceLabels: Record<ControllerIconStyle, Partial<Record<number, string>>> = {
|
||||
xbox: { 0: 'A', 1: 'B', 2: 'X', 3: 'Y' },
|
||||
playstation: { 0: '×', 1: '○', 2: '□', 3: '△' },
|
||||
nintendo: { 0: 'B', 1: 'A', 2: 'Y', 3: 'X' },
|
||||
}
|
||||
const shoulderLabels: Record<ControllerIconStyle, Partial<Record<number, string>>> = {
|
||||
xbox: { 4: 'LB', 5: 'RB', 6: 'LT', 7: 'RT', 8: 'View', 9: 'Start' },
|
||||
playstation: { 4: 'L1', 5: 'R1', 6: 'L2', 7: 'R2', 8: 'Share', 9: 'Options' },
|
||||
nintendo: { 4: 'L', 5: 'R', 6: 'ZL', 7: 'ZR', 8: 'Minus', 9: 'Plus' },
|
||||
}
|
||||
return faceLabels[iconStyle][button]
|
||||
?? shoulderLabels[iconStyle][button]
|
||||
?? BUTTON_LABELS[button]
|
||||
?? `Button ${button}`
|
||||
}
|
||||
if (binding.startsWith('Axis')) {
|
||||
const axis = Number(binding.slice(4, -1))
|
||||
const direction = binding.endsWith('-') ? '-' : '+'
|
||||
const labels: Record<string, string> = {
|
||||
'0-': 'Left Stick Left',
|
||||
'0+': 'Left Stick Right',
|
||||
'1-': 'Left Stick Up',
|
||||
'1+': 'Left Stick Down',
|
||||
'2-': 'Right Stick Left',
|
||||
'2+': 'Right Stick Right',
|
||||
'3-': 'Right Stick Up',
|
||||
'3+': 'Right Stick Down',
|
||||
}
|
||||
return labels[`${axis}${direction}`] ?? `Axis ${axis} ${direction}`
|
||||
}
|
||||
if (KEY_LABELS[binding]) return KEY_LABELS[binding]
|
||||
if (binding.startsWith('Key') || binding.startsWith('Digit')) return binding.slice(-1)
|
||||
return binding
|
||||
}
|
||||
|
||||
export function compactBindingLabel(
|
||||
binding: string,
|
||||
iconStyle: ControllerIconStyle = 'xbox',
|
||||
) {
|
||||
const controllerLabels: Record<string, string> = {
|
||||
Button14: 'D-Pad Left',
|
||||
Button15: 'D-Pad Right',
|
||||
}
|
||||
return controllerLabels[binding] ?? bindingLabel(binding, iconStyle)
|
||||
}
|
||||
|
||||
function gamepadTokens(gamepad: Gamepad) {
|
||||
const tokens = new Set<string>()
|
||||
gamepad.buttons.forEach((button, index) => {
|
||||
if (button.pressed || button.value > 0.65) tokens.add(`Button${index}`)
|
||||
})
|
||||
gamepad.axes.forEach((value, index) => {
|
||||
if (value < -0.65) tokens.add(`Axis${index}-`)
|
||||
if (value > 0.65) tokens.add(`Axis${index}+`)
|
||||
})
|
||||
return tokens
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, nextValue: string) {
|
||||
const prototype = input instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||
setter?.call(input, nextValue)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const [bindings, setBindings] = useState(loadBindings)
|
||||
const [capture, setCapture] = useState<CaptureState>(null)
|
||||
const [lastDevice, setLastDevice] = useState<InputDevice>('pc')
|
||||
const [preferences, setPreferences] = useState(loadPreferences)
|
||||
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||
const [keyboardShift, setKeyboardShift] = useState(false)
|
||||
const bindingsRef = useRef(bindings)
|
||||
const preferencesRef = useRef(preferences)
|
||||
const captureRef = useRef(capture)
|
||||
const keyboardInputRef = useRef(keyboardInput)
|
||||
const previousTokensRef = useRef(new Set<string>())
|
||||
const repeatRef = useRef<Record<string, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
bindingsRef.current = bindings
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(bindings))
|
||||
}, [bindings])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(preferences))
|
||||
preferencesRef.current = preferences
|
||||
}, [preferences])
|
||||
|
||||
useEffect(() => {
|
||||
captureRef.current = capture
|
||||
}, [capture])
|
||||
|
||||
useEffect(() => {
|
||||
keyboardInputRef.current = keyboardInput
|
||||
}, [keyboardInput])
|
||||
|
||||
const assignBinding = useCallback((device: InputDevice, action: InputAction, token: string) => {
|
||||
setBindings((current) => {
|
||||
const nextDevice = { ...current[device] }
|
||||
const previousToken = nextDevice[action]
|
||||
const collision = INPUT_ACTIONS.find(
|
||||
(candidate) => (
|
||||
candidate !== action
|
||||
&& bindingGroup(candidate) === bindingGroup(action)
|
||||
&& nextDevice[candidate] === token
|
||||
),
|
||||
)
|
||||
if (collision) nextDevice[collision] = previousToken
|
||||
nextDevice[action] = token
|
||||
return { ...current, [device]: nextDevice }
|
||||
})
|
||||
setCapture(null)
|
||||
}, [])
|
||||
|
||||
const closeKeyboard = useCallback(() => {
|
||||
const input = keyboardInputRef.current
|
||||
setKeyboardInput(null)
|
||||
window.requestAnimationFrame(() => input?.focus({ preventScroll: true }))
|
||||
}, [])
|
||||
|
||||
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
||||
setLastDevice(device)
|
||||
document.documentElement.dataset.inputDevice = device
|
||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||
|
||||
if (action.startsWith('navigate')) {
|
||||
if (!combatActive) moveFocus(action)
|
||||
} else if (action === 'confirm') {
|
||||
const active = document.activeElement
|
||||
if (isTextInput(active)) {
|
||||
setKeyboardInput(active)
|
||||
window.requestAnimationFrame(() => focusFirstControl())
|
||||
} else if (active instanceof HTMLElement && active.matches('button, [role="button"]')) {
|
||||
active.click()
|
||||
} else {
|
||||
focusFirstControl()
|
||||
}
|
||||
} else if (action === 'back') {
|
||||
if (keyboardInputRef.current) {
|
||||
closeKeyboard()
|
||||
} else if (!combatActive) {
|
||||
const backButton = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>('.back-button:not(:disabled)'),
|
||||
).find(isVisible)
|
||||
backButton?.click()
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||
detail: { action, device },
|
||||
}))
|
||||
}, [closeKeyboard])
|
||||
|
||||
const dispatchControllerToken = useCallback((token: string, repeat = false) => {
|
||||
if (captureRef.current?.device === 'controller') {
|
||||
if (!repeat) assignBinding('controller', captureRef.current.action, token)
|
||||
return
|
||||
}
|
||||
if (captureRef.current) return
|
||||
|
||||
const combatActive = Boolean(
|
||||
document.querySelector('[data-combat-active="true"]'),
|
||||
)
|
||||
const menuDpadActions: Partial<Record<string, InputAction>> = {
|
||||
Button12: 'navigateUp',
|
||||
Button13: 'navigateDown',
|
||||
Button14: 'navigateLeft',
|
||||
Button15: 'navigateRight',
|
||||
}
|
||||
const directTargetActions = [
|
||||
'targetParty1',
|
||||
'targetParty2',
|
||||
'targetParty3',
|
||||
'targetParty4',
|
||||
'targetParty5',
|
||||
'toggleTargetGroup',
|
||||
] satisfies InputAction[]
|
||||
const combatPriority = [
|
||||
'pause',
|
||||
'ability1',
|
||||
'ability2',
|
||||
'ability3',
|
||||
'ability4',
|
||||
'ability5',
|
||||
'ability6',
|
||||
'previousTarget',
|
||||
'nextTarget',
|
||||
'navigateUp',
|
||||
'navigateDown',
|
||||
'navigateLeft',
|
||||
'navigateRight',
|
||||
] satisfies InputAction[]
|
||||
const action = combatActive && preferencesRef.current.directPartyTargeting
|
||||
? [...directTargetActions, ...combatPriority].find(
|
||||
(candidate) => bindingsRef.current.controller[candidate] === token,
|
||||
)
|
||||
: combatActive && menuDpadActions[token]
|
||||
? menuDpadActions[token]
|
||||
: !combatActive && menuDpadActions[token]
|
||||
? menuDpadActions[token]
|
||||
: (combatActive ? combatPriority : INPUT_ACTIONS).find(
|
||||
(candidate) => bindingsRef.current.controller[candidate] === token,
|
||||
)
|
||||
if (!action) return
|
||||
if (repeat && !action.startsWith('navigate')) return
|
||||
dispatchAction(action, 'controller')
|
||||
}, [assignBinding, dispatchAction])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const active = document.activeElement
|
||||
if (captureRef.current?.device === 'pc') {
|
||||
event.preventDefault()
|
||||
if (event.code === 'Escape') {
|
||||
setCapture(null)
|
||||
return
|
||||
}
|
||||
assignBinding('pc', captureRef.current.action, event.code)
|
||||
return
|
||||
}
|
||||
if (captureRef.current || (isTextInput(active) && !keyboardInputRef.current)) return
|
||||
const action = INPUT_ACTIONS.find(
|
||||
(candidate) => bindingsRef.current.pc[candidate] === event.code,
|
||||
)
|
||||
if (!action) return
|
||||
event.preventDefault()
|
||||
if (event.repeat && !action.startsWith('navigate')) return
|
||||
dispatchAction(action, 'pc')
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [assignBinding, dispatchAction])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail
|
||||
dispatchControllerToken(detail.token, Boolean(detail.repeat))
|
||||
}
|
||||
window.addEventListener(NATIVE_CONTROLLER_EVENT, listener)
|
||||
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
|
||||
}, [dispatchControllerToken])
|
||||
|
||||
useEffect(() => {
|
||||
const ensureFocus = () => {
|
||||
const combatActive = document.querySelector('[data-combat-active="true"]')
|
||||
if (combatActive) return
|
||||
if (
|
||||
document.activeElement === document.body
|
||||
&& !keyboardInputRef.current
|
||||
&& !captureRef.current
|
||||
) {
|
||||
focusFirstControl()
|
||||
}
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
window.requestAnimationFrame(ensureFocus)
|
||||
})
|
||||
observer.observe(document.getElementById('root') ?? document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
window.requestAnimationFrame(ensureFocus)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0
|
||||
const poll = (time: number) => {
|
||||
const gamepad = Array.from(navigator.getGamepads?.() ?? []).find(Boolean)
|
||||
const currentTokens = gamepad ? gamepadTokens(gamepad) : new Set<string>()
|
||||
const previousTokens = previousTokensRef.current
|
||||
|
||||
currentTokens.forEach((token) => {
|
||||
const pressed = !previousTokens.has(token)
|
||||
if (pressed && captureRef.current?.device === 'controller') {
|
||||
assignBinding('controller', captureRef.current.action, token)
|
||||
return
|
||||
}
|
||||
if (captureRef.current) return
|
||||
const action = INPUT_ACTIONS.find(
|
||||
(candidate) => bindingsRef.current.controller[candidate] === token,
|
||||
)
|
||||
const canRepeat = action?.startsWith('navigate') ?? false
|
||||
const nextRepeat = repeatRef.current[token] ?? 0
|
||||
if (pressed || (canRepeat && time >= nextRepeat)) {
|
||||
dispatchControllerToken(token, !pressed)
|
||||
repeatRef.current[token] = time + (pressed ? 360 : 125)
|
||||
}
|
||||
})
|
||||
|
||||
Object.keys(repeatRef.current).forEach((token) => {
|
||||
if (!currentTokens.has(token)) delete repeatRef.current[token]
|
||||
})
|
||||
previousTokensRef.current = currentTokens
|
||||
frame = window.requestAnimationFrame(poll)
|
||||
}
|
||||
frame = window.requestAnimationFrame(poll)
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [assignBinding, dispatchControllerToken])
|
||||
|
||||
const contextValue = useMemo<InputContextValue>(() => ({
|
||||
bindings,
|
||||
capture,
|
||||
lastDevice,
|
||||
controllerIconStyle: preferences.controllerIconStyle,
|
||||
directPartyTargeting: preferences.directPartyTargeting,
|
||||
beginCapture: (device, action) => setCapture({ device, action }),
|
||||
cancelCapture: () => setCapture(null),
|
||||
resetBindings: (device) => setBindings((current) => ({
|
||||
...current,
|
||||
[device]: { ...DEFAULT_BINDINGS[device] },
|
||||
})),
|
||||
setControllerIconStyle: (controllerIconStyle) => setPreferences((current) => ({
|
||||
...current,
|
||||
controllerIconStyle,
|
||||
})),
|
||||
setDirectPartyTargeting: (directPartyTargeting) => setPreferences((current) => ({
|
||||
...current,
|
||||
directPartyTargeting,
|
||||
})),
|
||||
}), [bindings, capture, lastDevice, preferences])
|
||||
|
||||
function typeKeyboardKey(key: string) {
|
||||
if (!keyboardInput) return
|
||||
const start = keyboardInput.selectionStart ?? keyboardInput.value.length
|
||||
const end = keyboardInput.selectionEnd ?? start
|
||||
if (key === 'backspace') {
|
||||
const from = start === end ? Math.max(0, start - 1) : start
|
||||
setInputValue(keyboardInput, keyboardInput.value.slice(0, from) + keyboardInput.value.slice(end))
|
||||
window.requestAnimationFrame(() => keyboardInput.setSelectionRange(from, from))
|
||||
return
|
||||
}
|
||||
const value = key === 'space' ? ' ' : keyboardShift ? key.toUpperCase() : key
|
||||
const next = keyboardInput.value.slice(0, start) + value + keyboardInput.value.slice(end)
|
||||
const maxLength = keyboardInput.maxLength > 0 ? keyboardInput.maxLength : Number.POSITIVE_INFINITY
|
||||
const limited = next.slice(0, maxLength)
|
||||
setInputValue(keyboardInput, limited)
|
||||
const cursor = Math.min(start + value.length, limited.length)
|
||||
window.requestAnimationFrame(() => keyboardInput.setSelectionRange(cursor, cursor))
|
||||
}
|
||||
|
||||
const keyboardKeys = [
|
||||
...'1234567890',
|
||||
...'qwertyuiop',
|
||||
...'asdfghjkl',
|
||||
...'zxcvbnm',
|
||||
'_', '-', '@', '.', '!', '?', '#', '$',
|
||||
]
|
||||
|
||||
return (
|
||||
<InputContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{keyboardInput && (
|
||||
<div className="controller-keyboard-backdrop" role="presentation">
|
||||
<section className="controller-keyboard" aria-label="On-screen keyboard">
|
||||
<div className="controller-keyboard-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Controller Keyboard</p>
|
||||
<strong>{keyboardInput.value || 'Enter text'}</strong>
|
||||
</div>
|
||||
<button onClick={closeKeyboard} type="button">Done</button>
|
||||
</div>
|
||||
<div className="controller-keyboard-grid">
|
||||
{keyboardKeys.map((key) => (
|
||||
<button key={key} onClick={() => typeKeyboardKey(key)} type="button">
|
||||
{keyboardShift ? key.toUpperCase() : key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="controller-keyboard-actions">
|
||||
<button
|
||||
className={keyboardShift ? 'active' : ''}
|
||||
onClick={() => setKeyboardShift((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
Shift
|
||||
</button>
|
||||
<button onClick={() => typeKeyboardKey('space')} type="button">Space</button>
|
||||
<button onClick={() => typeKeyboardKey('backspace')} type="button">Backspace</button>
|
||||
<button onClick={closeKeyboard} type="button">Done</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</InputContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useInput() {
|
||||
const context = useContext(InputContext)
|
||||
if (!context) throw new Error('useInput must be used inside InputProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
export function useGameAction(
|
||||
handler: (action: InputAction, device: InputDevice) => void,
|
||||
) {
|
||||
const handlerRef = useRef(handler)
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler
|
||||
}, [handler])
|
||||
useEffect(() => {
|
||||
const listener = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ action: InputAction; device: InputDevice }>).detail
|
||||
handlerRef.current(detail.action, detail.device)
|
||||
}
|
||||
window.addEventListener(GAME_ACTION_EVENT, listener)
|
||||
return () => window.removeEventListener(GAME_ACTION_EVENT, listener)
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function dispatchExternalGameAction(
|
||||
action: InputAction,
|
||||
device: InputDevice,
|
||||
) {
|
||||
window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
|
||||
detail: { action, device },
|
||||
}))
|
||||
}
|
||||