Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b48b3a4f8f | ||
|
|
076f6cf97c | ||
|
|
6726c600e4 |
@@ -0,0 +1,121 @@
|
||||
# TrueNAS deployment
|
||||
|
||||
This game uses the same proven local-Gitea pattern as `testgame`: clone from the
|
||||
Gitea bare repository on the TrueNAS filesystem, mount that working checkout
|
||||
into one Node container, and update it with a local Git pull plus app restart.
|
||||
|
||||
## Paths
|
||||
|
||||
```text
|
||||
Local Gitea bare repository:
|
||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
||||
|
||||
Runnable working checkout:
|
||||
/mnt/usbssds/apps/iwanttoheal-mmo/app
|
||||
|
||||
Public URL:
|
||||
https://iwanttoheal.phenomrom.com
|
||||
|
||||
Container/host port:
|
||||
4173
|
||||
```
|
||||
|
||||
The bare Gitea repository is not a runnable application directory. Use a local
|
||||
`git clone`; do not copy the bare repository with `cp`. Local clone/pull keeps
|
||||
the source transfer on TrueNAS and creates the working tree the container needs.
|
||||
|
||||
## First installation
|
||||
|
||||
This configuration uses a separate `iwanttoheal-mmo` working directory, so it
|
||||
does not modify the old app checkout or its data.
|
||||
|
||||
Confirm the new repository path. If the first command fails, use the search:
|
||||
|
||||
```sh
|
||||
sudo test -d /mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
||||
sudo find /mnt -type d -name "i-want-to-heal-mmo.git" -prune -print 2>/dev/null
|
||||
```
|
||||
|
||||
Clone entirely through the local filesystem:
|
||||
|
||||
```sh
|
||||
sudo mkdir -p /mnt/usbssds/apps/iwanttoheal-mmo
|
||||
sudo git config --global --add safe.directory \
|
||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
||||
sudo git clone \
|
||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git \
|
||||
/mnt/usbssds/apps/iwanttoheal-mmo/app
|
||||
sudo chown -R truenas_admin:truenas_admin /mnt/usbssds/apps/iwanttoheal-mmo
|
||||
```
|
||||
|
||||
Verify the checkout:
|
||||
|
||||
```sh
|
||||
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app remote -v
|
||||
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app branch --show-current
|
||||
ls /mnt/usbssds/apps/iwanttoheal-mmo/app/package.json
|
||||
```
|
||||
|
||||
Expected branch: `main`. Expected origin: local Gitea path above.
|
||||
|
||||
## Install as a TrueNAS app
|
||||
|
||||
1. Open **Apps**.
|
||||
2. Open **Discover**.
|
||||
3. Open the three-dot menu.
|
||||
4. Select **Install via YAML**.
|
||||
5. Name the app `iwanttoheal-mmo`.
|
||||
6. Paste:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
iwanttoheal:
|
||||
image: node:24-bookworm-slim
|
||||
command: >-
|
||||
sh -lc "corepack pnpm install --frozen-lockfile && corepack pnpm run build && corepack pnpm start"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "4173"
|
||||
init: true
|
||||
ports:
|
||||
- "4173:4173"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/usbssds/apps/iwanttoheal-mmo/app:/app
|
||||
working_dir: /app
|
||||
```
|
||||
|
||||
This game has no server database. Do not add `db:init`, `/app/data`, cookie,
|
||||
CORS, or proxy environment settings from the older game.
|
||||
|
||||
The separate volume protects the old app files and data. The YAML still maps
|
||||
host port `4173`, so the old and MMO apps cannot run simultaneously while both
|
||||
use that host port. Stop the old app, or assign this app an unused host port and
|
||||
separate reverse-proxy hostname.
|
||||
|
||||
After deployment, test from the TrueNAS shell or another LAN machine:
|
||||
|
||||
```sh
|
||||
curl -I http://TRUENAS-IP:4173
|
||||
```
|
||||
|
||||
Expected result: HTTP `200`. Keep the existing HTTPS reverse proxy pointed at
|
||||
`TRUENAS-IP:4173` for `iwanttoheal.phenomrom.com`.
|
||||
|
||||
## Update workflow
|
||||
|
||||
Push `main` from the development Mac. Then run on TrueNAS:
|
||||
|
||||
```sh
|
||||
git -C /mnt/usbssds/apps/iwanttoheal-mmo/app pull --ff-only \
|
||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git \
|
||||
main
|
||||
```
|
||||
|
||||
This transfers Git objects locally from Gitea storage; it does not download the
|
||||
game through HTTPS. Restart `iwanttoheal-mmo` in the TrueNAS Apps UI afterward. The
|
||||
container startup command installs locked dependencies, rebuilds the browser
|
||||
bundle, and starts port `4173`.
|
||||
|
||||
For simultaneous operation, keep the new app name/directory and also assign an
|
||||
unused host port plus a separate reverse-proxy hostname.
|
||||
@@ -38,14 +38,23 @@ state rather than two independent WebViews.
|
||||
|
||||
## TrueNAS deployment
|
||||
|
||||
Complete first-install, local-Gitea clone, YAML, update, and verification steps:
|
||||
[DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
|
||||
The production preview server uses the existing deployment address and port:
|
||||
|
||||
- Public URL: `https://iwanttoheal.phenomrom.com`
|
||||
- Host/container port: `4173`
|
||||
- App directory: `/mnt/usbssds/apps/iwanttoheal/app`
|
||||
- App directory: `/mnt/usbssds/apps/iwanttoheal-mmo/app`
|
||||
|
||||
Clone the repository into the app directory, then deploy `compose.yaml`. Compared
|
||||
with the old game configuration:
|
||||
Clone from the TrueNAS-local Gitea bare repository into the app directory, then
|
||||
deploy `compose.yaml`. The expected source path is:
|
||||
|
||||
```text
|
||||
/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git
|
||||
```
|
||||
|
||||
Compared with the old game configuration:
|
||||
|
||||
- use `corepack pnpm install --frozen-lockfile`, not `npm ci`;
|
||||
- remove `npm run db:init` because this game has no server database;
|
||||
@@ -66,11 +75,10 @@ The repository target is:
|
||||
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
|
||||
```
|
||||
|
||||
Create a Gitea access token with repository write permission, expose it only for
|
||||
the publishing process, then publish from `main`:
|
||||
The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can
|
||||
optionally override it for one run. Publish from `main`:
|
||||
|
||||
```bash
|
||||
export GITEA_TOKEN="..."
|
||||
pnpm publish:gitea -- --message "Describe the update"
|
||||
```
|
||||
|
||||
@@ -80,11 +88,11 @@ version tag and Gitea prerelease, and uploads the APK plus its SHA-256 file. It
|
||||
uses `package.json` version unless that version is already tagged, then advances
|
||||
the patch number. Pass `--version 0.2.0` to choose an explicit version.
|
||||
|
||||
If the TrueNAS app directory is mounted locally, publishing also performs a
|
||||
fast-forward-only pull. Otherwise it prints the clone/pull command to run on
|
||||
TrueNAS before restarting the app. The pull refuses to run when the TrueNAS
|
||||
checkout still points at the old game's repository; move or archive that checkout
|
||||
and clone the new repository first.
|
||||
If the publisher runs where TrueNAS paths are visible, it prefers a
|
||||
fast-forward-only pull directly from TrueNAS-local Gitea storage. Otherwise it
|
||||
prints the exact local clone/pull commands to run in the TrueNAS shell. The pull
|
||||
refuses to run when the checkout still points at the old game's repository;
|
||||
archive that checkout and clone the new repository first.
|
||||
|
||||
Preview checks without changing Git or TrueNAS:
|
||||
|
||||
@@ -92,9 +100,9 @@ Preview checks without changing Git or TrueNAS:
|
||||
pnpm publish:gitea -- --dry-run
|
||||
```
|
||||
|
||||
Git push credentials still come from Git's credential manager. Gitea release API
|
||||
credentials come from `GITEA_TOKEN`. Never store access tokens or Android signing
|
||||
keys in this repository.
|
||||
Git push credentials still come from Git's credential manager. The configured
|
||||
Gitea token handles release creation and APK uploads. Android signing keys remain
|
||||
outside the repository.
|
||||
|
||||
## Controls
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.phenomrom.iwanttoheal;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.os.SystemClock;
|
||||
import android.view.InputDevice;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Routes every Thor controller event into one JavaScript input service. */
|
||||
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||
private static final float AXIS_DEAD_ZONE = 0.45f;
|
||||
private static final long REPEAT_THROTTLE_MS = 55L;
|
||||
private final long[] lastDpadDispatchAt = new long[16];
|
||||
private final Set<String> activeMotionTokens = new HashSet<>();
|
||||
private final Map<String, Long> lastMotionDispatchAt = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
dispatchNativeControllerToken("Button1", false);
|
||||
}
|
||||
});
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
WindowManager.LayoutParams attributes = getWindow().getAttributes();
|
||||
attributes.preferredRefreshRate = 60.0f;
|
||||
getWindow().setAttributes(attributes);
|
||||
if (bridge != null && bridge.getWebView() != null) {
|
||||
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
bridge.getWebView().setFocusable(true);
|
||||
bridge.getWebView().setFocusableInTouchMode(true);
|
||||
bridge.getWebView().requestFocus();
|
||||
}
|
||||
enterImmersiveMode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
clearHeldControllerState();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
enterImmersiveMode();
|
||||
if (bridge != null && bridge.getWebView() != null) bridge.getWebView().requestFocus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) enterImmersiveMode();
|
||||
else clearHeldControllerState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchTouchEvent(MotionEvent event) {
|
||||
if (event.getActionMasked() == MotionEvent.ACTION_DOWN && bridge != null) {
|
||||
bridge.getWebView().requestFocus();
|
||||
}
|
||||
return super.dispatchTouchEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
String token = controllerToken(event.getKeyCode());
|
||||
if (token == null || bridge == null) return super.dispatchKeyEvent(event);
|
||||
if (event.getAction() == KeyEvent.ACTION_DOWN) {
|
||||
boolean repeat = event.getRepeatCount() > 0;
|
||||
if (isDpadToken(token) && shouldThrottleDpad(token)) return true;
|
||||
dispatchNativeControllerToken(token, repeat);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchGenericMotionEvent(MotionEvent event) {
|
||||
if (
|
||||
bridge == null
|
||||
|| event.getActionMasked() != MotionEvent.ACTION_MOVE
|
||||
|| !isControllerMotionEvent(event)
|
||||
) {
|
||||
return super.dispatchGenericMotionEvent(event);
|
||||
}
|
||||
|
||||
float leftStickX = event.getAxisValue(MotionEvent.AXIS_X);
|
||||
float leftStickY = event.getAxisValue(MotionEvent.AXIS_Y);
|
||||
dispatchNativeControllerMotion(leftStickX, leftStickY);
|
||||
|
||||
Set<String> currentTokens = new HashSet<>();
|
||||
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_X), "Button14", "Button15");
|
||||
addAxisTokens(currentTokens, event.getAxisValue(MotionEvent.AXIS_HAT_Y), "Button12", "Button13");
|
||||
addAxisTokens(currentTokens, leftStickX, "Axis0-", "Axis0+");
|
||||
addAxisTokens(currentTokens, leftStickY, "Axis1-", "Axis1+");
|
||||
|
||||
long now = SystemClock.uptimeMillis();
|
||||
for (String token : currentTokens) {
|
||||
boolean repeat = activeMotionTokens.contains(token);
|
||||
long lastDispatch = lastMotionDispatchAt.containsKey(token)
|
||||
? lastMotionDispatchAt.get(token)
|
||||
: 0L;
|
||||
if (!repeat || now - lastDispatch >= REPEAT_THROTTLE_MS) {
|
||||
dispatchNativeControllerToken(token, repeat);
|
||||
lastMotionDispatchAt.put(token, now);
|
||||
}
|
||||
}
|
||||
activeMotionTokens.clear();
|
||||
activeMotionTokens.addAll(currentTokens);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void enterImmersiveMode() {
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(
|
||||
getWindow(),
|
||||
getWindow().getDecorView()
|
||||
);
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars());
|
||||
controller.setSystemBarsBehavior(
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isControllerMotionEvent(MotionEvent event) {
|
||||
int source = event.getSource();
|
||||
return (source & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|
||||
|| (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD
|
||||
|| (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
|
||||
}
|
||||
|
||||
private void addAxisTokens(Set<String> tokens, float value, String negative, String positive) {
|
||||
if (value <= -AXIS_DEAD_ZONE) tokens.add(negative);
|
||||
if (value >= AXIS_DEAD_ZONE) tokens.add(positive);
|
||||
}
|
||||
|
||||
private void dispatchNativeControllerToken(String token, boolean repeat) {
|
||||
if (bridge == null || bridge.getWebView() == null) return;
|
||||
String script =
|
||||
"window.dispatchEvent(new CustomEvent('iwt-native-controller',"
|
||||
+ "{detail:{token:'" + token + "',repeat:" + repeat + "}}));";
|
||||
bridge.getWebView().post(() -> {
|
||||
bridge.getWebView().requestFocus();
|
||||
bridge.getWebView().evaluateJavascript(script, null);
|
||||
});
|
||||
}
|
||||
|
||||
private void dispatchNativeControllerMotion(float x, float y) {
|
||||
if (bridge == null || bridge.getWebView() == null) return;
|
||||
String script =
|
||||
"window.dispatchEvent(new CustomEvent('iwt-native-controller-motion',"
|
||||
+ "{detail:{x:" + x + ",y:" + y + "}}));";
|
||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(script, null));
|
||||
}
|
||||
|
||||
private void clearHeldControllerState() {
|
||||
activeMotionTokens.clear();
|
||||
lastMotionDispatchAt.clear();
|
||||
dispatchNativeControllerMotion(0.0f, 0.0f);
|
||||
if (bridge == null || bridge.getWebView() == null) return;
|
||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript(
|
||||
"window.dispatchEvent(new Event('iwt-native-controller-reset'));",
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
private boolean shouldThrottleDpad(String token) {
|
||||
int index = Integer.parseInt(token.substring("Button".length()));
|
||||
long now = SystemClock.uptimeMillis();
|
||||
if (now - lastDpadDispatchAt[index] < REPEAT_THROTTLE_MS) return true;
|
||||
lastDpadDispatchAt[index] = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDpadToken(String token) {
|
||||
return token.equals("Button12")
|
||||
|| token.equals("Button13")
|
||||
|| token.equals("Button14")
|
||||
|| token.equals("Button15");
|
||||
}
|
||||
|
||||
private String controllerToken(int keyCode) {
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A:
|
||||
case KeyEvent.KEYCODE_DPAD_CENTER:
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
case KeyEvent.KEYCODE_NUMPAD_ENTER:
|
||||
return "Button0";
|
||||
case KeyEvent.KEYCODE_BUTTON_B:
|
||||
case KeyEvent.KEYCODE_ESCAPE:
|
||||
return "Button1";
|
||||
case KeyEvent.KEYCODE_BUTTON_X: return "Button2";
|
||||
case KeyEvent.KEYCODE_BUTTON_Y: return "Button3";
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: return "Button4";
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: return "Button5";
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: return "Button6";
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: return "Button7";
|
||||
case KeyEvent.KEYCODE_BUTTON_SELECT: return "Button8";
|
||||
case KeyEvent.KEYCODE_BUTTON_START: return "Button9";
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBL: return "Button10";
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: return "Button11";
|
||||
case KeyEvent.KEYCODE_DPAD_UP: return "Button12";
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: return "Button13";
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: return "Button14";
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: return "Button15";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,11 @@
|
||||
package com.phenomrom.iwanttoheal;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
public class MainActivity extends ControllerBridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(ThorDualScreenPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
WindowManager.LayoutParams attributes = getWindow().getAttributes();
|
||||
attributes.preferredRefreshRate = 60.0f;
|
||||
getWindow().setAttributes(attributes);
|
||||
getBridge().getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
getBridge().getWebView().setFocusableInTouchMode(true);
|
||||
getBridge().getWebView().requestFocus();
|
||||
enterImmersiveMode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) enterImmersiveMode();
|
||||
}
|
||||
|
||||
private void enterImmersiveMode() {
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(
|
||||
getWindow(),
|
||||
getWindow().getDecorView()
|
||||
);
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars());
|
||||
controller.setSystemBarsBehavior(
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.phenomrom.iwanttoheal;
|
||||
|
||||
import android.app.Presentation;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.hardware.display.DisplayManager;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.Display;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import com.getcapacitor.JSArray;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
/** Keeps a dedicated WebView visible on each AYN Thor panel for the activity lifetime. */
|
||||
@CapacitorPlugin(name = "ThorDualScreen")
|
||||
public class ThorDualScreenPlugin extends Plugin implements DisplayManager.DisplayListener {
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private DisplayManager displayManager;
|
||||
private ThorPresentation presentation;
|
||||
private boolean forceEnabled;
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
displayManager = (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
|
||||
displayManager.registerDisplayListener(this, handler);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getDisplays(PluginCall call) {
|
||||
Display current = currentDisplay();
|
||||
JSArray displays = new JSArray();
|
||||
for (Display display : displayManager.getDisplays()) displays.put(describeDisplay(display, current));
|
||||
JSObject result = new JSObject();
|
||||
result.put("currentDisplayId", current == null ? -1 : current.getDisplayId());
|
||||
result.put("displays", displays);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void forceBothDisplays(PluginCall call) {
|
||||
forceEnabled = true;
|
||||
getActivity().runOnUiThread(() -> {
|
||||
try {
|
||||
DisplayPlan plan = createPlan();
|
||||
if (plan.target == null) {
|
||||
call.reject("No active secondary Android display is available.");
|
||||
return;
|
||||
}
|
||||
boolean alreadyOpen = presentation != null
|
||||
&& presentation.isShowing()
|
||||
&& presentation.getDisplay().getDisplayId() == plan.target.getDisplayId();
|
||||
if (!alreadyOpen) openPlan(plan);
|
||||
JSObject result = describeDisplay(plan.target, currentDisplay());
|
||||
result.put("opened", true);
|
||||
result.put("topOnActivity", plan.currentIsTop);
|
||||
call.resolve(result);
|
||||
} catch (Exception exception) {
|
||||
closePresentation();
|
||||
call.reject("Unable to force both Thor displays.", exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleOnResume() {
|
||||
if (forceEnabled) scheduleEnsurePresentation(0L);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleOnDestroy() {
|
||||
forceEnabled = false;
|
||||
if (displayManager != null) displayManager.unregisterDisplayListener(this);
|
||||
handler.removeCallbacksAndMessages(null);
|
||||
closePresentation();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisplayAdded(int displayId) {
|
||||
if (forceEnabled) scheduleEnsurePresentation(100L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisplayRemoved(int displayId) {
|
||||
if (presentation != null && presentation.getDisplay().getDisplayId() == displayId) {
|
||||
closePresentation();
|
||||
}
|
||||
if (forceEnabled) scheduleEnsurePresentation(250L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisplayChanged(int displayId) {
|
||||
if (forceEnabled) scheduleEnsurePresentation(100L);
|
||||
}
|
||||
|
||||
private void scheduleEnsurePresentation(long delayMs) {
|
||||
handler.removeCallbacks(ensurePresentation);
|
||||
handler.postDelayed(ensurePresentation, delayMs);
|
||||
}
|
||||
|
||||
private final Runnable ensurePresentation = () -> {
|
||||
if (!forceEnabled || getActivity() == null || getActivity().isFinishing()) return;
|
||||
DisplayPlan plan = createPlan();
|
||||
if (plan.target == null) return;
|
||||
if (
|
||||
presentation != null
|
||||
&& presentation.isShowing()
|
||||
&& presentation.getDisplay().getDisplayId() == plan.target.getDisplayId()
|
||||
) return;
|
||||
openPlan(plan);
|
||||
};
|
||||
|
||||
private void openPlan(DisplayPlan plan) {
|
||||
String baseUrl = bridge.getLocalUrl();
|
||||
String targetUrl = baseUrl + "/?display=" + (plan.targetIsTop ? "top" : "bottom") + "&role=presentation";
|
||||
String currentUrl = baseUrl + "/?display=" + (plan.currentIsTop ? "top" : "bottom") + "&role=activity";
|
||||
closePresentation();
|
||||
presentation = new ThorPresentation(
|
||||
getActivity(),
|
||||
plan.target,
|
||||
targetUrl
|
||||
);
|
||||
presentation.setOnDismissListener(dialog -> {
|
||||
presentation = null;
|
||||
notifyListeners("displayDisconnected", new JSObject());
|
||||
if (forceEnabled) scheduleEnsurePresentation(300L);
|
||||
});
|
||||
presentation.show();
|
||||
bridge.getWebView().loadUrl(currentUrl);
|
||||
}
|
||||
|
||||
private DisplayPlan createPlan() {
|
||||
Display current = currentDisplay();
|
||||
int currentId = current == null ? -1 : current.getDisplayId();
|
||||
Display[] displays = displayManager.getDisplays();
|
||||
Display top = largestDisplay(displays);
|
||||
boolean currentIsTop = top != null && top.getDisplayId() == currentId;
|
||||
Display target = currentIsTop
|
||||
? smallestOtherDisplay(displays, currentId)
|
||||
: top != null && top.getDisplayId() != currentId ? top : largestOtherDisplay(displays, currentId);
|
||||
boolean targetIsTop = target != null && top != null && target.getDisplayId() == top.getDisplayId();
|
||||
return new DisplayPlan(target, targetIsTop, currentIsTop);
|
||||
}
|
||||
|
||||
private Display currentDisplay() {
|
||||
return getActivity().getWindow().getDecorView().getDisplay();
|
||||
}
|
||||
|
||||
private Display largestDisplay(Display[] displays) {
|
||||
Display selected = null;
|
||||
long pixels = -1L;
|
||||
for (Display display : displays) {
|
||||
if (display.getState() == Display.STATE_OFF) continue;
|
||||
long candidate = pixelCount(display);
|
||||
if (candidate > pixels) {
|
||||
selected = display;
|
||||
pixels = candidate;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private Display largestOtherDisplay(Display[] displays, int currentId) {
|
||||
Display selected = null;
|
||||
long pixels = -1L;
|
||||
for (Display display : displays) {
|
||||
if (display.getDisplayId() == currentId || display.getState() == Display.STATE_OFF) continue;
|
||||
long candidate = pixelCount(display);
|
||||
if (candidate > pixels) {
|
||||
selected = display;
|
||||
pixels = candidate;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private Display smallestOtherDisplay(Display[] displays, int currentId) {
|
||||
Display selected = null;
|
||||
long pixels = Long.MAX_VALUE;
|
||||
for (Display display : displays) {
|
||||
if (display.getDisplayId() == currentId || display.getState() == Display.STATE_OFF) continue;
|
||||
long candidate = pixelCount(display);
|
||||
if (candidate < pixels) {
|
||||
selected = display;
|
||||
pixels = candidate;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private long pixelCount(Display display) {
|
||||
Display.Mode mode = display.getMode();
|
||||
return (long) mode.getPhysicalWidth() * mode.getPhysicalHeight();
|
||||
}
|
||||
|
||||
private JSObject describeDisplay(Display display, Display current) {
|
||||
Display.Mode mode = display.getMode();
|
||||
JSObject result = new JSObject();
|
||||
result.put("id", display.getDisplayId());
|
||||
result.put("name", display.getName());
|
||||
result.put("width", mode.getPhysicalWidth());
|
||||
result.put("height", mode.getPhysicalHeight());
|
||||
result.put("refreshRate", mode.getRefreshRate());
|
||||
result.put("isCurrent", current != null && current.getDisplayId() == display.getDisplayId());
|
||||
return result;
|
||||
}
|
||||
|
||||
private void closePresentation() {
|
||||
if (presentation == null) return;
|
||||
ThorPresentation closing = presentation;
|
||||
presentation = null;
|
||||
closing.setOnDismissListener(null);
|
||||
closing.dismiss();
|
||||
}
|
||||
|
||||
private final class ThorPresentation extends Presentation {
|
||||
private final String initialUrl;
|
||||
private WebView webView;
|
||||
|
||||
ThorPresentation(Context context, Display display, String initialUrl) {
|
||||
super(context, display);
|
||||
this.initialUrl = initialUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
getWindow().setBackgroundDrawableResource(android.R.color.black);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
WindowManager.LayoutParams attributes = getWindow().getAttributes();
|
||||
attributes.preferredRefreshRate = 60.0f;
|
||||
getWindow().setAttributes(attributes);
|
||||
getWindow().getDecorView().setSystemUiVisibility(
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
);
|
||||
|
||||
FrameLayout container = new FrameLayout(getContext());
|
||||
container.setBackgroundColor(Color.BLACK);
|
||||
webView = new WebView(getContext());
|
||||
configureWebView(webView);
|
||||
container.addView(webView, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
));
|
||||
setContentView(container);
|
||||
webView.loadUrl(initialUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
if (webView != null) {
|
||||
webView.stopLoading();
|
||||
webView.destroy();
|
||||
webView = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void configureWebView(WebView webView) {
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setDatabaseEnabled(true);
|
||||
settings.setMediaPlaybackRequiresUserGesture(false);
|
||||
settings.setAllowFileAccess(true);
|
||||
settings.setAllowContentAccess(true);
|
||||
webView.setBackgroundColor(Color.BLACK);
|
||||
webView.setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
webView.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||
return bridge.getLocalServer().shouldInterceptRequest(request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static final class DisplayPlan {
|
||||
private final Display target;
|
||||
private final boolean targetIsTop;
|
||||
private final boolean currentIsTop;
|
||||
|
||||
DisplayPlan(Display target, boolean targetIsTop, boolean currentIsTop) {
|
||||
this.target = target;
|
||||
this.targetIsTop = targetIsTop;
|
||||
this.currentIsTop = currentIsTop;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 524 KiB |
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 455 KiB |
|
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 624 KiB |
|
After Width: | Height: | Size: 674 KiB |
|
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 849 KiB |
|
After Width: | Height: | Size: 866 KiB |
|
After Width: | Height: | Size: 878 KiB |
|
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 657 KiB |
|
After Width: | Height: | Size: 673 KiB |
|
After Width: | Height: | Size: 659 KiB |
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 670 KiB |
|
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,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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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>1–3 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>1–6</b> Cast</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StorageAdapter } from "./saveRepository";
|
||||
import { AccountRepository } from "./accountRepository";
|
||||
|
||||
function memoryStorage(): StorageAdapter {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => { data.set(key, value); },
|
||||
};
|
||||
}
|
||||
|
||||
const testHasher = async (password: string, salt: string) => {
|
||||
const checksum = [...password].reduce((total, character) => total + character.charCodeAt(0), 0);
|
||||
return `derived:${salt}:${checksum}`;
|
||||
};
|
||||
|
||||
describe("AccountRepository", () => {
|
||||
it("requires both a username and password", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
|
||||
await expect(repository.create("", "secret")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
await expect(repository.create("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
await expect(repository.authenticate("healer", "")).resolves.toEqual({ ok: false, reason: "missing-credentials" });
|
||||
});
|
||||
|
||||
it("requires account creation before sign-in", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
|
||||
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: false, reason: "account-not-found" });
|
||||
await expect(repository.create("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
|
||||
await expect(repository.authenticate("new-healer", "secret")).resolves.toEqual({ ok: true, username: "new-healer" });
|
||||
});
|
||||
|
||||
it("rejects an incorrect password and duplicate account names", async () => {
|
||||
const repository = new AccountRepository(memoryStorage(), testHasher, () => "salt");
|
||||
await repository.create("Wayfinder", "correct");
|
||||
|
||||
await expect(repository.authenticate("wayfinder", "wrong")).resolves.toEqual({ ok: false, reason: "invalid-password" });
|
||||
await expect(repository.create(" wayfinder ", "another")).resolves.toEqual({ ok: false, reason: "account-exists" });
|
||||
});
|
||||
|
||||
it("persists only a derived password verifier", async () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new AccountRepository(storage, testHasher, () => "unique-salt");
|
||||
await repository.create("healer", "plaintext-secret");
|
||||
|
||||
const persisted = storage.getItem("i-want-to-heal:accounts:v1") ?? "";
|
||||
expect(persisted).toContain("derived:unique-salt:");
|
||||
expect(persisted).not.toContain("plaintext-secret");
|
||||
expect(JSON.parse(persisted).healer).not.toHaveProperty("password");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { StorageAdapter } from "./saveRepository";
|
||||
|
||||
const ACCOUNTS_KEY = "i-want-to-heal:accounts:v1";
|
||||
const PASSWORD_ITERATIONS = 120_000;
|
||||
|
||||
interface AccountRecord {
|
||||
username: string;
|
||||
salt: string;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
type AccountMap = Record<string, AccountRecord>;
|
||||
type PasswordHasher = (password: string, salt: string) => Promise<string>;
|
||||
|
||||
export type AccountResult =
|
||||
| { ok: true; username: string }
|
||||
| { ok: false; reason: "missing-credentials" | "account-exists" | "account-not-found" | "invalid-password" | "storage-unavailable" };
|
||||
|
||||
const fallbackMemory = new Map<string, string>();
|
||||
const fallbackStorage: StorageAdapter = {
|
||||
getItem: (key) => fallbackMemory.get(key) ?? null,
|
||||
setItem: (key, value) => { fallbackMemory.set(key, value); },
|
||||
};
|
||||
|
||||
function browserStorage(): StorageAdapter {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") return localStorage;
|
||||
} catch {
|
||||
// Android WebView can deny storage before its host is ready.
|
||||
}
|
||||
return fallbackStorage;
|
||||
}
|
||||
|
||||
function encodeBytes(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decodeBytes(value: string) {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function hashPassword(password: string, salt: string) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits({
|
||||
name: "PBKDF2",
|
||||
hash: "SHA-256",
|
||||
salt: decodeBytes(salt),
|
||||
iterations: PASSWORD_ITERATIONS,
|
||||
}, key, 256);
|
||||
return encodeBytes(new Uint8Array(bits));
|
||||
}
|
||||
|
||||
function randomSalt() {
|
||||
const salt = new Uint8Array(16);
|
||||
crypto.getRandomValues(salt);
|
||||
return encodeBytes(salt);
|
||||
}
|
||||
|
||||
function canonicalUsername(username: string) {
|
||||
return username.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function parseAccounts(raw: string | null): AccountMap {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as AccountMap;
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local prototype account registry. Passwords are salted and derived before
|
||||
* persistence; replace this adapter with the server authentication API when
|
||||
* remote sync leaves local prototype storage.
|
||||
*/
|
||||
export class AccountRepository {
|
||||
constructor(
|
||||
private readonly storage: StorageAdapter = browserStorage(),
|
||||
private readonly hasher: PasswordHasher = hashPassword,
|
||||
private readonly createSalt: () => string = randomSalt,
|
||||
) {}
|
||||
|
||||
async create(usernameInput: string, password: string): Promise<AccountResult> {
|
||||
const username = usernameInput.trim();
|
||||
const canonical = canonicalUsername(username);
|
||||
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
|
||||
|
||||
const accounts = this.read();
|
||||
if (accounts[canonical]) return { ok: false, reason: "account-exists" };
|
||||
|
||||
try {
|
||||
const salt = this.createSalt();
|
||||
accounts[canonical] = { username, salt, passwordHash: await this.hasher(password, salt) };
|
||||
this.storage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
|
||||
return { ok: true, username };
|
||||
} catch {
|
||||
return { ok: false, reason: "storage-unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate(usernameInput: string, password: string): Promise<AccountResult> {
|
||||
const canonical = canonicalUsername(usernameInput);
|
||||
if (!canonical || !password) return { ok: false, reason: "missing-credentials" };
|
||||
|
||||
const account = this.read()[canonical];
|
||||
if (!account) return { ok: false, reason: "account-not-found" };
|
||||
|
||||
try {
|
||||
const passwordHash = await this.hasher(password, account.salt);
|
||||
return passwordHash === account.passwordHash
|
||||
? { ok: true, username: account.username }
|
||||
: { ok: false, reason: "invalid-password" };
|
||||
} catch {
|
||||
return { ok: false, reason: "storage-unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
private read() {
|
||||
return parseAccounts(this.storage.getItem(ACCOUNTS_KEY));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "1–4 hunters · chosen encounter",
|
||||
title: "Dungeons",
|
||||
description: "Choose a guardian, review its mechanics, and bring a prepared healing loadout into a focused encounter.",
|
||||
detail: "Bulldrome · Vexa · Cindermaw",
|
||||
detail: "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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||