Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076f6cf97c | ||
|
|
6726c600e4 |
+121
@@ -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.
|
||||
@@ -38,14 +38,23 @@ state rather than two independent WebViews.
|
||||
|
||||
## 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:
|
||||
|
||||
- Public URL: `https://iwanttoheal.phenomrom.com`
|
||||
- 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
|
||||
with the old game configuration:
|
||||
Clone from the TrueNAS-local Gitea bare repository into the app directory, then
|
||||
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`;
|
||||
- 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
|
||||
```
|
||||
|
||||
Create a Gitea access token with repository write permission, expose it only for
|
||||
the publishing process, then publish from `main`:
|
||||
The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can
|
||||
optionally override it for one run. Publish from `main`:
|
||||
|
||||
```bash
|
||||
export GITEA_TOKEN="..."
|
||||
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
|
||||
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
|
||||
fast-forward-only pull. Otherwise it prints the clone/pull command to run on
|
||||
TrueNAS before restarting the app. The pull refuses to run when the TrueNAS
|
||||
checkout still points at the old game's repository; move or archive that checkout
|
||||
and clone the new repository first.
|
||||
If the publisher runs where TrueNAS paths are visible, it prefers a
|
||||
fast-forward-only pull directly from TrueNAS-local Gitea storage. Otherwise it
|
||||
prints the exact local clone/pull commands to run in the TrueNAS shell. The pull
|
||||
refuses to run when the checkout still points at the old game's repository;
|
||||
archive that checkout and clone the new repository first.
|
||||
|
||||
Preview checks without changing Git or TrueNAS:
|
||||
|
||||
@@ -92,9 +100,9 @@ Preview checks without changing Git or TrueNAS:
|
||||
pnpm publish:gitea -- --dry-run
|
||||
```
|
||||
|
||||
Git push credentials still come from Git's credential manager. Gitea release API
|
||||
credentials come from `GITEA_TOKEN`. Never store access tokens or Android signing
|
||||
keys in this repository.
|
||||
Git push credentials still come from Git's credential manager. The configured
|
||||
Gitea token handles release creation and APK uploads. Android signing keys remain
|
||||
outside the repository.
|
||||
|
||||
## 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;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
public class MainActivity extends ControllerBridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(ThorDualScreenPlugin.class);
|
||||
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
@@ -11,5 +11,5 @@ services:
|
||||
- "4173:4173"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/usbssds/apps/iwanttoheal/app:/app
|
||||
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/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.
|
||||
+74
@@ -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.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 849 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 866 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 878 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 869 KiB |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "i-want-to-heal",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -0,0 +1,833 @@
|
||||
"""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)
|
||||
bpy.context.preferences.filepaths.save_version = 0
|
||||
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()
|
||||
+38
-11
@@ -28,7 +28,10 @@ GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
||||
GITEA_OWNER = "phenom"
|
||||
GITEA_REPO = "i-want-to-heal-mmo"
|
||||
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*)$")
|
||||
|
||||
|
||||
@@ -256,8 +259,7 @@ def ensure_tag(version: str, commit: str, message: str) -> str:
|
||||
|
||||
|
||||
def gitea_token() -> str:
|
||||
token = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
||||
|
||||
token = os.environ.get("GITEA_TOKEN", GITEA_TOKEN).strip()
|
||||
if not token:
|
||||
raise SystemExit(
|
||||
"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:
|
||||
if (TRUENAS_PATH / ".git").is_dir():
|
||||
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(
|
||||
"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."
|
||||
)
|
||||
run(["git", "pull", "--ff-only", "origin", BRANCH], cwd=TRUENAS_PATH)
|
||||
print("TrueNAS source updated. Restart the iwanttoheal app in the TrueNAS UI.")
|
||||
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)
|
||||
print("TrueNAS source updated from its configured origin.")
|
||||
print("Restart the iwanttoheal-mmo app in the TrueNAS UI.")
|
||||
return
|
||||
|
||||
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(f" git clone {GITEA_REMOTE} {TRUENAS_PATH}")
|
||||
print("Run these commands in the TrueNAS shell:")
|
||||
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(f" git -C {TRUENAS_PATH} pull --ff-only origin {BRANCH}")
|
||||
print("Then restart the iwanttoheal app in the TrueNAS UI.")
|
||||
print(
|
||||
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:
|
||||
|
||||
+10
@@ -6,6 +6,8 @@ import { useActiveHunter, useFrontendStore } from "./frontend/store";
|
||||
import { useGameStore } from "./game/store";
|
||||
import type { BossId } from "./game/types";
|
||||
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 BottomScreen = lazy(() => import("./components/BottomScreen").then((module) => ({ default: module.BottomScreen })));
|
||||
@@ -20,6 +22,8 @@ function GameLoadingScreen() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
useForcedThorDisplays();
|
||||
useAuthoritativeDualScreenSync();
|
||||
useGameLoop();
|
||||
const screen = useFrontendStore((state) => state.screen);
|
||||
const hunter = useActiveHunter();
|
||||
@@ -45,6 +49,12 @@ export default function App() {
|
||||
navigate("game");
|
||||
}, [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);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
|
||||
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);
|
||||
activeSurfaceRef.current = activeSurface;
|
||||
|
||||
useEffect(() => {
|
||||
if (!document.documentElement.classList.contains("native-platform")) return;
|
||||
let frame = 0;
|
||||
let selectHeld = false;
|
||||
const dedicatedSurface = new URLSearchParams(window.location.search).has("display");
|
||||
if (dedicatedSurface) return;
|
||||
const toggle = () => setActiveSurface((surface) => surface === "top" ? "bottom" : "top");
|
||||
const unsubscribeSurface = subscribeDisplaySurface(setActiveSurface);
|
||||
const unsubscribeController = subscribeControllerToken(({ token, repeat }) => {
|
||||
if (token === "Button8" && !repeat) toggle();
|
||||
});
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Tab" || event.repeat) return;
|
||||
event.preventDefault();
|
||||
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);
|
||||
frame = requestAnimationFrame(poll);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
unsubscribeSurface();
|
||||
cancelAnimationFrame(frame);
|
||||
unsubscribeController();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
+43
-10
@@ -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 { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
|
||||
import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||
@@ -51,15 +51,24 @@ function ControllerLegend({ back = false }: { back?: boolean }) {
|
||||
|
||||
function LoginScreen() {
|
||||
const signIn = useFrontendStore((state) => state.signIn);
|
||||
const createAccount = useFrontendStore((state) => state.createAccount);
|
||||
const continueOffline = useFrontendStore((state) => state.continueOffline);
|
||||
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[]>(() => [
|
||||
{ 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 },
|
||||
], [continueOffline, hunterId, signIn]);
|
||||
], [continueOffline, createAccount, password, signIn, username]);
|
||||
const controller = useMenuController(actions);
|
||||
|
||||
const submitSignIn = () => { void signIn(username, password); };
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
top={
|
||||
@@ -71,17 +80,41 @@ function LoginScreen() {
|
||||
<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>
|
||||
</div>
|
||||
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); signIn(hunterId); }}>
|
||||
<label htmlFor="hunter-id">Hunter ID</label>
|
||||
<input id="hunter-id" value={hunterId} onChange={(event) => setHunterId(event.target.value)} autoComplete="username" />
|
||||
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
|
||||
<label htmlFor="account-username">Username</label>
|
||||
<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">
|
||||
<span>Sign in & sync</span><small>Online saves enabled</small>
|
||||
</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}>
|
||||
<span>Continue with offline save</span><small>No account required</small>
|
||||
</FocusButton>
|
||||
</form>
|
||||
{notice && <div className="front-notice">{notice}</div>}
|
||||
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
|
||||
<ControllerLegend />
|
||||
</FrontSurface>
|
||||
}
|
||||
@@ -92,8 +125,8 @@ function LoginScreen() {
|
||||
<span className="context-kicker">How saving works</span>
|
||||
<ol>
|
||||
<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>03</b><span><strong>Move devices</strong><small>Download the online copy and overwrite local.</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>Sign in, then upload or download an online copy.</small></span></li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="device-route"><span>PC</span><i>↔</i><b>ONLINE COPY</b><i>↔</i><span>THOR</span></div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useAnimations, useGLTF } from "@react-three/drei";
|
||||
import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import { getControllerMovement } from "../input/controller";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { useGameStore } from "../game/store";
|
||||
import type { MemberId, PulseKind } from "../game/types";
|
||||
@@ -9,7 +10,14 @@ import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||
|
||||
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_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 EMBER_MANTIS_URL = new URL("../../game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.glb", import.meta.url).href;
|
||||
const PARTY_MODEL_URLS: Record<MemberId, string> = {
|
||||
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
|
||||
brann: new URL("../../game_assets/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
|
||||
@@ -41,6 +49,11 @@ const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
|
||||
orin: "Spellcast_Shoot",
|
||||
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>;
|
||||
|
||||
function encounterBossAt(state: GameStoreState, bossIndex: number) {
|
||||
@@ -59,6 +72,13 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string)
|
||||
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 WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop";
|
||||
@@ -208,11 +228,24 @@ function PartyCharacterModel({
|
||||
}
|
||||
|
||||
function Arena() {
|
||||
const columns = useMemo(() => {
|
||||
return 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 pillarInstances = useRef<THREE.InstancedMesh>(null);
|
||||
const torchInstances = useRef<THREE.InstancedMesh>(null);
|
||||
|
||||
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 (
|
||||
@@ -229,19 +262,16 @@ function Arena() {
|
||||
<circleGeometry args={[2.1, 48]} />
|
||||
<meshStandardMaterial color="#27342d" roughness={1} />
|
||||
</mesh>
|
||||
{columns.map(([x, z], index) => (
|
||||
<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]} />
|
||||
<meshStandardMaterial color="#26342f" roughness={0.8} />
|
||||
</mesh>
|
||||
<pointLight color={index % 2 ? "#dd7b38" : "#6fc9ba"} intensity={2.2} distance={5} position={[0, 2.7, 0]} />
|
||||
<mesh position={[0, 2.6, 0]}>
|
||||
<octahedronGeometry args={[0.2, 0]} />
|
||||
<meshBasicMaterial color={index % 2 ? "#ff9a4f" : "#77ddce"} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
<instancedMesh ref={pillarInstances} args={[undefined, undefined, ARENA_COLUMNS.length]} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
|
||||
<meshStandardMaterial color="#26342f" roughness={0.8} />
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={torchInstances} args={[undefined, undefined, ARENA_COLUMNS.length]}>
|
||||
<octahedronGeometry args={[0.2, 0]} />
|
||||
<meshBasicMaterial />
|
||||
</instancedMesh>
|
||||
<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]} />
|
||||
</group>
|
||||
);
|
||||
@@ -365,11 +395,9 @@ function PlayerCharacter() {
|
||||
if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) {
|
||||
inputX = Number(keys.current.has("d")) - Number(keys.current.has("a"));
|
||||
inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w"));
|
||||
const gamepad = navigator.getGamepads?.()[0];
|
||||
if (gamepad) {
|
||||
inputX += Math.abs(gamepad.axes[0] ?? 0) > 0.18 ? gamepad.axes[0] : 0;
|
||||
inputZ += Math.abs(gamepad.axes[1] ?? 0) > 0.18 ? gamepad.axes[1] : 0;
|
||||
}
|
||||
const controller = getControllerMovement();
|
||||
inputX += controller.x;
|
||||
inputZ += controller.y;
|
||||
}
|
||||
const length = Math.hypot(inputX, inputZ);
|
||||
if (length > 0.05) {
|
||||
@@ -464,7 +492,7 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
|
||||
return (
|
||||
<mesh castShadow position={[position[0], 1.1, position[1]]}>
|
||||
<dodecahedronGeometry args={[1.1, 0]} />
|
||||
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
|
||||
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : bossId === "ember-mantis-duelist" ? "#a42d18" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -553,7 +581,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
type AlternateBossKind = "vexa" | "cindermaw";
|
||||
type AlternateBossKind = "vexa" | "cindermaw" | "ember-mantis-duelist";
|
||||
|
||||
const ALTERNATE_BOSS_CONFIG = {
|
||||
vexa: {
|
||||
@@ -578,6 +606,17 @@ const ALTERNATE_BOSS_CONFIG = {
|
||||
light: "#ff8742",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
"ember-mantis-duelist": {
|
||||
url: EMBER_MANTIS_URL,
|
||||
scale: 0.78,
|
||||
idle: "Idle",
|
||||
move: "Sidestep",
|
||||
attack: "LineSlash",
|
||||
special: "CrossSlash",
|
||||
death: "Death",
|
||||
light: "#ff5a24",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
} as const;
|
||||
|
||||
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
|
||||
@@ -587,7 +626,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
|
||||
const defeated = bossHp <= 0;
|
||||
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 { actions } = useAnimations(gltf.animations, model);
|
||||
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
||||
@@ -607,27 +646,43 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
|
||||
const clipName = phase === "victory" || defeated
|
||||
? config.death
|
||||
: motionMode === "skyfall"
|
||||
? config.move
|
||||
: motionMode === "breath_telegraph" || motionMode === "breath_sweeping"
|
||||
? config.special
|
||||
: motionMode === "tethering" || motionMode === "venom_cast"
|
||||
: kind === "ember-mantis-duelist"
|
||||
? motionMode === "mantis_sidestep"
|
||||
? config.move
|
||||
: motionMode === "mantis_line_telegraph"
|
||||
? config.attack
|
||||
: config.idle;
|
||||
: motionMode === "mantis_cross_telegraph"
|
||||
? config.special
|
||||
: motionMode === "mantis_recover"
|
||||
? "Recover"
|
||||
: config.idle
|
||||
: motionMode === "skyfall"
|
||||
? config.move
|
||||
: motionMode === "breath_telegraph" || motionMode === "breath_sweeping"
|
||||
? config.special
|
||||
: motionMode === "tethering" || motionMode === "venom_cast"
|
||||
? config.attack
|
||||
: config.idle;
|
||||
|
||||
useEffect(() => {
|
||||
const next = actions[clipName];
|
||||
if (!next) return;
|
||||
for (const action of Object.values(actions)) action?.fadeOut(0.16);
|
||||
next.reset().setEffectiveWeight(1).fadeIn(0.16).play();
|
||||
if (phase === "victory" || defeated) {
|
||||
const timeScale = kind === "ember-mantis-duelist" && motionMode === "mantis_line_telegraph"
|
||||
? 0.55
|
||||
: kind === "ember-mantis-duelist" && motionMode === "mantis_cross_telegraph"
|
||||
? 0.6
|
||||
: 1;
|
||||
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play();
|
||||
const emberOneShot = kind === "ember-mantis-duelist" && clipName !== config.idle;
|
||||
if (phase === "victory" || defeated || emberOneShot) {
|
||||
next.setLoop(THREE.LoopOnce, 1);
|
||||
next.clampWhenFinished = true;
|
||||
} else {
|
||||
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
|
||||
}
|
||||
return () => { next.fadeOut(0.16); };
|
||||
}, [actions, clipName, defeated, phase]);
|
||||
}, [actions, clipName, config.idle, defeated, kind, motionMode, phase]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!group.current) return;
|
||||
@@ -645,6 +700,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
);
|
||||
if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
|
||||
targetAngle = motion.breathAngle;
|
||||
} else if (kind === "ember-mantis-duelist" && (
|
||||
motion.mode === "mantis_sidestep"
|
||||
|| motion.mode === "mantis_line_telegraph"
|
||||
|| motion.mode === "mantis_cross_telegraph"
|
||||
)) {
|
||||
const target = state.partyPositions[motion.chargeTargetId];
|
||||
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
|
||||
}
|
||||
const difference = Math.atan2(
|
||||
Math.sin(targetAngle - group.current.rotation.y),
|
||||
@@ -850,7 +912,7 @@ function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
|
||||
const state = useGameStore.getState();
|
||||
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
|
||||
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
|
||||
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
|
||||
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
|
||||
useFrame((_, delta) => {
|
||||
age.current += delta;
|
||||
if (!ring.current || !material.current) return;
|
||||
@@ -899,6 +961,7 @@ export function GameScene() {
|
||||
}
|
||||
|
||||
useGLTF.preload(BULL_URL, false, true);
|
||||
useGLTF.preload(EMBER_MANTIS_URL, false, true);
|
||||
for (const modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true);
|
||||
for (const loadout of Object.values(PARTY_WEAPON_URLS)) {
|
||||
useGLTF.preload(loadout.right, false, true);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useGameStore } from "../../game/store";
|
||||
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
|
||||
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
|
||||
const EMPTY_HAZARDS: never[] = [];
|
||||
const EMPTY_SLASH_LANES: never[] = [];
|
||||
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
||||
|
||||
function motionAt(state: GameStoreState, bossIndex: number) {
|
||||
@@ -68,6 +69,63 @@ export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex: number }) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const motion = useGameStore((state) => motionAt(state, bossIndex));
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const edgeMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
useFrame(({ clock }) => {
|
||||
if (!material.current || !edgeMaterial.current) return;
|
||||
const current = motionAt(useGameStore.getState(), bossIndex);
|
||||
const active = current?.mode === "mantis_recover";
|
||||
material.current.opacity = active ? 0.5 : 0.14 + (Math.sin(clock.elapsedTime * 12) + 1) * 0.09;
|
||||
edgeMaterial.current.opacity = active ? 1 : 0.62 + (Math.sin(clock.elapsedTime * 12) + 1) * 0.15;
|
||||
});
|
||||
const lane = motion?.slashLanes.find((candidate) => candidate.id === laneId);
|
||||
if (!lane || phase !== "combat") return null;
|
||||
const visible = motion.mode === "mantis_line_telegraph"
|
||||
|| motion.mode === "mantis_cross_telegraph"
|
||||
|| motion.mode === "mantis_recover";
|
||||
if (!visible) return null;
|
||||
|
||||
const dx = lane.end[0] - lane.start[0];
|
||||
const dz = lane.end[1] - lane.start[1];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const angle = Math.atan2(dx, dz);
|
||||
const midpoint: [number, number, number] = [
|
||||
(lane.start[0] + lane.end[0]) * 0.5,
|
||||
0.052,
|
||||
(lane.start[1] + lane.end[1]) * 0.5,
|
||||
];
|
||||
const active = motion.mode === "mantis_recover";
|
||||
const color = active ? "#ffd36a" : "#ff5128";
|
||||
return (
|
||||
<group position={midpoint} rotation={[0, angle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[lane.width, length]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
|
||||
</mesh>
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
|
||||
<boxGeometry args={[0.07, 0.03, length]} />
|
||||
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{active && (
|
||||
<mesh position={[0, 0.055, 0]}>
|
||||
<boxGeometry args={[0.16, 0.08, length]} />
|
||||
<meshBasicMaterial color="#fff0a8" transparent opacity={0.92} depthWrite={false} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlashLaneIndicators({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
const lanes = useGameStore((state) => motionAt(state, bossIndex)?.slashLanes ?? EMPTY_SLASH_LANES);
|
||||
return <>{lanes.map((lane) => <SlashLaneIndicator key={lane.id} laneId={lane.id} bossIndex={bossIndex} />)}</>;
|
||||
}
|
||||
|
||||
export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
|
||||
@@ -205,6 +263,7 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
|
||||
|
||||
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
|
||||
ChargeLaneIndicator,
|
||||
SlashLaneIndicators,
|
||||
PounceStackIndicator,
|
||||
BindingWebIndicator,
|
||||
BreathConeIndicator,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ describe("game mode configuration", () => {
|
||||
it("selects a boss across the full encounter pool", () => {
|
||||
expect(selectRandomBoss(() => 0)).toBe("bulldrome");
|
||||
expect(selectRandomBoss(() => 0.34)).toBe("vexa");
|
||||
expect(selectRandomBoss(() => 0.99)).toBe("cindermaw");
|
||||
expect(selectRandomBoss(() => 0.7)).toBe("cindermaw");
|
||||
expect(selectRandomBoss(() => 0.99)).toBe("ember-mantis-duelist");
|
||||
});
|
||||
|
||||
it("selects two distinct bosses for PVE", () => {
|
||||
|
||||
+13
-2
@@ -44,6 +44,17 @@ export const DEFAULT_COLLECTIONS: BossCollection[] = [
|
||||
{ id: "maw-breath", name: "Bottled Breath", icon: "☀", rarity: "Mythic", count: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
bossId: "ember-mantis-duelist",
|
||||
bossName: "Ember Mantis Duelist",
|
||||
defeated: false,
|
||||
drops: [
|
||||
{ id: "mantis-chitin", name: "Ember Chitin", icon: "◇", rarity: "Common", count: 0 },
|
||||
{ id: "mantis-edge", name: "Cinderblade Edge", icon: "⚔", rarity: "Uncommon", count: 0 },
|
||||
{ id: "mantis-antenna", name: "Duelist Antenna", icon: "⌁", rarity: "Rare", count: 0 },
|
||||
{ id: "mantis-core", name: "Molten Mantis Core", icon: "✦", rarity: "Mythic", count: 0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
|
||||
@@ -58,7 +69,7 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
|
||||
eyebrow: "1–4 hunters · chosen encounter",
|
||||
title: "Dungeons",
|
||||
description: "Choose a guardian, review its mechanics, and bring a prepared healing loadout into a focused encounter.",
|
||||
detail: "Bulldrome · Vexa · Cindermaw",
|
||||
detail: "Bulldrome · Vexa · Cindermaw · Ember Mantis",
|
||||
status: "Playable now",
|
||||
},
|
||||
"roguelike-pvp": {
|
||||
@@ -118,7 +129,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
||||
flawlessClears: 5,
|
||||
alliesSaved: 143,
|
||||
healingDone: 284_650,
|
||||
bossKills: { Bulldrome: 12, Vexa: 0, Cindermaw: 4 },
|
||||
bossKills: { Bulldrome: 12, Vexa: 0, Cindermaw: 4, "Ember Mantis Duelist": 0 },
|
||||
},
|
||||
collections: structuredClone(DEFAULT_COLLECTIONS),
|
||||
};
|
||||
|
||||
+84
-5
@@ -1,10 +1,12 @@
|
||||
import { create } from "zustand";
|
||||
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
|
||||
import { SaveRepository } from "./saveRepository";
|
||||
import { AccountRepository, type AccountResult } from "./accountRepository";
|
||||
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
|
||||
|
||||
const repository = new SaveRepository();
|
||||
const accounts = new AccountRepository();
|
||||
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
|
||||
|
||||
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;
|
||||
accountId: string | null;
|
||||
slots: SaveSlotState[];
|
||||
@@ -34,7 +48,8 @@ interface FrontendState {
|
||||
selectedBossId: BossId;
|
||||
settings: GameSettings;
|
||||
notice: string;
|
||||
signIn: (accountId: string) => void;
|
||||
signIn: (username: string, password: string) => Promise<boolean>;
|
||||
createAccount: (username: string, password: string) => Promise<boolean>;
|
||||
continueOffline: () => void;
|
||||
signOut: () => void;
|
||||
navigate: (screen: AppScreen) => void;
|
||||
@@ -70,9 +85,23 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
settings: loadSettings(),
|
||||
notice: "",
|
||||
|
||||
signIn: (rawAccountId) => {
|
||||
const accountId = rawAccountId.trim() || "wayfinder";
|
||||
set({ accountId, slots: repository.list(accountId), screen: "saves", notice: `Online sync connected as ${accountId}.` });
|
||||
signIn: async (username, password) => {
|
||||
const result = await accounts.authenticate(username, password);
|
||||
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." }),
|
||||
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: "" }),
|
||||
}));
|
||||
|
||||
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 {
|
||||
return useFrontendStore((state) => activeSave(state.slots, state.activeSlotId));
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ describe("full-mechanics dual-boss battle simulations", () => {
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = [
|
||||
["bulldrome", "vexa"],
|
||||
["bulldrome", "cindermaw"],
|
||||
["bulldrome", "ember-mantis-duelist"],
|
||||
["vexa", "cindermaw"],
|
||||
["vexa", "ember-mantis-duelist"],
|
||||
["cindermaw", "ember-mantis-duelist"],
|
||||
];
|
||||
|
||||
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
|
||||
|
||||
+16
-1
@@ -16,7 +16,7 @@ export interface BossDefinition {
|
||||
maxHp: number;
|
||||
}
|
||||
|
||||
export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw"];
|
||||
export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw", "ember-mantis-duelist"];
|
||||
|
||||
export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
|
||||
bulldrome: {
|
||||
@@ -64,4 +64,19 @@ export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
|
||||
mechanics: ["Searing Sweep", "Skyfall"],
|
||||
maxHp: 410,
|
||||
},
|
||||
"ember-mantis-duelist": {
|
||||
id: "ember-mantis-duelist",
|
||||
name: "Ember Mantis Duelist",
|
||||
title: "The Cinderblade",
|
||||
trial: "Trial IV · Blades in Motion",
|
||||
icon: "⚔",
|
||||
accent: "#ff6a2a",
|
||||
summary: "Sidesteps across the arena before carving single and crossed slash lanes.",
|
||||
briefing: "Track each sidestep. Clear the glowing Line Slash, then find a safe quadrant when both scythes form Cross Slash.",
|
||||
failure: "Do not chase the duelist through a telegraph. Preserve space and move perpendicular to each ember lane.",
|
||||
mapTitle: "The Cinderblade Court",
|
||||
mapCopy: "Follow the mantis laterally, but cross glowing cut lanes only after the blades finish their recovery.",
|
||||
mechanics: ["Line Slash", "Cross Slash"],
|
||||
maxHp: 520,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BOSS_DEFINITIONS } from "./bossCatalog";
|
||||
import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw";
|
||||
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
|
||||
import { createBaseMotion } from "./bosses/shared";
|
||||
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
|
||||
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
|
||||
@@ -39,6 +40,7 @@ const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale"
|
||||
export function createBossState(bossId: BossId = "bulldrome"): BossState {
|
||||
if (bossId === "vexa") return createVexaState();
|
||||
if (bossId === "cindermaw") return createCindermawState();
|
||||
if (bossId === "ember-mantis-duelist") return createEmberMantisState();
|
||||
const definition = BOSS_DEFINITIONS.bulldrome;
|
||||
return {
|
||||
id: "bulldrome",
|
||||
@@ -55,6 +57,7 @@ export function createBossState(bossId: BossId = "bulldrome"): BossState {
|
||||
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
|
||||
if (bossId === "vexa") return createVexaMotion();
|
||||
if (bossId === "cindermaw") return createCindermawMotion();
|
||||
if (bossId === "ember-mantis-duelist") return createEmberMantisMotion();
|
||||
return {
|
||||
...createBaseMotion("bulldrome"),
|
||||
mode: "holding",
|
||||
@@ -348,6 +351,7 @@ function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicRe
|
||||
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
if (context.boss.id === "vexa") return advanceVexaMechanics(context);
|
||||
if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context);
|
||||
if (context.boss.id === "ember-mantis-duelist") return advanceEmberMantisMechanics(context);
|
||||
return advanceBulldromeMechanics(context);
|
||||
}
|
||||
|
||||
@@ -371,6 +375,7 @@ export function handleBossDispel(
|
||||
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
|
||||
if (boss.id === "vexa") return upcomingVexaMechanic(boss, motion, time);
|
||||
if (boss.id === "cindermaw") return upcomingCindermawMechanic(boss, motion, time);
|
||||
if (boss.id === "ember-mantis-duelist") return upcomingEmberMantisMechanic(boss, motion, time);
|
||||
if (motion.mode === "telegraph") {
|
||||
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { freshParty } from "../data";
|
||||
import { pointToSegmentDistance } from "../geometry";
|
||||
import { evadeSlashLanesBehavior } from "../partyBehaviors";
|
||||
import type { BossMechanicContext } from "./types";
|
||||
import {
|
||||
advanceEmberMantisMechanics,
|
||||
createEmberMantisMotion,
|
||||
createEmberMantisState,
|
||||
EMBER_MANTIS_SLASH,
|
||||
} from "./emberMantis";
|
||||
|
||||
const POSITIONS: BossMechanicContext["partyPositions"] = {
|
||||
aelia: [0, 4.5],
|
||||
brann: [0, 0],
|
||||
nia: [-3, 2],
|
||||
orin: [3, 2],
|
||||
vale: [0, -2],
|
||||
};
|
||||
|
||||
function context(
|
||||
boss: ReturnType<typeof createEmberMantisState>,
|
||||
motion: ReturnType<typeof createEmberMantisMotion>,
|
||||
party = freshParty(),
|
||||
time = 0,
|
||||
delta = 0.1,
|
||||
): BossMechanicContext {
|
||||
return {
|
||||
boss,
|
||||
motion,
|
||||
party,
|
||||
partyPositions: structuredClone(POSITIONS),
|
||||
time,
|
||||
delta,
|
||||
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Ember Mantis Duelist mechanics", () => {
|
||||
it("sidesteps, telegraphs Line Slash, then damages targets left in the lane", () => {
|
||||
const boss = createEmberMantisState();
|
||||
const sidestep = advanceEmberMantisMechanics(context(boss, createEmberMantisMotion(), freshParty(), 5, 0.1));
|
||||
expect(sidestep.motion.mode).toBe("mantis_sidestep");
|
||||
expect(sidestep.motion.chargeTargetId).toBe("nia");
|
||||
|
||||
const telegraph = advanceEmberMantisMechanics(context(sidestep.boss, sidestep.motion, sidestep.party, 5.6, 0.6));
|
||||
expect(telegraph.motion.mode).toBe("mantis_line_telegraph");
|
||||
expect(telegraph.motion.slashLanes).toHaveLength(1);
|
||||
|
||||
const niaBefore = telegraph.party.find((member) => member.id === "nia")!.hp;
|
||||
const impact = advanceEmberMantisMechanics(context(telegraph.boss, telegraph.motion, telegraph.party, 6.51, 0.91));
|
||||
expect(impact.motion.mode).toBe("mantis_recover");
|
||||
expect(impact.motion.mechanicHitIds).toContain("nia");
|
||||
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - EMBER_MANTIS_SLASH.lineDamage);
|
||||
expect(impact.events.some((event) => event.message.includes("Line Slash"))).toBe(true);
|
||||
});
|
||||
|
||||
it("alternates into two crossed slash lanes", () => {
|
||||
const motion = {
|
||||
...createEmberMantisMotion(),
|
||||
mode: "mantis_sidestep" as const,
|
||||
mechanicCount: 1,
|
||||
chargeTargetId: "orin" as const,
|
||||
chargeEnd: [0, -6.6] as [number, number],
|
||||
phaseEndsAt: 0,
|
||||
};
|
||||
const result = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1));
|
||||
expect(result.motion.mode).toBe("mantis_cross_telegraph");
|
||||
expect(result.motion.slashLanes).toHaveLength(2);
|
||||
expect(result.motion.slashLanes[0].id).toContain("cross");
|
||||
});
|
||||
|
||||
it("gives mobile allies a target outside crossed lanes", () => {
|
||||
const motion = {
|
||||
...createEmberMantisMotion(),
|
||||
mode: "mantis_sidestep" as const,
|
||||
mechanicCount: 1,
|
||||
chargeTargetId: "orin" as const,
|
||||
chargeEnd: [0, -6.6] as [number, number],
|
||||
phaseEndsAt: 0,
|
||||
};
|
||||
const telegraph = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1)).motion;
|
||||
const decision = evadeSlashLanesBehavior.decide({
|
||||
memberId: "orin",
|
||||
current: POSITIONS.orin,
|
||||
formationTarget: POSITIONS.orin,
|
||||
bossMotion: telegraph,
|
||||
partyPositions: POSITIONS,
|
||||
time: 1,
|
||||
});
|
||||
expect(decision).not.toBeNull();
|
||||
const minimumLaneDistance = Math.min(...telegraph.slashLanes.map((lane) =>
|
||||
pointToSegmentDistance(decision!.target, lane.start, lane.end),
|
||||
));
|
||||
expect(minimumLaneDistance).toBeGreaterThan(EMBER_MANTIS_SLASH.aiClearance);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { BOSS_DEFINITIONS } from "../bossCatalog";
|
||||
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
|
||||
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
|
||||
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName } from "./shared";
|
||||
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
|
||||
|
||||
export const EMBER_MANTIS_SLASH = {
|
||||
firstAt: 5,
|
||||
repeatDelay: 3.4,
|
||||
sidestepDuration: 0.55,
|
||||
sidestepDistance: 3.8,
|
||||
sidestepSpeed: 7.2,
|
||||
telegraphDuration: 0.9,
|
||||
recoverDuration: 0.7,
|
||||
laneLength: 18,
|
||||
lineWidth: 1.65,
|
||||
crossWidth: 1.45,
|
||||
lineDamage: 32,
|
||||
crossDamage: 25,
|
||||
crossAngle: Math.PI * 0.18,
|
||||
staggerDuration: 0.35,
|
||||
aiClearance: 1.35,
|
||||
aiEvadeSpeed: 4.8,
|
||||
} as const;
|
||||
|
||||
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
|
||||
const MIN_BOSS_X = -5.8;
|
||||
const MAX_BOSS_X = 5.8;
|
||||
|
||||
export function createEmberMantisState(): BossState {
|
||||
const definition = BOSS_DEFINITIONS["ember-mantis-duelist"];
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
maxHp: definition.maxHp,
|
||||
hp: definition.maxHp,
|
||||
nextMeleeAt: 2.2,
|
||||
nextNovaAt: Number.POSITIVE_INFINITY,
|
||||
nextBrandAt: Number.POSITIVE_INFINITY,
|
||||
brandCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmberMantisMotion(): BossMotionState {
|
||||
return {
|
||||
...createBaseMotion("ember-mantis-duelist"),
|
||||
position: [0, -6.6],
|
||||
nextMechanicAt: EMBER_MANTIS_SLASH.firstAt,
|
||||
};
|
||||
}
|
||||
|
||||
function clampBossX(value: number) {
|
||||
return Math.max(MIN_BOSS_X, Math.min(MAX_BOSS_X, value));
|
||||
}
|
||||
|
||||
function createLane(
|
||||
id: string,
|
||||
center: WorldPosition,
|
||||
angle: number,
|
||||
width: number,
|
||||
damage: number,
|
||||
): SlashLane {
|
||||
const halfLength = EMBER_MANTIS_SLASH.laneLength * 0.5;
|
||||
const dx = Math.sin(angle) * halfLength;
|
||||
const dz = Math.cos(angle) * halfLength;
|
||||
return {
|
||||
id,
|
||||
start: [center[0] - dx, center[1] - dz],
|
||||
end: [center[0] + dx, center[1] + dz],
|
||||
width,
|
||||
damage,
|
||||
};
|
||||
}
|
||||
|
||||
function beginSidestep(
|
||||
motion: BossMotionState,
|
||||
party: BossMechanicContext["party"],
|
||||
partyPositions: BossMechanicContext["partyPositions"],
|
||||
time: number,
|
||||
) {
|
||||
const targetId = chooseLivingTarget(party, TARGET_ORDER, motion.mechanicCount);
|
||||
const direction = motion.mechanicCount % 2 === 0 ? 1 : -1;
|
||||
let targetX = clampBossX(motion.position[0] + direction * EMBER_MANTIS_SLASH.sidestepDistance);
|
||||
if (Math.abs(targetX - motion.position[0]) < 1) {
|
||||
targetX = clampBossX(motion.position[0] - direction * EMBER_MANTIS_SLASH.sidestepDistance);
|
||||
}
|
||||
return {
|
||||
...motion,
|
||||
mode: "mantis_sidestep" as const,
|
||||
chargeTargetId: targetId,
|
||||
chargeEnd: [targetX, motion.position[1]] as WorldPosition,
|
||||
phaseStartedAt: time,
|
||||
phaseEndsAt: time + EMBER_MANTIS_SLASH.sidestepDuration,
|
||||
nextMechanicAt: Number.POSITIVE_INFINITY,
|
||||
slashLanes: [],
|
||||
mechanicHitIds: [],
|
||||
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]] as WorldPosition,
|
||||
};
|
||||
}
|
||||
|
||||
function beginSlashTelegraph(
|
||||
motion: BossMotionState,
|
||||
partyPositions: BossMechanicContext["partyPositions"],
|
||||
time: number,
|
||||
) {
|
||||
const target = partyPositions[motion.chargeTargetId];
|
||||
const aimedAngle = angleTo(motion.position, target);
|
||||
const isCrossSlash = motion.mechanicCount % 2 === 1;
|
||||
const slashNumber = motion.mechanicCount + 1;
|
||||
const lanes = isCrossSlash
|
||||
? [
|
||||
createLane(`cross-${slashNumber}-left`, target, aimedAngle - EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
|
||||
createLane(`cross-${slashNumber}-right`, target, aimedAngle + EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
|
||||
]
|
||||
: [createLane(`line-${slashNumber}`, target, aimedAngle, EMBER_MANTIS_SLASH.lineWidth, EMBER_MANTIS_SLASH.lineDamage)];
|
||||
|
||||
return {
|
||||
...motion,
|
||||
mode: isCrossSlash ? "mantis_cross_telegraph" as const : "mantis_line_telegraph" as const,
|
||||
phaseStartedAt: time,
|
||||
phaseEndsAt: time + EMBER_MANTIS_SLASH.telegraphDuration,
|
||||
mechanicCount: slashNumber,
|
||||
slashLanes: lanes,
|
||||
mechanicHitIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSlash(
|
||||
motion: BossMotionState,
|
||||
context: BossMechanicContext,
|
||||
events: BossMechanicResult["events"],
|
||||
) {
|
||||
const isCrossSlash = motion.mode === "mantis_cross_telegraph";
|
||||
const hitIds: MemberId[] = [];
|
||||
const party = context.party.map((member) => {
|
||||
if (member.hp <= 0) return member;
|
||||
const lane = motion.slashLanes.find((candidate) =>
|
||||
pointToSegmentDistance(context.partyPositions[member.id], candidate.start, candidate.end) <= candidate.width * 0.5,
|
||||
);
|
||||
if (!lane) return member;
|
||||
hitIds.push(member.id);
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: `${member.name} is caught by ${isCrossSlash ? "Cross Slash" : "Line Slash"}.`,
|
||||
tone: "danger",
|
||||
pulseKind: "slash",
|
||||
targetId: member.id,
|
||||
});
|
||||
return {
|
||||
...context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time),
|
||||
knockedUntil: Math.max(member.knockedUntil, context.time + EMBER_MANTIS_SLASH.staggerDuration),
|
||||
};
|
||||
});
|
||||
return { party, hitIds };
|
||||
}
|
||||
|
||||
export function advanceEmberMantisMechanics(context: BossMechanicContext): BossMechanicResult {
|
||||
const boss = { ...context.boss };
|
||||
let motion = cloneMotion(context.motion);
|
||||
let party = context.party;
|
||||
const events: BossMechanicResult["events"] = [];
|
||||
|
||||
if (motion.mode === "holding") {
|
||||
const tank = context.partyPositions.brann;
|
||||
motion.position = moveToward(
|
||||
motion.position,
|
||||
[tank[0] + motion.formationOffsetX, tank[1] - 4.25],
|
||||
2.5 * context.delta,
|
||||
);
|
||||
if (context.time >= motion.nextMechanicAt) {
|
||||
motion = beginSidestep(motion, party, context.partyPositions, context.time);
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: `Ember Mantis sidesteps toward ${memberName(party, motion.chargeTargetId)}. Track the blades.`,
|
||||
tone: "danger",
|
||||
pulseKind: "slash",
|
||||
targetId: motion.chargeTargetId,
|
||||
});
|
||||
}
|
||||
} else if (motion.mode === "mantis_sidestep") {
|
||||
motion.position = moveToward(motion.position, motion.chargeEnd, EMBER_MANTIS_SLASH.sidestepSpeed * context.delta);
|
||||
if (context.time >= motion.phaseEndsAt) {
|
||||
motion = beginSlashTelegraph(motion, context.partyPositions, context.time);
|
||||
const cross = motion.mode === "mantis_cross_telegraph";
|
||||
events.push({
|
||||
at: context.time,
|
||||
message: cross ? "Cross Slash! Find a safe quadrant." : "Line Slash! Clear the glowing lane.",
|
||||
tone: "danger",
|
||||
pulseKind: "slash",
|
||||
targetId: motion.chargeTargetId,
|
||||
});
|
||||
}
|
||||
} else if (motion.mode === "mantis_line_telegraph" || motion.mode === "mantis_cross_telegraph") {
|
||||
if (context.time >= motion.phaseEndsAt) {
|
||||
const resolved = resolveSlash(motion, context, events);
|
||||
party = resolved.party;
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "mantis_recover",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: context.time + EMBER_MANTIS_SLASH.recoverDuration,
|
||||
mechanicHitIds: resolved.hitIds,
|
||||
};
|
||||
}
|
||||
} else if (motion.mode === "mantis_recover" && context.time >= motion.phaseEndsAt) {
|
||||
motion = {
|
||||
...motion,
|
||||
mode: "holding",
|
||||
phaseStartedAt: context.time,
|
||||
phaseEndsAt: 0,
|
||||
nextMechanicAt: context.time + EMBER_MANTIS_SLASH.repeatDelay,
|
||||
slashLanes: [],
|
||||
mechanicHitIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
applyMelee(boss, motion, party, context.partyPositions, context.time, 1.9, 14, context.damageMember);
|
||||
return { boss, motion, party, events };
|
||||
}
|
||||
|
||||
export function upcomingEmberMantisMechanic(
|
||||
boss: BossState,
|
||||
motion: BossMotionState,
|
||||
time: number,
|
||||
): UpcomingMechanic {
|
||||
void boss;
|
||||
if (motion.mode === "mantis_sidestep") {
|
||||
return { name: "Duelist repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "mantis_line_telegraph") {
|
||||
return { name: "Line Slash — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "mantis_cross_telegraph") {
|
||||
return { name: "Cross Slash — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
|
||||
}
|
||||
if (motion.mode === "mantis_recover") {
|
||||
return { name: "Cinderblade exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false };
|
||||
}
|
||||
const nextIsCross = motion.mechanicCount % 2 === 1;
|
||||
const remaining = Math.max(0, motion.nextMechanicAt - time);
|
||||
return {
|
||||
name: nextIsCross ? "Cross Slash" : "Line Slash",
|
||||
remaining,
|
||||
cycle: EMBER_MANTIS_SLASH.repeatDelay + EMBER_MANTIS_SLASH.telegraphDuration,
|
||||
urgent: remaining < 2.5,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
|
||||
breathStartAngle: 0,
|
||||
breathEndAngle: 0,
|
||||
hazards: [],
|
||||
slashLanes: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +51,11 @@ export function cloneMotion(source: BossMotionState): BossMotionState {
|
||||
nextDamageAt: { ...hazard.nextDamageAt },
|
||||
hitIds: [...hazard.hitIds],
|
||||
})),
|
||||
slashLanes: source.slashLanes.map((lane) => ({
|
||||
...lane,
|
||||
start: [lane.start[0], lane.start[1]],
|
||||
end: [lane.end[0], lane.end[1]],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BULL_CHARGE } from "./bossMechanics";
|
||||
import { CINDER_BREATH } from "./bosses/cindermaw";
|
||||
import { EMBER_MANTIS_SLASH } from "./bosses/emberMantis";
|
||||
import { moveToward, pointOutsideCircle, pointOutsideLane, pointToSegmentDistance } from "./geometry";
|
||||
import type { BossMotionState, MemberId, PartyMember, WorldPosition } from "./types";
|
||||
|
||||
@@ -122,6 +123,42 @@ export const avoidBreathBehavior: PartyBehavior = {
|
||||
},
|
||||
};
|
||||
|
||||
export const evadeSlashLanesBehavior: PartyBehavior = {
|
||||
id: "evade-slash-lanes",
|
||||
decide: ({ memberId, current, formationTarget, bossMotion }) => {
|
||||
if (bossMotion.mode !== "mantis_line_telegraph" && bossMotion.mode !== "mantis_cross_telegraph") return null;
|
||||
if (!bossMotion.slashLanes.length) return null;
|
||||
const unsafe = (position: WorldPosition) => bossMotion.slashLanes.some((lane) =>
|
||||
pointToSegmentDistance(position, lane.start, lane.end) < EMBER_MANTIS_SLASH.aiClearance,
|
||||
);
|
||||
if (!unsafe(current) && !unsafe(formationTarget)) return null;
|
||||
|
||||
const origin = unsafe(formationTarget) ? formationTarget : current;
|
||||
const memberOffset = AI_MEMBER_IDS.indexOf(memberId) * (Math.PI / 4);
|
||||
let best = current;
|
||||
let bestScore = Number.NEGATIVE_INFINITY;
|
||||
for (const radius of [2.2, 3.4]) {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const angle = memberOffset + (index / 8) * Math.PI * 2;
|
||||
const candidate = clampToArena([
|
||||
origin[0] + Math.sin(angle) * radius,
|
||||
origin[1] + Math.cos(angle) * radius,
|
||||
]);
|
||||
const laneDistance = Math.min(...bossMotion.slashLanes.map((lane) =>
|
||||
pointToSegmentDistance(candidate, lane.start, lane.end),
|
||||
));
|
||||
const travelPenalty = Math.hypot(candidate[0] - current[0], candidate[1] - current[1]) * 0.08;
|
||||
const score = laneDistance - travelPenalty;
|
||||
if (score > bestScore) {
|
||||
best = candidate;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { target: best, speed: EMBER_MANTIS_SLASH.aiEvadeSpeed };
|
||||
},
|
||||
};
|
||||
|
||||
export const avoidCircleHazardsBehavior: PartyBehavior = {
|
||||
id: "avoid-circle-hazards",
|
||||
decide: ({ memberId, current, formationTarget, bossMotion, time }) => {
|
||||
@@ -143,7 +180,7 @@ export const avoidCircleHazardsBehavior: PartyBehavior = {
|
||||
export const maintainFormationBehavior: PartyBehavior = {
|
||||
id: "maintain-formation",
|
||||
decide: ({ formationTarget, bossMotion, memberId }) => {
|
||||
if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall"].includes(bossMotion.mode)) return null;
|
||||
if (!["holding", "telegraph", "tethering", "venom_cast", "skyfall", "mantis_sidestep", "mantis_recover"].includes(bossMotion.mode)) return null;
|
||||
return { target: formationTarget, speed: MOVE_SPEEDS[memberId] };
|
||||
},
|
||||
};
|
||||
@@ -152,6 +189,7 @@ export const DEFAULT_PARTY_BEHAVIORS: readonly PartyBehavior[] = [
|
||||
breakTetherBehavior,
|
||||
stackForPounceBehavior,
|
||||
evadeChargeBehavior,
|
||||
evadeSlashLanesBehavior,
|
||||
avoidBreathBehavior,
|
||||
avoidCircleHazardsBehavior,
|
||||
maintainFormationBehavior,
|
||||
|
||||
@@ -144,7 +144,10 @@ describe("dual-boss damage simulations", () => {
|
||||
const combinations: readonly (readonly [BossId, BossId])[] = [
|
||||
["bulldrome", "vexa"],
|
||||
["bulldrome", "cindermaw"],
|
||||
["bulldrome", "ember-mantis-duelist"],
|
||||
["vexa", "cindermaw"],
|
||||
["vexa", "ember-mantis-duelist"],
|
||||
["cindermaw", "ember-mantis-duelist"],
|
||||
];
|
||||
|
||||
it.each(combinations)("defeats %s + %s using explicit party abilities", (first, second) => {
|
||||
|
||||
@@ -173,6 +173,19 @@ describe("Disc Priest combat simulation", () => {
|
||||
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", () => {
|
||||
while (useGameStore.getState().time < 7.1) useGameStore.getState().tick(0.1);
|
||||
const telegraph = useGameStore.getState().bossMotion;
|
||||
|
||||
+51
-5
@@ -43,7 +43,7 @@ export interface AdditionalBossState {
|
||||
motion: BossMotionState;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
export interface GameState {
|
||||
bossId: BossId;
|
||||
paused: boolean;
|
||||
pauseSelection: "resume" | "exit";
|
||||
@@ -261,10 +261,20 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
setPaused: (paused) => set({ paused, pauseSelection: "resume" }),
|
||||
togglePause: () => set((state) => ({ paused: !state.paused, pauseSelection: "resume" })),
|
||||
setPauseSelection: (pauseSelection) => set({ pauseSelection }),
|
||||
setPlayerPosition: (playerPosition) => set((state) => ({
|
||||
playerPosition,
|
||||
partyPositions: { ...state.partyPositions, aelia: [...playerPosition] },
|
||||
})),
|
||||
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,
|
||||
partyPositions: { ...state.partyPositions, aelia: [...playerPosition] },
|
||||
};
|
||||
}),
|
||||
|
||||
castAbility: (abilityId) => {
|
||||
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>) {
|
||||
return Math.max(0, cooldowns[abilityId] - time);
|
||||
}
|
||||
|
||||
+16
-3
@@ -1,9 +1,9 @@
|
||||
export type MemberId = "aelia" | "brann" | "nia" | "orin" | "vale";
|
||||
export type AbilityId = "mend" | "renew" | "shield" | "purify" | "radiance" | "barrier";
|
||||
export type BossId = "bulldrome" | "vexa" | "cindermaw";
|
||||
export type BossId = "bulldrome" | "vexa" | "cindermaw" | "ember-mantis-duelist";
|
||||
export type GamePhase = "briefing" | "combat" | "victory" | "defeat";
|
||||
export type BottomTab = "combat" | "map" | "pack";
|
||||
export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall";
|
||||
export type PulseKind = AbilityId | "boss" | "debuff" | "charge" | "pounce" | "tether" | "venom" | "breath" | "skyfall" | "slash";
|
||||
export type BossMotionMode =
|
||||
| "holding"
|
||||
| "telegraph"
|
||||
@@ -15,7 +15,11 @@ export type BossMotionMode =
|
||||
| "venom_cast"
|
||||
| "breath_telegraph"
|
||||
| "breath_sweeping"
|
||||
| "skyfall";
|
||||
| "skyfall"
|
||||
| "mantis_sidestep"
|
||||
| "mantis_line_telegraph"
|
||||
| "mantis_cross_telegraph"
|
||||
| "mantis_recover";
|
||||
export type WorldPosition = [number, number];
|
||||
|
||||
export type CircleHazardKind = "venom_pool" | "skyfall";
|
||||
@@ -34,6 +38,14 @@ export interface CircleHazard {
|
||||
hitIds: MemberId[];
|
||||
}
|
||||
|
||||
export interface SlashLane {
|
||||
id: string;
|
||||
start: WorldPosition;
|
||||
end: WorldPosition;
|
||||
width: number;
|
||||
damage: number;
|
||||
}
|
||||
|
||||
export interface Debuff {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -95,6 +107,7 @@ export interface BossMotionState {
|
||||
breathStartAngle: number;
|
||||
breathEndAngle: number;
|
||||
hazards: CircleHazard[];
|
||||
slashLanes: SlashLane[];
|
||||
}
|
||||
|
||||
export interface AbilityDefinition {
|
||||
|
||||
+28
-38
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { subscribeControllerToken } from "../input/controller";
|
||||
import { ABILITY_ORDER } from "./data";
|
||||
import { useGameStore } from "./store";
|
||||
import type { AbilityId } from "./types";
|
||||
@@ -84,43 +85,32 @@ export function useActionBindings(enabled = true, onExit?: () => void) {
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
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();
|
||||
if (store.paused) {
|
||||
if (buttons[12] && !previousButtons[12]) store.setPauseSelection("resume");
|
||||
if (buttons[13] && !previousButtons[13]) store.setPauseSelection("exit");
|
||||
if ((buttons[1] && !previousButtons[1]) || (buttons[9] && !previousButtons[9])) store.setPaused(false);
|
||||
if (buttons[0] && !previousButtons[0]) {
|
||||
if (store.pauseSelection === "resume") store.setPaused(false);
|
||||
else exitRef.current?.();
|
||||
}
|
||||
} else {
|
||||
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 (buttons[13] && !previousButtons[13]) store.cycleMember(1);
|
||||
if (buttons[8] && !previousButtons[8]) store.setActiveTab(store.activeTab === "map" ? "combat" : "map");
|
||||
if (buttons[9] && !previousButtons[9]) {
|
||||
if (store.phase === "briefing") store.startEncounter();
|
||||
if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
||||
if (store.phase === "combat") store.setPaused(true);
|
||||
}
|
||||
}
|
||||
previousButtons = buttons;
|
||||
} else {
|
||||
previousButtons = [];
|
||||
useEffect(() => subscribeControllerToken(({ token, repeat }) => {
|
||||
if (!enabled) return;
|
||||
const store = useGameStore.getState();
|
||||
if (store.paused) {
|
||||
if (token === "Button12" || token === "Axis1-") store.setPauseSelection("resume");
|
||||
if (token === "Button13" || token === "Axis1+") store.setPauseSelection("exit");
|
||||
if (repeat) return;
|
||||
if (token === "Button1" || token === "Button9") store.setPaused(false);
|
||||
if (token === "Button0") {
|
||||
if (store.pauseSelection === "resume") store.setPaused(false);
|
||||
else exitRef.current?.();
|
||||
}
|
||||
frame = requestAnimationFrame(poll);
|
||||
};
|
||||
frame = requestAnimationFrame(poll);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [enabled]);
|
||||
return;
|
||||
}
|
||||
if (repeat) return;
|
||||
if (token.startsWith("Button")) {
|
||||
const ability = gamepadAbilityMap[Number(token.slice("Button".length))];
|
||||
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();
|
||||
else if (store.phase === "victory" || store.phase === "defeat") store.restart();
|
||||
else if (store.phase === "combat") store.setPaused(true);
|
||||
}
|
||||
}), [enabled]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { subscribeControllerToken } from "./controller";
|
||||
|
||||
export interface MenuAction {
|
||||
id: string;
|
||||
@@ -68,41 +69,16 @@ export function useMenuController(actions: MenuAction[], options: MenuController
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [confirm, move]);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
let previous: boolean[] = [];
|
||||
let heldDirection: Direction | null = null;
|
||||
let nextRepeatAt = 0;
|
||||
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;
|
||||
if (direction && (direction !== heldDirection || now >= nextRepeatAt)) {
|
||||
move(direction);
|
||||
nextRepeatAt = direction === heldDirection ? now + 120 : now + 360;
|
||||
heldDirection = direction;
|
||||
} 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]);
|
||||
useEffect(() => subscribeControllerToken(({ token, repeat }) => {
|
||||
const direction: Direction | null = token === "Button12" || token === "Axis1-" ? "up"
|
||||
: token === "Button13" || token === "Axis1+" ? "down"
|
||||
: token === "Button14" || token === "Axis0-" ? "left"
|
||||
: token === "Button15" || token === "Axis0+" ? "right"
|
||||
: null;
|
||||
if (direction) move(direction);
|
||||
else if (!repeat && token === "Button0") confirm();
|
||||
else if (!repeat && token === "Button1") backRef.current?.();
|
||||
}), [confirm, move]);
|
||||
|
||||
return {
|
||||
focusedId,
|
||||
|
||||
+9
-1
@@ -2,16 +2,24 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import App from "./App";
|
||||
import { BottomDisplayApp } from "./platform/BottomDisplayApp";
|
||||
import { startControllerInput } from "./input/controller";
|
||||
import "./styles.css";
|
||||
|
||||
const nativeLayoutRequested = new URLSearchParams(window.location.search).has("nativeLayout");
|
||||
const displayMode = new URLSearchParams(window.location.search).get("display");
|
||||
|
||||
if (Capacitor.isNativePlatform() || nativeLayoutRequested) {
|
||||
document.documentElement.classList.add("native-platform");
|
||||
}
|
||||
if (displayMode === "top" || displayMode === "bottom") {
|
||||
document.documentElement.dataset.displaySurface = displayMode;
|
||||
}
|
||||
|
||||
startControllerInput();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
{displayMode === "bottom" ? <BottomDisplayApp /> : <App />}
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -1335,6 +1335,67 @@ button:focus-visible {
|
||||
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 */
|
||||
|
||||
.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 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-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 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 > .controller-legend { position: absolute; right: 44px; bottom: 47px; }
|
||||
.login-context { padding: 0; }
|
||||
|
||||
Reference in New Issue
Block a user