Compare commits

...
3 Commits
Author SHA1 Message Date
Warren H b48b3a4f8f Release v0.1.3 2026-07-11 2026-07-11 23:23:02 -04:00
Warren H 076f6cf97c Release v0.1.2 2026-07-11 2026-07-11 00:12:57 -04:00
Warren H 6726c600e4 Release v0.1.1 2026-07-10 2026-07-10 23:47:37 -04:00
130 changed files with 10136 additions and 552 deletions
+121
View File
@@ -0,0 +1,121 @@
# TrueNAS deployment
This game uses the same proven local-Gitea pattern as `testgame`: clone from the
Gitea bare repository on the TrueNAS filesystem, mount that working checkout
into one Node container, and update it with a local Git pull plus app restart.
## Paths
```text
Local Gitea bare repository:
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
Runnable working checkout:
/mnt/usbssds/apps/iwanttoheal-mmo/app
Public URL:
https://iwanttoheal.phenomrom.com
Container/host port:
4173
```
The bare Gitea repository is not a runnable application directory. Use a local
`git clone`; do not copy the bare repository with `cp`. Local clone/pull keeps
the source transfer on TrueNAS and creates the working tree the container needs.
## First installation
This configuration uses a separate `iwanttoheal-mmo` working directory, so it
does not modify the old app checkout or its data.
Confirm the new repository path. If the first command fails, use the search:
```sh
sudo test -d /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
sudo find /mnt -type d -name "i-want-to-heal-mmo.git" -prune -print 2>/dev/null
```
Clone entirely through the local filesystem:
```sh
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo
sudo git config --global --add safe.directory \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
sudo git clone \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git \
/mnt/usbssds/apps/iwanttoheal-mmo/app
sudo chown -R truenas_admin:truenas_admin /mnt/usbssds/apps/iwanttoheal-mmo
```
Verify the checkout:
```sh
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app remote -v
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app branch --show-current
ls /mnt/usbssds/apps/iwanttoheal-mmo/app/package.json
```
Expected branch: `main`. Expected origin: local Gitea path above.
## Install as a TrueNAS app
1. Open **Apps**.
2. Open **Discover**.
3. Open the three-dot menu.
4. Select **Install via YAML**.
5. Name the app `iwanttoheal-mmo`.
6. Paste:
```yaml
services:
iwanttoheal:
image: node:24-bookworm-slim
command: >-
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start"
environment:
HOST: 0.0.0.0
PORT: "4173"
init: true
ports:
- "4173:4173"
restart: unless-stopped
volumes:
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
working_dir: /app
```
This game has no server database. Do not add `db:init`, `/app/data`, cookie,
CORS, or proxy environment settings from the older game.
The separate volume protects the old app files and data. The YAML still maps
host port `4173`, so the old and MMO apps cannot run simultaneously while both
use that host port. Stop the old app, or assign this app an unused host port and
separate reverse-proxy hostname.
After deployment, test from the TrueNAS shell or another LAN machine:
```sh
curl -I http://TRUENAS-IP:4173
```
Expected result: HTTP `200`. Keep the existing HTTPS reverse proxy pointed at
`TRUENAS-IP:4173` for `iwanttoheal.phenomrom.com`.
## Update workflow
Push `main` from the development Mac. Then run on TrueNAS:
```sh
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app pull --ff-only \
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git \
main
```
This transfers Git objects locally from Gitea storage; it does not download the
game through HTTPS. Restart `iwanttoheal-mmo` in the TrueNAS Apps UI afterward. The
container startup command installs locked dependencies, rebuilds the browser
bundle, and starts port `4173`.
For simultaneous operation, keep the new app name/directory and also assign an
unused host port plus a separate reverse-proxy hostname.
+22 -14
View File
@@ -38,14 +38,23 @@ state rather than two independent WebViews.
## TrueNAS deployment
Complete first-install, local-Gitea clone, YAML, update, and verification steps:
[DEPLOYMENT.md](DEPLOYMENT.md).
The production preview server uses the existing deployment address and port:
- Public URL: `https://iwanttoheal.phenomrom.com`
- Host/container port: `4173`
- App directory: `/mnt/usbssds/apps/iwanttoheal/app`
- App directory: `/mnt/usbssds/apps/iwanttoheal-mmo/app`
Clone the repository into the app directory, then deploy `compose.yaml`. Compared
with the old game configuration:
Clone from the TrueNAS-local Gitea bare repository into the app directory, then
deploy `compose.yaml`. The expected source path is:
```text
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
```
Compared with the old game configuration:
- use `corepack pnpm install --frozen-lockfile`, not `npm ci`;
- remove `npm run db:init` because this game has no server database;
@@ -66,11 +75,10 @@ The repository target is:
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
```
Create a Gitea access token with repository write permission, expose it only for
the publishing process, then publish from `main`:
The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can
optionally override it for one run. Publish from `main`:
```bash
export GITEA_TOKEN="..."
pnpm publish:gitea -- --message "Describe the update"
```
@@ -80,11 +88,11 @@ version tag and Gitea prerelease, and uploads the APK plus its SHA-256 file. It
uses `package.json` version unless that version is already tagged, then advances
the patch number. Pass `--version 0.2.0` to choose an explicit version.
If the TrueNAS app directory is mounted locally, publishing also performs a
fast-forward-only pull. Otherwise it prints the clone/pull command to run on
TrueNAS before restarting the app. The pull refuses to run when the TrueNAS
checkout still points at the old game's repository; move or archive that checkout
and clone the new repository first.
If the publisher runs where TrueNAS paths are visible, it prefers a
fast-forward-only pull directly from TrueNAS-local Gitea storage. Otherwise it
prints the exact local clone/pull commands to run in the TrueNAS shell. The pull
refuses to run when the checkout still points at the old game's repository;
archive that checkout and clone the new repository first.
Preview checks without changing Git or TrueNAS:
@@ -92,9 +100,9 @@ Preview checks without changing Git or TrueNAS:
pnpm publish:gitea -- --dry-run
```
Git push credentials still come from Git's credential manager. Gitea release API
credentials come from `GITEA_TOKEN`. Never store access tokens or Android signing
keys in this repository.
Git push credentials still come from Git's credential manager. The configured
Gitea token handles release creation and APK uploads. Android signing keys remain
outside the repository.
## Controls
@@ -0,0 +1,225 @@
package com.phenomrom.iwanttoheal;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import androidx.activity.OnBackPressedCallback;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** Routes every Thor controller event into one JavaScript input service. */
public abstract class ControllerBridgeActivity extends BridgeActivity {
private static final float AXIS_DEAD_ZONE = 0.45f;
private static final long REPEAT_THROTTLE_MS = 55L;
private final long[] lastDpadDispatchAt = new long[16];
private final Set<String> activeMotionTokens = new HashSet<>();
private final Map<String, Long> lastMotionDispatchAt = new HashMap<>();
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
dispatchNativeControllerToken("Button1", false);
}
});
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
WindowManager.LayoutParams attributes = getWindow().getAttributes();
attributes.preferredRefreshRate = 60.0f;
getWindow().setAttributes(attributes);
if (bridge != null && bridge.getWebView() != null) {
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
bridge.getWebView().setFocusable(true);
bridge.getWebView().setFocusableInTouchMode(true);
bridge.getWebView().requestFocus();
}
enterImmersiveMode();
}
@Override
public void onPause() {
clearHeldControllerState();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
enterImmersiveMode();
if (bridge != null && bridge.getWebView() != null) bridge.getWebView().requestFocus();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
else clearHeldControllerState();
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN && bridge != null) {
bridge.getWebView().requestFocus();
}
return super.dispatchTouchEvent(event);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
String token = controllerToken(event.getKeyCode());
if (token == null || bridge == null) return super.dispatchKeyEvent(event);
if (event.getAction() == KeyEvent.ACTION_DOWN) {
boolean repeat = event.getRepeatCount() > 0;
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
dispatchNativeControllerToken(token, repeat);
}
return true;
}
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
if (
bridge == null
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|| !isControllerMotionEvent(event)
) {
return super.dispatchGenericMotionEvent(event);
}
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
dispatchNativeControllerMotion(leftStickX, leftStickY);
Set<String> currentTokens = new HashSet<>();
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15");
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_Y), "Button12", "Button13");
addAxisTokens(currentTokens, leftStickX, "Axis0-", "Axis0+");
addAxisTokens(currentTokens, leftStickY, "Axis1-", "Axis1+");
long now = SystemClock.uptimeMillis();
for (String token : currentTokens) {
boolean repeat = activeMotionTokens.contains(token);
long lastDispatch = lastMotionDispatchAt.containsKey(token)
? lastMotionDispatchAt.get(token)
: 0L;
if (!repeat || now - lastDispatch >= REPEAT_THROTTLE_MS) {
dispatchNativeControllerToken(token, repeat);
lastMotionDispatchAt.put(token, now);
}
}
activeMotionTokens.clear();
activeMotionTokens.addAll(currentTokens);
return true;
}
private void enterImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(
getWindow(),
getWindow().getDecorView()
);
controller.hide(WindowInsetsCompat.Type.systemBars());
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
}
private boolean isControllerMotionEvent(MotionEvent event) {
int source = event.getSource();
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
}
private void addAxisTokens(Set<String> tokens, float value, String negative, String positive) {
if (value <= -AXIS_DEAD_ZONE) tokens.add(negative);
if (value >= AXIS_DEAD_ZONE) tokens.add(positive);
}
private void dispatchNativeControllerToken(String token, boolean repeat) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller',"
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
bridge.getWebView().post(() -> {
bridge.getWebView().requestFocus();
bridge.getWebView().evaluateJavascript(script, null);
});
}
private void dispatchNativeControllerMotion(float x, float y) {
if (bridge == null || bridge.getWebView() == null) return;
String script =
"window.dispatchEvent(new CustomEvent('iwt-native-controller-motion',"
+ "{detail:{x:" + x + ",y:" + y + "}}));";
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
}
private void clearHeldControllerState() {
activeMotionTokens.clear();
lastMotionDispatchAt.clear();
dispatchNativeControllerMotion(0.0f, 0.0f);
if (bridge == null || bridge.getWebView() == null) return;
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
"window.dispatchEvent(new Event('iwt-native-controller-reset'));",
null
));
}
private boolean shouldThrottleDpad(String token) {
int index = Integer.parseInt(token.substring("Button".length()));
long now = SystemClock.uptimeMillis();
if (now - lastDpadDispatchAt[index] < REPEAT_THROTTLE_MS) return true;
lastDpadDispatchAt[index] = now;
return false;
}
private boolean isDpadToken(String token) {
return token.equals("Button12")
|| token.equals("Button13")
|| token.equals("Button14")
|| token.equals("Button15");
}
private String controllerToken(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_BUTTON_A:
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_NUMPAD_ENTER:
return "Button0";
case KeyEvent.KEYCODE_BUTTON_B:
case KeyEvent.KEYCODE_ESCAPE:
return "Button1";
case KeyEvent.KEYCODE_BUTTON_X: return "Button2";
case KeyEvent.KEYCODE_BUTTON_Y: return "Button3";
case KeyEvent.KEYCODE_BUTTON_L1: return "Button4";
case KeyEvent.KEYCODE_BUTTON_R1: return "Button5";
case KeyEvent.KEYCODE_BUTTON_L2: return "Button6";
case KeyEvent.KEYCODE_BUTTON_R2: return "Button7";
case KeyEvent.KEYCODE_BUTTON_SELECT: return "Button8";
case KeyEvent.KEYCODE_BUTTON_START: return "Button9";
case KeyEvent.KEYCODE_BUTTON_THUMBL: return "Button10";
case KeyEvent.KEYCODE_BUTTON_THUMBR: return "Button11";
case KeyEvent.KEYCODE_DPAD_UP: return "Button12";
case KeyEvent.KEYCODE_DPAD_DOWN: return "Button13";
case KeyEvent.KEYCODE_DPAD_LEFT: return "Button14";
case KeyEvent.KEYCODE_DPAD_RIGHT: return "Button15";
default: return null;
}
}
}
@@ -1,44 +1,11 @@
package com.phenomrom.iwanttoheal;
import android.os.Bundle;
import android.view.View;
import android.view.WindowManager;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
public class MainActivity extends ControllerBridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(ThorDualScreenPlugin.class);
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
WindowManager.LayoutParams attributes = getWindow().getAttributes();
attributes.preferredRefreshRate = 60.0f;
getWindow().setAttributes(attributes);
getBridge().getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
getBridge().getWebView().setFocusableInTouchMode(true);
getBridge().getWebView().requestFocus();
enterImmersiveMode();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
}
private void enterImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(
getWindow(),
getWindow().getDecorView()
);
controller.hide(WindowInsetsCompat.Type.systemBars());
controller.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
}
}
@@ -0,0 +1,307 @@
package com.phenomrom.iwanttoheal;
import android.app.Presentation;
import android.content.Context;
import android.graphics.Color;
import android.hardware.display.DisplayManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.Display;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/** Keeps a dedicated WebView visible on each AYN Thor panel for the activity lifetime. */
@CapacitorPlugin(name = "ThorDualScreen")
public class ThorDualScreenPlugin extends Plugin implements DisplayManager.DisplayListener {
private final Handler handler = new Handler(Looper.getMainLooper());
private DisplayManager displayManager;
private ThorPresentation presentation;
private boolean forceEnabled;
@Override
public void load() {
displayManager = (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
displayManager.registerDisplayListener(this, handler);
}
@PluginMethod
public void getDisplays(PluginCall call) {
Display current = currentDisplay();
JSArray displays = new JSArray();
for (Display display : displayManager.getDisplays()) displays.put(describeDisplay(display, current));
JSObject result = new JSObject();
result.put("currentDisplayId", current == null ? -1 : current.getDisplayId());
result.put("displays", displays);
call.resolve(result);
}
@PluginMethod
public void forceBothDisplays(PluginCall call) {
forceEnabled = true;
getActivity().runOnUiThread(() -> {
try {
DisplayPlan plan = createPlan();
if (plan.target == null) {
call.reject("No active secondary Android display is available.");
return;
}
boolean alreadyOpen = presentation != null
&& presentation.isShowing()
&& presentation.getDisplay().getDisplayId() == plan.target.getDisplayId();
if (!alreadyOpen) openPlan(plan);
JSObject result = describeDisplay(plan.target, currentDisplay());
result.put("opened", true);
result.put("topOnActivity", plan.currentIsTop);
call.resolve(result);
} catch (Exception exception) {
closePresentation();
call.reject("Unable to force both Thor displays.", exception);
}
});
}
@Override
protected void handleOnResume() {
if (forceEnabled) scheduleEnsurePresentation(0L);
}
@Override
protected void handleOnDestroy() {
forceEnabled = false;
if (displayManager != null) displayManager.unregisterDisplayListener(this);
handler.removeCallbacksAndMessages(null);
closePresentation();
}
@Override
public void onDisplayAdded(int displayId) {
if (forceEnabled) scheduleEnsurePresentation(100L);
}
@Override
public void onDisplayRemoved(int displayId) {
if (presentation != null && presentation.getDisplay().getDisplayId() == displayId) {
closePresentation();
}
if (forceEnabled) scheduleEnsurePresentation(250L);
}
@Override
public void onDisplayChanged(int displayId) {
if (forceEnabled) scheduleEnsurePresentation(100L);
}
private void scheduleEnsurePresentation(long delayMs) {
handler.removeCallbacks(ensurePresentation);
handler.postDelayed(ensurePresentation, delayMs);
}
private final Runnable ensurePresentation = () -> {
if (!forceEnabled || getActivity() == null || getActivity().isFinishing()) return;
DisplayPlan plan = createPlan();
if (plan.target == null) return;
if (
presentation != null
&& presentation.isShowing()
&& presentation.getDisplay().getDisplayId() == plan.target.getDisplayId()
) return;
openPlan(plan);
};
private void openPlan(DisplayPlan plan) {
String baseUrl = bridge.getLocalUrl();
String targetUrl = baseUrl + "/?display=" + (plan.targetIsTop ? "top" : "bottom") + "&role=presentation";
String currentUrl = baseUrl + "/?display=" + (plan.currentIsTop ? "top" : "bottom") + "&role=activity";
closePresentation();
presentation = new ThorPresentation(
getActivity(),
plan.target,
targetUrl
);
presentation.setOnDismissListener(dialog -> {
presentation = null;
notifyListeners("displayDisconnected", new JSObject());
if (forceEnabled) scheduleEnsurePresentation(300L);
});
presentation.show();
bridge.getWebView().loadUrl(currentUrl);
}
private DisplayPlan createPlan() {
Display current = currentDisplay();
int currentId = current == null ? -1 : current.getDisplayId();
Display[] displays = displayManager.getDisplays();
Display top = largestDisplay(displays);
boolean currentIsTop = top != null && top.getDisplayId() == currentId;
Display target = currentIsTop
? smallestOtherDisplay(displays, currentId)
: top != null && top.getDisplayId() != currentId ? top : largestOtherDisplay(displays, currentId);
boolean targetIsTop = target != null && top != null && target.getDisplayId() == top.getDisplayId();
return new DisplayPlan(target, targetIsTop, currentIsTop);
}
private Display currentDisplay() {
return getActivity().getWindow().getDecorView().getDisplay();
}
private Display largestDisplay(Display[] displays) {
Display selected = null;
long pixels = -1L;
for (Display display : displays) {
if (display.getState() == Display.STATE_OFF) continue;
long candidate = pixelCount(display);
if (candidate > pixels) {
selected = display;
pixels = candidate;
}
}
return selected;
}
private Display largestOtherDisplay(Display[] displays, int currentId) {
Display selected = null;
long pixels = -1L;
for (Display display : displays) {
if (display.getDisplayId() == currentId || display.getState() == Display.STATE_OFF) continue;
long candidate = pixelCount(display);
if (candidate > pixels) {
selected = display;
pixels = candidate;
}
}
return selected;
}
private Display smallestOtherDisplay(Display[] displays, int currentId) {
Display selected = null;
long pixels = Long.MAX_VALUE;
for (Display display : displays) {
if (display.getDisplayId() == currentId || display.getState() == Display.STATE_OFF) continue;
long candidate = pixelCount(display);
if (candidate < pixels) {
selected = display;
pixels = candidate;
}
}
return selected;
}
private long pixelCount(Display display) {
Display.Mode mode = display.getMode();
return (long) mode.getPhysicalWidth() * mode.getPhysicalHeight();
}
private JSObject describeDisplay(Display display, Display current) {
Display.Mode mode = display.getMode();
JSObject result = new JSObject();
result.put("id", display.getDisplayId());
result.put("name", display.getName());
result.put("width", mode.getPhysicalWidth());
result.put("height", mode.getPhysicalHeight());
result.put("refreshRate", mode.getRefreshRate());
result.put("isCurrent", current != null && current.getDisplayId() == display.getDisplayId());
return result;
}
private void closePresentation() {
if (presentation == null) return;
ThorPresentation closing = presentation;
presentation = null;
closing.setOnDismissListener(null);
closing.dismiss();
}
private final class ThorPresentation extends Presentation {
private final String initialUrl;
private WebView webView;
ThorPresentation(Context context, Display display, String initialUrl) {
super(context, display);
this.initialUrl = initialUrl;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().setBackgroundDrawableResource(android.R.color.black);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
WindowManager.LayoutParams attributes = getWindow().getAttributes();
attributes.preferredRefreshRate = 60.0f;
getWindow().setAttributes(attributes);
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
);
FrameLayout container = new FrameLayout(getContext());
container.setBackgroundColor(Color.BLACK);
webView = new WebView(getContext());
configureWebView(webView);
container.addView(webView, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
setContentView(container);
webView.loadUrl(initialUrl);
}
@Override
protected void onStop() {
super.onStop();
if (webView != null) {
webView.stopLoading();
webView.destroy();
webView = null;
}
}
}
private void configureWebView(WebView webView) {
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setDatabaseEnabled(true);
settings.setMediaPlaybackRequiresUserGesture(false);
settings.setAllowFileAccess(true);
settings.setAllowContentAccess(true);
webView.setBackgroundColor(Color.BLACK);
webView.setOverScrollMode(View.OVER_SCROLL_NEVER);
webView.setWebViewClient(new WebViewClient() {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
return bridge.getLocalServer().shouldInterceptRequest(request);
}
});
}
private static final class DisplayPlan {
private final Display target;
private final boolean targetIsTop;
private final boolean currentIsTop;
DisplayPlan(Display target, boolean targetIsTop, boolean currentIsTop) {
this.target = target;
this.targetIsTop = targetIsTop;
this.currentIsTop = currentIsTop;
}
}
}
+1 -1
View File
@@ -11,5 +11,5 @@ services:
- "4173:4173"
restart: unless-stopped
volumes:
- /mnt/usbssds/apps/iwanttoheal/app:/app
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
working_dir: /app
@@ -0,0 +1,135 @@
{
"name": "Tri-Headed Ivory Dragon",
"assetId": "blue-eyes-ultimate-dragon",
"source": "MMD_004386.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 5992,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"BEUD_UpperBody",
"BEUD_Lumbar1"
],
"head": [
"BEUD_LHead",
"BEUD_RHead",
"BEUD_Head"
],
"jaw": [
"BEUD_LChin",
"BEUD_RChin",
"BEUD_Chin"
],
"wing_l": [
"BEUD_LWing1",
"BEUD_LWing2",
"BEUD_LWing3",
"BEUD_LWing6",
"BEUD_LWing7",
"BEUD_LWing8",
"BEUD_LWing4",
"BEUD_LWing5"
],
"wing_r": [
"BEUD_RWing1",
"BEUD_RWing2",
"BEUD_RWing3",
"BEUD_RWing4",
"BEUD_RWing5",
"BEUD_RWing6",
"BEUD_RWing7",
"BEUD_RWing8"
],
"arm_l": [
"BEUD_LBicep",
"BEUD_LForearm",
"BEUD_LHand"
],
"arm_r": [
"BEUD_RBicep",
"BEUD_RForearm",
"BEUD_RHand"
],
"leg_l": [
"BEUD_LThigh",
"BEUD_LShin",
"BEUD_LFootBone1",
"BEUD_LFootBone4",
"BEUD_LFootBone2",
"BEUD_LFootBone3"
],
"leg_r": [
"BEUD_RThigh",
"BEUD_RShin",
"BEUD_RFootBone1",
"BEUD_RFootBone2",
"BEUD_RFootBone3",
"BEUD_RFootBone4"
],
"tail": [
"BEUD_Tail1",
"BEUD_Tail2",
"BEUD_Tail3",
"BEUD_Tail4",
"BEUD_Tail5",
"BEUD_Tail6",
"BEUD_Tail7",
"BEUD_Tail8"
],
"head_l": [
"BEUD_LNeck1",
"BEUD_LNeck2",
"BEUD_LNeck3",
"BEUD_LNeck4",
"BEUD_LNeck5",
"BEUD_LNeck6",
"BEUD_LHead"
],
"head_c": [
"BEUD_Neck1",
"BEUD_Neck2",
"BEUD_Neck3",
"BEUD_Neck4",
"BEUD_Neck5",
"BEUD_Neck6",
"BEUD_Head"
],
"head_r": [
"BEUD_RNeck1",
"BEUD_RNeck2",
"BEUD_RNeck3",
"BEUD_RNeck4",
"BEUD_RNeck5",
"BEUD_RNeck6",
"BEUD_RHead"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

@@ -0,0 +1,81 @@
{
"name": "Ivory-Eyed Sky Dragon",
"assetId": "blue-eyes-white-dragon",
"source": "MMD_004007.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 9214,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"MMD_004007_UpperBody",
"MMD_004007_Lumbar1"
],
"head": [
"MMD_004007_Head"
],
"jaw": [
"MMD_004007_chin"
],
"arm_l": [
"MMD_004007_LShoulder",
"MMD_004007_LBicep",
"MMD_004007_LForearm",
"MMD_004007_LHand"
],
"arm_r": [
"MMD_004007_RShoulder",
"MMD_004007_RBicep",
"MMD_004007_RForearm",
"MMD_004007_RHand"
],
"leg_l": [
"MMD_004007_LThigh",
"MMD_004007_LShin",
"MMD_004007_LFootBone1",
"MMD_004007_LFootBone2",
"MMD_004007_LFootBone3"
],
"leg_r": [
"MMD_004007_RThigh",
"MMD_004007_RShin",
"MMD_004007_RFootBone1",
"MMD_004007_RFootBone2",
"MMD_004007_RFootBone3"
],
"tail": [
"MMD_004007_tail1",
"MMD_004007_tail2",
"MMD_004007_tail3",
"MMD_004007_tail4",
"MMD_004007_tail5"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

@@ -0,0 +1,71 @@
{
"name": "Ruin-Orb Black Dragon",
"assetId": "gandora-the-dragon-of-destruction",
"source": "MMD_006076.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 6008,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"wing_l": [
"LWing_Base",
"LWing1",
"LWing_Lower1",
"LWing_Lower2",
"LWing2",
"LWing_Upper1",
"LWing_Upper2",
"LWing_Middle1",
"LWing_Middle2"
],
"leg_l": [
"LThigh",
"LShin",
"LFootBone1",
"LFootBone2"
],
"leg_r": [
"RThigh",
"RShin",
"RFootBone1",
"RFootBone2"
],
"tail": [
"Tail1",
"Tail2",
"Tail3",
"Tail4",
"Tail5",
"Tail6",
"Tail7",
"Tail8"
]
}
}
@@ -0,0 +1,100 @@
{
"name": "Tri-Element Gate Sentinel",
"assetId": "gate-guardian",
"source": "MMD_004380.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 16426,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"Spine"
],
"head": [
"Head_nouse"
],
"jaw": [
"mouth01",
"mouth02"
],
"arm_l": [
"LeftShoulder01",
"LeftArm",
"LeftForeArm",
"LeftHand",
"LeftHandThumb1",
"LeftHandThumb2",
"LeftHandThumb3",
"LeftHandIndex1",
"LeftHandIndex2",
"LeftHandMiddle1",
"LeftHandMiddle2",
"LeftHandRing1",
"LeftHandRing2",
"LeftShoulder02",
"LeftArm01",
"LeftForeArm01",
"LeftHand01",
"LeftHandIndex01",
"LeftHandRing01",
"LeftHandMiddle01"
],
"arm_r": [
"RightShoulder01",
"RightArm",
"RightForeArm",
"RightHand",
"RightHandMiddle1",
"RightHandMiddle2",
"RightHandThumb1",
"RightHandThumb2",
"RightHandThumb3",
"RightHandRing1",
"RightHandRing2",
"RightHandIndex1",
"RightHandIndex2",
"RightShoulder02",
"RightArm01",
"RightForeArm01",
"RightHand01",
"RightHandIndex01",
"RightHandRing01",
"RightHandMiddle01"
],
"leg_l": [
"LeftUpLeg",
"LeftFoot"
],
"leg_r": [
"RightUpLeg",
"RightFoot"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

@@ -0,0 +1,99 @@
{
"name": "Royal Carapace Matriarch",
"assetId": "insect-queen",
"source": "MMD_004768.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 5982,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"UpperBody",
"Spine1"
],
"head": [
"Neck1",
"Neck2",
"Head1",
"LSensor1",
"LSensor2",
"RSensor1",
"RSensor2"
],
"jaw": [
"Mouth3",
"Mandible1",
"Mouth5",
"Mouth6",
"Mouth7",
"Mouth4",
"Mouth1",
"Mouth2"
],
"wing_l": [
"LWing2",
"LWIng3",
"LWing1"
],
"wing_r": [
"RWing1",
"RWing2",
"RWing3"
],
"arm_l": [
"LArm1",
"LArm2",
"LArm3",
"LHand1"
],
"arm_r": [
"RArm1",
"RArm2",
"RArm3",
"RHAnd1"
],
"leg_l": [
"LFoot4",
"LFoot5",
"LFoot6",
"LFoot1",
"LFoot2",
"LFoot3"
],
"leg_r": [
"RFoot1",
"RFoot2",
"RFoot3",
"RFoot4",
"RFoot5",
"RFoot6"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

@@ -0,0 +1,170 @@
{
"name": "Crowned Vine Wraith",
"assetId": "pumpking-the-king-of-ghosts",
"source": "MMD_004105.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 7898,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"hips",
"upper_01",
"upper_02"
],
"head": [
"eye",
"crown",
"eye_upper",
"eye_lower"
],
"jaw": [
"chin_upper",
"chin_lower",
"under"
],
"arm_l": [
"L_arm1_01",
"L_arm1_02",
"L_arm1_03",
"L_arm1_04",
"L_arm1_05",
"L_arm1_06",
"L_arm1_07",
"L_arm1_08",
"L_arm1_09",
"L_arm1_10",
"L_arm1_11",
"L_arm1_12",
"L_arm1_13",
"L_arm1_14",
"L_arm1_15",
"L_arm1_16",
"L_arm2_01",
"L_arm2_02",
"L_arm2_03",
"L_arm2_04",
"L_arm2_05",
"L_arm2_06",
"L_arm2_07",
"L_arm2_08",
"L_arm2_09",
"L_arm2_10",
"L_arm2_11",
"L_arm2_12",
"L_arm2_13",
"L_arm2_14",
"L_arm3_01",
"L_arm3_02",
"L_arm3_03",
"L_arm3_04",
"L_arm3_05",
"L_arm3_06",
"L_arm3_07",
"L_arm3_08",
"L_arm3_09",
"L_arm3_10",
"L_arm3_11",
"L_arm3_12",
"L_arm4_01",
"L_arm4_02",
"L_arm4_03",
"L_arm4_04",
"L_arm4_05",
"L_arm4_06",
"L_arm4_07",
"L_arm4_08",
"L_arm4_09",
"L_arm4_10",
"L_arm4_11",
"L_arm4_12",
"L_arm4_13",
"L_arm4_14"
],
"arm_r": [
"R_arm1_01",
"R_arm1_02",
"R_arm1_03",
"R_arm1_04",
"R_arm1_05",
"R_arm1_06",
"R_arm1_07",
"R_arm1_08",
"R_arm1_09",
"R_arm1_10",
"R_arm1_11",
"R_arm1_12",
"R_arm1_13",
"R_arm1_14",
"R_arm1_15",
"R_arm1_16",
"R_arm2_01",
"R_arm2_02",
"R_arm2_03",
"R_arm2_04",
"R_arm2_05",
"R_arm2_06",
"R_arm2_07",
"R_arm2_08",
"R_arm2_09",
"R_arm2_10",
"R_arm2_11",
"R_arm2_12",
"R_arm2_13",
"R_arm2_14",
"R_arm3_01",
"R_arm3_02",
"R_arm3_03",
"R_arm3_04",
"R_arm3_05",
"R_arm3_06",
"R_arm3_07",
"R_arm3_08",
"R_arm3_09",
"R_arm3_10",
"R_arm3_11",
"R_arm3_12",
"R_arm4_01",
"R_arm4_02",
"R_arm4_03",
"R_arm4_04",
"R_arm4_05",
"R_arm4_06",
"R_arm4_07",
"R_arm4_08",
"R_arm4_09",
"R_arm4_10",
"R_arm4_11",
"R_arm4_12",
"R_arm4_13",
"R_arm4_14"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

@@ -0,0 +1,137 @@
{
"name": "Crimson-Eyed Night Dragon",
"assetId": "red-eyes-black-dragon",
"source": "MMD_004088.dae",
"format": "glTF 2.0 binary (GLB)",
"authoringTool": "5.1.2",
"fps": 30,
"trianglesApprox": 9730,
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Move": {
"frames": 36,
"seconds": 1.2,
"loop": true
},
"Attack": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"HitReact": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
},
"animatedBones": {
"body": [
"bone0000",
"bone0076",
"bone0009",
"bone0078",
"bone0057"
],
"head": [
"bone0025",
"bone0031",
"bone0047",
"bone0073"
],
"jaw": [
"bone0001",
"bone0002",
"bone0029",
"bone0010",
"bone0020"
],
"wing_l": [
"bone0016",
"bone0006",
"bone0049",
"bone0032",
"bone0003",
"bone0058",
"bone0008",
"bone0063",
"bone0079",
"bone0069",
"bone0013",
"bone0030",
"bone0077"
],
"wing_r": [
"bone0033",
"bone0012",
"bone0055",
"bone0046",
"bone0021",
"bone0045",
"bone0041",
"bone0060",
"bone0075",
"bone0070",
"bone0042",
"bone0024",
"bone0040"
],
"arm_l": [
"bone0048",
"bone0061",
"bone0005",
"bone0015",
"bone0038",
"bone0017",
"bone0028",
"bone0023"
],
"arm_r": [
"bone0059",
"bone0027",
"bone0071",
"bone0022",
"bone0052",
"bone0064",
"bone0056",
"bone0074",
"bone0067"
],
"leg_l": [
"bone0004",
"bone0039",
"bone0051",
"bone0014",
"bone0035",
"bone0050",
"bone0066"
],
"leg_r": [
"bone0007",
"bone0043",
"bone0011",
"bone0036",
"bone0026",
"bone0037",
"bone0062"
],
"tail": [
"bone0034",
"bone0053",
"bone0019",
"bone0044",
"bone0054",
"bone0065",
"bone0072",
"bone0018"
]
}
}
@@ -0,0 +1,61 @@
{
"name": "Cinderback Ricochet",
"assetId": "cinderback-ricochet",
"authoringTool": "5.1.2",
"format": "glTF 2.0 binary (GLB)",
"runtime": {
"renderMesh": "Cinderback_Body",
"armature": "Cinderback_Rig",
"collisionAsset": "cinderback-ricochet_collision.glb",
"materials": [
"M_Charcoal",
"M_Plate",
"M_Underbody",
"M_Lava",
"M_Claw"
],
"trianglesApprox": 788
},
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Walk": {
"frames": 32,
"seconds": 1.067,
"loop": true
},
"Curl": {
"frames": 28,
"seconds": 0.933,
"loop": false
},
"Ricochet": {
"frames": 30,
"seconds": 1.0,
"loop": true
},
"ArmorSlam": {
"frames": 38,
"seconds": 1.267,
"loop": false
},
"Recover": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"Stagger": {
"frames": 26,
"seconds": 0.867,
"loop": false
},
"Death": {
"frames": 66,
"seconds": 2.2,
"loop": false
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

@@ -0,0 +1,47 @@
# Ember Mantis Duelist
Original stylized low-poly 3D interpretation of the IWT2 Ember Mantis Duelist concept.
## Runtime assets
- `ember_mantis_duelist.glb` — skinned render model and animation clips
- `ember_mantis_duelist_collision.glb` — three low-cost collision volumes
- `ember_mantis_duelist.asset.json` — dimensions, materials, clip lengths, and conventions
- `ember_mantis_duelist.blend` — editable Blender source and preview studio
- `previews/` — front, side, three-quarter, and CrossSlash renders
## Animation clips
| Clip | Loop | Intended use |
| --- | --- | --- |
| `Idle` | Yes | Combat-ready idle |
| `Walk` | Yes | Forward locomotion driven by game movement |
| `Sidestep` | No | Fast lateral reposition |
| `Melee` | No | Close-range scythe strike |
| `LineSlash` | No | Single glowing arena cut |
| `CrossSlash` | No | Two-arm crossed arena cut |
| `Recover` | No | Post-attack settle |
| `Stagger` | No | Hit reaction |
| `Death` | No | Final collapse |
## React Three Fiber loading
```tsx
const gltf = useGLTF(EMBER_MANTIS_URL)
const { actions } = useAnimations(gltf.animations, gltf.scene)
actions.Idle?.reset().fadeIn(0.15).play()
```
Model uses meters, Blender `-Y` forward, and glTF `+Y` up. Runtime movement should remain authoritative; locomotion clips contain only small pose offsets and no travel root motion.
## Rebuild
```sh
/Applications/Blender.app/Contents/MacOS/Blender \
--background \
--factory-startup \
--python scripts/blender/build_ember_mantis_duelist.py
```
Build script recreates Blender source, GLBs, metadata, and preview renders deterministically.
@@ -0,0 +1,74 @@
{
"name": "Ember Mantis Duelist",
"assetId": "ember-mantis-duelist",
"license": "Original project asset",
"sourceConcept": "IWT2 Ember Mantis Duelist side-cel concept",
"authoringTool": "5.1.2",
"format": "glTF 2.0 binary (GLB)",
"coordinateConvention": {
"up": "+Y after glTF export",
"blenderForward": "-Y",
"units": "meters"
},
"runtime": {
"renderMesh": "EmberMantis_Body",
"armature": "EmberMantis_Rig",
"collisionAsset": "ember_mantis_duelist_collision.glb",
"materials": [
"M_Chitin",
"M_CrimsonArmor",
"M_DeepRed",
"M_EyeGlow",
"M_BoneBlade",
"M_EmberGlow"
],
"trianglesApprox": 1574
},
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Walk": {
"frames": 30,
"seconds": 1.0,
"loop": true
},
"Sidestep": {
"frames": 18,
"seconds": 0.6,
"loop": false
},
"Melee": {
"frames": 24,
"seconds": 0.8,
"loop": false
},
"LineSlash": {
"frames": 32,
"seconds": 1.067,
"loop": false
},
"CrossSlash": {
"frames": 38,
"seconds": 1.267,
"loop": false
},
"Recover": {
"frames": 22,
"seconds": 0.733,
"loop": false
},
"Stagger": {
"frames": 28,
"seconds": 0.933,
"loop": false
},
"Death": {
"frames": 72,
"seconds": 2.4,
"loop": false
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

@@ -0,0 +1,55 @@
{
"name": "Obsidian Ram Golem",
"assetId": "obsidian-ram-golem",
"authoringTool": "5.1.2",
"format": "glTF 2.0 binary (GLB)",
"runtime": {
"renderMesh": "ObsidianRam_Body",
"armature": "ObsidianRam_Rig",
"collisionAsset": "obsidian-ram-golem_collision.glb",
"materials": [
"M_Obsidian",
"M_Basalt",
"M_Armor",
"M_Lava"
],
"trianglesApprox": 916
},
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Walk": {
"frames": 32,
"seconds": 1.067,
"loop": true
},
"Charge": {
"frames": 30,
"seconds": 1.0,
"loop": false
},
"Quake": {
"frames": 36,
"seconds": 1.2,
"loop": false
},
"ArmorShatter": {
"frames": 42,
"seconds": 1.4,
"loop": false
},
"Stagger": {
"frames": 26,
"seconds": 0.867,
"loop": false
},
"Death": {
"frames": 70,
"seconds": 2.333,
"loop": false
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

@@ -0,0 +1,61 @@
{
"name": "Sandglass Scorpion",
"assetId": "sandglass-scorpion",
"authoringTool": "5.1.2",
"format": "glTF 2.0 binary (GLB)",
"runtime": {
"renderMesh": "SandglassScorpion_Body",
"armature": "SandglassScorpion_Rig",
"collisionAsset": "sandglass-scorpion_collision.glb",
"materials": [
"M_Obsidian",
"M_Ochre",
"M_Gold",
"M_Glass",
"M_Bone"
],
"trianglesApprox": 1048
},
"animations": {
"Idle": {
"frames": 60,
"seconds": 2.0,
"loop": true
},
"Walk": {
"frames": 32,
"seconds": 1.067,
"loop": true
},
"ClawAttack": {
"frames": 28,
"seconds": 0.933,
"loop": false
},
"Burrow": {
"frames": 34,
"seconds": 1.133,
"loop": false
},
"Eruption": {
"frames": 38,
"seconds": 1.267,
"loop": false
},
"Hourglass": {
"frames": 44,
"seconds": 1.467,
"loop": false
},
"Stagger": {
"frames": 26,
"seconds": 0.867,
"loop": false
},
"Death": {
"frames": 70,
"seconds": 2.333,
"loop": false
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.0",
"version": "0.1.3",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -0,0 +1,833 @@
"""Build Ember Mantis Duelist as a rigged, animated, runtime-ready GLB.
Run with:
blender --background --factory-startup --python scripts/blender/build_ember_mantis_duelist.py
The asset is an original 3D interpretation of the IWT2 Ember Mantis concept.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
OUT_DIR = ROOT / "game_assets/models/original/bosses/ember-mantis-duelist"
PREVIEW_DIR = OUT_DIR / "previews"
BLEND_PATH = OUT_DIR / "ember_mantis_duelist.blend"
GLB_PATH = OUT_DIR / "ember_mantis_duelist.glb"
COLLISION_PATH = OUT_DIR / "ember_mantis_duelist_collision.glb"
METADATA_PATH = OUT_DIR / "ember_mantis_duelist.asset.json"
FPS = 30
TAU = math.tau
PARTS: list[bpy.types.Object] = []
RUNTIME_MATERIALS: list[bpy.types.Material] = []
def reset_scene() -> None:
bpy.ops.object.mode_set(mode="OBJECT") if bpy.context.object and bpy.context.object.mode != "OBJECT" else None
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for datablocks in (
bpy.data.meshes,
bpy.data.curves,
bpy.data.armatures,
bpy.data.materials,
bpy.data.cameras,
bpy.data.lights,
bpy.data.actions,
):
for datablock in list(datablocks):
datablocks.remove(datablock)
def make_material(
name: str,
color: tuple[float, float, float, float],
*,
metallic: float = 0.0,
roughness: float = 0.5,
emission: tuple[float, float, float, float] | None = None,
emission_strength: float = 0.0,
) -> bpy.types.Material:
mat = bpy.data.materials.new(name)
mat.use_nodes = True
mat.diffuse_color = color
mat.metallic = metallic
mat.roughness = roughness
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = roughness
if emission:
bsdf.inputs["Emission Color"].default_value = emission
bsdf.inputs["Emission Strength"].default_value = emission_strength
return mat
def assign_rigid_group(obj: bpy.types.Object, bone_name: str) -> bpy.types.Object:
group = obj.vertex_groups.new(name=bone_name)
group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
PARTS.append(obj)
return obj
def finish_mesh(
obj: bpy.types.Object,
name: str,
material: bpy.types.Material,
bone_name: str,
*,
smooth: bool = True,
) -> bpy.types.Object:
obj.name = name
obj.data.name = f"{name}_Mesh"
# Every source part uses the same ordered slots. Blender 5.1 otherwise keeps
# joined material slots but resets joined face indices to slot zero.
for runtime_material in RUNTIME_MATERIALS:
obj.data.materials.append(runtime_material)
material_index = next(
index for index, runtime_material in enumerate(RUNTIME_MATERIALS) if runtime_material.name == material.name
)
for polygon in obj.data.polygons:
polygon.material_index = material_index
material_group = obj.vertex_groups.new(name=f"__MAT_{material_index}")
material_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
if smooth:
for polygon in obj.data.polygons:
polygon.use_smooth = True
return assign_rigid_group(obj, bone_name)
def add_ellipsoid(
name: str,
location: tuple[float, float, float],
scale: tuple[float, float, float],
material: bpy.types.Material,
bone_name: str,
*,
subdivisions: int = 2,
rotation: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> bpy.types.Object:
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdivisions, radius=1.0, location=location, rotation=rotation)
obj = bpy.context.object
obj.scale = scale
return finish_mesh(obj, name, material, bone_name)
def add_cone_between(
name: str,
start: tuple[float, float, float],
end: tuple[float, float, float],
radius_start: float,
radius_end: float,
material: bpy.types.Material,
bone_name: str,
*,
vertices: int = 8,
) -> bpy.types.Object:
start_v = Vector(start)
end_v = Vector(end)
direction = end_v - start_v
midpoint = (start_v + end_v) * 0.5
bpy.ops.mesh.primitive_cone_add(
vertices=vertices,
radius1=radius_start,
radius2=radius_end,
depth=direction.length,
location=midpoint,
)
obj = bpy.context.object
obj.rotation_mode = "QUATERNION"
obj.rotation_quaternion = direction.to_track_quat("Z", "Y")
obj.rotation_mode = "XYZ"
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
return finish_mesh(obj, name, material, bone_name)
def add_blade(
name: str,
side: float,
material: bpy.types.Material,
bone_name: str,
*,
variant: str = "main",
) -> bpy.types.Object:
# Extruded hook polygon. Forward is Blender -Y; mirrored across X.
x_center = side * 1.42
thickness = {"main": 0.095, "edge": 0.104, "scar": 0.108}[variant]
if variant == "edge":
yz = [
(-0.72, 2.78),
(-1.34, 2.55),
(-2.62, 1.72),
(-2.42, 2.06),
(-1.31, 2.78),
]
elif variant == "scar":
yz = [
(-0.82, 2.94),
(-1.33, 2.72),
(-2.30, 2.02),
(-2.10, 2.33),
(-1.33, 2.91),
]
else:
yz = [
(-0.50, 3.12),
(-0.72, 2.78),
(-1.34, 2.55),
(-2.62, 1.72),
(-2.31, 2.28),
(-1.45, 3.18),
(-0.82, 3.34),
]
vertices = []
for x in (x_center - thickness, x_center + thickness):
vertices.extend((x, y, z) for y, z in yz)
count = len(yz)
faces = [tuple(range(count)), tuple(range(count, count * 2))[::-1]]
for index in range(count):
nxt = (index + 1) % count
faces.append((index, nxt, count + nxt, count + index))
mesh = bpy.data.meshes.new(f"{name}_Mesh")
mesh.from_pydata(vertices, [], faces)
mesh.validate()
mesh.update()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
return finish_mesh(obj, name, material, bone_name, smooth=False)
def add_plate(
name: str,
location: tuple[float, float, float],
scale: tuple[float, float, float],
rotation: tuple[float, float, float],
material: bpy.types.Material,
bone_name: str,
) -> bpy.types.Object:
bpy.ops.mesh.primitive_cone_add(vertices=6, radius1=1.0, radius2=0.72, depth=0.46, location=location, rotation=rotation)
obj = bpy.context.object
obj.scale = scale
return finish_mesh(obj, name, material, bone_name, smooth=False)
def create_armature() -> bpy.types.Object:
arm_data = bpy.data.armatures.new("EmberMantis_Rig")
armature = bpy.data.objects.new("EmberMantis_Rig", arm_data)
bpy.context.collection.objects.link(armature)
bpy.context.view_layer.objects.active = armature
armature.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
bones = {
"Root": ((0, 0, 0), (0, 0, 0.55), None),
"Pelvis": ((0, 0, 1.70), (0, 0, 2.35), "Root"),
"Abdomen": ((0, 0.05, 2.15), (0, 0.42, 2.78), "Pelvis"),
"Tail": ((0, 0.38, 2.58), (0, 1.30, 2.18), "Abdomen"),
"Chest": ((0, 0.02, 2.65), (0, -0.04, 3.48), "Abdomen"),
"Neck": ((0, -0.03, 3.38), (0, -0.14, 3.88), "Chest"),
"Head": ((0, -0.13, 3.78), (0, -0.34, 4.35), "Neck"),
"UpperArm.L": ((-0.34, -0.02, 3.30), (-0.98, -0.27, 3.20), "Chest"),
"Forearm.L": ((-0.98, -0.27, 3.20), (-1.38, -0.60, 3.02), "UpperArm.L"),
"Blade.L": ((-1.38, -0.60, 3.02), (-1.46, -1.48, 2.55), "Forearm.L"),
"UpperArm.R": ((0.34, -0.02, 3.30), (0.98, -0.27, 3.20), "Chest"),
"Forearm.R": ((0.98, -0.27, 3.20), (1.38, -0.60, 3.02), "UpperArm.R"),
"Blade.R": ((1.38, -0.60, 3.02), (1.46, -1.48, 2.55), "Forearm.R"),
"Thigh.L": ((-0.30, 0.02, 2.00), (-0.66, 0.02, 1.18), "Pelvis"),
"Shin.L": ((-0.66, 0.02, 1.18), (-0.76, -0.34, 0.38), "Thigh.L"),
"Foot.L": ((-0.76, -0.34, 0.38), (-0.76, -0.96, 0.15), "Shin.L"),
"Thigh.R": ((0.30, 0.02, 2.00), (0.66, 0.02, 1.18), "Pelvis"),
"Shin.R": ((0.66, 0.02, 1.18), (0.76, -0.34, 0.38), "Thigh.R"),
"Foot.R": ((0.76, -0.34, 0.38), (0.76, -0.96, 0.15), "Shin.R"),
}
for name, (head, tail, parent) in bones.items():
bone = arm_data.edit_bones.new(name)
bone.head = head
bone.tail = tail
if parent:
bone.parent = arm_data.edit_bones[parent]
bpy.ops.object.mode_set(mode="POSE")
for pose_bone in armature.pose.bones:
pose_bone.rotation_mode = "XYZ"
bpy.ops.object.mode_set(mode="OBJECT")
armature.show_in_front = True
return armature
def create_model(armature: bpy.types.Object, mats: dict[str, bpy.types.Material]) -> bpy.types.Object:
chitin = mats["chitin"]
crimson = mats["crimson"]
deep_red = mats["deep_red"]
ember = mats["ember"]
bone = mats["bone"]
eye = mats["eye"]
# Core insect anatomy.
add_ellipsoid("Thorax", (0, 0.00, 3.05), (0.54, 0.47, 0.72), chitin, "Chest")
add_ellipsoid("ChestArmor", (0, -0.35, 3.18), (0.47, 0.18, 0.57), crimson, "Chest")
add_ellipsoid("Pelvis", (0, 0.08, 2.13), (0.48, 0.42, 0.55), deep_red, "Pelvis")
add_ellipsoid("Abdomen", (0, 0.52, 2.34), (0.58, 0.82, 0.49), chitin, "Abdomen")
add_ellipsoid("TailMass", (0, 1.18, 2.10), (0.56, 0.92, 0.40), deep_red, "Tail")
add_cone_between("TailTip", (0, 1.44, 2.13), (0, 2.20, 1.88), 0.50, 0.06, crimson, "Tail", vertices=7)
# Overlapping abdominal armor plates.
for index, y in enumerate((0.33, 0.72, 1.10, 1.47)):
scale = 0.52 - index * 0.055
add_plate(
f"AbdomenPlate_{index + 1}",
(0, y, 2.50 - index * 0.10),
(scale, 0.42, 0.20),
(math.radians(83), 0, 0),
crimson if index % 2 == 0 else deep_red,
"Abdomen" if index < 2 else "Tail",
)
for side in (-1, 1):
add_cone_between(
f"AbdomenSpike_{index}_{side:+d}",
(side * scale * 0.72, y, 2.48 - index * 0.10),
(side * (scale + 0.38), y + 0.08, 2.34 - index * 0.12),
0.10,
0.0,
crimson,
"Abdomen" if index < 2 else "Tail",
vertices=6,
)
# Neck, predatory head, jaws, eyes, crown spikes.
add_cone_between("NeckCore", (0, -0.03, 3.40), (0, -0.20, 3.88), 0.30, 0.25, chitin, "Neck")
add_ellipsoid("Head", (0, -0.34, 4.10), (0.43, 0.50, 0.38), crimson, "Head")
add_ellipsoid("FaceMask", (0, -0.73, 4.05), (0.32, 0.19, 0.28), chitin, "Head")
for side in (-1, 1):
suffix = "L" if side < 0 else "R"
add_ellipsoid(f"Eye_{suffix}", (side * 0.30, -0.67, 4.16), (0.09, 0.045, 0.075), eye, "Head", subdivisions=1)
add_cone_between(f"Mandible_{suffix}", (side * 0.18, -0.70, 3.98), (side * 0.34, -1.05, 3.83), 0.10, 0.02, bone, "Head", vertices=6)
add_cone_between(f"AntennaBase_{suffix}", (side * 0.22, -0.33, 4.34), (side * 0.46, -0.45, 4.82), 0.055, 0.035, deep_red, "Head", vertices=6)
add_cone_between(f"AntennaTip_{suffix}", (side * 0.46, -0.45, 4.82), (side * 0.76, -0.88, 5.20), 0.04, 0.0, ember, "Head", vertices=6)
add_cone_between(f"CrownSpike_{suffix}", (side * 0.23, -0.05, 4.31), (side * 0.48, 0.13, 4.90), 0.12, 0.0, crimson, "Head", vertices=6)
add_cone_between("CrownSpike_Center", (0, 0.02, 4.34), (0, 0.32, 5.02), 0.13, 0.0, crimson, "Head", vertices=6)
# Scythe arms: dark joints, crimson armor, pale blades, emissive blade scars.
for side in (-1, 1):
suffix = "L" if side < 0 else "R"
bones = (f"UpperArm.{suffix}", f"Forearm.{suffix}", f"Blade.{suffix}")
add_cone_between(
f"UpperArm_{suffix}",
(side * 0.34, -0.02, 3.30),
(side * 0.98, -0.27, 3.20),
0.25,
0.17,
chitin,
bones[0],
)
add_plate(
f"ShoulderPlate_{suffix}",
(side * 0.55, -0.07, 3.42),
(0.27, 0.33, 0.24),
(0, math.radians(side * 18), math.radians(side * 12)),
crimson,
bones[0],
)
add_cone_between(
f"Forearm_{suffix}",
(side * 0.98, -0.27, 3.20),
(side * 1.40, -0.64, 3.02),
0.19,
0.13,
deep_red,
bones[1],
)
add_ellipsoid(f"BladeJoint_{suffix}", (side * 1.41, -0.62, 3.02), (0.22, 0.24, 0.22), crimson, bones[2], subdivisions=1)
add_blade(f"ScytheBlade_{suffix}", side, deep_red, bones[2])
add_blade(f"ScytheBoneEdge_{suffix}", side, bone, bones[2], variant="edge")
add_blade(f"ScytheEmberScar_{suffix}", side, ember, bones[2], variant="scar")
add_cone_between(
f"ElbowSpike_{suffix}",
(side * 1.00, -0.22, 3.30),
(side * 1.28, 0.04, 3.55),
0.09,
0.0,
crimson,
bones[1],
vertices=6,
)
# Digitigrade legs and hooked feet.
for side in (-1, 1):
suffix = "L" if side < 0 else "R"
thigh_bone, shin_bone, foot_bone = f"Thigh.{suffix}", f"Shin.{suffix}", f"Foot.{suffix}"
add_cone_between(f"Thigh_{suffix}", (side * 0.30, 0.02, 2.00), (side * 0.66, 0.02, 1.18), 0.25, 0.18, chitin, thigh_bone)
add_plate(
f"ThighPlate_{suffix}",
(side * 0.47, -0.02, 1.72),
(0.28, 0.31, 0.34),
(0, math.radians(side * 12), 0),
crimson,
thigh_bone,
)
add_cone_between(f"Shin_{suffix}", (side * 0.66, 0.02, 1.18), (side * 0.76, -0.34, 0.38), 0.18, 0.11, deep_red, shin_bone)
add_cone_between(f"Foot_{suffix}", (side * 0.76, -0.34, 0.38), (side * 0.76, -0.96, 0.15), 0.14, 0.08, chitin, foot_bone)
for toe_index, toe_x in enumerate((-0.13, 0.0, 0.13)):
add_cone_between(
f"Toe_{suffix}_{toe_index + 1}",
(side * 0.76 + toe_x, -0.88, 0.15),
(side * 0.76 + toe_x * 1.35, -1.25, 0.07),
0.055,
0.0,
bone,
foot_bone,
vertices=5,
)
add_cone_between(
f"KneeSpike_{suffix}",
(side * 0.68, 0.00, 1.20),
(side * 0.95, 0.28, 1.25),
0.09,
0.0,
crimson,
shin_bone,
vertices=6,
)
# Ember fissures: limited emissive geometry gives strong read without textures.
fissures = [
("ChestFissure", (-0.05, -0.535, 3.48), (0.12, -0.535, 2.92), "Chest"),
("AbdomenFissure", (-0.07, -0.27, 2.47), (0.15, -0.31, 2.11), "Abdomen"),
]
for name, start, end, group in fissures:
add_cone_between(name, start, end, 0.035, 0.02, ember, group, vertices=5)
# Join all render meshes into one skinned mesh; keep four material primitives.
bpy.ops.object.select_all(action="DESELECT")
for obj in PARTS:
obj.select_set(True)
bpy.context.view_layer.objects.active = PARTS[0]
bpy.ops.object.join()
body = bpy.context.object
body.name = "EmberMantis_Body"
body.data.name = "EmberMantis_Body_Mesh"
consolidate_material_slots(body)
modifier = body.modifiers.new("EmberMantis_Armature", "ARMATURE")
modifier.object = armature
modifier.use_deform_preserve_volume = False
body.parent = armature
return body
def consolidate_material_slots(obj: bpy.types.Object) -> None:
old_materials = [slot.material for slot in obj.material_slots]
unique: list[bpy.types.Material] = []
new_index_by_name: dict[str, int] = {}
old_to_new: dict[int, int] = {}
for old_index, material in enumerate(old_materials):
if material.name not in new_index_by_name:
new_index_by_name[material.name] = len(unique)
unique.append(material)
old_to_new[old_index] = new_index_by_name[material.name]
material_groups = {
group.index: int(group.name.removeprefix("__MAT_"))
for group in obj.vertex_groups
if group.name.startswith("__MAT_")
}
face_material_ids: list[int] = []
for polygon in obj.data.polygons:
vertex = obj.data.vertices[polygon.vertices[0]]
face_material_ids.append(next(
material_groups[assignment.group]
for assignment in vertex.groups
if assignment.group in material_groups
))
obj.data.materials.clear()
for material in unique:
obj.data.materials.append(material)
for polygon, material_id in zip(obj.data.polygons, face_material_ids, strict=True):
polygon.material_index = material_id
for group in [group for group in obj.vertex_groups if group.name.startswith("__MAT_")]:
obj.vertex_groups.remove(group)
def reset_pose(armature: bpy.types.Object) -> None:
for bone in armature.pose.bones:
bone.location = (0, 0, 0)
bone.rotation_euler = (0, 0, 0)
bone.scale = (1, 1, 1)
def key_pose(
armature: bpy.types.Object,
frame: int,
rotations: dict[str, tuple[float, float, float]] | None = None,
locations: dict[str, tuple[float, float, float]] | None = None,
scales: dict[str, tuple[float, float, float]] | None = None,
) -> None:
reset_pose(armature)
for name, value in (rotations or {}).items():
armature.pose.bones[name].rotation_euler = tuple(math.radians(component) for component in value)
for name, value in (locations or {}).items():
armature.pose.bones[name].location = value
for name, value in (scales or {}).items():
armature.pose.bones[name].scale = value
for bone in armature.pose.bones:
bone.keyframe_insert("location", frame=frame, group=bone.name)
bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name)
bone.keyframe_insert("scale", frame=frame, group=bone.name)
def build_action(
armature: bpy.types.Object,
name: str,
end_frame: int,
poses: list[dict],
*,
loop: bool = False,
) -> bpy.types.Action:
action = bpy.data.actions.new(name)
action.use_fake_user = True
action.use_frame_range = True
action.frame_start = 1
action.frame_end = end_frame
action.use_cyclic = loop
armature.animation_data.action = action
for pose in poses:
key_pose(armature, **pose)
armature.animation_data.action = None
return action
def create_animations(armature: bpy.types.Object) -> dict[str, dict]:
armature.animation_data_create()
clips: dict[str, dict] = {}
def add(name: str, frames: int, poses: list[dict], loop: bool = False) -> None:
build_action(armature, name, frames, poses, loop=loop)
clips[name] = {"frames": frames, "seconds": round(frames / FPS, 3), "loop": loop}
add(
"Idle",
60,
[
{"frame": 1, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}},
{"frame": 16, "locations": {"Root": (0, 0, 0.045)}, "rotations": {"Chest": (-2, 0, 2), "Abdomen": (3, 0, -2), "Head": (-2, 0, -3), "Blade.L": (2, 0, 2), "Blade.R": (-2, 0, -2)}},
{"frame": 31, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}},
{"frame": 46, "locations": {"Root": (0, 0, 0.045)}, "rotations": {"Chest": (-2, 0, 2), "Abdomen": (3, 0, -2), "Head": (-2, 0, -3), "Blade.L": (2, 0, 2), "Blade.R": (-2, 0, -2)}},
{"frame": 60, "rotations": {"Chest": (0, 0, -2), "Head": (2, 0, 3), "Blade.L": (0, 0, -4), "Blade.R": (0, 0, 4)}},
],
loop=True,
)
walk_a = {"Thigh.L": (-24, 0, 4), "Shin.L": (18, 0, 0), "Foot.L": (-8, 0, 0), "Thigh.R": (22, 0, -4), "Shin.R": (-12, 0, 0), "Foot.R": (8, 0, 0), "UpperArm.L": (5, 0, 2), "UpperArm.R": (-5, 0, -2)}
walk_b = {name: tuple(-component for component in rotation) for name, rotation in walk_a.items()}
add(
"Walk",
30,
[
{"frame": 1, "rotations": walk_a},
{"frame": 8, "locations": {"Root": (0, 0, 0.055)}, "rotations": {"Chest": (-3, 0, 0), "Abdomen": (4, 0, 0)}},
{"frame": 16, "rotations": walk_b},
{"frame": 23, "locations": {"Root": (0, 0, 0.055)}, "rotations": {"Chest": (-3, 0, 0), "Abdomen": (4, 0, 0)}},
{"frame": 30, "rotations": walk_a},
],
loop=True,
)
add(
"Sidestep",
18,
[
{"frame": 1, "rotations": {"Chest": (0, 0, 0)}},
{"frame": 5, "locations": {"Root": (-0.10, 0, -0.03)}, "rotations": {"Chest": (0, -8, -12), "Thigh.L": (8, 0, 15), "Thigh.R": (-10, 0, 16), "Blade.L": (0, 0, 8), "Blade.R": (0, 0, 12)}},
{"frame": 11, "locations": {"Root": (0.12, 0, 0.04)}, "rotations": {"Chest": (0, 7, 10), "Thigh.L": (-8, 0, -12), "Thigh.R": (10, 0, -15)}},
{"frame": 18, "rotations": {"Chest": (0, 0, 0)}},
],
)
add(
"Melee",
24,
[
{"frame": 1},
{"frame": 7, "rotations": {"Chest": (0, 0, 7), "UpperArm.R": (-12, 18, 25), "Forearm.R": (10, 0, 38), "Blade.R": (-16, 5, 42), "UpperArm.L": (5, 0, -8)}},
{"frame": 11, "locations": {"Root": (0, -0.08, 0)}, "rotations": {"Chest": (9, 0, -12), "UpperArm.R": (18, -12, -52), "Forearm.R": (-24, 0, -62), "Blade.R": (30, 0, -58), "Head": (-8, 0, 0)}},
{"frame": 16, "rotations": {"UpperArm.R": (6, 0, -20), "Forearm.R": (-10, 0, -25)}},
{"frame": 24},
],
)
add(
"LineSlash",
32,
[
{"frame": 1},
{"frame": 10, "locations": {"Root": (0, 0.05, -0.03)}, "rotations": {"Chest": (-7, 0, 10), "Head": (7, 0, -5), "UpperArm.L": (-15, 12, -30), "Forearm.L": (0, 0, -46), "Blade.L": (-20, 0, -35), "UpperArm.R": (8, 0, 8)}},
{"frame": 15, "locations": {"Root": (0, -0.12, 0.03)}, "rotations": {"Chest": (12, 0, -16), "UpperArm.L": (24, -10, 58), "Forearm.L": (-18, 0, 74), "Blade.L": (34, 0, 70), "Head": (-9, 0, 5)}},
{"frame": 21, "rotations": {"Chest": (5, 0, -6), "UpperArm.L": (8, 0, 20), "Forearm.L": (-5, 0, 28), "Blade.L": (10, 0, 20)}},
{"frame": 32},
],
)
add(
"CrossSlash",
38,
[
{"frame": 1},
{"frame": 11, "locations": {"Root": (0, 0.04, -0.05)}, "rotations": {"Chest": (-9, 0, 0), "UpperArm.L": (-8, 18, 38), "Forearm.L": (10, 0, 52), "Blade.L": (-15, 0, 32), "UpperArm.R": (-8, -18, -38), "Forearm.R": (10, 0, -52), "Blade.R": (-15, 0, -32)}},
{"frame": 16, "locations": {"Root": (0, -0.10, 0.08)}, "rotations": {"Chest": (14, 0, 0), "UpperArm.L": (22, -14, -52), "Forearm.L": (-18, 0, -72), "Blade.L": (28, 0, -64), "UpperArm.R": (22, 14, 52), "Forearm.R": (-18, 0, 72), "Blade.R": (28, 0, 64), "Head": (-10, 0, 0)}},
{"frame": 21, "locations": {"Root": (0, -0.04, 0.02)}, "rotations": {"Chest": (5, 0, 0), "UpperArm.L": (8, 0, -22), "Forearm.L": (-6, 0, -30), "UpperArm.R": (8, 0, 22), "Forearm.R": (-6, 0, 30)}},
{"frame": 38},
],
)
add(
"Recover",
22,
[
{"frame": 1, "locations": {"Root": (0, -0.04, -0.05)}, "rotations": {"Chest": (14, 0, 0), "Head": (-9, 0, 0), "UpperArm.L": (12, 0, -18), "UpperArm.R": (12, 0, 18)}},
{"frame": 8, "rotations": {"Chest": (-4, 0, 0), "Abdomen": (6, 0, 0), "Head": (4, 0, 0)}},
{"frame": 15, "rotations": {"Chest": (2, 0, 0), "Abdomen": (-2, 0, 0)}},
{"frame": 22},
],
)
add(
"Stagger",
28,
[
{"frame": 1},
{"frame": 4, "locations": {"Root": (0, 0.10, -0.07)}, "rotations": {"Chest": (-18, 0, -10), "Head": (24, 0, 12), "UpperArm.L": (-16, 0, -22), "UpperArm.R": (-16, 0, 22), "Thigh.L": (12, 0, 0), "Thigh.R": (12, 0, 0)}},
{"frame": 10, "rotations": {"Chest": (8, 0, 6), "Head": (-12, 0, -8), "Blade.L": (14, 0, 0), "Blade.R": (14, 0, 0)}},
{"frame": 18, "rotations": {"Chest": (-3, 0, -2), "Head": (4, 0, 2)}},
{"frame": 28},
],
)
add(
"Death",
72,
[
{"frame": 1},
{"frame": 12, "locations": {"Root": (0, 0.08, -0.10)}, "rotations": {"Chest": (-20, 0, 9), "Head": (18, 0, -12), "UpperArm.L": (-20, 0, -25), "UpperArm.R": (-20, 0, 25), "Thigh.L": (14, 0, 0), "Thigh.R": (14, 0, 0)}},
{"frame": 28, "locations": {"Root": (0.10, 0.20, -0.55)}, "rotations": {"Root": (0, 38, 74), "Chest": (-34, 8, -18), "Head": (28, 0, 15), "UpperArm.L": (30, 0, -48), "UpperArm.R": (-12, 0, 48), "Thigh.L": (28, 0, 22), "Thigh.R": (-14, 0, -18)}},
{"frame": 48, "locations": {"Root": (0.18, 0.20, -1.12)}, "rotations": {"Root": (0, 52, 88), "Chest": (-24, 12, -22), "Head": (38, 0, 22), "UpperArm.L": (42, 0, -62), "UpperArm.R": (8, 0, 58), "Thigh.L": (36, 0, 28), "Thigh.R": (-20, 0, -22)}},
{"frame": 72, "locations": {"Root": (0.18, 0.20, -1.18)}, "rotations": {"Root": (0, 52, 88), "Chest": (-24, 12, -22), "Head": (42, 0, 25), "UpperArm.L": (44, 0, -64), "UpperArm.R": (10, 0, 60), "Thigh.L": (38, 0, 28), "Thigh.R": (-20, 0, -22)}},
],
)
return clips
def create_collision_meshes() -> list[bpy.types.Object]:
collision_material = make_material("CollisionProxy", (0.05, 0.8, 0.2, 0.35), roughness=1.0)
collision_parts: list[bpy.types.Object] = []
specs = [
("COLLISION_Body", (0, 0.18, 2.55), (0.72, 1.12, 1.55)),
("COLLISION_Head", (0, -0.38, 4.08), (0.52, 0.62, 0.48)),
("COLLISION_ScytheReach", (0, -1.35, 2.66), (1.82, 1.18, 0.84)),
]
for name, location, scale in specs:
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=1, location=location)
obj = bpy.context.object
obj.name = name
obj.scale = scale
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(collision_material)
collision_parts.append(obj)
return collision_parts
def select_only(objects: list[bpy.types.Object]) -> None:
bpy.ops.object.select_all(action="DESELECT")
for obj in objects:
obj.hide_set(False)
obj.select_set(True)
if objects:
bpy.context.view_layer.objects.active = objects[0]
def export_glbs(armature: bpy.types.Object, body: bpy.types.Object, collision_parts: list[bpy.types.Object]) -> None:
select_only([armature, body])
bpy.ops.export_scene.gltf(
filepath=str(GLB_PATH),
export_format="GLB",
use_selection=True,
export_animations=True,
export_animation_mode="ACTIONS",
export_frame_range=True,
export_skins=True,
export_morph=False,
export_yup=True,
export_apply=False,
export_cameras=False,
export_lights=False,
)
select_only(collision_parts)
bpy.ops.export_scene.gltf(
filepath=str(COLLISION_PATH),
export_format="GLB",
use_selection=True,
export_animations=False,
export_skins=False,
export_materials="NONE",
export_yup=True,
export_apply=True,
)
for obj in collision_parts:
obj.hide_render = True
obj.hide_set(True)
def look_at(obj: bpy.types.Object, target: tuple[float, float, float]) -> None:
direction = Vector(target) - obj.location
obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def setup_preview_scene() -> tuple[bpy.types.Object, list[bpy.types.Object]]:
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = 900
scene.render.resolution_y = 900
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.film_transparent = False
scene.render.fps = FPS
scene.world.color = (0.006, 0.008, 0.014)
world_nodes = scene.world.node_tree.nodes if scene.world.use_nodes else None
if not scene.world.use_nodes:
scene.world.use_nodes = True
world_nodes = scene.world.node_tree.nodes
world_nodes["Background"].inputs["Color"].default_value = (0.006, 0.009, 0.018, 1)
world_nodes["Background"].inputs["Strength"].default_value = 0.28
camera_data = bpy.data.cameras.new("PreviewCamera")
camera = bpy.data.objects.new("PreviewCamera", camera_data)
bpy.context.collection.objects.link(camera)
camera_data.lens = 58
scene.camera = camera
lights: list[bpy.types.Object] = []
light_specs = [
("Key", "AREA", (4.5, -6.5, 7.5), (1.0, 0.30, 0.10), 1150, 5.0),
("Fill", "AREA", (-5.5, -3.0, 4.5), (0.10, 0.25, 1.0), 850, 4.0),
("Rim", "AREA", (1.0, 5.5, 6.5), (1.0, 0.08, 0.02), 1300, 3.0),
]
for name, kind, location, color, energy, size in light_specs:
data = bpy.data.lights.new(name, type=kind)
data.energy = energy
data.color = color
data.shape = "DISK"
data.size = size
light = bpy.data.objects.new(name, data)
light.location = location
bpy.context.collection.objects.link(light)
look_at(light, (0, 0, 2.5))
lights.append(light)
floor_mat = make_material("PreviewFloor", (0.018, 0.022, 0.032, 1), metallic=0.05, roughness=0.82)
bpy.ops.mesh.primitive_plane_add(size=30, location=(0, 0, 0))
floor = bpy.context.object
floor.name = "PreviewFloor"
floor.data.materials.append(floor_mat)
return camera, lights + [floor]
def activate_action(armature: bpy.types.Object, name: str, frame: int) -> None:
armature.animation_data.action = bpy.data.actions[name]
bpy.context.scene.frame_set(frame)
def render_previews(armature: bpy.types.Object) -> None:
camera, _studio = setup_preview_scene()
views = [
("ember_mantis_three_quarter.png", (7.4, -10.5, 5.8), "Idle", 16, 58),
("ember_mantis_front.png", (0.0, -12.5, 4.2), "Idle", 16, 62),
("ember_mantis_side.png", (10.8, -0.2, 4.3), "Idle", 16, 60),
("ember_mantis_cross_slash.png", (7.4, -10.5, 5.8), "CrossSlash", 16, 58),
]
for filename, camera_position, action_name, frame, lens in views:
activate_action(armature, action_name, frame)
camera.location = camera_position
camera.data.lens = lens
look_at(camera, (0, -0.05, 2.55))
bpy.context.scene.render.filepath = str(PREVIEW_DIR / filename)
bpy.ops.render.render(write_still=True)
armature.animation_data.action = None
bpy.context.scene.frame_set(1)
def write_metadata(body: bpy.types.Object, clips: dict[str, dict]) -> None:
triangles = sum(len(poly.vertices) - 2 for poly in body.data.polygons)
metadata = {
"name": "Ember Mantis Duelist",
"assetId": "ember-mantis-duelist",
"license": "Original project asset",
"sourceConcept": "IWT2 Ember Mantis Duelist side-cel concept",
"authoringTool": bpy.app.version_string,
"format": "glTF 2.0 binary (GLB)",
"coordinateConvention": {"up": "+Y after glTF export", "blenderForward": "-Y", "units": "meters"},
"runtime": {
"renderMesh": "EmberMantis_Body",
"armature": "EmberMantis_Rig",
"collisionAsset": COLLISION_PATH.name,
"materials": [material.name for material in body.data.materials],
"trianglesApprox": triangles,
},
"animations": clips,
}
METADATA_PATH.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
bpy.context.preferences.filepaths.save_version = 0
reset_scene()
mats = {
"chitin": make_material("M_Chitin", (0.025, 0.022, 0.026, 1), metallic=0.18, roughness=0.42),
"crimson": make_material("M_CrimsonArmor", (0.34, 0.025, 0.018, 1), metallic=0.22, roughness=0.36),
"deep_red": make_material("M_DeepRed", (0.13, 0.012, 0.012, 1), metallic=0.15, roughness=0.48),
"bone": make_material("M_BoneBlade", (0.62, 0.45, 0.26, 1), metallic=0.12, roughness=0.28),
"ember": make_material(
"M_EmberGlow",
(1.0, 0.075, 0.004, 1),
roughness=0.2,
emission=(1.0, 0.028, 0.0, 1),
emission_strength=8.0,
),
"eye": make_material(
"M_EyeGlow",
(1.0, 0.33, 0.01, 1),
roughness=0.16,
emission=(1.0, 0.14, 0.0, 1),
emission_strength=12.0,
),
}
RUNTIME_MATERIALS.extend(
[mats["chitin"], mats["crimson"], mats["deep_red"], mats["eye"], mats["bone"], mats["ember"]]
)
armature = create_armature()
body = create_model(armature, mats)
clips = create_animations(armature)
collision_parts = create_collision_meshes()
export_glbs(armature, body, collision_parts)
write_metadata(body, clips)
render_previews(armature)
bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH))
print(f"BUILT={BLEND_PATH}")
print(f"EXPORTED={GLB_PATH}")
print(f"COLLISION={COLLISION_PATH}")
print(f"ANIMATIONS={','.join(clips)}")
if __name__ == "__main__":
main()
+491
View File
@@ -0,0 +1,491 @@
"""Build three original IWT2-inspired, rigged low-poly boss assets."""
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_ROOT = ROOT / "game_assets/models/original/bosses"
FPS = 30
PARTS: list[bpy.types.Object] = []
RUNTIME_MATERIALS: list[bpy.types.Material] = []
def reset_scene() -> None:
global PARTS, RUNTIME_MATERIALS
PARTS = []
RUNTIME_MATERIALS = []
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.cameras, bpy.data.lights, bpy.data.actions):
for block in list(blocks):
blocks.remove(block)
def material(name, color, metallic=0.0, roughness=0.5, emission=None, strength=0.0):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
mat.diffuse_color = color
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 = strength
return mat
def prepare_materials(specs):
mats = {name: material(f"M_{name}", **settings) for name, settings in specs.items()}
RUNTIME_MATERIALS.extend(mats.values())
return mats
def finish(obj, name, mat, bone, smooth=True):
obj.name = name
obj.data.name = f"{name}_Mesh"
for runtime_mat in RUNTIME_MATERIALS:
obj.data.materials.append(runtime_mat)
mat_index = next(index for index, candidate in enumerate(RUNTIME_MATERIALS) if candidate.name == mat.name)
for polygon in obj.data.polygons:
polygon.material_index = mat_index
polygon.use_smooth = smooth
bone_group = obj.vertex_groups.new(name=bone)
bone_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
mat_group = obj.vertex_groups.new(name=f"__MAT_{mat_index}")
mat_group.add(range(len(obj.data.vertices)), 1.0, "REPLACE")
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
PARTS.append(obj)
return obj
def ellipsoid(name, location, scale, mat, bone, subdivisions=1, rotation=(0, 0, 0)):
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdivisions, radius=1, location=location, rotation=rotation)
obj = bpy.context.object
obj.scale = scale
return finish(obj, name, mat, bone)
def cone(name, start, end, radius_start, radius_end, mat, bone, vertices=7):
start_v, end_v = Vector(start), Vector(end)
direction = end_v - start_v
bpy.ops.mesh.primitive_cone_add(
vertices=vertices,
radius1=radius_start,
radius2=radius_end,
depth=direction.length,
location=(start_v + end_v) * 0.5,
)
obj = bpy.context.object
obj.rotation_mode = "QUATERNION"
obj.rotation_quaternion = direction.to_track_quat("Z", "Y")
obj.rotation_mode = "XYZ"
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
return finish(obj, name, mat, bone, smooth=False)
def plate(name, location, scale, rotation, mat, bone, vertices=6):
bpy.ops.mesh.primitive_cone_add(vertices=vertices, radius1=1, radius2=0.58, depth=0.42, location=location, rotation=rotation)
obj = bpy.context.object
obj.scale = scale
return finish(obj, name, mat, bone, smooth=False)
def armature(name, specs):
data = bpy.data.armatures.new(f"{name}_Rig")
rig = bpy.data.objects.new(f"{name}_Rig", data)
bpy.context.collection.objects.link(rig)
bpy.context.view_layer.objects.active = rig
rig.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
for bone_name, head, tail, parent in specs:
bone = data.edit_bones.new(bone_name)
bone.head, bone.tail = head, tail
if parent:
bone.parent = data.edit_bones[parent]
bpy.ops.object.mode_set(mode="POSE")
for bone in rig.pose.bones:
bone.rotation_mode = "XYZ"
bpy.ops.object.mode_set(mode="OBJECT")
return rig
def join_parts(name, rig):
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 = f"{name}_Body"
body.data.name = f"{name}_Body_Mesh"
material_groups = {g.index: int(g.name.removeprefix("__MAT_")) for g in body.vertex_groups if g.name.startswith("__MAT_")}
face_materials = []
for polygon in body.data.polygons:
vertex = body.data.vertices[polygon.vertices[0]]
face_materials.append(next(material_groups[a.group] for a in vertex.groups if a.group in material_groups))
body.data.materials.clear()
for mat in RUNTIME_MATERIALS:
body.data.materials.append(mat)
for polygon, index in zip(body.data.polygons, face_materials, strict=True):
polygon.material_index = index
for group in [g for g in body.vertex_groups if g.name.startswith("__MAT_")]:
body.vertex_groups.remove(group)
modifier = body.modifiers.new(f"{name}_Armature", "ARMATURE")
modifier.object = rig
body.parent = rig
return body
def reset_pose(rig):
for bone in rig.pose.bones:
bone.location = (0, 0, 0)
bone.rotation_euler = (0, 0, 0)
bone.scale = (1, 1, 1)
def key_pose(rig, frame, rotations=None, locations=None, scales=None):
reset_pose(rig)
for name, value in (rotations or {}).items():
rig.pose.bones[name].rotation_euler = tuple(math.radians(component) for component in value)
for name, value in (locations or {}).items():
rig.pose.bones[name].location = value
for name, value in (scales or {}).items():
rig.pose.bones[name].scale = value
for bone in rig.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 actions(rig, definitions):
rig.animation_data_create()
metadata = {}
for name, end_frame, loop, poses in definitions:
action = bpy.data.actions.new(name)
action.use_fake_user = True
action.use_frame_range = True
action.frame_start, action.frame_end, action.use_cyclic = 1, end_frame, loop
rig.animation_data.action = action
for pose in poses:
key_pose(rig, **pose)
rig.animation_data.action = None
metadata[name] = {"frames": end_frame, "seconds": round(end_frame / FPS, 3), "loop": loop}
return metadata
def select_only(objects):
bpy.ops.object.select_all(action="DESELECT")
for obj in objects:
obj.hide_set(False)
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
def collision(asset_id, specs, out_dir):
objects = []
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 = f"COLLISION_{name}"
obj.scale = scale
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
objects.append(obj)
select_only(objects)
bpy.ops.export_scene.gltf(
filepath=str(out_dir / f"{asset_id}_collision.glb"), export_format="GLB", use_selection=True,
export_animations=False, export_skins=False, export_materials="NONE", export_apply=True, export_yup=True,
)
for obj in objects:
obj.hide_render = True
obj.hide_set(True)
return objects
def look_at(obj, target):
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler()
def studio():
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = scene.render.resolution_y = 760
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.fps = FPS
scene.world.use_nodes = True
scene.world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.005, 0.007, 0.012, 1)
scene.world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.3
camera_data = bpy.data.cameras.new("PreviewCamera")
camera = bpy.data.objects.new("PreviewCamera", camera_data)
bpy.context.collection.objects.link(camera)
scene.camera = camera
for name, location, color, energy, size in [
("Key", (5, -7, 7), (1, 0.34, 0.1), 1200, 5),
("Fill", (-5, -3, 4), (0.12, 0.32, 1), 800, 4),
("Rim", (1, 6, 6), (1, 0.12, 0.02), 1150, 3),
]:
data = bpy.data.lights.new(name, "AREA")
data.energy, data.color, data.shape, data.size = energy, color, "DISK", size
obj = bpy.data.objects.new(name, data)
obj.location = location
bpy.context.collection.objects.link(obj)
look_at(obj, (0, 0, 1.5))
floor_mat = material("M_PreviewFloor", color=(0.014, 0.018, 0.026, 1), roughness=0.85)
bpy.ops.mesh.primitive_plane_add(size=28, location=(0, 0, 0))
floor = bpy.context.object
floor.data.materials.append(floor_mat)
return camera
def render_previews(rig, camera, out_dir, asset_id, target, distance, special_clip, special_frame):
preview_dir = out_dir / "previews"
preview_dir.mkdir(parents=True, exist_ok=True)
for filename, position, clip, frame in [
(f"{asset_id}_three_quarter.png", (distance * 0.62, -distance, distance * 0.48), "Idle", 15),
(f"{asset_id}_side.png", (distance, 0, distance * 0.42), "Idle", 15),
(f"{asset_id}_{special_clip.lower()}.png", (distance * 0.62, -distance, distance * 0.48), special_clip, special_frame),
]:
rig.animation_data.action = bpy.data.actions[clip]
bpy.context.scene.frame_set(frame)
camera.location = position
camera.data.lens = 58
look_at(camera, target)
bpy.context.scene.render.filepath = str(preview_dir / filename)
bpy.ops.render.render(write_still=True)
rig.animation_data.action = None
def export_asset(asset_id, display_name, rig, body, clips, collision_specs, preview_target, preview_distance, special_clip, special_frame):
out_dir = OUT_ROOT / asset_id
out_dir.mkdir(parents=True, exist_ok=True)
select_only([rig, body])
bpy.ops.export_scene.gltf(
filepath=str(out_dir / f"{asset_id}.glb"), 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,
)
collision(asset_id, collision_specs, out_dir)
triangles = sum(len(poly.vertices) - 2 for poly in body.data.polygons)
metadata = {
"name": display_name,
"assetId": asset_id,
"authoringTool": bpy.app.version_string,
"format": "glTF 2.0 binary (GLB)",
"runtime": {
"renderMesh": body.name,
"armature": rig.name,
"collisionAsset": f"{asset_id}_collision.glb",
"materials": [mat.name for mat in body.data.materials],
"trianglesApprox": triangles,
},
"animations": clips,
}
(out_dir / f"{asset_id}.asset.json").write_text(json.dumps(metadata, indent=2) + "\n")
camera = studio()
render_previews(rig, camera, out_dir, asset_id, preview_target, preview_distance, special_clip, special_frame)
bpy.ops.wm.save_as_mainfile(filepath=str(out_dir / f"{asset_id}.blend"))
print(f"BUILT={asset_id} TRIANGLES={triangles} CLIPS={','.join(clips)}")
def build_ram():
reset_scene()
mats = prepare_materials({
"Obsidian": {"color": (0.025, 0.028, 0.034, 1), "metallic": 0.28, "roughness": 0.4},
"Basalt": {"color": (0.10, 0.09, 0.09, 1), "metallic": 0.14, "roughness": 0.62},
"Armor": {"color": (0.18, 0.16, 0.16, 1), "metallic": 0.22, "roughness": 0.48},
"Lava": {"color": (1, 0.08, 0.003, 1), "roughness": 0.2, "emission": (1, 0.025, 0, 1), "strength": 9},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.5), None), ("Body", (0, 0, 1.35), (0, 0, 2.5), "Root"),
("Head", (0, -0.75, 2.05), (0, -1.75, 2.05), "Body"), ("Horn.L", (-0.45, -1.45, 2.35), (-1.2, -1.5, 2.55), "Head"),
("Horn.R", (0.45, -1.45, 2.35), (1.2, -1.5, 2.55), "Head"), ("Leg.FL", (-0.7, -0.75, 1.45), (-0.78, -0.8, 0.35), "Body"),
("Leg.FR", (0.7, -0.75, 1.45), (0.78, -0.8, 0.35), "Body"), ("Leg.BL", (-0.7, 0.8, 1.35), (-0.78, 0.8, 0.3), "Body"),
("Leg.BR", (0.7, 0.8, 1.35), (0.78, 0.8, 0.3), "Body"), ("Tail", (0, 1.2, 1.75), (0, 2.25, 1.35), "Body"),
]
rig = armature("ObsidianRam", specs)
ellipsoid("BodyCore", (0, 0.1, 1.95), (1.18, 1.48, 0.86), mats["Basalt"], "Body", 2)
ellipsoid("ShoulderMass", (0, -0.62, 2.08), (1.28, 0.82, 1.0), mats["Obsidian"], "Body", 2)
ellipsoid("Head", (0, -1.48, 1.93), (0.72, 0.78, 0.62), mats["Armor"], "Head", 2)
cone("Snout", (0, -1.7, 1.92), (0, -2.25, 1.72), 0.48, 0.22, mats["Obsidian"], "Head")
ellipsoid("Eye.L", (-0.47, -1.9, 2.08), (0.075, 0.05, 0.075), mats["Lava"], "Head")
ellipsoid("Eye.R", (0.47, -1.9, 2.08), (0.075, 0.05, 0.075), mats["Lava"], "Head")
for side, suffix in [(-1, "L"), (1, "R")]:
points = [(side * 0.48, -1.55, 2.33), (side * 1.05, -1.62, 2.67), (side * 1.45, -1.44, 2.35), (side * 1.43, -1.25, 1.93), (side * 1.02, -1.45, 1.78)]
for index in range(len(points) - 1):
cone(f"Horn{suffix}_{index}", points[index], points[index + 1], 0.19 - index * 0.025, 0.15 - index * 0.025, mats["Obsidian"], f"Horn.{suffix}", 8)
for front, y, bone in [(True, -0.72, f"Leg.F{suffix}"), (False, 0.82, f"Leg.B{suffix}")]:
x = side * 0.78
cone(f"Leg{suffix}_{'F' if front else 'B'}", (x, y, 1.45), (x * 1.05, y, 0.35), 0.34, 0.24, mats["Basalt"], bone, 7)
ellipsoid(f"Hoof{suffix}_{'F' if front else 'B'}", (x * 1.06, y - 0.18, 0.25), (0.34, 0.46, 0.24), mats["Obsidian"], bone)
cone("Tail", (0, 1.1, 1.72), (0, 2.22, 1.3), 0.38, 0.12, mats["Basalt"], "Tail", 7)
for index, y in enumerate((-0.7, -0.15, 0.4, 0.95)):
plate(f"BackPlate{index}", (0, y, 2.72 + index * 0.03), (0.92 - index * 0.08, 0.62, 0.32), (math.radians(82), 0, 0), mats["Armor"], "Body")
cone(f"BackSpike{index}", (0, y, 2.86), (0, y + 0.05, 3.38 - index * 0.06), 0.17, 0, mats["Obsidian"], "Body", 6)
for side in (-1, 1):
cone(f"ShoulderSpike{side}", (side * 0.9, -0.55, 2.55), (side * 1.48, -0.5, 2.82), 0.18, 0, mats["Obsidian"], "Body", 6)
for index, (start, end) in enumerate([((-0.12, -1.96, 2.22), (0.1, -1.98, 1.78)), ((-0.55, -0.98, 2.54), (-0.35, -0.99, 1.93)), ((0.48, 0.42, 2.55), (0.25, 0.45, 1.95))]):
cone(f"LavaCrack{index}", start, end, 0.035, 0.02, mats["Lava"], "Head" if index == 0 else "Body", 5)
body = join_parts("ObsidianRam", rig)
clips = actions(rig, ram_actions())
export_asset("obsidian-ram-golem", "Obsidian Ram Golem", rig, body, clips,
[("Body", (0, 0, 1.7), (1.4, 1.75, 1.35)), ("Head", (0, -1.55, 1.85), (1.45, 0.85, 0.8))],
(0, 0, 1.6), 8.5, "Quake", 16)
def ram_actions():
return [
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail": (-4, 0, 0)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail": (-4, 0, 0)}}, {"frame": 60}]),
("Walk", 32, True, [{"frame": 1, "rotations": {"Leg.FL": (-18, 0, 0), "Leg.BR": (-18, 0, 0), "Leg.FR": (18, 0, 0), "Leg.BL": (18, 0, 0)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 17, "rotations": {"Leg.FL": (18, 0, 0), "Leg.BR": (18, 0, 0), "Leg.FR": (-18, 0, 0), "Leg.BL": (-18, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 32, "rotations": {"Leg.FL": (-18, 0, 0), "Leg.BR": (-18, 0, 0), "Leg.FR": (18, 0, 0), "Leg.BL": (18, 0, 0)}}]),
("Charge", 30, False, [{"frame": 1}, {"frame": 8, "locations": {"Root": (0, 0.08, -0.12)}, "rotations": {"Head": (28, 0, 0), "Body": (8, 0, 0)}}, {"frame": 15, "locations": {"Root": (0, -0.18, 0.03)}, "rotations": {"Head": (18, 0, 0), "Leg.FL": (-24, 0, 0), "Leg.FR": (24, 0, 0)}}, {"frame": 23, "locations": {"Root": (0, -0.12, 0.08)}, "rotations": {"Head": (12, 0, 0), "Leg.FL": (24, 0, 0), "Leg.FR": (-24, 0, 0)}}, {"frame": 30}]),
("Quake", 36, False, [{"frame": 1}, {"frame": 12, "locations": {"Root": (0, 0.08, 0.18)}, "rotations": {"Body": (-18, 0, 0), "Head": (-20, 0, 0)}}, {"frame": 17, "locations": {"Root": (0, -0.08, -0.18)}, "rotations": {"Body": (24, 0, 0), "Head": (34, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.03)}, "rotations": {"Body": (-5, 0, 0)}}, {"frame": 36}]),
("ArmorShatter", 42, False, [{"frame": 1}, {"frame": 10, "scales": {"Body": (0.94, 0.94, 0.94)}, "rotations": {"Head": (-12, 0, 0)}}, {"frame": 17, "scales": {"Body": (1.08, 1.08, 1.08)}, "rotations": {"Body": (0, 0, 10), "Horn.L": (0, -12, 0), "Horn.R": (0, 12, 0)}}, {"frame": 23, "rotations": {"Body": (0, 0, -8)}}, {"frame": 31, "rotations": {"Body": (0, 0, 4)}}, {"frame": 42}]),
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.16, -0.08)}, "rotations": {"Body": (-15, 0, 12), "Head": (20, 0, -10)}}, {"frame": 13, "rotations": {"Body": (7, 0, -6)}}, {"frame": 26}]),
("Death", 70, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.15, 0.1, -0.35)}, "rotations": {"Root": (0, 35, 32), "Head": (26, 0, 10)}}, {"frame": 42, "locations": {"Root": (0.25, 0.1, -1.15)}, "rotations": {"Root": (0, 58, 82), "Head": (42, 0, 18), "Leg.FL": (30, 0, 0), "Leg.BL": (-24, 0, 0)}}, {"frame": 70, "locations": {"Root": (0.25, 0.1, -1.2)}, "rotations": {"Root": (0, 58, 82), "Head": (44, 0, 18), "Leg.FL": (30, 0, 0), "Leg.BL": (-24, 0, 0)}}]),
]
def build_cinderback():
reset_scene()
mats = prepare_materials({
"Charcoal": {"color": (0.035, 0.03, 0.03, 1), "metallic": 0.18, "roughness": 0.55},
"Plate": {"color": (0.16, 0.10, 0.075, 1), "metallic": 0.2, "roughness": 0.48},
"Underbody": {"color": (0.18, 0.018, 0.01, 1), "roughness": 0.66},
"Lava": {"color": (1, 0.07, 0.002, 1), "roughness": 0.18, "emission": (1, 0.02, 0, 1), "strength": 10},
"Claw": {"color": (0.55, 0.32, 0.15, 1), "roughness": 0.4},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.45), None), ("Body", (0, 0, 1.0), (0, 0, 1.95), "Root"),
("Head", (0, -1.0, 1.25), (0, -1.9, 1.18), "Body"), ("Tail.1", (0, 1.05, 1.2), (0, 1.8, 1.05), "Body"),
("Tail.2", (0, 1.75, 1.05), (0, 2.45, 0.8), "Tail.1"),
("Leg.FL", (-0.65, -0.75, 0.95), (-0.82, -0.82, 0.25), "Body"), ("Leg.FR", (0.65, -0.75, 0.95), (0.82, -0.82, 0.25), "Body"),
("Leg.BL", (-0.65, 0.65, 0.95), (-0.82, 0.7, 0.25), "Body"), ("Leg.BR", (0.65, 0.65, 0.95), (0.82, 0.7, 0.25), "Body"),
]
rig = armature("Cinderback", specs)
ellipsoid("Underbody", (0, 0, 1.12), (1.05, 1.62, 0.65), mats["Underbody"], "Body", 2)
ellipsoid("Head", (0, -1.42, 1.2), (0.68, 0.78, 0.54), mats["Plate"], "Head", 2)
cone("Snout", (0, -1.58, 1.2), (0, -2.05, 1.05), 0.4, 0.18, mats["Charcoal"], "Head")
ellipsoid("Eye.L", (-0.42, -1.72, 1.37), (0.07, 0.05, 0.07), mats["Lava"], "Head")
ellipsoid("Eye.R", (0.42, -1.72, 1.37), (0.07, 0.05, 0.07), mats["Lava"], "Head")
for index, y in enumerate((-1.02, -0.58, -0.12, 0.34, 0.78, 1.16)):
width = 0.93 - abs(index - 2.5) * 0.055
plate(f"ShellPlate{index}", (0, y, 1.73 + math.sin(index / 5 * math.pi) * 0.18), (width, 0.53, 0.28), (math.radians(82), 0, 0), mats["Plate"], "Body")
cone(f"ShellSpike{index}", (0, y, 1.9), (0, y, 2.35 + math.sin(index / 5 * math.pi) * 0.15), 0.13, 0, mats["Charcoal"], "Body", 6)
for side, suffix in [(-1, "L"), (1, "R")]:
for front, y, bone in [(True, -0.68, f"Leg.F{suffix}"), (False, 0.68, f"Leg.B{suffix}")]:
x = side * 0.7
ellipsoid(f"LegMass{suffix}{front}", (x, y, 0.78), (0.38, 0.48, 0.42), mats["Charcoal"], bone)
cone(f"Leg{suffix}{front}", (x, y, 0.72), (side * 0.92, y - 0.12, 0.22), 0.22, 0.14, mats["Underbody"], bone)
for toe in (-0.12, 0, 0.12):
cone(f"Toe{suffix}{front}{toe}", (side * 0.92 + toe, y - 0.22, 0.2), (side * 0.95 + toe, y - 0.58, 0.11), 0.05, 0, mats["Claw"], bone, 5)
cone("Tail1", (0, 1.0, 1.12), (0, 1.85, 0.88), 0.42, 0.27, mats["Plate"], "Tail.1")
cone("Tail2", (0, 1.78, 0.88), (0, 2.48, 0.68), 0.29, 0.06, mats["Charcoal"], "Tail.2")
for index, (x, y, z) in enumerate([(-0.48, -0.82, 1.56), (0.45, -0.28, 1.68), (-0.42, 0.28, 1.72), (0.38, 0.8, 1.55)]):
cone(f"LavaCrack{index}", (x, y, z), (x * 0.65, y + 0.18, z - 0.32), 0.035, 0.018, mats["Lava"], "Body", 5)
body = join_parts("Cinderback", rig)
clips = actions(rig, cinderback_actions())
export_asset("cinderback-ricochet", "Cinderback Ricochet", rig, body, clips,
[("Body", (0, 0, 1.05), (1.25, 1.85, 0.95)), ("Roll", (0, 0, 1.2), (1.45, 1.45, 1.45))],
(0, 0, 1.15), 8.3, "ArmorSlam", 15)
def cinderback_actions():
return [
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail.2": (-5, 0, 0)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Head": (3, 0, 0), "Tail.2": (-5, 0, 0)}}, {"frame": 60}]),
("Walk", 32, True, [{"frame": 1, "rotations": {"Leg.FL": (-14, 0, 0), "Leg.BR": (-14, 0, 0), "Leg.FR": (14, 0, 0), "Leg.BL": (14, 0, 0)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.04)}}, {"frame": 17, "rotations": {"Leg.FL": (14, 0, 0), "Leg.BR": (14, 0, 0), "Leg.FR": (-14, 0, 0), "Leg.BL": (-14, 0, 0)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.04)}}, {"frame": 32, "rotations": {"Leg.FL": (-14, 0, 0), "Leg.BR": (-14, 0, 0), "Leg.FR": (14, 0, 0), "Leg.BL": (14, 0, 0)}}]),
("Curl", 28, False, [{"frame": 1}, {"frame": 9, "locations": {"Root": (0, 0, 0.12)}, "rotations": {"Head": (-42, 0, 0), "Tail.1": (50, 0, 0), "Tail.2": (65, 0, 0), "Leg.FL": (45, 0, 0), "Leg.FR": (45, 0, 0), "Leg.BL": (-45, 0, 0), "Leg.BR": (-45, 0, 0)}}, {"frame": 17, "scales": {"Body": (1.02, 1.02, 1.02)}, "rotations": {"Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0), "Leg.FL": (58, 0, 0), "Leg.FR": (58, 0, 0), "Leg.BL": (-58, 0, 0), "Leg.BR": (-58, 0, 0)}}, {"frame": 28, "rotations": {"Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0), "Leg.FL": (58, 0, 0), "Leg.FR": (58, 0, 0), "Leg.BL": (-58, 0, 0), "Leg.BR": (-58, 0, 0)}}]),
("Ricochet", 30, True, [{"frame": 1, "rotations": {"Root": (0, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 8, "rotations": {"Root": (90, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 16, "rotations": {"Root": (180, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 23, "rotations": {"Root": (270, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}, {"frame": 30, "rotations": {"Root": (360, 0, 0), "Head": (-55, 0, 0), "Tail.1": (70, 0, 0), "Tail.2": (80, 0, 0)}}]),
("ArmorSlam", 38, False, [{"frame": 1}, {"frame": 11, "locations": {"Root": (0, 0.1, 0.22)}, "rotations": {"Body": (-22, 0, 0), "Head": (-18, 0, 0)}}, {"frame": 16, "locations": {"Root": (0, -0.08, -0.16)}, "rotations": {"Body": (28, 0, 0), "Head": (34, 0, 0)}}, {"frame": 24, "rotations": {"Body": (-8, 0, 0)}}, {"frame": 38}]),
("Recover", 24, False, [{"frame": 1, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (18, 0, 0)}}, {"frame": 8, "rotations": {"Body": (-5, 0, 0)}}, {"frame": 16, "rotations": {"Body": (2, 0, 0)}}, {"frame": 24}]),
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.14, -0.08)}, "rotations": {"Body": (-12, 0, 11), "Head": (22, 0, -10)}}, {"frame": 13, "rotations": {"Body": (6, 0, -5)}}, {"frame": 26}]),
("Death", 66, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.1, 0.1, -0.25)}, "rotations": {"Root": (0, 28, 35), "Head": (24, 0, 0)}}, {"frame": 42, "locations": {"Root": (0.18, 0.1, -0.8)}, "rotations": {"Root": (0, 50, 78), "Head": (38, 0, 0), "Tail.1": (-25, 0, 0)}}, {"frame": 66, "locations": {"Root": (0.18, 0.1, -0.84)}, "rotations": {"Root": (0, 50, 78), "Head": (40, 0, 0), "Tail.1": (-28, 0, 0)}}]),
]
def build_scorpion():
reset_scene()
mats = prepare_materials({
"Obsidian": {"color": (0.025, 0.025, 0.03, 1), "metallic": 0.2, "roughness": 0.5},
"Ochre": {"color": (0.42, 0.19, 0.045, 1), "metallic": 0.16, "roughness": 0.44},
"Gold": {"color": (0.85, 0.38, 0.035, 1), "metallic": 0.3, "roughness": 0.28},
"Glass": {"color": (1, 0.38, 0.02, 1), "roughness": 0.12, "emission": (1, 0.16, 0.01, 1), "strength": 4},
"Bone": {"color": (0.58, 0.38, 0.17, 1), "roughness": 0.38},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.4), None), ("Body", (0, 0, 0.85), (0, 0, 1.55), "Root"),
("Head", (0, -0.65, 1.0), (0, -1.35, 0.92), "Body"), ("Claw.L", (-0.55, -0.8, 1.0), (-1.45, -1.6, 0.9), "Body"),
("Claw.R", (0.55, -0.8, 1.0), (1.45, -1.6, 0.9), "Body"), ("LegFront.L", (-0.5, -0.25, 0.8), (-1.4, -0.55, 0.25), "Body"),
("LegFront.R", (0.5, -0.25, 0.8), (1.4, -0.55, 0.25), "Body"), ("LegBack.L", (-0.5, 0.45, 0.8), (-1.4, 0.75, 0.25), "Body"),
("LegBack.R", (0.5, 0.45, 0.8), (1.4, 0.75, 0.25), "Body"),
("Tail.1", (0, 0.65, 1.0), (0, 1.35, 1.35), "Body"), ("Tail.2", (0, 1.3, 1.35), (0, 1.75, 2.0), "Tail.1"),
("Tail.3", (0, 1.7, 2.0), (0, 1.5, 2.68), "Tail.2"), ("Tail.4", (0, 1.5, 2.68), (0, 0.82, 3.0), "Tail.3"),
("Stinger", (0, 0.82, 3.0), (0, 0.18, 2.52), "Tail.4"),
]
rig = armature("SandglassScorpion", specs)
ellipsoid("Abdomen", (0, 0.28, 1.0), (0.92, 1.12, 0.54), mats["Obsidian"], "Body", 2)
ellipsoid("Torso", (0, -0.55, 0.98), (0.76, 0.7, 0.48), mats["Ochre"], "Body", 2)
ellipsoid("Head", (0, -1.12, 0.9), (0.52, 0.48, 0.36), mats["Obsidian"], "Head", 1)
ellipsoid("Hourglass", (0, 0.15, 1.48), (0.42, 0.55, 0.24), mats["Glass"], "Body", 2)
ellipsoid("HourglassWaist", (0, 0.15, 1.5), (0.44, 0.13, 0.26), mats["Obsidian"], "Body", 1)
for side, suffix in [(-1, "L"), (1, "R")]:
ellipsoid(f"Eye{suffix}", (side * 0.31, -1.42, 1.02), (0.06, 0.04, 0.06), mats["Glass"], "Head")
cone(f"ClawArm{suffix}", (side * 0.45, -0.65, 1.0), (side * 1.25, -1.3, 0.9), 0.22, 0.16, mats["Ochre"], f"Claw.{suffix}")
ellipsoid(f"ClawBody{suffix}", (side * 1.55, -1.55, 0.9), (0.62, 0.75, 0.42), mats["Gold"], f"Claw.{suffix}", 1, rotation=(0, 0, math.radians(side * 8)))
ellipsoid(f"ClawGlass{suffix}", (side * 1.55, -1.68, 0.95), (0.37, 0.42, 0.22), mats["Glass"], f"Claw.{suffix}")
cone(f"ClawTipOuter{suffix}", (side * 1.62, -1.98, 0.92), (side * 1.9, -2.45, 0.8), 0.2, 0, mats["Bone"], f"Claw.{suffix}")
cone(f"ClawTipInner{suffix}", (side * 1.42, -1.98, 0.88), (side * 1.25, -2.32, 0.76), 0.14, 0, mats["Bone"], f"Claw.{suffix}")
for index, (y, z) in enumerate([(-0.65, 0.7), (-0.18, 0.62), (0.32, 0.62), (0.78, 0.68)]):
bone = f"LegFront.{suffix}" if index < 2 else f"LegBack.{suffix}"
start = (side * 0.52, y, z + 0.18)
joint = (side * (1.08 + index * 0.08), y + (index - 1.5) * 0.12, z)
end = (side * (1.45 + index * 0.09), y + (index - 1.5) * 0.24, 0.16)
cone(f"Leg{suffix}{index}A", start, joint, 0.14, 0.1, mats["Ochre"], bone, 6)
cone(f"Leg{suffix}{index}B", joint, end, 0.1, 0.035, mats["Obsidian"], bone, 6)
tail_points = [(0, 0.62, 1.02), (0, 1.35, 1.36), (0, 1.75, 2.0), (0, 1.5, 2.68), (0, 0.82, 3.0), (0, 0.18, 2.52)]
tail_bones = ["Tail.1", "Tail.2", "Tail.3", "Tail.4", "Stinger"]
for index, bone in enumerate(tail_bones):
cone(f"TailSegment{index}", tail_points[index], tail_points[index + 1], 0.32 - index * 0.045, 0.25 - index * 0.04, mats["Ochre"] if index < 4 else mats["Gold"], bone, 8)
if index < 4:
plate(f"TailPlate{index}", tail_points[index + 1], (0.38 - index * 0.03, 0.32, 0.2), (0, 0, 0), mats["Gold"], bone)
cone("StingerTip", (0, 0.18, 2.52), (0, -0.15, 2.18), 0.18, 0, mats["Glass"], "Stinger", 7)
body = join_parts("SandglassScorpion", rig)
clips = actions(rig, scorpion_actions())
export_asset("sandglass-scorpion", "Sandglass Scorpion", rig, body, clips,
[("Body", (0, -0.1, 0.85), (1.15, 1.45, 0.8)), ("Claws", (0, -1.55, 0.9), (2.15, 1.0, 0.65)), ("Tail", (0, 1.0, 2.0), (0.65, 1.4, 1.35))],
(0, -0.1, 1.3), 9.5, "Eruption", 16)
def scorpion_actions():
return [
("Idle", 60, True, [{"frame": 1}, {"frame": 16, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Tail.3": (0, 0, 4), "Claw.L": (0, 0, -3), "Claw.R": (0, 0, 3)}}, {"frame": 31}, {"frame": 46, "locations": {"Root": (0, 0, 0.035)}, "rotations": {"Tail.3": (0, 0, -4), "Claw.L": (0, 0, 3), "Claw.R": (0, 0, -3)}}, {"frame": 60}]),
("Walk", 32, True, [{"frame": 1, "rotations": {"LegFront.L": (-16, 0, 5), "LegBack.R": (-16, 0, -5), "LegFront.R": (16, 0, -5), "LegBack.L": (16, 0, 5)}}, {"frame": 9, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 17, "rotations": {"LegFront.L": (16, 0, -5), "LegBack.R": (16, 0, 5), "LegFront.R": (-16, 0, 5), "LegBack.L": (-16, 0, -5)}}, {"frame": 25, "locations": {"Root": (0, 0, 0.05)}}, {"frame": 32, "rotations": {"LegFront.L": (-16, 0, 5), "LegBack.R": (-16, 0, -5), "LegFront.R": (16, 0, -5), "LegBack.L": (16, 0, 5)}}]),
("ClawAttack", 28, False, [{"frame": 1}, {"frame": 9, "rotations": {"Claw.L": (-10, 18, 30), "Claw.R": (-10, -18, -30)}}, {"frame": 14, "locations": {"Root": (0, -0.08, 0)}, "rotations": {"Claw.L": (16, -10, -38), "Claw.R": (16, 10, 38), "Head": (-8, 0, 0)}}, {"frame": 21, "rotations": {"Claw.L": (5, 0, -12), "Claw.R": (5, 0, 12)}}, {"frame": 28}]),
("Burrow", 34, False, [{"frame": 1}, {"frame": 10, "locations": {"Root": (0, 0, -0.15)}, "rotations": {"Claw.L": (35, 0, 0), "Claw.R": (35, 0, 0), "Tail.1": (-20, 0, 0)}}, {"frame": 20, "locations": {"Root": (0, 0, -0.62)}, "scales": {"Body": (0.92, 0.92, 0.92)}, "rotations": {"Claw.L": (48, 0, 0), "Claw.R": (48, 0, 0), "Tail.1": (-32, 0, 0)}}, {"frame": 34, "locations": {"Root": (0, 0, -0.72)}, "rotations": {"Claw.L": (48, 0, 0), "Claw.R": (48, 0, 0), "Tail.1": (-32, 0, 0)}}]),
("Eruption", 38, False, [{"frame": 1}, {"frame": 11, "rotations": {"Tail.2": (-18, 0, 0), "Tail.3": (-22, 0, 0), "Tail.4": (24, 0, 0), "Stinger": (18, 0, 0)}}, {"frame": 16, "locations": {"Root": (0, -0.05, 0.1)}, "rotations": {"Tail.2": (22, 0, 0), "Tail.3": (34, 0, 0), "Tail.4": (-42, 0, 0), "Stinger": (-38, 0, 0)}}, {"frame": 24, "rotations": {"Tail.3": (10, 0, 0), "Tail.4": (-12, 0, 0)}}, {"frame": 38}]),
("Hourglass", 44, False, [{"frame": 1}, {"frame": 12, "locations": {"Root": (0, 0, 0.1)}, "rotations": {"Body": (-10, 0, 0), "Claw.L": (0, 0, -24), "Claw.R": (0, 0, 24), "Tail.3": (-18, 0, 0)}}, {"frame": 20, "scales": {"Body": (1.08, 1.08, 1.08)}, "rotations": {"Claw.L": (0, 0, 38), "Claw.R": (0, 0, -38), "Tail.3": (22, 0, 0)}}, {"frame": 30, "scales": {"Body": (0.96, 0.96, 0.96)}, "rotations": {"Claw.L": (0, 0, -16), "Claw.R": (0, 0, 16)}}, {"frame": 44}]),
("Stagger", 26, False, [{"frame": 1}, {"frame": 5, "locations": {"Root": (0, 0.12, -0.08)}, "rotations": {"Body": (-12, 0, 10), "Claw.L": (-15, 0, -18), "Claw.R": (-15, 0, 18), "Tail.2": (18, 0, 0)}}, {"frame": 13, "rotations": {"Body": (6, 0, -5)}}, {"frame": 26}]),
("Death", 70, False, [{"frame": 1}, {"frame": 18, "locations": {"Root": (0.1, 0.1, -0.3)}, "rotations": {"Root": (0, 30, 38), "Tail.2": (-28, 0, 0), "Claw.L": (25, 0, -30), "Claw.R": (-10, 0, 30)}}, {"frame": 44, "locations": {"Root": (0.18, 0.1, -0.78)}, "rotations": {"Root": (0, 52, 82), "Tail.2": (-48, 0, 0), "Tail.3": (-32, 0, 0), "Claw.L": (38, 0, -42), "Claw.R": (12, 0, 38)}}, {"frame": 70, "locations": {"Root": (0.18, 0.1, -0.82)}, "rotations": {"Root": (0, 52, 82), "Tail.2": (-50, 0, 0), "Tail.3": (-34, 0, 0), "Claw.L": (40, 0, -44), "Claw.R": (14, 0, 40)}}]),
]
def main():
bpy.context.preferences.filepaths.save_version = 0
OUT_ROOT.mkdir(parents=True, exist_ok=True)
build_ram()
build_cinderback()
build_scorpion()
if __name__ == "__main__":
main()
+709
View File
@@ -0,0 +1,709 @@
"""Build animation-ready GLBs for the locally-authored inspired creature set.
The legacy source FBX files use ASCII encoding, which modern Blender cannot read.
Assimp converts each DAE to glTF without changing the source assets; Blender then
adds reusable actions and exports a texture-packed GLB beside each source model.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
import math
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
FPS = 30
CLIP_SPECS = {
"Idle": {"frames": 60, "loop": True},
"Move": {"frames": 36, "loop": True},
"Attack": {"frames": 36, "loop": False},
"HitReact": {"frames": 24, "loop": False},
"Death": {"frames": 72, "loop": False},
}
@dataclass(frozen=True)
class AssetProfile:
asset_id: str
display_name: str
body: tuple[str, ...]
creature: str
red_eyes_roles: bool = False
PROFILES = (
AssetProfile("blue-eyes-ultimate-dragon", "Tri-Headed Ivory Dragon", ("BEUD_UpperBody", "BEUD_Lumbar1"), "dragon"),
AssetProfile("blue-eyes-white-dragon", "Ivory-Eyed Sky Dragon", ("MMD_004007_UpperBody", "MMD_004007_Lumbar1"), "dragon"),
AssetProfile("gandora-the-dragon-of-destruction", "Ruin-Orb Black Dragon", ("UpperBody", "Lumber1"), "dragon"),
AssetProfile("gate-guardian", "Tri-Element Gate Sentinel", ("Spine", "Spine1", "Spine2"), "guardian"),
AssetProfile("insect-queen", "Royal Carapace Matriarch", ("UpperBody", "Spine1"), "insect"),
AssetProfile("pumpking-the-king-of-ghosts", "Crowned Vine Wraith", ("hips", "upper_01", "upper_02"), "wraith"),
AssetProfile("red-eyes-black-dragon", "Crimson-Eyed Night Dragon", ("bone0000", "bone0076", "bone0009"), "dragon", True),
)
RED_EYES = {
"body": ("bone0000", "bone0076", "bone0009", "bone0078", "bone0057"),
"head": ("bone0025", "bone0031", "bone0047", "bone0073"),
"jaw": ("bone0001", "bone0002", "bone0029", "bone0010", "bone0020"),
"wing_l": ("bone0016", "bone0006", "bone0049", "bone0032", "bone0003", "bone0058", "bone0008", "bone0063", "bone0079", "bone0069", "bone0013", "bone0030", "bone0077"),
"wing_r": ("bone0033", "bone0012", "bone0055", "bone0046", "bone0021", "bone0045", "bone0041", "bone0060", "bone0075", "bone0070", "bone0042", "bone0024", "bone0040"),
"arm_l": ("bone0048", "bone0061", "bone0005", "bone0015", "bone0038", "bone0017", "bone0028", "bone0023"),
"arm_r": ("bone0059", "bone0027", "bone0071", "bone0022", "bone0052", "bone0064", "bone0056", "bone0074", "bone0067"),
"leg_l": ("bone0004", "bone0039", "bone0051", "bone0014", "bone0035", "bone0050", "bone0066"),
"leg_r": ("bone0007", "bone0043", "bone0011", "bone0036", "bone0026", "bone0037", "bone0062"),
"tail": ("bone0034", "bone0053", "bone0019", "bone0044", "bone0054", "bone0065", "bone0072", "bone0018"),
}
ULTIMATE_HEAD_CHAINS = {
"head_l": tuple(f"BEUD_LNeck{index}" for index in range(1, 7)) + ("BEUD_LHead",),
"head_c": tuple(f"BEUD_Neck{index}" for index in range(1, 7)) + ("BEUD_Head",),
"head_r": tuple(f"BEUD_RNeck{index}" for index in range(1, 7)) + ("BEUD_RHead",),
}
def reset_scene() -> None:
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.images, bpy.data.actions, bpy.data.cameras, bpy.data.lights):
for block in list(blocks):
blocks.remove(block)
def parse_smd_bones(path: Path, count: int) -> list[str]:
nodes: list[tuple[int, str, int]] = []
in_nodes = False
for line in path.read_text(errors="replace").splitlines():
if line == "nodes":
in_nodes = True
continue
if in_nodes and line == "end":
break
if not in_nodes:
continue
match = re.fullmatch(r'(\d+)\s+"(.+)"\s+(-?\d+)', line)
if match:
nodes.append((int(match.group(1)), match.group(2), int(match.group(3))))
if len(nodes) < count:
raise RuntimeError(f"{path}: found {len(nodes)} nodes for {count} Blender bones")
return [name for _, name, _ in nodes[-count:]]
def convert_and_import(profile: AssetProfile) -> tuple[bpy.types.Object, list[bpy.types.Object], Path]:
source_dir = MODEL_ROOT / profile.asset_id
dae_path = next(source_dir.glob("MMD_*.dae"))
smd_path = dae_path.with_suffix(".smd")
with tempfile.TemporaryDirectory(prefix=f"thor-{profile.asset_id}-") as temp_dir_name:
temp_dir = Path(temp_dir_name)
gltf_path = temp_dir / "model.gltf"
subprocess.run(
["assimp", "export", str(dae_path), str(gltf_path), "-f", "gltf2"],
check=True,
capture_output=True,
text=True,
)
for image_path in source_dir.glob("*.png"):
shutil.copy2(image_path, temp_dir / image_path.name)
bpy.ops.import_scene.gltf(filepath=str(gltf_path), import_pack_images=True)
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
if len(armatures) != 1:
raise RuntimeError(f"{profile.asset_id}: expected one armature")
rig = armatures[0]
meshes = [
obj for obj in bpy.context.scene.objects
if obj.type == "MESH" and (
obj.parent == rig
or any(modifier.type == "ARMATURE" and modifier.object == rig for modifier in obj.modifiers)
)
]
if not meshes:
raise RuntimeError(f"{profile.asset_id}: expected one armature and at least one mesh")
for helper in [obj for obj in bpy.context.scene.objects if obj.type == "MESH" and obj not in meshes]:
bpy.data.objects.remove(helper, do_unlink=True)
source_names = parse_smd_bones(smd_path, len(rig.data.bones))
old_names = [bone.name for bone in rig.data.bones]
for old_name, source_name in zip(old_names, source_names, strict=True):
rig.data.bones[old_name].name = source_name
for mesh in meshes:
group = mesh.vertex_groups.get(old_name)
if group:
group.name = source_name
rig.name = f"{profile.asset_id}_Rig"
rig.data.name = f"{profile.asset_id}_Armature"
for index, mesh in enumerate(meshes):
mesh.name = f"{profile.asset_id}_Mesh_{index:02d}"
mesh.data.name = f"{profile.asset_id}_Geometry_{index:02d}"
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
rig.animation_data_clear()
return rig, meshes, dae_path
def side(name: str) -> str | None:
lower = name.lower()
if "left" in lower or re.search(r"(^|[_\.])l(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower):
return "l"
if "right" in lower or re.search(r"(^|[_\.])r(?:wing|arm|hand|foot|thigh|shin|shoulder|bicep|forearm|sensor)", lower):
return "r"
if lower.startswith("l") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")):
return "l"
if lower.startswith("r") and any(token in lower for token in ("wing", "arm", "hand", "foot", "thigh", "shin", "sensor")):
return "r"
return None
def build_roles(profile: AssetProfile, rig: bpy.types.Object) -> dict[str, list[str]]:
names = [bone.name for bone in rig.data.bones]
if profile.red_eyes_roles:
return {role: [name for name in role_names if name in names] for role, role_names in RED_EYES.items()}
roles = {key: [] for key in ("body", "head", "jaw", "wing_l", "wing_r", "arm_l", "arm_r", "leg_l", "leg_r", "tail")}
roles["body"] = [name for name in profile.body if name in names]
for name in names:
lower = name.lower()
bone_side = side(name)
if "wing" in lower:
if bone_side:
roles[f"wing_{bone_side}"].append(name)
elif any(token in lower for token in ("arm", "hand", "shoulder", "bicep", "forearm")):
if bone_side:
roles[f"arm_{bone_side}"].append(name)
elif any(token in lower for token in ("foot", "thigh", "shin", "upleg")) or (profile.creature == "insect" and "foot" in lower):
if bone_side:
roles[f"leg_{bone_side}"].append(name)
if "tail" in lower:
roles["tail"].append(name)
if "head" in lower or (profile.creature == "insect" and lower in {"neck1", "neck2"}):
roles["head"].append(name)
if any(token in lower for token in ("jaw", "chin", "mandible", "mouth")):
roles["jaw"].append(name)
if profile.creature == "wraith":
roles["arm_l"] = [name for name in names if name.lower().startswith("l_arm")]
roles["arm_r"] = [name for name in names if name.lower().startswith("r_arm")]
roles["head"].extend(name for name in ("eye", "crown", "eye_upper", "eye_lower") if name in names)
roles["jaw"].extend(name for name in ("chin_upper", "chin_lower", "under") if name in names)
if profile.creature == "insect":
roles["head"].extend(name for name in names if "sensor" in name.lower())
if profile.asset_id == "blue-eyes-ultimate-dragon":
for role, chain in ULTIMATE_HEAD_CHAINS.items():
roles[role] = [name for name in chain if name in names]
return {role: list(dict.fromkeys(role_names)) for role, role_names in roles.items()}
def rotate_role(roles: dict[str, list[str]], role: str, degrees: tuple[float, float, float], limit: int = 99, falloff: float = 0.72) -> dict[str, tuple[float, float, float]]:
result = {}
for index, name in enumerate(roles.get(role, [])[:limit]):
factor = falloff ** index
result[name] = tuple(component * factor for component in degrees)
return result
def merge(*maps: dict[str, tuple[float, float, float]]) -> dict[str, tuple[float, float, float]]:
merged: dict[str, tuple[float, float, float]] = {}
for values in maps:
for name, rotation in values.items():
previous = merged.get(name, (0.0, 0.0, 0.0))
merged[name] = tuple(previous[index] + rotation[index] for index in range(3))
return merged
def dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
wings_up = merge(rotate_role(roles, "wing_l", (0, -14, 18), 5), rotate_role(roles, "wing_r", (0, 14, -18), 5))
wings_down = merge(rotate_role(roles, "wing_l", (0, 18, -14), 5), rotate_role(roles, "wing_r", (0, -18, 14), 5))
tail_l = rotate_role(roles, "tail", (0, 0, 8), 8, 0.82)
tail_r = rotate_role(roles, "tail", (0, 0, -8), 8, 0.82)
legs_a = merge(rotate_role(roles, "leg_l", (10, 0, 0), 2), rotate_role(roles, "leg_r", (-10, 0, 0), 2))
legs_b = merge(rotate_role(roles, "leg_l", (-10, 0, 0), 2), rotate_role(roles, "leg_r", (10, 0, 0), 2))
return {
"Idle": [(1, neutral), (16, merge(wings_up, tail_l, rotate_role(roles, "head", (2, 0, 0), 3))), (31, neutral), (46, merge(wings_down, tail_r, rotate_role(roles, "head", (-2, 0, 0), 3))), (60, neutral)],
"Move": [(1, merge(wings_up, legs_a, tail_l)), (10, neutral), (19, merge(wings_down, legs_b, tail_r)), (28, neutral), (36, merge(wings_up, legs_a, tail_l))],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), wings_up, rotate_role(roles, "head", (-14, 0, 0), 4), rotate_role(roles, "jaw", (-8, 0, 0), 3))), (16, merge(rotate_role(roles, "body", (17, 0, 0), 2), wings_down, rotate_role(roles, "head", (24, 0, 0), 5), rotate_role(roles, "jaw", (24, 0, 0), 4), rotate_role(roles, "arm_l", (18, 0, -14), 3), rotate_role(roles, "arm_r", (18, 0, 14), 3))), (25, merge(rotate_role(roles, "head", (5, 0, 0), 4), rotate_role(roles, "jaw", (8, 0, 0), 3))), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), rotate_role(roles, "head", (-22, 0, -10), 5), wings_up)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), rotate_role(roles, "head", (18, 0, 8), 5), wings_down)), (44, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (28, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (10, 62, 70), 3), rotate_role(roles, "head", (30, 0, 12), 5), rotate_role(roles, "wing_l", (0, 34, -28), 5), rotate_role(roles, "wing_r", (0, -34, 28), 5)))],
}
def ultimate_head_pose(
roles: dict[str, list[str]],
left: tuple[float, float, float],
center: tuple[float, float, float],
right: tuple[float, float, float],
) -> dict[str, tuple[float, float, float]]:
"""Bend each long neck as a chain; rotating only head bones reads as static."""
result: dict[str, tuple[float, float, float]] = {}
weights = (0.10, 0.14, 0.17, 0.18, 0.16, 0.12, 0.22)
for role, motion in zip(("head_l", "head_c", "head_r"), (left, center, right), strict=True):
pitch, yaw, roll = motion
for index, name in enumerate(roles.get(role, [])):
weight = weights[min(index, len(weights) - 1)]
result[name] = (roll * weight, pitch * weight, yaw * weight)
return result
def ultimate_living_heads(
roles: dict[str, list[str]],
progress: float,
intensity: float = 1.0,
) -> dict[str, tuple[float, float, float]]:
"""Use distinct harmonic timing so three heads never bob in lockstep."""
phase = math.tau * progress
left = (
intensity * (2.8 * math.sin(phase) + 0.8 * math.sin(phase * 2 + 0.35)),
intensity * (2.0 * math.sin(phase * 2 + 0.75)),
intensity * 0.8 * math.sin(phase + 0.2),
)
center = (
intensity * (2.5 * math.sin(phase + 2.05) + 0.65 * math.sin(phase * 3 + 0.25)),
intensity * (1.5 * math.sin(phase * 3 + 1.4)),
intensity * 0.65 * math.sin(phase + 2.6),
)
right = (
intensity * (2.7 * math.sin(phase + 4.15) + 0.75 * math.sin(phase * 2 + 1.15)),
intensity * (1.9 * math.sin(phase * 2 + 3.7)),
intensity * 0.75 * math.sin(phase + 4.55),
)
return ultimate_head_pose(roles, left, center, right)
def ultimate_wings(flare: float) -> dict[str, tuple[float, float, float]]:
"""Flap around local Z only; local Y sweeps these wings around body."""
result: dict[str, tuple[float, float, float]] = {}
for prefix, sign in (("BEUD_LWing", 1), ("BEUD_RWing", -1)):
for index, weight in ((1, 1.0), (2, 0.34), (3, 0.12)):
result[f"{prefix}{index}"] = (0.0, 0.0, sign * flare * weight)
return result
def ultimate_jaws(left: float, center: float, right: float) -> dict[str, tuple[float, float, float]]:
"""Jaw hinges use local Y; local X would twist along snout."""
return {
"BEUD_LChin": (0.0, left, 0.0),
"BEUD_Chin": (0.0, center, 0.0),
"BEUD_RChin": (0.0, right, 0.0),
}
def ultimate_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
idle = []
for frame in (1, 6, 11, 16, 21, 26, 31, 36, 41, 46, 51, 56, 60):
progress = (frame - 1) / 59
idle.append((frame, merge(
ultimate_living_heads(roles, progress),
ultimate_wings(1.8 * math.sin(math.tau * progress)),
rotate_role(roles, "tail", (0, 0, 4.0 * math.sin(math.tau * progress + 0.6)), 8, 0.82),
)))
move = []
for frame in (1, 5, 9, 13, 17, 21, 25, 29, 33, 36):
progress = (frame - 1) / 35
stride = math.sin(math.tau * progress)
move.append((frame, merge(
ultimate_living_heads(roles, progress, 1.2),
ultimate_wings(7.0 * stride),
rotate_role(roles, "tail", (0, 0, 5.5 * math.sin(math.tau * progress + 0.8)), 8, 0.82),
rotate_role(roles, "leg_l", (5.0 * stride, 0, 0), 2),
rotate_role(roles, "leg_r", (-5.0 * stride, 0, 0), 2),
)))
attack_specs = (
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
(6, (-4, 2, -1), (-2, -1, 0), (-1, -2, 1), (0, 0, 0), 2),
(10, (-8, 3, -1), (-5, 0, 0), (-3, -3, 1), (4, 0, 0), 4),
(14, (11, -2, 1), (-8, 1, 0), (-5, -1, 1), (24, 3, 0), -3),
(17, (6, 1, 0), (12, 0, 0), (-8, 2, -1), (8, 25, 3), -4),
(21, (3, -1, 0), (7, -1, 0), (13, 2, -1), (2, 9, 25), -2),
(28, (-1, 1, 0), (2, 0, 0), (5, -1, 0), (0, 2, 8), 1),
(36, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
)
attack = [
(frame, merge(
ultimate_head_pose(roles, left, center, right),
ultimate_jaws(*jaws),
ultimate_wings(wing),
))
for frame, left, center, right, jaws, wing in attack_specs
]
hit_specs = (
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
(4, (-10, -3, 2), (-6, 2, -1), (-4, 3, -2), (-14, 0, 10), 4),
(7, (-5, 2, -1), (-11, -2, 1), (-7, -3, 2), (-8, 0, 6), 2),
(11, (3, -1, 0), (-4, 1, 0), (-10, 2, -1), (5, 0, -4), -1),
(16, (-1, 1, 0), (3, -1, 0), (-2, 0, 0), (2, 0, -2), 0),
(24, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
)
hit_react = [
(frame, merge(
ultimate_head_pose(roles, left, center, right),
rotate_role(roles, "body", body, 2),
ultimate_wings(wing),
))
for frame, left, center, right, body, wing in hit_specs
]
death_specs = (
(1, (0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0), 0),
(14, (-5, -3, 2), (-3, 2, -1), (-7, 2, -2), (-10, 0, 8), 2),
(25, (-14, -7, 4), (-8, 4, -2), (-18, 6, -5), (-16, 8, 18), -3),
(42, (-23, -10, 7), (-17, 6, -4), (-28, 9, -8), (8, 48, 58), -7),
(58, (-30, -13, 9), (-24, 8, -6), (-34, 11, -10), (10, 62, 70), -9),
(72, (-32, -14, 10), (-27, 9, -7), (-36, 12, -11), (10, 62, 70), -9),
)
death = [
(frame, merge(
ultimate_head_pose(roles, left, center, right),
rotate_role(roles, "body", body, 2),
ultimate_wings(wing),
))
for frame, left, center, right, body, wing in death_specs
]
return {"Idle": idle, "Move": move, "Attack": attack, "HitReact": hit_react, "Death": death}
def red_eyes_dragon_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
"""Author Red-Eyes motion around its unusually oriented local bone axes.
This rig's wing bones do not share the local axes used by the named dragon
rigs. Rotating every wing segment as one role makes each membrane chain curl
forward like an arm. Instead, rotate the two shoulder roots in mirrored
armature-space directions and add only a small elbow fold. Both sides use
identical keyframe timing so they read as one aerodynamic flap.
"""
neutral: dict[str, tuple[float, float, float]] = {}
wings_up = {
"bone0016": (38.0, 32.8, 38.3),
"bone0033": (-38.0, -32.8, 38.3),
"bone0006": (-11.3, 14.0, -0.4),
"bone0012": (11.3, -14.0, -0.4),
}
wings_down = {
"bone0016": (-12.8, -28.5, -13.1),
"bone0033": (12.8, 28.5, -13.1),
"bone0006": (7.6, -9.3, -1.3),
"bone0012": (-7.6, 9.3, -1.3),
}
wings_recover = {
"bone0016": (15.5, 16.0, 15.7),
"bone0033": (-15.5, -16.0, 15.7),
"bone0006": (-5.0, 6.0, -0.2),
"bone0012": (5.0, -6.0, -0.2),
}
tail_l = rotate_role(roles, "tail", (0, 0, 7), 5, 0.78)
tail_r = rotate_role(roles, "tail", (0, 0, -7), 5, 0.78)
legs_a = merge(rotate_role(roles, "leg_l", (8, 0, 0), 2), rotate_role(roles, "leg_r", (-8, 0, 0), 2))
legs_b = merge(rotate_role(roles, "leg_l", (-8, 0, 0), 2), rotate_role(roles, "leg_r", (8, 0, 0), 2))
# The neck runs forward/down in armature space. Its local Z axis is the
# pitch axis: negative lifts the head for anticipation; positive drives the
# snout down through the bite. Jaw roots open during the windup, then close
# at impact instead of swinging the forelimbs as the attack silhouette.
head_lift = {"bone0025": (0, 0, 45), "bone0031": (0, 0, 15)}
head_bite = {"bone0025": (0, 0, -32), "bone0031": (0, 0, -10)}
head_follow_through = {"bone0025": (0, 0, -18), "bone0031": (0, 0, -6)}
jaw_open = {"bone0001": (24, 0, 0), "bone0029": (24, 0, 0), "bone0020": (20, 0, 0)}
jaw_closed = {"bone0001": (-4, 0, 0), "bone0029": (-4, 0, 0), "bone0020": (-3, 0, 0)}
return {
"Idle": [
(1, neutral),
(14, wings_up),
(28, neutral),
(42, wings_down),
(54, wings_recover),
(60, neutral),
],
"Move": [
(1, merge(wings_up, legs_a, tail_l)),
(9, neutral),
(18, merge(wings_down, legs_b, tail_r)),
(27, neutral),
(36, merge(wings_up, legs_a, tail_l)),
],
"Attack": [
(1, neutral),
(8, merge(wings_up, head_lift, jaw_open)),
(14, merge(wings_down, head_bite, jaw_open)),
(17, merge(wings_down, head_bite, jaw_closed)),
(23, merge(wings_recover, head_follow_through)),
(30, neutral),
(36, neutral),
],
"HitReact": [
(1, neutral),
(5, merge(rotate_role(roles, "body", (-18, 0, 12), 2), head_lift, wings_up)),
(13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)),
(24, neutral),
],
"Death": [
(1, neutral),
(18, merge(rotate_role(roles, "body", (-20, 0, 18), 3), head_follow_through, wings_down)),
(44, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down, rotate_role(roles, "leg_l", (28, 0, 0), 3), rotate_role(roles, "leg_r", (-18, 0, 0), 3))),
(72, merge(rotate_role(roles, "body", (10, 62, 70), 3), head_bite, wings_down)),
],
}
def guardian_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
arms_open = merge(rotate_role(roles, "arm_l", (0, -8, -12), 3), rotate_role(roles, "arm_r", (0, 8, 12), 3))
arms_close = merge(rotate_role(roles, "arm_l", (0, 5, 8), 3), rotate_role(roles, "arm_r", (0, -5, -8), 3))
step_a = merge(rotate_role(roles, "leg_l", (-20, 0, 0), 3), rotate_role(roles, "leg_r", (20, 0, 0), 3), rotate_role(roles, "arm_l", (14, 0, 0), 3), rotate_role(roles, "arm_r", (-14, 0, 0), 3))
step_b = merge(rotate_role(roles, "leg_l", (20, 0, 0), 3), rotate_role(roles, "leg_r", (-20, 0, 0), 3), rotate_role(roles, "arm_l", (-14, 0, 0), 3), rotate_role(roles, "arm_r", (14, 0, 0), 3))
return {
"Idle": [(1, neutral), (16, merge(arms_open, rotate_role(roles, "body", (2, 0, 0), 2))), (31, neutral), (46, merge(arms_close, rotate_role(roles, "body", (-2, 0, 0), 2))), (60, neutral)],
"Move": [(1, step_a), (10, neutral), (19, step_b), (28, neutral), (36, step_a)],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (0, -18, -8), 3), rotate_role(roles, "arm_r", (-35, 15, 26), 5))), (15, merge(rotate_role(roles, "body", (8, 20, 8), 3), rotate_role(roles, "arm_r", (42, -12, -35), 5), rotate_role(roles, "arm_l", (12, 0, 8), 3), rotate_role(roles, "jaw", (16, 0, 0), 2))), (25, rotate_role(roles, "arm_r", (8, 0, -8), 4)), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), arms_open)), (13, merge(rotate_role(roles, "body", (8, 0, -6), 2), arms_close)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-24, 0, 18), 3), arms_open)), (44, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (24, 0, -38), 5), rotate_role(roles, "arm_r", (-18, 0, 34), 5), rotate_role(roles, "leg_l", (28, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (12, 65, 78), 3), rotate_role(roles, "arm_l", (26, 0, -40), 5), rotate_role(roles, "arm_r", (-20, 0, 36), 5)))],
}
def insect_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
legs_a = merge(rotate_role(roles, "leg_l", (12, 0, -10), 4), rotate_role(roles, "leg_r", (-12, 0, 10), 4))
legs_b = merge(rotate_role(roles, "leg_l", (-12, 0, 10), 4), rotate_role(roles, "leg_r", (12, 0, -10), 4))
wings_up = merge(rotate_role(roles, "wing_l", (0, -18, 20), 3), rotate_role(roles, "wing_r", (0, 18, -20), 3))
wings_down = merge(rotate_role(roles, "wing_l", (0, 22, -16), 3), rotate_role(roles, "wing_r", (0, -22, 16), 3))
return {
"Idle": [(1, neutral), (16, merge(wings_up, rotate_role(roles, "head", (0, 0, 5), 4))), (31, neutral), (46, merge(wings_down, rotate_role(roles, "head", (0, 0, -5), 4))), (60, neutral)],
"Move": [(1, merge(legs_a, wings_up)), (10, neutral), (19, merge(legs_b, wings_down)), (28, neutral), (36, merge(legs_a, wings_up))],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-10, 0, 0), 2), rotate_role(roles, "arm_l", (-20, 0, -20), 3), rotate_role(roles, "arm_r", (-20, 0, 20), 3), wings_up)), (15, merge(rotate_role(roles, "body", (18, 0, 0), 2), rotate_role(roles, "head", (22, 0, 0), 4), rotate_role(roles, "jaw", (25, 0, 0), 4), rotate_role(roles, "arm_l", (28, 0, 22), 3), rotate_role(roles, "arm_r", (28, 0, -22), 3))), (25, rotate_role(roles, "jaw", (8, 0, 0), 3)), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 12), 3), wings_up, legs_b)), (13, merge(rotate_role(roles, "body", (7, 0, -5), 2), wings_down)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-18, 0, 16), 3), wings_down)), (44, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5), rotate_role(roles, "wing_l", (0, 32, -28), 3), rotate_role(roles, "wing_r", (0, -32, 28), 3))), (72, merge(rotate_role(roles, "body", (8, 58, 76), 3), rotate_role(roles, "leg_l", (30, 0, 22), 5), rotate_role(roles, "leg_r", (-24, 0, -18), 5)))],
}
def wraith_tentacles(
roles: dict[str, list[str]],
sweep: float,
curl: float,
lift: float = 0,
phase: float = 0,
) -> dict[str, tuple[float, float, float]]:
"""Pose every independently rigged vine chain instead of one flattened pair."""
result: dict[str, tuple[float, float, float]] = {}
for role, side_sign in (("arm_l", -1), ("arm_r", 1)):
chains: dict[str, list[str]] = {}
for name in roles.get(role, []):
match = re.match(r"([LR]_arm\d+)_", name, re.IGNORECASE)
if match:
chains.setdefault(match.group(1).lower(), []).append(name)
for chain_index, chain_name in enumerate(sorted(chains)):
chain = chains[chain_name]
pair_direction = -1 if chain_index % 2 else 1
chain_phase = phase + chain_index * 0.85
for bone_index, name in enumerate(chain):
progress = bone_index / max(1, len(chain) - 1)
tip_weight = 0.38 + progress * 0.62
wave = math.sin(chain_phase + progress * math.pi * 1.65)
result[name] = (
lift * pair_direction * (1 - progress * 0.45),
side_sign * sweep * tip_weight * 0.42,
side_sign * (curl * wave + sweep * pair_direction * 0.22) * tip_weight,
)
return result
def wraith_poses(roles: dict[str, list[str]]) -> dict[str, list[tuple[int, dict[str, tuple[float, float, float]]]]]:
neutral: dict[str, tuple[float, float, float]] = {}
idle_a = wraith_tentacles(roles, sweep=3.5, curl=5.5, lift=1.5, phase=0.2)
idle_b = wraith_tentacles(roles, sweep=-3.5, curl=5.5, lift=-1.5, phase=math.pi + 0.2)
drift_a = wraith_tentacles(roles, sweep=6, curl=8, lift=2.5, phase=0.65)
drift_b = wraith_tentacles(roles, sweep=-6, curl=8, lift=-2.5, phase=math.pi + 0.65)
windup = wraith_tentacles(roles, sweep=-8, curl=10, lift=-4, phase=1.1)
lash = wraith_tentacles(roles, sweep=15, curl=17, lift=7, phase=2.35)
recoil = wraith_tentacles(roles, sweep=-11, curl=13, lift=-6, phase=1.75)
collapse = wraith_tentacles(roles, sweep=-14, curl=7, lift=18, phase=math.pi * 0.5)
return {
"Idle": [(1, neutral), (16, merge(idle_a, rotate_role(roles, "body", (0, 0, 2), 3))), (31, neutral), (46, merge(idle_b, rotate_role(roles, "body", (0, 0, -2), 3))), (60, neutral)],
"Move": [(1, drift_a), (10, merge(idle_a, rotate_role(roles, "body", (0, 0, 5), 3))), (19, drift_b), (28, merge(idle_b, rotate_role(roles, "body", (0, 0, -5), 3))), (36, drift_a)],
"Attack": [(1, neutral), (9, merge(rotate_role(roles, "body", (-12, 0, 0), 3), windup)), (16, merge(rotate_role(roles, "body", (18, 0, 0), 3), lash, rotate_role(roles, "jaw", (24, 0, 0), 3))), (25, drift_a), (36, neutral)],
"HitReact": [(1, neutral), (5, merge(rotate_role(roles, "body", (-18, 0, 14), 3), recoil)), (13, merge(rotate_role(roles, "body", (7, 0, -6), 3), idle_a)), (24, neutral)],
"Death": [(1, neutral), (18, merge(rotate_role(roles, "body", (-22, 0, 20), 3), recoil)), (44, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse, rotate_role(roles, "jaw", (30, 0, 0), 3))), (72, merge(rotate_role(roles, "body", (18, 55, 72), 3), collapse))],
}
def create_actions(profile: AssetProfile, rig: bpy.types.Object, roles: dict[str, list[str]]) -> dict[str, dict[str, object]]:
pose_factory = {
"dragon": dragon_poses,
"guardian": guardian_poses,
"insect": insect_poses,
"wraith": wraith_poses,
}[profile.creature]
if profile.asset_id == "blue-eyes-ultimate-dragon":
definitions = ultimate_dragon_poses(roles)
elif profile.red_eyes_roles:
definitions = red_eyes_dragon_poses(roles)
else:
definitions = pose_factory(roles)
rig.animation_data_create()
metadata = {}
for clip_name, poses in definitions.items():
action = bpy.data.actions.new(clip_name)
action.use_fake_user = True
action.use_frame_range = True
action.frame_start = 1
action.frame_end = CLIP_SPECS[clip_name]["frames"]
action.use_cyclic = CLIP_SPECS[clip_name]["loop"]
rig.animation_data.action = action
animated_names = sorted({name for _, rotations in poses for name in rotations})
for frame, rotations in poses:
for name in animated_names:
bone = rig.pose.bones.get(name)
if not bone:
continue
bone.rotation_mode = "XYZ"
degrees = rotations.get(name, (0.0, 0.0, 0.0))
bone.rotation_euler = tuple(math.radians(component) for component in degrees)
bone.keyframe_insert("rotation_euler", frame=frame, group=bone.name)
metadata[clip_name] = {
"frames": CLIP_SPECS[clip_name]["frames"],
"seconds": CLIP_SPECS[clip_name]["frames"] / FPS,
"loop": CLIP_SPECS[clip_name]["loop"],
}
rig.animation_data.action = None
for bone in rig.pose.bones:
bone.rotation_euler = (0, 0, 0)
return metadata
def select_only(objects: list[bpy.types.Object]) -> None:
bpy.ops.object.select_all(action="DESELECT")
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
def world_bounds(meshes: list[bpy.types.Object]) -> tuple[Vector, Vector]:
depsgraph = bpy.context.evaluated_depsgraph_get()
points: list[Vector] = []
for mesh in meshes:
evaluated = mesh.evaluated_get(depsgraph)
evaluated_mesh = evaluated.to_mesh()
points.extend(evaluated.matrix_world @ vertex.co for vertex in evaluated_mesh.vertices)
evaluated.to_mesh_clear()
minimum = Vector(tuple(min(point[index] for point in points) for index in range(3)))
maximum = Vector(tuple(max(point[index] for point in points) for index in range(3)))
return minimum, maximum
def look_at(obj: bpy.types.Object, target: Vector) -> None:
obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler()
def render_preview(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], out_path: Path, frame: int = 17) -> None:
rig.animation_data.action = bpy.data.actions["Attack"]
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
minimum, maximum = world_bounds(meshes)
center = (minimum + maximum) * 0.5
radius = (maximum - minimum).length * 0.5
size = radius * 2
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = 640
scene.render.resolution_y = 640
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = False
scene.world.color = (0.008, 0.012, 0.02)
camera_data = bpy.data.cameras.new("PreviewCamera")
camera = bpy.data.objects.new("PreviewCamera", camera_data)
bpy.context.collection.objects.link(camera)
camera.data.lens = 65
view_direction = Vector((0.8, -1.05, 0.42)).normalized()
half_fov = math.atan(camera.data.sensor_width / (2 * camera.data.lens))
camera.location = center + view_direction * (radius / math.sin(half_fov) * 0.45)
look_at(camera, center)
scene.camera = camera
for name, offset, color, energy, radius in (
("Key", (1.4, -1.2, 1.7), (1.0, 0.52, 0.28), 1000, 5.0),
("Fill", (-1.3, -0.4, 0.8), (0.22, 0.42, 1.0), 800, 4.0),
("Rim", (0.3, 1.4, 1.3), (0.65, 0.22, 1.0), 900, 3.5),
):
light_data = bpy.data.lights.new(name, "AREA")
light_data.energy = energy
light_data.color = color
light_data.shape = "DISK"
light_data.size = size * radius
light = bpy.data.objects.new(name, light_data)
light.location = center + Vector(offset) * size
bpy.context.collection.objects.link(light)
look_at(light, center)
out_path.parent.mkdir(parents=True, exist_ok=True)
scene.render.filepath = str(out_path)
bpy.ops.render.render(write_still=True)
rig.animation_data.action = None
def export_asset(profile: AssetProfile, rig: bpy.types.Object, meshes: list[bpy.types.Object], source_path: Path, clips: dict[str, dict[str, object]], roles: dict[str, list[str]]) -> None:
source_dir = MODEL_ROOT / profile.asset_id
output_path = source_dir / f"{profile.asset_id}-animated.glb"
select_only([rig, *meshes])
bpy.ops.export_scene.gltf(
filepath=str(output_path),
export_format="GLB",
use_selection=True,
export_animations=True,
export_animation_mode="ACTIONS",
export_frame_range=True,
export_skins=True,
export_morph=True,
export_yup=True,
export_apply=False,
export_cameras=False,
export_lights=False,
export_image_format="AUTO",
)
triangles = sum(len(polygon.vertices) - 2 for mesh in meshes for polygon in mesh.data.polygons)
metadata = {
"name": profile.display_name,
"assetId": profile.asset_id,
"source": source_path.name,
"format": "glTF 2.0 binary (GLB)",
"authoringTool": bpy.app.version_string,
"fps": FPS,
"trianglesApprox": triangles,
"animations": clips,
"animatedBones": {role: names for role, names in roles.items() if names},
}
(source_dir / f"{profile.asset_id}-animated.asset.json").write_text(json.dumps(metadata, indent=2) + "\n")
render_preview(profile, rig, meshes, source_dir / "previews" / f"{profile.asset_id}-attack.png")
print(f"BUILT={profile.asset_id} BONES={len(rig.data.bones)} TRIANGLES={triangles} CLIPS={','.join(clips)}")
def main() -> None:
bpy.context.preferences.filepaths.save_version = 0
bpy.context.scene.render.fps = FPS
requested_ids = set(sys.argv[sys.argv.index("--") + 1:]) if "--" in sys.argv else set()
unknown_ids = requested_ids - {profile.asset_id for profile in PROFILES}
if unknown_ids:
raise RuntimeError(f"Unknown asset ids: {', '.join(sorted(unknown_ids))}")
selected_profiles = [profile for profile in PROFILES if not requested_ids or profile.asset_id in requested_ids]
for profile in selected_profiles:
reset_scene()
rig, meshes, source_path = convert_and_import(profile)
roles = build_roles(profile, rig)
clips = create_actions(profile, rig, roles)
export_asset(profile, rig, meshes, source_path, clips, roles)
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
"""Print rig and mesh details for the locally-authored inspired creature models."""
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import tempfile
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
def reset_scene() -> None:
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
def inspect_model(path: Path) -> dict[str, object]:
reset_scene()
source_path = path.with_suffix(".smd")
with tempfile.TemporaryDirectory(prefix="thor-yugioh-inspect-") as temp_dir:
converted_path = Path(temp_dir) / "model.glb"
subprocess.run(
["assimp", "export", str(source_path), str(converted_path), "-f", "glb2"],
check=True,
capture_output=True,
text=True,
)
bpy.ops.import_scene.gltf(filepath=str(converted_path))
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
meshes = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
bounds = []
for mesh in meshes:
bounds.extend(mesh.matrix_world @ Vector(corner) for corner in mesh.bound_box)
return {
"asset": path.parent.name,
"source": source_path.name,
"armatures": [
{
"name": rig.name,
"bones": [bone.name for bone in rig.data.bones],
"actions": [action.name for action in bpy.data.actions],
}
for rig in armatures
],
"meshes": [mesh.name for mesh in meshes],
"materials": sorted({slot.material.name for mesh in meshes for slot in mesh.material_slots if slot.material}),
"bounds": {
"min": [round(min(point[index] for point in bounds), 4) for index in range(3)],
"max": [round(max(point[index] for point in bounds), 4) for index in range(3)],
} if bounds else None,
}
def main() -> None:
report = [inspect_model(path) for path in sorted(MODEL_ROOT.glob("*/MMD_*.fbx"))]
print("YUGIOH_MODEL_REPORT=" + json.dumps(report, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,240 @@
"""Re-import and validate every generated creature animation GLB."""
from __future__ import annotations
import json
from pathlib import Path
import re
import struct
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[2]
MODEL_ROOT = ROOT / "game_assets/models/downloaded/yugioh"
EXPECTED_CLIPS = {"Idle", "Move", "Attack", "HitReact", "Death"}
def read_glb_json(path: Path) -> dict[str, object]:
data = path.read_bytes()
magic, version, total_length = struct.unpack_from("<4sII", data, 0)
if magic != b"glTF" or version != 2 or total_length != len(data):
raise RuntimeError(f"{path}: invalid GLB header")
chunk_length, chunk_type = struct.unpack_from("<II", data, 12)
if chunk_type != 0x4E4F534A:
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
return json.loads(data[20:20 + chunk_length].decode("utf-8"))
def read_glb_binary(path: Path) -> bytes:
data = path.read_bytes()
json_length, json_type = struct.unpack_from("<II", data, 12)
if json_type != 0x4E4F534A:
raise RuntimeError(f"{path}: JSON is not the first GLB chunk")
binary_header = 20 + json_length
binary_length, binary_type = struct.unpack_from("<II", data, binary_header)
if binary_type != 0x004E4942:
raise RuntimeError(f"{path}: binary data is not the second GLB chunk")
binary_start = binary_header + 8
return data[binary_start:binary_start + binary_length]
def float_accessor(document: dict[str, object], binary: bytes, index: int) -> list[tuple[float, ...]]:
accessor = document["accessors"][index]
if accessor["componentType"] != 5126:
raise RuntimeError(f"Accessor {index}: expected float data")
component_counts = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}
component_count = component_counts[accessor["type"]]
view = document["bufferViews"][accessor["bufferView"]]
element_size = component_count * 4
stride = view.get("byteStride", element_size)
start = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
return [
struct.unpack_from(f"<{component_count}f", binary, start + item * stride)
for item in range(accessor["count"])
]
def values_vary(values: list[tuple[float, ...]], tolerance: float = 1e-5) -> bool:
first = values[0]
return any(
abs(component - first[index]) > tolerance
for value in values[1:]
for index, component in enumerate(value)
)
def validate_pumpking_tentacles(path: Path) -> int:
document = read_glb_json(path)
nodes = document.get("nodes", [])
animations = document.get("animations", [])
tentacle_nodes = {
index for index, node in enumerate(nodes)
if re.fullmatch(r"[LR]_arm\d+_\d+", node.get("name", ""), re.IGNORECASE)
}
if not tentacle_nodes:
raise RuntimeError(f"{path}: no Pumpking tentacle nodes")
for animation in animations:
targeted = {channel["target"]["node"] for channel in animation.get("channels", [])}
missing = tentacle_nodes - targeted
if missing:
missing_names = [nodes[index].get("name", str(index)) for index in sorted(missing)]
raise RuntimeError(f"{path}: {animation.get('name')} misses tentacles {missing_names}")
return len(tentacle_nodes)
def validate_ultimate_dragon(path: Path) -> dict[str, int]:
document = read_glb_json(path)
binary = read_glb_binary(path)
nodes = document.get("nodes", [])
animations = document.get("animations", [])
head_chains = (
{f"BEUD_LNeck{index}" for index in range(1, 7)} | {"BEUD_LHead"},
{f"BEUD_Neck{index}" for index in range(1, 7)} | {"BEUD_Head"},
{f"BEUD_RNeck{index}" for index in range(1, 7)} | {"BEUD_RHead"},
)
animated_wings = {
f"BEUD_{side}Wing{index}"
for side in ("L", "R")
for index in range(1, 4)
}
static_wings = {
f"BEUD_{side}Wing{index}"
for side in ("L", "R")
for index in range(4, 9)
}
head_tips = {"BEUD_LHead", "BEUD_Head", "BEUD_RHead"}
for animation in animations:
variation = {}
rotation_values = {}
for channel in animation.get("channels", []):
if channel["target"].get("path") != "rotation":
continue
name = nodes[channel["target"]["node"]].get("name", "")
accessor_index = animation["samplers"][channel["sampler"]]["output"]
values = float_accessor(document, binary, accessor_index)
rotation_values[name] = values
variation[name] = values_vary(values)
for chain in head_chains:
static = {name for name in chain if not variation.get(name, False)}
if static:
raise RuntimeError(f"{path}: {animation.get('name')} has static head-chain bones {sorted(static)}")
static_roots = {name for name in animated_wings if not variation.get(name, False)}
moving_tips = {name for name in static_wings if variation.get(name, False)}
if static_roots or moving_tips:
raise RuntimeError(
f"{path}: {animation.get('name')} static wing roots {sorted(static_roots)}, "
f"moving wing tips {sorted(moving_tips)}"
)
if animation.get("name") in {"Idle", "Move", "Attack"}:
peak_frames = {}
for name in head_tips:
values = rotation_values[name]
start = values[0]
distances = [1 - min(1.0, abs(sum(component * start[index] for index, component in enumerate(value)))) for value in values]
peak_frames[name] = distances.index(max(distances))
if len(set(peak_frames.values())) != len(head_tips):
raise RuntimeError(f"{path}: {animation.get('name')} head cadences overlap at {peak_frames}")
return {
"headChainBonesPerClip": sum(len(chain) for chain in head_chains),
"wingBonesPerClip": len(animated_wings),
"staggeredHeadClips": 3,
}
def reset_scene() -> None:
if bpy.context.object and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
for image in list(bpy.data.images):
bpy.data.images.remove(image)
def validate_red_eyes_motion(rig: bpy.types.Object) -> dict[str, float]:
"""Regression-check the paired flap and bite silhouette in the shipped GLB."""
rig.animation_data.action = bpy.data.actions["Attack"]
def sample(frame: int) -> dict[str, Vector]:
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
return {
"wing_l": rig.pose.bones["bone0079"].tail.copy(),
"wing_r": rig.pose.bones["bone0075"].tail.copy(),
"head": rig.pose.bones["bone0073"].tail.copy(),
}
windup = sample(8)
downstroke = sample(14)
impact = sample(17)
left_flap = windup["wing_l"].z - downstroke["wing_l"].z
right_flap = windup["wing_r"].z - downstroke["wing_r"].z
head_drop = windup["head"].z - impact["head"].z
head_lunge = windup["head"].y - impact["head"].y
if left_flap < 0.2 or right_flap < 0.2:
raise RuntimeError(f"Red-Eyes wings do not complete a paired flap: {left_flap=:.4f}, {right_flap=:.4f}")
if head_drop < 0.01 or head_lunge < 0.05:
raise RuntimeError(f"Red-Eyes bite lacks head lift/lunge: {head_drop=:.4f}, {head_lunge=:.4f}")
return {
"leftWingDrop": round(left_flap, 4),
"rightWingDrop": round(right_flap, 4),
"headDrop": round(head_drop, 4),
"headLunge": round(head_lunge, 4),
}
def validate(path: Path) -> dict[str, object]:
reset_scene()
bpy.context.scene.render.fps = 30
bpy.ops.import_scene.gltf(filepath=str(path))
armatures = [obj for obj in bpy.context.scene.objects if obj.type == "ARMATURE"]
meshes = [
obj for obj in bpy.context.scene.objects
if obj.type == "MESH" and any(
modifier.type == "ARMATURE" and modifier.object in armatures for modifier in obj.modifiers
)
]
clips = {action.name: tuple(round(value, 3) for value in action.frame_range) for action in bpy.data.actions}
if len(armatures) != 1:
raise RuntimeError(f"{path}: expected one armature, found {len(armatures)}")
if not meshes:
raise RuntimeError(f"{path}: no render meshes")
if set(clips) != EXPECTED_CLIPS:
raise RuntimeError(f"{path}: clips {sorted(clips)} != {sorted(EXPECTED_CLIPS)}")
if any(end <= start for start, end in clips.values()):
raise RuntimeError(f"{path}: empty animation range in {clips}")
if not bpy.data.images:
raise RuntimeError(f"{path}: no packed texture images")
corners = [mesh.matrix_world @ Vector(corner) for mesh in meshes for corner in mesh.bound_box]
dimensions = [round(max(point[index] for point in corners) - min(point[index] for point in corners), 4) for index in range(3)]
report = {
"asset": path.parent.name,
"sizeBytes": path.stat().st_size,
"bones": len(armatures[0].data.bones),
"meshes": len(meshes),
"images": len(bpy.data.images),
"dimensions": dimensions,
"clips": clips,
}
if path.parent.name == "pumpking-the-king-of-ghosts":
report["tentacleBonesPerClip"] = validate_pumpking_tentacles(path)
if path.parent.name == "blue-eyes-ultimate-dragon":
report.update(validate_ultimate_dragon(path))
if path.parent.name == "red-eyes-black-dragon":
report["motion"] = validate_red_eyes_motion(armatures[0])
return report
def main() -> None:
paths = sorted(MODEL_ROOT.glob("*/*-animated.glb"))
if len(paths) != 7:
raise RuntimeError(f"Expected 7 generated GLBs, found {len(paths)}")
report = [validate(path) for path in paths]
print("YUGIOH_ANIMATION_VALIDATION=" + json.dumps(report, indent=2))
if __name__ == "__main__":
main()
+38 -11
View File
@@ -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:
+58 -13
View File
@@ -5,7 +5,10 @@ import { FrontEnd } from "./components/FrontEnd";
import { useActiveHunter, useFrontendStore } from "./frontend/store";
import { useGameStore } from "./game/store";
import type { BossId } from "./game/types";
import type { DifficultySlug } from "./game/progression/loot";
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 +23,8 @@ function GameLoadingScreen() {
}
export default function App() {
useForcedThorDisplays();
useAuthoritativeDualScreenSync();
useGameLoop();
const screen = useFrontendStore((state) => state.screen);
const hunter = useActiveHunter();
@@ -28,22 +33,38 @@ export default function App() {
const touchActiveSave = useFrontendStore((state) => state.touchActiveSave);
const updateActiveHealerInventory = useFrontendStore((state) => state.updateActiveHealerInventory);
const recordBossVictory = useFrontendStore((state) => state.recordBossVictory);
const phase = useGameStore((state) => state.phase);
const boss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const victoryRecorded = useRef(false);
const clearRecentRewards = useFrontendStore((state) => state.clearRecentRewards);
const rewardedBossInstances = useRef(new Set<string>());
const screenRef = useRef(screen);
screenRef.current = screen;
const leaveGame = useCallback(() => {
updateActiveHealerInventory(useGameStore.getState().inventory);
touchActiveSave();
navigate("home");
}, [navigate, touchActiveSave, updateActiveHealerInventory]);
const launchGame = useCallback((bossIds: readonly BossId[]) => {
const launchGame = useCallback((bossIds: readonly BossId[], requestedDifficultySlug?: DifficultySlug) => {
if (!hunter) return;
const progress = hunter.healers[hunter.activeClassId];
useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds);
const runMode = useFrontendStore.getState().selectedMode === "roguelike-pve" ? "roguelike" : "encounter";
const launchDifficulty = runMode === "roguelike"
? "initiate"
: requestedDifficultySlug ?? useFrontendStore.getState().selectedDifficultySlug;
rewardedBossInstances.current.clear();
clearRecentRewards();
useGameStore.getState().configureHealer(hunter.activeClassId, hunter.hunterName, progress.inventory, bossIds, runMode, hunter.gearProgress, launchDifficulty);
touchActiveSave();
navigate("game");
}, [hunter, navigate, touchActiveSave]);
}, [clearRecentRewards, hunter, navigate, touchActiveSave]);
useEffect(() => {
const onDualScreenLaunch = (event: Event) => {
const detail = (event as CustomEvent<{ bossIds: readonly BossId[]; difficultySlug?: DifficultySlug } | readonly BossId[]>).detail;
if ("bossIds" in detail) launchGame(detail.bossIds, detail.difficultySlug);
else launchGame(detail);
};
window.addEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
return () => window.removeEventListener(DUAL_SCREEN_LAUNCH_EVENT, onDualScreenLaunch);
}, [launchGame]);
useActionBindings(screen === "game", leaveGame);
@@ -53,12 +74,36 @@ export default function App() {
}, [settings.largeText, settings.reducedMotion]);
useEffect(() => {
if (phase === "combat") victoryRecorded.current = false;
if (screen === "game" && phase === "victory" && !victoryRecorded.current) {
victoryRecorded.current = true;
for (const bossName of [boss.name, ...additionalBosses.map((entry) => entry.boss.name)]) recordBossVictory(bossName);
}
}, [additionalBosses, boss.name, phase, recordBossVictory, screen]);
return useGameStore.subscribe((state, previousState) => {
const startedFreshEncounter = state.phase === "briefing" && previousState.phase !== "briefing"
|| previousState.phase === "intermission" && state.phase === "combat";
if (state.phase === "briefing" || previousState.phase === "intermission" && state.phase === "combat") {
rewardedBossInstances.current.clear();
}
if (startedFreshEncounter) clearRecentRewards();
if (screenRef.current !== "game") return;
const bossCount = 1 + state.additionalBosses.length;
if (state.boss.hp <= 0 && previousState.boss.hp > 0) {
const primaryInstanceId = `boss-0-${state.boss.id}`;
if (!rewardedBossInstances.current.has(primaryInstanceId)) {
rewardedBossInstances.current.add(primaryInstanceId);
const defeatedBefore = (state.round - 1) * bossCount;
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(state.boss.id, rewardDifficulty);
}
}
for (let index = 0; index < state.additionalBosses.length; index += 1) {
const entry = state.additionalBosses[index];
const previous = previousState.additionalBosses[index];
const justDefeated = entry.boss.hp <= 0 && (!previous || previous.instanceId !== entry.instanceId || previous.boss.hp > 0);
if (!justDefeated || rewardedBossInstances.current.has(entry.instanceId)) continue;
rewardedBossInstances.current.add(entry.instanceId);
const defeatedBefore = (state.round - 1) * bossCount + index + 1;
const rewardDifficulty = state.runMode === "roguelike" && defeatedBefore >= 5 ? "veteran" : state.difficultySlug;
recordBossVictory(entry.boss.id, rewardDifficulty);
}
});
}, [clearRecentRewards, recordBossVictory]);
return (
<main className="prototype-shell">
+23
View File
@@ -4,6 +4,13 @@ import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GLOBAL_COOLDOWN_SECONDS, abilityRemaining, barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
import { PARTY_ABILITY_NAMES, tankAuraProtects } from "../game/partyCombat";
import type { BottomTab, PartyMember } from "../game/types";
import { useFrontendStore } from "../frontend/store";
function RewardSummary() {
const rewards = useFrontendStore((state) => state.recentRewards);
if (!rewards.length) return null;
return <div className="reward-summary" aria-label="Boss rewards">{rewards.map((reward, index) => <span key={`${reward.coin.id}-${index}`}><b>{reward.coin.glyph}</b>{reward.coin.name} ×{reward.quantity}{reward.pet ? <i> + {reward.pet.name}</i> : null}</span>)}</div>;
}
function HealthBar({ member }: { member: PartyMember }) {
const health = Math.max(0, (member.hp / member.maxHp) * 100);
@@ -195,6 +202,7 @@ function EndPanel() {
<span><small>Party vitality</small><strong>{Math.round((totalHp / totalMax) * 100)}%</strong></span>
<span><small>Boss</small><strong>{phase === "victory" ? "Defeated" : "Standing"}</strong></span>
</div>
{phase === "victory" && <RewardSummary />}
<div className="end-actions">
<button onClick={() => { restart(); startEncounter(); }}>Run again</button>
<button className="secondary" onClick={restart}>Return to briefing</button>
@@ -203,9 +211,24 @@ function EndPanel() {
);
}
function IntermissionStatusPanel() {
const round = useGameStore((state) => state.round);
return (
<div className="intermission-status" aria-label={`Round ${round} cleared. Choose a blessing on the top display.`}>
<i></i>
<span>Round {round} cleared</span>
<h2>Choose on top display</h2>
<p>Next encounter stays locked until one blessing is claimed.</p>
<RewardSummary />
<small>Use D-pad to choose · A to claim</small>
</div>
);
}
function CombatPanel() {
const phase = useGameStore((state) => state.phase);
if (phase === "briefing") return <BriefingPanel />;
if (phase === "intermission") return <IntermissionStatusPanel />;
if (phase === "victory" || phase === "defeat") return <EndPanel />;
return <div className="combat-panel"><PartyList /><AbilityTray /></div>;
}
+44
View File
@@ -0,0 +1,44 @@
import { RUN_BUFFS, bossHealthMultiplier, countRunBuff } from "../game/roguelike";
import { useGameStore } from "../game/store";
export function BuffDraftPanel({ className = "" }: { className?: string }) {
const round = useGameStore((state) => state.round);
const runBuffs = useGameStore((state) => state.runBuffs);
const choices = useGameStore((state) => state.draftBuffIds);
const selected = useGameStore((state) => state.selectedRunBuffId);
const setSelected = useGameStore((state) => state.setSelectedRunBuff);
const choose = useGameStore((state) => state.chooseRunBuff);
const nextRound = round + 1;
return (
<div className={`buff-draft ${className}`.trim()} role="dialog" aria-modal="true" aria-label={`Choose a buff for round ${nextRound}`}>
<header>
<span>Round {round} cleared</span>
<h2>Choose one blessing</h2>
<p>Claim required. Round {nextRound} begins with two new bosses at {Math.round(bossHealthMultiplier(nextRound) * 100)}% base HP.</p>
</header>
<div className="buff-choice-grid">
{choices.map((buffId) => {
const buff = RUN_BUFFS[buffId];
const stacks = countRunBuff(runBuffs, buffId);
return (
<button
key={buffId}
className={selected === buffId ? "is-controller-focused" : ""}
style={{ "--buff-accent": buff.accent } as React.CSSProperties}
onFocus={() => setSelected(buffId)}
onPointerEnter={() => setSelected(buffId)}
onClick={() => choose(buffId)}
aria-pressed={selected === buffId}
>
<i>{buff.icon}</i>
<span><small>{stacks ? `${stacks} owned` : "New blessing"}</small><strong>{buff.name}</strong></span>
<b>{buff.summary}</b>
<p>{buff.detail}</p>
</button>
);
})}
</div>
<footer><b> / </b> Choose <i /> <b>A / ENTER</b> Claim</footer>
</div>
);
}
+13 -12
View File
@@ -1,36 +1,37 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
import { subscribeControllerToken } from "../input/controller";
export function DualDisplayFrame({ top, bottom }: { top: ReactNode; bottom: ReactNode }) {
const [activeSurface, setActiveSurface] = useState<DisplaySurface>("top");
const dedicatedSurface = new URLSearchParams(window.location.search).get("display");
const [activeSurface, setActiveSurface] = useState<DisplaySurface>(() => dedicatedSurface === "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;
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") 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();
};
}, []);
}, [dedicatedSurface]);
if (dedicatedSurface === "top" || dedicatedSurface === "bottom") {
return <div className={`dedicated-display-surface dedicated-${dedicatedSurface}`}>{dedicatedSurface === "top" ? top : bottom}</div>;
}
return (
<div className={`device-frame active-${activeSurface}`}>
+285 -52
View File
@@ -1,12 +1,35 @@
import { useMemo, useState } from "react";
import { MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName, selectRandomBossPair } from "../frontend/data";
import { useMemo, useRef, useState } from "react";
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { BossCollection, GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import { selectRandomBossPair } from "../game/roguelike";
import type { BossId } from "../game/types";
import {
GEAR_OWNER_LABELS,
GEAR_OWNER_ORDER,
GEAR_RECIPES,
GEAR_SLOT_LABELS,
GEAR_SLOT_ORDER,
GEAR_STAT_LABELS,
MAX_GEAR_LEVEL,
canAffordGearUpgrade,
gearBonusText,
gearUpgradeCosts,
} from "../game/progression/gear";
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossCoinDrop } from "../game/progression/loot";
import {
ACTIVE_INFUSION_MIN_GEAR_LEVEL,
PASSIVE_INFUSIONS,
PASSIVE_INFUSION_MIN_GEAR_LEVEL,
activeInfusionUnlocked,
infusionCosts,
infusionsForOwner,
passiveInfusionUnlocked,
} from "../game/progression/infusions";
import { requestDisplaySurface } from "../platform/displayRouting";
import { DualDisplayFrame } from "./DualDisplayFrame";
@@ -51,15 +74,24 @@ function ControllerLegend({ back = false }: { back?: boolean }) {
function LoginScreen() {
const signIn = useFrontendStore((state) => state.signIn);
const createAccount = useFrontendStore((state) => state.createAccount);
const continueOffline = useFrontendStore((state) => state.continueOffline);
const notice = useFrontendStore((state) => state.notice);
const [hunterId, setHunterId] = useState("wayfinder");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const actions = useMemo<MenuAction[]>(() => [
{ id: "sign-in", run: () => signIn(hunterId) },
{ id: "username", run: () => usernameRef.current?.focus() },
{ id: "password", run: () => passwordRef.current?.focus() },
{ id: "sign-in", run: () => { void signIn(username, password); } },
{ id: "create-account", run: () => { void createAccount(username, password); } },
{ id: "offline", run: continueOffline },
], [continueOffline, hunterId, signIn]);
], [continueOffline, createAccount, password, signIn, username]);
const controller = useMenuController(actions);
const submitSignIn = () => { void signIn(username, password); };
return (
<DualDisplayFrame
top={
@@ -71,17 +103,41 @@ function LoginScreen() {
<h1>Keep everyone standing.</h1>
<p>Your save always lives on this device. Sign in only when you want a second copy for PC AYN Thor handoff.</p>
</div>
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); signIn(hunterId); }}>
<label htmlFor="hunter-id">Hunter ID</label>
<input id="hunter-id" value={hunterId} onChange={(event) => setHunterId(event.target.value)} autoComplete="username" />
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
<label htmlFor="account-username">Username</label>
<input
ref={usernameRef}
id="account-username"
className={controller.focusedId === "username" ? "is-controller-focused" : ""}
value={username}
onChange={(event) => setUsername(event.target.value)}
onFocus={() => controller.focus("username")}
autoComplete="username"
required
/>
<label htmlFor="account-password">Password</label>
<input
ref={passwordRef}
id="account-password"
className={controller.focusedId === "password" ? "is-controller-focused" : ""}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onFocus={() => controller.focus("password")}
autoComplete="current-password"
required
/>
<FocusButton id="sign-in" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" type="submit">
<span>Sign in & sync</span><small>Online saves enabled</small>
</FocusButton>
<FocusButton id="create-account" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
<span>Create account</span><small>Required for first sync</small>
</FocusButton>
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue with offline save</span><small>No account required</small>
</FocusButton>
</form>
{notice && <div className="front-notice">{notice}</div>}
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
<ControllerLegend />
</FrontSurface>
}
@@ -92,8 +148,8 @@ function LoginScreen() {
<span className="context-kicker">How saving works</span>
<ol>
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
<li><b>02</b><span><strong>Sync when ready</strong><small>Upload any slot after signing in.</small></span></li>
<li><b>03</b><span><strong>Move devices</strong><small>Download the online copy and overwrite local.</small></span></li>
<li><b>02</b><span><strong>Create or sign in</strong><small>Username and password unlock online sync.</small></span></li>
<li><b>03</b><span><strong>Move devices</strong><small>Sign in, then upload or download an online copy.</small></span></li>
</ol>
</div>
<div className="device-route"><span>PC</span><i></i><b>ONLINE COPY</b><i></i><span>THOR</span></div>
@@ -297,15 +353,16 @@ function HomeScreen() {
{ id: "dungeons", run: () => selectMode("dungeons"), neighbors: { left: "roguelike-pve", down: "stadium-pvp" } },
{ id: "roguelike-pvp", run: () => selectMode("roguelike-pvp"), neighbors: { left: "roguelike-pve", right: "stadium-pvp", up: "roguelike-pve", down: "profile" } },
{ id: "stadium-pvp", run: () => selectMode("stadium-pvp"), neighbors: { left: "roguelike-pvp", up: "dungeons", down: "settings" } },
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "settings", down: "class-priest" } },
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "profile", down: "class-shaman" } },
{ id: "profile", run: () => navigate("profile"), neighbors: { up: "roguelike-pvp", right: "gear", down: "class-priest" } },
{ id: "gear", run: () => navigate("gear"), neighbors: { up: "roguelike-pvp", left: "profile", right: "settings", down: "class-druid" } },
{ id: "settings", run: () => navigate("settings"), neighbors: { up: "stadium-pvp", left: "gear", down: "class-shaman" } },
...HEALER_CLASS_ORDER.map((classId, index) => ({
id: `class-${classId}`,
run: () => selectHealerClass(classId),
neighbors: {
left: `class-${HEALER_CLASS_ORDER[(index + HEALER_CLASS_ORDER.length - 1) % HEALER_CLASS_ORDER.length]}`,
right: `class-${HEALER_CLASS_ORDER[(index + 1) % HEALER_CLASS_ORDER.length]}`,
up: index === 2 ? "settings" : "profile",
up: index === 0 ? "profile" : index === 1 ? "gear" : "settings",
down: "change-save",
},
})),
@@ -331,6 +388,7 @@ function HomeScreen() {
</div>
<div className="home-secondary-actions">
<FocusButton id="profile" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("profile")}><i></i><span><strong>Hunter Profile</strong><small>Stats & collection log</small></span><b></b></FocusButton>
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i></i><span><strong>Gear Upgrade</strong><small>Spend boss coins</small></span><b></b></FocusButton>
<FocusButton id="settings" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("settings")}><i></i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b></b></FocusButton>
</div>
<ControllerLegend back />
@@ -365,12 +423,13 @@ function HomeScreen() {
function ProfileScreen() {
const hunter = useActiveHunter();
const navigate = useFrontendStore((state) => state.navigate);
const [bossId, setBossId] = useState(hunter?.collections[0].bossId ?? "");
const collection = hunter?.collections.find((boss) => boss.bossId === bossId) ?? hunter?.collections[0];
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
const [bossId, setBossId] = useState(collections[0]?.bossId ?? "");
const collection = collections.find((boss) => boss.bossId === bossId) ?? collections[0];
const actions = useMemo<MenuAction[]>(() => [
...(hunter?.collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })) ?? []),
...collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })),
{ id: "back", run: () => navigate("home") },
], [hunter?.collections, navigate]);
], [collections, navigate]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
if (!hunter || !collection) return null;
const activeHealer = HEALER_CLASSES[hunter.activeClassId];
@@ -389,6 +448,7 @@ function ProfileScreen() {
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
<small>{drop.rarity}</small><strong>{drop.name}</strong>
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : "Defeat boss to reveal"}</p>
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
</article>
))}
</div>
@@ -404,9 +464,9 @@ function ProfileScreen() {
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
</div>
<div className="boss-log"><span>Boss records</span>{hunter.collections.map((boss) => (
<div className="boss-log"><span>Boss records</span>{collections.map((boss) => (
<FocusButton key={boss.bossId} id={boss.bossId} focusedId={controller.focusedId} focus={controller.focus} className={boss.bossId === collection.bossId ? "is-selected" : ""} onClick={() => setBossId(boss.bossId)}>
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossName] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossId] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
</FocusButton>
))}</div>
</FrontSurface>
@@ -415,6 +475,140 @@ function ProfileScreen() {
);
}
function GearScreen() {
const hunter = useActiveHunter();
const navigate = useFrontendStore((state) => state.navigate);
const notice = useFrontendStore((state) => state.notice);
const selectedOwnerId = useFrontendStore((state) => state.selectedGearOwnerId);
const selectedSlotId = useFrontendStore((state) => state.selectedGearSlotId);
const workshopMode = useFrontendStore((state) => state.gearWorkshopMode);
const selectedInfusionId = useFrontendStore((state) => state.selectedInfusionId);
const selectOwner = useFrontendStore((state) => state.selectGearOwner);
const selectSlot = useFrontendStore((state) => state.selectGearSlot);
const selectWorkshopMode = useFrontendStore((state) => state.selectGearWorkshopMode);
const selectInfusion = useFrontendStore((state) => state.selectInfusion);
const upgrade = useFrontendStore((state) => state.upgradeSelectedGear);
const installInfusion = useFrontendStore((state) => state.equipSelectedInfusion);
const installPassive = useFrontendStore((state) => state.equipPassiveInfusion);
const slot = hunter?.gearProgress[selectedOwnerId].slots[selectedSlotId];
const recipe = GEAR_RECIPES[selectedOwnerId][selectedSlotId];
const costs = hunter && slot ? gearUpgradeCosts(selectedOwnerId, selectedSlotId, slot.level) : [];
const canUpgrade = Boolean(hunter && slot && slot.level < MAX_GEAR_LEVEL && canAffordGearUpgrade(hunter.materials, costs));
const infusionChoices = infusionsForOwner(selectedOwnerId);
const selectedInfusion = infusionChoices.find((choice) => choice.id === selectedInfusionId) ?? infusionChoices[0];
const selectedInfusionCosts = hunter ? infusionCosts(selectedOwnerId, selectedSlotId, selectedInfusion.id) : [];
const activeUnlocked = Boolean(hunter && activeInfusionUnlocked(hunter.gearProgress[selectedOwnerId]));
const anchorUnlocked = Boolean(slot && slot.level >= ACTIVE_INFUSION_MIN_GEAR_LEVEL);
const infusionEquipped = hunter?.gearProgress[selectedOwnerId].infusionAbilityId === selectedInfusion.id;
const canInstallInfusion = Boolean(hunter && activeUnlocked && anchorUnlocked && !infusionEquipped && canAffordGearUpgrade(hunter.materials, selectedInfusionCosts));
const passiveUnlocked = Boolean(hunter && passiveInfusionUnlocked(hunter.gearProgress));
const healerOwner = selectedOwnerId === "priest" || selectedOwnerId === "druid" || selectedOwnerId === "shaman";
const previewEntryId = workshopMode === "upgrade" ? "upgrade" : `infusion-${infusionChoices[0].id}`;
const actions = useMemo<MenuAction[]>(() => [
...GEAR_OWNER_ORDER.map((ownerId, index) => ({
id: `owner-${ownerId}`,
run: () => selectOwner(ownerId),
neighbors: {
up: index === 0 ? "back" : `owner-${GEAR_OWNER_ORDER[index - 1]}`,
down: index === GEAR_OWNER_ORDER.length - 1 ? previewEntryId : `owner-${GEAR_OWNER_ORDER[index + 1]}`,
right: `slot-${selectedSlotId}`,
},
})),
...GEAR_SLOT_ORDER.map((slotId, index) => ({
id: `slot-${slotId}`,
run: () => selectSlot(slotId),
neighbors: {
up: index === 0 ? "back" : `slot-${GEAR_SLOT_ORDER[index - 1]}`,
down: index === GEAR_SLOT_ORDER.length - 1 ? previewEntryId : `slot-${GEAR_SLOT_ORDER[index + 1]}`,
left: `owner-${selectedOwnerId}`,
right: previewEntryId,
},
})),
{ id: "workshop-upgrade", run: () => selectWorkshopMode("upgrade"), neighbors: { right: "workshop-infusion", down: "slot-weapon" } },
{ id: "workshop-infusion", run: () => selectWorkshopMode("infusion"), neighbors: { left: "workshop-upgrade", down: `infusion-${infusionChoices[0].id}` } },
...infusionChoices.map((infusion, index) => ({
id: `infusion-${infusion.id}`,
run: () => selectInfusion(infusion.id),
neighbors: {
up: index === 0 ? "workshop-infusion" : `infusion-${infusionChoices[index - 1].id}`,
down: index === infusionChoices.length - 1 ? (healerOwner ? `passive-${PASSIVE_INFUSIONS[0].id}` : "install-infusion") : `infusion-${infusionChoices[index + 1].id}`,
left: `slot-${selectedSlotId}`,
},
})),
...(healerOwner ? PASSIVE_INFUSIONS.map((passive, index) => ({
id: `passive-${passive.id}`,
run: () => installPassive(passive.id),
enabled: passiveUnlocked,
neighbors: {
up: index === 0 ? `infusion-${infusionChoices[infusionChoices.length - 1].id}` : `passive-${PASSIVE_INFUSIONS[index - 1].id}`,
down: index === PASSIVE_INFUSIONS.length - 1 ? "install-infusion" : `passive-${PASSIVE_INFUSIONS[index + 1].id}`,
left: `slot-${selectedSlotId}`,
},
})) : []),
{ id: "upgrade", run: upgrade, enabled: canUpgrade, neighbors: { left: `slot-${selectedSlotId}`, up: `slot-${selectedSlotId}` } },
{ id: "install-infusion", run: installInfusion, enabled: canInstallInfusion, neighbors: { left: `slot-${selectedSlotId}`, up: healerOwner ? `passive-${PASSIVE_INFUSIONS[PASSIVE_INFUSIONS.length - 1].id}` : `infusion-${infusionChoices[infusionChoices.length - 1].id}` } },
{ id: "back", run: () => navigate("home"), neighbors: { down: `owner-${GEAR_OWNER_ORDER[0]}` } },
], [canInstallInfusion, canUpgrade, healerOwner, infusionChoices, installInfusion, installPassive, navigate, passiveUnlocked, previewEntryId, selectInfusion, selectOwner, selectSlot, selectWorkshopMode, selectedOwnerId, selectedSlotId, upgrade]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
if (!hunter || !slot) return null;
const currentBonus = gearBonusText(recipe.statId, slot.level);
const nextBonus = gearBonusText(recipe.statId, Math.min(MAX_GEAR_LEVEL, slot.level + 1));
return (
<DualDisplayFrame
top={
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
<header className="front-screen-header"><BrandMark compact /><div><span>Boss coin workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="gear-workshop-layout">
<section className="gear-owner-list" aria-label="Party gear owners">
{GEAR_OWNER_ORDER.map((ownerId) => {
const highest = Math.max(...GEAR_SLOT_ORDER.map((slotId) => hunter.gearProgress[ownerId].slots[slotId].level));
return <FocusButton key={ownerId} id={`owner-${ownerId}`} focusedId={controller.focusedId} focus={controller.focus} className={ownerId === selectedOwnerId ? "is-selected" : ""} onClick={() => selectOwner(ownerId)}><span><strong>{GEAR_OWNER_LABELS[ownerId]}</strong><small>Highest slot +{highest}</small></span><b>{ownerId === selectedOwnerId ? "✓" : ""}</b></FocusButton>;
})}
</section>
<section className="gear-slot-list" aria-label={`${GEAR_OWNER_LABELS[selectedOwnerId]} gear slots`}>
{GEAR_SLOT_ORDER.map((slotId) => {
const progress = hunter.gearProgress[selectedOwnerId].slots[slotId];
const slotRecipe = GEAR_RECIPES[selectedOwnerId][slotId];
return <FocusButton key={slotId} id={`slot-${slotId}`} focusedId={controller.focusedId} focus={controller.focus} className={slotId === selectedSlotId ? "is-selected" : ""} onClick={() => selectSlot(slotId)}><i>{slotId === "weapon" ? "⚔" : slotId === "helmet" ? "♙" : slotId === "chest" ? "▧" : slotId === "legs" ? "Ⅱ" : "⌁"}</i><span><strong>{GEAR_SLOT_LABELS[slotId]}</strong><small>{GEAR_STAT_LABELS[slotRecipe.statId]}</small></span><b>+{progress.level}</b></FocusButton>;
})}
</section>
{workshopMode === "upgrade" ? <article className="gear-preview">
<span>Selected upgrade</span>
<h2>{GEAR_OWNER_LABELS[selectedOwnerId]} · {GEAR_SLOT_LABELS[selectedSlotId]} +{slot.level}</h2>
<p>{GEAR_STAT_LABELS[recipe.statId]} from {BOSS_DEFINITIONS[recipe.primaryBossId].name} and {BOSS_DEFINITIONS[recipe.secondaryBossId].name} coins.</p>
<div className="gear-stat-comparison"><span><small>Current</small><strong>{currentBonus}</strong></span><i></i><span><small>{slot.level >= MAX_GEAR_LEVEL ? "Maximum" : `Rank +${slot.level + 1}`}</small><strong>{nextBonus}</strong></span></div>
</article> : <article className="gear-preview gear-infusion-preview">
<span>Active infusion · unlock +{ACTIVE_INFUSION_MIN_GEAR_LEVEL}</span>
<h2>{selectedInfusion.icon} {selectedInfusion.name}</h2>
<p>{selectedInfusion.description} Anchor purchase to a +{ACTIVE_INFUSION_MIN_GEAR_LEVEL} slot.</p>
<div className="gear-infusion-options">
{infusionChoices.map((infusion) => <FocusButton key={infusion.id} id={`infusion-${infusion.id}`} focusedId={controller.focusedId} focus={controller.focus} className={`${infusion.id === selectedInfusion.id ? "is-selected" : ""} ${hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "is-equipped" : ""}`} onClick={() => selectInfusion(infusion.id)}><i>{infusion.icon}</i><span><strong>{infusion.name}</strong><small>{infusion.description}</small></span><b>{hunter.gearProgress[selectedOwnerId].infusionAbilityId === infusion.id ? "✓" : ""}</b></FocusButton>)}
</div>
{healerOwner && <div className="gear-passive-options"><span>Passive · global +{PASSIVE_INFUSION_MIN_GEAR_LEVEL}</span>{PASSIVE_INFUSIONS.map((passive) => <FocusButton key={passive.id} id={`passive-${passive.id}`} focusedId={controller.focusedId} focus={controller.focus} disabled={!passiveUnlocked} className={hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "is-equipped" : ""} onClick={() => installPassive(passive.id)}><i>{passive.icon}</i><span><strong>{passive.name}</strong><small>{passive.summary}</small></span><b>{hunter.gearProgress[selectedOwnerId].passiveInfusionId === passive.id ? "✓" : ""}</b></FocusButton>)}</div>}
</article>}
</div>
<ControllerLegend back />
</FrontSurface>
}
bottom={
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} COINS</b></header>
<div className="gear-costs">
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
const owned = hunter.materials.find((item) => item.id === cost.itemId)?.quantity ?? 0;
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
}) : <article className="is-met"><i></i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
</div>
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend coins · autosave" : "Collect required boss coins"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend coins · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required boss coins"}</small></FocusButton>}
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
</FrontSurface>
}
/>
);
}
function SettingToggle({ id, label, copy, value, focusedId, focus, onClick }: { id: string; label: string; copy: string; value: boolean; focusedId: string; focus: (id: string) => void; onClick: () => void }) {
return <FocusButton id={id} focusedId={focusedId} focus={focus} className="setting-row" onClick={onClick}><span><strong>{label}</strong><small>{copy}</small></span><b className={value ? "is-on" : ""}>{value ? "ON" : "OFF"}</b></FocusButton>;
}
@@ -461,36 +655,65 @@ function SettingsScreen() {
);
}
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) {
function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) {
const hunter = useActiveHunter();
const modeId = useFrontendStore((state) => state.selectedMode);
const selectedBossId = useFrontendStore((state) => state.selectedBossId);
const selectedDifficultySlug = useFrontendStore((state) => state.selectedDifficultySlug);
const selectBoss = useFrontendStore((state) => state.selectBoss);
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
const navigate = useFrontendStore((state) => state.navigate);
const [message, setMessage] = useState("");
const mode = MODE_COPY[modeId];
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
const progress = hunter?.healers[hunter.activeClassId];
const selectedBoss = BOSS_DEFINITIONS[selectedBossId];
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
const isPve = modeId === "roguelike-pve";
const isDungeon = modeId === "dungeons";
const bossGridRows = Math.min(8, Math.ceil(BOSS_ORDER.length / 3));
const launch = () => {
if (isPve) return onLaunch(selectRandomBossPair());
if (isDungeon) return onLaunch([selectedBossId]);
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
setMessage("Online matchmaking connects here when game server is configured.");
};
const actions = useMemo<MenuAction[]>(() => [
...(isDungeon ? BOSS_ORDER.map((bossId, index) => ({
id: `boss-${bossId}`,
run: () => selectBoss(bossId),
...(isDungeon ? BOSS_ORDER.map((bossId, index) => {
const column = Math.floor(index / bossGridRows);
const row = index % bossGridRows;
const neighborInColumn = (targetColumn: number) => {
const columnStart = targetColumn * bossGridRows;
if (columnStart >= BOSS_ORDER.length || targetColumn < 0) return undefined;
const columnEnd = Math.min(columnStart + bossGridRows, BOSS_ORDER.length) - 1;
return `boss-${BOSS_ORDER[Math.min(columnStart + row, columnEnd)]}`;
};
return {
id: `boss-${bossId}`,
run: () => selectBoss(bossId),
neighbors: {
up: row > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
down: index + 1 < Math.min((column + 1) * bossGridRows, BOSS_ORDER.length)
? `boss-${BOSS_ORDER[index + 1]}`
: `difficulty-${DIFFICULTIES[0].slug}`,
left: neighborInColumn(column - 1),
right: neighborInColumn(column + 1),
},
};
}) : []),
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
id: `difficulty-${difficulty.slug}`,
run: () => selectDifficulty(difficulty.slug),
neighbors: {
up: index > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
down: index < BOSS_ORDER.length - 1 ? `boss-${BOSS_ORDER[index + 1]}` : "launch",
left: index > 0 ? `difficulty-${DIFFICULTIES[index - 1].slug}` : `boss-${selectedBossId}`,
right: index < DIFFICULTIES.length - 1 ? `difficulty-${DIFFICULTIES[index + 1].slug}` : "launch",
up: `boss-${selectedBossId}`,
down: "launch",
},
})) : []),
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `boss-${BOSS_ORDER[BOSS_ORDER.length - 1]}` } : { up: "back" } },
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${BOSS_ORDER[0]}` } : { down: "launch" } },
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectedBossId]);
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug]);
const controller = useMenuController(actions, { onBack: () => navigate("home") });
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
const contextRules = isDungeon
@@ -503,7 +726,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
? [
["Randomized pair", "Two distinct bosses are selected only when the run begins."],
["Dual-boss pressure", "Both guardians fight simultaneously and must be defeated."],
["Roguelike foundation", "Three-choice buff drafts are next in development."],
["Buff intermission", "Choose one of three stacking buffs after every cleared round."],
]
: [
["Draft a healing path", "Choose rites after every completed room."],
@@ -515,27 +738,35 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
top={
<FrontSurface className={`mode-surface mode-${modeId}`} ariaLabel={`${mode.title} details`}>
<header className="front-screen-header"><BrandMark compact /><div><span>Game mode</span><h1>{mode.title}</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
<div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
{isDungeon && (
<div className="boss-picker" aria-label="Choose boss encounter">
<span>Choose encounter</span>
{BOSS_ORDER.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId];
return (
<FocusButton
key={bossId}
id={`boss-${bossId}`}
focusedId={controller.focusedId}
focus={controller.focus}
className={`boss-choice ${selectedBossId === bossId ? "is-selected" : ""}`}
style={{ "--boss-accent": boss.accent } as React.CSSProperties}
aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(bossId)}
>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanics.join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
</FocusButton>
);
})}
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows } as React.CSSProperties}>
{BOSS_ORDER.map((bossId) => {
const boss = BOSS_DEFINITIONS[bossId];
return (
<FocusButton
key={bossId}
id={`boss-${bossId}`}
focusedId={controller.focusedId}
focus={controller.focus}
className={`boss-choice ${selectedBossId === bossId ? "is-selected" : ""}`}
style={{ "--boss-accent": boss.accent } as React.CSSProperties}
aria-pressed={selectedBossId === bossId}
onClick={() => selectBoss(bossId)}
>
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanics.join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
</FocusButton>
);
})}
</div>
</div>
)}
{isDungeon && (
<div className="difficulty-picker" aria-label="Choose encounter difficulty">
<span>Difficulty</span>
{DIFFICULTIES.map((difficulty) => <FocusButton key={difficulty.slug} id={`difficulty-${difficulty.slug}`} focusedId={controller.focusedId} focus={controller.focus} className={difficulty.slug === selectedDifficultySlug ? "is-selected" : ""} onClick={() => selectDifficulty(difficulty.slug)}><strong>{difficulty.name}</strong><small>iLvl {difficulty.itemLevel}</small></FocusButton>)}
</div>
)}
<FocusButton id="launch" focusedId={controller.focusedId} focus={controller.focus} className="mode-launch" onClick={launch}><span>{launchLabel}</span><small>{mode.status} · A</small></FocusButton>
@@ -544,21 +775,23 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => vo
}
bottom={
<FrontSurface className="mode-context" bottom ariaLabel={`${mode.title} preparation`}>
<header className="context-header"><span>Run preparation</span><b>{mode.status.toUpperCase()}</b></header>
<header className="context-header"><span>Run preparation</span><b>{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}</b></header>
{contextRules.map(([title, copy], index) => <div className="mode-rule" key={title}><i>0{index + 1}</i><span><strong>{title}</strong><small>{copy}</small></span></div>)}
<div className="mode-loadout"><span>Equipped role</span><b>{healer.specialization} · Level {progress?.level ?? 1}</b><small>6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready</small></div>
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossCoinDrop(selectedBossId, selectedDifficultySlug).name}</b><small>13 coins · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
</FrontSurface>
}
/>
);
}
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[]) => void }) {
export function FrontEnd({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], difficultySlug?: (typeof DIFFICULTIES)[number]["slug"]) => void }) {
const screen = useFrontendStore((state) => state.screen);
if (screen === "login") return <LoginScreen />;
if (screen === "saves") return <SaveScreen />;
if (screen === "home") return <HomeScreen />;
if (screen === "profile") return <ProfileScreen />;
if (screen === "gear") return <GearScreen />;
if (screen === "settings") return <SettingsScreen />;
if (screen === "mode") return <ModeScreen onLaunch={onLaunch} />;
return null;
+449 -85
View File
@@ -2,14 +2,31 @@ 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 { ARENA_CENTER, ARENA_WALL_RADIUS, clampToArena } from "../game/arena";
import {
isActorAnimationOneShot,
shouldStartActorAnimation,
type ActorAnimationState,
} from "../game/actorAnimation";
import { PERFORMANCE_PROBE_ENABLED, simulationTickSnapshot } from "../game/performance";
import { useGameStore } from "../game/store";
import type { MemberId, PulseKind } from "../game/types";
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 DRAGON_URL = new URL("../../game_assets/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href;
const INSECT_QUEEN_URL = new URL("../../game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.glb", import.meta.url).href;
const BLUE_EYES_WHITE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.glb", import.meta.url).href;
const GATE_GUARDIAN_URL = new URL("../../game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.glb", import.meta.url).href;
const GANDORA_URL = new URL("../../game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.glb", import.meta.url).href;
const RED_EYES_BLACK_URL = new URL("../../game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.glb", import.meta.url).href;
const PUMPKING_URL = new URL("../../game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.glb", import.meta.url).href;
const BLUE_EYES_ULTIMATE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.glb", import.meta.url).href;
const SANDGLASS_URL = new URL("../../game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../../game_assets/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../../game_assets/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../../game_assets/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
brann: new URL("../../game_assets/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
@@ -41,6 +58,19 @@ const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
orin: "Spellcast_Shoot",
vale: "Dualwield_Melee_Attack_Chop",
};
const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
const angle = (index / 10) * Math.PI * 2;
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
});
const ARENA_TORCH_COLORS = [new THREE.Color("#ff9a4f"), new THREE.Color("#77ddce")] as const;
const ARENA_WALL_SEGMENTS = Array.from({ length: 16 }, (_, index) => {
const angle = (index / 16) * Math.PI * 2;
return {
angle,
position: [Math.sin(angle) * ARENA_WALL_RADIUS, 1.15, ARENA_CENTER[1] + Math.cos(angle) * ARENA_WALL_RADIUS] as const,
};
});
const PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia">[] = ["brann", "nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function encounterBossAt(state: GameStoreState, bossIndex: number) {
@@ -59,8 +89,6 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string)
return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
}
type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop";
const PARTY_WEAPON_GRIPS: Record<MemberId, { right: WeaponGrip; left?: WeaponGrip }> = {
@@ -121,9 +149,11 @@ function prepareHeldWeapon(scene: THREE.Object3D, grip: WeaponGrip, side: "r" |
function PartyCharacterModel({
memberId,
animationState,
animationTrigger,
}: {
memberId: MemberId;
animationState: MutableRefObject<ActorAnimationState>;
animationTrigger: MutableRefObject<number>;
}) {
const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true);
const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
@@ -143,6 +173,8 @@ function PartyCharacterModel({
);
const { actions } = useAnimations(gltf.animations, actorScene);
const activeClip = useRef<string | undefined>(undefined);
const activeState = useRef<ActorAnimationState | undefined>(undefined);
const activeTrigger = useRef(Number.NaN);
useEffect(() => {
actorScene.traverse((object) => {
@@ -169,6 +201,7 @@ function PartyCharacterModel({
useFrame(() => {
const state = animationState.current;
const trigger = animationTrigger.current;
const clipName = state === "death"
? "Death_A"
: state === "hit"
@@ -182,20 +215,24 @@ function PartyCharacterModel({
: state === "attack"
? PARTY_ATTACK_CLIPS[memberId]
: "Idle";
if (activeClip.current === clipName) return;
if (!shouldStartActorAnimation(activeState.current, activeTrigger.current, state, trigger)) return;
const next = actions[clipName];
if (!next) return;
if (activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1).fadeIn(0.16);
if (state === "death" || state === "hit" || state === "attack" || state === "cast") {
const clipChanged = activeClip.current !== clipName;
if (clipChanged && activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1);
if (clipChanged) next.fadeIn(0.16);
if (isActorAnimationOneShot(state)) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = state === "death";
next.clampWhenFinished = true;
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
next.clampWhenFinished = false;
}
next.play();
activeClip.current = clipName;
activeState.current = state;
activeTrigger.current = trigger;
});
return (
@@ -208,11 +245,24 @@ function PartyCharacterModel({
}
function Arena() {
const columns = useMemo(() => {
return Array.from({ length: 10 }, (_, index) => {
const angle = (index / 10) * Math.PI * 2;
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
const pillarInstances = useRef<THREE.InstancedMesh>(null);
const torchInstances = useRef<THREE.InstancedMesh>(null);
useEffect(() => {
const pillars = pillarInstances.current;
const torches = torchInstances.current;
if (!pillars || !torches) return;
const matrix = new THREE.Matrix4();
ARENA_COLUMNS.forEach(([x, z], index) => {
matrix.makeTranslation(x, 1.1, z - 1);
pillars.setMatrixAt(index, matrix);
matrix.makeTranslation(x, 2.6, z - 1);
torches.setMatrixAt(index, matrix);
torches.setColorAt(index, ARENA_TORCH_COLORS[index % ARENA_TORCH_COLORS.length]);
});
pillars.instanceMatrix.needsUpdate = true;
torches.instanceMatrix.needsUpdate = true;
if (torches.instanceColor) torches.instanceColor.needsUpdate = true;
}, []);
return (
@@ -229,27 +279,48 @@ function Arena() {
<circleGeometry args={[2.1, 48]} />
<meshStandardMaterial color="#27342d" roughness={1} />
</mesh>
{columns.map(([x, z], index) => (
<group key={index} position={[x, 0, z - 1]}>
<mesh castShadow receiveShadow position={[0, 1.1, 0]}>
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
<meshStandardMaterial color="#26342f" roughness={0.8} />
</mesh>
<pointLight color={index % 2 ? "#dd7b38" : "#6fc9ba"} intensity={2.2} distance={5} position={[0, 2.7, 0]} />
<mesh position={[0, 2.6, 0]}>
<octahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial color={index % 2 ? "#ff9a4f" : "#77ddce"} />
</mesh>
</group>
))}
<instancedMesh ref={pillarInstances} args={[undefined, undefined, ARENA_COLUMNS.length]} castShadow receiveShadow>
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
<meshStandardMaterial color="#26342f" roughness={0.8} />
</instancedMesh>
<instancedMesh ref={torchInstances} args={[undefined, undefined, ARENA_COLUMNS.length]}>
<octahedronGeometry args={[0.2, 0]} />
<meshBasicMaterial />
</instancedMesh>
<ArenaWalls />
<pointLight color="#dd7b38" intensity={2.2} distance={7} position={[-6, 2.7, -1]} />
<pointLight color="#6fc9ba" intensity={2.2} distance={7} position={[6, 2.7, -1]} />
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
</group>
);
}
function ArenaWalls() {
const walls = useRef<THREE.Group>(null);
useFrame(({ camera }) => {
if (!walls.current) return;
for (const child of walls.current.children) {
const material = (child as THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>).material;
const cameraDistance = Math.hypot(camera.position.x - child.position.x, camera.position.z - child.position.z);
material.opacity = THREE.MathUtils.smoothstep(cameraDistance, 2.5, 7.5) * 0.52 + 0.06;
}
});
return (
<group ref={walls}>
{ARENA_WALL_SEGMENTS.map(({ angle, position }, index) => (
<mesh key={index} position={position} rotation={[0, angle, 0]} receiveShadow>
<boxGeometry args={[3.86, 2.3, 0.18]} />
<meshStandardMaterial color="#263a34" roughness={0.9} transparent opacity={0.58} depthWrite={false} />
</mesh>
))}
</group>
);
}
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
const animationTrigger = useRef(0);
useEffect(() => {
const start = useGameStore.getState().partyPositions[memberId];
group.current?.position.set(start[0], 0.025, start[1]);
@@ -273,6 +344,13 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
const attacking = state.phase === "combat"
&& visualAction !== null
&& visualAction.endsAt > state.time;
animationTrigger.current = member.hp <= 0
? 0
: knocked
? member.knockedUntil
: attacking
? visualAction.startedAt
: 0;
animationState.current = member.hp <= 0
? "death"
: knocked
@@ -298,7 +376,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
return (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId={memberId} animationState={animationState} />
<PartyCharacterModel memberId={memberId} animationState={animationState} animationTrigger={animationTrigger} />
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
@@ -312,6 +390,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
function PlayerCharacter() {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
const animationTrigger = useRef(0);
const keys = useRef(new Set<string>());
const scenePulse = useGameStore((state) => state.scenePulse);
const selected = useGameStore((state) => state.selectedMemberId === "aelia");
@@ -319,6 +398,7 @@ function PlayerCharacter() {
const { camera } = useThree();
const broadcastTimer = useRef(0);
const castingUntil = useRef(0);
const instantCastTrigger = useRef(0);
const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
@@ -329,6 +409,7 @@ function PlayerCharacter() {
useEffect(() => {
if (["renew", "shield", "purify", "radiance", "barrier"].includes(scenePulse.kind)) {
castingUntil.current = performance.now() + 700;
instantCastTrigger.current = scenePulse.id;
}
}, [scenePulse]);
@@ -342,8 +423,9 @@ function PlayerCharacter() {
const nudgeX = Number(key === "d") - Number(key === "a");
const nudgeZ = Number(key === "s") - Number(key === "w");
if (!nudgeX && !nudgeZ) return;
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + nudgeX * 0.18, -7.2, 7.2);
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + nudgeZ * 0.18, -4.8, 7.2);
const next = clampToArena([group.current.position.x + nudgeX * 0.18, group.current.position.z + nudgeZ * 0.18]);
group.current.position.x = next[0];
group.current.position.z = next[1];
setPlayerPosition([group.current.position.x, group.current.position.z]);
};
const up = (event: KeyboardEvent) => keys.current.delete(event.key.toLowerCase());
@@ -365,17 +447,16 @@ 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) {
const speed = 4.6 * delta / Math.max(1, length);
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + inputX * speed, -7.2, 7.2);
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + inputZ * speed, -4.8, 7.2);
const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length);
const next = clampToArena([group.current.position.x + inputX * speed, group.current.position.z + inputZ * speed]);
group.current.position.x = next[0];
group.current.position.z = next[1];
group.current.rotation.y = Math.atan2(inputX, inputZ);
} else if (state.phase === "combat" && player.hp > 0 && !knocked) {
const boss = targetBossMotion(state).position;
@@ -386,6 +467,16 @@ function PlayerCharacter() {
);
group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
}
const instantCasting = performance.now() < castingUntil.current;
animationTrigger.current = player.hp <= 0
? 0
: knocked
? player.knockedUntil
: state.activeCast
? state.activeCast.startedAt
: instantCasting
? instantCastTrigger.current
: 0;
animationState.current = player.hp <= 0
? "death"
: knocked
@@ -394,7 +485,7 @@ function PlayerCharacter() {
? "cast"
: length > 0.05
? "run"
: performance.now() < castingUntil.current
: instantCasting
? "cast"
: "idle";
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
@@ -412,7 +503,7 @@ function PlayerCharacter() {
return (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId="aelia" animationState={animationState} />
<PartyCharacterModel memberId="aelia" animationState={animationState} animationTrigger={animationTrigger} />
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
@@ -425,16 +516,15 @@ function PlayerCharacter() {
}
function Party() {
const party = useGameStore((state) => state.party);
const selected = useGameStore((state) => state.selectedMemberId);
return (
<>
<PlayerCharacter />
{party.slice(1).map((member) => (
{PARTY_MEMBER_IDS.map((memberId) => (
<Character
key={member.id}
memberId={member.id as Exclude<MemberId, "aelia">}
selected={selected === member.id}
key={memberId}
memberId={memberId}
selected={selected === memberId}
/>
))}
</>
@@ -464,7 +554,7 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
return (
<mesh castShadow position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : bossId === "sandglass-scorpion" ? "#b78b32" : bossId === "ember-mantis-duelist" || bossId === "cinderback-ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
);
}
@@ -553,33 +643,217 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
);
}
type AlternateBossKind = "vexa" | "cindermaw";
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["boss"]["id"], "bulldrome">;
const ALTERNATE_BOSS_CONFIG = {
vexa: {
url: SPIDER_URL,
scale: 0.022,
idle: "Spider_Armature|warte_pose",
move: "Spider_Armature|run_ani_vor",
attack: "Spider_Armature|Attack",
special: "Spider_Armature|Jump",
death: "Spider_Armature|die",
url: INSECT_QUEEN_URL,
scale: 9,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#bb67ff",
rotationOffset: Math.PI,
rotationOffset: 0,
prototype: true,
},
cindermaw: {
url: DRAGON_URL,
scale: 1.15,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Headbutt",
special: "Punch",
url: BLUE_EYES_WHITE_URL,
scale: 10,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff8742",
rotationOffset: 0,
prototype: true,
},
"ember-mantis-duelist": {
url: GATE_GUARDIAN_URL,
scale: 4.3,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff5a24",
rotationOffset: 0,
prototype: true,
},
"obsidian-ram-golem": {
url: GANDORA_URL,
scale: 6.2,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff7438",
rotationOffset: 0,
prototype: true,
},
"cinderback-ricochet": {
url: RED_EYES_BLACK_URL,
scale: 10,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#ff8b3d",
rotationOffset: 0,
prototype: true,
},
"sandglass-scorpion": {
url: SANDGLASS_URL,
scale: 0.7,
idle: "Idle",
move: "Burrow",
attack: "Eruption",
special: "Hourglass",
death: "Death",
light: "#e9b94f",
rotationOffset: 0,
},
"cragclaw-crab": {
url: CRAGCLAW_URL,
scale: 1.2,
idle: "Idle",
move: "Walk",
attack: "Bite_Front",
special: "Bite_InPlace",
death: "Death",
light: "#49d5df",
rotationOffset: 0,
},
"pumpking-king-of-ghosts": {
url: PUMPKING_URL,
scale: 1.5,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#d87842",
rotationOffset: 0,
prototype: true,
},
"blue-eyes-ultimate-dragon": {
url: BLUE_EYES_ULTIMATE_URL,
scale: 4.5,
idle: "Idle",
move: "Move",
attack: "Attack",
special: "Attack",
death: "Death",
light: "#8fc8ff",
rotationOffset: 0,
prototype: true,
},
"mournveil-ghost": {
url: MOURNVEIL_URL,
scale: 1.1,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Punch",
special: "Headbutt",
death: "Death",
light: "#9d72ff",
rotationOffset: 0,
},
"crownshard-golem": {
url: CROWNSHARD_URL,
scale: 1.15,
idle: "Flying_Idle",
move: "Fast_Flying",
attack: "Punch",
special: "Headbutt",
death: "Death",
light: "#e0bd45",
rotationOffset: 0,
},
} as const;
const PROTOTYPE_MOVE_MODES = [
"skyfall",
"mantis_sidestep",
"ram_charging",
"cinderback_ricochet",
] as const;
const PROTOTYPE_ATTACK_MODES = [
"tethering",
"venom_cast",
"breath_telegraph",
"breath_sweeping",
"mantis_line_telegraph",
"mantis_cross_telegraph",
"ram_charge_telegraph",
"ram_quake",
"ram_shatter",
"cinderback_curl",
"cinderback_slam",
"ghost_soul_cross",
"ghost_soul_cross_followup",
"ghost_haunting",
"golem_shockwave",
"golem_crownfall",
] as const;
function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeof useGameStore.getState>["bossMotion"]["mode"]) {
const config = ALTERNATE_BOSS_CONFIG[kind];
if ("prototype" in config && config.prototype) {
if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move;
if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack;
return config.idle;
}
if (kind === "ember-mantis-duelist") {
if (motionMode === "mantis_sidestep") return config.move;
if (motionMode === "mantis_line_telegraph") return config.attack;
if (motionMode === "mantis_cross_telegraph") return config.special;
if (motionMode === "mantis_recover") return "Recover";
}
if (kind === "obsidian-ram-golem") {
if (motionMode === "ram_charge_telegraph" || motionMode === "ram_charging") return config.attack;
if (motionMode === "ram_quake") return config.special;
if (motionMode === "ram_shatter") return "ArmorShatter";
if (motionMode === "ram_recover") return "Stagger";
}
if (kind === "cinderback-ricochet") {
if (motionMode === "cinderback_curl") return config.attack;
if (motionMode === "cinderback_ricochet") return config.move;
if (motionMode === "cinderback_slam") return config.special;
if (motionMode === "cinderback_recover") return "Recover";
}
if (kind === "sandglass-scorpion") {
if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move;
if (motionMode === "sandglass_eruption") return config.attack;
if (motionMode === "sandglass_hourglass") return config.special;
if (motionMode === "sandglass_recover") return "Stagger";
}
if (kind === "cragclaw-crab") {
if (motionMode === "crab_scuttling") return config.move;
if (motionMode === "crab_scuttle_telegraph") return config.attack;
if (motionMode === "crab_tidal_burst") return config.special;
}
if (kind === "mournveil-ghost") {
if (motionMode === "ghost_soul_cross" || motionMode === "ghost_soul_cross_followup") return config.attack;
if (motionMode === "ghost_haunting") return config.special;
}
if (kind === "crownshard-golem") {
if (motionMode === "golem_shockwave") return config.attack;
if (motionMode === "golem_crownfall") return config.special;
}
if (kind === "cindermaw") {
if (motionMode === "skyfall") return config.move;
if (motionMode === "breath_telegraph" || motionMode === "breath_sweeping") return config.special;
}
if (kind === "vexa" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
return config.idle;
}
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
const config = ALTERNATE_BOSS_CONFIG[kind];
const phase = useGameStore((state) => state.phase);
@@ -599,35 +873,29 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
object.receiveShadow = true;
}
});
if (kind === "vexa") {
const authoredHelperBox = model.getObjectByName("Box");
if (authoredHelperBox) authoredHelperBox.visible = false;
}
}, [kind, model]);
const clipName = phase === "victory" || defeated
? config.death
: motionMode === "skyfall"
? config.move
: motionMode === "breath_telegraph" || motionMode === "breath_sweeping"
? config.special
: motionMode === "tethering" || motionMode === "venom_cast"
? config.attack
: config.idle;
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motionMode);
useEffect(() => {
const next = actions[clipName];
if (!next) return;
for (const action of Object.values(actions)) action?.fadeOut(0.16);
next.reset().setEffectiveWeight(1).fadeIn(0.16).play();
if (phase === "victory" || defeated) {
const timeScale = kind === "ember-mantis-duelist" && motionMode === "mantis_line_telegraph"
? 0.55
: kind === "ember-mantis-duelist" && motionMode === "mantis_cross_telegraph"
? 0.6
: 1;
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play();
const authoredOneShot = ![config.idle, config.move].includes(clipName as never) || kind === "ember-mantis-duelist" && clipName !== config.idle;
if (phase === "victory" || defeated || authoredOneShot) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true;
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
}
return () => { next.fadeOut(0.16); };
}, [actions, clipName, defeated, phase]);
}, [actions, clipName, config.idle, defeated, kind, motionMode, phase]);
useFrame((_, delta) => {
if (!group.current) return;
@@ -636,7 +904,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
if (!current) return;
const motion = current.motion;
const airborne = kind === "cindermaw" && motion.mode === "skyfall";
targetPosition.set(motion.position[0], airborne ? 3.2 : 0.03, motion.position[1]);
const burrowed = kind === "sandglass-scorpion" && motion.mode === "sandglass_burrowing";
const floatingHeight = kind === "mournveil-ghost" || kind === "crownshard-golem" ? 0.2 : 0.03;
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
let targetAngle = Math.atan2(
@@ -645,6 +915,15 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
);
if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
targetAngle = motion.breathAngle;
} else if (kind === "ember-mantis-duelist" && (
motion.mode === "mantis_sidestep"
|| motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_cross_telegraph"
)) {
const target = state.partyPositions[motion.chargeTargetId];
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
} else if (["ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"].includes(motion.mode)) {
targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
}
const difference = Math.atan2(
Math.sin(targetAngle - group.current.rotation.y),
@@ -829,19 +1108,104 @@ function RangedProjectiles() {
function BossActor() {
const phase = useGameStore((state) => state.phase);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
const primaryBossId = useGameStore((state) => state.boss.id);
const additionalBossIds = useGameStore((state) => state.additionalBosses.map((entry) => entry.boss.id).join("|"));
if (phase === "briefing") return null;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const bossIds = additionalBossIds ? [primaryBossId, ...additionalBossIds.split("|")] : [primaryBossId];
return (
<>{bosses.map((boss, bossIndex) => (
<Suspense key={`${boss.id}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
{boss.id === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={boss.id} bossIndex={bossIndex} />}
<>{bossIds.map((bossId, bossIndex) => (
<Suspense key={`${bossId}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
{bossId === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={bossId as AlternateBossKind} bossIndex={bossIndex} />}
</Suspense>
))}</>
);
}
type PerformanceMemory = Performance & {
memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number };
};
function percentile(sorted: readonly number[], ratio: number) {
if (!sorted.length) return 0;
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
}
function PerformanceProbe() {
const { gl } = useThree();
const frameSamples = useRef<number[]>([]);
const longTaskCount = useRef(0);
const longTaskDuration = useRef(0);
const lastPublishAt = useRef(0);
useEffect(() => {
if (!PERFORMANCE_PROBE_ENABLED || typeof PerformanceObserver === "undefined") return;
let observer: PerformanceObserver | undefined;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longTaskCount.current += 1;
longTaskDuration.current += entry.duration;
}
});
observer.observe({ type: "longtask", buffered: true });
} catch {
// Long Tasks API is optional on Android WebView implementations.
}
return () => {
observer?.disconnect();
delete document.documentElement.dataset.gamePerf;
};
}, []);
useFrame(({ clock }, delta) => {
if (!PERFORMANCE_PROBE_ENABLED) return;
const samples = frameSamples.current;
if (samples.length === 300) samples.shift();
samples.push(delta * 1000);
if (clock.elapsedTime - lastPublishAt.current < 1 || samples.length < 30) return;
lastPublishAt.current = clock.elapsedTime;
const sorted = [...samples].sort((left, right) => left - right);
let total = 0;
let overBudget = 0;
for (const duration of samples) {
total += duration;
if (duration > 16.67) overBudget += 1;
}
const memory = performance as PerformanceMemory;
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
let transferredBytes = 0;
let decodedBytes = 0;
for (const resource of resources) {
transferredBytes += resource.transferSize;
decodedBytes += resource.decodedBodySize;
}
document.documentElement.dataset.gamePerf = JSON.stringify({
frame: {
averageMs: total / samples.length,
p95Ms: percentile(sorted, 0.95),
p99Ms: percentile(sorted, 0.99),
overBudget,
samples: samples.length,
},
renderer: {
calls: gl.info.render.calls,
triangles: gl.info.render.triangles,
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
},
simulation: simulationTickSnapshot(),
memory: memory.memory ? {
usedJSHeapSize: memory.memory.usedJSHeapSize,
totalJSHeapSize: memory.memory.totalJSHeapSize,
jsHeapSizeLimit: memory.memory.jsHeapSizeLimit,
} : null,
resources: { transferredBytes, decodedBytes, count: resources.length },
longTasks: { count: longTaskCount.current, durationMs: longTaskDuration.current },
});
});
return null;
}
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
const ring = useRef<THREE.Mesh>(null);
const material = useRef<THREE.MeshBasicMaterial>(null);
@@ -850,7 +1214,7 @@ function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
const state = useGameStore.getState();
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
useFrame((_, delta) => {
age.current += delta;
if (!ring.current || !material.current) return;
@@ -894,11 +1258,11 @@ export function GameScene() {
<BossActor />
<RangedProjectiles />
<CombatFx />
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
</Canvas>
);
}
useGLTF.preload(BULL_URL, false, true);
for (const modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true);
for (const loadout of Object.values(PARTY_WEAPON_URLS)) {
useGLTF.preload(loadout.right, false, true);
+15 -3
View File
@@ -3,6 +3,7 @@ import { HEALER_CLASSES } from "../game/healers";
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
import { GameScene } from "./GameScene";
import { tankAuraProtects } from "../game/partyCombat";
import { BuffDraftPanel } from "./BuffDraftPanel";
function CompactParty() {
const party = useGameStore((state) => state.party);
@@ -106,12 +107,21 @@ function PhaseOverlay() {
const phase = useGameStore((state) => state.phase);
const primaryBoss = useGameStore((state) => state.boss);
const additionalBosses = useGameStore((state) => state.additionalBosses);
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
const bossNames = bosses.map((boss) => boss.name).join(" & ");
if (phase === "combat") return null;
const title = phase === "briefing" ? definitions.map((boss) => boss.title).join(" & ") : phase === "victory" ? `${bossNames} Broken` : "Party Broken";
const eyebrow = phase === "briefing" ? (bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial) : phase === "victory" ? "Encounter Complete" : "Encounter Failed";
const title = phase === "briefing"
? definitions.map((boss) => boss.title).join(" & ")
: phase === "victory"
? `${bossNames} Broken`
: "Party Broken";
const eyebrow = phase === "briefing"
? (bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial)
: phase === "victory"
? "Encounter Complete"
: "Encounter Failed";
const copy = phase === "briefing"
? definitions.map((boss) => boss.briefing).join(" ")
: phase === "victory"
@@ -167,6 +177,8 @@ function PauseOverlay({ onExit }: { onExit?: () => void }) {
export function TopScreen({ onExit }: { onExit?: () => void }) {
const phase = useGameStore((state) => state.phase);
const bossCount = useGameStore((state) => state.additionalBosses.length + 1);
const round = useGameStore((state) => state.round);
const runMode = useGameStore((state) => state.runMode);
const setPaused = useGameStore((state) => state.setPaused);
return (
<section className="display top-display" aria-label="Main game viewport">
@@ -175,7 +187,7 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
<div className="top-hud">
<CompactParty />
<BossBar />
<div className="objective-chip"><span>Objective</span><strong>{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<div className="objective-chip"><span>{runMode === "roguelike" ? `Round ${round}` : "Objective"}</span><strong>{bossCount > 1 ? "Defeat both · keep five alive" : "Keep all five alive"}</strong></div>
<EncounterCallout />
<CastingBar />
<div className="control-hint"><b>WASD</b> Move <i /> <b>Q / E</b> Target <i /> <b>16</b> Cast</div>
+80 -6
View File
@@ -8,6 +8,11 @@ import { useGameStore } from "../../game/store";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const EMPTY_HAZARDS: never[] = [];
const EMPTY_SLASH_LANES: never[] = [];
const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]);
const DANGER_WARNING_COLOR = "#ff3b30";
const DANGER_ACTIVE_COLOR = "#d4142a";
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function motionAt(state: GameStoreState, bossIndex: number) {
@@ -36,7 +41,7 @@ export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
0.045,
(motion.chargeStart[1] + motion.chargeEnd[1]) / 2,
];
const color = motionMode === "charging" ? "#ffb04a" : "#ff4f37";
const color = motionMode === "charging" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
const laneWidth = BULL_CHARGE.hitRadius * 2;
return (
<>
@@ -68,6 +73,59 @@ export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
);
}
function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex: number }) {
const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex));
const material = useRef<THREE.MeshBasicMaterial>(null);
const edgeMaterial = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!material.current || !edgeMaterial.current) return;
const current = motionAt(useGameStore.getState(), bossIndex);
const active = current ? ACTIVE_LANE_MODES.has(current.mode) : false;
material.current.opacity = active ? 0.5 : 0.14 + (Math.sin(clock.elapsedTime * 12) + 1) * 0.09;
edgeMaterial.current.opacity = active ? 1 : 0.62 + (Math.sin(clock.elapsedTime * 12) + 1) * 0.15;
});
const lane = motion?.slashLanes.find((candidate) => candidate.id === laneId);
if (!lane || phase !== "combat") return null;
const dx = lane.end[0] - lane.start[0];
const dz = lane.end[1] - lane.start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
const midpoint: [number, number, number] = [
(lane.start[0] + lane.end[0]) * 0.5,
0.052,
(lane.start[1] + lane.end[1]) * 0.5,
];
const active = ACTIVE_LANE_MODES.has(motion.mode);
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={midpoint} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[lane.width, length]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
<boxGeometry args={[0.07, 0.03, length]} />
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
</mesh>
))}
{active && (
<mesh position={[0, 0.055, 0]}>
<boxGeometry args={[0.16, 0.08, length]} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.92} depthWrite={false} />
</mesh>
)}
</group>
);
}
export function SlashLaneIndicators({ bossIndex = 0 }: { bossIndex?: number }) {
const lanes = useGameStore((state) => motionAt(state, bossIndex)?.slashLanes ?? EMPTY_SLASH_LANES);
return <>{lanes.map((lane) => <SlashLaneIndicator key={lane.id} laneId={lane.id} bossIndex={bossIndex} />)}</>;
}
export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
@@ -151,7 +209,7 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
if (material.current) material.current.opacity = 0.2 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.07;
});
if (!motion || phase !== "combat" || (motion.mode !== "breath_telegraph" && motion.mode !== "breath_sweeping")) return null;
const color = motion.mode === "breath_sweeping" ? "#ff7b2e" : "#ffb04f";
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
return (
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - CINDER_BREATH.halfAngle]}>
@@ -176,12 +234,27 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
});
if (!hazard) return null;
const active = time >= hazard.activatesAt;
const venom = hazard.kind === "venom_pool";
const color = venom ? "#a94ee6" : active ? "#ff642d" : "#ffc14d";
const colors = {
venom_pool: "#a94ee6",
skyfall: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
quake: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
lava_pool: "#ff5a24",
stinger_eruption: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
hourglass: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR,
tidal_burst: active ? DANGER_ACTIVE_COLOR : "#39c9df",
soul_rift: active ? "#7446d8" : "#aa78ff",
crownfall: active ? DANGER_ACTIVE_COLOR : "#e4c548",
royal_shockwave: active ? DANGER_ACTIVE_COLOR : "#f0ca4d",
} as const;
const color = colors[hazard.kind];
return (
<group position={[hazard.center[0], 0.065, hazard.center[1]]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[hazard.radius, 40]} />
{hazard.innerRadius ? (
<ringGeometry args={[hazard.innerRadius, hazard.radius, 48]} />
) : (
<circleGeometry args={[hazard.radius, 40]} />
)}
<meshBasicMaterial ref={material} color={color} transparent opacity={active ? 0.24 : 0.16} depthWrite={false} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
@@ -191,7 +264,7 @@ function CircleHazardIndicator({ hazardId, bossIndex }: { hazardId: string; boss
{!active && (
<mesh position={[0, 0.03, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.22, 0.34, 24]} />
<meshBasicMaterial color="#fff0b0" transparent opacity={0.95} depthWrite={false} />
<meshBasicMaterial color={DANGER_HIGHLIGHT_COLOR} transparent opacity={0.95} depthWrite={false} />
</mesh>
)}
</group>
@@ -205,6 +278,7 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
ChargeLaneIndicator,
SlashLaneIndicators,
PounceStackIndicator,
BindingWebIndicator,
BreathConeIndicator,
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { StorageAdapter } from "./saveRepository";
import { AccountRepository } from "./accountRepository";
function memoryStorage(): StorageAdapter {
const data = new Map<string, string>();
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => { data.set(key, value); },
};
}
const testHasher = async (password: string, salt: string) => {
const checksum = [...password].reduce((total, character) => total + character.charCodeAt(0), 0);
return `derived:${salt}:${checksum}`;
};
describe("AccountRepository", () => {
it("requires both a username and password", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
await expect(repository.create("", "secret")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
await expect(repository.create("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
await expect(repository.authenticate("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
});
it("requires account creation before sign-in", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: false, reason: "account-not-found" });
await expect(repository.create("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
});
it("rejects an incorrect password and duplicate account names", async () => {
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
await repository.create("Wayfinder", "correct");
await expect(repository.authenticate("wayfinder", "wrong")).resolves.toEqual({ ok: false, reason: "invalid-password" });
await expect(repository.create(" wayfinder ", "another")).resolves.toEqual({ ok: false, reason: "account-exists" });
});
it("persists only a derived password verifier", async () => {
const storage = memoryStorage();
const repository = new AccountRepository(storage, testHasher, () => "unique-salt");
await repository.create("healer", "plaintext-secret");
const persisted = storage.getItem("i-want-to-heal:accounts:v1") ?? "";
expect(persisted).toContain("derived:unique-salt:");
expect(persisted).not.toContain("plaintext-secret");
expect(JSON.parse(persisted).healer).not.toHaveProperty("password");
});
});
+132
View File
@@ -0,0 +1,132 @@
import type { StorageAdapter } from "./saveRepository";
const ACCOUNTS_KEY = "i-want-to-heal:accounts:v1";
const PASSWORD_ITERATIONS = 120_000;
interface AccountRecord {
username: string;
salt: string;
passwordHash: string;
}
type AccountMap = Record<string, AccountRecord>;
type PasswordHasher = (password: string, salt: string) => Promise<string>;
export type AccountResult =
| { ok: true; username: string }
| { ok: false; reason: "missing-credentials" | "account-exists" | "account-not-found" | "invalid-password" | "storage-unavailable" };
const fallbackMemory = new Map<string, string>();
const fallbackStorage: StorageAdapter = {
getItem: (key) => fallbackMemory.get(key) ?? null,
setItem: (key, value) => { fallbackMemory.set(key, value); },
};
function browserStorage(): StorageAdapter {
try {
if (typeof localStorage !== "undefined") return localStorage;
} catch {
// Android WebView can deny storage before its host is ready.
}
return fallbackStorage;
}
function encodeBytes(bytes: Uint8Array) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function decodeBytes(value: string) {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
async function hashPassword(password: string, salt: string) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits({
name: "PBKDF2",
hash: "SHA-256",
salt: decodeBytes(salt),
iterations: PASSWORD_ITERATIONS,
}, key, 256);
return encodeBytes(new Uint8Array(bits));
}
function randomSalt() {
const salt = new Uint8Array(16);
crypto.getRandomValues(salt);
return encodeBytes(salt);
}
function canonicalUsername(username: string) {
return username.trim().toLocaleLowerCase();
}
function parseAccounts(raw: string | null): AccountMap {
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as AccountMap;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
/**
* Local prototype account registry. Passwords are salted and derived before
* persistence; replace this adapter with the server authentication API when
* remote sync leaves local prototype storage.
*/
export class AccountRepository {
constructor(
private readonly storage: StorageAdapter = browserStorage(),
private readonly hasher: PasswordHasher = hashPassword,
private readonly createSalt: () => string = randomSalt,
) {}
async create(usernameInput: string, password: string): Promise<AccountResult> {
const username = usernameInput.trim();
const canonical = canonicalUsername(username);
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
const accounts = this.read();
if (accounts[canonical]) return { ok: false, reason: "account-exists" };
try {
const salt = this.createSalt();
accounts[canonical] = { username, salt, passwordHash: await this.hasher(password, salt) };
this.storage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
return { ok: true, username };
} catch {
return { ok: false, reason: "storage-unavailable" };
}
}
async authenticate(usernameInput: string, password: string): Promise<AccountResult> {
const canonical = canonicalUsername(usernameInput);
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
const account = this.read()[canonical];
if (!account) return { ok: false, reason: "account-not-found" };
try {
const passwordHash = await this.hasher(password, account.salt);
return passwordHash === account.passwordHash
? { ok: true, username: account.username }
: { ok: false, reason: "invalid-password" };
} catch {
return { ok: false, reason: "storage-unavailable" };
}
}
private read() {
return parseAccounts(this.storage.getItem(ACCOUNTS_KEY));
}
}
+18 -5
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { MODE_COPY, selectRandomBoss, selectRandomBossPair } from "./data";
import { buildCollections, MODE_COPY, selectRandomBoss } from "./data";
import { selectRandomBossPair } from "../game/roguelike";
import { BOSS_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
import { BOSS_ORDER } from "../game/bossCatalog";
describe("game mode configuration", () => {
it("separates randomized PVE from selectable Dungeons", () => {
@@ -8,15 +11,25 @@ describe("game mode configuration", () => {
});
it("selects a boss across the full encounter pool", () => {
expect(selectRandomBoss(() => 0)).toBe("bulldrome");
expect(selectRandomBoss(() => 0.34)).toBe("vexa");
expect(selectRandomBoss(() => 0.99)).toBe("cindermaw");
for (let index = 0; index < BOSS_ORDER.length; index += 1) {
expect(selectRandomBoss(() => (index + 0.5) / BOSS_ORDER.length)).toBe(BOSS_ORDER[index]);
}
});
it("selects two distinct bosses for PVE", () => {
const values = [0, 0];
const pair = selectRandomBossPair(() => values.shift() ?? 0);
const pair = selectRandomBossPair([], () => values.shift() ?? 0);
expect(pair).toEqual(["bulldrome", "vexa"]);
expect(new Set(pair)).toHaveLength(2);
});
it("derives collection entries from canonical boss drop tables", () => {
const collections = buildCollections(createEmptyCollectionLog(), {});
expect(collections.map((boss) => boss.bossId)).toEqual(BOSS_ORDER);
for (const collection of collections) {
expect(collection.drops.map((drop) => drop.id)).toEqual(
BOSS_DROP_TABLES[collection.bossId as keyof typeof BOSS_DROP_TABLES].entries.map((drop) => drop.id),
);
}
});
});
+54 -52
View File
@@ -1,7 +1,15 @@
import type { BossCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
import { BOSS_ORDER } from "../game/bossCatalog";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import { createClassInventory } from "../game/healers";
import type { BossId } from "../game/types";
import { createDefaultGearProgress } from "../game/progression/gear";
import {
BOSS_DROP_TABLES,
createEmptyCollectionLog,
type CollectionLog,
type LootRarity,
type MaterialStack,
} from "../game/progression/loot";
export const DEFAULT_SETTINGS: GameSettings = {
masterVolume: 80,
@@ -10,41 +18,39 @@ export const DEFAULT_SETTINGS: GameSettings = {
largeText: false,
};
export const DEFAULT_COLLECTIONS: BossCollection[] = [
{
bossId: "bulldrome",
bossName: "Bulldrome",
defeated: true,
drops: [
{ id: "bull-horn", name: "Cinder Horn", icon: "♜", rarity: "Common", count: 7 },
{ id: "bull-hide", name: "Ember Hide", icon: "▧", rarity: "Uncommon", count: 3 },
{ id: "bull-idol", name: "Vault Idol", icon: "◇", rarity: "Rare", count: 1 },
{ id: "bull-heart", name: "Furnace Heart", icon: "✦", rarity: "Mythic", count: 0 },
],
},
{
bossId: "vexa",
bossName: "Vexa",
defeated: false,
drops: [
{ id: "vexa-silk", name: "Living Silk", icon: "⌁", rarity: "Common", count: 0 },
{ id: "vexa-venom", name: "Widow Venom", icon: "✣", rarity: "Uncommon", count: 0 },
{ id: "vexa-eye", name: "Loom Eye", icon: "◉", rarity: "Rare", count: 0 },
{ id: "vexa-heart", name: "Webmother Heart", icon: "✦", rarity: "Mythic", count: 0 },
],
},
{
bossId: "cindermaw",
bossName: "Cindermaw",
defeated: true,
drops: [
{ id: "maw-scale", name: "Soot Scale", icon: "◈", rarity: "Common", count: 4 },
{ id: "maw-gland", name: "Mending Gland", icon: "+", rarity: "Uncommon", count: 2 },
{ id: "maw-crest", name: "Ashen Crest", icon: "⌁", rarity: "Rare", count: 0 },
{ id: "maw-breath", name: "Bottled Breath", icon: "☀", rarity: "Mythic", count: 0 },
],
},
];
const RARITY_LABELS: Record<LootRarity, BossCollection["drops"][number]["rarity"]> = {
common: "Common",
uncommon: "Uncommon",
rare: "Rare",
epic: "Epic",
legendary: "Legendary",
};
export function buildCollections(collectionLog: CollectionLog, bossKills: Record<string, number>): BossCollection[] {
return BOSS_ORDER.map((bossId) => {
const table = BOSS_DROP_TABLES[bossId];
return {
bossId,
bossName: BOSS_DEFINITIONS[bossId].name,
defeated: (bossKills[bossId] ?? 0) > 0,
drops: table.entries.map((drop) => ({
id: drop.id,
name: drop.name,
icon: drop.glyph,
rarity: RARITY_LABELS[drop.rarity],
count: drop.kind === "coin"
? collectionLog.dropsFound[drop.id] ?? 0
: collectionLog.petsFound[drop.id] ?? 0,
chance: drop.chanceLabel,
itemLevel: drop.kind === "coin" ? drop.itemLevel : undefined,
kind: drop.kind,
})),
};
});
}
export const DEFAULT_COLLECTION_LOG: CollectionLog = createEmptyCollectionLog();
export const DEFAULT_COLLECTIONS: BossCollection[] = buildCollections(DEFAULT_COLLECTION_LOG, {});
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
"roguelike-pve": {
@@ -58,7 +64,7 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
eyebrow: "14 hunters · chosen encounter",
title: "Dungeons",
description: "Choose a guardian, review its mechanics, and bring a prepared healing loadout into a focused encounter.",
detail: "Bulldrome · Vexa · Cindermaw",
detail: "Ten prototype guardians available",
status: "Playable now",
},
"roguelike-pvp": {
@@ -81,12 +87,6 @@ export function selectRandomBoss(random: () => number = Math.random): BossId {
return BOSS_ORDER[Math.floor(random() * BOSS_ORDER.length)] ?? BOSS_ORDER[0];
}
export function selectRandomBossPair(random: () => number = Math.random): readonly [BossId, BossId] {
const firstIndex = Math.floor(random() * BOSS_ORDER.length) % BOSS_ORDER.length;
const secondOffset = 1 + Math.floor(random() * (BOSS_ORDER.length - 1));
return [BOSS_ORDER[firstIndex], BOSS_ORDER[(firstIndex + secondOffset) % BOSS_ORDER.length]];
}
export const MAX_HUNTER_NAME_LENGTH = 20;
export function normalizeHunterName(value: string): string {
@@ -101,25 +101,27 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
const normalizedName = normalizeHunterName(hunterName);
if (!normalizedName) throw new Error("Hunter name is required.");
return {
schemaVersion: 2,
schemaVersion: 4,
slotId,
hunterName: normalizedName,
activeClassId: "priest",
healers: {
priest: { level: 12, inventory: createClassInventory("priest") },
priest: { level: 1, inventory: createClassInventory("priest") },
druid: { level: 1, inventory: createClassInventory("druid") },
shaman: { level: 1, inventory: createClassInventory("shaman") },
},
location: "Ember Vault Approach",
playSeconds: 8 * 60 * 60 + 42 * 60,
playSeconds: 0,
updatedAt: now,
stats: {
totalBossKills: 16,
flawlessClears: 5,
alliesSaved: 143,
healingDone: 284_650,
bossKills: { Bulldrome: 12, Vexa: 0, Cindermaw: 4 },
totalBossKills: 0,
flawlessClears: 0,
alliesSaved: 0,
healingDone: 0,
bossKills: {},
},
collections: structuredClone(DEFAULT_COLLECTIONS),
materials: [] as MaterialStack[],
collectionLog: createEmptyCollectionLog(),
gearProgress: createDefaultGearProgress(),
};
}
+30 -9
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { SaveRepository, type StorageAdapter } from "./saveRepository";
import { buildCollections, DEFAULT_COLLECTIONS } from "./data";
function memoryStorage(): StorageAdapter {
const data = new Map<string, string>();
@@ -50,11 +51,11 @@ describe("SaveRepository", () => {
}));
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(40);
expect(repository.list("healer@example.com")[0].online?.healers.priest.level).toBe(12);
expect(repository.list("healer@example.com")[0].online?.healers.priest.level).toBe(1);
now = "2026-07-10T14:00:00.000Z";
repository.download(1, "healer@example.com");
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(12);
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(1);
expect(repository.list("healer@example.com")[0].local?.updatedAt).toBe(now);
});
@@ -85,7 +86,7 @@ describe("SaveRepository", () => {
expect(save.hunterName).toBe("Aelia");
expect(save.activeClassId).toBe("druid");
expect(save.healers.druid.level).toBe(8);
expect(save.healers.priest.level).toBe(12);
expect(save.healers.priest.level).toBe(1);
expect(save.healers.druid.inventory).toHaveLength(4);
expect(save.healers.priest.inventory).toHaveLength(4);
});
@@ -100,7 +101,7 @@ describe("SaveRepository", () => {
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.list(null)[0].local!;
expect(migrated.schemaVersion).toBe(2);
expect(migrated.schemaVersion).toBe(4);
expect(migrated.hunterName).toBe("Legacy");
expect(migrated.activeClassId).toBe("priest");
expect(migrated.healers.priest.level).toBe(27);
@@ -108,15 +109,35 @@ describe("SaveRepository", () => {
expect(migrated.healers.shaman.inventory.length).toBeGreaterThan(0);
});
it("adds newly shipped bosses to existing schema v2 collection logs", () => {
it("derives newly shipped bosses from drop tables after migrating schema v2 collections", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Veteran");
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({
1: { ...created, collections: created.collections.filter((boss) => boss.bossId !== "vexa") },
}));
const legacy = { ...created, schemaVersion: 2, collections: DEFAULT_COLLECTIONS.filter((boss) => boss.bossId !== "vexa") } as Record<string, unknown>;
delete legacy.collectionLog;
delete legacy.materials;
delete legacy.gearProgress;
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
const migrated = repository.list(null)[0].local!;
expect(migrated.collections.some((boss) => boss.bossId === "vexa")).toBe(true);
expect(buildCollections(migrated.collectionLog, migrated.stats.bossKills).some((boss) => boss.bossId === "vexa")).toBe(true);
});
it("migrates valid infusion choices and discards stale ids", () => {
const storage = memoryStorage();
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
const created = repository.create(1, "Infused");
created.gearProgress.priest.infusionAbilityId = "priest-sanctuary";
created.gearProgress.priest.passiveInfusionId = "restoring-grace";
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
created.gearProgress.brann.passiveInfusionId = "deep-wells";
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 3 } }));
const migrated = repository.list(null)[0].local!;
expect(migrated.schemaVersion).toBe(4);
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
expect(migrated.gearProgress.priest.passiveInfusionId).toBe("restoring-grace");
expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull();
expect(migrated.gearProgress.brann.passiveInfusionId).toBeNull();
});
});
+121 -40
View File
@@ -1,7 +1,11 @@
import { createHunterSave, DEFAULT_COLLECTIONS } from "./data";
import { createHunterSave } from "./data";
import { createClassInventory } from "../game/healers";
import type { HealerClassId } from "../game/types";
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL, type GearProgress } from "../game/progression/gear";
import { normalizeActiveInfusionId, normalizePassiveInfusionId } from "../game/progression/infusions";
import { BOSS_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type MaterialStack } from "../game/progression/loot";
import type { BossId, HealerClassId } from "../game/types";
import type { BossCollection, HunterSave, SaveSlotId, SaveSlotState } from "./types";
export interface StorageAdapter {
getItem(key: string): string | null;
@@ -29,54 +33,131 @@ function browserStorage(): StorageAdapter {
return fallbackStorage;
}
interface LegacyHunterSave extends Omit<HunterSave, "schemaVersion" | "activeClassId" | "healers"> {
schemaVersion: 1;
level: number;
interface LegacyHunterSave {
schemaVersion?: number;
slotId?: SaveSlotId;
hunterName?: string;
activeClassId?: HealerClassId;
healers?: HunterSave["healers"];
level?: number;
location?: string;
playSeconds?: number;
updatedAt?: string;
stats?: HunterSave["stats"];
collections?: BossCollection[];
materials?: MaterialStack[];
collectionLog?: CollectionLog;
gearProgress?: GearProgress;
}
const HEALER_IDS: HealerClassId[] = ["priest", "druid", "shaman"];
function normalizeCollections(collections: HunterSave["collections"] | undefined) {
const source = collections ?? [];
const knownIds = new Set(DEFAULT_COLLECTIONS.map((boss) => boss.bossId));
const current = DEFAULT_COLLECTIONS.map((fallback) => source.find((boss) => boss.bossId === fallback.bossId) ?? structuredClone(fallback));
return [...current, ...source.filter((boss) => !knownIds.has(boss.bossId))];
function positiveCounts(value: unknown): Record<string, number> {
if (!value || typeof value !== "object") return {};
return Object.fromEntries(Object.entries(value as Record<string, unknown>).flatMap(([id, rawQuantity]) => {
const quantity = Math.max(0, Math.floor(Number(rawQuantity) || 0));
return quantity > 0 ? [[id, quantity]] : [];
}));
}
function normalizeCollectionLog(candidate: LegacyHunterSave): CollectionLog {
if (candidate.collectionLog) {
return {
dropsFound: positiveCounts(candidate.collectionLog.dropsFound),
petsFound: positiveCounts(candidate.collectionLog.petsFound),
};
}
const result = createEmptyCollectionLog();
for (const legacyBoss of candidate.collections ?? []) {
if (!BOSS_ORDER.includes(legacyBoss.bossId as BossId)) continue;
const bossId = legacyBoss.bossId as BossId;
const quantity = legacyBoss.drops.reduce((sum, drop) => sum + Math.max(0, Math.floor(drop.count || 0)), 0);
if (quantity > 0) result.dropsFound[BOSS_DROP_TABLES[bossId].coins.initiate.id] = quantity;
}
return result;
}
function knownMaterial(id: string) {
for (const bossId of BOSS_ORDER) {
const coin = Object.values(BOSS_DROP_TABLES[bossId].coins).find((candidate) => candidate.id === id);
if (coin) return coin;
}
return undefined;
}
function normalizeMaterials(value: unknown, collectionLog: CollectionLog): MaterialStack[] {
const quantities = new Map<string, number>();
if (Array.isArray(value)) {
for (const raw of value) {
if (!raw || typeof raw !== "object") continue;
const item = raw as Partial<MaterialStack>;
if (!item.id) continue;
const quantity = Math.max(0, Math.floor(Number(item.quantity) || 0));
if (quantity > 0) quantities.set(item.id, (quantities.get(item.id) ?? 0) + quantity);
}
} else {
for (const [id, quantity] of Object.entries(collectionLog.dropsFound)) quantities.set(id, quantity);
}
return [...quantities].flatMap(([id, quantity]) => {
const coin = knownMaterial(id);
return coin ? [{ id, quantity, name: coin.name, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }] : [];
});
}
function normalizeGearProgress(value: unknown): GearProgress {
const defaults = createDefaultGearProgress();
if (!value || typeof value !== "object") return defaults;
const candidate = value as Partial<GearProgress>;
for (const ownerId of GEAR_OWNER_ORDER) {
for (const slotId of GEAR_SLOT_ORDER) {
const level = Math.max(0, Math.min(MAX_GEAR_LEVEL, Math.floor(Number(candidate[ownerId]?.slots?.[slotId]?.level) || 0)));
defaults[ownerId].slots[slotId].level = level as GearProgress[typeof ownerId]["slots"][typeof slotId]["level"];
}
defaults[ownerId].infusionAbilityId = normalizeActiveInfusionId(ownerId, candidate[ownerId]?.infusionAbilityId);
defaults[ownerId].passiveInfusionId = normalizePassiveInfusionId(ownerId, candidate[ownerId]?.passiveInfusionId);
}
return defaults;
}
function normalizeBossKills(value: unknown): Record<string, number> {
const source = positiveCounts(value);
const result: Record<string, number> = {};
for (const [key, quantity] of Object.entries(source)) {
const bossId = BOSS_ORDER.find((id) => id === key || BOSS_DEFINITIONS[id].name === key);
result[bossId ?? key] = (result[bossId ?? key] ?? 0) + quantity;
}
return result;
}
function normalizeSave(value: unknown): HunterSave | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<HunterSave> & Partial<LegacyHunterSave>;
const candidate = value as LegacyHunterSave;
if (!candidate.slotId || !candidate.hunterName) return null;
if (candidate.schemaVersion === 2 && candidate.healers) {
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
return {
...(candidate as HunterSave),
activeClassId,
collections: normalizeCollections(candidate.collections),
healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, {
level: Math.max(1, candidate.healers?.[classId]?.level ?? 1),
inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId),
}])) as HunterSave["healers"],
};
}
const legacy = candidate as LegacyHunterSave;
const collectionLog = normalizeCollectionLog(candidate);
const bossKills = normalizeBossKills(candidate.stats?.bossKills);
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
return {
schemaVersion: 2,
slotId: legacy.slotId,
hunterName: legacy.hunterName,
activeClassId: "priest",
healers: {
priest: { level: Math.max(1, legacy.level || 1), inventory: createClassInventory("priest") },
druid: { level: 1, inventory: createClassInventory("druid") },
shaman: { level: 1, inventory: createClassInventory("shaman") },
schemaVersion: 4,
slotId: candidate.slotId,
hunterName: candidate.hunterName,
activeClassId,
healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, {
level: Math.max(1, candidate.healers?.[classId]?.level ?? (classId === "priest" ? candidate.level ?? 1 : 1)),
inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId),
}])) as HunterSave["healers"],
location: candidate.location ?? "Ember Vault Approach",
playSeconds: Math.max(0, candidate.playSeconds ?? 0),
updatedAt: candidate.updatedAt ?? new Date(0).toISOString(),
stats: {
totalBossKills: Math.max(0, candidate.stats?.totalBossKills ?? Object.values(bossKills).reduce((sum, count) => sum + count, 0)),
flawlessClears: Math.max(0, candidate.stats?.flawlessClears ?? 0),
alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0),
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
bossKills,
},
location: legacy.location,
playSeconds: legacy.playSeconds,
updatedAt: legacy.updatedAt,
stats: legacy.stats,
collections: normalizeCollections(legacy.collections),
materials: normalizeMaterials(candidate.materials, collectionLog),
collectionLog,
gearProgress: normalizeGearProgress(candidate.gearProgress),
};
}
+220 -14
View File
@@ -1,10 +1,20 @@
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";
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
import {
equipActiveInfusion,
equipPassiveInfusion,
infusionsForOwner,
} from "../game/progression/infusions";
import type { RunBuffId } from "../game/types";
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
const repository = new SaveRepository();
const accounts = new AccountRepository();
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
function loadSettings(): GameSettings {
@@ -24,7 +34,19 @@ function persistSettings(settings: GameSettings) {
}
}
interface FrontendState {
function accountNotice(result: Extract<AccountResult, { ok: false }>, action: "sign-in" | "create") {
switch (result.reason) {
case "missing-credentials": return "Enter both username and password.";
case "account-exists": return "Account already exists. Sign in with its password.";
case "account-not-found": return "Account not found. Create an account before enabling online sync.";
case "invalid-password": return "Username or password is incorrect.";
case "storage-unavailable": return action === "create"
? "Account could not be saved on this device. Continue offline or try again."
: "Account could not be verified on this device. Continue offline or try again.";
}
}
export interface FrontendState {
screen: AppScreen;
accountId: string | null;
slots: SaveSlotState[];
@@ -32,9 +54,16 @@ interface FrontendState {
activeSlotId: SaveSlotId | null;
selectedMode: GameModeId;
selectedBossId: BossId;
selectedDifficultySlug: DifficultySlug;
selectedGearOwnerId: GearOwnerId;
selectedGearSlotId: GearSlotId;
gearWorkshopMode: "upgrade" | "infusion";
selectedInfusionId: string;
recentRewards: BossRewardAward[];
settings: GameSettings;
notice: string;
signIn: (accountId: string) => void;
signIn: (username: string, password: string) => Promise<boolean>;
createAccount: (username: string, password: string) => Promise<boolean>;
continueOffline: () => void;
signOut: () => void;
navigate: (screen: AppScreen) => void;
@@ -47,11 +76,20 @@ interface FrontendState {
downloadSlot: (slotId: SaveSlotId) => void;
selectMode: (mode: GameModeId) => void;
selectBoss: (bossId: BossId) => void;
selectDifficulty: (difficultySlug: DifficultySlug) => void;
selectGearOwner: (ownerId: GearOwnerId) => void;
selectGearSlot: (slotId: GearSlotId) => void;
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
selectInfusion: (infusionId: string) => void;
upgradeSelectedGear: () => boolean;
equipSelectedInfusion: () => boolean;
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
selectHealerClass: (classId: HealerClassId) => void;
updateActiveHealerInventory: (inventory: InventoryItem[]) => void;
updateSetting: <K extends keyof GameSettings>(key: K, value: GameSettings[K]) => void;
touchActiveSave: () => void;
recordBossVictory: (bossName: string) => void;
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
clearRecentRewards: () => void;
clearNotice: () => void;
}
@@ -67,12 +105,32 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
activeSlotId: null,
selectedMode: "roguelike-pve",
selectedBossId: "bulldrome",
selectedDifficultySlug: "initiate",
selectedGearOwnerId: "priest",
selectedGearSlotId: "weapon",
gearWorkshopMode: "upgrade",
selectedInfusionId: infusionsForOwner("priest")[0].id,
recentRewards: [],
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." }),
@@ -120,12 +178,84 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
},
selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }),
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
selectDifficulty: (selectedDifficultySlug) => set({ selectedDifficultySlug: normalizeDifficultySlug(selectedDifficultySlug), notice: "" }),
selectGearOwner: (selectedGearOwnerId) => set({
selectedGearOwnerId,
selectedInfusionId: infusionsForOwner(selectedGearOwnerId)[0].id,
notice: "",
}),
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
upgradeSelectedGear: () => {
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
if (!activeSlotId) return false;
let message = "Gear upgrade failed.";
let upgraded = false;
repository.updateLocal(activeSlotId, (save) => {
try {
const result = upgradeGearSlot(save.gearProgress, save.materials, selectedGearOwnerId, selectedGearSlotId);
upgraded = true;
message = `${selectedGearOwnerId} ${selectedGearSlotId} upgraded to +${result.gearProgress[selectedGearOwnerId].slots[selectedGearSlotId].level}.`;
return { ...save, gearProgress: result.gearProgress, materials: result.inventory };
} catch (error) {
message = error instanceof Error ? error.message : message;
return save;
}
});
set({ slots: repository.list(accountId), notice: message });
return upgraded;
},
equipSelectedInfusion: () => {
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId, selectedInfusionId } = get();
if (!activeSlotId) return false;
let message = "Infusion failed.";
let equipped = false;
repository.updateLocal(activeSlotId, (save) => {
try {
const wasEquipped = save.gearProgress[selectedGearOwnerId].infusionAbilityId === selectedInfusionId;
const result = equipActiveInfusion(save.gearProgress, save.materials, selectedGearOwnerId, selectedGearSlotId, selectedInfusionId);
equipped = true;
message = wasEquipped ? "Infusion already equipped." : `${selectedGearOwnerId} infusion equipped.`;
return { ...save, gearProgress: result.gearProgress, materials: result.inventory };
} catch (error) {
message = error instanceof Error ? error.message : message;
return save;
}
});
set({ slots: repository.list(accountId), notice: message });
return equipped;
},
equipPassiveInfusion: (passiveId) => {
const { activeSlotId, accountId, selectedGearOwnerId } = get();
if (!activeSlotId) return false;
let message = "Passive infusion failed.";
let equipped = false;
repository.updateLocal(activeSlotId, (save) => {
try {
const gearProgress = equipPassiveInfusion(save.gearProgress, selectedGearOwnerId, passiveId);
equipped = true;
message = "Passive infusion equipped. Applies next encounter.";
return { ...save, gearProgress };
} catch (error) {
message = error instanceof Error ? error.message : message;
return save;
}
});
set({ slots: repository.list(accountId), notice: message });
return equipped;
},
selectHealerClass: (classId) => {
const { activeSlotId, accountId } = get();
if (!activeSlotId) return;
const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId }));
if (!updated) return;
set({ slots: repository.list(accountId), notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.` });
set({
slots: repository.list(accountId),
selectedGearOwnerId: classId,
selectedInfusionId: infusionsForOwner(classId)[0].id,
notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.`,
});
},
updateActiveHealerInventory: (inventory) => {
const { activeSlotId, accountId } = get();
@@ -150,24 +280,100 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
repository.touch(activeSlotId);
set({ slots: repository.list(accountId) });
},
recordBossVictory: (bossName) => {
recordBossVictory: (bossId, difficultySlug) => {
const { activeSlotId, accountId } = get();
if (!activeSlotId) return;
if (!activeSlotId) return null;
let awarded: BossRewardAward | null = null;
repository.updateLocal(activeSlotId, (save) => {
const bossKills = { ...save.stats.bossKills, [bossName]: (save.stats.bossKills[bossName] ?? 0) + 1 };
const reward = rollBossReward(bossId, difficultySlug, save.materials, save.collectionLog);
awarded = reward.award;
const bossKills = { ...save.stats.bossKills, [bossId]: (save.stats.bossKills[bossId] ?? 0) + 1 };
return {
...save,
stats: { ...save.stats, totalBossKills: save.stats.totalBossKills + 1, flawlessClears: save.stats.flawlessClears + 1, bossKills },
collections: save.collections.map((boss) => boss.bossName === bossName
? { ...boss, defeated: true, drops: boss.drops.map((drop, index) => index === 0 ? { ...drop, count: drop.count + 1 } : drop) }
: boss),
materials: reward.inventory,
collectionLog: reward.collectionLog,
};
});
set({ slots: repository.list(accountId), notice: `${bossName} clear saved offline.` });
set((state) => ({
slots: repository.list(accountId),
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards,
notice: awarded ? `${awarded.coin.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
}));
return awarded;
},
clearRecentRewards: () => set({ recentRewards: [] }),
clearNotice: () => set({ notice: "" }),
}));
export type FrontendSnapshot = Omit<FrontendState,
| "signIn"
| "createAccount"
| "continueOffline"
| "signOut"
| "navigate"
| "selectSlot"
| "createSlot"
| "playSlot"
| "deleteSlot"
| "copySlot"
| "uploadSlot"
| "downloadSlot"
| "selectMode"
| "selectBoss"
| "selectDifficulty"
| "selectGearOwner"
| "selectGearSlot"
| "selectGearWorkshopMode"
| "selectInfusion"
| "upgradeSelectedGear"
| "equipSelectedInfusion"
| "equipPassiveInfusion"
| "selectHealerClass"
| "updateActiveHealerInventory"
| "updateSetting"
| "touchActiveSave"
| "recordBossVictory"
| "clearRecentRewards"
| "clearNotice"
>;
export function getFrontendSnapshot(): FrontendSnapshot {
const {
signIn: _signIn,
createAccount: _createAccount,
continueOffline: _continueOffline,
signOut: _signOut,
navigate: _navigate,
selectSlot: _selectSlot,
createSlot: _createSlot,
playSlot: _playSlot,
deleteSlot: _deleteSlot,
copySlot: _copySlot,
uploadSlot: _uploadSlot,
downloadSlot: _downloadSlot,
selectMode: _selectMode,
selectBoss: _selectBoss,
selectDifficulty: _selectDifficulty,
selectGearOwner: _selectGearOwner,
selectGearSlot: _selectGearSlot,
selectGearWorkshopMode: _selectGearWorkshopMode,
selectInfusion: _selectInfusion,
upgradeSelectedGear: _upgradeSelectedGear,
equipSelectedInfusion: _equipSelectedInfusion,
equipPassiveInfusion: _equipPassiveInfusion,
selectHealerClass: _selectHealerClass,
updateActiveHealerInventory: _updateActiveHealerInventory,
updateSetting: _updateSetting,
touchActiveSave: _touchActiveSave,
recordBossVictory: _recordBossVictory,
clearRecentRewards: _clearRecentRewards,
clearNotice: _clearNotice,
...snapshot
} = useFrontendStore.getState();
return snapshot;
}
export function useActiveHunter(): HunterSave | null {
return useFrontendStore((state) => activeSave(state.slots, state.activeSlotId));
}
+11 -4
View File
@@ -1,15 +1,20 @@
import type { HealerClassId, InventoryItem } from "../game/types";
import type { GearProgress } from "../game/progression/gear";
import type { CollectionLog, MaterialStack } from "../game/progression/loot";
export type SaveSlotId = 1 | 2 | 3;
export type AppScreen = "login" | "saves" | "home" | "profile" | "settings" | "mode" | "game";
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game";
export type GameModeId = "roguelike-pve" | "dungeons" | "roguelike-pvp" | "stadium-pvp";
export interface CollectionDrop {
id: string;
name: string;
icon: string;
rarity: "Common" | "Uncommon" | "Rare" | "Mythic";
rarity: "Common" | "Uncommon" | "Rare" | "Epic" | "Legendary";
count: number;
chance: string;
itemLevel?: number;
kind: "coin" | "pet";
}
export interface BossCollection {
@@ -33,7 +38,7 @@ export interface HealerProgress {
}
export interface HunterSave {
schemaVersion: 2;
schemaVersion: 4;
slotId: SaveSlotId;
hunterName: string;
activeClassId: HealerClassId;
@@ -42,7 +47,9 @@ export interface HunterSave {
playSeconds: number;
updatedAt: string;
stats: HunterStats;
collections: BossCollection[];
materials: MaterialStack[];
collectionLog: CollectionLog;
gearProgress: GearProgress;
}
export interface SaveSlotState {
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { isActorAnimationOneShot, shouldStartActorAnimation } from "./actorAnimation";
describe("actor animation playback", () => {
it("restarts a repeated attack when a new combat action begins", () => {
expect(shouldStartActorAnimation("attack", 4.2, "attack", 5.3)).toBe(true);
});
it("does not restart the same combat action every frame", () => {
expect(shouldStartActorAnimation("attack", 4.2, "attack", 4.2)).toBe(false);
});
it("keeps looping locomotion stable while its trigger changes", () => {
expect(shouldStartActorAnimation("walk", 0, "walk", 1)).toBe(false);
expect(shouldStartActorAnimation("run", 0, "run", 1)).toBe(false);
});
it("starts when the actor changes animation state", () => {
expect(shouldStartActorAnimation("attack", 4.2, "walk", 0)).toBe(true);
expect(isActorAnimationOneShot("death")).toBe(true);
});
});
+16
View File
@@ -0,0 +1,16 @@
export type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
export function isActorAnimationOneShot(state: ActorAnimationState) {
return state === "attack" || state === "cast" || state === "hit" || state === "death";
}
export function shouldStartActorAnimation(
activeState: ActorAnimationState | undefined,
activeTrigger: number,
nextState: ActorAnimationState,
nextTrigger: number,
) {
if (activeState !== nextState) return true;
if (nextState === "death" || !isActorAnimationOneShot(nextState)) return false;
return activeTrigger !== nextTrigger;
}
+29
View File
@@ -0,0 +1,29 @@
import type { BossMotionState, WorldPosition } from "./types";
export const ARENA_CENTER: WorldPosition = [0, -1];
export const ARENA_RADIUS = 8.35;
export const ARENA_WALL_RADIUS = 9.55;
export function clampToArena(position: WorldPosition, padding = 0): WorldPosition {
const radius = Math.max(0, ARENA_RADIUS - padding);
const dx = position[0] - ARENA_CENTER[0];
const dz = position[1] - ARENA_CENTER[1];
const distance = Math.hypot(dx, dz);
if (distance <= radius || distance < 0.0001) return [position[0], position[1]];
const scale = radius / distance;
return [ARENA_CENTER[0] + dx * scale, ARENA_CENTER[1] + dz * scale];
}
export function constrainBossMotion(motion: BossMotionState): BossMotionState {
return {
...motion,
position: clampToArena(motion.position),
chargeStart: clampToArena(motion.chargeStart),
chargeEnd: clampToArena(motion.chargeEnd),
pounceCenter: clampToArena(motion.pounceCenter),
};
}
export function isInsideArena(position: WorldPosition, tolerance = 0.001) {
return Math.hypot(position[0] - ARENA_CENTER[0], position[1] - ARENA_CENTER[1]) <= ARENA_RADIUS + tolerance;
}
+4 -5
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { createClassInventory } from "./healers";
import { BOSS_ORDER } from "./bossCatalog";
import { useGameStore } from "./store";
import type { BossId } from "./types";
@@ -25,11 +26,9 @@ function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds
}
describe("full-mechanics dual-boss battle simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = [
["bulldrome", "vexa"],
["bulldrome", "cindermaw"],
["vexa", "cindermaw"],
];
const combinations: readonly (readonly [BossId, BossId])[] = BOSS_ORDER.flatMap((first, index) =>
BOSS_ORDER.slice(index + 1).map((second) => [first, second] as const),
);
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
const result = simulateControlledBattle([first, second]);
+167 -19
View File
@@ -16,7 +16,20 @@ export interface BossDefinition {
maxHp: number;
}
export const BOSS_ORDER: readonly BossId[] = ["bulldrome", "vexa", "cindermaw"];
export const BOSS_ORDER: readonly BossId[] = [
"bulldrome",
"vexa",
"cindermaw",
"ember-mantis-duelist",
"obsidian-ram-golem",
"cinderback-ricochet",
"sandglass-scorpion",
"cragclaw-crab",
"pumpking-king-of-ghosts",
"blue-eyes-ultimate-dragon",
"mournveil-ghost",
"crownshard-golem",
];
export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
bulldrome: {
@@ -36,32 +49,167 @@ export const BOSS_DEFINITIONS: Record<BossId, BossDefinition> = {
},
vexa: {
id: "vexa",
name: "Vexa",
title: "The Webmother",
trial: "Trial II · Tangled Remedy",
name: "Insect Queen",
title: "The Hive Sovereign",
trial: "Trial II · Royal Brood",
icon: "✣",
accent: "#b56cff",
summary: "Binds allies together and weaponizes every cleanse.",
briefing: "Break Binding Web by spreading linked allies. Move away before cleansing Widow Venom or its pool poisons the formation.",
failure: "Break purple tethers quickly. Cleanse venom only after its target reaches open ground.",
mapTitle: "The Tangled Loom",
mapCopy: "Spread tethered allies toward opposite edges. Keep dropped venom pools away from the center lane.",
mechanics: ["Binding Web", "Venom Purge"],
summary: "Pins prey with silk before flooding safe ground with venom.",
briefing: "Break Binding Web by spreading linked allies. Move away before cleansing Widow Venom or its brood pool poisons formation.",
failure: "Break royal tethers quickly. Cleanse venom only after its target reaches open ground.",
mapTitle: "The Royal Hive",
mapCopy: "Spread tethered allies toward opposite edges. Keep venom brood pools away from center.",
mechanics: ["Binding Web", "Venom Brood"],
maxHp: 535,
},
cindermaw: {
id: "cindermaw",
name: "Cindermaw",
title: "The Sky Tyrant",
trial: "Trial III · Ashen Orbit",
name: "Blue-Eyes White Dragon",
title: "The White Lightning",
trial: "Trial III · Burststream Orbit",
icon: "◆",
accent: "#ff9b45",
summary: "Sweeps the arena with flame and removes safe ground.",
briefing: "Rotate behind Searing Sweep. During Skyfall, leave each numbered impact circle before it becomes persistent fire.",
failure: "Follow the safe side of the breath cone. Keep moving as Skyfall removes sections of the arena.",
mapTitle: "The Ashen Crown",
mapCopy: "Orbit behind the dragon during breath. Preserve a clean escape route between Skyfall impacts.",
mechanics: ["Searing Sweep", "Skyfall"],
summary: "Sweeps the arena with white lightning and dives through targeted ground.",
briefing: "Rotate behind Burst Stream. During White Skyfall, leave each numbered impact before it becomes charged ground.",
failure: "Follow the safe side of the breath cone. Keep moving as White Skyfall removes sections of arena.",
mapTitle: "The Ivory Crown",
mapCopy: "Orbit behind the dragon during breath. Preserve a clean escape route between skyfall impacts.",
mechanics: ["Burst Stream", "White Skyfall"],
maxHp: 410,
},
"ember-mantis-duelist": {
id: "ember-mantis-duelist",
name: "Gate Guardian",
title: "The Tri-Element Sentinel",
trial: "Trial IV · Elements in Motion",
icon: "⚔",
accent: "#ff6a2a",
summary: "Repositions its stacked body before firing single and crossed elemental lanes.",
briefing: "Track each sidestep. Clear Elemental Beam, then find a safe quadrant when three powers form Guardian Cross.",
failure: "Do not chase the guardian through a telegraph. Preserve space and move perpendicular to each beam lane.",
mapTitle: "The Sealed Gate",
mapCopy: "Follow the guardian laterally, but cross glowing lanes only after its arms finish firing.",
mechanics: ["Elemental Beam", "Guardian Cross"],
maxHp: 520,
},
"obsidian-ram-golem": {
id: "obsidian-ram-golem",
name: "Gandora the Dragon of Destruction",
title: "The Ruin Orb",
trial: "Trial V · Destruction March",
icon: "♞",
accent: "#ff7a38",
summary: "Breaks formation with armored rushes, ruin quakes, and radial destruction beams.",
briefing: "Clear Destruction Rush, leave Ruin Quake, then step between Gandora's radial destruction lines.",
failure: "Do not remain in front of the armored dragon. Treat every glowing orb as an active strike lane.",
mapTitle: "The Ruined Causeway",
mapCopy: "Hold open flanks for Destruction Rush. Spread between radial fractures when its armor vents.",
mechanics: ["Destruction Rush", "Ruin Quake"],
maxHp: 540,
},
"cinderback-ricochet": {
id: "cinderback-ricochet",
name: "Red-Eyes Black Dragon",
title: "The Black Flare",
trial: "Trial VI · Inferno Rebound",
icon: "⬢",
accent: "#ff9345",
summary: "Rebounds through marked flight lanes and leaves black-flame impact pools.",
briefing: "Clear both Inferno Rush lanes. Meteor Slam blooms into black-flame pools around its landing zone.",
failure: "Watch the second rebound before returning to formation. Preserve a clean route around black flame.",
mapTitle: "The Inferno Circuit",
mapCopy: "Bait the dragon along arena edges. Never cross a marked flight lane before second impact.",
mechanics: ["Inferno Rush", "Meteor Slam"],
maxHp: 505,
},
"sandglass-scorpion": {
id: "sandglass-scorpion",
name: "Sandglass Scorpion",
title: "The Dune Chronarch",
trial: "Trial VII · Hour of Venom",
icon: "⌛",
accent: "#e9b94f",
summary: "Burrows beneath marked paths and erupts through timed hourglass zones.",
briefing: "Cross the Burrow Rush lane before it dives. Leave Stinger Eruptions, then outrun the active Hourglass zone.",
failure: "Move before each timer completes. Sand warnings become damaging ground the instant they fill.",
mapTitle: "The Sunken Hour",
mapCopy: "Keep the center open. Burrow paths split formation while hourglass zones close escape routes.",
mechanics: ["Burrow Rush", "Hourglass Eruption"],
maxHp: 515,
},
"cragclaw-crab": {
id: "cragclaw-crab",
name: "Cragclaw",
title: "The Breakwater Tyrant",
trial: "Trial VIII · Tide in the Claws",
icon: "♋",
accent: "#49c7d4",
summary: "Scuttles through marked lanes and crushes the arena beneath tidal bursts.",
briefing: "Clear Sidewinder Rush, then leave every Crushing Tide circle before the claws close.",
failure: "Cross the scuttle lane only after Cragclaw passes. Spread targeted circles away from formation.",
mapTitle: "The Drowned Breakwater",
mapCopy: "Keep open water between party lanes. Tidal marks punish overlapping escape routes.",
mechanics: ["Sidewinder Rush", "Crushing Tide"],
maxHp: 505,
},
"pumpking-king-of-ghosts": {
id: "pumpking-king-of-ghosts",
name: "Pumpking the King of Ghosts",
title: "The Haunted Harvest",
trial: "Trial XI · Vines Unbound",
icon: "♚",
accent: "#d87842",
summary: "Whips the arena twice with spectral vines and grows hungry rifts beneath allies.",
briefing: "Dodge both Vine Scissor patterns. Carry Haunting Rifts away before they sprout.",
failure: "The second vine cross rotates. Do not return to the first safe quadrant early.",
mapTitle: "The Haunted Patch",
mapCopy: "Read both crossing vine patterns, then preserve clear ground for persistent ghost rifts.",
mechanics: ["Vine Scissors", "Haunting Rifts"],
maxHp: 505,
},
"blue-eyes-ultimate-dragon": {
id: "blue-eyes-ultimate-dragon",
name: "Blue-Eyes Ultimate Dragon",
title: "The Three-Headed Tyrant",
trial: "Trial XII · Ultimate Evolution",
icon: "♕",
accent: "#8fc8ff",
summary: "Three heads fire expanding burst rings before marking allies for ultimate skyfall.",
briefing: "Move through each Tri-Burst ring, then spread targeted Ultimate Skyfall circles.",
failure: "Three shockwaves expand in sequence. Commit to each safe band before the next head fires.",
mapTitle: "The Ultimate Aerie",
mapCopy: "Follow expanding safe bands. Spread triple-head skyfall marks toward separate arena edges.",
mechanics: ["Tri-Burst Rings", "Ultimate Skyfall"],
maxHp: 500,
},
"mournveil-ghost": {
id: "mournveil-ghost",
name: "Mournveil",
title: "The Hollow Choir",
trial: "Trial IX · Echoes Unbound",
icon: "◉",
accent: "#9d72ff",
summary: "Cuts the arena twice with spectral lanes and leaves hungry rifts beneath allies.",
briefing: "Dodge both Soul Scissor patterns. Carry Haunting Rifts away before they open.",
failure: "The second spectral cross rotates. Do not return to the first safe quadrant early.",
mapTitle: "The Silent Reliquary",
mapCopy: "Read both crossing patterns, then preserve clear ground for persistent soul rifts.",
mechanics: ["Soul Scissors", "Haunting Rifts"],
maxHp: 505,
},
"crownshard-golem": {
id: "crownshard-golem",
name: "Crownshard Golem",
title: "The Fallen Idol",
trial: "Trial X · Edict of Stone",
icon: "♛",
accent: "#e0bd45",
summary: "Sends royal shockwaves across the floor and calls crushing crown shards from above.",
briefing: "Move through each Royal Shockwave ring, then clear targeted Crownfall circles.",
failure: "Shockwaves expand in three steps. Commit to each safe band before the next ring fires.",
mapTitle: "The Broken Coronation",
mapCopy: "Follow the expanding safe bands. Spread Crownfall marks toward separate arena edges.",
mechanics: ["Royal Shockwave", "Crownfall"],
maxHp: 500,
},
};
+60 -5
View File
@@ -1,5 +1,13 @@
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { clampToArena } from "./arena";
import { advanceCindermawMechanics, createCindermawMotion, createCindermawState, upcomingCindermawMechanic } from "./bosses/cindermaw";
import { advanceCinderbackMechanics, createCinderbackMotion, createCinderbackState, upcomingCinderbackMechanic } from "./bosses/cinderbackRicochet";
import { advanceCragclawMechanics, createCragclawMotion, createCragclawState, upcomingCragclawMechanic } from "./bosses/cragclawCrab";
import { advanceCrownshardMechanics, createCrownshardMotion, createCrownshardState, upcomingCrownshardMechanic } from "./bosses/crownshardGolem";
import { advanceEmberMantisMechanics, createEmberMantisMotion, createEmberMantisState, upcomingEmberMantisMechanic } from "./bosses/emberMantis";
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, upcomingMournveilMechanic } from "./bosses/mournveilGhost";
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, upcomingObsidianRamMechanic } from "./bosses/obsidianRamGolem";
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, upcomingSandglassMechanic } from "./bosses/sandglassScorpion";
import { createBaseMotion } from "./bosses/shared";
import type { BossMechanicContext, BossMechanicEvent, BossMechanicResult } from "./bosses/types";
import { advanceVexaMechanics, createVexaMotion, createVexaState, dropVexaVenomPool, upcomingVexaMechanic } from "./bosses/vexa";
@@ -23,7 +31,7 @@ export const BULL_POUNCE = {
afterCharges: 3,
stackDuration: 5,
stackRadius: 2.2,
sharedDamage: 300,
sharedDamage: 200,
leapDuration: 0.55,
} as const;
@@ -39,6 +47,26 @@ const POUNCE_TARGET_ORDER: readonly MemberId[] = ["aelia", "nia", "orin", "vale"
export function createBossState(bossId: BossId = "bulldrome"): BossState {
if (bossId === "vexa") return createVexaState();
if (bossId === "cindermaw") return createCindermawState();
if (bossId === "ember-mantis-duelist") return createEmberMantisState();
if (bossId === "obsidian-ram-golem") return createObsidianRamState();
if (bossId === "cinderback-ricochet") return createCinderbackState();
if (bossId === "sandglass-scorpion") return createSandglassState();
if (bossId === "cragclaw-crab") return createCragclawState();
if (bossId === "mournveil-ghost") return createMournveilState();
if (bossId === "crownshard-golem") return createCrownshardState();
if (bossId === "pumpking-king-of-ghosts" || bossId === "blue-eyes-ultimate-dragon") {
const definition = BOSS_DEFINITIONS[bossId];
return {
id: definition.id,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: bossId === "pumpking-king-of-ghosts" ? 2.35 : 2.4,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
const definition = BOSS_DEFINITIONS.bulldrome;
return {
id: "bulldrome",
@@ -55,6 +83,15 @@ export function createBossState(bossId: BossId = "bulldrome"): BossState {
export function createBossMotionState(bossId: BossId = "bulldrome"): BossMotionState {
if (bossId === "vexa") return createVexaMotion();
if (bossId === "cindermaw") return createCindermawMotion();
if (bossId === "ember-mantis-duelist") return createEmberMantisMotion();
if (bossId === "obsidian-ram-golem") return createObsidianRamMotion();
if (bossId === "cinderback-ricochet") return createCinderbackMotion();
if (bossId === "sandglass-scorpion") return createSandglassMotion();
if (bossId === "cragclaw-crab") return createCragclawMotion();
if (bossId === "mournveil-ghost") return createMournveilMotion();
if (bossId === "crownshard-golem") return createCrownshardMotion();
if (bossId === "pumpking-king-of-ghosts") return { ...createMournveilMotion(), bossId };
if (bossId === "blue-eyes-ultimate-dragon") return { ...createCrownshardMotion(), bossId };
return {
...createBaseMotion("bulldrome"),
mode: "holding",
@@ -77,10 +114,10 @@ function chargeEndpoint(start: WorldPosition, target: WorldPosition): WorldPosit
const dx = target[0] - start[0];
const dz = target[1] - start[1];
const length = Math.max(0.001, Math.hypot(dx, dz));
return [
Math.max(-7.3, Math.min(7.3, start[0] + (dx / length) * BULL_CHARGE.distance)),
Math.max(-8.8, Math.min(7.1, start[1] + (dz / length) * BULL_CHARGE.distance)),
];
return clampToArena([
start[0] + (dx / length) * BULL_CHARGE.distance,
start[1] + (dz / length) * BULL_CHARGE.distance,
]);
}
function livingMember(party: PartyMember[], memberId: MemberId) {
@@ -348,6 +385,15 @@ function advanceBulldromeMechanics(context: BossMechanicContext): BossMechanicRe
export function advanceBossMechanics(context: BossMechanicContext): BossMechanicResult {
if (context.boss.id === "vexa") return advanceVexaMechanics(context);
if (context.boss.id === "cindermaw") return advanceCindermawMechanics(context);
if (context.boss.id === "ember-mantis-duelist") return advanceEmberMantisMechanics(context);
if (context.boss.id === "obsidian-ram-golem") return advanceObsidianRamMechanics(context);
if (context.boss.id === "cinderback-ricochet") return advanceCinderbackMechanics(context);
if (context.boss.id === "sandglass-scorpion") return advanceSandglassMechanics(context);
if (context.boss.id === "cragclaw-crab") return advanceCragclawMechanics(context);
if (context.boss.id === "mournveil-ghost") return advanceMournveilMechanics(context);
if (context.boss.id === "crownshard-golem") return advanceCrownshardMechanics(context);
if (context.boss.id === "pumpking-king-of-ghosts") return advanceMournveilMechanics(context);
if (context.boss.id === "blue-eyes-ultimate-dragon") return advanceCrownshardMechanics(context);
return advanceBulldromeMechanics(context);
}
@@ -371,6 +417,15 @@ export function handleBossDispel(
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
if (boss.id === "vexa") return upcomingVexaMechanic(boss, motion, time);
if (boss.id === "cindermaw") return upcomingCindermawMechanic(boss, motion, time);
if (boss.id === "ember-mantis-duelist") return upcomingEmberMantisMechanic(boss, motion, time);
if (boss.id === "obsidian-ram-golem") return upcomingObsidianRamMechanic(boss, motion, time);
if (boss.id === "cinderback-ricochet") return upcomingCinderbackMechanic(boss, motion, time);
if (boss.id === "sandglass-scorpion") return upcomingSandglassMechanic(boss, motion, time);
if (boss.id === "cragclaw-crab") return upcomingCragclawMechanic(boss, motion, time);
if (boss.id === "mournveil-ghost") return upcomingMournveilMechanic(boss, motion, time);
if (boss.id === "crownshard-golem") return upcomingCrownshardMechanic(boss, motion, time);
if (boss.id === "pumpking-king-of-ghosts") return upcomingMournveilMechanic(boss, motion, time);
if (boss.id === "blue-eyes-ultimate-dragon") return upcomingCrownshardMechanic(boss, motion, time);
if (motion.mode === "telegraph") {
return { name: "Bull Charge", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: BULL_CHARGE.telegraphDuration, urgent: true };
}
+115
View File
@@ -0,0 +1,115 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CINDERBACK = {
firstAt: 5,
repeatDelay: 3.6,
curlWarning: 1.25,
speed: 12.5,
distance: 13,
laneWidth: 2.25,
rushDamage: 22,
slamWarning: 1.3,
slamRadius: 3.1,
slamDamage: 29,
lavaRadius: 1.5,
lavaDamage: 5,
lavaDuration: 4.5,
recoverDuration: 0.75,
} as const;
const TARGETS: readonly MemberId[] = ["orin", "nia", "aelia", "vale", "brann"];
export function createCinderbackState(): BossState {
const definition = BOSS_DEFINITIONS["cinderback-ricochet"];
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.4, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
}
export function createCinderbackMotion(): BossMotionState {
return { ...createBaseMotion("cinderback-ricochet"), position: [0, -6.4], nextMechanicAt: CINDERBACK.firstAt };
}
function rushEnd(start: WorldPosition, target: WorldPosition) {
const angle = angleTo(start, target);
return clampToArena([start[0] + Math.sin(angle) * CINDERBACK.distance, start[1] + Math.cos(angle) * CINDERBACK.distance] as WorldPosition);
}
function rushLane(id: string, start: WorldPosition, end: WorldPosition): SlashLane {
return { id, start: [...start], end: [...end], width: CINDERBACK.laneWidth, damage: CINDERBACK.rushDamage };
}
function lavaPool(id: string, center: WorldPosition, at: number): CircleHazard {
return { id, kind: "lava_pool", center: [...center], radius: CINDERBACK.lavaRadius, activatesAt: at, expiresAt: at + CINDERBACK.lavaDuration, damage: CINDERBACK.lavaDamage, tickInterval: 0.8, nextDamageAt: {}, resolved: false, hitIds: [] };
}
export function advanceCinderbackMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2 * context.delta);
if (context.time >= motion.nextMechanicAt) {
const count = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
const end = rushEnd(motion.position, context.partyPositions[targetId]);
motion = { ...motion, mode: "cinderback_curl", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], chargeCount: 0, phaseStartedAt: context.time, phaseEndsAt: context.time + CINDERBACK.curlWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [rushLane(`ricochet-${count}-0`, motion.position, end)] };
events.push({ at: context.time, message: `Red-Eyes dives toward ${memberName(party, targetId)}. Two rebounds incoming.`, tone: "danger", pulseKind: "charge", targetId });
} else {
const activatesAt = context.time + CINDERBACK.slamWarning;
const pools = [0, 1, 2].map((index) => {
const angle = (index / 3) * Math.PI * 2;
return lavaPool(`slam-lava-${count}-${index}`, clampToArena([motion.position[0] + Math.sin(angle) * 3.7, motion.position[1] + Math.cos(angle) * 3.7]), activatesAt);
});
motion = { ...motion, mode: "cinderback_slam", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.25, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, { id: `armor-slam-${count}`, kind: "quake", center: [...motion.position], radius: CINDERBACK.slamRadius, activatesAt, expiresAt: activatesAt + 0.3, damage: CINDERBACK.slamDamage, nextDamageAt: {}, resolved: false, hitIds: [] }, ...pools] };
events.push({ at: context.time, message: "Meteor Slam! Leave Red-Eyes and spreading black flame.", tone: "danger", pulseKind: "boss" });
}
}
} else if (motion.mode === "cinderback_curl" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "cinderback_ricochet", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CINDERBACK.speed };
} else if (motion.mode === "cinderback_ricochet") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, CINDERBACK.speed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CINDERBACK.laneWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, CINDERBACK.rushDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.4 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
motion.hazards.push(lavaPool(`ricochet-lava-${motion.mechanicCount}-${motion.chargeCount}`, motion.chargeEnd, context.time));
if (motion.chargeCount === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount + 2);
const start = [...motion.chargeEnd] as WorldPosition;
const end = rushEnd(start, context.partyPositions[targetId]);
motion = { ...motion, position: start, chargeStart: start, chargeEnd: end, chargeTargetId: targetId, chargeHitIds: [], chargeCount: 1, phaseEndsAt: context.time + distance(start, end) / CINDERBACK.speed, slashLanes: [rushLane(`ricochet-${motion.mechanicCount}-1`, start, end)] };
events.push({ at: context.time, message: `Inferno Rush rebounds toward ${memberName(party, targetId)}!`, tone: "danger", pulseKind: "charge", targetId });
} else {
motion = { ...motion, position: [...motion.chargeEnd], mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
}
}
} else if (motion.mode === "cinderback_slam" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "cinderback_recover", phaseEndsAt: context.time + CINDERBACK.recoverDuration };
} else if (motion.mode === "cinderback_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", nextMechanicAt: context.time + CINDERBACK.repeatDelay, phaseEndsAt: 0, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.1, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCinderbackMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "cinderback_curl") return { name: "Inferno Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.curlWarning, urgent: true };
if (motion.mode === "cinderback_ricochet") return { name: motion.chargeCount === 0 ? "First rebound" : "Second rebound", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: 1.2, urgent: true };
if (motion.mode === "cinderback_slam") return { name: "Meteor Slam — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.slamWarning, urgent: true };
if (motion.mode === "cinderback_recover") return { name: "Red-Eyes exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDERBACK.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Inferno Rush" : "Meteor Slam", remaining, cycle: CINDERBACK.repeatDelay + CINDERBACK.curlWarning, urgent: remaining < 2.5 };
}
+7 -7
View File
@@ -78,7 +78,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
mechanicHitIds: [],
mechanicNextDamageAt: {},
};
events.push({ at: context.time, message: "Cindermaw draws a sweeping breath. Rotate behind it!", tone: "danger", pulseKind: "breath" });
events.push({ at: context.time, message: "Blue-Eyes draws a sweeping Burst Stream. Rotate behind it!", tone: "danger", pulseKind: "breath" });
} else {
const set = SKYFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % SKYFALL_TARGETS.length];
const hazards = set.map((memberId, index) => {
@@ -105,7 +105,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
mechanicCount: motion.mechanicCount + 1,
hazards: [...motion.hazards, ...hazards],
};
events.push({ at: context.time, message: "Cindermaw takes flight. Three Skyfalls incoming!", tone: "danger", pulseKind: "skyfall", targetId: set[0] });
events.push({ at: context.time, message: "Blue-Eyes takes flight. Three White Skyfalls incoming!", tone: "danger", pulseKind: "skyfall", targetId: set[0] });
}
} else if (motion.mode === "breath_telegraph" && context.time >= motion.phaseEndsAt) {
motion = {
@@ -115,7 +115,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
phaseEndsAt: context.time + CINDER_BREATH.sweepDuration,
breathAngle: motion.breathStartAngle,
};
events.push({ at: context.time, message: "Searing Sweep crosses the arena!", tone: "danger", pulseKind: "breath" });
events.push({ at: context.time, message: "Burst Stream crosses the arena!", tone: "danger", pulseKind: "breath" });
} else if (motion.mode === "breath_sweeping") {
const progress = Math.max(0, Math.min(1, (context.time - motion.phaseStartedAt) / CINDER_BREATH.sweepDuration));
motion.breathAngle = motion.breathStartAngle + (motion.breathEndAngle - motion.breathStartAngle) * progress;
@@ -135,7 +135,7 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
motion.mechanicNextDamageAt[member.id] = tickAt;
if (!motion.mechanicHitIds.includes(member.id)) {
motion.mechanicHitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is scorched by Searing Sweep.`, tone: "danger", pulseKind: "breath", targetId: member.id });
events.push({ at: context.time, message: `${member.name} is struck by Burst Stream.`, tone: "danger", pulseKind: "breath", targetId: member.id });
}
return next;
});
@@ -152,10 +152,10 @@ export function advanceCindermawMechanics(context: BossMechanicContext): BossMec
export function upcomingCindermawMechanic(boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
void boss;
if (motion.mode === "breath_telegraph") return { name: "Searing Sweep", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.telegraphDuration, urgent: true };
if (motion.mode === "breath_telegraph") return { name: "Burst Stream", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.telegraphDuration, urgent: true };
if (motion.mode === "breath_sweeping") return { name: "Rotate behind", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_BREATH.sweepDuration, urgent: true };
if (motion.mode === "skyfall") return { name: "Skyfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_SKYFALL.warning + CINDER_SKYFALL.stagger * 2, urgent: true };
if (motion.mode === "skyfall") return { name: "White Skyfall", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CINDER_SKYFALL.warning + CINDER_SKYFALL.stagger * 2, urgent: true };
const nextIsBreath = motion.mechanicCount % 2 === 0;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: nextIsBreath ? "Searing Sweep" : "Skyfall", remaining, cycle: 8, urgent: remaining < 2.5 };
return { name: nextIsBreath ? "Burst Stream" : "White Skyfall", remaining, cycle: 8, urgent: remaining < 2.5 };
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import type { BossMotionState, BossState, WorldPosition } from "../types";
import { advanceCragclawMechanics, CRAGCLAW, createCragclawMotion, createCragclawState } from "./cragclawCrab";
import { advanceCrownshardMechanics, CROWNSHARD, createCrownshardMotion, createCrownshardState } from "./crownshardGolem";
import { advanceMournveilMechanics, createMournveilMotion, createMournveilState, MOURNVEIL } from "./mournveilGhost";
import type { BossMechanicContext } from "./types";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function context(
boss: BossState,
motion: BossMotionState,
time: number,
delta = 0.1,
positions = POSITIONS,
party = freshParty(),
): BossMechanicContext {
return {
boss,
motion,
party,
partyPositions: structuredClone(positions),
time,
delta,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
describe("ClaudeCraft boss trio mechanics", () => {
it("telegraphs and resolves Cragclaw Sidewinder Rush", () => {
const start = advanceCragclawMechanics(context(createCragclawState(), createCragclawMotion(), CRAGCLAW.firstAt));
expect(start.motion.mode).toBe("crab_scuttle_telegraph");
expect(start.motion.slashLanes).toHaveLength(1);
const active = advanceCragclawMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(active.motion.mode).toBe("crab_scuttling");
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
const impact = advanceCragclawMechanics(context(active.boss, active.motion, active.motion.phaseEndsAt, 2, POSITIONS, active.party));
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - CRAGCLAW.scuttleDamage);
});
it("places three Crushing Tide warnings on party positions", () => {
const motion = { ...createCragclawMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceCragclawMechanics(context(createCragclawState(), motion, 0));
expect(start.motion.mode).toBe("crab_tidal_burst");
expect(start.motion.hazards.filter((hazard) => hazard.kind === "tidal_burst")).toHaveLength(3);
const positions = structuredClone(POSITIONS);
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
const hpBefore = start.party.find((member) => member.id === "aelia")!.hp;
const impact = advanceCragclawMechanics(context(start.boss, start.motion, CRAGCLAW.tidalWarning + 0.05, 0.1, positions, start.party));
expect(impact.party.find((member) => member.id === "aelia")!.hp).toBe(hpBefore - CRAGCLAW.tidalDamage);
});
it("rotates Mournveil Soul Scissors for a second crossing pattern", () => {
const start = advanceMournveilMechanics(context(createMournveilState(), createMournveilMotion(), MOURNVEIL.firstAt));
const firstLaneIds = start.motion.slashLanes.map((lane) => lane.id);
expect(start.motion.mode).toBe("ghost_soul_cross");
expect(start.motion.slashLanes).toHaveLength(2);
const followup = advanceMournveilMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(followup.motion.mode).toBe("ghost_soul_cross_followup");
expect(followup.motion.slashLanes).toHaveLength(2);
expect(followup.motion.slashLanes.map((lane) => lane.id)).not.toEqual(firstLaneIds);
const resolved = advanceMournveilMechanics(context(followup.boss, followup.motion, followup.motion.phaseEndsAt, 0.1, POSITIONS, followup.party));
expect(resolved.motion.mode).toBe("ghost_recover");
});
it("opens two persistent Haunting Rifts", () => {
const motion = { ...createMournveilMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceMournveilMechanics(context(createMournveilState(), motion, 0));
const rifts = start.motion.hazards.filter((hazard) => hazard.kind === "soul_rift");
expect(start.motion.mode).toBe("ghost_haunting");
expect(rifts).toHaveLength(2);
expect(rifts[0].expiresAt - rifts[0].activatesAt).toBe(MOURNVEIL.riftDuration);
});
it("builds three non-overlapping Crownshard shockwave bands", () => {
const start = advanceCrownshardMechanics(context(createCrownshardState(), createCrownshardMotion(), CROWNSHARD.firstAt));
const rings = start.motion.hazards.filter((hazard) => hazard.kind === "royal_shockwave");
expect(start.motion.mode).toBe("golem_shockwave");
expect(rings).toHaveLength(3);
expect(rings.map((ring) => [ring.innerRadius ?? 0, ring.radius])).toEqual([[0, 2.35], [2.35, 4.7], [4.7, 7.05]]);
const positions = structuredClone(POSITIONS);
positions.aelia = [...rings[0].center];
positions.nia = [rings[0].center[0] + 3, rings[0].center[1]];
const first = advanceCrownshardMechanics(context(start.boss, start.motion, rings[0].activatesAt + 0.05, 0.1, positions, start.party));
const aeliaAfterFirst = first.party.find((member) => member.id === "aelia")!.hp;
const niaAfterFirst = first.party.find((member) => member.id === "nia")!.hp;
expect(aeliaAfterFirst).toBe(start.party.find((member) => member.id === "aelia")!.hp - CROWNSHARD.shockwaveDamage);
expect(niaAfterFirst).toBe(start.party.find((member) => member.id === "nia")!.hp);
const second = advanceCrownshardMechanics(context(first.boss, first.motion, rings[1].activatesAt + 0.05, 0.1, positions, first.party));
expect(second.party.find((member) => member.id === "aelia")!.hp).toBe(aeliaAfterFirst);
expect(second.party.find((member) => member.id === "nia")!.hp).toBe(niaAfterFirst - CROWNSHARD.shockwaveDamage);
});
it("marks three allies with Crownfall", () => {
const motion = { ...createCrownshardMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const result = advanceCrownshardMechanics(context(createCrownshardState(), motion, 0));
expect(result.motion.mode).toBe("golem_crownfall");
expect(result.motion.hazards.filter((hazard) => hazard.kind === "crownfall")).toHaveLength(3);
});
});
+147
View File
@@ -0,0 +1,147 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, memberName, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CRAGCLAW = {
firstAt: 5.1,
repeatDelay: 3.7,
scuttleWarning: 1.35,
scuttleSpeed: 11.8,
scuttleDistance: 13,
scuttleWidth: 2.35,
scuttleDamage: 24,
tidalWarning: 1.45,
tidalRadius: 1.7,
tidalDamage: 26,
recoverDuration: 0.72,
} as const;
const SCUTTLE_TARGETS: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
const TIDAL_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia", "orin"],
["brann", "vale", "aelia"],
["nia", "orin", "vale"],
];
export function createCragclawState(): BossState {
const definition = BOSS_DEFINITIONS["cragclaw-crab"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.3);
}
export function createCragclawMotion(): BossMotionState {
return { ...createBaseMotion("cragclaw-crab"), position: [0, -6.4], nextMechanicAt: CRAGCLAW.firstAt };
}
function scuttleEnd(start: WorldPosition, target: WorldPosition): WorldPosition {
const angle = angleTo(start, target);
return clampToArena([
start[0] + Math.sin(angle) * CRAGCLAW.scuttleDistance,
start[1] + Math.cos(angle) * CRAGCLAW.scuttleDistance,
]);
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(context.party, SCUTTLE_TARGETS, motion.mechanicCount);
const end = scuttleEnd(motion.position, context.partyPositions[targetId]);
const lane: SlashLane = {
id: `cragclaw-scuttle-${mechanicCount}`,
start: [...motion.position],
end,
width: CRAGCLAW.scuttleWidth,
damage: CRAGCLAW.scuttleDamage,
};
events.push({ at: context.time, message: `Cragclaw lines up Sidewinder Rush on ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
return {
...motion,
mode: "crab_scuttle_telegraph" as const,
chargeTargetId: targetId,
chargeStart: [...motion.position] as WorldPosition,
chargeEnd: end,
chargeHitIds: [],
slashLanes: [lane],
phaseStartedAt: context.time,
phaseEndsAt: context.time + CRAGCLAW.scuttleWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
};
}
const activatesAt = context.time + CRAGCLAW.tidalWarning;
const targetSet = TIDAL_TARGETS[Math.floor(motion.mechanicCount / 2) % TIDAL_TARGETS.length];
events.push({ at: context.time, message: "Crushing Tide marks three allies. Spread before the claws close.", tone: "danger", pulseKind: "skyfall" });
return {
...motion,
mode: "crab_tidal_burst" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.3,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `cragclaw-tide-${mechanicCount}-${index}`,
kind: "tidal_burst",
center: context.partyPositions[targetId],
radius: CRAGCLAW.tidalRadius,
activatesAt,
duration: 0.3,
damage: CRAGCLAW.tidalDamage,
})),
],
};
}
export function advanceCragclawMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.05 * context.delta);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "crab_scuttle_telegraph" && context.time >= motion.phaseEndsAt) {
motion = {
...motion,
mode: "crab_scuttling",
phaseStartedAt: context.time,
phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / CRAGCLAW.scuttleSpeed,
};
events.push({ at: context.time, message: "Sidewinder Rush! Clear the surf lane.", tone: "danger", pulseKind: "charge" });
} else if (motion.mode === "crab_scuttling") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, CRAGCLAW.scuttleSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id)) return member;
if (pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > CRAGCLAW.scuttleWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is crushed by Sidewinder Rush.`, tone: "danger", pulseKind: "charge", targetId: member.id });
return { ...context.damageMember(member, CRAGCLAW.scuttleDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.42 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) {
motion = { ...motion, position: [...motion.chargeEnd], mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
}
} else if (motion.mode === "crab_tidal_burst" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "crab_recover", phaseEndsAt: context.time + CRAGCLAW.recoverDuration };
} else if (motion.mode === "crab_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CRAGCLAW.repeatDelay, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCragclawMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "crab_scuttle_telegraph" || motion.mode === "crab_scuttling") return { name: "Sidewinder Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.scuttleWarning, urgent: true };
if (motion.mode === "crab_tidal_burst") return { name: "Crushing Tide — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.tidalWarning, urgent: true };
if (motion.mode === "crab_recover") return { name: "Cragclaw exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CRAGCLAW.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Sidewinder Rush" : "Crushing Tide", remaining, cycle: CRAGCLAW.repeatDelay + CRAGCLAW.scuttleWarning, urgent: remaining < 2.5 };
}
+119
View File
@@ -0,0 +1,119 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { moveToward } from "../geometry";
import type { BossMotionState, BossState, MemberId } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const CROWNSHARD = {
firstAt: 5.5,
repeatDelay: 4,
shockwaveWarning: 1.2,
shockwaveInterval: 0.65,
shockwaveDamage: 19,
crownfallWarning: 1.5,
crownfallRadius: 1.85,
crownfallDamage: 28,
recoverDuration: 0.78,
} as const;
const CROWNFALL_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia", "orin"],
["brann", "vale", "aelia"],
["nia", "orin", "vale"],
];
export function createCrownshardState(): BossState {
const definition = BOSS_DEFINITIONS["crownshard-golem"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.4);
}
export function createCrownshardMotion(): BossMotionState {
return { ...createBaseMotion("crownshard-golem"), position: [0, -6.7], nextMechanicAt: CROWNSHARD.firstAt };
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const bands = [
{ innerRadius: 0, radius: 2.35 },
{ innerRadius: 2.35, radius: 4.7 },
{ innerRadius: 4.7, radius: 7.05 },
];
const firstActivation = context.time + CROWNSHARD.shockwaveWarning;
events.push({ at: context.time, message: "Tri-Burst expands in three rings. Move with each head's safe band.", tone: "danger", pulseKind: "boss" });
return {
...motion,
mode: "golem_shockwave" as const,
phaseStartedAt: context.time,
phaseEndsAt: firstActivation + CROWNSHARD.shockwaveInterval * (bands.length - 1) + 0.32,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...bands.map((band, index) => createCircleHazard({
id: `crownshard-shockwave-${mechanicCount}-${index}`,
kind: "royal_shockwave",
center: motion.position,
innerRadius: band.innerRadius,
radius: band.radius,
activatesAt: firstActivation + index * CROWNSHARD.shockwaveInterval,
duration: 0.3,
damage: CROWNSHARD.shockwaveDamage,
})),
],
};
}
const activatesAt = context.time + CROWNSHARD.crownfallWarning;
const targetSet = CROWNFALL_TARGETS[Math.floor(motion.mechanicCount / 2) % CROWNFALL_TARGETS.length];
events.push({ at: context.time, message: "Ultimate Skyfall marks three allies. Break formation before impact.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
return {
...motion,
mode: "golem_crownfall" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.32,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `crownshard-fall-${mechanicCount}-${index}`,
kind: "crownfall",
center: context.partyPositions[targetId],
radius: CROWNSHARD.crownfallRadius,
activatesAt,
duration: 0.3,
damage: CROWNSHARD.crownfallDamage,
})),
],
};
}
export function advanceCrownshardMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.65], 1.55 * context.delta);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if ((motion.mode === "golem_shockwave" || motion.mode === "golem_crownfall") && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "golem_recover", phaseEndsAt: context.time + CROWNSHARD.recoverDuration };
} else if (motion.mode === "golem_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + CROWNSHARD.repeatDelay };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.3, 15, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingCrownshardMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "golem_shockwave") return { name: "Tri-Burst — follow rings", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.shockwaveWarning + CROWNSHARD.shockwaveInterval * 2, urgent: true };
if (motion.mode === "golem_crownfall") return { name: "Ultimate Skyfall — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.crownfallWarning, urgent: true };
if (motion.mode === "golem_recover") return { name: "Ultimate Dragon exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: CROWNSHARD.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Tri-Burst" : "Ultimate Skyfall", remaining, cycle: CROWNSHARD.repeatDelay + CROWNSHARD.shockwaveWarning, urgent: remaining < 2.5 };
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import { pointToSegmentDistance } from "../geometry";
import { evadeSlashLanesBehavior } from "../partyBehaviors";
import type { BossMechanicContext } from "./types";
import {
advanceEmberMantisMechanics,
createEmberMantisMotion,
createEmberMantisState,
EMBER_MANTIS_SLASH,
} from "./emberMantis";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function context(
boss: ReturnType<typeof createEmberMantisState>,
motion: ReturnType<typeof createEmberMantisMotion>,
party = freshParty(),
time = 0,
delta = 0.1,
): BossMechanicContext {
return {
boss,
motion,
party,
partyPositions: structuredClone(POSITIONS),
time,
delta,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
describe("Gate Guardian mechanics", () => {
it("sidesteps, telegraphs Elemental Beam, then damages targets left in the lane", () => {
const boss = createEmberMantisState();
const sidestep = advanceEmberMantisMechanics(context(boss, createEmberMantisMotion(), freshParty(), 5, 0.1));
expect(sidestep.motion.mode).toBe("mantis_sidestep");
expect(sidestep.motion.chargeTargetId).toBe("nia");
const telegraph = advanceEmberMantisMechanics(context(sidestep.boss, sidestep.motion, sidestep.party, 5.6, 0.6));
expect(telegraph.motion.mode).toBe("mantis_line_telegraph");
expect(telegraph.motion.slashLanes).toHaveLength(1);
const niaBefore = telegraph.party.find((member) => member.id === "nia")!.hp;
const impact = advanceEmberMantisMechanics(context(telegraph.boss, telegraph.motion, telegraph.party, 6.51, 0.91));
expect(impact.motion.mode).toBe("mantis_recover");
expect(impact.motion.mechanicHitIds).toContain("nia");
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - EMBER_MANTIS_SLASH.lineDamage);
expect(impact.events.some((event) => event.message.includes("Elemental Beam"))).toBe(true);
});
it("alternates into two crossed slash lanes", () => {
const motion = {
...createEmberMantisMotion(),
mode: "mantis_sidestep" as const,
mechanicCount: 1,
chargeTargetId: "orin" as const,
chargeEnd: [0, -6.6] as [number, number],
phaseEndsAt: 0,
};
const result = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1));
expect(result.motion.mode).toBe("mantis_cross_telegraph");
expect(result.motion.slashLanes).toHaveLength(2);
expect(result.motion.slashLanes[0].id).toContain("cross");
});
it("gives mobile allies a target outside crossed lanes", () => {
const motion = {
...createEmberMantisMotion(),
mode: "mantis_sidestep" as const,
mechanicCount: 1,
chargeTargetId: "orin" as const,
chargeEnd: [0, -6.6] as [number, number],
phaseEndsAt: 0,
};
const telegraph = advanceEmberMantisMechanics(context(createEmberMantisState(), motion, freshParty(), 1, 0.1)).motion;
const decision = evadeSlashLanesBehavior.decide({
memberId: "orin",
current: POSITIONS.orin,
formationTarget: POSITIONS.orin,
bossMotion: telegraph,
partyPositions: POSITIONS,
time: 1,
});
expect(decision).not.toBeNull();
const minimumLaneDistance = Math.min(...telegraph.slashLanes.map((lane) =>
pointToSegmentDistance(decision!.target, lane.start, lane.end),
));
expect(minimumLaneDistance).toBeGreaterThan(EMBER_MANTIS_SLASH.aiClearance);
});
});
+247
View File
@@ -0,0 +1,247 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const EMBER_MANTIS_SLASH = {
firstAt: 5,
repeatDelay: 3.4,
sidestepDuration: 0.55,
sidestepDistance: 3.8,
sidestepSpeed: 7.2,
telegraphDuration: 0.9,
recoverDuration: 0.7,
laneLength: 18,
lineWidth: 1.65,
crossWidth: 1.45,
lineDamage: 32,
crossDamage: 25,
crossAngle: Math.PI * 0.18,
staggerDuration: 0.35,
aiClearance: 1.35,
aiEvadeSpeed: 4.8,
} as const;
const TARGET_ORDER: readonly MemberId[] = ["nia", "orin", "aelia", "vale", "brann"];
const MIN_BOSS_X = -5.8;
const MAX_BOSS_X = 5.8;
export function createEmberMantisState(): BossState {
const definition = BOSS_DEFINITIONS["ember-mantis-duelist"];
return {
id: definition.id,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.2,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createEmberMantisMotion(): BossMotionState {
return {
...createBaseMotion("ember-mantis-duelist"),
position: [0, -6.6],
nextMechanicAt: EMBER_MANTIS_SLASH.firstAt,
};
}
function clampBossX(value: number) {
return Math.max(MIN_BOSS_X, Math.min(MAX_BOSS_X, value));
}
function createLane(
id: string,
center: WorldPosition,
angle: number,
width: number,
damage: number,
): SlashLane {
const halfLength = EMBER_MANTIS_SLASH.laneLength * 0.5;
const dx = Math.sin(angle) * halfLength;
const dz = Math.cos(angle) * halfLength;
return {
id,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width,
damage,
};
}
function beginSidestep(
motion: BossMotionState,
party: BossMechanicContext["party"],
partyPositions: BossMechanicContext["partyPositions"],
time: number,
) {
const targetId = chooseLivingTarget(party, TARGET_ORDER, motion.mechanicCount);
const direction = motion.mechanicCount % 2 === 0 ? 1 : -1;
let targetX = clampBossX(motion.position[0] + direction * EMBER_MANTIS_SLASH.sidestepDistance);
if (Math.abs(targetX - motion.position[0]) < 1) {
targetX = clampBossX(motion.position[0] - direction * EMBER_MANTIS_SLASH.sidestepDistance);
}
return {
...motion,
mode: "mantis_sidestep" as const,
chargeTargetId: targetId,
chargeEnd: [targetX, motion.position[1]] as WorldPosition,
phaseStartedAt: time,
phaseEndsAt: time + EMBER_MANTIS_SLASH.sidestepDuration,
nextMechanicAt: Number.POSITIVE_INFINITY,
slashLanes: [],
mechanicHitIds: [],
pounceCenter: [partyPositions[targetId][0], partyPositions[targetId][1]] as WorldPosition,
};
}
function beginSlashTelegraph(
motion: BossMotionState,
partyPositions: BossMechanicContext["partyPositions"],
time: number,
) {
const target = partyPositions[motion.chargeTargetId];
const aimedAngle = angleTo(motion.position, target);
const isCrossSlash = motion.mechanicCount % 2 === 1;
const slashNumber = motion.mechanicCount + 1;
const lanes = isCrossSlash
? [
createLane(`cross-${slashNumber}-left`, target, aimedAngle - EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
createLane(`cross-${slashNumber}-right`, target, aimedAngle + EMBER_MANTIS_SLASH.crossAngle, EMBER_MANTIS_SLASH.crossWidth, EMBER_MANTIS_SLASH.crossDamage),
]
: [createLane(`line-${slashNumber}`, target, aimedAngle, EMBER_MANTIS_SLASH.lineWidth, EMBER_MANTIS_SLASH.lineDamage)];
return {
...motion,
mode: isCrossSlash ? "mantis_cross_telegraph" as const : "mantis_line_telegraph" as const,
phaseStartedAt: time,
phaseEndsAt: time + EMBER_MANTIS_SLASH.telegraphDuration,
mechanicCount: slashNumber,
slashLanes: lanes,
mechanicHitIds: [],
};
}
function resolveSlash(
motion: BossMotionState,
context: BossMechanicContext,
events: BossMechanicResult["events"],
) {
const isCrossSlash = motion.mode === "mantis_cross_telegraph";
const hitIds: MemberId[] = [];
const party = context.party.map((member) => {
if (member.hp <= 0) return member;
const lane = motion.slashLanes.find((candidate) =>
pointToSegmentDistance(context.partyPositions[member.id], candidate.start, candidate.end) <= candidate.width * 0.5,
);
if (!lane) return member;
hitIds.push(member.id);
events.push({
at: context.time,
message: `${member.name} is caught by ${isCrossSlash ? "Guardian Cross" : "Elemental Beam"}.`,
tone: "danger",
pulseKind: "slash",
targetId: member.id,
});
return {
...context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time),
knockedUntil: Math.max(member.knockedUntil, context.time + EMBER_MANTIS_SLASH.staggerDuration),
};
});
return { party, hitIds };
}
export function advanceEmberMantisMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
let party = context.party;
const events: BossMechanicResult["events"] = [];
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(
motion.position,
[tank[0] + motion.formationOffsetX, tank[1] - 4.25],
2.5 * context.delta,
);
if (context.time >= motion.nextMechanicAt) {
motion = beginSidestep(motion, party, context.partyPositions, context.time);
events.push({
at: context.time,
message: `Gate Guardian shifts toward ${memberName(party, motion.chargeTargetId)}. Track its arms.`,
tone: "danger",
pulseKind: "slash",
targetId: motion.chargeTargetId,
});
}
} else if (motion.mode === "mantis_sidestep") {
motion.position = moveToward(motion.position, motion.chargeEnd, EMBER_MANTIS_SLASH.sidestepSpeed * context.delta);
if (context.time >= motion.phaseEndsAt) {
motion = beginSlashTelegraph(motion, context.partyPositions, context.time);
const cross = motion.mode === "mantis_cross_telegraph";
events.push({
at: context.time,
message: cross ? "Guardian Cross! Find a safe quadrant." : "Elemental Beam! Clear the glowing lane.",
tone: "danger",
pulseKind: "slash",
targetId: motion.chargeTargetId,
});
}
} else if (motion.mode === "mantis_line_telegraph" || motion.mode === "mantis_cross_telegraph") {
if (context.time >= motion.phaseEndsAt) {
const resolved = resolveSlash(motion, context, events);
party = resolved.party;
motion = {
...motion,
mode: "mantis_recover",
phaseStartedAt: context.time,
phaseEndsAt: context.time + EMBER_MANTIS_SLASH.recoverDuration,
mechanicHitIds: resolved.hitIds,
};
}
} else if (motion.mode === "mantis_recover" && context.time >= motion.phaseEndsAt) {
motion = {
...motion,
mode: "holding",
phaseStartedAt: context.time,
phaseEndsAt: 0,
nextMechanicAt: context.time + EMBER_MANTIS_SLASH.repeatDelay,
slashLanes: [],
mechanicHitIds: [],
};
}
applyMelee(boss, motion, party, context.partyPositions, context.time, 1.9, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingEmberMantisMechanic(
boss: BossState,
motion: BossMotionState,
time: number,
): UpcomingMechanic {
void boss;
if (motion.mode === "mantis_sidestep") {
return { name: "Guardian repositioning", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.sidestepDuration, urgent: true };
}
if (motion.mode === "mantis_line_telegraph") {
return { name: "Elemental Beam — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
}
if (motion.mode === "mantis_cross_telegraph") {
return { name: "Guardian Cross — safe quadrant", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.telegraphDuration, urgent: true };
}
if (motion.mode === "mantis_recover") {
return { name: "Gate Guardian exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: EMBER_MANTIS_SLASH.recoverDuration, urgent: false };
}
const nextIsCross = motion.mechanicCount % 2 === 1;
const remaining = Math.max(0, motion.nextMechanicAt - time);
return {
name: nextIsCross ? "Guardian Cross" : "Elemental Beam",
remaining,
cycle: EMBER_MANTIS_SLASH.repeatDelay + EMBER_MANTIS_SLASH.telegraphDuration,
urgent: remaining < 2.5,
};
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { freshParty } from "../data";
import type { BossMotionState, BossState, WorldPosition } from "../types";
import { advanceCinderbackMechanics, CINDERBACK, createCinderbackMotion, createCinderbackState } from "./cinderbackRicochet";
import { advanceObsidianRamMechanics, createObsidianRamMotion, createObsidianRamState, OBSIDIAN_RAM } from "./obsidianRamGolem";
import { advanceSandglassMechanics, createSandglassMotion, createSandglassState, SANDGLASS } from "./sandglassScorpion";
import type { BossMechanicContext } from "./types";
const POSITIONS: BossMechanicContext["partyPositions"] = {
aelia: [0, 4.5],
brann: [0, 0],
nia: [-3, 2],
orin: [3, 2],
vale: [0, -2],
};
function context(
boss: BossState,
motion: BossMotionState,
time: number,
delta = 0.1,
positions = POSITIONS,
party = freshParty(),
): BossMechanicContext {
return {
boss,
motion,
party,
partyPositions: structuredClone(positions),
time,
delta,
damageMember: (member, amount) => ({ ...member, hp: Math.max(0, member.hp - amount) }),
};
}
describe("IWT2 boss trio mechanics", () => {
it("telegraphs and resolves Obsidian Ram Plate Charge", () => {
const start = advanceObsidianRamMechanics(context(createObsidianRamState(), createObsidianRamMotion(), OBSIDIAN_RAM.firstAt));
expect(start.motion.mode).toBe("ram_charge_telegraph");
expect(start.motion.slashLanes).toHaveLength(1);
const active = advanceObsidianRamMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
expect(active.motion.mode).toBe("ram_charging");
const niaBefore = active.party.find((member) => member.id === "nia")!.hp;
const impact = advanceObsidianRamMechanics(context(active.boss, active.motion, active.motion.phaseStartedAt + 1, 1, POSITIONS, active.party));
expect(impact.party.find((member) => member.id === "nia")!.hp).toBe(niaBefore - OBSIDIAN_RAM.chargeDamage);
});
it("builds three Armor Shatter fault lanes", () => {
const motion = { ...createObsidianRamMotion(), mechanicCount: 2, nextMechanicAt: 0 };
const result = advanceObsidianRamMechanics(context(createObsidianRamState(), motion, 0));
expect(result.motion.mode).toBe("ram_shatter");
expect(result.motion.slashLanes).toHaveLength(3);
});
it("executes both Cinderback rebounds and leaves lava at each impact", () => {
const start = advanceCinderbackMechanics(context(createCinderbackState(), createCinderbackMotion(), CINDERBACK.firstAt));
const firstRush = advanceCinderbackMechanics(context(start.boss, start.motion, start.motion.phaseEndsAt, 0.1, POSITIONS, start.party));
const firstImpact = advanceCinderbackMechanics(context(firstRush.boss, firstRush.motion, firstRush.motion.phaseStartedAt + 2, 2, POSITIONS, firstRush.party));
expect(firstImpact.motion.mode).toBe("cinderback_ricochet");
expect(firstImpact.motion.chargeCount).toBe(1);
expect(firstImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(1);
const secondImpact = advanceCinderbackMechanics(context(firstImpact.boss, firstImpact.motion, firstImpact.motion.phaseEndsAt, 2, POSITIONS, firstImpact.party));
expect(secondImpact.motion.mode).toBe("cinderback_recover");
expect(secondImpact.motion.hazards.filter((hazard) => hazard.kind === "lava_pool")).toHaveLength(2);
});
it("turns Sandglass stinger warnings into an active hourglass zone", () => {
const motion = { ...createSandglassMotion(), mechanicCount: 1, nextMechanicAt: 0 };
const start = advanceSandglassMechanics(context(createSandglassState(), motion, 0));
expect(start.motion.mode).toBe("sandglass_eruption");
expect(start.motion.hazards.filter((hazard) => hazard.kind === "stinger_eruption")).toHaveLength(3);
const positions = structuredClone(POSITIONS);
positions.aelia = [...start.motion.hazards[0].center] as WorldPosition;
const aeliaBefore = start.party[0].hp;
const eruption = advanceSandglassMechanics(context(start.boss, start.motion, SANDGLASS.eruptionWarning + 0.05, 0.1, positions, start.party));
expect(eruption.party[0].hp).toBe(aeliaBefore - SANDGLASS.eruptionDamage);
const hourglass = advanceSandglassMechanics(context(eruption.boss, eruption.motion, eruption.motion.phaseEndsAt + 0.01, 0.1, POSITIONS, eruption.party));
expect(hourglass.motion.mode).toBe("sandglass_hourglass");
expect(hourglass.motion.hazards.some((hazard) => hazard.kind === "hourglass")).toBe(true);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, cloneMotion, createBaseMotion, createBossStateFor, createCircleHazard, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const MOURNVEIL = {
firstAt: 5.3,
repeatDelay: 3.9,
crossWarning: 1.35,
followupWarning: 1.05,
laneWidth: 1.55,
laneDamage: 22,
riftWarning: 1.45,
riftRadius: 1.75,
riftDamage: 5,
riftDuration: 4.2,
recoverDuration: 0.75,
} as const;
const RIFT_TARGETS: readonly (readonly MemberId[])[] = [
["aelia", "nia"],
["orin", "vale"],
["brann", "aelia"],
];
export function createMournveilState(): BossState {
const definition = BOSS_DEFINITIONS["mournveil-ghost"];
return createBossStateFor(definition.id, definition.name, definition.maxHp, 2.35);
}
export function createMournveilMotion(): BossMotionState {
return { ...createBaseMotion("mournveil-ghost"), position: [0, -6.6], nextMechanicAt: MOURNVEIL.firstAt };
}
function crossLanes(center: WorldPosition, angle: number, mechanicCount: number, phase: number): SlashLane[] {
return [angle, angle + Math.PI / 2].map((laneAngle, index) => {
const dx = Math.sin(laneAngle) * 9;
const dz = Math.cos(laneAngle) * 9;
return {
id: `mournveil-cross-${mechanicCount}-${phase}-${index}`,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width: MOURNVEIL.laneWidth,
damage: MOURNVEIL.laneDamage,
};
});
}
function resolveCross(motion: BossMotionState, context: BossMechanicContext, party: BossMechanicContext["party"], events: BossMechanicResult["events"]) {
const hitIds: MemberId[] = [];
const nextParty = party.map((member) => {
if (member.hp <= 0) return member;
const hit = motion.slashLanes.some((lane) => pointToSegmentDistance(context.partyPositions[member.id], lane.start, lane.end) <= lane.width * 0.5);
if (!hit) return member;
hitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is cut by Vine Scissors.`, tone: "danger", pulseKind: "slash", targetId: member.id });
return context.damageMember(member, MOURNVEIL.laneDamage, context.partyPositions[member.id], context.time);
});
return { party: nextParty, hitIds };
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const mechanicCount = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = (["aelia", "nia", "orin", "vale", "brann"] as const)[motion.mechanicCount % 5];
const angle = angleTo(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: "Vine Scissors carve a spectral cross. A second cut will rotate.", tone: "danger", pulseKind: "slash", targetId });
return {
...motion,
mode: "ghost_soul_cross" as const,
breathStartAngle: angle,
chargeCount: 0,
phaseStartedAt: context.time,
phaseEndsAt: context.time + MOURNVEIL.crossWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
mechanicHitIds: [],
slashLanes: crossLanes(motion.position, angle, mechanicCount, 0),
};
}
const activatesAt = context.time + MOURNVEIL.riftWarning;
const targetSet = RIFT_TARGETS[Math.floor(motion.mechanicCount / 2) % RIFT_TARGETS.length];
events.push({ at: context.time, message: "Haunting Rifts follow two allies. Carry them away from formation.", tone: "danger", pulseKind: "skyfall", targetId: targetSet[0] });
return {
...motion,
mode: "ghost_haunting" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.35,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [
...motion.hazards,
...targetSet.map((targetId, index) => createCircleHazard({
id: `mournveil-rift-${mechanicCount}-${index}`,
kind: "soul_rift",
center: context.partyPositions[targetId],
radius: MOURNVEIL.riftRadius,
activatesAt,
duration: MOURNVEIL.riftDuration,
damage: MOURNVEIL.riftDamage,
tickInterval: 0.8,
})),
],
};
}
export function advanceMournveilMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.65], 1.7 * context.delta);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "ghost_soul_cross" && context.time >= motion.phaseEndsAt) {
const resolved = resolveCross(motion, { ...context, party }, party, events);
party = resolved.party;
const followupAngle = motion.breathStartAngle + Math.PI / 4;
motion = {
...motion,
mode: "ghost_soul_cross_followup",
chargeCount: 1,
mechanicHitIds: resolved.hitIds,
slashLanes: crossLanes(motion.position, followupAngle, motion.mechanicCount, 1),
phaseStartedAt: context.time,
phaseEndsAt: context.time + MOURNVEIL.followupWarning,
};
events.push({ at: context.time, message: "Vine Scissors rotate. Find the new safe quadrant.", tone: "danger", pulseKind: "slash" });
} else if (motion.mode === "ghost_soul_cross_followup" && context.time >= motion.phaseEndsAt) {
const resolved = resolveCross(motion, { ...context, party }, party, events);
party = resolved.party;
motion = { ...motion, mode: "ghost_recover", mechanicHitIds: [...new Set([...motion.mechanicHitIds, ...resolved.hitIds])], phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
} else if (motion.mode === "ghost_haunting" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ghost_recover", phaseEndsAt: context.time + MOURNVEIL.recoverDuration };
} else if (motion.mode === "ghost_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + MOURNVEIL.repeatDelay, slashLanes: [], mechanicHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.25, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingMournveilMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "ghost_soul_cross") return { name: "Vine Scissors — first cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.crossWarning, urgent: true };
if (motion.mode === "ghost_soul_cross_followup") return { name: "Vine Scissors — rotated cross", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.followupWarning, urgent: true };
if (motion.mode === "ghost_haunting") return { name: "Haunting Rifts — spread", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.riftWarning, urgent: true };
if (motion.mode === "ghost_recover") return { name: "Pumpking exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: MOURNVEIL.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Vine Scissors" : "Haunting Rifts", remaining, cycle: MOURNVEIL.repeatDelay + MOURNVEIL.crossWarning, urgent: remaining < 2.5 };
}
+183
View File
@@ -0,0 +1,183 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const OBSIDIAN_RAM = {
firstAt: 5.5,
repeatDelay: 3.8,
chargeWarning: 1.45,
chargeSpeed: 11.5,
chargeDistance: 14,
chargeWidth: 2.5,
chargeDamage: 27,
quakeWarning: 1.35,
quakeRadius: 3.6,
quakeDamage: 31,
shatterWarning: 1.2,
shatterWidth: 1.25,
shatterDamage: 24,
recoverDuration: 0.7,
} as const;
const TARGETS: readonly MemberId[] = ["nia", "orin", "vale", "aelia", "brann"];
export function createObsidianRamState(): BossState {
const definition = BOSS_DEFINITIONS["obsidian-ram-golem"];
return {
id: definition.id,
name: definition.name,
maxHp: definition.maxHp,
hp: definition.maxHp,
nextMeleeAt: 2.3,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createObsidianRamMotion(): BossMotionState {
return { ...createBaseMotion("obsidian-ram-golem"), position: [0, -6.8], nextMechanicAt: OBSIDIAN_RAM.firstAt };
}
function laneAt(center: WorldPosition, angle: number, id: string): SlashLane {
const half = 9;
const dx = Math.sin(angle) * half;
const dz = Math.cos(angle) * half;
return {
id,
start: [center[0] - dx, center[1] - dz],
end: [center[0] + dx, center[1] + dz],
width: OBSIDIAN_RAM.shatterWidth,
damage: OBSIDIAN_RAM.shatterDamage,
};
}
function endpoint(start: WorldPosition, target: WorldPosition): WorldPosition {
const angle = angleTo(start, target);
return clampToArena([
start[0] + Math.sin(angle) * OBSIDIAN_RAM.chargeDistance,
start[1] + Math.cos(angle) * OBSIDIAN_RAM.chargeDistance,
]);
}
function beginMechanic(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const index = motion.mechanicCount % 3;
const mechanicCount = motion.mechanicCount + 1;
if (index === 0) {
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
const end = endpoint(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: `Destruction Rush locks onto ${memberName(context.party, targetId)}.`, tone: "danger", pulseKind: "charge", targetId });
return {
...motion,
mode: "ram_charge_telegraph" as const,
chargeTargetId: targetId,
chargeStart: [...motion.position] as WorldPosition,
chargeEnd: end,
chargeHitIds: [],
slashLanes: [{ id: `ram-charge-${mechanicCount}`, start: [...motion.position] as WorldPosition, end, width: OBSIDIAN_RAM.chargeWidth, damage: OBSIDIAN_RAM.chargeDamage }],
phaseStartedAt: context.time,
phaseEndsAt: context.time + OBSIDIAN_RAM.chargeWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
};
}
if (index === 1) {
const activatesAt = context.time + OBSIDIAN_RAM.quakeWarning;
events.push({ at: context.time, message: "Ruin Quake! Leave the destruction circle.", tone: "danger", pulseKind: "boss" });
return {
...motion,
mode: "ram_quake" as const,
phaseStartedAt: context.time,
phaseEndsAt: activatesAt + 0.25,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
hazards: [...motion.hazards, {
id: `ram-quake-${mechanicCount}`,
kind: "quake" as const,
center: [...motion.position] as WorldPosition,
radius: OBSIDIAN_RAM.quakeRadius,
activatesAt,
expiresAt: activatesAt + 0.3,
damage: OBSIDIAN_RAM.quakeDamage,
nextDamageAt: {},
resolved: false,
hitIds: [],
}],
};
}
const targetId = chooseLivingTarget(context.party, TARGETS, motion.mechanicCount);
const aimed = angleTo(motion.position, context.partyPositions[targetId]);
events.push({ at: context.time, message: "Destruction Pulse! Step between the radial beams.", tone: "danger", pulseKind: "slash", targetId });
return {
...motion,
mode: "ram_shatter" as const,
phaseStartedAt: context.time,
phaseEndsAt: context.time + OBSIDIAN_RAM.shatterWarning,
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount,
mechanicHitIds: [],
slashLanes: [0, Math.PI / 3, -Math.PI / 3].map((offset, laneIndex) => laneAt(motion.position, aimed + offset, `ram-shatter-${mechanicCount}-${laneIndex}`)),
};
}
function resolveShatter(motion: BossMotionState, context: BossMechanicContext, events: BossMechanicResult["events"]) {
const hitIds: MemberId[] = [];
const party = context.party.map((member) => {
const lane = motion.slashLanes.find((entry) => pointToSegmentDistance(context.partyPositions[member.id], entry.start, entry.end) <= entry.width * 0.5);
if (member.hp <= 0 || !lane) return member;
hitIds.push(member.id);
events.push({ at: context.time, message: `${member.name} is struck by Destruction Pulse.`, tone: "danger", pulseKind: "slash", targetId: member.id });
return context.damageMember(member, lane.damage, context.partyPositions[member.id], context.time);
});
return { party, hitIds };
}
export function advanceObsidianRamMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 1.8 * context.delta);
if (context.time >= motion.nextMechanicAt) motion = beginMechanic(motion, { ...context, party }, events);
} else if (motion.mode === "ram_charge_telegraph" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ram_charging", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / OBSIDIAN_RAM.chargeSpeed };
events.push({ at: context.time, message: "Destruction Rush! Clear the lane.", tone: "danger", pulseKind: "charge" });
} else if (motion.mode === "ram_charging") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, OBSIDIAN_RAM.chargeSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > OBSIDIAN_RAM.chargeWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, OBSIDIAN_RAM.chargeDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.55 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_shatter" && context.time >= motion.phaseEndsAt) {
const resolved = resolveShatter(motion, { ...context, party }, events);
party = resolved.party;
motion = { ...motion, mode: "ram_recover", mechanicHitIds: resolved.hitIds, phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_quake" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "ram_recover", phaseEndsAt: context.time + OBSIDIAN_RAM.recoverDuration };
} else if (motion.mode === "ram_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + OBSIDIAN_RAM.repeatDelay, slashLanes: [], chargeHitIds: [], mechanicHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.2, 15, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingObsidianRamMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "ram_charge_telegraph" || motion.mode === "ram_charging") return { name: "Destruction Rush — clear lane", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.chargeWarning, urgent: true };
if (motion.mode === "ram_quake") return { name: "Ruin Quake — move out", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.quakeWarning, urgent: true };
if (motion.mode === "ram_shatter") return { name: "Destruction Pulse — find gap", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.shatterWarning, urgent: true };
if (motion.mode === "ram_recover") return { name: "Gandora exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: OBSIDIAN_RAM.recoverDuration, urgent: false };
const names = ["Destruction Rush", "Ruin Quake", "Destruction Pulse"];
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: names[motion.mechanicCount % 3], remaining, cycle: OBSIDIAN_RAM.repeatDelay + OBSIDIAN_RAM.chargeWarning, urgent: remaining < 2.5 };
}
+104
View File
@@ -0,0 +1,104 @@
import { clampToArena } from "../arena";
import { BOSS_DEFINITIONS } from "../bossCatalog";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossMotionState, BossState, CircleHazard, MemberId, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createBaseMotion, memberName, resolveCircleHazards } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
export const SANDGLASS = {
firstAt: 5.4,
repeatDelay: 3.8,
burrowWarning: 1.3,
burrowSpeed: 10.8,
burrowDistance: 13,
burrowWidth: 2,
burrowDamage: 25,
eruptionWarning: 1.55,
eruptionRadius: 1.75,
eruptionDamage: 27,
hourglassRadius: 2.55,
hourglassDamage: 6,
hourglassDuration: 3.6,
recoverDuration: 0.7,
} as const;
const TARGETS: readonly MemberId[] = ["aelia", "nia", "orin", "vale", "brann"];
const ERUPTION_TARGETS: readonly MemberId[][] = [["aelia", "nia", "orin"], ["brann", "vale", "aelia"], ["nia", "orin", "vale"]];
export function createSandglassState(): BossState {
const definition = BOSS_DEFINITIONS["sandglass-scorpion"];
return { id: definition.id, name: definition.name, maxHp: definition.maxHp, hp: definition.maxHp, nextMeleeAt: 2.2, nextNovaAt: Infinity, nextBrandAt: Infinity, brandCount: 0 };
}
export function createSandglassMotion(): BossMotionState {
return { ...createBaseMotion("sandglass-scorpion"), position: [0, -6.5], nextMechanicAt: SANDGLASS.firstAt };
}
function burrowEnd(start: WorldPosition, target: WorldPosition) {
const angle = angleTo(start, target);
return clampToArena([start[0] + Math.sin(angle) * SANDGLASS.burrowDistance, start[1] + Math.cos(angle) * SANDGLASS.burrowDistance] as WorldPosition);
}
function eruption(id: string, center: WorldPosition, activatesAt: number): CircleHazard {
return { id, kind: "stinger_eruption", center: [...center], radius: SANDGLASS.eruptionRadius, activatesAt, expiresAt: activatesAt + 0.32, damage: SANDGLASS.eruptionDamage, nextDamageAt: {}, resolved: false, hitIds: [] };
}
export function advanceSandglassMechanics(context: BossMechanicContext): BossMechanicResult {
const boss = { ...context.boss };
let motion = cloneMotion(context.motion);
const events: BossMechanicResult["events"] = [];
let party = context.party;
if (motion.mode === "holding") {
const tank = context.partyPositions.brann;
motion.position = moveToward(motion.position, [tank[0] + motion.formationOffsetX, tank[1] - 4.25], 2.1 * context.delta);
if (context.time >= motion.nextMechanicAt) {
const count = motion.mechanicCount + 1;
if (motion.mechanicCount % 2 === 0) {
const targetId = chooseLivingTarget(party, TARGETS, motion.mechanicCount);
const end = burrowEnd(motion.position, context.partyPositions[targetId]);
const lane: SlashLane = { id: `burrow-${count}`, start: [...motion.position], end, width: SANDGLASS.burrowWidth, damage: SANDGLASS.burrowDamage };
motion = { ...motion, mode: "sandglass_burrow_telegraph", chargeTargetId: targetId, chargeStart: [...motion.position], chargeEnd: end, chargeHitIds: [], phaseStartedAt: context.time, phaseEndsAt: context.time + SANDGLASS.burrowWarning, nextMechanicAt: Infinity, mechanicCount: count, slashLanes: [lane] };
events.push({ at: context.time, message: `Burrow Rush tracks ${memberName(party, targetId)}. Cross the sand trail.`, tone: "danger", pulseKind: "charge", targetId });
} else {
const activatesAt = context.time + SANDGLASS.eruptionWarning;
const targets = ERUPTION_TARGETS[Math.floor(motion.mechanicCount / 2) % ERUPTION_TARGETS.length];
motion = { ...motion, mode: "sandglass_eruption", phaseStartedAt: context.time, phaseEndsAt: activatesAt + 0.3, nextMechanicAt: Infinity, mechanicCount: count, hazards: [...motion.hazards, ...targets.map((targetId, index) => eruption(`stinger-${count}-${index}`, context.partyPositions[targetId], activatesAt))] };
events.push({ at: context.time, message: "Stinger Eruption! Leave the timed sand circles.", tone: "danger", pulseKind: "skyfall" });
}
}
} else if (motion.mode === "sandglass_burrow_telegraph" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "sandglass_burrowing", phaseStartedAt: context.time, phaseEndsAt: context.time + distance(motion.position, motion.chargeEnd) / SANDGLASS.burrowSpeed };
} else if (motion.mode === "sandglass_burrowing") {
const previous = [...motion.position] as WorldPosition;
motion.position = moveToward(motion.position, motion.chargeEnd, SANDGLASS.burrowSpeed * context.delta);
party = party.map((member) => {
if (member.hp <= 0 || motion.chargeHitIds.includes(member.id) || pointToSegmentDistance(context.partyPositions[member.id], previous, motion.position) > SANDGLASS.burrowWidth * 0.5) return member;
motion.chargeHitIds.push(member.id);
return { ...context.damageMember(member, SANDGLASS.burrowDamage, context.partyPositions[member.id], context.time), knockedUntil: context.time + 0.35 };
});
if (distance(motion.position, motion.chargeEnd) < 0.08 || context.time >= motion.phaseEndsAt) motion = { ...motion, position: [...motion.chargeEnd], mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
} else if (motion.mode === "sandglass_eruption" && context.time >= motion.phaseEndsAt) {
const center = clampToArena([motion.position[0], motion.position[1] + 3]);
const activatesAt = context.time + 0.9;
motion = { ...motion, mode: "sandglass_hourglass", phaseStartedAt: context.time, phaseEndsAt: activatesAt + SANDGLASS.hourglassDuration, hazards: [...motion.hazards, { id: `hourglass-${motion.mechanicCount}`, kind: "hourglass", center, radius: SANDGLASS.hourglassRadius, activatesAt, expiresAt: activatesAt + SANDGLASS.hourglassDuration, damage: SANDGLASS.hourglassDamage, tickInterval: 0.75, nextDamageAt: {}, resolved: false, hitIds: [] }] };
events.push({ at: context.time, message: "Hourglass zone turns active. Keep moving.", tone: "danger", pulseKind: "skyfall" });
} else if (motion.mode === "sandglass_hourglass" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "sandglass_recover", phaseEndsAt: context.time + SANDGLASS.recoverDuration };
} else if (motion.mode === "sandglass_recover" && context.time >= motion.phaseEndsAt) {
motion = { ...motion, mode: "holding", phaseEndsAt: 0, nextMechanicAt: context.time + SANDGLASS.repeatDelay, slashLanes: [], chargeHitIds: [] };
}
party = resolveCircleHazards(motion, party, context.partyPositions, context.time, context.damageMember, events);
applyMelee(boss, motion, party, context.partyPositions, context.time, 2.15, 14, context.damageMember);
return { boss, motion, party, events };
}
export function upcomingSandglassMechanic(_boss: BossState, motion: BossMotionState, time: number): UpcomingMechanic {
if (motion.mode === "sandglass_burrow_telegraph" || motion.mode === "sandglass_burrowing") return { name: "Burrow Rush — clear trail", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.burrowWarning, urgent: true };
if (motion.mode === "sandglass_eruption") return { name: "Stinger Eruption — move", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.eruptionWarning, urgent: true };
if (motion.mode === "sandglass_hourglass") return { name: "Hourglass zone active", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.hourglassDuration, urgent: true };
if (motion.mode === "sandglass_recover") return { name: "Chronarch exposed", remaining: Math.max(0, motion.phaseEndsAt - time), cycle: SANDGLASS.recoverDuration, urgent: false };
const remaining = Math.max(0, motion.nextMechanicAt - time);
return { name: motion.mechanicCount % 2 === 0 ? "Burrow Rush" : "Hourglass Eruption", remaining, cycle: SANDGLASS.repeatDelay + SANDGLASS.burrowWarning, urgent: remaining < 2.5 };
}
+79 -8
View File
@@ -1,7 +1,71 @@
import { distance } from "../geometry";
import type { BossId, BossMotionState, BossState, MemberId, PartyMember, WorldPosition } from "../types";
import type { BossId, BossMotionState, BossState, CircleHazard, CircleHazardKind, MemberId, PartyMember, WorldPosition } from "../types";
import type { BossMechanicContext, BossMechanicEvent } from "./types";
const PERSISTENT_HAZARD_KINDS = new Set<CircleHazardKind>(["venom_pool", "lava_pool", "hourglass", "soul_rift"]);
const HAZARD_LABELS = {
venom_pool: "Venom pool",
skyfall: "Skyfall",
quake: "Fracture Quake",
lava_pool: "Lava pool",
stinger_eruption: "Stinger Eruption",
hourglass: "Hourglass zone",
tidal_burst: "Crushing Tide",
soul_rift: "Soul rift",
crownfall: "Crownfall",
royal_shockwave: "Royal Shockwave",
} as const;
export function createBossStateFor(bossId: BossId, name: string, maxHp: number, nextMeleeAt: number): BossState {
return {
id: bossId,
name,
maxHp,
hp: maxHp,
nextMeleeAt,
nextNovaAt: Number.POSITIVE_INFINITY,
nextBrandAt: Number.POSITIVE_INFINITY,
brandCount: 0,
};
}
export function createCircleHazard({
id,
kind,
center,
innerRadius,
radius,
activatesAt,
duration,
damage,
tickInterval,
}: {
id: string;
kind: CircleHazardKind;
center: WorldPosition;
innerRadius?: number;
radius: number;
activatesAt: number;
duration: number;
damage: number;
tickInterval?: number;
}): CircleHazard {
return {
id,
kind,
center: [...center],
innerRadius,
radius,
activatesAt,
expiresAt: activatesAt + duration,
damage,
tickInterval,
nextDamageAt: {},
resolved: false,
hitIds: [],
};
}
export function createBaseMotion(bossId: BossId): BossMotionState {
return {
bossId,
@@ -30,6 +94,7 @@ export function createBaseMotion(bossId: BossId): BossMotionState {
breathStartAngle: 0,
breathEndAngle: 0,
hazards: [],
slashLanes: [],
};
}
@@ -50,6 +115,11 @@ export function cloneMotion(source: BossMotionState): BossMotionState {
nextDamageAt: { ...hazard.nextDamageAt },
hitIds: [...hazard.hitIds],
})),
slashLanes: source.slashLanes.map((lane) => ({
...lane,
start: [lane.start[0], lane.start[1]],
end: [lane.end[0], lane.end[1]],
})),
};
}
@@ -100,7 +170,8 @@ export function resolveCircleHazards(
const newlyHit: MemberId[] = [];
updatedParty = updatedParty.map((member) => {
if (member.hp <= 0) return member;
const inside = distance(positions[member.id], hazard.center) <= hazard.radius;
const hazardDistance = distance(positions[member.id], hazard.center);
const inside = hazardDistance <= hazard.radius && hazardDistance >= (hazard.innerRadius ?? 0);
if (!inside) {
delete hazard.nextDamageAt[member.id];
return member;
@@ -109,26 +180,26 @@ export function resolveCircleHazards(
hazard.hitIds.push(member.id);
newlyHit.push(member.id);
}
if (hazard.kind !== "venom_pool") {
if (!PERSISTENT_HAZARD_KINDS.has(hazard.kind)) {
return hazard.resolved || hazard.hitIds.includes(member.id) && !newlyHit.includes(member.id)
? member
: damageMember(member, hazard.damage, positions[member.id], time);
: damageMember(member, hazard.damage, positions[member.id], time, "hazard");
}
let next = member;
let tickAt = hazard.nextDamageAt[member.id] ?? time;
while (tickAt <= time + 0.001) {
next = damageMember(next, hazard.damage, positions[member.id], tickAt);
next = damageMember(next, hazard.damage, positions[member.id], tickAt, "hazard");
tickAt += hazard.tickInterval ?? 1;
}
hazard.nextDamageAt[member.id] = tickAt;
return next;
});
if (newlyHit.length && !hazard.resolved) {
const label = hazard.kind === "skyfall" ? "Skyfall" : "Venom pool";
events.push({ at: time, message: `${label} catches ${newlyHit.length} ally${newlyHit.length === 1 ? "" : "ies"}.`, tone: "danger", pulseKind: hazard.kind === "skyfall" ? "skyfall" : "venom" });
const pulseKind = hazard.kind === "venom_pool" ? "venom" : hazard.kind === "skyfall" || hazard.kind === "stinger_eruption" || hazard.kind === "hourglass" ? "skyfall" : "boss";
events.push({ at: time, message: `${HAZARD_LABELS[hazard.kind]} catches ${newlyHit.length} ally${newlyHit.length === 1 ? "" : "ies"}.`, tone: "danger", pulseKind });
}
if (newlyHit.length || hazard.kind === "skyfall" && time >= hazard.activatesAt) hazard.resolved = true;
if (!PERSISTENT_HAZARD_KINDS.has(hazard.kind) && (newlyHit.length || time >= hazard.activatesAt)) hazard.resolved = true;
}
motion.hazards = motion.hazards.filter((hazard) => hazard.expiresAt > time);
return updatedParty;
+1 -1
View File
@@ -22,7 +22,7 @@ export interface BossMechanicContext {
partyPositions: Record<MemberId, WorldPosition>;
time: number;
delta: number;
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number) => PartyMember;
damageMember: (member: PartyMember, amount: number, position: WorldPosition, at: number, kind?: "direct" | "hazard") => PartyMember;
}
export interface UpcomingMechanic {
+2 -2
View File
@@ -78,7 +78,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
tetherBreakDistance: VEXA_TETHER.breakDistance,
mechanicCount: motion.mechanicCount + 1,
};
events.push({ at: context.time, message: `Vexa binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
events.push({ at: context.time, message: `Insect Queen binds ${memberName(party, livingPair[0])} to ${memberName(party, livingPair[1])}. Spread apart!`, tone: "danger", pulseKind: "tether", targetId: livingPair[0] });
}
} else {
const targetSet = VENOM_TARGETS[Math.floor(motion.mechanicCount / 2) % VENOM_TARGETS.length];
@@ -103,7 +103,7 @@ export function advanceVexaMechanics(context: BossMechanicContext): BossMechanic
nextMechanicAt: Number.POSITIVE_INFINITY,
mechanicCount: motion.mechanicCount + 1,
};
events.push({ at: context.time, message: "Vexa injects Widow Venom. Move away before cleansing!", tone: "danger", pulseKind: "venom", targetId: targets[0] });
events.push({ at: context.time, message: "Insect Queen injects Widow Venom. Move away before cleansing!", tone: "danger", pulseKind: "venom", targetId: targets[0] });
}
} else if (motion.mode === "tethering") {
const [first, second] = motion.tetherIds;

Some files were not shown because too many files have changed in this diff Show More