Compare commits

...
3 Commits
Author SHA1 Message Date
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
27 changed files with 1517 additions and 274 deletions
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" 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);
+2
View File
@@ -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.
+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, 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}`}
+60 -2
View File
@@ -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,
} }
} }
+28 -22
View File
@@ -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>
+3 -2
View File
@@ -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">
+29 -15
View File
@@ -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>
+29 -16
View File
@@ -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">
+10
View File
@@ -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 -1
View File
@@ -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>
) )
}) })
+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`,
}
}
+71 -37
View File
@@ -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>
) )
+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 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),
+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)), 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
View File
@@ -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>
) )
} }