Compare commits

...
4 Commits
Author SHA1 Message Date
Warren H df6306fe7a Android build v1.1.6 2026-06-30 13:59:36 -04:00
Warren H f6d408f594 Android build v1.1.5 2026-06-30 13:30:33 -04:00
Warren H 1797154726 Android build v1.1.4 2026-06-29 23:06:37 -04:00
Warren H bbcc67a54a Android build v1.1.3 2026-06-29 22:55:53 -04:00
30 changed files with 1662 additions and 309 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 81
versionName "1.1.2"
versionCode 85
versionName "1.1.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// 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;
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.SystemClock;
import android.view.KeyEvent;
@@ -12,15 +16,22 @@ import java.io.File;
public abstract class ControllerBridgeActivity extends BridgeActivity {
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
private static final long DPAD_THROTTLE_MS = 220;
private long lastDpadDispatchAt = 0;
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 = 55;
private final long[] lastDpadDispatchAt = new long[16];
@Override
public void onCreate(Bundle savedInstanceState) {
clearWebViewServiceWorkers();
boolean shouldClearWebViewCache = shouldClearWebViewCacheForCurrentVersion();
if (shouldClearWebViewCache) clearWebViewServiceWorkers();
super.onCreate(savedInstanceState);
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), getWindowManager().getDefaultDisplay());
if (bridge != null) {
bridge.getWebView().clearCache(true);
if (shouldClearWebViewCache) {
bridge.getWebView().clearCache(true);
markWebViewCacheClearedForCurrentVersion();
}
}
loadIntentUrl();
}
@@ -35,7 +46,13 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enableImmersiveMode();
if (hasFocus) {
enableImmersiveMode();
AndroidDisplayPerformance.preferBatteryRefreshRate(
getWindow(),
getWindowManager().getDefaultDisplay()
);
}
}
@Override
@@ -74,6 +91,34 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
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) {
if (!file.exists()) return;
if (file.isDirectory()) {
@@ -94,7 +139,7 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad()) return true;
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
String script =
"window.dispatchEvent(new CustomEvent('ashen-halls-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
@@ -108,10 +153,11 @@ public abstract class ControllerBridgeActivity extends BridgeActivity {
return true;
}
private boolean shouldThrottleDpad() {
private boolean shouldThrottleDpad(String token) {
int buttonIndex = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt = now;
if (now - lastDpadDispatchAt[buttonIndex] < DPAD_THROTTLE_MS) return true;
lastDpadDispatchAt[buttonIndex] = now;
return false;
}
@@ -259,9 +259,11 @@ public class DualScreenPlugin extends Plugin {
private final class TopDisplayPresentation extends Presentation {
private final String initialUrl;
private final Display display;
TopDisplayPresentation(Context context, Display display, String initialUrl) {
super(context, display);
this.display = display;
this.initialUrl = initialUrl;
}
@@ -277,6 +279,7 @@ public class DualScreenPlugin extends Plugin {
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
);
AndroidDisplayPerformance.preferBatteryRefreshRate(getWindow(), display);
FrameLayout container = new FrameLayout(getContext());
container.setBackgroundColor(Color.BLACK);
+2
View File
@@ -12,6 +12,8 @@
"android:open": "cap open android",
"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",
"release:gui": "python3 scripts/release_game.py",
"release:cli": "python3 scripts/release_game_cli.py",
"accounts:ip": "node scripts/manage-ip-allowance.mjs",
"db:backup": "node scripts/backup-db.mjs",
"db:init": "node scripts/init-db.mjs",
Binary file not shown.
+502
View File
@@ -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())
+166
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -20,6 +20,7 @@ import {
type GameMode,
} from './gameRepository'
import { focusFirstControl } from './input.tsx'
import { barFillStyle } from './components/barStyles'
const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen })))
const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen })))
@@ -469,7 +470,7 @@ function App() {
<small>Level {profile.character.level}</small>
<small>Item Level {profile.gearStats.averageItemLevel.toFixed(1)}</small>
<div className="header-xp" title={`${profile.character.experience} total experience`}>
<span style={{ width: `${experiencePercent}%` }} />
<span style={barFillStyle(experiencePercent)} />
</div>
<button className="logout-button" onClick={signOut} type="button">
{gameMode === 'offline' ? 'Leave Offline' : `Sign Out ${account.username}`}
+4 -1
View File
@@ -3,9 +3,12 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import './App.css'
import { AdminScreen } from './components/AdminScreen'
import { InputProvider } from './input'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AdminScreen onBack={() => window.close()} />
<InputProvider>
<AdminScreen onBack={() => window.close()} />
</InputProvider>
</StrictMode>,
)
+60 -2
View File
@@ -1,21 +1,79 @@
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(
spell: Spell,
resource: number,
cooldowns: Record<string, 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(
cooldowns: Record<string, number>,
spell: Spell,
cooldownMultiplier = 1,
now = cooldownNow(),
) {
const cooldownMs = spell.cooldown * cooldownMultiplier * 1000
if (cooldownMs <= 0) {
const nextCooldowns = { ...cooldowns }
delete nextCooldowns[spell.id]
return nextCooldowns
}
return {
...cooldowns,
[spell.id]: spell.cooldown * cooldownMultiplier,
[spell.id]: now + cooldownMs,
}
}
+28 -22
View File
@@ -46,7 +46,7 @@ import {
spellPowerMultiplier,
spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
import { applyCastStateUpdate } from '../combat/combatStateTransitions'
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
@@ -58,7 +58,11 @@ import {
appendCombatLog,
type BasicFloatingCombatText,
} from '../combat/combatPresentation'
import { canCastSpell } from '../combat/spellCasting'
import {
canCastSpell,
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import {
applySpellEffectProfile,
buildSpellTargetPlan,
@@ -69,9 +73,11 @@ import {
useInput,
} from '../input'
import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { usePartyTargeting } from '../hooks/usePartyTargeting'
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 { ResultScreen } from './ResultScreen'
import {
@@ -668,10 +674,10 @@ export function CombatScreen({
})
floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
resourceSpentRef.current += effectiveCost
const nextCooldowns = { ...current.cooldowns }
let nextCooldowns = current.cooldowns
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
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({
current,
@@ -855,7 +861,7 @@ export function CombatScreen({
const runCombatTick = useCallback(() => {
const current = combatRef.current
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)
const living = current.party.filter((member) => member.health > 0)
@@ -1161,17 +1167,17 @@ export function CombatScreen({
}).reverse(),
[enemyCount, enemyHealth, encounter.maxHealth],
)
const spellSlots = useMemo<SpellSlot[]>(() => profile.abilitySlots.map((abilityId, slotIndex) => {
const spell = spellByKey.get(String(slotIndex + 1))
return abilityId && spell
? {
...spell,
cost: spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
slotIndex,
remaining: cooldowns[spell.id] ?? 0,
}
: null
}), [cooldowns, freeCastReady, profile.abilitySlots, roguelikeUpgradeCounts, spellByKey])
const spellSlotCost = useCallback(
(spell: Spell) => spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
[freeCastReady, roguelikeUpgradeCounts],
)
const spellSlots = useSpellSlots({
spells,
abilitySlots: profile.abilitySlots,
cooldowns,
active: status === 'playing' && !paused,
cost: spellSlotCost,
})
const dualScreenState = useMemo(() => buildCombatDualScreenState({
difficultyName: difficulty.name,
dungeonName: dungeon.name,
@@ -1230,9 +1236,9 @@ export function CombatScreen({
useDualScreenPublisher(dualScreenState, dualScreenEnabled)
return (
<main
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
data-combat-active={paused ? 'false' : 'true'}
<main
className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
>
{!dualScreenEnabled && <header className="topbar">
<div>
@@ -1266,13 +1272,13 @@ export function CombatScreen({
<div className="hard-enemy-bars">
{enemyHealthSegments.map((segment) => (
<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>
</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>
</div>
+3 -2
View File
@@ -7,6 +7,7 @@ import {
memberHotEffects,
} from '../combat/rules'
import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type FloatingCombatText = {
id: number
@@ -131,8 +132,8 @@ export const PartyMemberFrame = memo(function PartyMemberFrame({
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
</div>
<div className="bar member-health">
<span style={{ width: `${(member.health / maxHealth) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / maxHealth) * 100}%` }} />}
<span style={barFillStyle((member.health / maxHealth) * 100)} />
{member.shield > 0 && <i style={barFillStyle((member.shield / maxHealth) * 100, 'right')} />}
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
</div>
<div className="floating-combat-texts" aria-hidden="true">
+30 -15
View File
@@ -13,9 +13,11 @@ import { completeRoguelike, type DungeonReward } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
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 { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting'
import {
@@ -37,10 +39,14 @@ import {
spellExtraTargets,
spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine'
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
pruneExpiredCooldowns,
reduceCooldown,
} from '../combat/spellCasting'
import {
nextPvpRoguelikeStage,
resolvePvpRoguelikeCombatOutcome,
@@ -398,12 +404,16 @@ export function PvPRoguelikeScreen({
const cpuAlive = cpuSide.party.some((member) => member.health > 0)
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
...spell,
cost: spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
slotIndex,
remaining: playerSide.cooldowns[spell.id] ?? 0,
})), [playerBuffCounts, playerDebuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
const playerSpellSlotCost = useCallback(
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
[playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady],
)
const playerSpellSlots = useSpellSlots({
spells: starterSpells,
cooldowns: playerSide.cooldowns,
active: status === 'playing' && !paused,
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo(
() => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
[cpuSide.buffs, selfBuffChoicesCatalog],
@@ -880,9 +890,9 @@ export function PvPRoguelikeScreen({
damageReductionTargets: new Set<string>(),
groupTargets,
}
const nextCooldowns = { ...current.cooldowns }
let nextCooldowns = current.cooldowns
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({
current,
@@ -994,7 +1004,7 @@ export function PvPRoguelikeScreen({
...side,
party: nextParty,
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)),
}
}, [activeSpellEffects, addFloatingHeal, maxResource])
@@ -1339,6 +1349,9 @@ export function PvPRoguelikeScreen({
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentParty: cpuSide.party,
opponentEnemyHealth: cpuSide.enemyHealth,
opponentResource: cpuSide.resource,
opponentMaxResource: maxResource,
opponentResourceName: gameClass.resourceName,
opponentBuffSummary,
opponentDebuffSummary,
floatingTexts: dualScreenFloatingTexts,
@@ -1362,6 +1375,7 @@ export function PvPRoguelikeScreen({
cpuDifficulty,
cpuSide.enemyHealth,
cpuSide.party,
cpuSide.resource,
directPartyTargeting,
encounter.description,
encounter.enemyName,
@@ -1424,13 +1438,13 @@ export function PvPRoguelikeScreen({
<div className="pvp-clear-wrap">
<span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
<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 className="pvp-resource-wrap">
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
{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>
@@ -1464,12 +1478,12 @@ export function PvPRoguelikeScreen({
<div className="pvp-clear-wrap">
<span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
<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 className="pvp-resource-wrap">
<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>
@@ -1495,6 +1509,7 @@ export function PvPRoguelikeScreen({
bindings={activeBindings}
canCast={status === 'playing' && !playerDone && playerAlive}
className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle}
onCast={castPlayerSpell}
resource={playerSide.resource}
+30 -16
View File
@@ -11,9 +11,11 @@ import { completeRoguelike } from '../profile'
import type { CharacterProfile } from '../profile'
import type { GameMode } from '../gameRepository'
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 { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { useDualScreen, useDualScreenPublisher } from '../dualScreen'
@@ -31,10 +33,13 @@ import {
spellPowerMultiplier,
spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers'
import { advanceCooldowns, regenerateResource, tickSeconds } from '../combat/combatTick'
import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine'
import { buildStadiumDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
pruneExpiredCooldowns,
} from '../combat/spellCasting'
import {
appendCombatLog,
STADIUM_COMBAT_LOG_LIMIT,
@@ -400,12 +405,16 @@ export function PvpStadiumScreen({
const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
const playerAlive = playerSide.party.some((member) => member.health > 0)
const playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
const playerSpellSlots = useMemo<SpellSlot[]>(() => starterSpells.map((spell, slotIndex) => ({
...spell,
cost: spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
slotIndex,
remaining: playerSide.cooldowns[spell.id] ?? 0,
})), [playerBuffCounts, playerSide.cooldowns, playerSide.freeCastReady, starterSpells])
const playerSpellSlotCost = useCallback(
(spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady),
[playerBuffCounts, playerSide.freeCastReady],
)
const playerSpellSlots = useSpellSlots({
spells: starterSpells,
cooldowns: playerSide.cooldowns,
active: status === 'playing' && !paused,
cost: playerSpellSlotCost,
})
const opponentBuffSummary = useMemo(
() => summarizeStacks(cpuSide.buffs, buffCatalog),
[buffCatalog, cpuSide.buffs],
@@ -790,7 +799,7 @@ export function PvpStadiumScreen({
...side,
party: nextParty,
resource: regenerateResource(side.resource, RESOURCE_REGEN_PER_TICK, MAX_RESOURCE),
cooldowns: advanceCooldowns(side.cooldowns, tickSeconds(TICK_MS)),
cooldowns: pruneExpiredCooldowns(side.cooldowns),
survivalSeconds: nextSurvival,
dampeningPercent,
}
@@ -1055,6 +1064,9 @@ export function PvpStadiumScreen({
opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
opponentParty: cpuSide.party,
opponentEnemyHealth: 0,
opponentResource: cpuSide.resource,
opponentMaxResource: MAX_RESOURCE,
opponentResourceName: gameClass.resourceName,
opponentBuffSummary,
opponentDebuffSummary,
floatingTexts: dualScreenFloatingTexts,
@@ -1083,6 +1095,7 @@ export function PvpStadiumScreen({
controllerIconStyle,
cpuDifficulty,
cpuSide.party,
cpuSide.resource,
cpuSide.survivalSeconds,
directPartyTargeting,
dualScreenFloatingTexts,
@@ -1164,16 +1177,16 @@ export function PvpStadiumScreen({
</section>
<section className="dual-top-spell-strip">
{starterSpells.map((spell, slotIndex) => {
const remaining = playerSide.cooldowns[spell.id] ?? 0
const cost = spellResourceCost(spell, playerBuffCounts, playerSide.freeCastReady)
{playerSpellSlots.map((spell, slotIndex) => {
if (!spell) return null
const remaining = spell.remaining
const percent = remaining > 0
? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
: 0
return (
<button
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}
onClick={() => castPlayerSpell(spell)}
type="button"
@@ -1188,7 +1201,7 @@ export function PvpStadiumScreen({
<div className="dual-top-resource">
<strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
<div className="bar mana-bar">
<span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} />
<span style={barFillStyle((playerSide.resource / MAX_RESOURCE) * 100)} />
</div>
</div>
</section>
@@ -1222,7 +1235,7 @@ export function PvpStadiumScreen({
</div>
<div className="pvp-resource-wrap">
<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 className="party-grid pvp-party-grid">
@@ -1252,7 +1265,7 @@ export function PvpStadiumScreen({
</div>
<div className="pvp-resource-wrap">
<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 className="party-grid pvp-party-grid">
@@ -1276,6 +1289,7 @@ export function PvpStadiumScreen({
bindings={activeBindings}
canCast={status === 'playing' && playerAlive}
className="pvp-bottom-spell-bar"
focusable={false}
iconStyle={controllerIconStyle}
onCast={castPlayerSpell}
resource={playerSide.resource}
+10
View File
@@ -32,11 +32,13 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
capture,
controllerIconStyle,
directPartyTargeting,
combatTouchLocked,
beginCapture,
cancelCapture,
resetBindings,
setControllerIconStyle,
setDirectPartyTargeting,
setCombatTouchLocked,
} = useInput()
const {
enabled: dualScreenEnabled,
@@ -180,6 +182,14 @@ export function SettingsScreen({ onBack }: { onBack: () => void }) {
>
{directPartyTargeting ? 'Direct Targeting On' : 'Direct Targeting Off'}
</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">
<span>Controller Icons</span>
{(['xbox', 'playstation', 'nintendo'] as const).map((style) => (
+9 -1
View File
@@ -2,6 +2,7 @@ import { memo } from 'react'
import type { ControllerIconStyle } from '../input'
import type { Spell } from '../game'
import { ControllerBindingLabel } from './ControllerIcons'
import { barFillStyle } from './barStyles'
export type SpellSlot = (Spell & {
cost: number
@@ -28,7 +29,7 @@ export const ResourceBar = memo(function ResourceBar({
{unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`}
</span>
{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>
)
})
@@ -39,6 +40,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle,
resourceName,
disabled,
focusable = true,
onCast,
emptyKeyPrefix = 'empty',
}: {
@@ -47,6 +49,7 @@ export const SpellButton = memo(function SpellButton({
iconStyle: ControllerIconStyle
resourceName: string
disabled?: boolean
focusable?: boolean
onCast: (spell: Spell) => void
emptyKeyPrefix?: string
}) {
@@ -60,9 +63,11 @@ export const SpellButton = memo(function SpellButton({
return (
<button
className="spell"
data-controller-nav={focusable ? undefined : 'skip'}
disabled={disabled}
key={spell.id}
onClick={() => onCast(spell)}
tabIndex={focusable ? undefined : -1}
title={spell.description}
type="button"
>
@@ -94,6 +99,7 @@ export const SpellBar = memo(function SpellBar({
canCast,
onCast,
className = 'spell-bar six-slots vertical-spell-bar',
focusable = true,
}: {
spells: SpellSlot[]
bindings: Record<string, string>
@@ -103,6 +109,7 @@ export const SpellBar = memo(function SpellBar({
canCast: boolean
onCast: (spell: Spell) => void
className?: string
focusable?: boolean
}) {
return (
<div className={className}>
@@ -118,6 +125,7 @@ export const SpellBar = memo(function SpellBar({
<SpellButton
binding={bindings[`ability${slotIndex + 1}`]}
disabled={!canCast || resource < spell.cost || spell.remaining > 0}
focusable={focusable}
iconStyle={iconStyle}
key={spell.id}
onCast={onCast}
+16
View File
@@ -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`,
}
}
+83 -38
View File
@@ -19,9 +19,11 @@ import {
dispatchExternalGameAction,
type ControllerIconStyle,
type InputAction,
useGameAction,
} from './input'
import { ControllerBindingLabel } from './components/ControllerIcons'
import { PartyMemberFrame } from './components/PartyFrames'
import { barFillStyle } from './components/barStyles'
import { groupFloatingTextsByMember } from './combat/combatPresentation'
const STORAGE_KEY = 'ashen-halls-dual-screen-enabled'
@@ -47,6 +49,9 @@ export type DualScreenCombatState = {
opponentClassName?: string
opponentParty?: PartyMember[]
opponentEnemyHealth?: number
opponentResource?: number
opponentMaxResource?: number
opponentResourceName?: string
opponentBuffSummary?: string
opponentDebuffSummary?: string
floatingTexts: Array<{
@@ -398,6 +403,12 @@ export function DualScreenBottomDisplay() {
channel?.close()
}
useGameAction((action) => {
if (!state || state.opponentParty) return
if (action === 'confirm' || action === 'back' || action === 'toggleTouchLock') return
sendAction(action)
})
if (!state && workshopState) {
return (
<main className="dual-bottom-display workshop-bottom-display">
@@ -445,7 +456,10 @@ export function DualScreenBottomDisplay() {
}
return (
<main className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}>
<main
className={`dual-bottom-display ${state.opponentParty ? 'pvp-opponent-bottom-display' : ''}`}
data-combat-active={!state.opponentParty && state.status === 'playing' && !state.paused ? 'true' : 'false'}
>
<header className="dual-controls-header">
<div>
<p className="eyebrow">{state.opponentParty ? 'Opponent View' : `${state.difficultyName} ${state.contentName}`}</p>
@@ -477,8 +491,19 @@ export function DualScreenBottomDisplay() {
<strong>{Math.max(0, Math.floor(state.opponentEnemyHealth ?? 0))} / {state.encounterMaxHealth}</strong>
</div>
<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>
{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>
)}
@@ -513,7 +538,7 @@ export function DualScreenBottomDisplay() {
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
<span style={barFillStyle((state.resource / state.maxResource) * 100)} />
</div>
</div>
</section>
@@ -626,8 +651,35 @@ export function DualScreenTopCombat({
[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"
data-controller-nav={state.opponentParty ? 'skip' : undefined}
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 (
<div className="dual-top-main">
<div className={`dual-top-main ${state.opponentParty ? 'pvp-roguelike-dual-top' : ''}`}>
<section className="dual-top-enemy">
<div className="enemy-portrait" aria-hidden="true">
{state.encounterIsBoss ? 'B' : 'M'}
@@ -638,10 +690,25 @@ export function DualScreenTopCombat({
<span>{Math.ceil(state.encounterHealth)} / {state.encounterMaxHealth}</span>
</div>
<div className="bar enemy-health">
<span style={{ width: `${enemyPercent}%` }} />
<span style={barFillStyle(enemyPercent)} />
</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>
{state.opponentParty && (
<div className="dual-top-side-status">
<div className="dual-top-cooldowns" aria-label="Cooldowns">
{spellButtons}
</div>
</div>
)}
</section>
<section className="dual-top-party">
@@ -669,39 +736,17 @@ export function DualScreenTopCombat({
</div>
</section>
<section className="dual-top-spell-strip">
{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>
)
})}
<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}%` }} />
{!state.opponentParty && (
<section className="dual-top-spell-strip">
{spellButtons}
<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>
</div>
</section>
</section>
)}
</div>
)
+36
View File
@@ -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
}
+40 -18
View File
@@ -15,41 +15,63 @@ type FloatingCombatTextOptions = {
durationMs?: number
}
const FLOATING_TEXT_CLEANUP_INTERVAL_MS = 100
export function useFloatingCombatText<TText extends BasicFloatingCombatText>({
durationMs = 900,
}: FloatingCombatTextOptions = {}) {
const [floatingTexts, setFloatingTexts] = useState<TText[]>([])
const nextId = useRef(1)
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(() => {
clearCleanupTimer()
expirationsRef.current.clear()
setFloatingTexts([])
}, [])
}, [clearCleanupTimer])
const addFloatingText = useCallback((entry: Omit<TText, 'id'>) => {
if (entry.value <= 0) return
const id = nextId.current++
expirationsRef.current.set(id, performance.now() + durationMs)
setFloatingTexts((current) => appendFloatingText(current, { ...entry, id } as TText))
}, [durationMs])
scheduleCleanup()
}, [durationMs, scheduleCleanup])
useEffect(() => {
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)
}, [])
useEffect(() => () => clearCleanupTimer(), [clearCleanupTimer])
const floatingTextsByMember = useMemo(
() => groupFloatingTextsByMember(floatingTexts),
+38
View File
@@ -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
View File
@@ -3,7 +3,9 @@
linear-gradient(rgba(8, 9, 12, 0.88), rgba(8, 9, 12, 0.88)),
repeating-linear-gradient(0deg, #171922 0 2px, #11131a 2px 4px);
color: #f4eed8;
font-family: 'VT323', Consolas, monospace;
--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;
text-rendering: optimizeLegibility;
}
+224 -35
View File
@@ -37,6 +37,7 @@ export const INPUT_ACTIONS = [
'targetParty6',
'toggleTargetGroup',
'toggleSpeed',
'toggleTouchLock',
'pause',
] as const
@@ -66,6 +67,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
targetParty6: 'Target Party Member 6',
toggleTargetGroup: 'Switch Raid Target Group',
toggleSpeed: 'Toggle 2x Speed',
toggleTouchLock: 'Toggle Combat Touch Lock',
pause: 'Pause Menu',
}
@@ -93,6 +95,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty6: 'F6',
toggleTargetGroup: 'Tab',
toggleSpeed: 'Backquote',
toggleTouchLock: 'F7',
pause: 'Escape',
},
controller: {
@@ -115,9 +118,10 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
targetParty3: 'Button15',
targetParty4: 'Button13',
targetParty5: 'Button4',
targetParty6: 'Button10',
toggleTargetGroup: 'Button6',
targetParty6: 'Button6',
toggleTargetGroup: 'Button8',
toggleSpeed: 'Button11',
toggleTouchLock: 'Button10',
pause: 'Button9',
},
}
@@ -126,10 +130,20 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const FOCUSABLE_SELECTOR = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
const MAIN_CONTENT_SELECTOR = '.menu-screen, .content-screen, .dungeon-run-screen'
const HEADER_CONTENT_SELECTOR = '.app-header'
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
const GAMEPAD_MENU_POLL_MS = 1000 / 30
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
const CONTROLLER_REPEAT_INITIAL_MS = 260
const CONTROLLER_REPEAT_MS = 85
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
let lastControllerFocus: HTMLElement | null = null
@@ -144,11 +158,13 @@ type InputContextValue = {
lastDevice: InputDevice
controllerIconStyle: ControllerIconStyle
directPartyTargeting: boolean
combatTouchLocked: boolean
beginCapture: (device: InputDevice, action: InputAction) => void
cancelCapture: () => void
resetBindings: (device: InputDevice) => void
setControllerIconStyle: (style: ControllerIconStyle) => void
setDirectPartyTargeting: (enabled: boolean) => void
setCombatTouchLocked: (locked: boolean) => void
}
const InputContext = createContext<InputContextValue | null>(null)
@@ -187,6 +203,21 @@ function loadBindings(): Record<InputDevice, InputBindings> {
if (savedController?.targetParty6 === 'Button11') {
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 {
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
controller,
@@ -201,15 +232,18 @@ function loadPreferences() {
const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
controllerIconStyle?: ControllerIconStyle
directPartyTargeting?: boolean
combatTouchLocked?: boolean
}
return {
controllerIconStyle: saved.controllerIconStyle ?? 'xbox',
directPartyTargeting: saved.directPartyTargeting ?? false,
combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
}
} catch {
return {
controllerIconStyle: 'xbox' as ControllerIconStyle,
directPartyTargeting: false,
combatTouchLocked: Capacitor.isNativePlatform(),
}
}
}
@@ -223,6 +257,7 @@ function bindingGroup(action: InputAction) {
if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
if (action === 'pause') return 'pause'
if (action === 'toggleTouchLock' || action === 'toggleSpeed') return 'system'
return 'navigation'
}
@@ -231,6 +266,16 @@ function isVisible(element: HTMLElement) {
return element.getClientRects().length > 0
}
function focusableDescendants(scope: ParentNode) {
return Array.from(
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter(isVisible)
}
function uniqueElements(elements: HTMLElement[]) {
return elements.filter((element, index) => elements.indexOf(element) === index)
}
function focusableElements() {
const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
@@ -239,10 +284,22 @@ function focusableElements() {
'.result-screen, .binding-capture, .dual-startup-prompt',
),
).find(isVisible)
const scope: ParentNode = keyboard ?? pauseMenu ?? dialog ?? document
return Array.from(
scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
const mainContent = Array.from(
document.querySelectorAll<HTMLElement>(MAIN_CONTENT_SELECTOR),
).find(isVisible)
const overlay = keyboard ?? pauseMenu ?? dialog
if (overlay) return focusableDescendants(overlay)
const visibleHeaders = Array.from(
document.querySelectorAll<HTMLElement>(HEADER_CONTENT_SELECTOR),
).filter(isVisible)
if (!mainContent) return visibleHeaders.length > 0 ? [] : focusableDescendants(document)
const headerControls = visibleHeaders.flatMap(
(header) => focusableDescendants(header),
)
return uniqueElements([
...focusableDescendants(mainContent),
...headerControls,
])
}
function rememberFocusableControl(element: HTMLElement) {
@@ -266,6 +323,23 @@ function currentFocusableControl(candidates = focusableElements()) {
return null
}
function rangeDistance(startA: number, endA: number, startB: number, endB: number) {
if (endA < startB) return startB - endA
if (endB < startA) return startA - endB
return 0
}
function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) {
const options = Array.from(select.options).filter((option) => !option.disabled)
const currentIndex = options.findIndex((option) => option.index === select.selectedIndex)
const nextOption = options[currentIndex + direction]
if (!nextOption) return false
select.selectedIndex = nextOption.index
select.dispatchEvent(new Event('input', { bubbles: true }))
select.dispatchEvent(new Event('change', { bubbles: true }))
return true
}
export function focusFirstControl() {
const first = focusableElements()[0]
if (first) focusControl(first)
@@ -286,21 +360,48 @@ function moveFocus(action: InputAction) {
const currentY = currentRect.top + currentRect.height / 2
const vertical = action === 'navigateUp' || action === 'navigateDown'
const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1
const currentIndex = candidates.indexOf(current)
const adjacent = candidates[currentIndex + direction]
const fallbackToAdjacent = () => {
if (adjacent) focusControl(adjacent)
}
const ranked = candidates
.filter((candidate) => candidate !== current)
.map((candidate) => {
.map((candidate, index) => {
const rect = candidate.getBoundingClientRect()
const x = rect.left + rect.width / 2
const y = rect.top + rect.height / 2
const primary = vertical ? y - currentY : x - currentX
const secondary = vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)
return { candidate, primary, score: Math.abs(primary) + secondary * 2.5 }
const primary = vertical
? direction > 0
? rect.top - currentRect.bottom
: currentRect.top - rect.bottom
: direction > 0
? rect.left - currentRect.right
: currentRect.left - rect.right
const secondary = vertical
? rangeDistance(currentRect.left, currentRect.right, rect.left, rect.right)
: rangeDistance(currentRect.top, currentRect.bottom, rect.top, rect.bottom)
return {
candidate,
index,
primary,
secondary,
score: Math.max(0, primary) + secondary * 2.5 + (vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)) * 0.1,
}
})
.filter(({ primary }) => Math.sign(primary) === direction)
.sort((a, b) => a.score - b.score)
.filter(({ primary }) => primary >= -4)
const sameRowHorizontal = !vertical
? ranked.filter(({ secondary }) => secondary === 0)
: ranked
if (sameRowHorizontal.length === 0) {
fallbackToAdjacent()
return
}
sameRowHorizontal.sort((a, b) => a.score - b.score || a.secondary - b.secondary || a.index - b.index)
const next = ranked[0]?.candidate
const next = sameRowHorizontal[0]?.candidate
if (!next) return
focusControl(next)
}
@@ -428,6 +529,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const [preferences, setPreferences] = useState(loadPreferences)
const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
const [keyboardShift, setKeyboardShift] = useState(false)
const [touchLockMessage, setTouchLockMessage] = useState('')
const bindingsRef = useRef(bindings)
const preferencesRef = useRef(preferences)
const captureRef = useRef(capture)
@@ -435,6 +537,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({})
const gamepadConnectedRef = useRef(Capacitor.isNativePlatform())
const touchLockMessageTimerRef = useRef(0)
useEffect(() => {
bindingsRef.current = bindings
@@ -485,13 +588,38 @@ export function InputProvider({ children }: { children: ReactNode }) {
setLastDevice(device)
document.documentElement.dataset.inputDevice = device
if (action.startsWith('navigate')) {
if (uiOverlay || !combatActive) moveFocus(action)
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) {
const active = currentFocusableControl()
if (
active instanceof HTMLSelectElement
&& (action === 'navigateUp' || action === 'navigateDown')
&& changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
) {
return
}
moveFocus(action)
}
} else if (action === 'confirm') {
const active = currentFocusableControl()
if (isTextInput(active)) {
setKeyboardInput(active)
window.requestAnimationFrame(() => focusFirstControl())
} else if (active instanceof HTMLSelectElement) {
const select = active as HTMLSelectElement & { showPicker?: () => void }
if (select.showPicker) select.showPicker()
else active.click()
} else if (
active
&& active.matches('button:not(:disabled), [role="button"]')
@@ -528,12 +656,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
document.querySelector('[data-combat-active="true"]'),
)
const uiOverlay = hasUiOverlay()
const menuDpadActions: Partial<Record<string, InputAction>> = {
Button12: 'navigateUp',
Button13: 'navigateDown',
Button14: 'navigateLeft',
Button15: 'navigateRight',
}
const uiPriority = [
'navigateUp',
'navigateDown',
@@ -551,8 +673,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
'targetParty6',
'toggleTargetGroup',
'toggleSpeed',
'toggleTouchLock',
] satisfies InputAction[]
const combatPriority = [
'toggleTouchLock',
'pause',
'toggleSpeed',
'ability1',
@@ -568,18 +692,18 @@ export function InputProvider({ children }: { children: ReactNode }) {
'navigateLeft',
'navigateRight',
] satisfies InputAction[]
const action = menuDpadActions[token] && (!combatActive || uiOverlay)
? menuDpadActions[token]
const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
? DPAD_NAV_ACTIONS[token]
: uiOverlay
? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
: combatActive && preferencesRef.current.directPartyTargeting
? [...directTargetActions, ...combatPriority].find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
: combatActive && menuDpadActions[token]
? menuDpadActions[token]
: !combatActive && menuDpadActions[token]
? menuDpadActions[token]
: combatActive && DPAD_NAV_ACTIONS[token]
? DPAD_NAV_ACTIONS[token]
: !combatActive && DPAD_NAV_ACTIONS[token]
? DPAD_NAV_ACTIONS[token]
: (combatActive ? combatPriority : INPUT_ACTIONS).find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
@@ -621,7 +745,9 @@ export function InputProvider({ children }: { children: ReactNode }) {
rememberFocusableControl(target)
}
const onPointerDown = (event: PointerEvent) => {
document.documentElement.dataset.inputDevice = 'pc'
if (event.pointerType !== 'touch') {
document.documentElement.dataset.inputDevice = 'pc'
}
const target = event.target
if (!(target instanceof Element)) return
const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
@@ -645,8 +771,14 @@ export function InputProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
}, [dispatchControllerToken])
useEffect(() => () => {
window.clearTimeout(touchLockMessageTimerRef.current)
}, [])
useEffect(() => {
let focusFrame = 0
const ensureFocus = () => {
focusFrame = 0
const combatActive = document.querySelector('[data-combat-active="true"]')
if (combatActive) return
const candidates = focusableElements()
@@ -663,22 +795,37 @@ export function InputProvider({ children }: { children: ReactNode }) {
}
}
}
const scheduleEnsureFocus = () => {
if (focusFrame) return
focusFrame = window.requestAnimationFrame(ensureFocus)
}
const observer = new MutationObserver(() => {
window.requestAnimationFrame(ensureFocus)
scheduleEnsureFocus()
})
observer.observe(document.getElementById('root') ?? document.body, {
attributes: true,
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden', 'style'],
attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden'],
childList: true,
subtree: true,
})
window.requestAnimationFrame(ensureFocus)
return () => observer.disconnect()
scheduleEnsureFocus()
return () => {
if (focusFrame) window.cancelAnimationFrame(focusFrame)
observer.disconnect()
}
}, [])
useEffect(() => {
let timer = 0
if (Capacitor.isNativePlatform() && capture?.device !== 'controller') {
previousTokensRef.current = new Set()
repeatRef.current = {}
return undefined
}
const nextDelay = () => {
if (Capacitor.isNativePlatform()) {
return GAMEPAD_COMBAT_POLL_MS
}
if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) {
return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS
}
@@ -705,11 +852,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
const action = INPUT_ACTIONS.find(
(candidate) => bindingsRef.current.controller[candidate] === token,
)
const canRepeat = action?.startsWith('navigate') ?? false
const canRepeat = Boolean(action?.startsWith('navigate') || DPAD_NAV_ACTIONS[token])
const nextRepeat = repeatRef.current[token] ?? 0
if (pressed || (canRepeat && time >= nextRepeat)) {
dispatchControllerToken(token, !pressed)
repeatRef.current[token] = time + (pressed ? 360 : 125)
repeatRef.current[token] = time + (pressed ? CONTROLLER_REPEAT_INITIAL_MS : CONTROLLER_REPEAT_MS)
}
})
@@ -735,7 +882,39 @@ export function InputProvider({ children }: { children: ReactNode }) {
window.removeEventListener('gamepadconnected', onGamepadConnected)
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>(() => ({
bindings,
@@ -743,6 +922,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
lastDevice,
controllerIconStyle: preferences.controllerIconStyle,
directPartyTargeting: preferences.directPartyTargeting,
combatTouchLocked: preferences.combatTouchLocked,
beginCapture: (device, action) => setCapture({ device, action }),
cancelCapture: () => setCapture(null),
resetBindings: (device) => setBindings((current) => ({
@@ -757,6 +937,10 @@ export function InputProvider({ children }: { children: ReactNode }) {
...current,
directPartyTargeting,
})),
setCombatTouchLocked: (combatTouchLocked) => setPreferences((current) => ({
...current,
combatTouchLocked,
})),
}), [bindings, capture, lastDevice, preferences])
function typeKeyboardKey(key: string) {
@@ -821,6 +1005,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
</section>
</div>
)}
{touchLockMessage && (
<div className="combat-touch-lock-status" aria-live="polite">
{touchLockMessage}
</div>
)}
</InputContext.Provider>
)
}
+3 -1
View File
@@ -9,7 +9,9 @@ import { DualScreenBottomDisplay, DualScreenProvider, DualScreenStartupPrompt }
createRoot(document.getElementById('root')!).render(
<StrictMode>
{new URLSearchParams(window.location.search).get('display') === 'bottom' ? (
<DualScreenBottomDisplay />
<InputProvider>
<DualScreenBottomDisplay />
</InputProvider>
) : (
<DualScreenProvider>
<DualScreenStartupPrompt />