Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6d408f594 | ||
|
|
1797154726 | ||
|
|
bbcc67a54a |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 81
|
versionCode 84
|
||||||
versionName "1.1.2"
|
versionName "1.1.5"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.warren.iwanttoheal;
|
||||||
|
|
||||||
|
import android.view.Display;
|
||||||
|
import android.view.Window;
|
||||||
|
import android.view.WindowManager;
|
||||||
|
|
||||||
|
final class AndroidDisplayPerformance {
|
||||||
|
|
||||||
|
private static final float TARGET_REFRESH_RATE = 60.0f;
|
||||||
|
|
||||||
|
private AndroidDisplayPerformance() {}
|
||||||
|
|
||||||
|
static void preferBatteryRefreshRate(Window window, Display display) {
|
||||||
|
if (window == null || display == null) return;
|
||||||
|
Display.Mode currentMode = display.getMode();
|
||||||
|
Display.Mode selectedMode = closestBatteryMode(display, currentMode);
|
||||||
|
if (selectedMode == null) return;
|
||||||
|
|
||||||
|
WindowManager.LayoutParams attributes = window.getAttributes();
|
||||||
|
if (attributes.preferredDisplayModeId == selectedMode.getModeId()) return;
|
||||||
|
attributes.preferredDisplayModeId = selectedMode.getModeId();
|
||||||
|
window.setAttributes(attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Display.Mode closestBatteryMode(Display display, Display.Mode currentMode) {
|
||||||
|
Display.Mode selectedMode = null;
|
||||||
|
float selectedScore = Float.MAX_VALUE;
|
||||||
|
|
||||||
|
for (Display.Mode mode : display.getSupportedModes()) {
|
||||||
|
if (
|
||||||
|
currentMode != null
|
||||||
|
&& (
|
||||||
|
mode.getPhysicalWidth() != currentMode.getPhysicalWidth()
|
||||||
|
|| mode.getPhysicalHeight() != currentMode.getPhysicalHeight()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float refreshRate = mode.getRefreshRate();
|
||||||
|
float score = Math.abs(refreshRate - TARGET_REFRESH_RATE);
|
||||||
|
if (refreshRate > TARGET_REFRESH_RATE + 0.5f) score += 1000.0f;
|
||||||
|
if (score < selectedScore) {
|
||||||
|
selectedMode = mode;
|
||||||
|
selectedScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package com.warren.iwanttoheal;
|
package com.warren.iwanttoheal;
|
||||||
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.content.pm.PackageInfo;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.SystemClock;
|
import android.os.SystemClock;
|
||||||
import android.view.KeyEvent;
|
import android.view.KeyEvent;
|
||||||
@@ -12,15 +16,22 @@ import java.io.File;
|
|||||||
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||||
|
|
||||||
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
|
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
|
||||||
|
private static final String PREFS_NAME = "com.warren.iwanttoheal.performance";
|
||||||
|
private static final String CACHE_CLEARED_VERSION_KEY = "webview_cache_cleared_version";
|
||||||
private static final long DPAD_THROTTLE_MS = 220;
|
private static final long DPAD_THROTTLE_MS = 220;
|
||||||
private long lastDpadDispatchAt = 0;
|
private long lastDpadDispatchAt = 0;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
clearWebViewServiceWorkers();
|
boolean shouldClearWebViewCache = shouldClearWebViewCacheForCurrentVersion();
|
||||||
|
if (shouldClearWebViewCache) clearWebViewServiceWorkers();
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), getWindowManager().getDefaultDisplay());
|
||||||
if (bridge != null) {
|
if (bridge != null) {
|
||||||
bridge.getWebView().clearCache(true);
|
if (shouldClearWebViewCache) {
|
||||||
|
bridge.getWebView().clearCache(true);
|
||||||
|
markWebViewCacheClearedForCurrentVersion();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loadIntentUrl();
|
loadIntentUrl();
|
||||||
}
|
}
|
||||||
@@ -35,7 +46,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onWindowFocusChanged(boolean hasFocus) {
|
public void onWindowFocusChanged(boolean hasFocus) {
|
||||||
super.onWindowFocusChanged(hasFocus);
|
super.onWindowFocusChanged(hasFocus);
|
||||||
if (hasFocus) enableImmersiveMode();
|
if (hasFocus) {
|
||||||
|
enableImmersiveMode();
|
||||||
|
AndroidDisplayPerformance.preferBatteryRefreshRate(
|
||||||
|
getWindow(),
|
||||||
|
getWindowManager().getDefaultDisplay()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -74,6 +91,34 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
|
|||||||
deleteIfExists(new File(webViewData, "Service Worker"));
|
deleteIfExists(new File(webViewData, "Service Worker"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean shouldClearWebViewCacheForCurrentVersion() {
|
||||||
|
return performancePreferences().getLong(CACHE_CLEARED_VERSION_KEY, -1L)
|
||||||
|
!= currentVersionCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markWebViewCacheClearedForCurrentVersion() {
|
||||||
|
performancePreferences()
|
||||||
|
.edit()
|
||||||
|
.putLong(CACHE_CLEARED_VERSION_KEY, currentVersionCode())
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SharedPreferences performancePreferences() {
|
||||||
|
return getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long currentVersionCode() {
|
||||||
|
try {
|
||||||
|
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
return packageInfo.getLongVersionCode();
|
||||||
|
}
|
||||||
|
return packageInfo.versionCode;
|
||||||
|
} catch (PackageManager.NameNotFoundException exception) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void deleteIfExists(File file) {
|
private void deleteIfExists(File file) {
|
||||||
if (!file.exists()) return;
|
if (!file.exists()) return;
|
||||||
if (file.isDirectory()) {
|
if (file.isDirectory()) {
|
||||||
|
|||||||
@@ -259,9 +259,11 @@ public class DualScreenPlugin extends Plugin {
|
|||||||
private final class TopDisplayPresentation extends Presentation {
|
private final class TopDisplayPresentation extends Presentation {
|
||||||
|
|
||||||
private final String initialUrl;
|
private final String initialUrl;
|
||||||
|
private final Display display;
|
||||||
|
|
||||||
TopDisplayPresentation(Context context, Display display, String initialUrl) {
|
TopDisplayPresentation(Context context, Display display, String initialUrl) {
|
||||||
super(context, display);
|
super(context, display);
|
||||||
|
this.display = display;
|
||||||
this.initialUrl = initialUrl;
|
this.initialUrl = initialUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +279,7 @@ public class DualScreenPlugin extends Plugin {
|
|||||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||||
);
|
);
|
||||||
|
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), display);
|
||||||
|
|
||||||
FrameLayout container = new FrameLayout(getContext());
|
FrameLayout container = new FrameLayout(getContext());
|
||||||
container.setBackgroundColor(Color.BLACK);
|
container.setBackgroundColor(Color.BLACK);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"android:open": "cap open android",
|
"android:open": "cap open android",
|
||||||
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
|
"android:apk": "npm run android:sync && cd android && ./gradlew clean assembleDebug",
|
||||||
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
|
"android:apk:truenas": "VITE_API_BASE_URL=https://iwanttoheal.phenomrom.com npm run android:apk",
|
||||||
|
"release:gui": "python3 scripts/release_game.py",
|
||||||
|
"release:cli": "python3 scripts/release_game_cli.py",
|
||||||
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
|
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
|
||||||
"db:backup": "node scripts/backup-db.mjs",
|
"db:backup": "node scripts/backup-db.mjs",
|
||||||
"db:init": "node scripts/init-db.mjs",
|
"db:init": "node scripts/init-db.mjs",
|
||||||
|
|||||||
Binary file not shown.
Executable
+502
@@ -0,0 +1,502 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from getpass import getpass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox, ttk
|
||||||
|
except ImportError:
|
||||||
|
tk = None
|
||||||
|
messagebox = None
|
||||||
|
ttk = None
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
|
||||||
|
DEFAULT_JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||||
|
DEFAULT_API_BASE_URL = "https://iwanttoheal.phenomrom.com"
|
||||||
|
DEFAULT_GITEA_URL = "https://git.whoagland.com"
|
||||||
|
DEFAULT_GITEA_OWNER = "phenom"
|
||||||
|
DEFAULT_GITEA_REPO = "i-want-to-heal"
|
||||||
|
DEFAULT_BRANCH = "main"
|
||||||
|
DEFAULT_TRUENAS_PATH = "/mnt/usbssds/apps/iwanttoheal/app"
|
||||||
|
DEFAULT_DB_PATH = "/mnt/usbssds/apps/iwanttoheal/data/game.db"
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReleaseConfig:
|
||||||
|
version: str
|
||||||
|
token: str
|
||||||
|
api_base_url: str
|
||||||
|
java_home: str
|
||||||
|
gitea_url: str
|
||||||
|
gitea_owner: str
|
||||||
|
gitea_repo: str
|
||||||
|
branch: str
|
||||||
|
truenas_path: str
|
||||||
|
truenas_db_path: str
|
||||||
|
restart_command: str
|
||||||
|
run_build: bool
|
||||||
|
run_commit_push: bool
|
||||||
|
run_release: bool
|
||||||
|
run_truenas_pull: bool
|
||||||
|
run_truenas_backup: bool
|
||||||
|
run_restart: bool
|
||||||
|
|
||||||
|
|
||||||
|
def read_current_version() -> str:
|
||||||
|
text = GRADLE_FILE.read_text()
|
||||||
|
match = re.search(r'versionName\s+"([^"]+)"', text)
|
||||||
|
return match.group(1) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(
|
||||||
|
args: list[str],
|
||||||
|
*,
|
||||||
|
cwd: Path,
|
||||||
|
env: dict[str, str] | None,
|
||||||
|
log: Callable[[str], None],
|
||||||
|
) -> None:
|
||||||
|
log(f"$ {' '.join(args)}")
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
args,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
assert proc.stdout is not None
|
||||||
|
for line in proc.stdout:
|
||||||
|
log(line.rstrip("\n"))
|
||||||
|
code = proc.wait()
|
||||||
|
if code != 0:
|
||||||
|
raise ReleaseError(f"Command failed ({code}): {' '.join(args)}")
|
||||||
|
|
||||||
|
|
||||||
|
def update_gradle_version(version: str, log: Callable[[str], None]) -> int:
|
||||||
|
text = GRADLE_FILE.read_text()
|
||||||
|
code_match = re.search(r"versionCode\s+(\d+)", text)
|
||||||
|
if not code_match:
|
||||||
|
raise ReleaseError(f"Could not find versionCode in {GRADLE_FILE}")
|
||||||
|
|
||||||
|
next_code = int(code_match.group(1)) + 1
|
||||||
|
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
|
||||||
|
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
|
||||||
|
GRADLE_FILE.write_text(text)
|
||||||
|
log(f"Android version set: versionName {version}, versionCode {next_code}")
|
||||||
|
return next_code
|
||||||
|
|
||||||
|
|
||||||
|
def build_apk(config: ReleaseConfig, log: Callable[[str], None]) -> Path:
|
||||||
|
update_gradle_version(config.version, log)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["JAVA_HOME"] = config.java_home
|
||||||
|
env["PATH"] = f"{config.java_home}/bin:{env.get('PATH', '')}"
|
||||||
|
env["VITE_API_BASE_URL"] = config.api_base_url
|
||||||
|
|
||||||
|
run_command(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env, log=log)
|
||||||
|
run_command(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env, log=log)
|
||||||
|
|
||||||
|
apk_source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
|
||||||
|
if not apk_source.exists():
|
||||||
|
raise ReleaseError(f"APK not found: {apk_source}")
|
||||||
|
apk_target = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
|
||||||
|
apk_target.write_bytes(apk_source.read_bytes())
|
||||||
|
log(f"APK copied: {apk_target.name} ({apk_target.stat().st_size:,} bytes)")
|
||||||
|
return apk_target
|
||||||
|
|
||||||
|
|
||||||
|
def commit_and_push(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||||
|
message = f"Android build v{config.version}"
|
||||||
|
run_command(["git", "add", "."], cwd=REPO_ROOT, env=None, log=log)
|
||||||
|
run_command(["git", "commit", "-m", message], cwd=REPO_ROOT, env=None, log=log)
|
||||||
|
run_command(["git", "push", "origin", config.branch], cwd=REPO_ROOT, env=None, log=log)
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_request(
|
||||||
|
config: ReleaseConfig,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
body: bytes | None = None,
|
||||||
|
content_type: str | None = None,
|
||||||
|
) -> tuple[int, bytes]:
|
||||||
|
url = config.gitea_url.rstrip("/") + path
|
||||||
|
headers = {"Authorization": f"token {config.token}"}
|
||||||
|
if content_type:
|
||||||
|
headers["Content-Type"] = content_type
|
||||||
|
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return resp.status, resp.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
data = exc.read()
|
||||||
|
return exc.code, data
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_response(status: int, data: bytes, action: str) -> dict:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(data.decode("utf-8") or "{}")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ReleaseError(f"{action} failed ({status}): {data.decode('utf-8', 'replace')}") from exc
|
||||||
|
if status >= 400:
|
||||||
|
raise ReleaseError(f"{action} failed ({status}): {parsed}")
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ReleaseError(f"{action} returned unexpected JSON: {parsed}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def find_or_create_release(config: ReleaseConfig, log: Callable[[str], None]) -> int:
|
||||||
|
repo_path = f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
|
||||||
|
tag = f"v{config.version}"
|
||||||
|
tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="")
|
||||||
|
status, data = gitea_request(config, "GET", tag_path)
|
||||||
|
if status == 200:
|
||||||
|
release = parse_json_response(status, data, "Find release")
|
||||||
|
release_id = release.get("id")
|
||||||
|
if not release_id:
|
||||||
|
raise ReleaseError(f"Existing release missing id: {release}")
|
||||||
|
log(f"Gitea release exists: {tag} (id {release_id})")
|
||||||
|
return int(release_id)
|
||||||
|
if status != 404:
|
||||||
|
parse_json_response(status, data, "Find release")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"tag_name": tag,
|
||||||
|
"target_commitish": config.branch,
|
||||||
|
"name": tag,
|
||||||
|
"body": f"I Want to Heal Android build v{config.version}",
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
}
|
||||||
|
status, data = gitea_request(
|
||||||
|
config,
|
||||||
|
"POST",
|
||||||
|
repo_path + "/releases",
|
||||||
|
body=json.dumps(payload).encode("utf-8"),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
release = parse_json_response(status, data, "Create release")
|
||||||
|
release_id = release.get("id")
|
||||||
|
if not release_id:
|
||||||
|
raise ReleaseError(f"Created release missing id: {release}")
|
||||||
|
log(f"Gitea release created: {tag} (id {release_id})")
|
||||||
|
return int(release_id)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||||
|
apk_path = REPO_ROOT / f"IWantToHeal-Thor-v{config.version}.apk"
|
||||||
|
if not apk_path.exists():
|
||||||
|
raise ReleaseError(f"APK not found for release upload: {apk_path}")
|
||||||
|
release_id = find_or_create_release(config, log)
|
||||||
|
|
||||||
|
boundary = f"----iwanttoheal{int(time.time() * 1000)}"
|
||||||
|
header = (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n'
|
||||||
|
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||||
|
body = header + apk_path.read_bytes() + footer
|
||||||
|
asset_name = urllib.parse.quote(apk_path.name)
|
||||||
|
path = (
|
||||||
|
f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}"
|
||||||
|
f"/releases/{release_id}/assets?name={asset_name}"
|
||||||
|
)
|
||||||
|
status, data = gitea_request(
|
||||||
|
config,
|
||||||
|
"POST",
|
||||||
|
path,
|
||||||
|
body=body,
|
||||||
|
content_type=f"multipart/form-data; boundary={boundary}",
|
||||||
|
)
|
||||||
|
if status == 409:
|
||||||
|
raise ReleaseError(f"Release asset already exists: {apk_path.name}")
|
||||||
|
parse_json_response(status, data, "Upload release asset")
|
||||||
|
log(f"Gitea asset uploaded: {apk_path.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def update_truenas(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||||
|
app_path = Path(config.truenas_path)
|
||||||
|
if not app_path.exists():
|
||||||
|
raise ReleaseError(f"TrueNAS path not found: {app_path}")
|
||||||
|
run_command(["git", "pull"], cwd=app_path, env=None, log=log)
|
||||||
|
|
||||||
|
if config.run_truenas_backup:
|
||||||
|
db_path = Path(config.truenas_db_path)
|
||||||
|
if not db_path.exists():
|
||||||
|
raise ReleaseError(f"TrueNAS database not found: {db_path}")
|
||||||
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup_path = db_path.with_name(f"game-before-update-{stamp}.db")
|
||||||
|
backup_path.write_bytes(db_path.read_bytes())
|
||||||
|
log(f"TrueNAS database backup copied: {backup_path}")
|
||||||
|
|
||||||
|
if config.run_restart:
|
||||||
|
if not config.restart_command.strip():
|
||||||
|
raise ReleaseError("Restart command is empty")
|
||||||
|
run_command(["sh", "-lc", config.restart_command], cwd=app_path, env=None, log=log)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(config: ReleaseConfig) -> None:
|
||||||
|
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", config.version):
|
||||||
|
raise ReleaseError("Version must look like 1.1.2")
|
||||||
|
if config.run_release and not config.token:
|
||||||
|
raise ReleaseError("Gitea token required for release step")
|
||||||
|
if config.run_restart and not config.run_truenas_pull:
|
||||||
|
raise ReleaseError("Restart needs TrueNAS update step enabled")
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_text(label: str, default: str = "", *, secret: bool = False) -> str:
|
||||||
|
prompt = f"{label}"
|
||||||
|
if default:
|
||||||
|
prompt += f" [{default}]"
|
||||||
|
prompt += ": "
|
||||||
|
try:
|
||||||
|
value = getpass(prompt) if secret else input(prompt)
|
||||||
|
except EOFError:
|
||||||
|
value = ""
|
||||||
|
return value.strip() or default
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_bool(label: str, default: bool) -> bool:
|
||||||
|
suffix = "Y/n" if default else "y/N"
|
||||||
|
try:
|
||||||
|
value = input(f"{label} [{suffix}]: ").strip().lower()
|
||||||
|
except EOFError:
|
||||||
|
value = ""
|
||||||
|
if not value:
|
||||||
|
return default
|
||||||
|
return value in {"y", "yes", "true", "1"}
|
||||||
|
|
||||||
|
|
||||||
|
def run_release_steps(config: ReleaseConfig, log: Callable[[str], None]) -> None:
|
||||||
|
if config.run_build:
|
||||||
|
build_apk(config, log)
|
||||||
|
if config.run_commit_push:
|
||||||
|
commit_and_push(config, log)
|
||||||
|
if config.run_release:
|
||||||
|
upload_release_asset(config, log)
|
||||||
|
if config.run_truenas_pull:
|
||||||
|
update_truenas(config, log)
|
||||||
|
|
||||||
|
|
||||||
|
def cli_main() -> int:
|
||||||
|
print("Tkinter not available. CLI mode.")
|
||||||
|
current_version = read_current_version()
|
||||||
|
config = ReleaseConfig(
|
||||||
|
version=prompt_text("Version", current_version),
|
||||||
|
token=prompt_text("Gitea token", secret=True),
|
||||||
|
api_base_url=prompt_text("API base URL", DEFAULT_API_BASE_URL),
|
||||||
|
java_home=prompt_text("JAVA_HOME", DEFAULT_JAVA_HOME),
|
||||||
|
gitea_url=prompt_text("Gitea URL", DEFAULT_GITEA_URL),
|
||||||
|
gitea_owner=prompt_text("Gitea owner", DEFAULT_GITEA_OWNER),
|
||||||
|
gitea_repo=prompt_text("Gitea repo", DEFAULT_GITEA_REPO),
|
||||||
|
branch=prompt_text("Git branch", DEFAULT_BRANCH),
|
||||||
|
truenas_path=prompt_text("TrueNAS app path", DEFAULT_TRUENAS_PATH),
|
||||||
|
truenas_db_path=prompt_text("TrueNAS DB path", DEFAULT_DB_PATH),
|
||||||
|
restart_command=prompt_text("Restart command"),
|
||||||
|
run_build=prompt_bool("Build APK", True),
|
||||||
|
run_commit_push=prompt_bool("Commit + push", True),
|
||||||
|
run_release=prompt_bool("Create Gitea release", True),
|
||||||
|
run_truenas_pull=prompt_bool("TrueNAS pull", True),
|
||||||
|
run_truenas_backup=prompt_bool("Backup DB", True),
|
||||||
|
run_restart=prompt_bool("Restart", False),
|
||||||
|
)
|
||||||
|
validate_config(config)
|
||||||
|
run_release_steps(config, print)
|
||||||
|
print("Release steps complete")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if tk is not None and ttk is not None and messagebox is not None:
|
||||||
|
|
||||||
|
class ReleaseApp(ttk.Frame):
|
||||||
|
def __init__(self, root: tk.Tk) -> None:
|
||||||
|
super().__init__(root, padding=12)
|
||||||
|
self.root = root
|
||||||
|
self.log_queue: queue.Queue[tuple[str, str]] = queue.Queue()
|
||||||
|
self.worker: threading.Thread | None = None
|
||||||
|
self.vars: dict[str, tk.Variable] = {}
|
||||||
|
self.grid(sticky="nsew")
|
||||||
|
root.title("I Want to Heal Release Tool")
|
||||||
|
root.geometry("920x720")
|
||||||
|
root.columnconfigure(0, weight=1)
|
||||||
|
root.rowconfigure(0, weight=1)
|
||||||
|
self.columnconfigure(1, weight=1)
|
||||||
|
self.rowconfigure(13, weight=1)
|
||||||
|
self._build_form()
|
||||||
|
self.after(100, self._drain_log_queue)
|
||||||
|
|
||||||
|
def _var(self, name: str, value: object = "") -> tk.Variable:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
var: tk.Variable = tk.BooleanVar(value=value)
|
||||||
|
else:
|
||||||
|
var = tk.StringVar(value=str(value))
|
||||||
|
self.vars[name] = var
|
||||||
|
return var
|
||||||
|
|
||||||
|
def _build_form(self) -> None:
|
||||||
|
current_version = read_current_version()
|
||||||
|
fields = [
|
||||||
|
("Version", "version", current_version),
|
||||||
|
("Gitea token", "token", ""),
|
||||||
|
("API base URL", "api_base_url", DEFAULT_API_BASE_URL),
|
||||||
|
("JAVA_HOME", "java_home", DEFAULT_JAVA_HOME),
|
||||||
|
("Gitea URL", "gitea_url", DEFAULT_GITEA_URL),
|
||||||
|
("Gitea owner", "gitea_owner", DEFAULT_GITEA_OWNER),
|
||||||
|
("Gitea repo", "gitea_repo", DEFAULT_GITEA_REPO),
|
||||||
|
("Git branch", "branch", DEFAULT_BRANCH),
|
||||||
|
("TrueNAS app path", "truenas_path", DEFAULT_TRUENAS_PATH),
|
||||||
|
("TrueNAS DB path", "truenas_db_path", DEFAULT_DB_PATH),
|
||||||
|
("Restart command", "restart_command", ""),
|
||||||
|
]
|
||||||
|
for row, (label, name, value) in enumerate(fields):
|
||||||
|
ttk.Label(self, text=label).grid(row=row, column=0, sticky="w", pady=3)
|
||||||
|
entry = ttk.Entry(self, textvariable=self._var(name, value), show="*" if name == "token" else "")
|
||||||
|
entry.grid(row=row, column=1, sticky="ew", pady=3)
|
||||||
|
|
||||||
|
checks = ttk.Frame(self)
|
||||||
|
checks.grid(row=11, column=0, columnspan=2, sticky="ew", pady=(10, 4))
|
||||||
|
check_data = [
|
||||||
|
("Build APK", "run_build", True),
|
||||||
|
("Commit + push", "run_commit_push", True),
|
||||||
|
("Create Gitea release", "run_release", True),
|
||||||
|
("TrueNAS pull", "run_truenas_pull", True),
|
||||||
|
("Backup DB", "run_truenas_backup", True),
|
||||||
|
("Restart", "run_restart", False),
|
||||||
|
]
|
||||||
|
for index, (text, name, value) in enumerate(check_data):
|
||||||
|
ttk.Checkbutton(checks, text=text, variable=self._var(name, value)).grid(
|
||||||
|
row=0, column=index, padx=(0, 14), sticky="w"
|
||||||
|
)
|
||||||
|
|
||||||
|
buttons = ttk.Frame(self)
|
||||||
|
buttons.grid(row=12, column=0, columnspan=2, sticky="ew", pady=(6, 8))
|
||||||
|
self.run_button = ttk.Button(buttons, text="Run Selected Steps", command=self.run_release)
|
||||||
|
self.run_button.pack(side="left")
|
||||||
|
ttk.Button(buttons, text="Start Admin Server", command=self.start_admin).pack(side="left", padx=8)
|
||||||
|
|
||||||
|
self.log_text = tk.Text(self, height=18, wrap="word")
|
||||||
|
self.log_text.grid(row=13, column=0, columnspan=2, sticky="nsew")
|
||||||
|
scroll = ttk.Scrollbar(self, command=self.log_text.yview)
|
||||||
|
scroll.grid(row=13, column=2, sticky="ns")
|
||||||
|
self.log_text.configure(yscrollcommand=scroll.set)
|
||||||
|
|
||||||
|
def config_from_form(self) -> ReleaseConfig:
|
||||||
|
get = lambda name: str(self.vars[name].get()).strip()
|
||||||
|
get_bool = lambda name: bool(self.vars[name].get())
|
||||||
|
return ReleaseConfig(
|
||||||
|
version=get("version"),
|
||||||
|
token=get("token"),
|
||||||
|
api_base_url=get("api_base_url"),
|
||||||
|
java_home=get("java_home"),
|
||||||
|
gitea_url=get("gitea_url"),
|
||||||
|
gitea_owner=get("gitea_owner"),
|
||||||
|
gitea_repo=get("gitea_repo"),
|
||||||
|
branch=get("branch"),
|
||||||
|
truenas_path=get("truenas_path"),
|
||||||
|
truenas_db_path=get("truenas_db_path"),
|
||||||
|
restart_command=get("restart_command"),
|
||||||
|
run_build=get_bool("run_build"),
|
||||||
|
run_commit_push=get_bool("run_commit_push"),
|
||||||
|
run_release=get_bool("run_release"),
|
||||||
|
run_truenas_pull=get_bool("run_truenas_pull"),
|
||||||
|
run_truenas_backup=get_bool("run_truenas_backup"),
|
||||||
|
run_restart=get_bool("run_restart"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def log(self, message: str) -> None:
|
||||||
|
self.log_queue.put(("log", message))
|
||||||
|
|
||||||
|
def _drain_log_queue(self) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
kind, message = self.log_queue.get_nowait()
|
||||||
|
if kind == "done":
|
||||||
|
self.run_button.configure(state="normal")
|
||||||
|
if message:
|
||||||
|
messagebox.showinfo("Release tool", message)
|
||||||
|
elif kind == "error":
|
||||||
|
self.run_button.configure(state="normal")
|
||||||
|
messagebox.showerror("Release failed", message)
|
||||||
|
else:
|
||||||
|
self.log_text.insert("end", message + "\n")
|
||||||
|
self.log_text.see("end")
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
self.after(100, self._drain_log_queue)
|
||||||
|
|
||||||
|
def run_release(self) -> None:
|
||||||
|
if self.worker and self.worker.is_alive():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
config = self.config_from_form()
|
||||||
|
validate_config(config)
|
||||||
|
except ReleaseError as exc:
|
||||||
|
messagebox.showerror("Invalid settings", str(exc))
|
||||||
|
return
|
||||||
|
self.run_button.configure(state="disabled")
|
||||||
|
self.log_text.delete("1.0", "end")
|
||||||
|
self.worker = threading.Thread(target=self._run_release_worker, args=(config,), daemon=True)
|
||||||
|
self.worker.start()
|
||||||
|
|
||||||
|
def _run_release_worker(self, config: ReleaseConfig) -> None:
|
||||||
|
try:
|
||||||
|
run_release_steps(config, self.log)
|
||||||
|
self.log_queue.put(("done", "Release steps complete"))
|
||||||
|
except Exception as exc:
|
||||||
|
self.log_queue.put(("error", str(exc)))
|
||||||
|
|
||||||
|
def start_admin(self) -> None:
|
||||||
|
if self.worker and self.worker.is_alive():
|
||||||
|
messagebox.showerror("Busy", "Release steps are still running")
|
||||||
|
return
|
||||||
|
self.log_text.delete("1.0", "end")
|
||||||
|
self.run_button.configure(state="disabled")
|
||||||
|
self.worker = threading.Thread(target=self._start_admin_worker, daemon=True)
|
||||||
|
self.worker.start()
|
||||||
|
|
||||||
|
def _start_admin_worker(self) -> None:
|
||||||
|
try:
|
||||||
|
self.log("Admin URL: http://127.0.0.1:4174")
|
||||||
|
run_command(["npm", "run", "admin:start"], cwd=REPO_ROOT, env=None, log=self.log)
|
||||||
|
self.log_queue.put(("done", "Admin server stopped"))
|
||||||
|
except Exception as exc:
|
||||||
|
self.log_queue.put(("error", str(exc)))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not GRADLE_FILE.exists():
|
||||||
|
print(f"Missing Gradle file: {GRADLE_FILE}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if tk is None:
|
||||||
|
return cli_main()
|
||||||
|
root = tk.Tk()
|
||||||
|
ReleaseApp(root)
|
||||||
|
root.mainloop()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+166
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
GRADLE_FILE = REPO_ROOT / "android" / "app" / "build.gradle"
|
||||||
|
JAVA_HOME = "/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||||
|
API_BASE_URL = "https://iwanttoheal.phenomrom.com"
|
||||||
|
GITEA_URL = "https://git.whoagland.com"
|
||||||
|
GITEA_OWNER = "phenom"
|
||||||
|
GITEA_REPO = "i-want-to-heal"
|
||||||
|
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
||||||
|
BRANCH = "main"
|
||||||
|
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app")
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
|
||||||
|
print(f"$ {' '.join(args)}", flush=True)
|
||||||
|
subprocess.run(args, cwd=cwd, env=env, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_version() -> str:
|
||||||
|
current = GRADLE_FILE.read_text()
|
||||||
|
match = re.search(r'versionName\s+"([^"]+)"', current)
|
||||||
|
current_version = match.group(1) if match else ""
|
||||||
|
suffix = f" [{current_version}]" if current_version else ""
|
||||||
|
version = input(f"Version{suffix}: ").strip() or current_version
|
||||||
|
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
|
||||||
|
raise SystemExit("Version must look like 1.1.2")
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def update_gradle_version(version: str) -> None:
|
||||||
|
text = GRADLE_FILE.read_text()
|
||||||
|
code_match = re.search(r"versionCode\s+(\d+)", text)
|
||||||
|
if not code_match:
|
||||||
|
raise SystemExit(f"Could not find versionCode in {GRADLE_FILE}")
|
||||||
|
|
||||||
|
next_code = int(code_match.group(1)) + 1
|
||||||
|
text = re.sub(r"versionCode\s+\d+", f"versionCode {next_code}", text, count=1)
|
||||||
|
text = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version}"', text, count=1)
|
||||||
|
GRADLE_FILE.write_text(text)
|
||||||
|
print(f"Android version: {version}, versionCode: {next_code}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_apk(version: str) -> Path:
|
||||||
|
update_gradle_version(version)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["JAVA_HOME"] = JAVA_HOME
|
||||||
|
env["PATH"] = f"{JAVA_HOME}/bin:{env.get('PATH', '')}"
|
||||||
|
env["VITE_API_BASE_URL"] = API_BASE_URL
|
||||||
|
|
||||||
|
run(["npm", "run", "android:sync"], cwd=REPO_ROOT, env=env)
|
||||||
|
run(["./gradlew", "clean", "assembleDebug"], cwd=REPO_ROOT / "android", env=env)
|
||||||
|
|
||||||
|
source = REPO_ROOT / "android" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
|
||||||
|
apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk"
|
||||||
|
shutil.copy2(source, apk)
|
||||||
|
print(f"APK: {apk}")
|
||||||
|
return apk
|
||||||
|
|
||||||
|
|
||||||
|
def commit_and_push(version: str) -> None:
|
||||||
|
run(["git", "add", "."], cwd=REPO_ROOT)
|
||||||
|
run(["git", "commit", "-m", f"Android build v{version}"], cwd=REPO_ROOT)
|
||||||
|
run(["git", "push", "origin", BRANCH], cwd=REPO_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(method: str, path: str, token: str, data: dict | None = None) -> dict:
|
||||||
|
body = json.dumps(data).encode("utf-8") if data is not None else None
|
||||||
|
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||||
|
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", "replace")
|
||||||
|
raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def upload_asset(path: str, token: str, apk: Path) -> dict:
|
||||||
|
boundary = "----iwanttoheal-release-boundary"
|
||||||
|
body = (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n'
|
||||||
|
"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
body += apk.read_bytes()
|
||||||
|
body += f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"token {token}",
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", "replace")
|
||||||
|
raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def create_gitea_release(version: str, apk: Path) -> None:
|
||||||
|
token = GITEA_TOKEN.strip()
|
||||||
|
if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE":
|
||||||
|
raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py")
|
||||||
|
|
||||||
|
repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}"
|
||||||
|
release = request_json(
|
||||||
|
"POST",
|
||||||
|
repo_path + "/releases",
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
"tag_name": f"v{version}",
|
||||||
|
"target_commitish": BRANCH,
|
||||||
|
"name": f"v{version}",
|
||||||
|
"body": f"I Want to Heal Android build v{version}",
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
release_id = release.get("id")
|
||||||
|
if not release_id:
|
||||||
|
raise SystemExit(f"Gitea release missing id: {release}")
|
||||||
|
|
||||||
|
asset_name = urllib.parse.quote(apk.name)
|
||||||
|
upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk)
|
||||||
|
print(f"Gitea release uploaded: v{version}")
|
||||||
|
|
||||||
|
|
||||||
|
def update_truenas() -> None:
|
||||||
|
if TRUENAS_PATH.exists():
|
||||||
|
run(["git", "pull"], cwd=TRUENAS_PATH)
|
||||||
|
print("Restart app in TrueNAS UI.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("TrueNAS path not mounted here. Run on TrueNAS:")
|
||||||
|
print(f"cd {TRUENAS_PATH}")
|
||||||
|
print("git pull")
|
||||||
|
print("Then restart app in TrueNAS UI.")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
os.chdir(REPO_ROOT)
|
||||||
|
version = prompt_version()
|
||||||
|
apk = build_apk(version)
|
||||||
|
commit_and_push(version)
|
||||||
|
create_gitea_release(version, apk)
|
||||||
|
update_truenas()
|
||||||
|
print("Done.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+262
-145
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -20,6 +20,7 @@ import {
|
|||||||
type GameMode,
|
type GameMode,
|
||||||
} from './gameRepository'
|
} from './gameRepository'
|
||||||
import { focusFirstControl } from './input.tsx'
|
import { focusFirstControl } from './input.tsx'
|
||||||
|
import { barFillStyle } from './components/barStyles'
|
||||||
|
|
||||||
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
|
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
|
||||||
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
|
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
|
||||||
@@ -469,7 +470,7 @@ function App() {
|
|||||||
<small>Level {profile.character.level}</small>
|
<small>Level {profile.character.level}</small>
|
||||||
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
|
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
|
||||||
<div className="header-xp" title={`${profile.character.experience} total experience`}>
|
<div className="header-xp" title={`${profile.character.experience} total experience`}>
|
||||||
<span style={{ width: `${experiencePercent}%` }} />
|
<span style={barFillStyle(experiencePercent)} />
|
||||||
</div>
|
</div>
|
||||||
<button className="logout-button" onClick={signOut} type="button">
|
<button className="logout-button" onClick={signOut} type="button">
|
||||||
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
|
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
|
||||||
|
|||||||
@@ -1,21 +1,79 @@
|
|||||||
import type { Spell } from '../game'
|
import type { Spell } from '../game'
|
||||||
|
|
||||||
|
export function cooldownNow() {
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cooldownRemaining(
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
spellId: string,
|
||||||
|
now = cooldownNow(),
|
||||||
|
) {
|
||||||
|
return Math.max(0, ((cooldowns[spellId] ?? 0) - now) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasActiveCooldowns(
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
now = cooldownNow(),
|
||||||
|
) {
|
||||||
|
return Object.values(cooldowns).some((readyAtMs) => readyAtMs > now)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneExpiredCooldowns(
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
now = cooldownNow(),
|
||||||
|
) {
|
||||||
|
let changed = false
|
||||||
|
const nextCooldowns: Record<string, number> = {}
|
||||||
|
for (const id in cooldowns) {
|
||||||
|
if (cooldowns[id] <= now) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextCooldowns[id] = cooldowns[id]
|
||||||
|
}
|
||||||
|
return changed ? nextCooldowns : cooldowns
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduceCooldown(
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
spellId: string,
|
||||||
|
seconds: number,
|
||||||
|
now = cooldownNow(),
|
||||||
|
) {
|
||||||
|
const readyAtMs = cooldowns[spellId]
|
||||||
|
if (!readyAtMs || readyAtMs <= now) return cooldowns
|
||||||
|
const nextReadyAtMs = readyAtMs - seconds * 1000
|
||||||
|
const nextCooldowns = { ...cooldowns }
|
||||||
|
if (nextReadyAtMs <= now) delete nextCooldowns[spellId]
|
||||||
|
else nextCooldowns[spellId] = nextReadyAtMs
|
||||||
|
return nextCooldowns
|
||||||
|
}
|
||||||
|
|
||||||
export function canCastSpell(
|
export function canCastSpell(
|
||||||
spell: Spell,
|
spell: Spell,
|
||||||
resource: number,
|
resource: number,
|
||||||
cooldowns: Record<string, number>,
|
cooldowns: Record<string, number>,
|
||||||
resourceCost: number,
|
resourceCost: number,
|
||||||
|
now = cooldownNow(),
|
||||||
) {
|
) {
|
||||||
return resource >= resourceCost && (cooldowns[spell.id] ?? 0) <= 0
|
return resource >= resourceCost && cooldownRemaining(cooldowns, spell.id, now) <= 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export function putSpellOnCooldown(
|
export function putSpellOnCooldown(
|
||||||
cooldowns: Record<string, number>,
|
cooldowns: Record<string, number>,
|
||||||
spell: Spell,
|
spell: Spell,
|
||||||
cooldownMultiplier = 1,
|
cooldownMultiplier = 1,
|
||||||
|
now = cooldownNow(),
|
||||||
) {
|
) {
|
||||||
|
const cooldownMs = spell.cooldown * cooldownMultiplier * 1000
|
||||||
|
if (cooldownMs <= 0) {
|
||||||
|
const nextCooldowns = { ...cooldowns }
|
||||||
|
delete nextCooldowns[spell.id]
|
||||||
|
return nextCooldowns
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...cooldowns,
|
...cooldowns,
|
||||||
[spell.id]: spell.cooldown * cooldownMultiplier,
|
[spell.id]: now + cooldownMs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
spellPowerMultiplier,
|
spellPowerMultiplier,
|
||||||
spellResourceCost as modifiedSpellResourceCost,
|
spellResourceCost as modifiedSpellResourceCost,
|
||||||
} from '../combat/spellModifiers'
|
} from '../combat/spellModifiers'
|
||||||
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
|
import { regenerateResource } from '../combat/combatTick'
|
||||||
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
|
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
|
||||||
import { applyCastStateUpdate } from '../combat/combatStateTransitions'
|
import { applyCastStateUpdate } from '../combat/combatStateTransitions'
|
||||||
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
|
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
|
||||||
@@ -58,7 +58,11 @@ import {
|
|||||||
appendCombatLog,
|
appendCombatLog,
|
||||||
type BasicFloatingCombatText,
|
type BasicFloatingCombatText,
|
||||||
} from '../combat/combatPresentation'
|
} from '../combat/combatPresentation'
|
||||||
import { canCastSpell } from '../combat/spellCasting'
|
import {
|
||||||
|
canCastSpell,
|
||||||
|
pruneExpiredCooldowns,
|
||||||
|
reduceCooldown,
|
||||||
|
} from '../combat/spellCasting'
|
||||||
import {
|
import {
|
||||||
applySpellEffectProfile,
|
applySpellEffectProfile,
|
||||||
buildSpellTargetPlan,
|
buildSpellTargetPlan,
|
||||||
@@ -69,9 +73,11 @@ import {
|
|||||||
useInput,
|
useInput,
|
||||||
} from '../input'
|
} from '../input'
|
||||||
import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
|
import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
|
||||||
|
import { useSpellSlots } from '../hooks/useSpellSlots'
|
||||||
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||||
import { PartyMemberFrame } from './PartyFrames'
|
import { PartyMemberFrame } from './PartyFrames'
|
||||||
import { ResourceBar, SpellBar, type SpellSlot } from './SpellBars'
|
import { ResourceBar, SpellBar } from './SpellBars'
|
||||||
|
import { barFillStyle } from './barStyles'
|
||||||
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
|
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
|
||||||
import { ResultScreen } from './ResultScreen'
|
import { ResultScreen } from './ResultScreen'
|
||||||
import {
|
import {
|
||||||
@@ -668,10 +674,10 @@ export function CombatScreen({
|
|||||||
})
|
})
|
||||||
floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
|
floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
|
||||||
resourceSpentRef.current += effectiveCost
|
resourceSpentRef.current += effectiveCost
|
||||||
const nextCooldowns = { ...current.cooldowns }
|
let nextCooldowns = current.cooldowns
|
||||||
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
|
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
|
||||||
const radiance = spellByName.get('Radiance')
|
const radiance = spellByName.get('Radiance')
|
||||||
if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2)
|
if (radiance) nextCooldowns = reduceCooldown(nextCooldowns, radiance.id, 2)
|
||||||
}
|
}
|
||||||
setCombat(applyCastStateUpdate({
|
setCombat(applyCastStateUpdate({
|
||||||
current,
|
current,
|
||||||
@@ -855,7 +861,7 @@ export function CombatScreen({
|
|||||||
const runCombatTick = useCallback(() => {
|
const runCombatTick = useCallback(() => {
|
||||||
const current = combatRef.current
|
const current = combatRef.current
|
||||||
const nextElapsedTicks = current.elapsedTicks + 1
|
const nextElapsedTicks = current.elapsedTicks + 1
|
||||||
const nextCooldowns = advanceCooldowns(current.cooldowns, tickSeconds(TICK_MS))
|
const nextCooldowns = pruneExpiredCooldowns(current.cooldowns)
|
||||||
let nextResource = regenerateResource(current.resource, 2.4, maxResource)
|
let nextResource = regenerateResource(current.resource, 2.4, maxResource)
|
||||||
|
|
||||||
const living = current.party.filter((member) => member.health > 0)
|
const living = current.party.filter((member) => member.health > 0)
|
||||||
@@ -1161,17 +1167,17 @@ export function CombatScreen({
|
|||||||
}).reverse(),
|
}).reverse(),
|
||||||
[enemyCount, enemyHealth, encounter.maxHealth],
|
[enemyCount, enemyHealth, encounter.maxHealth],
|
||||||
)
|
)
|
||||||
const spellSlots = useMemo<SpellSlot[]>(() => profile.abilitySlots.map((abilityId, slotIndex) => {
|
const spellSlotCost = useCallback(
|
||||||
const spell = spellByKey.get(String(slotIndex + 1))
|
(spell: Spell) => spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
|
||||||
return abilityId && spell
|
[freeCastReady, roguelikeUpgradeCounts],
|
||||||
? {
|
)
|
||||||
...spell,
|
const spellSlots = useSpellSlots({
|
||||||
cost: spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
|
spells,
|
||||||
slotIndex,
|
abilitySlots: profile.abilitySlots,
|
||||||
remaining: cooldowns[spell.id] ?? 0,
|
cooldowns,
|
||||||
}
|
active: status === 'playing' && !paused,
|
||||||
: null
|
cost: spellSlotCost,
|
||||||
}), [cooldowns, freeCastReady, profile.abilitySlots, roguelikeUpgradeCounts, spellByKey])
|
})
|
||||||
const dualScreenState = useMemo(() => buildCombatDualScreenState({
|
const dualScreenState = useMemo(() => buildCombatDualScreenState({
|
||||||
difficultyName: difficulty.name,
|
difficultyName: difficulty.name,
|
||||||
dungeonName: dungeon.name,
|
dungeonName: dungeon.name,
|
||||||
@@ -1230,9 +1236,9 @@ export function CombatScreen({
|
|||||||
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
|
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
|
||||||
data-combat-active={paused ? 'false' : 'true'}
|
data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
|
||||||
>
|
>
|
||||||
{!dualScreenEnabled && <header className="topbar">
|
{!dualScreenEnabled && <header className="topbar">
|
||||||
<div>
|
<div>
|
||||||
@@ -1266,13 +1272,13 @@ export function CombatScreen({
|
|||||||
<div className="hard-enemy-bars">
|
<div className="hard-enemy-bars">
|
||||||
{enemyHealthSegments.map((segment) => (
|
{enemyHealthSegments.map((segment) => (
|
||||||
<div className="bar enemy-health" key={segment.index}>
|
<div className="bar enemy-health" key={segment.index}>
|
||||||
<span style={{ width: `${segment.percent}%` }} />
|
<span style={barFillStyle(segment.percent)} />
|
||||||
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
|
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
|
<div className="bar enemy-health"><span style={barFillStyle(enemyPercent)} /></div>
|
||||||
)}
|
)}
|
||||||
<p>{encounter.description}</p>
|
<p>{encounter.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
memberHotEffects,
|
memberHotEffects,
|
||||||
} from '../combat/rules'
|
} from '../combat/rules'
|
||||||
import { ControllerBindingLabel } from './ControllerIcons'
|
import { ControllerBindingLabel } from './ControllerIcons'
|
||||||
|
import { barFillStyle } from './barStyles'
|
||||||
|
|
||||||
export type FloatingCombatText = {
|
export type FloatingCombatText = {
|
||||||
id: number
|
id: number
|
||||||
@@ -131,8 +132,8 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({
|
|||||||
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
|
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
|
||||||
</div>
|
</div>
|
||||||
<div className="bar member-health">
|
<div className="bar member-health">
|
||||||
<span style={{ width: `${(member.health / maxHealth) * 100}%` }} />
|
<span style={barFillStyle((member.health / maxHealth) * 100)} />
|
||||||
{member.shield > 0 && <i style={{ width: `${(member.shield / maxHealth) * 100}%` }} />}
|
{member.shield > 0 && <i style={barFillStyle((member.shield / maxHealth) * 100, 'right')} />}
|
||||||
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
|
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
|
||||||
</div>
|
</div>
|
||||||
<div className="floating-combat-texts" aria-hidden="true">
|
<div className="floating-combat-texts" aria-hidden="true">
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import { completeRoguelike, type DungeonReward } from '../profile'
|
|||||||
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
import type { CharacterProfile, DungeonEncounter } from '../profile'
|
||||||
import type { GameMode } from '../gameRepository'
|
import type { GameMode } from '../gameRepository'
|
||||||
import { PartyMemberFrame } from './PartyFrames'
|
import { PartyMemberFrame } from './PartyFrames'
|
||||||
import { SpellBar, type SpellSlot } from './SpellBars'
|
import { SpellBar } from './SpellBars'
|
||||||
|
import { barFillStyle } from './barStyles'
|
||||||
import { focusFirstControl, useGameAction, useInput } from '../input'
|
import { focusFirstControl, useGameAction, useInput } from '../input'
|
||||||
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
||||||
|
import { useSpellSlots } from '../hooks/useSpellSlots'
|
||||||
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
||||||
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||||
import {
|
import {
|
||||||
@@ -37,10 +39,14 @@ import {
|
|||||||
spellExtraTargets,
|
spellExtraTargets,
|
||||||
spellResourceCost as modifiedSpellResourceCost,
|
spellResourceCost as modifiedSpellResourceCost,
|
||||||
} from '../combat/spellModifiers'
|
} from '../combat/spellModifiers'
|
||||||
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
|
import { regenerateResource } from '../combat/combatTick'
|
||||||
import { advanceMemberTick } from '../combat/combatEngine'
|
import { advanceMemberTick } from '../combat/combatEngine'
|
||||||
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
|
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
|
||||||
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
||||||
|
import {
|
||||||
|
pruneExpiredCooldowns,
|
||||||
|
reduceCooldown,
|
||||||
|
} from '../combat/spellCasting'
|
||||||
import {
|
import {
|
||||||
nextPvpRoguelikeStage,
|
nextPvpRoguelikeStage,
|
||||||
resolvePvpRoguelikeCombatOutcome,
|
resolvePvpRoguelikeCombatOutcome,
|
||||||
@@ -398,12 +404,16 @@ export function PvPRoguelikeScreen({
|
|||||||
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
|
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
|
||||||
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
||||||
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
|
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
|
||||||
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
|
const playerSpellSlotCost = useCallback(
|
||||||
...spell,
|
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
|
||||||
cost: spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
|
[playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady],
|
||||||
slotIndex,
|
)
|
||||||
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
const playerSpellSlots = useSpellSlots({
|
||||||
})), [playerBuffCounts, playerDebuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
|
spells: starterSpells,
|
||||||
|
cooldowns: playerSide.cooldowns,
|
||||||
|
active: status === 'playing' && !paused,
|
||||||
|
cost: playerSpellSlotCost,
|
||||||
|
})
|
||||||
const opponentBuffSummary = useMemo(
|
const opponentBuffSummary = useMemo(
|
||||||
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
|
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
|
||||||
[cpuSide.buffs, selfBuffChoicesCatalog],
|
[cpuSide.buffs, selfBuffChoicesCatalog],
|
||||||
@@ -880,9 +890,9 @@ export function PvPRoguelikeScreen({
|
|||||||
damageReductionTargets: new Set<string>(),
|
damageReductionTargets: new Set<string>(),
|
||||||
groupTargets,
|
groupTargets,
|
||||||
}
|
}
|
||||||
const nextCooldowns = { ...current.cooldowns }
|
let nextCooldowns = current.cooldowns
|
||||||
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
|
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
|
||||||
nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2)
|
nextCooldowns = reduceCooldown(nextCooldowns, radianceEffect.id, 2)
|
||||||
}
|
}
|
||||||
return applyPvpSpellCast({
|
return applyPvpSpellCast({
|
||||||
current,
|
current,
|
||||||
@@ -994,7 +1004,7 @@ export function PvPRoguelikeScreen({
|
|||||||
...side,
|
...side,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
resource: regenerateResource(side.resource, 2.4, maxResource),
|
resource: regenerateResource(side.resource, 2.4, maxResource),
|
||||||
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)),
|
cooldowns: pruneExpiredCooldowns(side.cooldowns),
|
||||||
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
||||||
}
|
}
|
||||||
}, [activeSpellEffects, addFloatingHeal, maxResource])
|
}, [activeSpellEffects, addFloatingHeal, maxResource])
|
||||||
@@ -1339,6 +1349,9 @@ export function PvPRoguelikeScreen({
|
|||||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
||||||
opponentParty: cpuSide.party,
|
opponentParty: cpuSide.party,
|
||||||
opponentEnemyHealth: cpuSide.enemyHealth,
|
opponentEnemyHealth: cpuSide.enemyHealth,
|
||||||
|
opponentResource: cpuSide.resource,
|
||||||
|
opponentMaxResource: maxResource,
|
||||||
|
opponentResourceName: gameClass.resourceName,
|
||||||
opponentBuffSummary,
|
opponentBuffSummary,
|
||||||
opponentDebuffSummary,
|
opponentDebuffSummary,
|
||||||
floatingTexts: dualScreenFloatingTexts,
|
floatingTexts: dualScreenFloatingTexts,
|
||||||
@@ -1362,6 +1375,7 @@ export function PvPRoguelikeScreen({
|
|||||||
cpuDifficulty,
|
cpuDifficulty,
|
||||||
cpuSide.enemyHealth,
|
cpuSide.enemyHealth,
|
||||||
cpuSide.party,
|
cpuSide.party,
|
||||||
|
cpuSide.resource,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
encounter.description,
|
encounter.description,
|
||||||
encounter.enemyName,
|
encounter.enemyName,
|
||||||
@@ -1424,13 +1438,13 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="pvp-clear-wrap">
|
<div className="pvp-clear-wrap">
|
||||||
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
|
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||||
<div className="bar enemy-health boss-bar">
|
<div className="bar enemy-health boss-bar">
|
||||||
<span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
<span style={barFillStyle((playerSide.enemyHealth / encounter.maxHealth) * 100)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
||||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / maxResource) * 100)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1464,12 +1478,12 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="pvp-clear-wrap">
|
<div className="pvp-clear-wrap">
|
||||||
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
|
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
|
||||||
<div className="bar enemy-health boss-bar">
|
<div className="bar enemy-health boss-bar">
|
||||||
<span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
|
<span style={barFillStyle((cpuSide.enemyHealth / encounter.maxHealth) * 100)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
|
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / maxResource) * 100)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import { completeRoguelike } from '../profile'
|
|||||||
import type { CharacterProfile } from '../profile'
|
import type { CharacterProfile } from '../profile'
|
||||||
import type { GameMode } from '../gameRepository'
|
import type { GameMode } from '../gameRepository'
|
||||||
import { PartyMemberFrame } from './PartyFrames'
|
import { PartyMemberFrame } from './PartyFrames'
|
||||||
import { SpellBar, type SpellSlot } from './SpellBars'
|
import { SpellBar } from './SpellBars'
|
||||||
|
import { barFillStyle } from './barStyles'
|
||||||
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
|
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
|
||||||
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
|
||||||
|
import { useSpellSlots } from '../hooks/useSpellSlots'
|
||||||
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
|
||||||
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
import { usePartyTargeting } from '../hooks/usePartyTargeting'
|
||||||
import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
|
import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
|
||||||
@@ -31,10 +33,13 @@ import {
|
|||||||
spellPowerMultiplier,
|
spellPowerMultiplier,
|
||||||
spellResourceCost as modifiedSpellResourceCost,
|
spellResourceCost as modifiedSpellResourceCost,
|
||||||
} from '../combat/spellModifiers'
|
} from '../combat/spellModifiers'
|
||||||
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
|
import { regenerateResource } from '../combat/combatTick'
|
||||||
import { advanceMemberTick } from '../combat/combatEngine'
|
import { advanceMemberTick } from '../combat/combatEngine'
|
||||||
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
|
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
|
||||||
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
|
||||||
|
import {
|
||||||
|
pruneExpiredCooldowns,
|
||||||
|
} from '../combat/spellCasting'
|
||||||
import {
|
import {
|
||||||
appendCombatLog,
|
appendCombatLog,
|
||||||
STADIUM_COMBAT_LOG_LIMIT,
|
STADIUM_COMBAT_LOG_LIMIT,
|
||||||
@@ -400,12 +405,16 @@ export function PvpStadiumScreen({
|
|||||||
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
|
||||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||||
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
|
||||||
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
|
const playerSpellSlotCost = useCallback(
|
||||||
...spell,
|
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
|
||||||
cost: spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
|
[playerBuffCounts, playerSide.freeCastReady],
|
||||||
slotIndex,
|
)
|
||||||
remaining: playerSide.cooldowns[spell.id] ?? 0,
|
const playerSpellSlots = useSpellSlots({
|
||||||
})), [playerBuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
|
spells: starterSpells,
|
||||||
|
cooldowns: playerSide.cooldowns,
|
||||||
|
active: status === 'playing' && !paused,
|
||||||
|
cost: playerSpellSlotCost,
|
||||||
|
})
|
||||||
const opponentBuffSummary = useMemo(
|
const opponentBuffSummary = useMemo(
|
||||||
() => summarizeStacks(cpuSide.buffs, buffCatalog),
|
() => summarizeStacks(cpuSide.buffs, buffCatalog),
|
||||||
[buffCatalog, cpuSide.buffs],
|
[buffCatalog, cpuSide.buffs],
|
||||||
@@ -790,7 +799,7 @@ export function PvpStadiumScreen({
|
|||||||
...side,
|
...side,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
|
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
|
||||||
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)),
|
cooldowns: pruneExpiredCooldowns(side.cooldowns),
|
||||||
survivalSeconds: nextSurvival,
|
survivalSeconds: nextSurvival,
|
||||||
dampeningPercent,
|
dampeningPercent,
|
||||||
}
|
}
|
||||||
@@ -1055,6 +1064,9 @@ export function PvpStadiumScreen({
|
|||||||
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
|
||||||
opponentParty: cpuSide.party,
|
opponentParty: cpuSide.party,
|
||||||
opponentEnemyHealth: 0,
|
opponentEnemyHealth: 0,
|
||||||
|
opponentResource: cpuSide.resource,
|
||||||
|
opponentMaxResource: MAX_RESOURCE,
|
||||||
|
opponentResourceName: gameClass.resourceName,
|
||||||
opponentBuffSummary,
|
opponentBuffSummary,
|
||||||
opponentDebuffSummary,
|
opponentDebuffSummary,
|
||||||
floatingTexts: dualScreenFloatingTexts,
|
floatingTexts: dualScreenFloatingTexts,
|
||||||
@@ -1083,6 +1095,7 @@ export function PvpStadiumScreen({
|
|||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
cpuDifficulty,
|
cpuDifficulty,
|
||||||
cpuSide.party,
|
cpuSide.party,
|
||||||
|
cpuSide.resource,
|
||||||
cpuSide.survivalSeconds,
|
cpuSide.survivalSeconds,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
dualScreenFloatingTexts,
|
dualScreenFloatingTexts,
|
||||||
@@ -1164,16 +1177,16 @@ export function PvpStadiumScreen({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="dual-top-spell-strip">
|
<section className="dual-top-spell-strip">
|
||||||
{starterSpells.map((spell, slotIndex) => {
|
{playerSpellSlots.map((spell, slotIndex) => {
|
||||||
const remaining = playerSide.cooldowns[spell.id] ?? 0
|
if (!spell) return null
|
||||||
const cost = spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady)
|
const remaining = spell.remaining
|
||||||
const percent = remaining > 0
|
const percent = remaining > 0
|
||||||
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
|
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
|
||||||
: 0
|
: 0
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="dual-top-spell"
|
className="dual-top-spell"
|
||||||
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < cost || paused}
|
disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < spell.cost || paused}
|
||||||
key={spell.id}
|
key={spell.id}
|
||||||
onClick={() => castPlayerSpell(spell)}
|
onClick={() => castPlayerSpell(spell)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1188,7 +1201,7 @@ export function PvpStadiumScreen({
|
|||||||
<div className="dual-top-resource">
|
<div className="dual-top-resource">
|
||||||
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
|
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
|
||||||
<div className="bar mana-bar">
|
<div className="bar mana-bar">
|
||||||
<span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} />
|
<span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1222,7 +1235,7 @@ export function PvpStadiumScreen({
|
|||||||
</div>
|
</div>
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span>
|
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span>
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="party-grid pvp-party-grid">
|
<div className="party-grid pvp-party-grid">
|
||||||
@@ -1252,7 +1265,7 @@ export function PvpStadiumScreen({
|
|||||||
</div>
|
</div>
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
<span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / MAX_RESOURCE) * 100)} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="party-grid pvp-party-grid">
|
<div className="party-grid pvp-party-grid">
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
capture,
|
capture,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
|
combatTouchLocked,
|
||||||
beginCapture,
|
beginCapture,
|
||||||
cancelCapture,
|
cancelCapture,
|
||||||
resetBindings,
|
resetBindings,
|
||||||
setControllerIconStyle,
|
setControllerIconStyle,
|
||||||
setDirectPartyTargeting,
|
setDirectPartyTargeting,
|
||||||
|
setCombatTouchLocked,
|
||||||
} = useInput()
|
} = useInput()
|
||||||
const {
|
const {
|
||||||
enabled: dualScreenEnabled,
|
enabled: dualScreenEnabled,
|
||||||
@@ -180,6 +182,14 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
|
|||||||
>
|
>
|
||||||
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
|
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed={combatTouchLocked}
|
||||||
|
className={combatTouchLocked ? 'selected' : ''}
|
||||||
|
onClick={() => setCombatTouchLocked(!combatTouchLocked)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{combatTouchLocked ? 'Combat Touch Locked' : 'Combat Touch Unlocked'}
|
||||||
|
</button>
|
||||||
<div className="controller-icon-options">
|
<div className="controller-icon-options">
|
||||||
<span>Controller Icons</span>
|
<span>Controller Icons</span>
|
||||||
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
|
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { memo } from 'react'
|
|||||||
import type { ControllerIconStyle } from '../input'
|
import type { ControllerIconStyle } from '../input'
|
||||||
import type { Spell } from '../game'
|
import type { Spell } from '../game'
|
||||||
import { ControllerBindingLabel } from './ControllerIcons'
|
import { ControllerBindingLabel } from './ControllerIcons'
|
||||||
|
import { barFillStyle } from './barStyles'
|
||||||
|
|
||||||
export type SpellSlot = (Spell & {
|
export type SpellSlot = (Spell & {
|
||||||
cost: number
|
cost: number
|
||||||
@@ -28,7 +29,7 @@ export const ResourceBar = memo(function ResourceBar({
|
|||||||
{unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`}
|
{unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`}
|
||||||
</span>
|
</span>
|
||||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={barFillStyle((resource / maxResource) * 100)} /></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
|
function clampScale(value: number) {
|
||||||
|
if (!Number.isFinite(value)) return 0
|
||||||
|
return Math.min(1, Math.max(0, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function barFillStyle(
|
||||||
|
percent: number,
|
||||||
|
origin: 'left' | 'right' = 'left',
|
||||||
|
): CSSProperties {
|
||||||
|
return {
|
||||||
|
transform: `scaleX(${clampScale(percent / 100)})`,
|
||||||
|
transformOrigin: `${origin} center`,
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-37
@@ -22,6 +22,7 @@ import {
|
|||||||
} from './input'
|
} from './input'
|
||||||
import { ControllerBindingLabel } from './components/ControllerIcons'
|
import { ControllerBindingLabel } from './components/ControllerIcons'
|
||||||
import { PartyMemberFrame } from './components/PartyFrames'
|
import { PartyMemberFrame } from './components/PartyFrames'
|
||||||
|
import { barFillStyle } from './components/barStyles'
|
||||||
import { groupFloatingTextsByMember } from './combat/combatPresentation'
|
import { groupFloatingTextsByMember } from './combat/combatPresentation'
|
||||||
|
|
||||||
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
|
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
|
||||||
@@ -47,6 +48,9 @@ export type DualScreenCombatState = {
|
|||||||
opponentClassName?: string
|
opponentClassName?: string
|
||||||
opponentParty?: PartyMember[]
|
opponentParty?: PartyMember[]
|
||||||
opponentEnemyHealth?: number
|
opponentEnemyHealth?: number
|
||||||
|
opponentResource?: number
|
||||||
|
opponentMaxResource?: number
|
||||||
|
opponentResourceName?: string
|
||||||
opponentBuffSummary?: string
|
opponentBuffSummary?: string
|
||||||
opponentDebuffSummary?: string
|
opponentDebuffSummary?: string
|
||||||
floatingTexts: Array<{
|
floatingTexts: Array<{
|
||||||
@@ -477,8 +481,19 @@ export function DualScreenBottomDisplay() {
|
|||||||
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
|
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="bar enemy-health boss-bar">
|
<div className="bar enemy-health boss-bar">
|
||||||
<span style={{ width: `${Math.max(0, ((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)}%` }} />
|
<span style={barFillStyle(((state.opponentEnemyHealth ?? 0) / state.encounterMaxHealth) * 100)} />
|
||||||
</div>
|
</div>
|
||||||
|
{typeof state.opponentResource === 'number' && state.opponentMaxResource ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{state.opponentResourceName ?? state.resourceName}</p>
|
||||||
|
<strong>{Math.floor(state.opponentResource)} / {state.opponentMaxResource}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="bar mana-bar">
|
||||||
|
<span style={barFillStyle((state.opponentResource / state.opponentMaxResource) * 100)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -513,7 +528,7 @@ export function DualScreenBottomDisplay() {
|
|||||||
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
||||||
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||||
<div className="bar mana-bar">
|
<div className="bar mana-bar">
|
||||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -626,8 +641,34 @@ export function DualScreenTopCombat({
|
|||||||
[state.floatingTexts],
|
[state.floatingTexts],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const spellButtons = state.spells.map((spell, slotIndex) => {
|
||||||
|
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
|
||||||
|
const percent = spell.remaining > 0
|
||||||
|
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
|
||||||
|
: 0
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="dual-top-spell"
|
||||||
|
disabled={
|
||||||
|
!state.playerIsAlive
|
||||||
|
|| state.resource < spell.cost
|
||||||
|
|| spell.remaining > 0
|
||||||
|
|| state.status !== 'playing'
|
||||||
|
|| state.paused
|
||||||
|
}
|
||||||
|
key={spell.id}
|
||||||
|
onClick={() => onCastSpell?.(spell)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
||||||
|
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
|
||||||
|
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dual-top-main">
|
<div className={`dual-top-main ${state.opponentParty ? 'pvp-roguelike-dual-top' : ''}`}>
|
||||||
<section className="dual-top-enemy">
|
<section className="dual-top-enemy">
|
||||||
<div className="enemy-portrait" aria-hidden="true">
|
<div className="enemy-portrait" aria-hidden="true">
|
||||||
{state.encounterIsBoss ? 'B' : 'M'}
|
{state.encounterIsBoss ? 'B' : 'M'}
|
||||||
@@ -638,10 +679,25 @@ export function DualScreenTopCombat({
|
|||||||
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
|
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bar enemy-health">
|
<div className="bar enemy-health">
|
||||||
<span style={{ width: `${enemyPercent}%` }} />
|
<span style={barFillStyle(enemyPercent)} />
|
||||||
</div>
|
</div>
|
||||||
<p>{state.encounterDescription}</p>
|
{state.opponentParty && (
|
||||||
|
<div className="dual-top-resource">
|
||||||
|
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||||
|
<div className="bar mana-bar">
|
||||||
|
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!state.opponentParty && <p>{state.encounterDescription}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
{state.opponentParty && (
|
||||||
|
<div className="dual-top-side-status">
|
||||||
|
<div className="dual-top-cooldowns" aria-label="Cooldowns">
|
||||||
|
{spellButtons}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="dual-top-party">
|
<section className="dual-top-party">
|
||||||
@@ -669,39 +725,17 @@ export function DualScreenTopCombat({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="dual-top-spell-strip">
|
{!state.opponentParty && (
|
||||||
{state.spells.map((spell, slotIndex) => {
|
<section className="dual-top-spell-strip">
|
||||||
if (!spell) return <div className="dual-top-spell empty" key={`empty-${slotIndex}`} />
|
{spellButtons}
|
||||||
const percent = spell.remaining > 0
|
<div className="dual-top-resource">
|
||||||
? Math.min(100, (spell.remaining / Math.max(1, spell.cooldown)) * 100)
|
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
||||||
: 0
|
<div className="bar mana-bar">
|
||||||
return (
|
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
|
||||||
<button
|
</div>
|
||||||
className="dual-top-spell"
|
|
||||||
disabled={
|
|
||||||
!state.playerIsAlive
|
|
||||||
|| state.resource < spell.cost
|
|
||||||
|| spell.remaining > 0
|
|
||||||
|| state.status !== 'playing'
|
|
||||||
|| state.paused
|
|
||||||
}
|
|
||||||
key={spell.id}
|
|
||||||
onClick={() => onCastSpell?.(spell)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
|
|
||||||
{spell.remaining > 0 && <i style={{ height: `${percent}%` }} />}
|
|
||||||
{spell.remaining > 0 && <small>{spell.remaining.toFixed(0)}</small>}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<div className="dual-top-resource">
|
|
||||||
<strong>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</strong>
|
|
||||||
<div className="bar mana-bar">
|
|
||||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { cooldownNow, hasActiveCooldowns } from '../combat/spellCasting'
|
||||||
|
|
||||||
|
export function useCooldownClock(
|
||||||
|
cooldowns: Record<string, number>,
|
||||||
|
active: boolean,
|
||||||
|
intervalMs = 100,
|
||||||
|
) {
|
||||||
|
const [now, setNow] = useState(cooldownNow)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !hasActiveCooldowns(cooldowns)) return undefined
|
||||||
|
|
||||||
|
let timer = 0
|
||||||
|
let startTimer = 0
|
||||||
|
const tick = () => {
|
||||||
|
const nextNow = cooldownNow()
|
||||||
|
setNow(nextNow)
|
||||||
|
if (timer && !hasActiveCooldowns(cooldowns, nextNow)) {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
timer = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimer = window.setTimeout(() => {
|
||||||
|
tick()
|
||||||
|
timer = window.setInterval(tick, intervalMs)
|
||||||
|
}, 0)
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(startTimer)
|
||||||
|
if (timer) window.clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [active, cooldowns, intervalMs])
|
||||||
|
|
||||||
|
return now
|
||||||
|
}
|
||||||
@@ -15,41 +15,63 @@ type FloatingCombatTextOptions = {
|
|||||||
durationMs?: number
|
durationMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const FLOATING_TEXT_CLEANUP_INTERVAL_MS = 100
|
|
||||||
|
|
||||||
export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
|
export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
|
||||||
durationMs = 900,
|
durationMs = 900,
|
||||||
}: FloatingCombatTextOptions = {}) {
|
}: FloatingCombatTextOptions = {}) {
|
||||||
const [floatingTexts, setFloatingTexts] = useState<TText[]>([])
|
const [floatingTexts, setFloatingTexts] = useState<TText[]>([])
|
||||||
const nextId = useRef(1)
|
const nextId = useRef(1)
|
||||||
const expirationsRef = useRef(new Map<number, number>())
|
const expirationsRef = useRef(new Map<number, number>())
|
||||||
|
const cleanupTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const clearCleanupTimer = useCallback(() => {
|
||||||
|
if (cleanupTimerRef.current === null) return
|
||||||
|
window.clearTimeout(cleanupTimerRef.current)
|
||||||
|
cleanupTimerRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scheduleCleanup = useCallback(() => {
|
||||||
|
function scheduleNext() {
|
||||||
|
clearCleanupTimer()
|
||||||
|
let nextExpiry = Infinity
|
||||||
|
for (const expiresAt of expirationsRef.current.values()) {
|
||||||
|
nextExpiry = Math.min(nextExpiry, expiresAt)
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(nextExpiry)) return
|
||||||
|
|
||||||
|
cleanupTimerRef.current = window.setTimeout(() => {
|
||||||
|
cleanupTimerRef.current = null
|
||||||
|
const now = performance.now()
|
||||||
|
let removed = false
|
||||||
|
for (const [id, expiresAt] of expirationsRef.current) {
|
||||||
|
if (expiresAt > now) continue
|
||||||
|
expirationsRef.current.delete(id)
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
if (removed) {
|
||||||
|
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
|
||||||
|
}
|
||||||
|
scheduleNext()
|
||||||
|
}, Math.max(0, nextExpiry - performance.now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNext()
|
||||||
|
}, [clearCleanupTimer])
|
||||||
|
|
||||||
const clearFloatingTexts = useCallback(() => {
|
const clearFloatingTexts = useCallback(() => {
|
||||||
|
clearCleanupTimer()
|
||||||
expirationsRef.current.clear()
|
expirationsRef.current.clear()
|
||||||
setFloatingTexts([])
|
setFloatingTexts([])
|
||||||
}, [])
|
}, [clearCleanupTimer])
|
||||||
|
|
||||||
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
|
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
|
||||||
if (entry.value <= 0) return
|
if (entry.value <= 0) return
|
||||||
const id = nextId.current++
|
const id = nextId.current++
|
||||||
expirationsRef.current.set(id, performance.now() + durationMs)
|
expirationsRef.current.set(id, performance.now() + durationMs)
|
||||||
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
|
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
|
||||||
}, [durationMs])
|
scheduleCleanup()
|
||||||
|
}, [durationMs, scheduleCleanup])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer])
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
const now = performance.now()
|
|
||||||
let removed = false
|
|
||||||
for (const [id, expiresAt] of expirationsRef.current) {
|
|
||||||
if (expiresAt > now) continue
|
|
||||||
expirationsRef.current.delete(id)
|
|
||||||
removed = true
|
|
||||||
}
|
|
||||||
if (!removed) return
|
|
||||||
setFloatingTexts((current) => current.filter((text) => expirationsRef.current.has(text.id)))
|
|
||||||
}, FLOATING_TEXT_CLEANUP_INTERVAL_MS)
|
|
||||||
return () => window.clearInterval(timer)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const floatingTextsByMember = useMemo(
|
const floatingTextsByMember = useMemo(
|
||||||
() => groupFloatingTextsByMember(floatingTexts),
|
() => groupFloatingTextsByMember(floatingTexts),
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { Spell } from '../game'
|
||||||
|
import type { SpellSlot } from '../components/SpellBars'
|
||||||
|
import { cooldownRemaining } from '../combat/spellCasting'
|
||||||
|
import { useCooldownClock } from './useCooldownClock'
|
||||||
|
|
||||||
|
type UseSpellSlotsOptions = {
|
||||||
|
spells: readonly Spell[]
|
||||||
|
cooldowns: Record<string, number>
|
||||||
|
active: boolean
|
||||||
|
cost: (spell: Spell) => number
|
||||||
|
abilitySlots?: readonly (number | null)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpellSlots({
|
||||||
|
spells,
|
||||||
|
cooldowns,
|
||||||
|
active,
|
||||||
|
cost,
|
||||||
|
abilitySlots,
|
||||||
|
}: UseSpellSlotsOptions) {
|
||||||
|
const now = useCooldownClock(cooldowns, active)
|
||||||
|
|
||||||
|
return useMemo<SpellSlot[]>(() => {
|
||||||
|
const slotCount = abilitySlots?.length ?? spells.length
|
||||||
|
return Array.from({ length: slotCount }, (_, slotIndex) => {
|
||||||
|
if (abilitySlots && !abilitySlots[slotIndex]) return null
|
||||||
|
const spell = spells[slotIndex]
|
||||||
|
if (!spell) return null
|
||||||
|
return {
|
||||||
|
...spell,
|
||||||
|
cost: cost(spell),
|
||||||
|
slotIndex,
|
||||||
|
remaining: cooldownRemaining(cooldowns, spell.id, now),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [abilitySlots, cooldowns, cost, now, spells])
|
||||||
|
}
|
||||||
+3
-1
@@ -3,7 +3,9 @@
|
|||||||
linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)),
|
linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)),
|
||||||
repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px);
|
repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px);
|
||||||
color: #f4eed8;
|
color: #f4eed8;
|
||||||
font-family: 'VT323', Consolas, monospace;
|
--body-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace;
|
||||||
|
--pixel-font: ui-monospace, 'Roboto Mono', 'Droid Sans Mono', Consolas, monospace;
|
||||||
|
font-family: var(--body-font);
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-9
@@ -37,6 +37,7 @@ export const INPUT_ACTIONS = [
|
|||||||
'targetParty6',
|
'targetParty6',
|
||||||
'toggleTargetGroup',
|
'toggleTargetGroup',
|
||||||
'toggleSpeed',
|
'toggleSpeed',
|
||||||
|
'toggleTouchLock',
|
||||||
'pause',
|
'pause',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
|
|||||||
targetParty6: 'Target Party Member 6',
|
targetParty6: 'Target Party Member 6',
|
||||||
toggleTargetGroup: 'Switch Raid Target Group',
|
toggleTargetGroup: 'Switch Raid Target Group',
|
||||||
toggleSpeed: 'Toggle 2x Speed',
|
toggleSpeed: 'Toggle 2x Speed',
|
||||||
|
toggleTouchLock: 'Toggle Combat Touch Lock',
|
||||||
pause: 'Pause Menu',
|
pause: 'Pause Menu',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +95,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
|||||||
targetParty6: 'F6',
|
targetParty6: 'F6',
|
||||||
toggleTargetGroup: 'Tab',
|
toggleTargetGroup: 'Tab',
|
||||||
toggleSpeed: 'Backquote',
|
toggleSpeed: 'Backquote',
|
||||||
|
toggleTouchLock: 'F7',
|
||||||
pause: 'Escape',
|
pause: 'Escape',
|
||||||
},
|
},
|
||||||
controller: {
|
controller: {
|
||||||
@@ -115,9 +118,10 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
|||||||
targetParty3: 'Button15',
|
targetParty3: 'Button15',
|
||||||
targetParty4: 'Button13',
|
targetParty4: 'Button13',
|
||||||
targetParty5: 'Button4',
|
targetParty5: 'Button4',
|
||||||
targetParty6: 'Button10',
|
targetParty6: 'Button6',
|
||||||
toggleTargetGroup: 'Button6',
|
toggleTargetGroup: 'Button8',
|
||||||
toggleSpeed: 'Button11',
|
toggleSpeed: 'Button11',
|
||||||
|
toggleTouchLock: 'Button10',
|
||||||
pause: 'Button9',
|
pause: 'Button9',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -144,11 +148,13 @@ type InputContextValue = {
|
|||||||
lastDevice: InputDevice
|
lastDevice: InputDevice
|
||||||
controllerIconStyle: ControllerIconStyle
|
controllerIconStyle: ControllerIconStyle
|
||||||
directPartyTargeting: boolean
|
directPartyTargeting: boolean
|
||||||
|
combatTouchLocked: boolean
|
||||||
beginCapture: (device: InputDevice, action: InputAction) => void
|
beginCapture: (device: InputDevice, action: InputAction) => void
|
||||||
cancelCapture: () => void
|
cancelCapture: () => void
|
||||||
resetBindings: (device: InputDevice) => void
|
resetBindings: (device: InputDevice) => void
|
||||||
setControllerIconStyle: (style: ControllerIconStyle) => void
|
setControllerIconStyle: (style: ControllerIconStyle) => void
|
||||||
setDirectPartyTargeting: (enabled: boolean) => void
|
setDirectPartyTargeting: (enabled: boolean) => void
|
||||||
|
setCombatTouchLocked: (locked: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputContext = createContext<InputContextValue | null>(null)
|
const InputContext = createContext<InputContextValue | null>(null)
|
||||||
@@ -187,6 +193,21 @@ function loadBindings(): Record<InputDevice, InputBindings> {
|
|||||||
if (savedController?.targetParty6 === 'Button11') {
|
if (savedController?.targetParty6 === 'Button11') {
|
||||||
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
|
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
savedController?.targetParty6 === undefined
|
||||||
|
|| savedController.targetParty6 === 'Button10'
|
||||||
|
) {
|
||||||
|
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
savedController?.toggleTargetGroup === undefined
|
||||||
|
|| savedController.toggleTargetGroup === 'Button6'
|
||||||
|
) {
|
||||||
|
controller.toggleTargetGroup = DEFAULT_BINDINGS.controller.toggleTargetGroup
|
||||||
|
}
|
||||||
|
if (savedController?.toggleTouchLock === undefined) {
|
||||||
|
controller.toggleTouchLock = DEFAULT_BINDINGS.controller.toggleTouchLock
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
||||||
controller,
|
controller,
|
||||||
@@ -201,15 +222,18 @@ function loadPreferences() {
|
|||||||
const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
|
const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
|
||||||
controllerIconStyle?: ControllerIconStyle
|
controllerIconStyle?: ControllerIconStyle
|
||||||
directPartyTargeting?: boolean
|
directPartyTargeting?: boolean
|
||||||
|
combatTouchLocked?: boolean
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
|
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
|
||||||
directPartyTargeting: saved.directPartyTargeting ?? false,
|
directPartyTargeting: saved.directPartyTargeting ?? false,
|
||||||
|
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
controllerIconStyle: 'xbox' as ControllerIconStyle,
|
controllerIconStyle: 'xbox' as ControllerIconStyle,
|
||||||
directPartyTargeting: false,
|
directPartyTargeting: false,
|
||||||
|
combatTouchLocked: Capacitor.isNativePlatform(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,6 +247,7 @@ function bindingGroup(action: InputAction) {
|
|||||||
if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
|
if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
|
||||||
if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
|
if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
|
||||||
if (action === 'pause') return 'pause'
|
if (action === 'pause') return 'pause'
|
||||||
|
if (action === 'toggleTouchLock' || action === 'toggleSpeed') return 'system'
|
||||||
return 'navigation'
|
return 'navigation'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +453,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const [preferences, setPreferences] = useState(loadPreferences)
|
const [preferences, setPreferences] = useState(loadPreferences)
|
||||||
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||||
const [keyboardShift, setKeyboardShift] = useState(false)
|
const [keyboardShift, setKeyboardShift] = useState(false)
|
||||||
|
const [touchLockMessage, setTouchLockMessage] = useState('')
|
||||||
const bindingsRef = useRef(bindings)
|
const bindingsRef = useRef(bindings)
|
||||||
const preferencesRef = useRef(preferences)
|
const preferencesRef = useRef(preferences)
|
||||||
const captureRef = useRef(capture)
|
const captureRef = useRef(capture)
|
||||||
@@ -435,6 +461,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const previousTokensRef = useRef(new Set<string>())
|
const previousTokensRef = useRef(new Set<string>())
|
||||||
const repeatRef = useRef<Record<string, number>>({})
|
const repeatRef = useRef<Record<string, number>>({})
|
||||||
const gamepadConnectedRef = useRef(Capacitor.isNativePlatform())
|
const gamepadConnectedRef = useRef(Capacitor.isNativePlatform())
|
||||||
|
const touchLockMessageTimerRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bindingsRef.current = bindings
|
bindingsRef.current = bindings
|
||||||
@@ -485,7 +512,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
setLastDevice(device)
|
setLastDevice(device)
|
||||||
document.documentElement.dataset.inputDevice = device
|
document.documentElement.dataset.inputDevice = device
|
||||||
|
|
||||||
if (action.startsWith('navigate')) {
|
if (action === 'toggleTouchLock') {
|
||||||
|
setPreferences((current) => ({
|
||||||
|
...current,
|
||||||
|
combatTouchLocked: !current.combatTouchLocked,
|
||||||
|
}))
|
||||||
|
window.clearTimeout(touchLockMessageTimerRef.current)
|
||||||
|
const nextLocked = !preferencesRef.current.combatTouchLocked
|
||||||
|
setTouchLockMessage(`Combat touch ${nextLocked ? 'locked' : 'unlocked'}`)
|
||||||
|
touchLockMessageTimerRef.current = window.setTimeout(() => {
|
||||||
|
setTouchLockMessage('')
|
||||||
|
}, 1400)
|
||||||
|
} else if (action.startsWith('navigate')) {
|
||||||
if (uiOverlay || !combatActive) moveFocus(action)
|
if (uiOverlay || !combatActive) moveFocus(action)
|
||||||
} else if (action === 'confirm') {
|
} else if (action === 'confirm') {
|
||||||
const active = currentFocusableControl()
|
const active = currentFocusableControl()
|
||||||
@@ -551,8 +589,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
'targetParty6',
|
'targetParty6',
|
||||||
'toggleTargetGroup',
|
'toggleTargetGroup',
|
||||||
'toggleSpeed',
|
'toggleSpeed',
|
||||||
|
'toggleTouchLock',
|
||||||
] satisfies InputAction[]
|
] satisfies InputAction[]
|
||||||
const combatPriority = [
|
const combatPriority = [
|
||||||
|
'toggleTouchLock',
|
||||||
'pause',
|
'pause',
|
||||||
'toggleSpeed',
|
'toggleSpeed',
|
||||||
'ability1',
|
'ability1',
|
||||||
@@ -621,7 +661,9 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
rememberFocusableControl(target)
|
rememberFocusableControl(target)
|
||||||
}
|
}
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
document.documentElement.dataset.inputDevice = 'pc'
|
if (event.pointerType !== 'touch') {
|
||||||
|
document.documentElement.dataset.inputDevice = 'pc'
|
||||||
|
}
|
||||||
const target = event.target
|
const target = event.target
|
||||||
if (!(target instanceof Element)) return
|
if (!(target instanceof Element)) return
|
||||||
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
|
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||||
@@ -645,8 +687,14 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
|
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
|
||||||
}, [dispatchControllerToken])
|
}, [dispatchControllerToken])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
window.clearTimeout(touchLockMessageTimerRef.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let focusFrame = 0
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
|
focusFrame = 0
|
||||||
const combatActive = document.querySelector('[data-combat-active="true"]')
|
const combatActive = document.querySelector('[data-combat-active="true"]')
|
||||||
if (combatActive) return
|
if (combatActive) return
|
||||||
const candidates = focusableElements()
|
const candidates = focusableElements()
|
||||||
@@ -663,22 +711,37 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const scheduleEnsureFocus = () => {
|
||||||
|
if (focusFrame) return
|
||||||
|
focusFrame = window.requestAnimationFrame(ensureFocus)
|
||||||
|
}
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
window.requestAnimationFrame(ensureFocus)
|
scheduleEnsureFocus()
|
||||||
})
|
})
|
||||||
observer.observe(document.getElementById('root') ?? document.body, {
|
observer.observe(document.getElementById('root') ?? document.body, {
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'],
|
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden'],
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: true,
|
subtree: true,
|
||||||
})
|
})
|
||||||
window.requestAnimationFrame(ensureFocus)
|
scheduleEnsureFocus()
|
||||||
return () => observer.disconnect()
|
return () => {
|
||||||
|
if (focusFrame) window.cancelAnimationFrame(focusFrame)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer = 0
|
let timer = 0
|
||||||
|
if (Capacitor.isNativePlatform() && capture?.device !== 'controller') {
|
||||||
|
previousTokensRef.current = new Set()
|
||||||
|
repeatRef.current = {}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
const nextDelay = () => {
|
const nextDelay = () => {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
return GAMEPAD_COMBAT_POLL_MS
|
||||||
|
}
|
||||||
if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) {
|
if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) {
|
||||||
return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS
|
return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS
|
||||||
}
|
}
|
||||||
@@ -735,7 +798,39 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
window.removeEventListener('gamepadconnected', onGamepadConnected)
|
window.removeEventListener('gamepadconnected', onGamepadConnected)
|
||||||
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected)
|
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected)
|
||||||
}
|
}
|
||||||
}, [assignBinding, dispatchControllerToken])
|
}, [assignBinding, capture, dispatchControllerToken])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldBlockTouch = () => (
|
||||||
|
Capacitor.isNativePlatform()
|
||||||
|
&& preferencesRef.current.combatTouchLocked
|
||||||
|
&& isCombatActive()
|
||||||
|
&& !hasUiOverlay()
|
||||||
|
)
|
||||||
|
const blockTouch = (event: Event) => {
|
||||||
|
if (
|
||||||
|
typeof PointerEvent !== 'undefined'
|
||||||
|
&& event instanceof PointerEvent
|
||||||
|
&& event.pointerType !== 'touch'
|
||||||
|
) return
|
||||||
|
if (!shouldBlockTouch()) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
}
|
||||||
|
const options = { capture: true, passive: false }
|
||||||
|
document.addEventListener('pointerdown', blockTouch, options)
|
||||||
|
document.addEventListener('pointerup', blockTouch, options)
|
||||||
|
document.addEventListener('touchstart', blockTouch, options)
|
||||||
|
document.addEventListener('touchmove', blockTouch, options)
|
||||||
|
document.addEventListener('touchend', blockTouch, options)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', blockTouch, options)
|
||||||
|
document.removeEventListener('pointerup', blockTouch, options)
|
||||||
|
document.removeEventListener('touchstart', blockTouch, options)
|
||||||
|
document.removeEventListener('touchmove', blockTouch, options)
|
||||||
|
document.removeEventListener('touchend', blockTouch, options)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const contextValue = useMemo<InputContextValue>(() => ({
|
const contextValue = useMemo<InputContextValue>(() => ({
|
||||||
bindings,
|
bindings,
|
||||||
@@ -743,6 +838,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
lastDevice,
|
lastDevice,
|
||||||
controllerIconStyle: preferences.controllerIconStyle,
|
controllerIconStyle: preferences.controllerIconStyle,
|
||||||
directPartyTargeting: preferences.directPartyTargeting,
|
directPartyTargeting: preferences.directPartyTargeting,
|
||||||
|
combatTouchLocked: preferences.combatTouchLocked,
|
||||||
beginCapture: (device, action) => setCapture({ device, action }),
|
beginCapture: (device, action) => setCapture({ device, action }),
|
||||||
cancelCapture: () => setCapture(null),
|
cancelCapture: () => setCapture(null),
|
||||||
resetBindings: (device) => setBindings((current) => ({
|
resetBindings: (device) => setBindings((current) => ({
|
||||||
@@ -757,6 +853,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
...current,
|
...current,
|
||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
})),
|
})),
|
||||||
|
setCombatTouchLocked: (combatTouchLocked) => setPreferences((current) => ({
|
||||||
|
...current,
|
||||||
|
combatTouchLocked,
|
||||||
|
})),
|
||||||
}), [bindings, capture, lastDevice, preferences])
|
}), [bindings, capture, lastDevice, preferences])
|
||||||
|
|
||||||
function typeKeyboardKey(key: string) {
|
function typeKeyboardKey(key: string) {
|
||||||
@@ -821,6 +921,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{touchLockMessage && (
|
||||||
|
<div className="combat-touch-lock-status" aria-live="polite">
|
||||||
|
{touchLockMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</InputContext.Provider>
|
</InputContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user