Release v0.1.1 2026-07-10

This commit is contained in:
Warren H
2026-07-10 23:47:37 -04:00
parent 537a311f52
commit a2ad6ab34a
40 changed files with 2987 additions and 192 deletions
+121
View File
@@ -0,0 +1,121 @@
# TrueNAS deployment
This game uses the same proven local-Gitea pattern as `testgame`: clone from the
Gitea bare repository on the TrueNAS filesystem, mount that working checkout
into one Node container, and update it with a local Git pull plus app restart.
## Paths
```text
Local Gitea bare repository:
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
Runnable working checkout:
/mnt/usbssds/apps/iwanttoheal-mmo/app
Public URL:
https://iwanttoheal.phenomrom.com
Container/host port:
4173
```
The bare Gitea repository is not a runnable application directory. Use a local
`git clone`; do not copy the bare repository with `cp`. Local clone/pull keeps
the source transfer on TrueNAS and creates the working tree the container needs.
## First installation
This configuration uses a separate `iwanttoheal-mmo` working directory, so it
does not modify the old app checkout or its data.
Confirm the new repository path. If the first command fails, use the search:
```sh
sudo test -d /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
sudo find /mnt -type d -name "i-want-to-heal-mmo.git" -prune -print 2>/dev/null
```
Clone entirely through the local filesystem:
```sh
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo
sudo git config --global --add safe.directory \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
sudo git clone \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git \
/mnt/usbssds/apps/iwanttoheal-mmo/app
sudo chown -R truenas_admin:truenas_admin /mnt/usbssds/apps/iwanttoheal-mmo
```
Verify the checkout:
```sh
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app remote -v
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app branch --show-current
ls /mnt/usbssds/apps/iwanttoheal-mmo/app/package.json
```
Expected branch: `main`. Expected origin: local Gitea path above.
## Install as a TrueNAS app
1. Open **Apps**.
2. Open **Discover**.
3. Open the three-dot menu.
4. Select **Install via YAML**.
5. Name the app `iwanttoheal-mmo`.
6. Paste:
```yaml
services:
iwanttoheal:
image: node:24-bookworm-slim
command: >-
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start"
environment:
HOST: 0.0.0.0
PORT: "4173"
init: true
ports:
- "4173:4173"
restart: unless-stopped
volumes:
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
working_dir: /app
```
This game has no server database. Do not add `db:init`, `/app/data`, cookie,
CORS, or proxy environment settings from the older game.
The separate volume protects the old app files and data. The YAML still maps
host port `4173`, so the old and MMO apps cannot run simultaneously while both
use that host port. Stop the old app, or assign this app an unused host port and
separate reverse-proxy hostname.
After deployment, test from the TrueNAS shell or another LAN machine:
```sh
curl -I http://TRUENAS-IP:4173
```
Expected result: HTTP `200`. Keep the existing HTTPS reverse proxy pointed at
`TRUENAS-IP:4173` for `iwanttoheal.phenomrom.com`.
## Update workflow
Push `main` from the development Mac. Then run on TrueNAS:
```sh
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app pull --ff-only \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git \
main
```
This transfers Git objects locally from Gitea storage; it does not download the
game through HTTPS. Restart `iwanttoheal-mmo` in the TrueNAS Apps UI afterward. The
container startup command installs locked dependencies, rebuilds the browser
bundle, and starts port `4173`.
For simultaneous operation, keep the new app name/directory and also assign an
unused host port plus a separate reverse-proxy hostname.
+22 -14
View File
@@ -38,14 +38,23 @@ state rather than two independent WebViews.
## TrueNAS deployment ## TrueNAS deployment
Complete first-install, local-Gitea clone, YAML, update, and verification steps:
[DEPLOYMENT.md](DEPLOYMENT.md).
The production preview server uses the existing deployment address and port: The production preview server uses the existing deployment address and port:
- Public URL: `https://iwanttoheal.phenomrom.com` - Public URL: `https://iwanttoheal.phenomrom.com`
- Host/container port: `4173` - Host/container port: `4173`
- App directory: `/mnt/usbssds/apps/iwanttoheal/app` - App directory: `/mnt/usbssds/apps/iwanttoheal-mmo/app`
Clone the repository into the app directory, then deploy `compose.yaml`. Compared Clone from the TrueNAS-local Gitea bare repository into the app directory, then
with the old game configuration: deploy `compose.yaml`. The expected source path is:
```text
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
```
Compared with the old game configuration:
- use `corepack pnpm install --frozen-lockfile`, not `npm ci`; - use `corepack pnpm install --frozen-lockfile`, not `npm ci`;
- remove `npm run db:init` because this game has no server database; - remove `npm run db:init` because this game has no server database;
@@ -66,11 +75,10 @@ The repository target is:
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
``` ```
Create a Gitea access token with repository write permission, expose it only for The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can
the publishing process, then publish from `main`: optionally override it for one run. Publish from `main`:
```bash ```bash
export GITEA_TOKEN="..."
pnpm publish:gitea -- --message "Describe the update" pnpm publish:gitea -- --message "Describe the update"
``` ```
@@ -80,11 +88,11 @@ version tag and Gitea prerelease, and uploads the APK plus its SHA-256 file. It
uses `package.json` version unless that version is already tagged, then advances uses `package.json` version unless that version is already tagged, then advances
the patch number. Pass `--version 0.2.0` to choose an explicit version. the patch number. Pass `--version 0.2.0` to choose an explicit version.
If the TrueNAS app directory is mounted locally, publishing also performs a If the publisher runs where TrueNAS paths are visible, it prefers a
fast-forward-only pull. Otherwise it prints the clone/pull command to run on fast-forward-only pull directly from TrueNAS-local Gitea storage. Otherwise it
TrueNAS before restarting the app. The pull refuses to run when the TrueNAS prints the exact local clone/pull commands to run in the TrueNAS shell. The pull
checkout still points at the old game's repository; move or archive that checkout refuses to run when the checkout still points at the old game's repository;
and clone the new repository first. archive that checkout and clone the new repository first.
Preview checks without changing Git or TrueNAS: Preview checks without changing Git or TrueNAS:
@@ -92,9 +100,9 @@ Preview checks without changing Git or TrueNAS:
pnpm publish:gitea -- --dry-run pnpm publish:gitea -- --dry-run
``` ```
Git push credentials still come from Git's credential manager. Gitea release API Git push credentials still come from Git's credential manager. The configured
credentials come from `GITEA_TOKEN`. Never store access tokens or Android signing Gitea token handles release creation and APK uploads. Android signing keys remain
keys in this repository. outside the repository.
## Controls ## Controls
@@ -0,0 +1,225 @@
package com.phenomrom.iwanttoheal;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import androidx.activity.OnBackPressedCallback;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Routes every Thor controller event into one JavaScript input service. */
public abstract class ControllerBridgeActivity extends BridgeActivity {
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final long REPEAT_THROTTLE_MS = 55L;
private final long[] lastDpadDispatchAt = new long[16];
private final Set<String> activeMotionTokens = new HashSet<>();
private final Map<String, Long> lastMotionDispatchAt = new HashMap<>();
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
dispatchNativeControllerToken("Button1", false);
}
});
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
WindowManager.LayoutParams attributes = getWindow().getAttributes();
attributes.preferredRefreshRate = 60.0f;
getWindow().setAttributes(attributes);
if (bridge != null && bridge.getWebView() != null) {
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
bridge.getWebView().setFocusable(true);
bridge.getWebView().setFocusableInTouchMode(true);
bridge.getWebView().requestFocus();
}
enterImmersiveMode();
}
@Override
public void onPause() {
clearHeldControllerState();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
enterImmersiveMode();
if (bridge != null && bridge.getWebView() != null) bridge.getWebView().requestFocus();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
else clearHeldControllerState();
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN && bridge != null) {
bridge.getWebView().requestFocus();
}
return super.dispatchTouchEvent(event);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
String token = controllerToken(event.getKeyCode());
if (token == null || bridge == null) return super.dispatchKeyEvent(event);
if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
dispatchNativeControllerToken(token, repeat);
}
return true;
}
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (
bridge == null
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|| !isControllerMotionEvent(event)
) {
return super.dispatchGenericMotionEvent(event);
}
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
dispatchNativeControllerMotion(leftStickX, leftStickY);
Set<String> currentTokens = new HashSet<>();
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15");
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_Y), "Button12", "Button13");
addAxisTokens(currentTokens, leftStickX, "Axis0-", "Axis0+");
addAxisTokens(currentTokens, leftStickY, "Axis1-", "Axis1+");
long now = SystemClock.uptimeMillis();
for (String token : currentTokens) {
boolean repeat = activeMotionTokens.contains(token);
long lastDispatch = lastMotionDispatchAt.containsKey(token)
? lastMotionDispatchAt.get(token)
: 0L;
if (!repeat || now - lastDispatch >= REPEAT_THROTTLE_MS) {
dispatchNativeControllerToken(token, repeat);
lastMotionDispatchAt.put(token, now);
}
}
activeMotionTokens.clear();
activeMotionTokens.addAll(currentTokens);
return true;
}
private void enterImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(
getWindow(),
getWindow().getDecorView()
);
controller.hide(WindowInsetsCompat.Type.systemBars());
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
}
private boolean isControllerMotionEvent(MotionEvent event) {
int source = event.getSource();
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
}
private void addAxisTokens(Set<String> tokens, float value, String negative, String positive) {
if (value <= -AXIS_DEAD_ZONE) tokens.add(negative);
if (value >= AXIS_DEAD_ZONE) tokens.add(positive);
}
private void dispatchNativeControllerToken(String token, boolean repeat) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
});
}
private void dispatchNativeControllerMotion(float x, float y) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller-motion',"
+ "{detail:{x:" + x + ",y:" + y + "}}));";
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
}
private void clearHeldControllerState() {
activeMotionTokens.clear();
lastMotionDispatchAt.clear();
dispatchNativeControllerMotion(0.0f, 0.0f);
if (bridge == null || bridge.getWebView() == null) return;
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
"window.dispatchEvent(new Event('iwt-native-controller-reset'));",
null
));
}
private boolean shouldThrottleDpad(String token) {
int index = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt[index] < REPEAT_THROTTLE_MS) return true;
lastDpadDispatchAt[index] = now;
return false;
}
private boolean isDpadToken(String token) {
return token.equals("Button12")
|| token.equals("Button13")
|| token.equals("Button14")
|| token.equals("Button15");
}
private String controllerToken(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A:
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_NUMPAD_ENTER:
return "Button0";
case KeyEvent.KEYCODE_BUTTON_B:
case KeyEvent.KEYCODE_ESCAPE:
return "Button1";
case KeyEvent.KEYCODE_BUTTON_X: return "Button2";
case KeyEvent.KEYCODE_BUTTON_Y: return "Button3";
case KeyEvent.KEYCODE_BUTTON_L1: return "Button4";
case KeyEvent.KEYCODE_BUTTON_R1: return "Button5";
case KeyEvent.KEYCODE_BUTTON_L2: return "Button6";
case KeyEvent.KEYCODE_BUTTON_R2: return "Button7";
case KeyEvent.KEYCODE_BUTTON_SELECT: return "Button8";
case KeyEvent.KEYCODE_BUTTON_START: return "Button9";
case KeyEvent.KEYCODE_BUTTON_THUMBL: return "Button10";
case KeyEvent.KEYCODE_BUTTON_THUMBR: return "Button11";
case KeyEvent.KEYCODE_DPAD_UP: return "Button12";
case KeyEvent.KEYCODE_DPAD_DOWN: return "Button13";
case KeyEvent.KEYCODE_DPAD_LEFT: return "Button14";
case KeyEvent.KEYCODE_DPAD_RIGHT: return "Button15";
default: return null;
}
}
}
@@ -1,44 +1,11 @@
package com.phenomrom.iwanttoheal; package com.phenomrom.iwanttoheal;
import android.os.Bundle; import android.os.Bundle;
import android.view.View;
import android.view.WindowManager;
import androidx.core.view.WindowCompat; public class MainActivity extends ControllerBridgeActivity {
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
registerPlugin(ThorDualScreenPlugin.class);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
WindowManager.LayoutParams attributes = getWindow().getAttributes();
attributes.preferredRefreshRate = 60.0f;
getWindow().setAttributes(attributes);
getBridge().getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
getBridge().getWebView().setFocusableInTouchMode(true);
getBridge().getWebView().requestFocus();
enterImmersiveMode();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
}
private void enterImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(
getWindow(),
getWindow().getDecorView()
);
controller.hide(WindowInsetsCompat.Type.systemBars());
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
} }
} }
@@ -0,0 +1,307 @@
package com.phenomrom.iwanttoheal;
import android.app.Presentation;
import android.content.Context;
import android.graphics.Color;
import android.hardware.display.DisplayManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.Display;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/** Keeps a dedicated WebView visible on each AYN Thor panel for the activity lifetime. */
@CapacitorPlugin(name = "ThorDualScreen")
public class ThorDualScreenPlugin extends Plugin implements DisplayManager.DisplayListener {
private final Handler handler = new Handler(Looper.getMainLooper());
private DisplayManager displayManager;
private ThorPresentation presentation;
private boolean forceEnabled;
@Override
public void load() {
displayManager = (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
displayManager.registerDisplayListener(this, handler);
}
@PluginMethod
public void getDisplays(PluginCall call) {
Display current = currentDisplay();
JSArray displays = new JSArray();
for (Display display : displayManager.getDisplays()) displays.put(describeDisplay(display, current));
JSObject result = new JSObject();
result.put("currentDisplayId", current == null ? -1 : current.getDisplayId());
result.put("displays", displays);
call.resolve(result);
}
@PluginMethod
public void forceBothDisplays(PluginCall call) {
forceEnabled = true;
getActivity().runOnUiThread(() -> {
try {
DisplayPlan plan = createPlan();
if (plan.target == null) {
call.reject("No active secondary Android display is available.");
return;
}
boolean alreadyOpen = presentation != null
&& presentation.isShowing()
&& presentation.getDisplay().getDisplayId() == plan.target.getDisplayId();
if (!alreadyOpen) openPlan(plan);
JSObject result = describeDisplay(plan.target, currentDisplay());
result.put("opened", true);
result.put("topOnActivity", plan.currentIsTop);
call.resolve(result);
} catch (Exception exception) {
closePresentation();
call.reject("Unable to force both Thor displays.", exception);
}
});
}
@Override
protected void handleOnResume() {
if (forceEnabled) scheduleEnsurePresentation(0L);
}
@Override
protected void handleOnDestroy() {
forceEnabled = false;
if (displayManager != null) displayManager.unregisterDisplayListener(this);
handler.removeCallbacksAndMessages(null);
closePresentation();
}
@Override
public void onDisplayAdded(int displayId) {
if (forceEnabled) scheduleEnsurePresentation(100L);
}
@Override
public void onDisplayRemoved(int displayId) {
if (presentation != null && presentation.getDisplay().getDisplayId() == displayId) {
closePresentation();
}
if (forceEnabled) scheduleEnsurePresentation(250L);
}
@Override
public void onDisplayChanged(int displayId) {
if (forceEnabled) scheduleEnsurePresentation(100L);
}
private void scheduleEnsurePresentation(long delayMs) {
handler.removeCallbacks(ensurePresentation);
handler.postDelayed(ensurePresentation, delayMs);
}
private final Runnable ensurePresentation = () -> {
if (!forceEnabled || getActivity() == null || getActivity().isFinishing()) return;
DisplayPlan plan = createPlan();
if (plan.target == null) return;
if (
presentation != null
&& presentation.isShowing()
&& presentation.getDisplay().getDisplayId() == plan.target.getDisplayId()
) return;
openPlan(plan);
};
private void openPlan(DisplayPlan plan) {
String baseUrl = bridge.getLocalUrl();
String targetUrl = baseUrl + "/?display=" + (plan.targetIsTop ? "top" : "bottom") + "&role=presentation";
String currentUrl = baseUrl + "/?display=" + (plan.currentIsTop ? "top" : "bottom") + "&role=activity";
closePresentation();
presentation = new ThorPresentation(
getActivity(),
plan.target,
targetUrl
);
presentation.setOnDismissListener(dialog -> {
presentation = null;
notifyListeners("displayDisconnected", new JSObject());
if (forceEnabled) scheduleEnsurePresentation(300L);
});
presentation.show();
bridge.getWebView().loadUrl(currentUrl);
}
private DisplayPlan createPlan() {
Display current = currentDisplay();
int currentId = current == null ? -1 : current.getDisplayId();
Display[] displays = displayManager.getDisplays();
Display top = largestDisplay(displays);
boolean currentIsTop = top != null && top.getDisplayId() == currentId;
Display target = currentIsTop
? smallestOtherDisplay(displays, currentId)
: top != null && top.getDisplayId() != currentId ? top : largestOtherDisplay(displays, currentId);
boolean targetIsTop = target != null && top != null && target.getDisplayId() == top.getDisplayId();
return new DisplayPlan(target, targetIsTop, currentIsTop);
}
private Display currentDisplay() {
return getActivity().getWindow().getDecorView().getDisplay();
}
private Display largestDisplay(Display[] displays) {
Display selected = null;
long pixels = -1L;
for (Display display : displays) {
if (display.getState() == Display.STATE_OFF) continue;
long candidate = pixelCount(display);
if (candidate > pixels) {
selected = display;
pixels = candidate;
}
}
return selected;
}
private Display largestOtherDisplay(Display[] displays, int currentId) {
Display selected = null;
long pixels = -1L;
for (Display display : displays) {
if (display.getDisplayId() == currentId || display.getState() == Display.STATE_OFF) continue;
long candidate = pixelCount(display);
if (candidate > pixels) {
selected = display;
pixels = candidate;
}
}
return selected;
}
private Display smallestOtherDisplay(Display[] displays, int currentId) {
Display selected = null;
long pixels = Long.MAX_VALUE;
for (Display display : displays) {
if (display.getDisplayId() == currentId || display.getState() == Display.STATE_OFF) continue;
long candidate = pixelCount(display);
if (candidate < pixels) {
selected = display;
pixels = candidate;
}
}
return selected;
}
private long pixelCount(Display display) {
Display.Mode mode = display.getMode();
return (long) mode.getPhysicalWidth() * mode.getPhysicalHeight();
}
private JSObject describeDisplay(Display display, Display current) {
Display.Mode mode = display.getMode();
JSObject result = new JSObject();
result.put("id", display.getDisplayId());
result.put("name", display.getName());
result.put("width", mode.getPhysicalWidth());
result.put("height", mode.getPhysicalHeight());
result.put("refreshRate", mode.getRefreshRate());
result.put("isCurrent", current != null && current.getDisplayId() == display.getDisplayId());
return result;
}
private void closePresentation() {
if (presentation == null) return;
ThorPresentation closing = presentation;
presentation = null;
closing.setOnDismissListener(null);
closing.dismiss();
}
private final class ThorPresentation extends Presentation {
private final String initialUrl;
private WebView webView;
ThorPresentation(Context context, Display display, String initialUrl) {
super(context, display);
this.initialUrl = initialUrl;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().setBackgroundDrawableResource(android.R.color.black);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
WindowManager.LayoutParams attributes = getWindow().getAttributes();
attributes.preferredRefreshRate = 60.0f;
getWindow().setAttributes(attributes);
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
);
FrameLayout container = new FrameLayout(getContext());
container.setBackgroundColor(Color.BLACK);
webView = new WebView(getContext());
configureWebView(webView);
container.addView(webView, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
setContentView(container);
webView.loadUrl(initialUrl);
}
@Override
protected void onStop() {
super.onStop();
if (webView != null) {
webView.stopLoading();
webView.destroy();
webView = null;
}
}
}
private void configureWebView(WebView webView) {
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setDatabaseEnabled(true);
settings.setMediaPlaybackRequiresUserGesture(false);
settings.setAllowFileAccess(true);
settings.setAllowContentAccess(true);
webView.setBackgroundColor(Color.BLACK);
webView.setOverScrollMode(View.OVER_SCROLL_NEVER);
webView.setWebViewClient(new WebViewClient() {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
return bridge.getLocalServer().shouldInterceptRequest(request);
}
});
}
private static final class DisplayPlan {
private final Display target;
private final boolean targetIsTop;
private final boolean currentIsTop;
DisplayPlan(Display target, boolean targetIsTop, boolean currentIsTop) {
this.target = target;
this.targetIsTop = targetIsTop;
this.currentIsTop = currentIsTop;
}
}
}
+1 -1
View File
@@ -11,5 +11,5 @@ services:
- "4173:4173" - "4173:4173"
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /mnt/usbssds/apps/iwanttoheal/app:/app - /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
working_dir: /app working_dir: /app
@@ -0,0 +1,47 @@
# Ember Mantis Duelist
Original stylized low-poly 3D interpretation of the IWT2 Ember Mantis Duelist concept.
## Runtime assets
- `ember_mantis_duelist.glb` — skinned render model and animation clips
- `ember_mantis_duelist_collision.glb` — three low-cost collision volumes
- `ember_mantis_duelist.asset.json` — dimensions, materials, clip lengths, and conventions
- `ember_mantis_duelist.blend` — editable Blender source and preview studio
- `previews/` — front, side, three-quarter, and CrossSlash renders
## Animation clips
| Clip | Loop | Intended use |
| --- | --- | --- |
| `Idle` | Yes | Combat-ready idle |
| `Walk` | Yes | Forward locomotion driven by game movement |
| `Sidestep` | No | Fast lateral reposition |
| `Melee` | No | Close-range scythe strike |
| `LineSlash` | No | Single glowing arena cut |
| `CrossSlash` | No | Two-arm crossed arena cut |
| `Recover` | No | Post-attack settle |
| `Stagger` | No | Hit reaction |
| `Death` | No | Final collapse |
## React Three Fiber loading
```tsx
const gltf = useGLTF(EMBER_MANTIS_URL)
const { actions } = useAnimations(gltf.animations, gltf.scene)
actions.Idle?.reset().fadeIn(0.15).play()
```
Model uses meters, Blender `-Y` forward, and glTF `+Y` up. Runtime movement should remain authoritative; locomotion clips contain only small pose offsets and no travel root motion.
## Rebuild
```sh
/Applications/Blender.app/Contents/MacOS/Blender \
--background \
--factory-startup \
--python scripts/blender/build_ember_mantis_duelist.py
```
Build script recreates Blender source, GLBs, metadata, and preview renders deterministically.
@@ -0,0 +1,74 @@
{
"name": "Ember Mantis Duelist",
"assetId": "ember-mantis-duelist",
"license": "Original project asset",
"sourceConcept": "IWT2 Ember Mantis Duelist side-cel concept",
"authoringTool": "5.1.2",
"format": "glTF 2.0 binary (GLB)",
"coordinateConvention": {
"up": "+Y after glTF export",
"blenderForward": "-Y",
"units": "meters"
},
"runtime": {
"renderMesh": "EmberMantis_Body",
"armature": "EmberMantis_Rig",
"collisionAsset": "ember_mantis_duelist_collision.glb",
"materials": [
"M_Chitin",
"M_CrimsonArmor",
"M_DeepRed",
"M_EyeGlow",
"M_BoneBlade",
"M_EmberGlow"
],
"trianglesApprox": 1574
},
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Walk": {
"frames": 30,
"seconds": 1.0,
"loop": true
},
"Sidestep": {
"frames": 18,
"seconds": 0.6,
"loop": false
},
"Melee": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"LineSlash": {
"frames": 32,
"seconds": 1.067,
"loop": false
},
"CrossSlash": {
"frames": 38,
"seconds": 1.267,
"loop": false
},
"Recover": {
"frames": 22,
"seconds": 0.733,
"loop": false
},
"Stagger": {
"frames": 28,
"seconds": 0.933,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "i-want-to-heal", "name": "i-want-to-heal",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -0,0 +1,832 @@
"""Build Ember Mantis Duelist as a rigged, animated, runtime-ready GLB.
Run with:
blender --background --factory-startup --python scripts/blender/build_ember_mantis_duelist.py
The asset is an original 3D interpretation of the IWT2 Ember Mantis concept.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
OUT_DIR = ROOT / "game_assets/models/original/bosses/ember-mantis-duelist"
PREVIEW_DIR = OUT_DIR / "previews"
BLEND_PATH = OUT_DIR / "ember_mantis_duelist.blend"
GLB_PATH = OUT_DIR / "ember_mantis_duelist.glb"
COLLISION_PATH = OUT_DIR / "ember_mantis_duelist_collision.glb"
METADATA_PATH = OUT_DIR / "ember_mantis_duelist.asset.json"
FPS = 30
TAU = math.tau
PARTS: list[bpy.types.Object] = []
RUNTIME_MATERIALS: list[bpy.types.Material] = []
def reset_scene() -> None:
bpy.ops.object.mode_set(mode="OBJECT") if bpy.context.object and bpy.context.object.mode != "OBJECT" else None
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for datablocks in (
bpy.data.meshes,
bpy.data.curves,
bpy.data.armatures,
bpy.data.materials,
bpy.data.cameras,
bpy.data.lights,
bpy.data.actions,
):
for datablock in list(datablocks):
datablocks.remove(datablock)
def make_material(
name: str,
color: tuple[float, float, float, float],
*,
metallic: float = 0.0,
roughness: float = 0.5,
emission: tuple[float, float, float, float] | None = None,
emission_strength: float = 0.0,
) -> bpy.types.Material:
mat = bpy.data.materials.new(name)
mat.use_nodes = True
mat.diffuse_color = color
mat.metallic = metallic
mat.roughness = roughness
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = roughness
if emission:
bsdf.inputs["Emission Color"].default_value = emission
bsdf.inputs["Emission Strength"].default_value = emission_strength
return mat
def assign_rigid_group(obj: bpy.types.Object, bone_name: str) -> bpy.types.Object:
group = obj.vertex_groups.new(name=bone_name)
group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
PARTS.append(obj)
return obj
def finish_mesh(
obj: bpy.types.Object,
name: str,
material: bpy.types.Material,
bone_name: str,
*,
smooth: bool = True,
) -> bpy.types.Object:
obj.name = name
obj.data.name = f"{name}_Mesh"
# Every source part uses the same ordered slots. Blender 5.1 otherwise keeps
# joined material slots but resets joined face indices to slot zero.
for runtime_material in RUNTIME_MATERIALS:
obj.data.materials.append(runtime_material)
material_index = next(
index for index, runtime_material in enumerate(RUNTIME_MATERIALS) if runtime_material.name == material.name
)
for polygon in obj.data.polygons:
polygon.material_index = material_index
material_group = obj.vertex_groups.new(name=f"__MAT_{material_index}")
material_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
if smooth:
for polygon in obj.data.polygons:
polygon.use_smooth = True
return assign_rigid_group(obj, bone_name)
def add_ellipsoid(
name: str,
location: tuple[float, float, float],
scale: tuple[float, float, float],
material: bpy.types.Material,
bone_name: str,
*,
subdivisions: int = 2,
rotation: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> bpy.types.Object:
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdivisions, radius=1.0, location=location, rotation=rotation)
obj = bpy.context.object
obj.scale = scale
return finish_mesh(obj, name, material, bone_name)
def add_cone_between(
name: str,
start: tuple[float, float, float],
end: tuple[float, float, float],
radius_start: float,
radius_end: float,
material: bpy.types.Material,
bone_name: str,
*,
vertices: int = 8,
) -> bpy.types.Object:
start_v = Vector(start)
end_v = Vector(end)
direction = end_v - start_v
midpoint = (start_v + end_v) * 0.5
bpy.ops.mesh.primitive_cone_add(
vertices=vertices,
radius1=radius_start,
radius2=radius_end,
depth=direction.length,
location=midpoint,
)
obj = bpy.context.object
obj.rotation_mode = "QUATERNION"
obj.rotation_quaternion = direction.to_track_quat("Z", "Y")
obj.rotation_mode = "XYZ"
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
return finish_mesh(obj, name, material, bone_name)
def add_blade(
name: str,
side: float,
material: bpy.types.Material,
bone_name: str,
*,
variant: str = "main",
) -> bpy.types.Object:
# Extruded hook polygon. Forward is Blender -Y; mirrored across X.
x_center = side * 1.42
thickness = {"main": 0.095, "edge": 0.104, "scar": 0.108}[variant]
if variant == "edge":
yz = [
(-0.72, 2.78),
(-1.34, 2.55),
(-2.62, 1.72),
(-2.42, 2.06),
(-1.31, 2.78),
]
elif variant == "scar":
yz = [
(-0.82, 2.94),
(-1.33, 2.72),
(-2.30, 2.02),
(-2.10, 2.33),
(-1.33, 2.91),
]
else:
yz = [
(-0.50, 3.12),
(-0.72, 2.78),
(-1.34, 2.55),
(-2.62, 1.72),
(-2.31, 2.28),
(-1.45, 3.18),
(-0.82, 3.34),
]
vertices = []
for x in (x_center - thickness, x_center + thickness):
vertices.extend((x, y, z) for y, z in yz)
count = len(yz)
faces = [tuple(range(count)), tuple(range(count, count * 2))[::-1]]
for index in range(count):
nxt = (index + 1) % count
faces.append((index, nxt, count + nxt, count + index))
mesh = bpy.data.meshes.new(f"{name}_Mesh")
mesh.from_pydata(vertices, [], faces)
mesh.validate()
mesh.update()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
return finish_mesh(obj, name, material, bone_name, smooth=False)
def add_plate(
name: str,
location: tuple[float, float, float],
scale: tuple[float, float, float],
rotation: tuple[float, float, float],
material: bpy.types.Material,
bone_name: str,
) -> bpy.types.Object:
bpy.ops.mesh.primitive_cone_add(vertices=6, radius1=1.0, radius2=0.72, depth=0.46, location=location, rotation=rotation)
obj = bpy.context.object
obj.scale = scale
return finish_mesh(obj, name, material, bone_name, smooth=False)
def create_armature() -> bpy.types.Object:
arm_data = bpy.data.armatures.new("EmberMantis_Rig")
armature = bpy.data.objects.new("EmberMantis_Rig", arm_data)
bpy.context.collection.objects.link(armature)
bpy.context.view_layer.objects.active = armature
armature.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
bones = {
"Root": ((0, 0, 0), (0, 0, 0.55), None),
"Pelvis": ((0, 0, 1.70), (0, 0, 2.35), "Root"),
"Abdomen": ((0, 0.05, 2.15), (0, 0.42, 2.78), "Pelvis"),
"Tail": ((0, 0.38, 2.58), (0, 1.30, 2.18), "Abdomen"),
"Chest": ((0, 0.02, 2.65), (0, -0.04, 3.48), "Abdomen"),
"Neck": ((0, -0.03, 3.38), (0, -0.14, 3.88), "Chest"),
"Head": ((0, -0.13, 3.78), (0, -0.34, 4.35), "Neck"),
"UpperArm.L": ((-0.34, -0.02, 3.30), (-0.98, -0.27, 3.20), "Chest"),
"Forearm.L": ((-0.98, -0.27, 3.20), (-1.38, -0.60, 3.02), "UpperArm.L"),
"Blade.L": ((-1.38, -0.60, 3.02), (-1.46, -1.48, 2.55), "Forearm.L"),
"UpperArm.R": ((0.34, -0.02, 3.30), (0.98, -0.27, 3.20), "Chest"),
"Forearm.R": ((0.98, -0.27, 3.20), (1.38, -0.60, 3.02), "UpperArm.R"),
"Blade.R": ((1.38, -0.60, 3.02), (1.46, -1.48, 2.55), "Forearm.R"),
"Thigh.L": ((-0.30, 0.02, 2.00), (-0.66, 0.02, 1.18), "Pelvis"),
"Shin.L": ((-0.66, 0.02, 1.18), (-0.76, -0.34, 0.38), "Thigh.L"),
"Foot.L": ((-0.76, -0.34, 0.38), (-0.76, -0.96, 0.15), "Shin.L"),
"Thigh.R": ((0.30, 0.02, 2.00), (0.66, 0.02, 1.18), "Pelvis"),
"Shin.R": ((0.66, 0.02, 1.18), (0.76, -0.34, 0.38), "Thigh.R"),
"Foot.R": ((0.76, -0.34, 0.38), (0.76, -0.96, 0.15), "Shin.R"),
}
for name, (head, tail, parent) in bones.items():
bone = arm_data.edit_bones.new(name)
bone.head = head
bone.tail = tail
if parent:
bone.parent = arm_data.edit_bones[parent]
bpy.ops.object.mode_set(mode="POSE")
for pose_bone in armature.pose.bones:
pose_bone.rotation_mode = "XYZ"
bpy.ops.object.mode_set(mode="OBJECT")
armature.show_in_front = True
return armature
def create_model(armature: bpy.types.Object, mats: dict[str, bpy.types.Material]) -> bpy.types.Object:
chitin = mats["chitin"]
crimson = mats["crimson"]
deep_red = mats["deep_red"]
ember = mats["ember"]
bone = mats["bone"]
eye = mats["eye"]
# Core insect anatomy.
add_ellipsoid("Thorax", (0, 0.00, 3.05), (0.54, 0.47, 0.72), chitin, "Chest")
add_ellipsoid("ChestArmor", (0, -0.35, 3.18), (0.47, 0.18, 0.57), crimson, "Chest")
add_ellipsoid("Pelvis", (0, 0.08, 2.13), (0.48, 0.42, 0.55), deep_red, "Pelvis")
add_ellipsoid("Abdomen", (0, 0.52, 2.34), (0.58, 0.82, 0.49), chitin, "Abdomen")
add_ellipsoid("TailMass", (0, 1.18, 2.10), (0.56, 0.92, 0.40), deep_red, "Tail")
add_cone_between("TailTip", (0, 1.44, 2.13), (0, 2.20, 1.88), 0.50, 0.06, crimson, "Tail", vertices=7)
# Overlapping abdominal armor plates.
for index, y in enumerate((0.33, 0.72, 1.10, 1.47)):
scale = 0.52 - index * 0.055
add_plate(
f"AbdomenPlate_{index + 1}",
(0, y, 2.50 - index * 0.10),
(scale, 0.42, 0.20),
(math.radians(83), 0, 0),
crimson if index % 2 == 0 else deep_red,
"Abdomen" if index < 2 else "Tail",
)
for side in (-1, 1):
add_cone_between(
f"AbdomenSpike_{index}_{side:+d}",
(side * scale * 0.72, y, 2.48 - index * 0.10),
(side * (scale + 0.38), y + 0.08, 2.34 - index * 0.12),
0.10,
0.0,
crimson,
"Abdomen" if index < 2 else "Tail",
vertices=6,
)
# Neck, predatory head, jaws, eyes, crown spikes.
add_cone_between("NeckCore", (0, -0.03, 3.40), (0, -0.20, 3.88), 0.30, 0.25, chitin, "Neck")
add_ellipsoid("Head", (0, -0.34, 4.10), (0.43, 0.50, 0.38), crimson, "Head")
add_ellipsoid("FaceMask", (0, -0.73, 4.05), (0.32, 0.19, 0.28), chitin, "Head")
for side in (-1, 1):
suffix = "L" if side < 0 else "R"
add_ellipsoid(f"Eye_{suffix}", (side * 0.30, -0.67, 4.16), (0.09, 0.045, 0.075), eye, "Head", subdivisions=1)
add_cone_between(f"Mandible_{suffix}", (side * 0.18, -0.70, 3.98), (side * 0.34, -1.05, 3.83), 0.10, 0.02, bone, "Head", vertices=6)
add_cone_between(f"AntennaBase_{suffix}", (side * 0.22, -0.33, 4.34), (side * 0.46, -0.45, 4.82), 0.055, 0.035, deep_red, "Head", vertices=6)
add_cone_between(f"AntennaTip_{suffix}", (side * 0.46, -0.45, 4.82), (side * 0.76, -0.88, 5.20), 0.04, 0.0, ember, "Head", vertices=6)
add_cone_between(f"CrownSpike_{suffix}", (side * 0.23, -0.05, 4.31), (side * 0.48, 0.13, 4.90), 0.12, 0.0, crimson, "Head", vertices=6)
add_cone_between("CrownSpike_Center", (0, 0.02, 4.34), (0, 0.32, 5.02), 0.13, 0.0, crimson, "Head", vertices=6)
# Scythe arms: dark joints, crimson armor, pale blades, emissive blade scars.
for side in (-1, 1):
suffix = "L" if side < 0 else "R"
bones = (f"UpperArm.{suffix}", f"Forearm.{suffix}", f"Blade.{suffix}")
add_cone_between(
f"UpperArm_{suffix}",
(side * 0.34, -0.02, 3.30),
(side * 0.98, -0.27, 3.20),
0.25,
0.17,
chitin,
bones[0],
)
add_plate(
f"ShoulderPlate_{suffix}",
(side * 0.55, -0.07, 3.42),
(0.27, 0.33, 0.24),
(0, math.radians(side * 18), math.radians(side * 12)),
crimson,
bones[0],
)
add_cone_between(
f"Forearm_{suffix}",
(side * 0.98, -0.27, 3.20),
(side * 1.40, -0.64, 3.02),
0.19,
0.13,
deep_red,
bones[1],
)
add_ellipsoid(f"BladeJoint_{suffix}", (side * 1.41, -0.62, 3.02), (0.22, 0.24, 0.22), crimson, bones[2], subdivisions=1)
add_blade(f"ScytheBlade_{suffix}", side, deep_red, bones[2])
add_blade(f"ScytheBoneEdge_{suffix}", side, bone, bones[2], variant="edge")
add_blade(f"ScytheEmberScar_{suffix}", side, ember, bones[2], variant="scar")
add_cone_between(
f"ElbowSpike_{suffix}",
(side * 1.00, -0.22, 3.30),
(side * 1.28, 0.04, 3.55),
0.09,
0.0,
crimson,
bones[1],
vertices=6,
)
# Digitigrade legs and hooked feet.
for side in (-1, 1):
suffix = "L" if side < 0 else "R"
thigh_bone, shin_bone, foot_bone = f"Thigh.{suffix}", f"Shin.{suffix}", f"Foot.{suffix}"
add_cone_between(f"Thigh_{suffix}", (side * 0.30, 0.02, 2.00), (side * 0.66, 0.02, 1.18), 0.25, 0.18, chitin, thigh_bone)
add_plate(
f"ThighPlate_{suffix}",
(side * 0.47, -0.02, 1.72),
(0.28, 0.31, 0.34),
(0, math.radians(side * 12), 0),
crimson,
thigh_bone,
)
add_cone_between(f"Shin_{suffix}", (side * 0.66, 0.02, 1.18), (side * 0.76, -0.34, 0.38), 0.18, 0.11, deep_red, shin_bone)
add_cone_between(f"Foot_{suffix}", (side * 0.76, -0.34, 0.38), (side * 0.76, -0.96, 0.15), 0.14, 0.08, chitin, foot_bone)
for toe_index, toe_x in enumerate((-0.13, 0.0, 0.13)):
add_cone_between(
f"Toe_{suffix}_{toe_index + 1}",
(side * 0.76 + toe_x, -0.88, 0.15),
(side * 0.76 + toe_x * 1.35, -1.25, 0.07),
0.055,
0.0,
bone,
foot_bone,
vertices=5,
)
add_cone_between(
f"KneeSpike_{suffix}",
(side * 0.68, 0.00, 1.20),
(side * 0.95, 0.28, 1.25),
0.09,
0.0,
crimson,
shin_bone,
vertices=6,
)
# Ember fissures: limited emissive geometry gives strong read without textures.
fissures = [
("ChestFissure", (-0.05, -0.535, 3.48), (0.12, -0.535, 2.92), "Chest"),
("AbdomenFissure", (-0.07, -0.27, 2.47), (0.15, -0.31, 2.11), "Abdomen"),
]
for name, start, end, group in fissures:
add_cone_between(name, start, end, 0.035, 0.02, ember, group, vertices=5)
# Join all render meshes into one skinned mesh; keep four material primitives.
bpy.ops.object.select_all(action="DESELECT")
for obj in PARTS:
obj.select_set(True)
bpy.context.view_layer.objects.active = PARTS[0]
bpy.ops.object.join()
body = bpy.context.object
body.name = "EmberMantis_Body"
body.data.name = "EmberMantis_Body_Mesh"
consolidate_material_slots(body)
modifier = body.modifiers.new("EmberMantis_Armature", "ARMATURE")
modifier.object = armature
modifier.use_deform_preserve_volume = False
body.parent = armature
return body
def consolidate_material_slots(obj: bpy.types.Object) -> None:
old_materials = [slot.material for slot in obj.material_slots]
unique: list[bpy.types.Material] = []
new_index_by_name: dict[str, int] = {}
old_to_new: dict[int, int] = {}
for old_index, material in enumerate(old_materials):
if material.name not in new_index_by_name:
new_index_by_name[material.name] = len(unique)
unique.append(material)
old_to_new[old_index] = new_index_by_name[material.name]
material_groups = {
group.index: int(group.name.removeprefix("__MAT_"))
for group in obj.vertex_groups
if group.name.startswith("__MAT_")
}
face_material_ids: list[int] = []
for polygon in obj.data.polygons:
vertex = obj.data.vertices[polygon.vertices[0]]
face_material_ids.append(next(
material_groups[assignment.group]
for assignment in vertex.groups
if assignment.group in material_groups
))
obj.data.materials.clear()
for material in unique:
obj.data.materials.append(material)
for polygon, material_id in zip(obj.data.polygons, face_material_ids, strict=True):
polygon.material_index = material_id
for group in [group for group in obj.vertex_groups if group.name.startswith("__MAT_")]:
obj.vertex_groups.remove(group)
def reset_pose(armature: bpy.types.Object) -> None:
for bone in armature.pose.bones:
bone.location = (0, 0, 0)
bone.rotation_euler = (0, 0, 0)
bone.scale = (1, 1, 1)
def key_pose(
armature: bpy.types.Object,
frame: int,
rotations: dict[str, tuple[float, float, float]] | None = None,
locations: dict[str, tuple[float, float, float]] | None = None,
scales: dict[str, tuple[float, float, float]] | None = None,
) -> None:
reset_pose(armature)
for name, value in (rotations or {}).items():
armature.pose.bones[name].rotation_euler = tuple(math.radians(component) for component in value)
for name, value in (locations or {}).items():
armature.pose.bones[name].location = value
for name, value in (scales or {}).items():
armature.pose.bones[name].scale = value
for bone in armature.pose.bones:
bone.keyframe_insert("location", frame=frame, group=bone.name)
bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name)
bone.keyframe_insert("scale", frame=frame, group=bone.name)
def build_action(
armature: bpy.types.Object,
name: str,
end_frame: int,
poses: list[dict],
*,
loop: bool = False,
) -> bpy.types.Action:
action = bpy.data.actions.new(name)
action.use_fake_user = True
action.use_frame_range = True
action.frame_start = 1
action.frame_end = end_frame
action.use_cyclic = loop
armature.animation_data.action = action
for pose in poses:
key_pose(armature, **pose)
armature.animation_data.action = None
return action
def create_animations(armature: bpy.types.Object) -> dict[str, dict]:
armature.animation_data_create()
clips: dict[str, dict] = {}
def add(name: str, frames: int, poses: list[dict], loop: bool = False) -> None:
build_action(armature, name, frames, poses, loop=loop)
clips[name] = {"frames": frames, "seconds": round(frames / FPS, 3), "loop": loop}
add(
"Idle",
60,
[
{"frame": 1, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}},
{"frame": 16, "locations": {"Root": (0, 0, 0.045)}, "rotations": {"Chest": (-2, 0, 2), "Abdomen": (3, 0, -2), "Head": (-2, 0, -3), "Blade.L": (2, 0, 2), "Blade.R": (-2, 0, -2)}},
{"frame": 31, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}},
{"frame": 46, "locations": {"Root": (0, 0, 0.045)}, "rotations": {"Chest": (-2, 0, 2), "Abdomen": (3, 0, -2), "Head": (-2, 0, -3), "Blade.L": (2, 0, 2), "Blade.R": (-2, 0, -2)}},
{"frame": 60, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}},
],
loop=True,
)
walk_a = {"Thigh.L": (-24, 0, 4), "Shin.L": (18, 0, 0), "Foot.L": (-8, 0, 0), "Thigh.R": (22, 0, -4), "Shin.R": (-12, 0, 0), "Foot.R": (8, 0, 0), "UpperArm.L": (5, 0, 2), "UpperArm.R": (-5, 0, -2)}
walk_b = {name: tuple(-component for component in rotation) for name, rotation in walk_a.items()}
add(
"Walk",
30,
[
{"frame": 1, "rotations": walk_a},
{"frame": 8, "locations": {"Root": (0, 0, 0.055)}, "rotations": {"Chest": (-3, 0, 0), "Abdomen": (4, 0, 0)}},
{"frame": 16, "rotations": walk_b},
{"frame": 23, "locations": {"Root": (0, 0, 0.055)}, "rotations": {"Chest": (-3, 0, 0), "Abdomen": (4, 0, 0)}},
{"frame": 30, "rotations": walk_a},
],
loop=True,
)
add(
"Sidestep",
18,
[
{"frame": 1, "rotations": {"Chest": (0, 0, 0)}},
{"frame": 5, "locations": {"Root": (-0.10, 0, -0.03)}, "rotations": {"Chest": (0, -8, -12), "Thigh.L": (8, 0, 15), "Thigh.R": (-10, 0, 16), "Blade.L": (0, 0, 8), "Blade.R": (0, 0, 12)}},
{"frame": 11, "locations": {"Root": (0.12, 0, 0.04)}, "rotations": {"Chest": (0, 7, 10), "Thigh.L": (-8, 0, -12), "Thigh.R": (10, 0, -15)}},
{"frame": 18, "rotations": {"Chest": (0, 0, 0)}},
],
)
add(
"Melee",
24,
[
{"frame": 1},
{"frame": 7, "rotations": {"Chest": (0, 0, 7), "UpperArm.R": (-12, 18, 25), "Forearm.R": (10, 0, 38), "Blade.R": (-16, 5, 42), "UpperArm.L": (5, 0, -8)}},
{"frame": 11, "locations": {"Root": (0, -0.08, 0)}, "rotations": {"Chest": (9, 0, -12), "UpperArm.R": (18, -12, -52), "Forearm.R": (-24, 0, -62), "Blade.R": (30, 0, -58), "Head": (-8, 0, 0)}},
{"frame": 16, "rotations": {"UpperArm.R": (6, 0, -20), "Forearm.R": (-10, 0, -25)}},
{"frame": 24},
],
)
add(
"LineSlash",
32,
[
{"frame": 1},
{"frame": 10, "locations": {"Root": (0, 0.05, -0.03)}, "rotations": {"Chest": (-7, 0, 10), "Head": (7, 0, -5), "UpperArm.L": (-15, 12, -30), "Forearm.L": (0, 0, -46), "Blade.L": (-20, 0, -35), "UpperArm.R": (8, 0, 8)}},
{"frame": 15, "locations": {"Root": (0, -0.12, 0.03)}, "rotations": {"Chest": (12, 0, -16), "UpperArm.L": (24, -10, 58), "Forearm.L": (-18, 0, 74), "Blade.L": (34, 0, 70), "Head": (-9, 0, 5)}},
{"frame": 21, "rotations": {"Chest": (5, 0, -6), "UpperArm.L": (8, 0, 20), "Forearm.L": (-5, 0, 28), "Blade.L": (10, 0, 20)}},
{"frame": 32},
],
)
add(
"CrossSlash",
38,
[
{"frame": 1},
{"frame": 11, "locations": {"Root": (0, 0.04, -0.05)}, "rotations": {"Chest": (-9, 0, 0), "UpperArm.L": (-8, 18, 38), "Forearm.L": (10, 0, 52), "Blade.L": (-15, 0, 32), "UpperArm.R": (-8, -18, -38), "Forearm.R": (10, 0, -52), "Blade.R": (-15, 0, -32)}},
{"frame": 16, "locations": {"Root": (0, -0.10, 0.08)}, "rotations": {"Chest": (14, 0, 0), "UpperArm.L": (22, -14, -52), "Forearm.L": (-18, 0, -72), "Blade.L": (28, 0, -64), "UpperArm.R": (22, 14, 52), "Forearm.R": (-18, 0, 72), "Blade.R": (28, 0, 64), "Head": (-10, 0, 0)}},
{"frame": 21, "locations": {"Root": (0, -0.04, 0.02)}, "rotations": {"Chest": (5, 0, 0), "UpperArm.L": (8, 0, -22), "Forearm.L": (-6, 0, -30), "UpperArm.R": (8, 0, 22), "Forearm.R": (-6, 0, 30)}},
{"frame": 38},
],
)
add(
"Recover",
22,
[
{"frame": 1, "locations": {"Root": (0, -0.04, -0.05)}, "rotations": {"Chest": (14, 0, 0), "Head": (-9, 0, 0), "UpperArm.L": (12, 0, -18), "UpperArm.R": (12, 0, 18)}},
{"frame": 8, "rotations": {"Chest": (-4, 0, 0), "Abdomen": (6, 0, 0), "Head": (4, 0, 0)}},
{"frame": 15, "rotations": {"Chest": (2, 0, 0), "Abdomen": (-2, 0, 0)}},
{"frame": 22},
],
)
add(
"Stagger",
28,
[
{"frame": 1},
{"frame": 4, "locations": {"Root": (0, 0.10, -0.07)}, "rotations": {"Chest": (-18, 0, -10), "Head": (24, 0, 12), "UpperArm.L": (-16, 0, -22), "UpperArm.R": (-16, 0, 22), "Thigh.L": (12, 0, 0), "Thigh.R": (12, 0, 0)}},
{"frame": 10, "rotations": {"Chest": (8, 0, 6), "Head": (-12, 0, -8), "Blade.L": (14, 0, 0), "Blade.R": (14, 0, 0)}},
{"frame": 18, "rotations": {"Chest": (-3, 0, -2), "Head": (4, 0, 2)}},
{"frame": 28},
],
)
add(
"Death",
72,
[
{"frame": 1},
{"frame": 12, "locations": {"Root": (0, 0.08, -0.10)}, "rotations": {"Chest": (-20, 0, 9), "Head": (18, 0, -12), "UpperArm.L": (-20, 0, -25), "UpperArm.R": (-20, 0, 25), "Thigh.L": (14, 0, 0), "Thigh.R": (14, 0, 0)}},
{"frame": 28, "locations": {"Root": (0.10, 0.20, -0.55)}, "rotations": {"Root": (0, 38, 74), "Chest": (-34, 8, -18), "Head": (28, 0, 15), "UpperArm.L": (30, 0, -48), "UpperArm.R": (-12, 0, 48), "Thigh.L": (28, 0, 22), "Thigh.R": (-14, 0, -18)}},
{"frame": 48, "locations": {"Root": (0.18, 0.20, -1.12)}, "rotations": {"Root": (0, 52, 88), "Chest": (-24, 12, -22), "Head": (38, 0, 22), "UpperArm.L": (42, 0, -62), "UpperArm.R": (8, 0, 58), "Thigh.L": (36, 0, 28), "Thigh.R": (-20, 0, -22)}},
{"frame": 72, "locations": {"Root": (0.18, 0.20, -1.18)}, "rotations": {"Root": (0, 52, 88), "Chest": (-24, 12, -22), "Head": (42, 0, 25), "UpperArm.L": (44, 0, -64), "UpperArm.R": (10, 0, 60), "Thigh.L": (38, 0, 28), "Thigh.R": (-20, 0, -22)}},
],
)
return clips
def create_collision_meshes() -> list[bpy.types.Object]:
collision_material = make_material("CollisionProxy", (0.05, 0.8, 0.2, 0.35), roughness=1.0)
collision_parts: list[bpy.types.Object] = []
specs = [
("COLLISION_Body", (0, 0.18, 2.55), (0.72, 1.12, 1.55)),
("COLLISION_Head", (0, -0.38, 4.08), (0.52, 0.62, 0.48)),
("COLLISION_ScytheReach", (0, -1.35, 2.66), (1.82, 1.18, 0.84)),
]
for name, location, scale in specs:
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=1, location=location)
obj = bpy.context.object
obj.name = name
obj.scale = scale
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(collision_material)
collision_parts.append(obj)
return collision_parts
def select_only(objects: list[bpy.types.Object]) -> None:
bpy.ops.object.select_all(action="DESELECT")
for obj in objects:
obj.hide_set(False)
obj.select_set(True)
if objects:
bpy.context.view_layer.objects.active = objects[0]
def export_glbs(armature: bpy.types.Object, body: bpy.types.Object, collision_parts: list[bpy.types.Object]) -> None:
select_only([armature, body])
bpy.ops.export_scene.gltf(
filepath=str(GLB_PATH),
export_format="GLB",
use_selection=True,
export_animations=True,
export_animation_mode="ACTIONS",
export_frame_range=True,
export_skins=True,
export_morph=False,
export_yup=True,
export_apply=False,
export_cameras=False,
export_lights=False,
)
select_only(collision_parts)
bpy.ops.export_scene.gltf(
filepath=str(COLLISION_PATH),
export_format="GLB",
use_selection=True,
export_animations=False,
export_skins=False,
export_materials="NONE",
export_yup=True,
export_apply=True,
)
for obj in collision_parts:
obj.hide_render = True
obj.hide_set(True)
def look_at(obj: bpy.types.Object, target: tuple[float, float, float]) -> None:
direction = Vector(target) - obj.location
obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def setup_preview_scene() -> tuple[bpy.types.Object, list[bpy.types.Object]]:
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = 900
scene.render.resolution_y = 900
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.film_transparent = False
scene.render.fps = FPS
scene.world.color = (0.006, 0.008, 0.014)
world_nodes = scene.world.node_tree.nodes if scene.world.use_nodes else None
if not scene.world.use_nodes:
scene.world.use_nodes = True
world_nodes = scene.world.node_tree.nodes
world_nodes["Background"].inputs["Color"].default_value = (0.006, 0.009, 0.018, 1)
world_nodes["Background"].inputs["Strength"].default_value = 0.28
camera_data = bpy.data.cameras.new("PreviewCamera")
camera = bpy.data.objects.new("PreviewCamera", camera_data)
bpy.context.collection.objects.link(camera)
camera_data.lens = 58
scene.camera = camera
lights: list[bpy.types.Object] = []
light_specs = [
("Key", "AREA", (4.5, -6.5, 7.5), (1.0, 0.30, 0.10), 1150, 5.0),
("Fill", "AREA", (-5.5, -3.0, 4.5), (0.10, 0.25, 1.0), 850, 4.0),
("Rim", "AREA", (1.0, 5.5, 6.5), (1.0, 0.08, 0.02), 1300, 3.0),
]
for name, kind, location, color, energy, size in light_specs:
data = bpy.data.lights.new(name, type=kind)
data.energy = energy
data.color = color
data.shape = "DISK"
data.size = size
light = bpy.data.objects.new(name, data)
light.location = location
bpy.context.collection.objects.link(light)
look_at(light, (0, 0, 2.5))
lights.append(light)
floor_mat = make_material("PreviewFloor", (0.018, 0.022, 0.032, 1), metallic=0.05, roughness=0.82)
bpy.ops.mesh.primitive_plane_add(size=30, location=(0, 0, 0))
floor = bpy.context.object
floor.name = "PreviewFloor"
floor.data.materials.append(floor_mat)
return camera, lights + [floor]
def activate_action(armature: bpy.types.Object, name: str, frame: int) -> None:
armature.animation_data.action = bpy.data.actions[name]
bpy.context.scene.frame_set(frame)
def render_previews(armature: bpy.types.Object) -> None:
camera, _studio = setup_preview_scene()
views = [
("ember_mantis_three_quarter.png", (7.4, -10.5, 5.8), "Idle", 16, 58),
("ember_mantis_front.png", (0.0, -12.5, 4.2), "Idle", 16, 62),
("ember_mantis_side.png", (10.8, -0.2, 4.3), "Idle", 16, 60),
("ember_mantis_cross_slash.png", (7.4, -10.5, 5.8), "CrossSlash", 16, 58),
]
for filename, camera_position, action_name, frame, lens in views:
activate_action(armature, action_name, frame)
camera.location = camera_position
camera.data.lens = lens
look_at(camera, (0, -0.05, 2.55))
bpy.context.scene.render.filepath = str(PREVIEW_DIR / filename)
bpy.ops.render.render(write_still=True)
armature.animation_data.action = None
bpy.context.scene.frame_set(1)
def write_metadata(body: bpy.types.Object, clips: dict[str, dict]) -> None:
triangles = sum(len(poly.vertices) - 2 for poly in body.data.polygons)
metadata = {
"name": "Ember Mantis Duelist",
"assetId": "ember-mantis-duelist",
"license": "Original project asset",
"sourceConcept": "IWT2 Ember Mantis Duelist side-cel concept",
"authoringTool": bpy.app.version_string,
"format": "glTF 2.0 binary (GLB)",
"coordinateConvention": {"up": "+Y after glTF export", "blenderForward": "-Y", "units": "meters"},
"runtime": {
"renderMesh": "EmberMantis_Body",
"armature": "EmberMantis_Rig",
"collisionAsset": COLLISION_PATH.name,
"materials": [material.name for material in body.data.materials],
"trianglesApprox": triangles,
},
"animations": clips,
}
METADATA_PATH.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
reset_scene()
mats = {
"chitin": make_material("M_Chitin", (0.025, 0.022, 0.026, 1), metallic=0.18, roughness=0.42),
"crimson": make_material("M_CrimsonArmor", (0.34, 0.025, 0.018, 1), metallic=0.22, roughness=0.36),
"deep_red": make_material("M_DeepRed", (0.13, 0.012, 0.012, 1), metallic=0.15, roughness=0.48),
"bone": make_material("M_BoneBlade", (0.62, 0.45, 0.26, 1), metallic=0.12, roughness=0.28),
"ember": make_material(
"M_EmberGlow",
(1.0, 0.075, 0.004, 1),
roughness=0.2,
emission=(1.0, 0.028, 0.0, 1),
emission_strength=8.0,
),
"eye": make_material(
"M_EyeGlow",
(1.0, 0.33, 0.01, 1),
roughness=0.16,
emission=(1.0, 0.14, 0.0, 1),
emission_strength=12.0,
),
}
RUNTIME_MATERIALS.extend(
[mats["chitin"], mats["crimson"], mats["deep_red"], mats["eye"], mats["bone"], mats["ember"]]
)
armature = create_armature()
body = create_model(armature, mats)
clips = create_animations(armature)
collision_parts = create_collision_meshes()
export_glbs(armature, body, collision_parts)
write_metadata(body, clips)
render_previews(armature)
bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH))
print(f"BUILT={BLEND_PATH}")
print(f"EXPORTED={GLB_PATH}")
print(f"COLLISION={COLLISION_PATH}")
print(f"ANIMATIONS={','.join(clips)}")
if __name__ == "__main__":
main()
+37 -10
View File
@@ -28,7 +28,10 @@ GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
GITEA_OWNER = "phenom" GITEA_OWNER = "phenom"
GITEA_REPO = "i-want-to-heal-mmo" GITEA_REPO = "i-want-to-heal-mmo"
BRANCH = "main" BRANCH = "main"
TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal/app") TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal-mmo/app")
TRUENAS_GITEA_REPO = Path(
"/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git"
)
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
@@ -256,8 +259,7 @@ def ensure_tag(version: str, commit: str, message: str) -> str:
def gitea_token() -> str: def gitea_token() -> str:
token = "ed2db3fd54546e9658377d0551b3fc3961583f1d" token = os.environ.get("GITEA_TOKEN", GITEA_TOKEN).strip()
if not token: if not token:
raise SystemExit( raise SystemExit(
"GITEA_TOKEN is required to create the release and upload the APK. " "GITEA_TOKEN is required to create the release and upload the APK. "
@@ -402,21 +404,46 @@ def upload_apk(release: dict[str, object], apk: Path, token: str) -> None:
def update_truenas() -> None: def update_truenas() -> None:
if (TRUENAS_PATH / ".git").is_dir(): if (TRUENAS_PATH / ".git").is_dir():
origin = capture(["git", "remote", "get-url", "origin"], cwd=TRUENAS_PATH) origin = capture(["git", "remote", "get-url", "origin"], cwd=TRUENAS_PATH)
if normalized_remote(origin) != normalized_remote(GITEA_REMOTE): accepted_origins = {
normalized_remote(GITEA_REMOTE),
normalized_remote(str(TRUENAS_GITEA_REPO)),
}
if normalized_remote(origin) not in accepted_origins:
raise SystemExit( raise SystemExit(
"Refusing to update TrueNAS from its old repository origin " "Refusing to update TrueNAS from its old repository origin "
f"{origin!r}. Replace that checkout with {GITEA_REMOTE!r} first." f"{origin!r}. Replace that checkout with this game's repository first."
) )
if TRUENAS_GITEA_REPO.is_dir():
run(
[
"git",
"-c",
f"safe.directory={TRUENAS_GITEA_REPO}",
"pull",
"--ff-only",
str(TRUENAS_GITEA_REPO),
BRANCH,
],
cwd=TRUENAS_PATH,
)
print(f"TrueNAS source updated from local Gitea storage: {TRUENAS_GITEA_REPO}")
else:
run(["git", "pull", "--ff-only", "origin", BRANCH], cwd=TRUENAS_PATH) run(["git", "pull", "--ff-only", "origin", BRANCH], cwd=TRUENAS_PATH)
print("TrueNAS source updated. Restart the iwanttoheal app in the TrueNAS UI.") print("TrueNAS source updated from its configured origin.")
print("Restart the iwanttoheal-mmo app in the TrueNAS UI.")
return return
print("New TrueNAS app clone is not mounted on this machine.") print("New TrueNAS app clone is not mounted on this machine.")
print(f"Move or remove the old app directory at {TRUENAS_PATH}, then run:") print("Run these commands in the TrueNAS shell:")
print(f" git clone {GITEA_REMOTE} {TRUENAS_PATH}") print(f" sudo git config --global --add safe.directory {TRUENAS_GITEA_REPO}")
print(f" sudo git clone {TRUENAS_GITEA_REPO} {TRUENAS_PATH}")
print(f" sudo chown -R truenas_admin:truenas_admin {TRUENAS_PATH.parent}")
print("For later releases:") print("For later releases:")
print(f" git -C {TRUENAS_PATH} pull --ff-only origin {BRANCH}") print(
print("Then restart the iwanttoheal app in the TrueNAS UI.") f" git -C {TRUENAS_PATH} pull --ff-only "
f"{TRUENAS_GITEA_REPO} {BRANCH}"
)
print("Then restart the iwanttoheal-mmo app in the TrueNAS UI.")
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
+10
View File
@@ -6,6 +6,8 @@ import { useActiveHunter, useFrontendStore } from "./frontend/store";
import { useGameStore } from "./game/store"; import { useGameStore } from "./game/store";
import type { BossId } from "./game/types"; import type { BossId } from "./game/types";
import { useActionBindings, useGameLoop } from "./game/useGameLoop"; import { useActionBindings, useGameLoop } from "./game/useGameLoop";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { DUAL_SCREEN_LAUNCH_EVENT } from "./platform/dualScreenSync";
const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen }))); const TopScreen = lazy(() => import("./components/TopScreen").then((module) => ({ default: module.TopScreen })));
const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen }))); const BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
@@ -20,6 +22,8 @@ function GameLoadingScreen() {
} }
export default function App() { export default function App() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
useGameLoop(); useGameLoop();
const screen = useFrontendStore((state) => state.screen); const screen = useFrontendStore((state) => state.screen);
const hunter = useActiveHunter(); const hunter = useActiveHunter();
@@ -45,6 +49,12 @@ export default function App() {
navigate("game"); navigate("game");
}, [hunter, navigate, touchActiveSave]); }, [hunter, navigate, touchActiveSave]);
useEffect(() => {
const onDualScreenLaunch = (event: Event) => launchGame((event as CustomEvent<readonly BossId[]>).detail);
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
}, [launchGame]);
useActionBindings(screen === "game", leaveGame); useActionBindings(screen === "game", leaveGame);
useEffect(() => { useEffect(() => {
+10 -11
View File
@@ -1,34 +1,33 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting"; import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { subscribeControllerToken } from "../input/controller";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) { export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
const [activeSurface, setActiveSurface] = useState<DisplaySurface>("top"); const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() =>
new URLSearchParams(window.location.search).get("display") === "bottom" ? "bottom" : "top"
);
const activeSurfaceRef = useRef(activeSurface); const activeSurfaceRef = useRef(activeSurface);
activeSurfaceRef.current = activeSurface; activeSurfaceRef.current = activeSurface;
useEffect(() => { useEffect(() => {
if (!document.documentElement.classList.contains("native-platform")) return; if (!document.documentElement.classList.contains("native-platform")) return;
let frame = 0; const dedicatedSurface = new URLSearchParams(window.location.search).has("display");
let selectHeld = false; if (dedicatedSurface) return;
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top"); const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface); const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
if (token === "Button8" && !repeat) toggle();
});
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Tab" || event.repeat) return; if (event.key !== "Tab" || event.repeat) return;
event.preventDefault(); event.preventDefault();
toggle(); toggle();
}; };
const poll = () => {
const held = navigator.getGamepads?.()[0]?.buttons[8]?.pressed ?? false;
if (held && !selectHeld) toggle();
selectHeld = held;
frame = requestAnimationFrame(poll);
};
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown);
frame = requestAnimationFrame(poll);
return () => { return () => {
window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keydown", onKeyDown);
unsubscribeSurface(); unsubscribeSurface();
cancelAnimationFrame(frame); unsubscribeController();
}; };
}, []); }, []);
+43 -10
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName, selectRandomBossPair } from "../frontend/data"; import { MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName, selectRandomBossPair } from "../frontend/data";
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository"; import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
import { useActiveHunter, useFrontendStore } from "../frontend/store"; import { useActiveHunter, useFrontendStore } from "../frontend/store";
@@ -51,15 +51,24 @@ function ControllerLegend({ back = false }: { back?: boolean }) {
function LoginScreen() { function LoginScreen() {
const signIn = useFrontendStore((state) => state.signIn); const signIn = useFrontendStore((state) => state.signIn);
const createAccount = useFrontendStore((state) => state.createAccount);
const continueOffline = useFrontendStore((state) => state.continueOffline); const continueOffline = useFrontendStore((state) => state.continueOffline);
const notice = useFrontendStore((state) => state.notice); const notice = useFrontendStore((state) => state.notice);
const [hunterId, setHunterId] = useState("wayfinder"); const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const actions = useMemo<MenuAction[]>(() => [ const actions = useMemo<MenuAction[]>(() => [
{ id: "sign-in", run: () => signIn(hunterId) }, { id: "username", run: () => usernameRef.current?.focus() },
{ id: "password", run: () => passwordRef.current?.focus() },
{ id: "sign-in", run: () => { void signIn(username, password); } },
{ id: "create-account", run: () => { void createAccount(username, password); } },
{ id: "offline", run: continueOffline }, { id: "offline", run: continueOffline },
], [continueOffline, hunterId, signIn]); ], [continueOffline, createAccount, password, signIn, username]);
const controller = useMenuController(actions); const controller = useMenuController(actions);
const submitSignIn = () => { void signIn(username, password); };
return ( return (
<DualDisplayFrame <DualDisplayFrame
top={ top={
@@ -71,17 +80,41 @@ function LoginScreen() {
<h1>Keep everyone standing.</h1> <h1>Keep everyone standing.</h1>
<p>Your save always lives on this device. Sign in only when you want a second copy for PC AYN Thor handoff.</p> <p>Your save always lives on this device. Sign in only when you want a second copy for PC AYN Thor handoff.</p>
</div> </div>
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); signIn(hunterId); }}> <form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
<label htmlFor="hunter-id">Hunter ID</label> <label htmlFor="account-username">Username</label>
<input id="hunter-id" value={hunterId} onChange={(event) => setHunterId(event.target.value)} autoComplete="username" /> <input
ref={usernameRef}
id="account-username"
className={controller.focusedId === "username" ? "is-controller-focused" : ""}
value={username}
onChange={(event) => setUsername(event.target.value)}
onFocus={() => controller.focus("username")}
autoComplete="username"
required
/>
<label htmlFor="account-password">Password</label>
<input
ref={passwordRef}
id="account-password"
className={controller.focusedId === "password" ? "is-controller-focused" : ""}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onFocus={() => controller.focus("password")}
autoComplete="current-password"
required
/>
<FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit"> <FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit">
<span>Sign in & sync</span><small>Online saves enabled</small> <span>Sign in & sync</span><small>Online saves enabled</small>
</FocusButton> </FocusButton>
<FocusButton id="create-account" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
<span>Create account</span><small>Required for first sync</small>
</FocusButton>
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}> <FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue with offline save</span><small>No account required</small> <span>Continue with offline save</span><small>No account required</small>
</FocusButton> </FocusButton>
</form> </form>
{notice && <div className="front-notice">{notice}</div>} {notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
<ControllerLegend /> <ControllerLegend />
</FrontSurface> </FrontSurface>
} }
@@ -92,8 +125,8 @@ function LoginScreen() {
<span className="context-kicker">How saving works</span> <span className="context-kicker">How saving works</span>
<ol> <ol>
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li> <li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
<li><b>02</b><span><strong>Sync when ready</strong><small>Upload any slot after signing in.</small></span></li> <li><b>02</b><span><strong>Create or sign in</strong><small>Username and password unlock online sync.</small></span></li>
<li><b>03</b><span><strong>Move devices</strong><small>Download the online copy and overwrite local.</small></span></li> <li><b>03</b><span><strong>Move devices</strong><small>Sign in, then upload or download an online copy.</small></span></li>
</ol> </ol>
</div> </div>
<div className="device-route"><span>PC</span><i></i><b>ONLINE COPY</b><i></i><span>THOR</span></div> <div className="device-route"><span>PC</span><i></i><b>ONLINE COPY</b><i></i><span>THOR</span></div>
+47 -20
View File
@@ -2,6 +2,7 @@ import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei"; import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "react"; import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import type { MemberId, PulseKind } from "../game/types"; import type { MemberId, PulseKind } from "../game/types";
@@ -9,6 +10,12 @@ import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href; const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
const SPIDER_URL = new URL("../../game_assets/models/downloaded/low-poly-spider/low-poly-spider.glb", import.meta.url).href; const SPIDER_URL = new URL("../../game_assets/models/downloaded/low-poly-spider/low-poly-spider.glb", import.meta.url).href;
const SPIDER_TEXTURE_URLS: Record<string, string> = {
"Spinnen_Bein_tex_2.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_2.jpg", import.meta.url).href,
"SH3.png": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/SH3.png", import.meta.url).href,
"Spinnen_Bein_tex_COLOR_.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_COLOR_.jpg", import.meta.url).href,
"haar_detail_NRM.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/haar_detail_NRM.jpg", import.meta.url).href,
};
const DRAGON_URL = new URL("../../game_assets/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href; const DRAGON_URL = new URL("../../game_assets/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href;
const PARTY_MODEL_URLS: Record<MemberId, string> = { const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href, aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
@@ -41,6 +48,11 @@ const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
orin: "Spellcast_Shoot", orin: "Spellcast_Shoot",
vale: "Dualwield_Melee_Attack_Chop", vale: "Dualwield_Melee_Attack_Chop",
}; };
const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
const angle = (index / 10) * Math.PI * 2;
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
});
const ARENA_TORCH_COLORS = [new THREE.Color("#ff9a4f"), new THREE.Color("#77ddce")] as const;
type GameStoreState = ReturnType<typeof useGameStore.getState>; type GameStoreState = ReturnType<typeof useGameStore.getState>;
function encounterBossAt(state: GameStoreState, bossIndex: number) { function encounterBossAt(state: GameStoreState, bossIndex: number) {
@@ -59,6 +71,13 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string)
return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state); return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
} }
const configureSpiderLoader: NonNullable<Parameters<typeof useGLTF>[3]> = (loader) => {
loader.manager.setURLModifier((url) => {
const fileName = url.slice(url.lastIndexOf("/") + 1);
return SPIDER_TEXTURE_URLS[fileName] ?? url;
});
};
type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death"; type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop"; type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop";
@@ -208,11 +227,24 @@ function PartyCharacterModel({
} }
function Arena() { function Arena() {
const columns = useMemo(() => { const pillarInstances = useRef<THREE.InstancedMesh>(null);
return Array.from({ length: 10 }, (_, index) => { const torchInstances = useRef<THREE.InstancedMesh>(null);
const angle = (index / 10) * Math.PI * 2;
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const; useEffect(() => {
const pillars = pillarInstances.current;
const torches = torchInstances.current;
if (!pillars || !torches) return;
const matrix = new THREE.Matrix4();
ARENA_COLUMNS.forEach(([x, z], index) => {
matrix.makeTranslation(x, 1.1, z - 1);
pillars.setMatrixAt(index, matrix);
matrix.makeTranslation(x, 2.6, z - 1);
torches.setMatrixAt(index, matrix);
torches.setColorAt(index, ARENA_TORCH_COLORS[index % ARENA_TORCH_COLORS.length]);
}); });
pillars.instanceMatrix.needsUpdate = true;
torches.instanceMatrix.needsUpdate = true;
if (torches.instanceColor) torches.instanceColor.needsUpdate = true;
}, []); }, []);
return ( return (
@@ -229,19 +261,16 @@ function Arena() {
<circleGeometry args={[2.1, 48]} /> <circleGeometry args={[2.1, 48]} />
<meshStandardMaterial color="#27342d" roughness={1} /> <meshStandardMaterial color="#27342d" roughness={1} />
</mesh> </mesh>
{columns.map(([x, z], index) => ( <instancedMesh ref={pillarInstances} args={[undefined, undefined, ARENA_COLUMNS.length]} castShadow receiveShadow>
<group key={index} position={[x, 0, z - 1]}>
<mesh castShadow receiveShadow position={[0, 1.1, 0]}>
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} /> <cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
<meshStandardMaterial color="#26342f" roughness={0.8} /> <meshStandardMaterial color="#26342f" roughness={0.8} />
</mesh> </instancedMesh>
<pointLight color={index % 2 ? "#dd7b38" : "#6fc9ba"} intensity={2.2} distance={5} position={[0, 2.7, 0]} /> <instancedMesh ref={torchInstances} args={[undefined, undefined, ARENA_COLUMNS.length]}>
<mesh position={[0, 2.6, 0]}>
<octahedronGeometry args={[0.2, 0]} /> <octahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial color={index % 2 ? "#ff9a4f" : "#77ddce"} /> <meshBasicMaterial />
</mesh> </instancedMesh>
</group> <pointLight color="#dd7b38" intensity={2.2} distance={7} position={[-6, 2.7, -1]} />
))} <pointLight color="#6fc9ba" intensity={2.2} distance={7} position={[6, 2.7, -1]} />
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} /> <gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
</group> </group>
); );
@@ -365,11 +394,9 @@ function PlayerCharacter() {
if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) { if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) {
inputX = Number(keys.current.has("d")) - Number(keys.current.has("a")); inputX = Number(keys.current.has("d")) - Number(keys.current.has("a"));
inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w")); inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w"));
const gamepad = navigator.getGamepads?.()[0]; const controller = getControllerMovement();
if (gamepad) { inputX += controller.x;
inputX += Math.abs(gamepad.axes[0] ?? 0) > 0.18 ? gamepad.axes[0] : 0; inputZ += controller.y;
inputZ += Math.abs(gamepad.axes[1] ?? 0) > 0.18 ? gamepad.axes[1] : 0;
}
} }
const length = Math.hypot(inputX, inputZ); const length = Math.hypot(inputX, inputZ);
if (length > 0.05) { if (length > 0.05) {
@@ -587,7 +614,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0; const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null); const group = useRef<THREE.Group>(null);
const gltf = useGLTF(config.url, false, true); const gltf = useGLTF(config.url, false, true, kind === "vexa" ? configureSpiderLoader : undefined);
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]); const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, model); const { actions } = useAnimations(gltf.animations, model);
const targetPosition = useMemo(() => new THREE.Vector3(), []); const targetPosition = useMemo(() => new THREE.Vector3(), []);
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { StorageAdapter } from "./saveRepository";
import { AccountRepository } from "./accountRepository";
function memoryStorage(): StorageAdapter {
const data = new Map<string, string>();
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => { data.set(key, value); },
};
}
const testHasher = async (password: string, salt: string) => {
const checksum = [...password].reduce((total, character) => total + character.charCodeAt(0), 0);
return `derived:${salt}:${checksum}`;
};
describe("AccountRepository", () => {
it("requires both a username and password", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
await expect(repository.create("", "secret")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
await expect(repository.create("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
await expect(repository.authenticate("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
});
it("requires account creation before sign-in", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: false, reason: "account-not-found" });
await expect(repository.create("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
});
it("rejects an incorrect password and duplicate account names", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
await repository.create("Wayfinder", "correct");
await expect(repository.authenticate("wayfinder", "wrong")).resolves.toEqual({ ok: false, reason: "invalid-password" });
await expect(repository.create(" wayfinder ", "another")).resolves.toEqual({ ok: false, reason: "account-exists" });
});
it("persists only a derived password verifier", async () => {
const storage = memoryStorage();
const repository = new AccountRepository(storage, testHasher, () => "unique-salt");
await repository.create("healer", "plaintext-secret");
const persisted = storage.getItem("i-want-to-heal:accounts:v1") ?? "";
expect(persisted).toContain("derived:unique-salt:");
expect(persisted).not.toContain("plaintext-secret");
expect(JSON.parse(persisted).healer).not.toHaveProperty("password");
});
});
+132
View File
@@ -0,0 +1,132 @@
import type { StorageAdapter } from "./saveRepository";
const ACCOUNTS_KEY = "i-want-to-heal:accounts:v1";
const PASSWORD_ITERATIONS = 120_000;
interface AccountRecord {
username: string;
salt: string;
passwordHash: string;
}
type AccountMap = Record<string, AccountRecord>;
type PasswordHasher = (password: string, salt: string) => Promise<string>;
export type AccountResult =
| { ok: true; username: string }
| { ok: false; reason: "missing-credentials" | "account-exists" | "account-not-found" | "invalid-password" | "storage-unavailable" };
const fallbackMemory = new Map<string, string>();
const fallbackStorage: StorageAdapter = {
getItem: (key) => fallbackMemory.get(key) ?? null,
setItem: (key, value) => { fallbackMemory.set(key, value); },
};
function browserStorage(): StorageAdapter {
try {
if (typeof localStorage !== "undefined") return localStorage;
} catch {
// Android WebView can deny storage before its host is ready.
}
return fallbackStorage;
}
function encodeBytes(bytes: Uint8Array) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function decodeBytes(value: string) {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
async function hashPassword(password: string, salt: string) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits({
name: "PBKDF2",
hash: "SHA-256",
salt: decodeBytes(salt),
iterations: PASSWORD_ITERATIONS,
}, key, 256);
return encodeBytes(new Uint8Array(bits));
}
function randomSalt() {
const salt = new Uint8Array(16);
crypto.getRandomValues(salt);
return encodeBytes(salt);
}
function canonicalUsername(username: string) {
return username.trim().toLocaleLowerCase();
}
function parseAccounts(raw: string | null): AccountMap {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as AccountMap;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
/**
* Local prototype account registry. Passwords are salted and derived before
* persistence; replace this adapter with the server authentication API when
* remote sync leaves local prototype storage.
*/
export class AccountRepository {
constructor(
private readonly storage: StorageAdapter = browserStorage(),
private readonly hasher: PasswordHasher = hashPassword,
private readonly createSalt: () => string = randomSalt,
) {}
async create(usernameInput: string, password: string): Promise<AccountResult> {
const username = usernameInput.trim();
const canonical = canonicalUsername(username);
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
const accounts = this.read();
if (accounts[canonical]) return { ok: false, reason: "account-exists" };
try {
const salt = this.createSalt();
accounts[canonical] = { username, salt, passwordHash: await this.hasher(password, salt) };
this.storage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
return { ok: true, username };
} catch {
return { ok: false, reason: "storage-unavailable" };
}
}
async authenticate(usernameInput: string, password: string): Promise<AccountResult> {
const canonical = canonicalUsername(usernameInput);
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
const account = this.read()[canonical];
if (!account) return { ok: false, reason: "account-not-found" };
try {
const passwordHash = await this.hasher(password, account.salt);
return passwordHash === account.passwordHash
? { ok: true, username: account.username }
: { ok: false, reason: "invalid-password" };
} catch {
return { ok: false, reason: "storage-unavailable" };
}
}
private read() {
return parseAccounts(this.storage.getItem(ACCOUNTS_KEY));
}
}
+84 -5
View File
@@ -1,10 +1,12 @@
import { create } from "zustand"; import { create } from "zustand";
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data"; import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
import { SaveRepository } from "./saveRepository"; import { SaveRepository } from "./saveRepository";
import { AccountRepository, type AccountResult } from "./accountRepository";
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types"; import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
import type { BossId, HealerClassId, InventoryItem } from "../game/types"; import type { BossId, HealerClassId, InventoryItem } from "../game/types";
const repository = new SaveRepository(); const repository = new SaveRepository();
const accounts = new AccountRepository();
const SETTINGS_KEY = "i-want-to-heal:settings:v1"; const SETTINGS_KEY = "i-want-to-heal:settings:v1";
function loadSettings(): GameSettings { function loadSettings(): GameSettings {
@@ -24,7 +26,19 @@ function persistSettings(settings: GameSettings) {
} }
} }
interface FrontendState { function accountNotice(result: Extract<AccountResult, { ok: false }>, action: "sign-in" | "create") {
switch (result.reason) {
case "missing-credentials": return "Enter both username and password.";
case "account-exists": return "Account already exists. Sign in with its password.";
case "account-not-found": return "Account not found. Create an account before enabling online sync.";
case "invalid-password": return "Username or password is incorrect.";
case "storage-unavailable": return action === "create"
? "Account could not be saved on this device. Continue offline or try again."
: "Account could not be verified on this device. Continue offline or try again.";
}
}
export interface FrontendState {
screen: AppScreen; screen: AppScreen;
accountId: string | null; accountId: string | null;
slots: SaveSlotState[]; slots: SaveSlotState[];
@@ -34,7 +48,8 @@ interface FrontendState {
selectedBossId: BossId; selectedBossId: BossId;
settings: GameSettings; settings: GameSettings;
notice: string; notice: string;
signIn: (accountId: string) => void; signIn: (username: string, password: string) => Promise<boolean>;
createAccount: (username: string, password: string) => Promise<boolean>;
continueOffline: () => void; continueOffline: () => void;
signOut: () => void; signOut: () => void;
navigate: (screen: AppScreen) => void; navigate: (screen: AppScreen) => void;
@@ -70,9 +85,23 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
settings: loadSettings(), settings: loadSettings(),
notice: "", notice: "",
signIn: (rawAccountId) => { signIn: async (username, password) => {
const accountId = rawAccountId.trim() || "wayfinder"; const result = await accounts.authenticate(username, password);
set({ accountId, slots: repository.list(accountId), screen: "saves", notice: `Online sync connected as ${accountId}.` }); if (!result.ok) {
set({ notice: accountNotice(result, "sign-in") });
return false;
}
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Online sync connected as ${result.username}.` });
return true;
},
createAccount: async (username, password) => {
const result = await accounts.create(username, password);
if (!result.ok) {
set({ notice: accountNotice(result, "create") });
return false;
}
set({ accountId: result.username, slots: repository.list(result.username), screen: "saves", notice: `Account created. Online sync connected as ${result.username}.` });
return true;
}, },
continueOffline: () => set({ accountId: null, slots: repository.list(null), screen: "saves", notice: "Offline saves ready." }), continueOffline: () => set({ accountId: null, slots: repository.list(null), screen: "saves", notice: "Offline saves ready." }),
signOut: () => set({ accountId: null, slots: repository.list(null), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." }), signOut: () => set({ accountId: null, slots: repository.list(null), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." }),
@@ -168,6 +197,56 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
clearNotice: () => set({ notice: "" }), clearNotice: () => set({ notice: "" }),
})); }));
export type FrontendSnapshot = Omit<FrontendState,
| "signIn"
| "createAccount"
| "continueOffline"
| "signOut"
| "navigate"
| "selectSlot"
| "createSlot"
| "playSlot"
| "deleteSlot"
| "copySlot"
| "uploadSlot"
| "downloadSlot"
| "selectMode"
| "selectBoss"
| "selectHealerClass"
| "updateActiveHealerInventory"
| "updateSetting"
| "touchActiveSave"
| "recordBossVictory"
| "clearNotice"
>;
export function getFrontendSnapshot(): FrontendSnapshot {
const {
signIn: _signIn,
createAccount: _createAccount,
continueOffline: _continueOffline,
signOut: _signOut,
navigate: _navigate,
selectSlot: _selectSlot,
createSlot: _createSlot,
playSlot: _playSlot,
deleteSlot: _deleteSlot,
copySlot: _copySlot,
uploadSlot: _uploadSlot,
downloadSlot: _downloadSlot,
selectMode: _selectMode,
selectBoss: _selectBoss,
selectHealerClass: _selectHealerClass,
updateActiveHealerInventory: _updateActiveHealerInventory,
updateSetting: _updateSetting,
touchActiveSave: _touchActiveSave,
recordBossVictory: _recordBossVictory,
clearNotice: _clearNotice,
...snapshot
} = useFrontendStore.getState();
return snapshot;
}
export function useActiveHunter(): HunterSave | null { export function useActiveHunter(): HunterSave | null {
return useFrontendStore((state) => activeSave(state.slots, state.activeSlotId)); return useFrontendStore((state) => activeSave(state.slots, state.activeSlotId));
} }
+13
View File
@@ -173,6 +173,19 @@ describe("Disc Priest combat simulation", () => {
expect(paused.castAbility("renew")).toBe(false); expect(paused.castAbility("renew")).toBe(false);
}); });
it("does not publish unchanged player positions while idle", () => {
let updates = 0;
const unsubscribe = useGameStore.subscribe(() => { updates += 1; });
const start = useGameStore.getState().playerPosition;
useGameStore.getState().setPlayerPosition([start[0], start[1]]);
expect(updates).toBe(0);
useGameStore.getState().setPlayerPosition([start[0] + 0.25, start[1]]);
expect(updates).toBe(1);
unsubscribe();
});
it("telegraphs, executes, and recovers from a Bull charge", () => { it("telegraphs, executes, and recovers from a Bull charge", () => {
while (useGameStore.getState().time < 7.1) useGameStore.getState().tick(0.1); while (useGameStore.getState().time < 7.1) useGameStore.getState().tick(0.1);
const telegraph = useGameStore.getState().bossMotion; const telegraph = useGameStore.getState().bossMotion;
+49 -3
View File
@@ -43,7 +43,7 @@ export interface AdditionalBossState {
motion: BossMotionState; motion: BossMotionState;
} }
interface GameState { export interface GameState {
bossId: BossId; bossId: BossId;
paused: boolean; paused: boolean;
pauseSelection: "resume" | "exit"; pauseSelection: "resume" | "exit";
@@ -261,10 +261,20 @@ export const useGameStore = create<GameState>((set, get) => ({
setPaused: (paused) => set({ paused, pauseSelection: "resume" }), setPaused: (paused) => set({ paused, pauseSelection: "resume" }),
togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })), togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })),
setPauseSelection: (pauseSelection) => set({ pauseSelection }), setPauseSelection: (pauseSelection) => set({ pauseSelection }),
setPlayerPosition: (playerPosition) => set((state) => ({ setPlayerPosition: (playerPosition) => set((state) => {
const current = state.playerPosition;
const partyCurrent = state.partyPositions.aelia;
if (current[0] === playerPosition[0]
&& current[1] === playerPosition[1]
&& partyCurrent[0] === playerPosition[0]
&& partyCurrent[1] === playerPosition[1]) {
return state;
}
return {
playerPosition, playerPosition,
partyPositions: { ...state.partyPositions, aelia: [...playerPosition] }, partyPositions: { ...state.partyPositions, aelia: [...playerPosition] },
})), };
}),
castAbility: (abilityId) => { castAbility: (abilityId) => {
const state = get(); const state = get();
@@ -522,6 +532,42 @@ export const useGameStore = create<GameState>((set, get) => ({
}, },
})); }));
export type GameSnapshot = Omit<GameState,
| "configureHealer"
| "startEncounter"
| "restart"
| "tick"
| "castAbility"
| "selectMember"
| "cycleMember"
| "setActiveTab"
| "selectItem"
| "setPlayerPosition"
| "setPaused"
| "togglePause"
| "setPauseSelection"
>;
export function getGameSnapshot(): GameSnapshot {
const {
configureHealer: _configureHealer,
startEncounter: _startEncounter,
restart: _restart,
tick: _tick,
castAbility: _castAbility,
selectMember: _selectMember,
cycleMember: _cycleMember,
setActiveTab: _setActiveTab,
selectItem: _selectItem,
setPlayerPosition: _setPlayerPosition,
setPaused: _setPaused,
togglePause: _togglePause,
setPauseSelection: _setPauseSelection,
...snapshot
} = useGameStore.getState();
return snapshot;
}
export function abilityRemaining(abilityId: AbilityId, time: number, cooldowns: Record<AbilityId, number>) { export function abilityRemaining(abilityId: AbilityId, time: number, cooldowns: Record<AbilityId, number>) {
return Math.max(0, cooldowns[abilityId] - time); return Math.max(0, cooldowns[abilityId] - time);
} }
+21 -31
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { subscribeControllerToken } from "../input/controller";
import { ABILITY_ORDER } from "./data"; import { ABILITY_ORDER } from "./data";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import type { AbilityId } from "./types"; import type { AbilityId } from "./types";
@@ -84,43 +85,32 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, [enabled]); }, [enabled]);
useEffect(() => { useEffect(() => subscribeControllerToken(({ token, repeat }) => {
let frame = 0; if (!enabled) return;
let previousButtons: boolean[] = [];
const poll = () => {
const gamepad = navigator.getGamepads?.()[0];
if (gamepad && enabled) {
const buttons = gamepad.buttons.map((button) => button.pressed);
const store = useGameStore.getState(); const store = useGameStore.getState();
if (store.paused) { if (store.paused) {
if (buttons[12] && !previousButtons[12]) store.setPauseSelection("resume"); if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume");
if (buttons[13] && !previousButtons[13]) store.setPauseSelection("exit"); if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit");
if ((buttons[1] && !previousButtons[1]) || (buttons[9] && !previousButtons[9])) store.setPaused(false); if (repeat) return;
if (buttons[0] && !previousButtons[0]) { if (token === "Button1" || token === "Button9") store.setPaused(false);
if (token === "Button0") {
if (store.pauseSelection === "resume") store.setPaused(false); if (store.pauseSelection === "resume") store.setPaused(false);
else exitRef.current?.(); else exitRef.current?.();
} }
} else { return;
for (const [button, ability] of Object.entries(gamepadAbilityMap)) {
const index = Number(button);
if (buttons[index] && !previousButtons[index]) store.castAbility(ability);
} }
if (buttons[12] && !previousButtons[12]) store.cycleMember(-1); if (repeat) return;
if (buttons[13] && !previousButtons[13]) store.cycleMember(1); if (token.startsWith("Button")) {
if (buttons[8] && !previousButtons[8]) store.setActiveTab(store.activeTab === "map" ? "combat" : "map"); const ability = gamepadAbilityMap[Number(token.slice("Button".length))];
if (buttons[9] && !previousButtons[9]) { if (ability && store.phase === "combat") store.castAbility(ability);
}
if (token === "Button12") store.cycleMember(-1);
if (token === "Button13") store.cycleMember(1);
if (token === "Button8") store.setActiveTab(store.activeTab === "map" ? "combat" : "map");
if (token === "Button9" || (token === "Button0" && store.phase !== "combat")) {
if (store.phase === "briefing") store.startEncounter(); if (store.phase === "briefing") store.startEncounter();
if (store.phase === "victory" || store.phase === "defeat") store.restart(); else if (store.phase === "victory" || store.phase === "defeat") store.restart();
if (store.phase === "combat") store.setPaused(true); else if (store.phase === "combat") store.setPaused(true);
} }
} }), [enabled]);
previousButtons = buttons;
} else {
previousButtons = [];
}
frame = requestAnimationFrame(poll);
};
frame = requestAnimationFrame(poll);
return () => cancelAnimationFrame(frame);
}, [enabled]);
} }
+28
View File
@@ -0,0 +1,28 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
getControllerMovement,
resetControllerState,
setExternalControllerMovement,
subscribeControllerMovement,
} from "./controller";
describe("controller movement normalization", () => {
beforeEach(() => resetControllerState());
it("ignores dead-zone noise and quantizes analog jitter", () => {
const updates: Array<{ x: number; y: number }> = [];
const unsubscribe = subscribeControllerMovement((movement) => {
updates.push({ x: movement.x, y: movement.y });
});
setExternalControllerMovement({ x: 0.08, y: -0.1 });
expect(updates).toHaveLength(0);
setExternalControllerMovement({ x: 0.5001, y: -0.5001 });
setExternalControllerMovement({ x: 0.5002, y: -0.5002 });
expect(updates).toEqual([{ x: 0.5, y: -0.5 }]);
expect(getControllerMovement()).toEqual({ x: 0.5, y: -0.5 });
unsubscribe();
});
});
+189
View File
@@ -0,0 +1,189 @@
import { Capacitor } from "@capacitor/core";
export interface ControllerTokenEvent {
token: string;
repeat: boolean;
}
export interface ControllerMovement {
x: number;
y: number;
}
type TokenListener = (event: ControllerTokenEvent) => void;
type MovementListener = (movement: Readonly<ControllerMovement>) => void;
const NATIVE_TOKEN_EVENT = "iwt-native-controller";
const NATIVE_MOTION_EVENT = "iwt-native-controller-motion";
const NATIVE_RESET_EVENT = "iwt-native-controller-reset";
const INITIAL_REPEAT_MS = 280;
const REPEAT_MS = 90;
const AXIS_THRESHOLD = 0.55;
const MOVEMENT_AXIS_STEPS = 128;
const BUTTON_TOKENS = Array.from({ length: 32 }, (_, index) => `Button${index}`);
const NEGATIVE_AXIS_TOKENS = Array.from({ length: 16 }, (_, index) => `Axis${index}-`);
const POSITIVE_AXIS_TOKENS = Array.from({ length: 16 }, (_, index) => `Axis${index}+`);
const listeners = new Set<TokenListener>();
const movementListeners = new Set<MovementListener>();
const repeatAt = new Map<string, number>();
const lastNativeTokenAt = new Map<string, number>();
let previousTokens = new Set<string>();
let currentTokens = new Set<string>();
let movement: ControllerMovement = { x: 0, y: 0 };
let stopService: (() => void) | null = null;
let dispatchDepth = 0;
function connectedGamepad() {
const gamepads = navigator.getGamepads?.();
if (!gamepads) return null;
for (let index = 0; index < gamepads.length; index += 1) {
if (gamepads[index]) return gamepads[index];
}
return null;
}
function tokensFor(gamepad: Gamepad, tokens: Set<string>) {
tokens.clear();
for (let index = 0; index < gamepad.buttons.length; index += 1) {
const button = gamepad.buttons[index];
if (button.pressed || button.value > 0.65) tokens.add(BUTTON_TOKENS[index] ?? `Button${index}`);
}
for (let index = 0; index < gamepad.axes.length; index += 1) {
const value = gamepad.axes[index];
if (value <= -AXIS_THRESHOLD) tokens.add(NEGATIVE_AXIS_TOKENS[index] ?? `Axis${index}-`);
if (value >= AXIS_THRESHOLD) tokens.add(POSITIVE_AXIS_TOKENS[index] ?? `Axis${index}+`);
}
}
function canRepeat(token: string) {
return token === "Button12"
|| token === "Button13"
|| token === "Button14"
|| token === "Button15"
|| token.startsWith("Axis0")
|| token.startsWith("Axis1");
}
export function emitControllerToken(event: ControllerTokenEvent) {
dispatchDepth += 1;
try {
for (const listener of listeners) listener(event);
} finally {
dispatchDepth -= 1;
}
}
export function isControllerDispatchActive() {
return dispatchDepth > 0;
}
export function subscribeControllerToken(listener: TokenListener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function subscribeControllerMovement(listener: MovementListener) {
movementListeners.add(listener);
return () => {
movementListeners.delete(listener);
};
}
export function getControllerMovement(): Readonly<ControllerMovement> {
return movement;
}
export function setExternalControllerMovement(next: ControllerMovement) {
setExternalControllerAxes(next.x, next.y);
}
function setExternalControllerAxes(nextX: number, nextY: number) {
const x = Math.abs(nextX) >= 0.12 ? Math.round(nextX * MOVEMENT_AXIS_STEPS) / MOVEMENT_AXIS_STEPS : 0;
const y = Math.abs(nextY) >= 0.12 ? Math.round(nextY * MOVEMENT_AXIS_STEPS) / MOVEMENT_AXIS_STEPS : 0;
if (x === movement.x && y === movement.y) return;
movement = { x, y };
for (const listener of movementListeners) listener(movement);
}
export function resetControllerState() {
previousTokens.clear();
currentTokens.clear();
repeatAt.clear();
lastNativeTokenAt.clear();
setExternalControllerAxes(0, 0);
}
export function startControllerInput() {
if (stopService) return stopService;
const onNativeToken = (event: Event) => {
const detail = (event as CustomEvent<ControllerTokenEvent>).detail;
if (!detail?.token) return;
const now = performance.now();
const lastAt = lastNativeTokenAt.get(detail.token) ?? Number.NEGATIVE_INFINITY;
// Some Android controllers report one D-pad edge through both key and hat-axis paths.
if (now - lastAt < 24) return;
lastNativeTokenAt.set(detail.token, now);
emitControllerToken({ token: detail.token, repeat: Boolean(detail.repeat) });
};
const onNativeMotion = (event: Event) => {
const detail = (event as CustomEvent<ControllerMovement>).detail;
if (detail) setExternalControllerMovement(detail);
};
const onReset = () => resetControllerState();
const onVisibility = () => {
if (document.visibilityState !== "visible") resetControllerState();
};
window.addEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
window.addEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
window.addEventListener(NATIVE_RESET_EVENT, onReset);
document.addEventListener("visibilitychange", onVisibility);
let frame = 0;
let hadConnectedGamepad = false;
const poll = (now: number) => {
const gamepad = connectedGamepad();
if (!gamepad) {
// Clear held input once on a disconnect edge, not on every idle frame.
if (hadConnectedGamepad) resetControllerState();
hadConnectedGamepad = false;
} else {
hadConnectedGamepad = true;
setExternalControllerAxes(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0);
tokensFor(gamepad, currentTokens);
for (const token of currentTokens) {
const pressed = !previousTokens.has(token);
const nextRepeat = repeatAt.get(token) ?? 0;
if (pressed || (canRepeat(token) && now >= nextRepeat)) {
emitControllerToken({ token, repeat: !pressed });
repeatAt.set(token, now + (pressed ? INITIAL_REPEAT_MS : REPEAT_MS));
}
}
for (const token of repeatAt.keys()) {
if (!currentTokens.has(token)) repeatAt.delete(token);
}
const scratch = previousTokens;
previousTokens = currentTokens;
currentTokens = scratch;
}
frame = window.requestAnimationFrame(poll);
};
// Android key/motion events are authoritative. Browser Gamepad polling remains the fallback.
if (!Capacitor.isNativePlatform()) frame = window.requestAnimationFrame(poll);
stopService = () => {
window.removeEventListener(NATIVE_TOKEN_EVENT, onNativeToken);
window.removeEventListener(NATIVE_MOTION_EVENT, onNativeMotion);
window.removeEventListener(NATIVE_RESET_EVENT, onReset);
document.removeEventListener("visibilitychange", onVisibility);
if (frame) window.cancelAnimationFrame(frame);
resetControllerState();
stopService = null;
};
return stopService;
}
+10 -34
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { subscribeControllerToken } from "./controller";
export interface MenuAction { export interface MenuAction {
id: string; id: string;
@@ -68,41 +69,16 @@ export function useMenuController(actions: MenuAction[], options: MenuController
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, [confirm, move]); }, [confirm, move]);
useEffect(() => { useEffect(() => subscribeControllerToken(({ token, repeat }) => {
let frame = 0; const direction: Direction | null = token === "Button12" || token === "Axis1-" ? "up"
let previous: boolean[] = []; : token === "Button13" || token === "Axis1+" ? "down"
let heldDirection: Direction | null = null; : token === "Button14" || token === "Axis0-" ? "left"
let nextRepeatAt = 0; : token === "Button15" || token === "Axis0+" ? "right"
const poll = (now: number) => {
const gamepad = navigator.getGamepads?.()[0];
if (!gamepad) {
previous = [];
heldDirection = null;
} else {
const pressed = gamepad.buttons.map((button) => button.pressed);
const stickX = Math.abs(gamepad.axes[0] ?? 0) >= 0.55 ? gamepad.axes[0] : 0;
const stickY = Math.abs(gamepad.axes[1] ?? 0) >= 0.55 ? gamepad.axes[1] : 0;
const direction: Direction | null = pressed[12] || stickY < 0 ? "up"
: pressed[13] || stickY > 0 ? "down"
: pressed[14] || stickX < 0 ? "left"
: pressed[15] || stickX > 0 ? "right"
: null; : null;
if (direction && (direction !== heldDirection || now >= nextRepeatAt)) { if (direction) move(direction);
move(direction); else if (!repeat && token === "Button0") confirm();
nextRepeatAt = direction === heldDirection ? now + 120 : now + 360; else if (!repeat && token === "Button1") backRef.current?.();
heldDirection = direction; }), [confirm, move]);
} else if (!direction) {
heldDirection = null;
}
if (pressed[0] && !previous[0]) confirm();
if (pressed[1] && !previous[1]) backRef.current?.();
previous = pressed;
}
frame = requestAnimationFrame(poll);
};
frame = requestAnimationFrame(poll);
return () => cancelAnimationFrame(frame);
}, [confirm, move]);
return { return {
focusedId, focusedId,
+9 -1
View File
@@ -2,16 +2,24 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import App from "./App"; import App from "./App";
import { BottomDisplayApp } from "./platform/BottomDisplayApp";
import { startControllerInput } from "./input/controller";
import "./styles.css"; import "./styles.css";
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout"); const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
const displayMode = new URLSearchParams(window.location.search).get("display");
if (Capacitor.isNativePlatform() || nativeLayoutRequested) { if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
document.documentElement.classList.add("native-platform"); document.documentElement.classList.add("native-platform");
} }
if (displayMode === "top" || displayMode === "bottom") {
document.documentElement.dataset.displaySurface = displayMode;
}
startControllerInput();
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> {displayMode === "bottom" ? <BottomDisplayApp /> : <App />}
</StrictMode>, </StrictMode>,
); );
+193
View File
@@ -0,0 +1,193 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { emitControllerToken, isControllerDispatchActive, subscribeControllerMovement, subscribeControllerToken } from "../input/controller";
import { useGameStore } from "../game/store";
import { useFrontendStore } from "../frontend/store";
import type { AppScreen } from "../frontend/types";
import { FrontEnd } from "../components/FrontEnd";
import { createDualScreenChannel, type DualScreenMessage, type FrontendCommand, type GameCommand } from "./dualScreenSync";
import type { BossId } from "../game/types";
import { useForcedThorDisplays } from "./useThorDualScreen";
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
const BottomScreen = lazy(() => import("../components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
const CONTROLLER_MOTION_SYNC_INTERVAL_MS = 33;
function screenTitle(screen: AppScreen) {
switch (screen) {
case "login": return "Sign in or continue offline";
case "saves": return "Choose hunter save";
case "home": return "Choose expedition";
case "profile": return "Hunter profile";
case "settings": return "Field settings";
case "mode": return "Prepare encounter";
case "game": return "Field console";
}
}
function CompanionStandby({ screen, hunterName, notice }: {
screen: AppScreen;
hunterName: string | null;
notice: string;
}) {
return (
<section className="display bottom-display companion-standby" aria-label="Thor context display">
<header>
<span>IH</span>
<div><small>AYN Thor · Context display</small><strong>I Want To Heal</strong></div>
</header>
<main>
<small>Current task</small>
<h1>{screenTitle(screen)}</h1>
<p>{hunterName ? `${hunterName}'s field console is linked to the upper display.` : "Upper display owns primary navigation. Lower display remains linked and ready."}</p>
{notice && <em>{notice}</em>}
</main>
<footer>
<span><b></b> Navigate</span>
<span><b>A</b> Select</span>
<span><b>B</b> Back</span>
</footer>
</section>
);
}
export function BottomDisplayApp() {
useForcedThorDisplays();
const channelRef = useRef<BroadcastChannel | null>(null);
const [surface, setSurface] = useState<{ screen: AppScreen; hunterName: string | null; notice: string }>({
screen: "login",
hunterName: null,
notice: "Linking upper display…",
});
const postFrontendCommand = useCallback((command: FrontendCommand) => {
if (isControllerDispatchActive()) return;
channelRef.current?.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage);
}, []);
const launchGame = useCallback((bossIds: readonly BossId[]) => {
postFrontendCommand({ name: "launchGame", bossIds });
}, [postFrontendCommand]);
useEffect(() => {
const channel = createDualScreenChannel();
if (!channel) return;
channelRef.current = channel;
const sentControllerIds = new Set<string>();
let controllerSequence = 0;
let receivingControllerEcho = false;
let latestMovement = { x: 0, y: 0 };
const movementPublisher = createRateLimitedPublisher(() => {
channel.postMessage({
type: "controller-motion",
movement: { x: latestMovement.x, y: latestMovement.y },
} satisfies DualScreenMessage);
}, CONTROLLER_MOTION_SYNC_INTERVAL_MS);
const announceReady = () => channel.postMessage({ type: "companion-ready" } satisfies DualScreenMessage);
const announceClosing = () => {
movementPublisher.cancel();
channel.postMessage({ type: "companion-closing" } satisfies DualScreenMessage);
};
const postCommand = (command: GameCommand) => channel.postMessage({ type: "game-command", command } satisfies DualScreenMessage);
const postFrontend = (command: FrontendCommand) => {
if (!isControllerDispatchActive()) channel.postMessage({ type: "frontend-command", command } satisfies DualScreenMessage);
};
useFrontendStore.setState({
signIn: (username, password) => {
postFrontend({ name: "signIn", username, password });
return Promise.resolve(false);
},
createAccount: (username, password) => {
postFrontend({ name: "createAccount", username, password });
return Promise.resolve(false);
},
continueOffline: () => postFrontend({ name: "continueOffline" }),
signOut: () => postFrontend({ name: "signOut" }),
navigate: (screen) => postFrontend({ name: "navigate", screen }),
selectSlot: (slotId) => postFrontend({ name: "selectSlot", slotId }),
createSlot: (slotId, hunterName) => {
postFrontend({ name: "createSlot", slotId, hunterName });
return false;
},
playSlot: (slotId) => postFrontend({ name: "playSlot", slotId }),
deleteSlot: (slotId) => postFrontend({ name: "deleteSlot", slotId }),
copySlot: (sourceId, targetId) => postFrontend({ name: "copySlot", sourceId, targetId }),
uploadSlot: (slotId) => postFrontend({ name: "uploadSlot", slotId }),
downloadSlot: (slotId) => postFrontend({ name: "downloadSlot", slotId }),
selectMode: (mode) => postFrontend({ name: "selectMode", mode }),
selectBoss: (bossId) => postFrontend({ name: "selectBoss", bossId }),
selectHealerClass: (classId) => postFrontend({ name: "selectHealerClass", classId }),
updateSetting: (key, value) => postFrontend({ name: "updateSetting", key, value }),
});
useGameStore.setState({
startEncounter: () => postCommand({ name: "startEncounter" }),
restart: () => postCommand({ name: "restart" }),
castAbility: (abilityId) => {
postCommand({ name: "castAbility", abilityId });
return false;
},
selectMember: (memberId) => postCommand({ name: "selectMember", memberId }),
cycleMember: (direction) => postCommand({ name: "cycleMember", direction }),
setActiveTab: (tab) => postCommand({ name: "setActiveTab", tab }),
selectItem: (itemId) => postCommand({ name: "selectItem", itemId }),
setPaused: (paused) => postCommand({ name: "setPaused", paused }),
setPauseSelection: (selection) => postCommand({ name: "setPauseSelection", selection }),
});
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "authoritative-ready") {
channel.postMessage({ type: "companion-ready" } satisfies DualScreenMessage);
return;
}
if (event.data.type === "controller-echo") {
if (sentControllerIds.delete(event.data.id)) return;
receivingControllerEcho = true;
emitControllerToken(event.data.event);
receivingControllerEcho = false;
return;
}
if (event.data.type === "app-state") {
const { screen, hunterName, notice, frontend, game } = event.data;
setSurface((current) => current.screen === screen
&& current.hunterName === hunterName
&& current.notice === notice
? current
: { screen, hunterName, notice });
if (frontend) useFrontendStore.setState(frontend);
if (game) useGameStore.setState(game);
}
};
const unsubscribeToken = subscribeControllerToken((event) => {
if (receivingControllerEcho) return;
controllerSequence += 1;
const id = `bottom-${controllerSequence}`;
sentControllerIds.add(id);
channel.postMessage({ type: "controller-token", id, event } satisfies DualScreenMessage);
});
const unsubscribeMovement = subscribeControllerMovement((movement) => {
latestMovement = movement;
movementPublisher.request();
});
window.addEventListener("pagehide", announceClosing);
window.addEventListener("pageshow", announceReady);
announceReady();
return () => {
unsubscribeToken();
unsubscribeMovement();
window.removeEventListener("pagehide", announceClosing);
window.removeEventListener("pageshow", announceReady);
movementPublisher.dispose();
announceClosing();
channelRef.current = null;
channel.close();
};
}, []);
return (
<main className="bottom-display-root">
{surface.screen === "game"
? <Suspense fallback={<CompanionStandby screen="game" hunterName={surface.hunterName} notice="Loading field controls…" />}><BottomScreen /></Suspense>
: surface.notice === "Linking upper display…"
? <CompanionStandby screen={surface.screen} hunterName={surface.hunterName} notice={surface.notice} />
: <FrontEnd onLaunch={launchGame} />}
</main>
);
}
+106
View File
@@ -0,0 +1,106 @@
import type { AppScreen } from "../frontend/types";
import type { FrontendSnapshot } from "../frontend/store";
import { useFrontendStore } from "../frontend/store";
import { emitControllerToken, setExternalControllerMovement, type ControllerMovement, type ControllerTokenEvent } from "../input/controller";
import { getGameSnapshot, type GameSnapshot, useGameStore } from "../game/store";
import type { AbilityId, BottomTab, MemberId } from "../game/types";
import type { BossId, HealerClassId } from "../game/types";
import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types";
const CHANNEL_NAME = "i-want-to-heal:thor-dual-screen:v1";
export type GameCommand =
| { name: "startEncounter" }
| { name: "restart" }
| { name: "castAbility"; abilityId: AbilityId }
| { name: "selectMember"; memberId: MemberId }
| { name: "cycleMember"; direction: 1 | -1 }
| { name: "setActiveTab"; tab: BottomTab }
| { name: "selectItem"; itemId: string }
| { name: "setPaused"; paused: boolean }
| { name: "setPauseSelection"; selection: "resume" | "exit" };
export type FrontendCommand =
| { name: "signIn"; username: string; password: string }
| { name: "createAccount"; username: string; password: string }
| { name: "continueOffline" }
| { name: "signOut" }
| { name: "navigate"; screen: AppScreen }
| { name: "selectSlot"; slotId: SaveSlotId }
| { name: "createSlot"; slotId: SaveSlotId; hunterName: string }
| { name: "playSlot"; slotId: SaveSlotId }
| { name: "deleteSlot"; slotId: SaveSlotId }
| { name: "copySlot"; sourceId: SaveSlotId; targetId: SaveSlotId }
| { name: "uploadSlot"; slotId: SaveSlotId }
| { name: "downloadSlot"; slotId: SaveSlotId }
| { name: "selectMode"; mode: GameModeId }
| { name: "selectBoss"; bossId: BossId }
| { name: "selectHealerClass"; classId: HealerClassId }
| { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] }
| { name: "launchGame"; bossIds: readonly BossId[] };
export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game";
export type DualScreenMessage =
| { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: GameSnapshot }
| { type: "controller-token"; id: string; event: ControllerTokenEvent }
| { type: "controller-echo"; id: string; event: ControllerTokenEvent }
| { type: "controller-motion"; movement: ControllerMovement }
| { type: "game-command"; command: GameCommand }
| { type: "frontend-command"; command: FrontendCommand }
| { type: "authoritative-ready" }
| { type: "companion-ready" }
| { type: "companion-closing" };
export function createDualScreenChannel() {
return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL_NAME);
}
export function executeGameCommand(command: GameCommand) {
const game = useGameStore.getState();
switch (command.name) {
case "startEncounter": game.startEncounter(); break;
case "restart": game.restart(); break;
case "castAbility": game.castAbility(command.abilityId); break;
case "selectMember": game.selectMember(command.memberId); break;
case "cycleMember": game.cycleMember(command.direction); break;
case "setActiveTab": game.setActiveTab(command.tab); break;
case "selectItem": game.selectItem(command.itemId); break;
case "setPaused": game.setPaused(command.paused); break;
case "setPauseSelection": game.setPauseSelection(command.selection); break;
}
}
export function executeFrontendCommand(command: FrontendCommand) {
const frontend = useFrontendStore.getState();
switch (command.name) {
case "signIn": void frontend.signIn(command.username, command.password); break;
case "createAccount": void frontend.createAccount(command.username, command.password); break;
case "continueOffline": frontend.continueOffline(); break;
case "signOut": frontend.signOut(); break;
case "navigate": frontend.navigate(command.screen); break;
case "selectSlot": frontend.selectSlot(command.slotId); break;
case "createSlot": frontend.createSlot(command.slotId, command.hunterName); break;
case "playSlot": frontend.playSlot(command.slotId); break;
case "deleteSlot": frontend.deleteSlot(command.slotId); break;
case "copySlot": frontend.copySlot(command.sourceId, command.targetId); break;
case "uploadSlot": frontend.uploadSlot(command.slotId); break;
case "downloadSlot": frontend.downloadSlot(command.slotId); break;
case "selectMode": frontend.selectMode(command.mode); break;
case "selectBoss": frontend.selectBoss(command.bossId); break;
case "selectHealerClass": frontend.selectHealerClass(command.classId); break;
case "updateSetting": frontend.updateSetting(command.key, command.value); break;
case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: command.bossIds })); break;
}
}
export function receiveAuthoritativeMessage(message: DualScreenMessage) {
if (message.type === "controller-token") emitControllerToken(message.event);
if (message.type === "controller-motion") setExternalControllerMovement(message.movement);
if (message.type === "game-command") executeGameCommand(message.command);
if (message.type === "frontend-command") executeFrontendCommand(message.command);
}
export function currentGameSnapshot() {
return getGameSnapshot();
}
+31
View File
@@ -0,0 +1,31 @@
import { Capacitor, registerPlugin } from "@capacitor/core";
interface AndroidDisplay {
id: number;
name: string;
width: number;
height: number;
refreshRate: number;
isCurrent: boolean;
}
interface ThorDualScreenPlugin {
getDisplays: () => Promise<{ currentDisplayId: number; displays: AndroidDisplay[] }>;
forceBothDisplays: () => Promise<AndroidDisplay & { opened: boolean; topOnActivity: boolean }>;
addListener: (eventName: "displayDisconnected", listener: () => void) => Promise<{ remove: () => Promise<void> }>;
}
const plugin = registerPlugin<ThorDualScreenPlugin>("ThorDualScreen");
export function shouldOwnNativeDisplays() {
const params = new URLSearchParams(window.location.search);
return Capacitor.isNativePlatform() && params.get("role") !== "presentation";
}
export function forceBothThorDisplays() {
return plugin.forceBothDisplays();
}
export function listenForDisplayDisconnect(listener: () => void) {
return plugin.addListener("displayDisconnected", listener);
}
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
afterEach(() => vi.useRealTimers());
describe("createRateLimitedPublisher", () => {
it("publishes immediately, then coalesces a burst into one trailing update", () => {
vi.useFakeTimers();
let now = 0;
const publish = vi.fn();
const limiter = createRateLimitedPublisher(publish, 100, () => now);
limiter.request();
limiter.request();
limiter.request();
expect(publish).toHaveBeenCalledTimes(1);
now = 99;
vi.advanceTimersByTime(99);
expect(publish).toHaveBeenCalledTimes(1);
now = 100;
vi.advanceTimersByTime(1);
expect(publish).toHaveBeenCalledTimes(2);
});
it("cancels pending work when the owning display unmounts", () => {
vi.useFakeTimers();
let now = 0;
const publish = vi.fn();
const limiter = createRateLimitedPublisher(publish, 100, () => now);
limiter.request();
now = 1;
limiter.request();
limiter.dispose();
now = 101;
vi.advanceTimersByTime(100);
expect(publish).toHaveBeenCalledTimes(1);
});
it("can cancel a pending publish and resume for a reconnected display", () => {
vi.useFakeTimers();
let now = 0;
const publish = vi.fn();
const limiter = createRateLimitedPublisher(publish, 100, () => now);
limiter.request();
now = 1;
limiter.request();
limiter.cancel();
now = 101;
vi.advanceTimersByTime(100);
expect(publish).toHaveBeenCalledTimes(1);
limiter.request();
expect(publish).toHaveBeenCalledTimes(2);
});
});
+50
View File
@@ -0,0 +1,50 @@
export interface RateLimitedPublisher {
request: () => void;
cancel: () => void;
dispose: () => void;
}
/**
* Coalesces bursty state updates while preserving an immediate leading publish.
* The trailing publish always contains current state because `publish` reads it
* when the timer fires.
*/
export function createRateLimitedPublisher(
publish: () => void,
intervalMs: number,
now: () => number = () => performance.now(),
): RateLimitedPublisher {
let lastPublishedAt = Number.NEGATIVE_INFINITY;
let timer: ReturnType<typeof setTimeout> | null = null;
let disposed = false;
const run = () => {
timer = null;
if (disposed) return;
lastPublishedAt = now();
publish();
};
const cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
return {
request: () => {
if (disposed) return;
const remaining = intervalMs - (now() - lastPublishedAt);
if (remaining <= 0) {
if (timer !== null) clearTimeout(timer);
run();
return;
}
if (timer === null) timer = setTimeout(run, remaining);
},
cancel,
dispose: () => {
disposed = true;
cancel();
},
};
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect } from "react";
import { emitControllerToken, setExternalControllerMovement, subscribeControllerToken } from "../input/controller";
import { getFrontendSnapshot, useFrontendStore } from "../frontend/store";
import { useGameStore } from "../game/store";
import { createDualScreenChannel, currentGameSnapshot, receiveAuthoritativeMessage, type DualScreenMessage } from "./dualScreenSync";
import { forceBothThorDisplays, listenForDisplayDisconnect, shouldOwnNativeDisplays } from "./nativeDualScreen";
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
const GAME_SYNC_INTERVAL_MS = 100;
export function useForcedThorDisplays() {
useEffect(() => {
if (!shouldOwnNativeDisplays()) return;
let disposed = false;
let listener: { remove: () => Promise<void> } | null = null;
const force = () => forceBothThorDisplays().catch(() => {
// Native display listener retains force mode and opens when Thor reports panel again.
});
force();
listenForDisplayDisconnect(() => {
if (!disposed) force();
}).then((handle) => {
if (disposed) handle.remove();
else listener = handle;
}).catch(() => undefined);
return () => {
disposed = true;
listener?.remove();
};
}, []);
}
export function useAuthoritativeDualScreenSync() {
useEffect(() => {
const channel = createDualScreenChannel();
if (!channel) return;
let latest = useFrontendStore.getState();
let companionReady = false;
let receivingRelayedToken = false;
let topTokenSequence = 0;
const publish = (includeFrontend: boolean) => {
const hunter = latest.activeSlotId
? latest.slots.find((slot) => slot.id === latest.activeSlotId)?.local ?? null
: null;
channel.postMessage({
type: "app-state",
screen: latest.screen,
hunterName: hunter?.hunterName ?? null,
notice: latest.notice,
frontend: includeFrontend ? getFrontendSnapshot() : undefined,
game: latest.screen === "game" ? currentGameSnapshot() : undefined,
} satisfies DualScreenMessage);
};
// Player motion and the simulation can update the same store several times per
// frame interval. One 10 Hz companion snapshot is enough for tactical UI and
// avoids repeatedly structured-cloning the full game state across displays.
const gamePublisher = createRateLimitedPublisher(() => publish(false), GAME_SYNC_INTERVAL_MS);
const unsubscribeFrontend = useFrontendStore.subscribe((state) => {
latest = state;
if (companionReady) publish(true);
});
const unsubscribeGame = useGameStore.subscribe(() => {
if (companionReady && latest.screen === "game") gamePublisher.request();
});
const unsubscribeController = subscribeControllerToken((controllerEvent) => {
if (!companionReady || receivingRelayedToken) return;
topTokenSequence += 1;
channel.postMessage({
type: "controller-echo",
id: `top-${topTokenSequence}`,
event: controllerEvent,
} satisfies DualScreenMessage);
});
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "companion-ready") {
companionReady = true;
publish(true);
} else if (event.data.type === "companion-closing") {
companionReady = false;
gamePublisher.cancel();
setExternalControllerMovement({ x: 0, y: 0 });
} else if (event.data.type === "controller-token") {
receivingRelayedToken = true;
emitControllerToken(event.data.event);
receivingRelayedToken = false;
channel.postMessage({ ...event.data, type: "controller-echo" } satisfies DualScreenMessage);
} else receiveAuthoritativeMessage(event.data);
};
// Lets a companion that opened first repeat its handshake without sending a
// full state snapshot when no second display exists.
channel.postMessage({ type: "authoritative-ready" } satisfies DualScreenMessage);
return () => {
unsubscribeFrontend();
unsubscribeGame();
unsubscribeController();
gamePublisher.dispose();
channel.close();
};
}, []);
}
+66 -2
View File
@@ -1335,6 +1335,67 @@ button:focus-visible {
line-height: 1.2; line-height: 1.2;
} }
.native-platform[data-display-surface] .native-display-switch {
display: none;
}
.native-platform[data-display-surface="top"] .top-display,
.native-platform[data-display-surface="top"] .front-surface {
width: 100%;
height: 100%;
aspect-ratio: auto;
}
.native-platform[data-display-surface="bottom"] .bottom-display-root,
.native-platform[data-display-surface="bottom"] .bottom-display-root > .bottom-display {
width: 100%;
height: 100%;
min-height: 100%;
}
.native-platform[data-display-surface="bottom"] .bottom-display-root > .bottom-display {
aspect-ratio: auto;
}
.companion-standby {
display: grid;
grid-template-rows: auto 1fr auto;
padding: 6.5% 7%;
background:
radial-gradient(circle at 82% 12%, rgba(94, 199, 176, 0.15), transparent 34%),
linear-gradient(145deg, #091713, #030807 72%);
}
.companion-standby header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 4%;
border-bottom: 1px solid var(--line);
}
.companion-standby header > span {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid rgba(232, 200, 114, 0.6);
color: var(--gold);
font-family: "Cinzel", serif;
transform: rotate(45deg);
}
.companion-standby header div { display: grid; gap: 2px; }
.companion-standby header small,
.companion-standby main > small { color: var(--muted); font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; }
.companion-standby header strong { font-family: "Cinzel", serif; font-size: 18px; }
.companion-standby main { align-self: center; }
.companion-standby h1 { margin: 6px 0 8px; font-family: "Cinzel", serif; font-size: clamp(26px, 6cqw, 38px); font-weight: 500; }
.companion-standby p { max-width: 440px; margin: 0; color: #9cb0a8; font-size: clamp(12px, 2.6cqw, 16px); line-height: 1.35; }
.companion-standby em { display: block; margin-top: 12px; color: var(--gold); font-size: 11px; font-style: normal; }
.companion-standby footer { display: flex; gap: 18px; padding-top: 4%; border-top: 1px solid var(--line); color: var(--muted); font-size: 10px; text-transform: uppercase; }
.companion-standby footer b { color: var(--ink); }
/* Frontend shell — I Want To Heal */ /* Frontend shell — I Want To Heal */
.front-surface { .front-surface {
@@ -1502,10 +1563,13 @@ button:focus-visible {
.login-copy > span { color: var(--teal); font-size: 9px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; } .login-copy > span { color: var(--teal); font-size: 9px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; }
.login-copy h1 { width: 390px; margin: 8px 0 9px; font-family: "Cinzel", serif; font-size: 36px; font-weight: 500; line-height: 1.05; } .login-copy h1 { width: 390px; margin: 8px 0 9px; font-family: "Cinzel", serif; font-size: 36px; font-weight: 500; line-height: 1.05; }
.login-copy p { width: 390px; margin: 0; color: #96aaa2; font-size: 13px; line-height: 1.45; } .login-copy p { width: 390px; margin: 0; color: #96aaa2; font-size: 13px; line-height: 1.45; }
.login-panel { position: absolute; top: 105px; right: 44px; width: 300px; display: grid; gap: 9px; padding: 20px; border: 1px solid rgba(163, 198, 185, 0.22); border-top: 2px solid rgba(232,200,114,0.68); background: linear-gradient(145deg, rgba(14, 31, 26, 0.96), rgba(5, 15, 12, 0.97)); box-shadow: 0 20px 50px rgba(0,0,0,0.35); } .login-panel { position: absolute; top: 68px; right: 44px; width: 300px; display: grid; gap: 6px; padding: 15px 20px; border: 1px solid rgba(163, 198, 185, 0.22); border-top: 2px solid rgba(232,200,114,0.68); background: linear-gradient(145deg, rgba(14, 31, 26, 0.96), rgba(5, 15, 12, 0.97)); box-shadow: 0 20px 50px rgba(0,0,0,0.35); }
.login-panel label { color: #81978e; font-size: 8px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; } .login-panel label { color: #81978e; font-size: 8px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.login-panel input { height: 36px; padding: 0 11px; border: 1px solid rgba(155,190,176,0.28); outline: 0; color: #e8f2ee; background: #071310; font: 600 12px "Rajdhani", sans-serif; } .login-panel input { height: 36px; padding: 0 11px; border: 1px solid rgba(155,190,176,0.28); outline: 0; color: #e8f2ee; background: #071310; font: 600 12px "Rajdhani", sans-serif; }
.login-panel input:focus { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(232,200,114,0.12); } .login-panel input:focus,
.login-panel input.is-controller-focused { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(232,200,114,0.12); }
.login-panel .front-primary,
.login-panel .front-secondary { min-height: 40px; padding-top: 6px; padding-bottom: 6px; }
.login-surface > .front-notice { position: absolute; right: 44px; bottom: 83px; width: 300px; } .login-surface > .front-notice { position: absolute; right: 44px; bottom: 83px; width: 300px; }
.login-surface > .controller-legend { position: absolute; right: 44px; bottom: 47px; } .login-surface > .controller-legend { position: absolute; right: 44px; bottom: 47px; }
.login-context { padding: 0; } .login-context { padding: 0; }