diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..fdc5324 --- /dev/null +++ b/DEPLOYMENT.md @@ -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. diff --git a/README.md b/README.md index f19eae8..7d26387 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java b/android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java new file mode 100644 index 0000000..e9ac063 --- /dev/null +++ b/android/app/src/main/java/com/phenomrom/iwanttoheal/ControllerBridgeActivity.java @@ -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 activeMotionTokens = new HashSet<>(); + private final Map 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 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 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; + } + } +} diff --git a/android/app/src/main/java/com/phenomrom/iwanttoheal/MainActivity.java b/android/app/src/main/java/com/phenomrom/iwanttoheal/MainActivity.java index b77f7aa..412acb0 100644 --- a/android/app/src/main/java/com/phenomrom/iwanttoheal/MainActivity.java +++ b/android/app/src/main/java/com/phenomrom/iwanttoheal/MainActivity.java @@ -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 - ); } } diff --git a/android/app/src/main/java/com/phenomrom/iwanttoheal/ThorDualScreenPlugin.java b/android/app/src/main/java/com/phenomrom/iwanttoheal/ThorDualScreenPlugin.java new file mode 100644 index 0000000..1e24607 --- /dev/null +++ b/android/app/src/main/java/com/phenomrom/iwanttoheal/ThorDualScreenPlugin.java @@ -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; + } + } +} diff --git a/compose.yaml b/compose.yaml index 40b524f..b7049db 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/game_assets/models/original/bosses/ember-mantis-duelist/README.md b/game_assets/models/original/bosses/ember-mantis-duelist/README.md new file mode 100644 index 0000000..c8c8075 --- /dev/null +++ b/game_assets/models/original/bosses/ember-mantis-duelist/README.md @@ -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. diff --git a/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.asset.json b/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.asset.json new file mode 100644 index 0000000..183c8e6 --- /dev/null +++ b/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.asset.json @@ -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 + } + } +} diff --git a/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.blend b/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.blend new file mode 100644 index 0000000..e2f50ff Binary files /dev/null and b/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.blend differ diff --git a/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.blend1 b/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.blend1 new file mode 100644 index 0000000..7c3e368 Binary files /dev/null and b/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.blend1 differ diff --git a/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.glb b/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.glb new file mode 100644 index 0000000..ea199d8 Binary files /dev/null and b/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.glb differ diff --git a/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist_collision.glb b/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist_collision.glb new file mode 100644 index 0000000..3418f7f Binary files /dev/null and b/game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist_collision.glb differ diff --git a/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_cross_slash.png b/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_cross_slash.png new file mode 100644 index 0000000..39de9d3 Binary files /dev/null and b/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_cross_slash.png differ diff --git a/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_front.png b/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_front.png new file mode 100644 index 0000000..29dd7a4 Binary files /dev/null and b/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_front.png differ diff --git a/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_side.png b/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_side.png new file mode 100644 index 0000000..c5acac4 Binary files /dev/null and b/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_side.png differ diff --git a/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_three_quarter.png b/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_three_quarter.png new file mode 100644 index 0000000..93c5130 Binary files /dev/null and b/game_assets/models/original/bosses/ember-mantis-duelist/previews/ember_mantis_three_quarter.png differ diff --git a/package.json b/package.json index fe41c90..194158f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "i-want-to-heal", "private": true, - "version": "0.1.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/scripts/blender/build_ember_mantis_duelist.py b/scripts/blender/build_ember_mantis_duelist.py new file mode 100644 index 0000000..7127200 --- /dev/null +++ b/scripts/blender/build_ember_mantis_duelist.py @@ -0,0 +1,832 @@ +"""Build Ember Mantis Duelist as a rigged, animated, runtime-ready GLB. + +Run with: + blender --background --factory-startup --python scripts/blender/build_ember_mantis_duelist.py + +The asset is an original 3D interpretation of the IWT2 Ember Mantis concept. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +import bpy +from mathutils import Vector + + +ROOT = Path(__file__).resolve().parents[2] +OUT_DIR = ROOT / "game_assets/models/original/bosses/ember-mantis-duelist" +PREVIEW_DIR = OUT_DIR / "previews" +BLEND_PATH = OUT_DIR / "ember_mantis_duelist.blend" +GLB_PATH = OUT_DIR / "ember_mantis_duelist.glb" +COLLISION_PATH = OUT_DIR / "ember_mantis_duelist_collision.glb" +METADATA_PATH = OUT_DIR / "ember_mantis_duelist.asset.json" + +FPS = 30 +TAU = math.tau +PARTS: list[bpy.types.Object] = [] +RUNTIME_MATERIALS: list[bpy.types.Material] = [] + + +def reset_scene() -> None: + bpy.ops.object.mode_set(mode="OBJECT") if bpy.context.object and bpy.context.object.mode != "OBJECT" else None + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for datablocks in ( + bpy.data.meshes, + bpy.data.curves, + bpy.data.armatures, + bpy.data.materials, + bpy.data.cameras, + bpy.data.lights, + bpy.data.actions, + ): + for datablock in list(datablocks): + datablocks.remove(datablock) + + +def make_material( + name: str, + color: tuple[float, float, float, float], + *, + metallic: float = 0.0, + roughness: float = 0.5, + emission: tuple[float, float, float, float] | None = None, + emission_strength: float = 0.0, +) -> bpy.types.Material: + mat = bpy.data.materials.new(name) + mat.use_nodes = True + mat.diffuse_color = color + mat.metallic = metallic + mat.roughness = roughness + bsdf = mat.node_tree.nodes.get("Principled BSDF") + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + if emission: + bsdf.inputs["Emission Color"].default_value = emission + bsdf.inputs["Emission Strength"].default_value = emission_strength + return mat + + +def assign_rigid_group(obj: bpy.types.Object, bone_name: str) -> bpy.types.Object: + group = obj.vertex_groups.new(name=bone_name) + group.add(range(len(obj.data.vertices)), 1.0, "REPLACE") + PARTS.append(obj) + return obj + + +def finish_mesh( + obj: bpy.types.Object, + name: str, + material: bpy.types.Material, + bone_name: str, + *, + smooth: bool = True, +) -> bpy.types.Object: + obj.name = name + obj.data.name = f"{name}_Mesh" + # Every source part uses the same ordered slots. Blender 5.1 otherwise keeps + # joined material slots but resets joined face indices to slot zero. + for runtime_material in RUNTIME_MATERIALS: + obj.data.materials.append(runtime_material) + material_index = next( + index for index, runtime_material in enumerate(RUNTIME_MATERIALS) if runtime_material.name == material.name + ) + for polygon in obj.data.polygons: + polygon.material_index = material_index + material_group = obj.vertex_groups.new(name=f"__MAT_{material_index}") + material_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE") + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + if smooth: + for polygon in obj.data.polygons: + polygon.use_smooth = True + return assign_rigid_group(obj, bone_name) + + +def add_ellipsoid( + name: str, + location: tuple[float, float, float], + scale: tuple[float, float, float], + material: bpy.types.Material, + bone_name: str, + *, + subdivisions: int = 2, + rotation: tuple[float, float, float] = (0.0, 0.0, 0.0), +) -> bpy.types.Object: + bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdivisions, radius=1.0, location=location, rotation=rotation) + obj = bpy.context.object + obj.scale = scale + return finish_mesh(obj, name, material, bone_name) + + +def add_cone_between( + name: str, + start: tuple[float, float, float], + end: tuple[float, float, float], + radius_start: float, + radius_end: float, + material: bpy.types.Material, + bone_name: str, + *, + vertices: int = 8, +) -> bpy.types.Object: + start_v = Vector(start) + end_v = Vector(end) + direction = end_v - start_v + midpoint = (start_v + end_v) * 0.5 + bpy.ops.mesh.primitive_cone_add( + vertices=vertices, + radius1=radius_start, + radius2=radius_end, + depth=direction.length, + location=midpoint, + ) + obj = bpy.context.object + obj.rotation_mode = "QUATERNION" + obj.rotation_quaternion = direction.to_track_quat("Z", "Y") + obj.rotation_mode = "XYZ" + bpy.context.view_layer.objects.active = obj + bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) + return finish_mesh(obj, name, material, bone_name) + + +def add_blade( + name: str, + side: float, + material: bpy.types.Material, + bone_name: str, + *, + variant: str = "main", +) -> bpy.types.Object: + # Extruded hook polygon. Forward is Blender -Y; mirrored across X. + x_center = side * 1.42 + thickness = {"main": 0.095, "edge": 0.104, "scar": 0.108}[variant] + if variant == "edge": + yz = [ + (-0.72, 2.78), + (-1.34, 2.55), + (-2.62, 1.72), + (-2.42, 2.06), + (-1.31, 2.78), + ] + elif variant == "scar": + yz = [ + (-0.82, 2.94), + (-1.33, 2.72), + (-2.30, 2.02), + (-2.10, 2.33), + (-1.33, 2.91), + ] + else: + yz = [ + (-0.50, 3.12), + (-0.72, 2.78), + (-1.34, 2.55), + (-2.62, 1.72), + (-2.31, 2.28), + (-1.45, 3.18), + (-0.82, 3.34), + ] + vertices = [] + for x in (x_center - thickness, x_center + thickness): + vertices.extend((x, y, z) for y, z in yz) + count = len(yz) + faces = [tuple(range(count)), tuple(range(count, count * 2))[::-1]] + for index in range(count): + nxt = (index + 1) % count + faces.append((index, nxt, count + nxt, count + index)) + mesh = bpy.data.meshes.new(f"{name}_Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.validate() + mesh.update() + obj = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(obj) + return finish_mesh(obj, name, material, bone_name, smooth=False) + + +def add_plate( + name: str, + location: tuple[float, float, float], + scale: tuple[float, float, float], + rotation: tuple[float, float, float], + material: bpy.types.Material, + bone_name: str, +) -> bpy.types.Object: + bpy.ops.mesh.primitive_cone_add(vertices=6, radius1=1.0, radius2=0.72, depth=0.46, location=location, rotation=rotation) + obj = bpy.context.object + obj.scale = scale + return finish_mesh(obj, name, material, bone_name, smooth=False) + + +def create_armature() -> bpy.types.Object: + arm_data = bpy.data.armatures.new("EmberMantis_Rig") + armature = bpy.data.objects.new("EmberMantis_Rig", arm_data) + bpy.context.collection.objects.link(armature) + bpy.context.view_layer.objects.active = armature + armature.select_set(True) + bpy.ops.object.mode_set(mode="EDIT") + + bones = { + "Root": ((0, 0, 0), (0, 0, 0.55), None), + "Pelvis": ((0, 0, 1.70), (0, 0, 2.35), "Root"), + "Abdomen": ((0, 0.05, 2.15), (0, 0.42, 2.78), "Pelvis"), + "Tail": ((0, 0.38, 2.58), (0, 1.30, 2.18), "Abdomen"), + "Chest": ((0, 0.02, 2.65), (0, -0.04, 3.48), "Abdomen"), + "Neck": ((0, -0.03, 3.38), (0, -0.14, 3.88), "Chest"), + "Head": ((0, -0.13, 3.78), (0, -0.34, 4.35), "Neck"), + "UpperArm.L": ((-0.34, -0.02, 3.30), (-0.98, -0.27, 3.20), "Chest"), + "Forearm.L": ((-0.98, -0.27, 3.20), (-1.38, -0.60, 3.02), "UpperArm.L"), + "Blade.L": ((-1.38, -0.60, 3.02), (-1.46, -1.48, 2.55), "Forearm.L"), + "UpperArm.R": ((0.34, -0.02, 3.30), (0.98, -0.27, 3.20), "Chest"), + "Forearm.R": ((0.98, -0.27, 3.20), (1.38, -0.60, 3.02), "UpperArm.R"), + "Blade.R": ((1.38, -0.60, 3.02), (1.46, -1.48, 2.55), "Forearm.R"), + "Thigh.L": ((-0.30, 0.02, 2.00), (-0.66, 0.02, 1.18), "Pelvis"), + "Shin.L": ((-0.66, 0.02, 1.18), (-0.76, -0.34, 0.38), "Thigh.L"), + "Foot.L": ((-0.76, -0.34, 0.38), (-0.76, -0.96, 0.15), "Shin.L"), + "Thigh.R": ((0.30, 0.02, 2.00), (0.66, 0.02, 1.18), "Pelvis"), + "Shin.R": ((0.66, 0.02, 1.18), (0.76, -0.34, 0.38), "Thigh.R"), + "Foot.R": ((0.76, -0.34, 0.38), (0.76, -0.96, 0.15), "Shin.R"), + } + for name, (head, tail, parent) in bones.items(): + bone = arm_data.edit_bones.new(name) + bone.head = head + bone.tail = tail + if parent: + bone.parent = arm_data.edit_bones[parent] + bpy.ops.object.mode_set(mode="POSE") + for pose_bone in armature.pose.bones: + pose_bone.rotation_mode = "XYZ" + bpy.ops.object.mode_set(mode="OBJECT") + armature.show_in_front = True + return armature + + +def create_model(armature: bpy.types.Object, mats: dict[str, bpy.types.Material]) -> bpy.types.Object: + chitin = mats["chitin"] + crimson = mats["crimson"] + deep_red = mats["deep_red"] + ember = mats["ember"] + bone = mats["bone"] + eye = mats["eye"] + + # Core insect anatomy. + add_ellipsoid("Thorax", (0, 0.00, 3.05), (0.54, 0.47, 0.72), chitin, "Chest") + add_ellipsoid("ChestArmor", (0, -0.35, 3.18), (0.47, 0.18, 0.57), crimson, "Chest") + add_ellipsoid("Pelvis", (0, 0.08, 2.13), (0.48, 0.42, 0.55), deep_red, "Pelvis") + add_ellipsoid("Abdomen", (0, 0.52, 2.34), (0.58, 0.82, 0.49), chitin, "Abdomen") + add_ellipsoid("TailMass", (0, 1.18, 2.10), (0.56, 0.92, 0.40), deep_red, "Tail") + add_cone_between("TailTip", (0, 1.44, 2.13), (0, 2.20, 1.88), 0.50, 0.06, crimson, "Tail", vertices=7) + + # Overlapping abdominal armor plates. + for index, y in enumerate((0.33, 0.72, 1.10, 1.47)): + scale = 0.52 - index * 0.055 + add_plate( + f"AbdomenPlate_{index + 1}", + (0, y, 2.50 - index * 0.10), + (scale, 0.42, 0.20), + (math.radians(83), 0, 0), + crimson if index % 2 == 0 else deep_red, + "Abdomen" if index < 2 else "Tail", + ) + for side in (-1, 1): + add_cone_between( + f"AbdomenSpike_{index}_{side:+d}", + (side * scale * 0.72, y, 2.48 - index * 0.10), + (side * (scale + 0.38), y + 0.08, 2.34 - index * 0.12), + 0.10, + 0.0, + crimson, + "Abdomen" if index < 2 else "Tail", + vertices=6, + ) + + # Neck, predatory head, jaws, eyes, crown spikes. + add_cone_between("NeckCore", (0, -0.03, 3.40), (0, -0.20, 3.88), 0.30, 0.25, chitin, "Neck") + add_ellipsoid("Head", (0, -0.34, 4.10), (0.43, 0.50, 0.38), crimson, "Head") + add_ellipsoid("FaceMask", (0, -0.73, 4.05), (0.32, 0.19, 0.28), chitin, "Head") + for side in (-1, 1): + suffix = "L" if side < 0 else "R" + add_ellipsoid(f"Eye_{suffix}", (side * 0.30, -0.67, 4.16), (0.09, 0.045, 0.075), eye, "Head", subdivisions=1) + add_cone_between(f"Mandible_{suffix}", (side * 0.18, -0.70, 3.98), (side * 0.34, -1.05, 3.83), 0.10, 0.02, bone, "Head", vertices=6) + add_cone_between(f"AntennaBase_{suffix}", (side * 0.22, -0.33, 4.34), (side * 0.46, -0.45, 4.82), 0.055, 0.035, deep_red, "Head", vertices=6) + add_cone_between(f"AntennaTip_{suffix}", (side * 0.46, -0.45, 4.82), (side * 0.76, -0.88, 5.20), 0.04, 0.0, ember, "Head", vertices=6) + add_cone_between(f"CrownSpike_{suffix}", (side * 0.23, -0.05, 4.31), (side * 0.48, 0.13, 4.90), 0.12, 0.0, crimson, "Head", vertices=6) + add_cone_between("CrownSpike_Center", (0, 0.02, 4.34), (0, 0.32, 5.02), 0.13, 0.0, crimson, "Head", vertices=6) + + # Scythe arms: dark joints, crimson armor, pale blades, emissive blade scars. + for side in (-1, 1): + suffix = "L" if side < 0 else "R" + bones = (f"UpperArm.{suffix}", f"Forearm.{suffix}", f"Blade.{suffix}") + add_cone_between( + f"UpperArm_{suffix}", + (side * 0.34, -0.02, 3.30), + (side * 0.98, -0.27, 3.20), + 0.25, + 0.17, + chitin, + bones[0], + ) + add_plate( + f"ShoulderPlate_{suffix}", + (side * 0.55, -0.07, 3.42), + (0.27, 0.33, 0.24), + (0, math.radians(side * 18), math.radians(side * 12)), + crimson, + bones[0], + ) + add_cone_between( + f"Forearm_{suffix}", + (side * 0.98, -0.27, 3.20), + (side * 1.40, -0.64, 3.02), + 0.19, + 0.13, + deep_red, + bones[1], + ) + add_ellipsoid(f"BladeJoint_{suffix}", (side * 1.41, -0.62, 3.02), (0.22, 0.24, 0.22), crimson, bones[2], subdivisions=1) + add_blade(f"ScytheBlade_{suffix}", side, deep_red, bones[2]) + add_blade(f"ScytheBoneEdge_{suffix}", side, bone, bones[2], variant="edge") + add_blade(f"ScytheEmberScar_{suffix}", side, ember, bones[2], variant="scar") + add_cone_between( + f"ElbowSpike_{suffix}", + (side * 1.00, -0.22, 3.30), + (side * 1.28, 0.04, 3.55), + 0.09, + 0.0, + crimson, + bones[1], + vertices=6, + ) + + # Digitigrade legs and hooked feet. + for side in (-1, 1): + suffix = "L" if side < 0 else "R" + thigh_bone, shin_bone, foot_bone = f"Thigh.{suffix}", f"Shin.{suffix}", f"Foot.{suffix}" + add_cone_between(f"Thigh_{suffix}", (side * 0.30, 0.02, 2.00), (side * 0.66, 0.02, 1.18), 0.25, 0.18, chitin, thigh_bone) + add_plate( + f"ThighPlate_{suffix}", + (side * 0.47, -0.02, 1.72), + (0.28, 0.31, 0.34), + (0, math.radians(side * 12), 0), + crimson, + thigh_bone, + ) + add_cone_between(f"Shin_{suffix}", (side * 0.66, 0.02, 1.18), (side * 0.76, -0.34, 0.38), 0.18, 0.11, deep_red, shin_bone) + add_cone_between(f"Foot_{suffix}", (side * 0.76, -0.34, 0.38), (side * 0.76, -0.96, 0.15), 0.14, 0.08, chitin, foot_bone) + for toe_index, toe_x in enumerate((-0.13, 0.0, 0.13)): + add_cone_between( + f"Toe_{suffix}_{toe_index + 1}", + (side * 0.76 + toe_x, -0.88, 0.15), + (side * 0.76 + toe_x * 1.35, -1.25, 0.07), + 0.055, + 0.0, + bone, + foot_bone, + vertices=5, + ) + add_cone_between( + f"KneeSpike_{suffix}", + (side * 0.68, 0.00, 1.20), + (side * 0.95, 0.28, 1.25), + 0.09, + 0.0, + crimson, + shin_bone, + vertices=6, + ) + + # Ember fissures: limited emissive geometry gives strong read without textures. + fissures = [ + ("ChestFissure", (-0.05, -0.535, 3.48), (0.12, -0.535, 2.92), "Chest"), + ("AbdomenFissure", (-0.07, -0.27, 2.47), (0.15, -0.31, 2.11), "Abdomen"), + ] + for name, start, end, group in fissures: + add_cone_between(name, start, end, 0.035, 0.02, ember, group, vertices=5) + + # Join all render meshes into one skinned mesh; keep four material primitives. + bpy.ops.object.select_all(action="DESELECT") + for obj in PARTS: + obj.select_set(True) + bpy.context.view_layer.objects.active = PARTS[0] + bpy.ops.object.join() + body = bpy.context.object + body.name = "EmberMantis_Body" + body.data.name = "EmberMantis_Body_Mesh" + consolidate_material_slots(body) + modifier = body.modifiers.new("EmberMantis_Armature", "ARMATURE") + modifier.object = armature + modifier.use_deform_preserve_volume = False + body.parent = armature + return body + + +def consolidate_material_slots(obj: bpy.types.Object) -> None: + old_materials = [slot.material for slot in obj.material_slots] + unique: list[bpy.types.Material] = [] + new_index_by_name: dict[str, int] = {} + old_to_new: dict[int, int] = {} + for old_index, material in enumerate(old_materials): + if material.name not in new_index_by_name: + new_index_by_name[material.name] = len(unique) + unique.append(material) + old_to_new[old_index] = new_index_by_name[material.name] + material_groups = { + group.index: int(group.name.removeprefix("__MAT_")) + for group in obj.vertex_groups + if group.name.startswith("__MAT_") + } + face_material_ids: list[int] = [] + for polygon in obj.data.polygons: + vertex = obj.data.vertices[polygon.vertices[0]] + face_material_ids.append(next( + material_groups[assignment.group] + for assignment in vertex.groups + if assignment.group in material_groups + )) + obj.data.materials.clear() + for material in unique: + obj.data.materials.append(material) + for polygon, material_id in zip(obj.data.polygons, face_material_ids, strict=True): + polygon.material_index = material_id + for group in [group for group in obj.vertex_groups if group.name.startswith("__MAT_")]: + obj.vertex_groups.remove(group) + + +def reset_pose(armature: bpy.types.Object) -> None: + for bone in armature.pose.bones: + bone.location = (0, 0, 0) + bone.rotation_euler = (0, 0, 0) + bone.scale = (1, 1, 1) + + +def key_pose( + armature: bpy.types.Object, + frame: int, + rotations: dict[str, tuple[float, float, float]] | None = None, + locations: dict[str, tuple[float, float, float]] | None = None, + scales: dict[str, tuple[float, float, float]] | None = None, +) -> None: + reset_pose(armature) + for name, value in (rotations or {}).items(): + armature.pose.bones[name].rotation_euler = tuple(math.radians(component) for component in value) + for name, value in (locations or {}).items(): + armature.pose.bones[name].location = value + for name, value in (scales or {}).items(): + armature.pose.bones[name].scale = value + for bone in armature.pose.bones: + bone.keyframe_insert("location", frame=frame, group=bone.name) + bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name) + bone.keyframe_insert("scale", frame=frame, group=bone.name) + + +def build_action( + armature: bpy.types.Object, + name: str, + end_frame: int, + poses: list[dict], + *, + loop: bool = False, +) -> bpy.types.Action: + action = bpy.data.actions.new(name) + action.use_fake_user = True + action.use_frame_range = True + action.frame_start = 1 + action.frame_end = end_frame + action.use_cyclic = loop + armature.animation_data.action = action + for pose in poses: + key_pose(armature, **pose) + armature.animation_data.action = None + return action + + +def create_animations(armature: bpy.types.Object) -> dict[str, dict]: + armature.animation_data_create() + clips: dict[str, dict] = {} + + def add(name: str, frames: int, poses: list[dict], loop: bool = False) -> None: + build_action(armature, name, frames, poses, loop=loop) + clips[name] = {"frames": frames, "seconds": round(frames / FPS, 3), "loop": loop} + + add( + "Idle", + 60, + [ + {"frame": 1, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}}, + {"frame": 16, "locations": {"Root": (0, 0, 0.045)}, "rotations": {"Chest": (-2, 0, 2), "Abdomen": (3, 0, -2), "Head": (-2, 0, -3), "Blade.L": (2, 0, 2), "Blade.R": (-2, 0, -2)}}, + {"frame": 31, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}}, + {"frame": 46, "locations": {"Root": (0, 0, 0.045)}, "rotations": {"Chest": (-2, 0, 2), "Abdomen": (3, 0, -2), "Head": (-2, 0, -3), "Blade.L": (2, 0, 2), "Blade.R": (-2, 0, -2)}}, + {"frame": 60, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}}, + ], + loop=True, + ) + + walk_a = {"Thigh.L": (-24, 0, 4), "Shin.L": (18, 0, 0), "Foot.L": (-8, 0, 0), "Thigh.R": (22, 0, -4), "Shin.R": (-12, 0, 0), "Foot.R": (8, 0, 0), "UpperArm.L": (5, 0, 2), "UpperArm.R": (-5, 0, -2)} + walk_b = {name: tuple(-component for component in rotation) for name, rotation in walk_a.items()} + add( + "Walk", + 30, + [ + {"frame": 1, "rotations": walk_a}, + {"frame": 8, "locations": {"Root": (0, 0, 0.055)}, "rotations": {"Chest": (-3, 0, 0), "Abdomen": (4, 0, 0)}}, + {"frame": 16, "rotations": walk_b}, + {"frame": 23, "locations": {"Root": (0, 0, 0.055)}, "rotations": {"Chest": (-3, 0, 0), "Abdomen": (4, 0, 0)}}, + {"frame": 30, "rotations": walk_a}, + ], + loop=True, + ) + + add( + "Sidestep", + 18, + [ + {"frame": 1, "rotations": {"Chest": (0, 0, 0)}}, + {"frame": 5, "locations": {"Root": (-0.10, 0, -0.03)}, "rotations": {"Chest": (0, -8, -12), "Thigh.L": (8, 0, 15), "Thigh.R": (-10, 0, 16), "Blade.L": (0, 0, 8), "Blade.R": (0, 0, 12)}}, + {"frame": 11, "locations": {"Root": (0.12, 0, 0.04)}, "rotations": {"Chest": (0, 7, 10), "Thigh.L": (-8, 0, -12), "Thigh.R": (10, 0, -15)}}, + {"frame": 18, "rotations": {"Chest": (0, 0, 0)}}, + ], + ) + + add( + "Melee", + 24, + [ + {"frame": 1}, + {"frame": 7, "rotations": {"Chest": (0, 0, 7), "UpperArm.R": (-12, 18, 25), "Forearm.R": (10, 0, 38), "Blade.R": (-16, 5, 42), "UpperArm.L": (5, 0, -8)}}, + {"frame": 11, "locations": {"Root": (0, -0.08, 0)}, "rotations": {"Chest": (9, 0, -12), "UpperArm.R": (18, -12, -52), "Forearm.R": (-24, 0, -62), "Blade.R": (30, 0, -58), "Head": (-8, 0, 0)}}, + {"frame": 16, "rotations": {"UpperArm.R": (6, 0, -20), "Forearm.R": (-10, 0, -25)}}, + {"frame": 24}, + ], + ) + + add( + "LineSlash", + 32, + [ + {"frame": 1}, + {"frame": 10, "locations": {"Root": (0, 0.05, -0.03)}, "rotations": {"Chest": (-7, 0, 10), "Head": (7, 0, -5), "UpperArm.L": (-15, 12, -30), "Forearm.L": (0, 0, -46), "Blade.L": (-20, 0, -35), "UpperArm.R": (8, 0, 8)}}, + {"frame": 15, "locations": {"Root": (0, -0.12, 0.03)}, "rotations": {"Chest": (12, 0, -16), "UpperArm.L": (24, -10, 58), "Forearm.L": (-18, 0, 74), "Blade.L": (34, 0, 70), "Head": (-9, 0, 5)}}, + {"frame": 21, "rotations": {"Chest": (5, 0, -6), "UpperArm.L": (8, 0, 20), "Forearm.L": (-5, 0, 28), "Blade.L": (10, 0, 20)}}, + {"frame": 32}, + ], + ) + + add( + "CrossSlash", + 38, + [ + {"frame": 1}, + {"frame": 11, "locations": {"Root": (0, 0.04, -0.05)}, "rotations": {"Chest": (-9, 0, 0), "UpperArm.L": (-8, 18, 38), "Forearm.L": (10, 0, 52), "Blade.L": (-15, 0, 32), "UpperArm.R": (-8, -18, -38), "Forearm.R": (10, 0, -52), "Blade.R": (-15, 0, -32)}}, + {"frame": 16, "locations": {"Root": (0, -0.10, 0.08)}, "rotations": {"Chest": (14, 0, 0), "UpperArm.L": (22, -14, -52), "Forearm.L": (-18, 0, -72), "Blade.L": (28, 0, -64), "UpperArm.R": (22, 14, 52), "Forearm.R": (-18, 0, 72), "Blade.R": (28, 0, 64), "Head": (-10, 0, 0)}}, + {"frame": 21, "locations": {"Root": (0, -0.04, 0.02)}, "rotations": {"Chest": (5, 0, 0), "UpperArm.L": (8, 0, -22), "Forearm.L": (-6, 0, -30), "UpperArm.R": (8, 0, 22), "Forearm.R": (-6, 0, 30)}}, + {"frame": 38}, + ], + ) + + add( + "Recover", + 22, + [ + {"frame": 1, "locations": {"Root": (0, -0.04, -0.05)}, "rotations": {"Chest": (14, 0, 0), "Head": (-9, 0, 0), "UpperArm.L": (12, 0, -18), "UpperArm.R": (12, 0, 18)}}, + {"frame": 8, "rotations": {"Chest": (-4, 0, 0), "Abdomen": (6, 0, 0), "Head": (4, 0, 0)}}, + {"frame": 15, "rotations": {"Chest": (2, 0, 0), "Abdomen": (-2, 0, 0)}}, + {"frame": 22}, + ], + ) + + add( + "Stagger", + 28, + [ + {"frame": 1}, + {"frame": 4, "locations": {"Root": (0, 0.10, -0.07)}, "rotations": {"Chest": (-18, 0, -10), "Head": (24, 0, 12), "UpperArm.L": (-16, 0, -22), "UpperArm.R": (-16, 0, 22), "Thigh.L": (12, 0, 0), "Thigh.R": (12, 0, 0)}}, + {"frame": 10, "rotations": {"Chest": (8, 0, 6), "Head": (-12, 0, -8), "Blade.L": (14, 0, 0), "Blade.R": (14, 0, 0)}}, + {"frame": 18, "rotations": {"Chest": (-3, 0, -2), "Head": (4, 0, 2)}}, + {"frame": 28}, + ], + ) + + add( + "Death", + 72, + [ + {"frame": 1}, + {"frame": 12, "locations": {"Root": (0, 0.08, -0.10)}, "rotations": {"Chest": (-20, 0, 9), "Head": (18, 0, -12), "UpperArm.L": (-20, 0, -25), "UpperArm.R": (-20, 0, 25), "Thigh.L": (14, 0, 0), "Thigh.R": (14, 0, 0)}}, + {"frame": 28, "locations": {"Root": (0.10, 0.20, -0.55)}, "rotations": {"Root": (0, 38, 74), "Chest": (-34, 8, -18), "Head": (28, 0, 15), "UpperArm.L": (30, 0, -48), "UpperArm.R": (-12, 0, 48), "Thigh.L": (28, 0, 22), "Thigh.R": (-14, 0, -18)}}, + {"frame": 48, "locations": {"Root": (0.18, 0.20, -1.12)}, "rotations": {"Root": (0, 52, 88), "Chest": (-24, 12, -22), "Head": (38, 0, 22), "UpperArm.L": (42, 0, -62), "UpperArm.R": (8, 0, 58), "Thigh.L": (36, 0, 28), "Thigh.R": (-20, 0, -22)}}, + {"frame": 72, "locations": {"Root": (0.18, 0.20, -1.18)}, "rotations": {"Root": (0, 52, 88), "Chest": (-24, 12, -22), "Head": (42, 0, 25), "UpperArm.L": (44, 0, -64), "UpperArm.R": (10, 0, 60), "Thigh.L": (38, 0, 28), "Thigh.R": (-20, 0, -22)}}, + ], + ) + + return clips + + +def create_collision_meshes() -> list[bpy.types.Object]: + collision_material = make_material("CollisionProxy", (0.05, 0.8, 0.2, 0.35), roughness=1.0) + collision_parts: list[bpy.types.Object] = [] + specs = [ + ("COLLISION_Body", (0, 0.18, 2.55), (0.72, 1.12, 1.55)), + ("COLLISION_Head", (0, -0.38, 4.08), (0.52, 0.62, 0.48)), + ("COLLISION_ScytheReach", (0, -1.35, 2.66), (1.82, 1.18, 0.84)), + ] + for name, location, scale in specs: + bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=1, location=location) + obj = bpy.context.object + obj.name = name + obj.scale = scale + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + obj.data.materials.append(collision_material) + collision_parts.append(obj) + return collision_parts + + +def select_only(objects: list[bpy.types.Object]) -> None: + bpy.ops.object.select_all(action="DESELECT") + for obj in objects: + obj.hide_set(False) + obj.select_set(True) + if objects: + bpy.context.view_layer.objects.active = objects[0] + + +def export_glbs(armature: bpy.types.Object, body: bpy.types.Object, collision_parts: list[bpy.types.Object]) -> None: + select_only([armature, body]) + bpy.ops.export_scene.gltf( + filepath=str(GLB_PATH), + export_format="GLB", + use_selection=True, + export_animations=True, + export_animation_mode="ACTIONS", + export_frame_range=True, + export_skins=True, + export_morph=False, + export_yup=True, + export_apply=False, + export_cameras=False, + export_lights=False, + ) + select_only(collision_parts) + bpy.ops.export_scene.gltf( + filepath=str(COLLISION_PATH), + export_format="GLB", + use_selection=True, + export_animations=False, + export_skins=False, + export_materials="NONE", + export_yup=True, + export_apply=True, + ) + for obj in collision_parts: + obj.hide_render = True + obj.hide_set(True) + + +def look_at(obj: bpy.types.Object, target: tuple[float, float, float]) -> None: + direction = Vector(target) - obj.location + obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + +def setup_preview_scene() -> tuple[bpy.types.Object, list[bpy.types.Object]]: + scene = bpy.context.scene + scene.render.engine = "BLENDER_EEVEE" + scene.render.resolution_x = 900 + scene.render.resolution_y = 900 + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = "PNG" + scene.render.image_settings.color_mode = "RGBA" + scene.render.film_transparent = False + scene.render.fps = FPS + scene.world.color = (0.006, 0.008, 0.014) + world_nodes = scene.world.node_tree.nodes if scene.world.use_nodes else None + if not scene.world.use_nodes: + scene.world.use_nodes = True + world_nodes = scene.world.node_tree.nodes + world_nodes["Background"].inputs["Color"].default_value = (0.006, 0.009, 0.018, 1) + world_nodes["Background"].inputs["Strength"].default_value = 0.28 + + camera_data = bpy.data.cameras.new("PreviewCamera") + camera = bpy.data.objects.new("PreviewCamera", camera_data) + bpy.context.collection.objects.link(camera) + camera_data.lens = 58 + scene.camera = camera + + lights: list[bpy.types.Object] = [] + light_specs = [ + ("Key", "AREA", (4.5, -6.5, 7.5), (1.0, 0.30, 0.10), 1150, 5.0), + ("Fill", "AREA", (-5.5, -3.0, 4.5), (0.10, 0.25, 1.0), 850, 4.0), + ("Rim", "AREA", (1.0, 5.5, 6.5), (1.0, 0.08, 0.02), 1300, 3.0), + ] + for name, kind, location, color, energy, size in light_specs: + data = bpy.data.lights.new(name, type=kind) + data.energy = energy + data.color = color + data.shape = "DISK" + data.size = size + light = bpy.data.objects.new(name, data) + light.location = location + bpy.context.collection.objects.link(light) + look_at(light, (0, 0, 2.5)) + lights.append(light) + + floor_mat = make_material("PreviewFloor", (0.018, 0.022, 0.032, 1), metallic=0.05, roughness=0.82) + bpy.ops.mesh.primitive_plane_add(size=30, location=(0, 0, 0)) + floor = bpy.context.object + floor.name = "PreviewFloor" + floor.data.materials.append(floor_mat) + return camera, lights + [floor] + + +def activate_action(armature: bpy.types.Object, name: str, frame: int) -> None: + armature.animation_data.action = bpy.data.actions[name] + bpy.context.scene.frame_set(frame) + + +def render_previews(armature: bpy.types.Object) -> None: + camera, _studio = setup_preview_scene() + views = [ + ("ember_mantis_three_quarter.png", (7.4, -10.5, 5.8), "Idle", 16, 58), + ("ember_mantis_front.png", (0.0, -12.5, 4.2), "Idle", 16, 62), + ("ember_mantis_side.png", (10.8, -0.2, 4.3), "Idle", 16, 60), + ("ember_mantis_cross_slash.png", (7.4, -10.5, 5.8), "CrossSlash", 16, 58), + ] + for filename, camera_position, action_name, frame, lens in views: + activate_action(armature, action_name, frame) + camera.location = camera_position + camera.data.lens = lens + look_at(camera, (0, -0.05, 2.55)) + bpy.context.scene.render.filepath = str(PREVIEW_DIR / filename) + bpy.ops.render.render(write_still=True) + armature.animation_data.action = None + bpy.context.scene.frame_set(1) + + +def write_metadata(body: bpy.types.Object, clips: dict[str, dict]) -> None: + triangles = sum(len(poly.vertices) - 2 for poly in body.data.polygons) + metadata = { + "name": "Ember Mantis Duelist", + "assetId": "ember-mantis-duelist", + "license": "Original project asset", + "sourceConcept": "IWT2 Ember Mantis Duelist side-cel concept", + "authoringTool": bpy.app.version_string, + "format": "glTF 2.0 binary (GLB)", + "coordinateConvention": {"up": "+Y after glTF export", "blenderForward": "-Y", "units": "meters"}, + "runtime": { + "renderMesh": "EmberMantis_Body", + "armature": "EmberMantis_Rig", + "collisionAsset": COLLISION_PATH.name, + "materials": [material.name for material in body.data.materials], + "trianglesApprox": triangles, + }, + "animations": clips, + } + METADATA_PATH.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8") + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + PREVIEW_DIR.mkdir(parents=True, exist_ok=True) + reset_scene() + mats = { + "chitin": make_material("M_Chitin", (0.025, 0.022, 0.026, 1), metallic=0.18, roughness=0.42), + "crimson": make_material("M_CrimsonArmor", (0.34, 0.025, 0.018, 1), metallic=0.22, roughness=0.36), + "deep_red": make_material("M_DeepRed", (0.13, 0.012, 0.012, 1), metallic=0.15, roughness=0.48), + "bone": make_material("M_BoneBlade", (0.62, 0.45, 0.26, 1), metallic=0.12, roughness=0.28), + "ember": make_material( + "M_EmberGlow", + (1.0, 0.075, 0.004, 1), + roughness=0.2, + emission=(1.0, 0.028, 0.0, 1), + emission_strength=8.0, + ), + "eye": make_material( + "M_EyeGlow", + (1.0, 0.33, 0.01, 1), + roughness=0.16, + emission=(1.0, 0.14, 0.0, 1), + emission_strength=12.0, + ), + } + RUNTIME_MATERIALS.extend( + [mats["chitin"], mats["crimson"], mats["deep_red"], mats["eye"], mats["bone"], mats["ember"]] + ) + armature = create_armature() + body = create_model(armature, mats) + clips = create_animations(armature) + collision_parts = create_collision_meshes() + export_glbs(armature, body, collision_parts) + write_metadata(body, clips) + render_previews(armature) + bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH)) + print(f"BUILT={BLEND_PATH}") + print(f"EXPORTED={GLB_PATH}") + print(f"COLLISION={COLLISION_PATH}") + print(f"ANIMATIONS={','.join(clips)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/publish_gitea.py b/scripts/publish_gitea.py index 7645538..7e51710 100644 --- a/scripts/publish_gitea.py +++ b/scripts/publish_gitea.py @@ -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: diff --git a/src/App.tsx b/src/App.tsx index deaa73e..a22039d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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).detail); + window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch); + return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch); + }, [launchGame]); + useActionBindings(screen === "game", leaveGame); useEffect(() => { diff --git a/src/components/DualDisplayFrame.tsx b/src/components/DualDisplayFrame.tsx index 93a757b..e64e9bb 100644 --- a/src/components/DualDisplayFrame.tsx +++ b/src/components/DualDisplayFrame.tsx @@ -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("top"); + const [activeSurface, setActiveSurface] = useState(() => + 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(); }; }, []); diff --git a/src/components/FrontEnd.tsx b/src/components/FrontEnd.tsx index 981fc63..4bee2fa 100644 --- a/src/components/FrontEnd.tsx +++ b/src/components/FrontEnd.tsx @@ -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(null); + const passwordRef = useRef(null); const actions = useMemo(() => [ - { 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 ( Keep everyone standing.

Your save always lives on this device. Sign in only when you want a second copy for PC ↔ AYN Thor handoff.

-
{ event.preventDefault(); signIn(hunterId); }}> - - setHunterId(event.target.value)} autoComplete="username" /> + { event.preventDefault(); submitSignIn(); }}> + + setUsername(event.target.value)} + onFocus={() => controller.focus("username")} + autoComplete="username" + required + /> + + setPassword(event.target.value)} + onFocus={() => controller.focus("password")} + autoComplete="current-password" + required + /> Sign in & syncOnline saves enabled + { void createAccount(username, password); }}> + Create accountRequired for first sync + Continue with offline saveNo account required
- {notice &&
{notice}
} + {notice &&
{notice}
} } @@ -92,8 +125,8 @@ function LoginScreen() { How saving works
  1. 01Play offlineEvery change writes to device storage first.
  2. -
  3. 02Sync when readyUpload any slot after signing in.
  4. -
  5. 03Move devicesDownload the online copy and overwrite local.
  6. +
  7. 02Create or sign inUsername and password unlock online sync.
  8. +
  9. 03Move devicesSign in, then upload or download an online copy.
PCONLINE COPYTHOR
diff --git a/src/components/GameScene.tsx b/src/components/GameScene.tsx index fc1cd2a..88871d9 100644 --- a/src/components/GameScene.tsx +++ b/src/components/GameScene.tsx @@ -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,6 +10,12 @@ import { BossMechanicIndicators } from "./boss/BossMechanicIndicators"; const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href; const SPIDER_URL = new URL("../../game_assets/models/downloaded/low-poly-spider/low-poly-spider.glb", import.meta.url).href; +const SPIDER_TEXTURE_URLS: Record = { + "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 PARTY_MODEL_URLS: Record = { aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href, @@ -41,6 +48,11 @@ const PARTY_ATTACK_CLIPS: Record = { 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; function encounterBossAt(state: GameStoreState, bossIndex: number) { @@ -59,6 +71,13 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string) return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state); } +const configureSpiderLoader: NonNullable[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 +227,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(null); + const torchInstances = useRef(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 +261,16 @@ function Arena() { - {columns.map(([x, z], index) => ( - - - - - - - - - - - - ))} + + + + + + + + + + ); @@ -365,11 +394,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) { @@ -587,7 +614,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0); const defeated = bossHp <= 0; const group = useRef(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(), []); diff --git a/src/frontend/accountRepository.test.ts b/src/frontend/accountRepository.test.ts new file mode 100644 index 0000000..44b4443 --- /dev/null +++ b/src/frontend/accountRepository.test.ts @@ -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(); + 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"); + }); +}); diff --git a/src/frontend/accountRepository.ts b/src/frontend/accountRepository.ts new file mode 100644 index 0000000..2a3b749 --- /dev/null +++ b/src/frontend/accountRepository.ts @@ -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; +type PasswordHasher = (password: string, salt: string) => Promise; + +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(); +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 { + 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 { + 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)); + } +} diff --git a/src/frontend/store.ts b/src/frontend/store.ts index e87bb22..cd1f91d 100644 --- a/src/frontend/store.ts +++ b/src/frontend/store.ts @@ -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, 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; + createAccount: (username: string, password: string) => Promise; continueOffline: () => void; signOut: () => void; navigate: (screen: AppScreen) => void; @@ -70,9 +85,23 @@ export const useFrontendStore = create((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((set, get) => ({ clearNotice: () => set({ notice: "" }), })); +export type FrontendSnapshot = Omit; + +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)); } diff --git a/src/game/store.test.ts b/src/game/store.test.ts index 0f523ae..fddf3ff 100644 --- a/src/game/store.test.ts +++ b/src/game/store.test.ts @@ -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; diff --git a/src/game/store.ts b/src/game/store.ts index 068d68e..161fa92 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -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((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((set, get) => ({ }, })); +export type GameSnapshot = Omit; + +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) { return Math.max(0, cooldowns[abilityId] - time); } diff --git a/src/game/useGameLoop.ts b/src/game/useGameLoop.ts index 22dc65e..047ca72 100644 --- a/src/game/useGameLoop.ts +++ b/src/game/useGameLoop.ts @@ -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]); } diff --git a/src/input/controller.test.ts b/src/input/controller.test.ts new file mode 100644 index 0000000..5d6f1d0 --- /dev/null +++ b/src/input/controller.test.ts @@ -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(); + }); +}); diff --git a/src/input/controller.ts b/src/input/controller.ts new file mode 100644 index 0000000..6bc46d8 --- /dev/null +++ b/src/input/controller.ts @@ -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) => 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(); +const movementListeners = new Set(); +const repeatAt = new Map(); +const lastNativeTokenAt = new Map(); +let previousTokens = new Set(); +let currentTokens = new Set(); +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) { + 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 { + 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).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).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; +} diff --git a/src/input/useMenuController.ts b/src/input/useMenuController.ts index e18834d..e45ac25 100644 --- a/src/input/useMenuController.ts +++ b/src/input/useMenuController.ts @@ -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, diff --git a/src/main.tsx b/src/main.tsx index fcb94fb..5b2125e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - + {displayMode === "bottom" ? : } , ); diff --git a/src/platform/BottomDisplayApp.tsx b/src/platform/BottomDisplayApp.tsx new file mode 100644 index 0000000..0948396 --- /dev/null +++ b/src/platform/BottomDisplayApp.tsx @@ -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 ( +
+
+ IH +
AYN Thor · Context displayI Want To Heal
+
+
+ Current task +

{screenTitle(screen)}

+

{hunterName ? `${hunterName}'s field console is linked to the upper display.` : "Upper display owns primary navigation. Lower display remains linked and ready."}

+ {notice && {notice}} +
+
+ Navigate + A Select + B Back +
+
+ ); +} + +export function BottomDisplayApp() { + useForcedThorDisplays(); + const channelRef = useRef(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(); + 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) => { + 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 ( +
+ {surface.screen === "game" + ? }> + : surface.notice === "Linking upper display…" + ? + : } +
+ ); +} diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts new file mode 100644 index 0000000..4c02d0f --- /dev/null +++ b/src/platform/dualScreenSync.ts @@ -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(); +} diff --git a/src/platform/nativeDualScreen.ts b/src/platform/nativeDualScreen.ts new file mode 100644 index 0000000..c966471 --- /dev/null +++ b/src/platform/nativeDualScreen.ts @@ -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; + addListener: (eventName: "displayDisconnected", listener: () => void) => Promise<{ remove: () => Promise }>; +} + +const plugin = registerPlugin("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); +} diff --git a/src/platform/rateLimitedPublisher.test.ts b/src/platform/rateLimitedPublisher.test.ts new file mode 100644 index 0000000..ca5b971 --- /dev/null +++ b/src/platform/rateLimitedPublisher.test.ts @@ -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); + }); +}); diff --git a/src/platform/rateLimitedPublisher.ts b/src/platform/rateLimitedPublisher.ts new file mode 100644 index 0000000..d0146ba --- /dev/null +++ b/src/platform/rateLimitedPublisher.ts @@ -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 | 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(); + }, + }; +} diff --git a/src/platform/useThorDualScreen.ts b/src/platform/useThorDualScreen.ts new file mode 100644 index 0000000..5511a71 --- /dev/null +++ b/src/platform/useThorDualScreen.ts @@ -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 } | 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) => { + 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(); + }; + }, []); +} diff --git a/src/styles.css b/src/styles.css index 3413e31..9dca404 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; }