Spaces:
Running
Running
Pixi battlefield: real deterministic engine running in-browser
Browse filesServe web/ (Pixi + bundled engine) at /, Gradio barracks at /app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- app.py +11 -28
- web/engine.js +1526 -0
- web/index.html +31 -0
- web/main.js +79 -0
app.py
CHANGED
|
@@ -1,38 +1,17 @@
|
|
| 1 |
-
"""Tiny Army — HF Space
|
| 2 |
|
| 3 |
-
FastAPI serves the
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
"""
|
| 8 |
import gradio as gr
|
| 9 |
import uvicorn
|
| 10 |
from fastapi import FastAPI
|
| 11 |
-
from fastapi.
|
| 12 |
|
| 13 |
app = FastAPI(title="Tiny Army")
|
| 14 |
|
| 15 |
-
INDEX = """<!doctype html>
|
| 16 |
-
<html><head><meta charset="utf-8"><title>Tiny Army</title>
|
| 17 |
-
<style>
|
| 18 |
-
body{margin:0;height:100vh;display:grid;place-items:center;background:#0b0e12;
|
| 19 |
-
color:#f4ecd8;font-family:ui-monospace,Menlo,monospace;text-align:center}
|
| 20 |
-
.badge{font-size:64px} a{color:#ffd54a}
|
| 21 |
-
p{color:#9aa4b2;max-width:32rem;line-height:1.5}
|
| 22 |
-
</style></head>
|
| 23 |
-
<body><div>
|
| 24 |
-
<div class="badge">🪖</div>
|
| 25 |
-
<h1>Tiny Army</h1>
|
| 26 |
-
<p><em>Tiny Army: every fighter writes its own legend — and the legend is true.</em></p>
|
| 27 |
-
<p>The battlefield (Pixi) mounts here. For now, visit the barracks:
|
| 28 |
-
<a href="/app">/app</a></p>
|
| 29 |
-
</div></body></html>"""
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
@app.get("/", response_class=HTMLResponse)
|
| 33 |
-
def index():
|
| 34 |
-
return INDEX
|
| 35 |
-
|
| 36 |
|
| 37 |
@app.get("/healthz")
|
| 38 |
def healthz():
|
|
@@ -50,14 +29,18 @@ def write_diary(unit: str, traits: str) -> str:
|
|
| 50 |
|
| 51 |
with gr.Blocks(title="Tiny Army — Barracks", theme=gr.themes.Soft()) as barracks:
|
| 52 |
gr.Markdown("# 🪖 Tiny Army — Barracks\n"
|
| 53 |
-
"*Every fighter writes its own legend.* (skeleton — diary is a stub)"
|
|
|
|
| 54 |
with gr.Row():
|
| 55 |
unit = gr.Textbox(label="Unit", value="Bram the Warrior")
|
| 56 |
traits = gr.Textbox(label="Traits", value="Cautious, Veteran, Vengeful")
|
| 57 |
out = gr.Textbox(label="War diary", lines=6)
|
| 58 |
gr.Button("Write diary", variant="primary").click(write_diary, [unit, traits], out)
|
| 59 |
|
|
|
|
|
|
|
| 60 |
app = gr.mount_gradio_app(app, barracks, path="/app")
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
"""Tiny Army — HF Space.
|
| 2 |
|
| 3 |
+
FastAPI serves the Pixi battle frontend (web/) at "/", where the deterministic
|
| 4 |
+
combat engine runs in-browser. The Gradio "barracks" is mounted at "/app"; its
|
| 5 |
+
war-diary generator is a stub for now, wired to a local llama.cpp small model
|
| 6 |
+
during the hack.
|
| 7 |
"""
|
| 8 |
import gradio as gr
|
| 9 |
import uvicorn
|
| 10 |
from fastapi import FastAPI
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
|
| 13 |
app = FastAPI(title="Tiny Army")
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
@app.get("/healthz")
|
| 17 |
def healthz():
|
|
|
|
| 29 |
|
| 30 |
with gr.Blocks(title="Tiny Army — Barracks", theme=gr.themes.Soft()) as barracks:
|
| 31 |
gr.Markdown("# 🪖 Tiny Army — Barracks\n"
|
| 32 |
+
"*Every fighter writes its own legend.* (skeleton — diary is a stub) · "
|
| 33 |
+
"[← battlefield](/)")
|
| 34 |
with gr.Row():
|
| 35 |
unit = gr.Textbox(label="Unit", value="Bram the Warrior")
|
| 36 |
traits = gr.Textbox(label="Traits", value="Cautious, Veteran, Vengeful")
|
| 37 |
out = gr.Textbox(label="War diary", lines=6)
|
| 38 |
gr.Button("Write diary", variant="primary").click(write_diary, [unit, traits], out)
|
| 39 |
|
| 40 |
+
# Gradio barracks at /app; the Pixi frontend (static) at / — mounted last so the
|
| 41 |
+
# more specific /app and /healthz routes win.
|
| 42 |
app = gr.mount_gradio_app(app, barracks, path="/app")
|
| 43 |
+
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
| 44 |
|
| 45 |
|
| 46 |
if __name__ == "__main__":
|
web/engine.js
ADDED
|
@@ -0,0 +1,1526 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// src/engine/skills.js
|
| 2 |
+
var FIRST_15 = [
|
| 3 |
+
// ── Warrior: adrenaline-fuelled condition → spike (Swordsmanship line) ──
|
| 4 |
+
{
|
| 5 |
+
id: 382,
|
| 6 |
+
name: "Sever Artery",
|
| 7 |
+
profession: "Warrior",
|
| 8 |
+
attribute: "Swordsmanship",
|
| 9 |
+
category: "melee_attack",
|
| 10 |
+
target: "foe",
|
| 11 |
+
cost: { adrenaline: 4 },
|
| 12 |
+
cast: 0,
|
| 13 |
+
recharge: 0,
|
| 14 |
+
requires: ["on_hit"],
|
| 15 |
+
effects: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [5, 25] } }]
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
id: 384,
|
| 19 |
+
name: "Gash",
|
| 20 |
+
profession: "Warrior",
|
| 21 |
+
attribute: "Swordsmanship",
|
| 22 |
+
category: "melee_attack",
|
| 23 |
+
target: "foe",
|
| 24 |
+
cost: { adrenaline: 6 },
|
| 25 |
+
cast: 0,
|
| 26 |
+
recharge: 0,
|
| 27 |
+
// The payoff: bonus damage + Deep Wound, but only on an already-Bleeding foe.
|
| 28 |
+
requires: ["on_hit", { target: "bleeding" }],
|
| 29 |
+
effects: [
|
| 30 |
+
{ op: "bonus_damage", amount: { scale: [5, 20] } },
|
| 31 |
+
{ op: "apply_condition", condition: "deepWound", duration: { scale: [5, 20] } }
|
| 32 |
+
]
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
id: 385,
|
| 36 |
+
name: "Final Thrust",
|
| 37 |
+
profession: "Warrior",
|
| 38 |
+
attribute: "Swordsmanship",
|
| 39 |
+
category: "melee_attack",
|
| 40 |
+
target: "foe",
|
| 41 |
+
cost: { adrenaline: 10 },
|
| 42 |
+
cast: 0,
|
| 43 |
+
recharge: 0,
|
| 44 |
+
requires: ["on_hit"],
|
| 45 |
+
effects: [
|
| 46 |
+
{ op: "lose_all_adrenaline" },
|
| 47 |
+
{ op: "bonus_damage", amount: { scale: [1, 40] } },
|
| 48 |
+
// "doubled if below 50%" — applying the same bonus a second time, gated.
|
| 49 |
+
{ op: "bonus_damage", amount: { scale: [1, 40] }, if: { target_below_health: 0.5 } }
|
| 50 |
+
]
|
| 51 |
+
},
|
| 52 |
+
// ── Ranger: preparations + ranged conditions/interrupt ──
|
| 53 |
+
{
|
| 54 |
+
id: 435,
|
| 55 |
+
name: "Apply Poison",
|
| 56 |
+
profession: "Ranger",
|
| 57 |
+
attribute: "Wilderness Survival",
|
| 58 |
+
category: "preparation",
|
| 59 |
+
target: "self",
|
| 60 |
+
cost: { energy: 15 },
|
| 61 |
+
cast: 2,
|
| 62 |
+
recharge: 12,
|
| 63 |
+
// The differentiator: a self rider — future physical attacks inflict Poison.
|
| 64 |
+
effects: [{
|
| 65 |
+
op: "preparation",
|
| 66 |
+
duration: { fixed: 24 },
|
| 67 |
+
on_attack: [{ op: "apply_condition", condition: "poison", duration: { scale: [3, 15] } }]
|
| 68 |
+
}]
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
id: 391,
|
| 72 |
+
name: "Hunter's Shot",
|
| 73 |
+
profession: "Ranger",
|
| 74 |
+
attribute: "Marksmanship",
|
| 75 |
+
category: "bow_attack",
|
| 76 |
+
target: "foe",
|
| 77 |
+
cost: { energy: 5 },
|
| 78 |
+
cast: 1,
|
| 79 |
+
recharge: 10,
|
| 80 |
+
requires: ["on_hit"],
|
| 81 |
+
effects: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [3, 25] } }]
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
id: 426,
|
| 85 |
+
name: "Savage Shot",
|
| 86 |
+
profession: "Ranger",
|
| 87 |
+
attribute: "Marksmanship",
|
| 88 |
+
category: "bow_attack",
|
| 89 |
+
target: "foe",
|
| 90 |
+
cost: { energy: 10 },
|
| 91 |
+
cast: 0.5,
|
| 92 |
+
recharge: 5,
|
| 93 |
+
requires: ["on_hit"],
|
| 94 |
+
effects: [
|
| 95 |
+
{ op: "interrupt" },
|
| 96 |
+
// Bonus only if the interrupted action was a spell.
|
| 97 |
+
{ op: "bonus_damage", amount: { scale: [13, 28] }, if: { target: "casting_spell" } }
|
| 98 |
+
]
|
| 99 |
+
},
|
| 100 |
+
// ── Necromancer: trigger-hexes (the event bus / physway) ──
|
| 101 |
+
{
|
| 102 |
+
id: 121,
|
| 103 |
+
name: "Spiteful Spirit",
|
| 104 |
+
profession: "Necromancer",
|
| 105 |
+
attribute: "Curses",
|
| 106 |
+
category: "hex",
|
| 107 |
+
target: "foe",
|
| 108 |
+
cost: { energy: 15 },
|
| 109 |
+
cast: 2,
|
| 110 |
+
recharge: 10,
|
| 111 |
+
elite: true,
|
| 112 |
+
effects: [{
|
| 113 |
+
op: "hex",
|
| 114 |
+
duration: { scale: [8, 20] },
|
| 115 |
+
trigger: "on_action",
|
| 116 |
+
payload: [{ op: "damage", damageType: "shadow", amount: { scale: [5, 35] }, scope: "target_and_adjacent" }]
|
| 117 |
+
}]
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
id: 101,
|
| 121 |
+
name: "Barbs",
|
| 122 |
+
profession: "Necromancer",
|
| 123 |
+
attribute: "Curses",
|
| 124 |
+
category: "hex",
|
| 125 |
+
target: "foe",
|
| 126 |
+
cost: { energy: 10 },
|
| 127 |
+
cast: 2,
|
| 128 |
+
recharge: 5,
|
| 129 |
+
effects: [{
|
| 130 |
+
op: "hex",
|
| 131 |
+
duration: { fixed: 30 },
|
| 132 |
+
// Passive amplifier — no discrete trigger; the damage pipeline reads it.
|
| 133 |
+
payload: [{ op: "amplify_damage", amount: { scale: [1, 15] }, vs: "physical" }]
|
| 134 |
+
}]
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
id: 150,
|
| 138 |
+
name: "Mark of Pain",
|
| 139 |
+
profession: "Necromancer",
|
| 140 |
+
attribute: "Curses",
|
| 141 |
+
category: "hex",
|
| 142 |
+
target: "foe",
|
| 143 |
+
cost: { energy: 10 },
|
| 144 |
+
cast: 1,
|
| 145 |
+
recharge: 20,
|
| 146 |
+
effects: [{
|
| 147 |
+
op: "hex",
|
| 148 |
+
duration: { fixed: 30 },
|
| 149 |
+
trigger: "on_physical_hit",
|
| 150 |
+
payload: [{ op: "damage", damageType: "shadow", amount: { scale: [10, 40] }, scope: "adjacent_to_target" }]
|
| 151 |
+
}]
|
| 152 |
+
},
|
| 153 |
+
// ── Monk: the damage-interception pipeline ──
|
| 154 |
+
{
|
| 155 |
+
id: 245,
|
| 156 |
+
name: "Protective Spirit",
|
| 157 |
+
profession: "Monk",
|
| 158 |
+
attribute: "Protection Prayers",
|
| 159 |
+
category: "enchantment",
|
| 160 |
+
target: "ally",
|
| 161 |
+
cost: { energy: 10 },
|
| 162 |
+
cast: 0.25,
|
| 163 |
+
recharge: 5,
|
| 164 |
+
effects: [{
|
| 165 |
+
op: "enchant",
|
| 166 |
+
duration: { scale: [5, 23] },
|
| 167 |
+
// Cap: a single hit can't remove more than 10% of max Health.
|
| 168 |
+
payload: [{ op: "cap_damage", maxFraction: 0.1 }]
|
| 169 |
+
}]
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
id: 307,
|
| 173 |
+
name: "Reversal of Fortune",
|
| 174 |
+
profession: "Monk",
|
| 175 |
+
attribute: "Protection Prayers",
|
| 176 |
+
category: "enchantment",
|
| 177 |
+
target: "ally",
|
| 178 |
+
cost: { energy: 5 },
|
| 179 |
+
cast: 0.25,
|
| 180 |
+
recharge: 2,
|
| 181 |
+
effects: [{
|
| 182 |
+
op: "enchant",
|
| 183 |
+
duration: { fixed: 8 },
|
| 184 |
+
charges: 1,
|
| 185 |
+
trigger: "on_incoming_damage",
|
| 186 |
+
payload: [{ op: "convert_damage_to_heal", cap: { scale: [15, 80] } }]
|
| 187 |
+
}]
|
| 188 |
+
},
|
| 189 |
+
{
|
| 190 |
+
id: 1114,
|
| 191 |
+
name: "Spirit Bond",
|
| 192 |
+
profession: "Monk",
|
| 193 |
+
attribute: "Protection Prayers",
|
| 194 |
+
category: "enchantment",
|
| 195 |
+
target: "ally",
|
| 196 |
+
cost: { energy: 10 },
|
| 197 |
+
cast: 0.25,
|
| 198 |
+
recharge: 2,
|
| 199 |
+
effects: [{
|
| 200 |
+
op: "enchant",
|
| 201 |
+
duration: { fixed: 8 },
|
| 202 |
+
charges: 10,
|
| 203 |
+
trigger: "on_incoming_damage",
|
| 204 |
+
threshold: { perHitDamageOver: 50 },
|
| 205 |
+
payload: [{ op: "heal", amount: { scale: [30, 90] }, scope: "target" }]
|
| 206 |
+
}]
|
| 207 |
+
},
|
| 208 |
+
// ── Assassin: the combo chain (lead → off-hand → dual) ──
|
| 209 |
+
{
|
| 210 |
+
id: 782,
|
| 211 |
+
name: "Jagged Strike",
|
| 212 |
+
profession: "Assassin",
|
| 213 |
+
attribute: "Dagger Mastery",
|
| 214 |
+
category: "lead_attack",
|
| 215 |
+
target: "foe",
|
| 216 |
+
cost: { energy: 5 },
|
| 217 |
+
cast: 0.5,
|
| 218 |
+
recharge: 1,
|
| 219 |
+
requires: ["on_hit"],
|
| 220 |
+
effects: [
|
| 221 |
+
{ op: "apply_condition", condition: "bleeding", duration: { scale: [5, 20] } },
|
| 222 |
+
{ op: "set_combo_mark", stage: "lead" }
|
| 223 |
+
]
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
id: 780,
|
| 227 |
+
name: "Fox Fangs",
|
| 228 |
+
profession: "Assassin",
|
| 229 |
+
attribute: "Dagger Mastery",
|
| 230 |
+
category: "offhand_attack",
|
| 231 |
+
target: "foe",
|
| 232 |
+
cost: { energy: 5 },
|
| 233 |
+
cast: 0.5,
|
| 234 |
+
recharge: 3,
|
| 235 |
+
requires: ["on_hit", { combo_follows: "lead" }],
|
| 236 |
+
effects: [
|
| 237 |
+
{ op: "bonus_damage", amount: { scale: [10, 35] }, unblockable: true },
|
| 238 |
+
{ op: "set_combo_mark", stage: "offhand" }
|
| 239 |
+
]
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
id: 775,
|
| 243 |
+
name: "Death Blossom",
|
| 244 |
+
profession: "Assassin",
|
| 245 |
+
attribute: "Dagger Mastery",
|
| 246 |
+
category: "dual_attack",
|
| 247 |
+
target: "foe",
|
| 248 |
+
cost: { energy: 5 },
|
| 249 |
+
cast: 0,
|
| 250 |
+
recharge: 2,
|
| 251 |
+
requires: ["on_hit", { combo_follows: "offhand" }],
|
| 252 |
+
effects: [
|
| 253 |
+
{ op: "bonus_damage", amount: { scale: [20, 45] } },
|
| 254 |
+
{ op: "damage", amount: { scale: [20, 45] }, scope: "adjacent_to_target" }
|
| 255 |
+
]
|
| 256 |
+
}
|
| 257 |
+
];
|
| 258 |
+
var VARIANT_EXTRA = [
|
| 259 |
+
// ── Warrior · Sentinel (soak / protect) ──
|
| 260 |
+
{
|
| 261 |
+
id: 348,
|
| 262 |
+
name: '"Watch Yourself!"',
|
| 263 |
+
profession: "Warrior",
|
| 264 |
+
attribute: "Tactics",
|
| 265 |
+
category: "shout",
|
| 266 |
+
target: "party",
|
| 267 |
+
cost: { adrenaline: 4 },
|
| 268 |
+
cast: 0,
|
| 269 |
+
recharge: 4,
|
| 270 |
+
// Party armor for 10s, but the buff also ends after 10 incoming attacks.
|
| 271 |
+
effects: [{ op: "armor_mod", amount: { scale: [5, 25] }, duration: { fixed: 10 }, attacksLeft: 10, scope: "party" }]
|
| 272 |
+
},
|
| 273 |
+
{
|
| 274 |
+
id: 372,
|
| 275 |
+
name: "Gladiator's Defense",
|
| 276 |
+
profession: "Warrior",
|
| 277 |
+
attribute: "Tactics",
|
| 278 |
+
category: "stance",
|
| 279 |
+
target: "self",
|
| 280 |
+
cost: { energy: 5 },
|
| 281 |
+
cast: 0,
|
| 282 |
+
recharge: 30,
|
| 283 |
+
elite: true,
|
| 284 |
+
// 75% block; whoever you block in melee takes 5…35 back.
|
| 285 |
+
effects: [{ op: "block", chance: 0.75, vs: "melee", reflect: { scale: [5, 35] }, duration: { scale: [5, 11] } }]
|
| 286 |
+
},
|
| 287 |
+
{
|
| 288 |
+
id: 1,
|
| 289 |
+
name: "Healing Signet",
|
| 290 |
+
profession: "Warrior",
|
| 291 |
+
attribute: "Tactics",
|
| 292 |
+
category: "signet",
|
| 293 |
+
target: "self",
|
| 294 |
+
cost: {},
|
| 295 |
+
cast: 2,
|
| 296 |
+
recharge: 4,
|
| 297 |
+
effects: [{ op: "heal", amount: { scale: [82, 172] }, scope: "self" }],
|
| 298 |
+
whileActivating: [{ op: "armor_mod", amount: -40, duration: { fixed: 0 }, scope: "self" }]
|
| 299 |
+
// −40 armor while using
|
| 300 |
+
},
|
| 301 |
+
// ── Warrior · Breaker (knockdown control) ──
|
| 302 |
+
{
|
| 303 |
+
id: 332,
|
| 304 |
+
name: "Bull's Strike",
|
| 305 |
+
profession: "Warrior",
|
| 306 |
+
attribute: "Strength",
|
| 307 |
+
category: "melee_attack",
|
| 308 |
+
target: "foe",
|
| 309 |
+
cost: { energy: 5 },
|
| 310 |
+
cast: 0,
|
| 311 |
+
recharge: 10,
|
| 312 |
+
requires: ["on_hit", { target: "moving" }],
|
| 313 |
+
effects: [
|
| 314 |
+
{ op: "bonus_damage", amount: { scale: [5, 30] } },
|
| 315 |
+
{ op: "knockdown", duration: { fixed: 2 } }
|
| 316 |
+
]
|
| 317 |
+
},
|
| 318 |
+
{
|
| 319 |
+
id: 331,
|
| 320 |
+
name: "Hammer Bash",
|
| 321 |
+
profession: "Warrior",
|
| 322 |
+
attribute: "Hammer Mastery",
|
| 323 |
+
category: "melee_attack",
|
| 324 |
+
target: "foe",
|
| 325 |
+
cost: { adrenaline: 6 },
|
| 326 |
+
cast: 0,
|
| 327 |
+
recharge: 0,
|
| 328 |
+
requires: ["on_hit"],
|
| 329 |
+
effects: [
|
| 330 |
+
{ op: "knockdown", duration: { fixed: 2 } },
|
| 331 |
+
{ op: "lose_all_adrenaline" }
|
| 332 |
+
]
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
id: 352,
|
| 336 |
+
name: "Crushing Blow",
|
| 337 |
+
profession: "Warrior",
|
| 338 |
+
attribute: "Hammer Mastery",
|
| 339 |
+
category: "melee_attack",
|
| 340 |
+
target: "foe",
|
| 341 |
+
cost: { energy: 5 },
|
| 342 |
+
cast: 0,
|
| 343 |
+
recharge: 10,
|
| 344 |
+
requires: ["on_hit"],
|
| 345 |
+
effects: [
|
| 346 |
+
{ op: "bonus_damage", amount: { scale: [1, 20] } },
|
| 347 |
+
{ op: "apply_condition", condition: "deepWound", duration: { scale: [5, 20] }, if: { target: "knocked_down" } }
|
| 348 |
+
]
|
| 349 |
+
},
|
| 350 |
+
// ── Ranger · Sharpshooter (Punishing Shot completes the interrupt bar) ──
|
| 351 |
+
{
|
| 352 |
+
id: 409,
|
| 353 |
+
name: "Punishing Shot",
|
| 354 |
+
profession: "Ranger",
|
| 355 |
+
attribute: "Marksmanship",
|
| 356 |
+
category: "bow_attack",
|
| 357 |
+
target: "foe",
|
| 358 |
+
cost: { energy: 10 },
|
| 359 |
+
cast: 0.5,
|
| 360 |
+
recharge: 5,
|
| 361 |
+
elite: true,
|
| 362 |
+
requires: ["on_hit"],
|
| 363 |
+
effects: [
|
| 364 |
+
{ op: "bonus_damage", amount: { scale: [10, 20] } },
|
| 365 |
+
{ op: "interrupt" }
|
| 366 |
+
]
|
| 367 |
+
},
|
| 368 |
+
// ── Ranger · Toxicologist (stacked degen) ──
|
| 369 |
+
{
|
| 370 |
+
id: 1470,
|
| 371 |
+
name: "Barbed Arrows",
|
| 372 |
+
profession: "Ranger",
|
| 373 |
+
attribute: "Wilderness Survival",
|
| 374 |
+
category: "preparation",
|
| 375 |
+
target: "self",
|
| 376 |
+
cost: { energy: 10 },
|
| 377 |
+
cast: 2,
|
| 378 |
+
recharge: 12,
|
| 379 |
+
effects: [{
|
| 380 |
+
op: "preparation",
|
| 381 |
+
duration: { fixed: 24 },
|
| 382 |
+
on_attack: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [3, 15] } }]
|
| 383 |
+
}],
|
| 384 |
+
whileActivating: [{ op: "armor_mod", amount: -40, duration: { fixed: 0 }, scope: "self" }]
|
| 385 |
+
// −40 armor while activating
|
| 386 |
+
},
|
| 387 |
+
{
|
| 388 |
+
id: 1466,
|
| 389 |
+
name: "Burning Arrow",
|
| 390 |
+
profession: "Ranger",
|
| 391 |
+
attribute: "Marksmanship",
|
| 392 |
+
category: "bow_attack",
|
| 393 |
+
target: "foe",
|
| 394 |
+
cost: { energy: 10 },
|
| 395 |
+
cast: 0,
|
| 396 |
+
recharge: 5,
|
| 397 |
+
elite: true,
|
| 398 |
+
requires: ["on_hit"],
|
| 399 |
+
effects: [
|
| 400 |
+
{ op: "bonus_damage", amount: { scale: [10, 30] } },
|
| 401 |
+
{ op: "apply_condition", condition: "burning", duration: { scale: [1, 7] } }
|
| 402 |
+
]
|
| 403 |
+
},
|
| 404 |
+
// ── Ranger · Survivalist (sustain / kite) ──
|
| 405 |
+
{
|
| 406 |
+
id: 446,
|
| 407 |
+
name: "Troll Unguent",
|
| 408 |
+
profession: "Ranger",
|
| 409 |
+
attribute: "Wilderness Survival",
|
| 410 |
+
category: "skill",
|
| 411 |
+
target: "self",
|
| 412 |
+
cost: { energy: 5 },
|
| 413 |
+
cast: 3,
|
| 414 |
+
recharge: 10,
|
| 415 |
+
effects: [{ op: "regen_mod", pips: { scale: [3, 10] }, duration: { fixed: 13 }, scope: "self" }]
|
| 416 |
+
},
|
| 417 |
+
{
|
| 418 |
+
id: 1727,
|
| 419 |
+
name: "Natural Stride",
|
| 420 |
+
profession: "Ranger",
|
| 421 |
+
attribute: "Wilderness Survival",
|
| 422 |
+
category: "stance",
|
| 423 |
+
target: "self",
|
| 424 |
+
cost: { energy: 5 },
|
| 425 |
+
cast: 0,
|
| 426 |
+
recharge: 12,
|
| 427 |
+
// Move 33% faster + 50% block; the stance ends if you become hexed/enchanted.
|
| 428 |
+
effects: [
|
| 429 |
+
{ op: "move_speed", mult: 1.33, duration: { scale: [1, 8] }, endsOnHexEnchant: true },
|
| 430 |
+
{ op: "block", chance: 0.5, vs: "all", duration: { scale: [1, 8] }, endsOnHexEnchant: true }
|
| 431 |
+
]
|
| 432 |
+
},
|
| 433 |
+
{
|
| 434 |
+
id: 393,
|
| 435 |
+
name: "Crippling Shot",
|
| 436 |
+
profession: "Ranger",
|
| 437 |
+
attribute: "Marksmanship",
|
| 438 |
+
category: "bow_attack",
|
| 439 |
+
target: "foe",
|
| 440 |
+
cost: { energy: 10 },
|
| 441 |
+
cast: 0,
|
| 442 |
+
recharge: 2,
|
| 443 |
+
elite: true,
|
| 444 |
+
requires: ["on_hit"],
|
| 445 |
+
effects: [{ op: "apply_condition", condition: "crippled", duration: { scale: [1, 12] }, unblockable: true }]
|
| 446 |
+
},
|
| 447 |
+
// ── Necromancer · Vampire (life-steal sustain) ──
|
| 448 |
+
{
|
| 449 |
+
id: 153,
|
| 450 |
+
name: "Vampiric Gaze",
|
| 451 |
+
profession: "Necromancer",
|
| 452 |
+
attribute: "Blood Magic",
|
| 453 |
+
category: "spell",
|
| 454 |
+
target: "foe",
|
| 455 |
+
cost: { energy: 10 },
|
| 456 |
+
cast: 1,
|
| 457 |
+
recharge: 8,
|
| 458 |
+
effects: [{ op: "life_steal", amount: { scale: [18, 60] } }]
|
| 459 |
+
},
|
| 460 |
+
{
|
| 461 |
+
id: 109,
|
| 462 |
+
name: "Life Siphon",
|
| 463 |
+
profession: "Necromancer",
|
| 464 |
+
attribute: "Blood Magic",
|
| 465 |
+
category: "hex",
|
| 466 |
+
target: "foe",
|
| 467 |
+
cost: { energy: 10 },
|
| 468 |
+
cast: 1,
|
| 469 |
+
recharge: 5,
|
| 470 |
+
effects: [
|
| 471 |
+
{ op: "regen_mod", pips: { scale: [-1, -3] }, duration: { scale: [12, 24] }, scope: "target" },
|
| 472 |
+
{ op: "regen_mod", pips: { scale: [1, 3] }, duration: { scale: [12, 24] }, scope: "self" }
|
| 473 |
+
]
|
| 474 |
+
},
|
| 475 |
+
{
|
| 476 |
+
id: 115,
|
| 477 |
+
name: "Blood Renewal",
|
| 478 |
+
profession: "Necromancer",
|
| 479 |
+
attribute: "Blood Magic",
|
| 480 |
+
category: "enchantment",
|
| 481 |
+
target: "self",
|
| 482 |
+
cost: { energy: 1, sacrifice: 15 },
|
| 483 |
+
cast: 1,
|
| 484 |
+
recharge: 7,
|
| 485 |
+
// +3…6 regen for 7s, then a burst heal of 40…190 when the enchant ends.
|
| 486 |
+
effects: [
|
| 487 |
+
{ op: "regen_mod", pips: { scale: [3, 6] }, duration: { fixed: 7 }, scope: "self" },
|
| 488 |
+
{ op: "enchant", duration: { fixed: 7 }, trigger: "on_end", payload: [{ op: "heal", amount: { scale: [40, 190] }, scope: "self" }] }
|
| 489 |
+
]
|
| 490 |
+
},
|
| 491 |
+
// ── Necromancer · Plaguebearer (condition spread) ──
|
| 492 |
+
{
|
| 493 |
+
id: 118,
|
| 494 |
+
name: "Enfeebling Blood",
|
| 495 |
+
profession: "Necromancer",
|
| 496 |
+
attribute: "Curses",
|
| 497 |
+
category: "spell",
|
| 498 |
+
target: "foe",
|
| 499 |
+
cost: { energy: 1, sacrifice: 10 },
|
| 500 |
+
cast: 1,
|
| 501 |
+
recharge: 8,
|
| 502 |
+
effects: [{ op: "apply_condition", condition: "weakness", duration: { scale: [5, 20] }, scope: "target_and_adjacent" }]
|
| 503 |
+
},
|
| 504 |
+
{
|
| 505 |
+
id: 106,
|
| 506 |
+
name: "Rotting Flesh",
|
| 507 |
+
profession: "Necromancer",
|
| 508 |
+
attribute: "Death Magic",
|
| 509 |
+
category: "spell",
|
| 510 |
+
target: "foe",
|
| 511 |
+
cost: { energy: 15 },
|
| 512 |
+
cast: 3,
|
| 513 |
+
recharge: 3,
|
| 514 |
+
effects: [{ op: "apply_condition", condition: "disease", duration: { scale: [10, 25] } }]
|
| 515 |
+
},
|
| 516 |
+
{
|
| 517 |
+
id: 135,
|
| 518 |
+
name: "Faintheartedness",
|
| 519 |
+
profession: "Necromancer",
|
| 520 |
+
attribute: "Curses",
|
| 521 |
+
category: "hex",
|
| 522 |
+
target: "foe",
|
| 523 |
+
cost: { energy: 10 },
|
| 524 |
+
cast: 1,
|
| 525 |
+
recharge: 8,
|
| 526 |
+
effects: [
|
| 527 |
+
{ op: "regen_mod", pips: { scale: [0, -3] }, duration: { scale: [3, 16] }, scope: "target" },
|
| 528 |
+
{ op: "attack_speed", mult: 2, duration: { scale: [3, 16] }, scope: "target" }
|
| 529 |
+
]
|
| 530 |
+
},
|
| 531 |
+
// ── Monk · Healer (raw healing) ──
|
| 532 |
+
{
|
| 533 |
+
id: 281,
|
| 534 |
+
name: "Orison of Healing",
|
| 535 |
+
profession: "Monk",
|
| 536 |
+
attribute: "Healing Prayers",
|
| 537 |
+
category: "spell",
|
| 538 |
+
target: "ally",
|
| 539 |
+
cost: { energy: 5 },
|
| 540 |
+
cast: 1,
|
| 541 |
+
recharge: 2,
|
| 542 |
+
effects: [{ op: "heal", amount: { scale: [20, 70] }, scope: "target" }]
|
| 543 |
+
},
|
| 544 |
+
{
|
| 545 |
+
id: 283,
|
| 546 |
+
name: "Dwayna's Kiss",
|
| 547 |
+
profession: "Monk",
|
| 548 |
+
attribute: "Healing Prayers",
|
| 549 |
+
category: "spell",
|
| 550 |
+
target: "other_ally",
|
| 551 |
+
cost: { energy: 5 },
|
| 552 |
+
cast: 1,
|
| 553 |
+
recharge: 3,
|
| 554 |
+
// Heal, +10…35 more for each enchantment and hex on the target ally.
|
| 555 |
+
effects: [{ op: "heal", amount: { scale: [15, 60] }, scope: "target", plusPerMod: { kinds: ["enchant", "hex"], amount: { scale: [10, 35] } } }]
|
| 556 |
+
},
|
| 557 |
+
{
|
| 558 |
+
id: 282,
|
| 559 |
+
name: "Word of Healing",
|
| 560 |
+
profession: "Monk",
|
| 561 |
+
attribute: "Healing Prayers",
|
| 562 |
+
category: "spell",
|
| 563 |
+
target: "ally",
|
| 564 |
+
cost: { energy: 5 },
|
| 565 |
+
cast: 0.75,
|
| 566 |
+
recharge: 3,
|
| 567 |
+
elite: true,
|
| 568 |
+
// Conditional bonus first so the "<50% Health" check reads the pre-heal HP
|
| 569 |
+
// (the base heal below would otherwise lift the ally over the threshold).
|
| 570 |
+
effects: [
|
| 571 |
+
{ op: "heal", amount: { scale: [30, 115] }, scope: "target", if: { target_below_health: 0.5 } },
|
| 572 |
+
{ op: "heal", amount: { scale: [5, 100] }, scope: "target" }
|
| 573 |
+
]
|
| 574 |
+
},
|
| 575 |
+
// ── Monk · Smiter (holy offense) ──
|
| 576 |
+
{
|
| 577 |
+
id: 312,
|
| 578 |
+
name: "Holy Strike",
|
| 579 |
+
profession: "Monk",
|
| 580 |
+
attribute: "Smiting Prayers",
|
| 581 |
+
category: "spell",
|
| 582 |
+
target: "foe",
|
| 583 |
+
cost: { energy: 5 },
|
| 584 |
+
cast: 0.75,
|
| 585 |
+
recharge: 8,
|
| 586 |
+
effects: [
|
| 587 |
+
{ op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target" },
|
| 588 |
+
{ op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target", if: { target: "knocked_down" } }
|
| 589 |
+
]
|
| 590 |
+
},
|
| 591 |
+
{
|
| 592 |
+
id: 240,
|
| 593 |
+
name: "Smite",
|
| 594 |
+
profession: "Monk",
|
| 595 |
+
attribute: "Smiting Prayers",
|
| 596 |
+
category: "spell",
|
| 597 |
+
target: "foe",
|
| 598 |
+
cost: { energy: 10 },
|
| 599 |
+
cast: 1,
|
| 600 |
+
recharge: 10,
|
| 601 |
+
effects: [
|
| 602 |
+
{ op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target_and_adjacent" },
|
| 603 |
+
{ op: "damage", damageType: "holy", amount: { scale: [10, 35] }, scope: "target_and_adjacent", if: { target: "attacking" } }
|
| 604 |
+
]
|
| 605 |
+
},
|
| 606 |
+
{
|
| 607 |
+
id: 252,
|
| 608 |
+
name: "Banish",
|
| 609 |
+
profession: "Monk",
|
| 610 |
+
attribute: "Smiting Prayers",
|
| 611 |
+
category: "spell",
|
| 612 |
+
target: "foe",
|
| 613 |
+
cost: { energy: 5 },
|
| 614 |
+
cast: 1,
|
| 615 |
+
recharge: 10,
|
| 616 |
+
// Double vs summoned creatures — a no-op until summons exist, but recorded.
|
| 617 |
+
effects: [{ op: "damage", damageType: "holy", amount: { scale: [20, 56] }, scope: "target", vsSummoned: 2 }]
|
| 618 |
+
},
|
| 619 |
+
// ── Assassin · Nightstalker (shadow-step burst) ──
|
| 620 |
+
{
|
| 621 |
+
id: 952,
|
| 622 |
+
name: "Death's Charge",
|
| 623 |
+
profession: "Assassin",
|
| 624 |
+
attribute: "Shadow Arts",
|
| 625 |
+
category: "skill",
|
| 626 |
+
target: "foe",
|
| 627 |
+
cost: { energy: 5 },
|
| 628 |
+
cast: 0.25,
|
| 629 |
+
recharge: 30,
|
| 630 |
+
// Shadow-step to the foe; heal only if that foe has more Health than you.
|
| 631 |
+
effects: [
|
| 632 |
+
{ op: "shadow_step", to: "foe" },
|
| 633 |
+
{ op: "heal", amount: { scale: [65, 200] }, scope: "self", if: { target_health_above_self: true } }
|
| 634 |
+
]
|
| 635 |
+
},
|
| 636 |
+
{
|
| 637 |
+
id: 1024,
|
| 638 |
+
name: "Black Mantis Thrust",
|
| 639 |
+
profession: "Assassin",
|
| 640 |
+
attribute: "Deadly Arts",
|
| 641 |
+
category: "lead_attack",
|
| 642 |
+
target: "foe",
|
| 643 |
+
cost: { energy: 5 },
|
| 644 |
+
cast: 1,
|
| 645 |
+
recharge: 6,
|
| 646 |
+
requires: ["on_hit"],
|
| 647 |
+
effects: [
|
| 648 |
+
{ op: "bonus_damage", amount: { scale: [8, 20] } },
|
| 649 |
+
{ op: "apply_condition", condition: "crippled", duration: { scale: [3, 15] }, if: { target: "hexed" } },
|
| 650 |
+
{ op: "set_combo_mark", stage: "lead" }
|
| 651 |
+
]
|
| 652 |
+
},
|
| 653 |
+
// ── Assassin · Saboteur (control / Deadly Arts) ──
|
| 654 |
+
{
|
| 655 |
+
id: 858,
|
| 656 |
+
name: "Dancing Daggers",
|
| 657 |
+
profession: "Assassin",
|
| 658 |
+
attribute: "Deadly Arts",
|
| 659 |
+
category: "spell",
|
| 660 |
+
target: "foe",
|
| 661 |
+
cost: { energy: 5 },
|
| 662 |
+
cast: 1,
|
| 663 |
+
recharge: 5,
|
| 664 |
+
// Three earth projectiles, each 5…35; counts as a lead attack.
|
| 665 |
+
effects: [
|
| 666 |
+
{ op: "damage", damageType: "earth", amount: { scale: [5, 35] }, projectiles: 3, delivery: "projectile_spell", scope: "target" },
|
| 667 |
+
{ op: "set_combo_mark", stage: "lead" }
|
| 668 |
+
]
|
| 669 |
+
},
|
| 670 |
+
{
|
| 671 |
+
id: 784,
|
| 672 |
+
name: "Entangling Asp",
|
| 673 |
+
profession: "Assassin",
|
| 674 |
+
attribute: "Deadly Arts",
|
| 675 |
+
category: "spell",
|
| 676 |
+
target: "foe",
|
| 677 |
+
cost: { energy: 10 },
|
| 678 |
+
cast: 1,
|
| 679 |
+
recharge: 20,
|
| 680 |
+
requires: [{ combo_follows: "lead" }],
|
| 681 |
+
effects: [
|
| 682 |
+
{ op: "knockdown", duration: { fixed: 2 } },
|
| 683 |
+
{ op: "apply_condition", condition: "poison", duration: { scale: [5, 20] } }
|
| 684 |
+
]
|
| 685 |
+
},
|
| 686 |
+
{
|
| 687 |
+
id: 988,
|
| 688 |
+
name: "Temple Strike",
|
| 689 |
+
profession: "Assassin",
|
| 690 |
+
attribute: "Dagger Mastery",
|
| 691 |
+
category: "offhand_attack",
|
| 692 |
+
target: "foe",
|
| 693 |
+
cost: { energy: 15 },
|
| 694 |
+
cast: 0,
|
| 695 |
+
recharge: 20,
|
| 696 |
+
elite: true,
|
| 697 |
+
requires: ["on_hit", { combo_follows: "lead" }],
|
| 698 |
+
effects: [
|
| 699 |
+
{ op: "interrupt", if: { target: "casting_spell" } },
|
| 700 |
+
// interrupts a spell
|
| 701 |
+
{ op: "apply_condition", condition: "dazed", duration: { scale: [1, 10] } },
|
| 702 |
+
{ op: "apply_condition", condition: "blind", duration: { scale: [1, 10] } }
|
| 703 |
+
]
|
| 704 |
+
}
|
| 705 |
+
];
|
| 706 |
+
var CB_SKILLS = [...FIRST_15, ...VARIANT_EXTRA];
|
| 707 |
+
|
| 708 |
+
// src/engine/rng.js
|
| 709 |
+
function makeRng(seed) {
|
| 710 |
+
let a = seed >>> 0;
|
| 711 |
+
return function rng() {
|
| 712 |
+
a |= 0;
|
| 713 |
+
a = a + 1831565813 | 0;
|
| 714 |
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
| 715 |
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
| 716 |
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
| 717 |
+
};
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
// src/engine/range.js
|
| 721 |
+
var MELEE_GW = 144;
|
| 722 |
+
var BASIC_MELEE_GW = MELEE_GW / 2;
|
| 723 |
+
var BOW_GW = 1e3;
|
| 724 |
+
|
| 725 |
+
// src/engine/teamBattle.js
|
| 726 |
+
var byId = Object.fromEntries(CB_SKILLS.map((s) => [s.id, s]));
|
| 727 |
+
var skillById = (id) => byId[id] || null;
|
| 728 |
+
var FIELD = { w: 1e3, h: 600 };
|
| 729 |
+
var FORMATION = [
|
| 730 |
+
{ x: 0.31, y: 0.66 },
|
| 731 |
+
{ x: 0.47, y: 0.74 },
|
| 732 |
+
{ x: 0.13, y: 0.73 },
|
| 733 |
+
{ x: 0.28, y: 0.82 },
|
| 734 |
+
{ x: 0.44, y: 0.91 }
|
| 735 |
+
];
|
| 736 |
+
var HIT_TOLERANCE = 130;
|
| 737 |
+
function val(v, rank = 12) {
|
| 738 |
+
if (typeof v === "number") return v;
|
| 739 |
+
if (v == null) return 0;
|
| 740 |
+
if (v.fixed != null) return v.fixed;
|
| 741 |
+
if (v.scale) {
|
| 742 |
+
const [a, b] = v.scale;
|
| 743 |
+
return Math.round(a + (b - a) * rank / 15);
|
| 744 |
+
}
|
| 745 |
+
return 0;
|
| 746 |
+
}
|
| 747 |
+
var DEGEN = { bleeding: 4, poison: 4, burning: 8, disease: 4 };
|
| 748 |
+
var ATTACK_CATEGORIES = ["melee_attack", "bow_attack", "lead_attack", "offhand_attack", "dual_attack", "scythe_attack", "spear_attack"];
|
| 749 |
+
var isAttack = (s) => ATTACK_CATEGORIES.includes(s.category);
|
| 750 |
+
var CLASS_TEMPLATES = {
|
| 751 |
+
Warrior: { maxHp: 520, role: "melee", weapon: { min: 15, max: 22, interval: 1.5, range: BASIC_MELEE_GW }, moveSpeed: 130, maxEnergy: 25, energyRegen: 0.5, armor: 80 },
|
| 752 |
+
Assassin: { maxHp: 480, role: "melee", weapon: { min: 7, max: 17, interval: 1.33, range: BASIC_MELEE_GW }, moveSpeed: 175, maxEnergy: 30, energyRegen: 1.2, armor: 55 },
|
| 753 |
+
Ranger: { maxHp: 430, role: "ranged", weapon: { min: 12, max: 28, interval: 1.9, range: BOW_GW, projSpeed: 850 }, moveSpeed: 155, preferredRange: 620, maxEnergy: 35, energyRegen: 1, armor: 45 },
|
| 754 |
+
Monk: { maxHp: 470, role: "melee", weapon: { min: 8, max: 14, interval: 1.6, range: BASIC_MELEE_GW }, moveSpeed: 140, maxEnergy: 40, energyRegen: 1.4, armor: 60 },
|
| 755 |
+
Necromancer: { maxHp: 450, role: "ranged", weapon: { min: 10, max: 20, interval: 1.8, range: BOW_GW, projSpeed: 720 }, moveSpeed: 140, preferredRange: 520, maxEnergy: 35, energyRegen: 1, armor: 45 }
|
| 756 |
+
};
|
| 757 |
+
var DEFAULT_TEMPLATE = { maxHp: 300, role: "melee", weapon: { min: 10, max: 16, interval: 1.5, range: BASIC_MELEE_GW }, moveSpeed: 150, maxEnergy: 30, energyRegen: 1, armor: 50 };
|
| 758 |
+
function templateFor(unit) {
|
| 759 |
+
if (unit.template) return unit.template;
|
| 760 |
+
if (unit.profession && CLASS_TEMPLATES[unit.profession]) return CLASS_TEMPLATES[unit.profession];
|
| 761 |
+
if (unit.stats) {
|
| 762 |
+
const s = unit.stats;
|
| 763 |
+
const basic = s.basicDamage ?? 12;
|
| 764 |
+
const ranged = unit.attackType === "ranged";
|
| 765 |
+
return {
|
| 766 |
+
maxHp: s.hp ?? 100,
|
| 767 |
+
role: ranged ? "ranged" : "melee",
|
| 768 |
+
armor: s.armor ?? 40,
|
| 769 |
+
moveSpeed: 150,
|
| 770 |
+
maxEnergy: 30,
|
| 771 |
+
energyRegen: 1,
|
| 772 |
+
preferredRange: ranged ? 600 : void 0,
|
| 773 |
+
weapon: {
|
| 774 |
+
min: Math.max(1, Math.round(basic * 0.8)),
|
| 775 |
+
max: Math.round(basic * 1.3),
|
| 776 |
+
interval: 1.4,
|
| 777 |
+
range: ranged ? BOW_GW : BASIC_MELEE_GW,
|
| 778 |
+
...ranged ? { projSpeed: 800 } : {}
|
| 779 |
+
}
|
| 780 |
+
};
|
| 781 |
+
}
|
| 782 |
+
return DEFAULT_TEMPLATE;
|
| 783 |
+
}
|
| 784 |
+
function makeActor(unit, team, id, slot) {
|
| 785 |
+
const tpl = templateFor(unit);
|
| 786 |
+
const p = FORMATION[slot % FORMATION.length];
|
| 787 |
+
const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y };
|
| 788 |
+
const bar = (unit.skills || []).map(skillById).filter(Boolean);
|
| 789 |
+
return {
|
| 790 |
+
id,
|
| 791 |
+
team,
|
| 792 |
+
name: unit.name || id,
|
| 793 |
+
profession: unit.profession || null,
|
| 794 |
+
role: tpl.role,
|
| 795 |
+
rank: unit.rank ?? 12,
|
| 796 |
+
armor: tpl.armor ?? 0,
|
| 797 |
+
weapon: { ...tpl.weapon },
|
| 798 |
+
moveSpeed: tpl.moveSpeed,
|
| 799 |
+
preferredRange: tpl.preferredRange,
|
| 800 |
+
radius: radiusOf(unit, tpl),
|
| 801 |
+
maxEnergy: tpl.maxEnergy,
|
| 802 |
+
energyRegen: tpl.energyRegen,
|
| 803 |
+
baseMaxHp: tpl.maxHp,
|
| 804 |
+
maxHp: tpl.maxHp,
|
| 805 |
+
hp: tpl.maxHp,
|
| 806 |
+
energy: tpl.maxEnergy,
|
| 807 |
+
adrenaline: 0,
|
| 808 |
+
bar,
|
| 809 |
+
x: pt.x * FIELD.w,
|
| 810 |
+
y: pt.y * FIELD.h,
|
| 811 |
+
facing: team === "player" ? 1 : -1,
|
| 812 |
+
faceX: team === "player" ? 1 : -1,
|
| 813 |
+
faceY: team === "player" ? -1 : 1,
|
| 814 |
+
// players look up-right, enemies down-left
|
| 815 |
+
attackTimer: tpl.weapon.interval,
|
| 816 |
+
casting: null,
|
| 817 |
+
recharge: {},
|
| 818 |
+
conds: [],
|
| 819 |
+
marks: {},
|
| 820 |
+
prep: null,
|
| 821 |
+
alive: true,
|
| 822 |
+
mods: [],
|
| 823 |
+
kd: 0
|
| 824 |
+
};
|
| 825 |
+
}
|
| 826 |
+
function makeTeamBattle({ seed = 1, players = [], enemies = [] } = {}) {
|
| 827 |
+
const actors = [];
|
| 828 |
+
players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i)));
|
| 829 |
+
enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i)));
|
| 830 |
+
return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null };
|
| 831 |
+
}
|
| 832 |
+
var ADJACENT_GW = 140;
|
| 833 |
+
var BODY_RADIUS = { melee: 35, ranged: 32 };
|
| 834 |
+
var DEFAULT_RADIUS = 32;
|
| 835 |
+
var DEOVERLAP_ITERS = 3;
|
| 836 |
+
var DEOVERLAP_FRACTION = 0.5;
|
| 837 |
+
var CONTACT_SLOP = 2;
|
| 838 |
+
var MAX_BATTLE_T = 90;
|
| 839 |
+
var COLLISION_Y_WEIGHT = 3.2;
|
| 840 |
+
var radiusOf = (unit, tpl) => unit.template?.radius ?? unit.stats?.radius ?? tpl.radius ?? BODY_RADIUS[tpl.role] ?? DEFAULT_RADIUS;
|
| 841 |
+
var edgeGap = (a, t) => dist(a, t) - (a.radius || 0) - (t.radius || 0);
|
| 842 |
+
var MELEE_REACH = 2;
|
| 843 |
+
var reachOf = (a) => a.role === "ranged" ? a.weapon.range : MELEE_REACH;
|
| 844 |
+
var SPELL_RANGE = 900;
|
| 845 |
+
var ARMOR_IGNORING = /* @__PURE__ */ new Set(["shadow", "holy", "dark", "chaos"]);
|
| 846 |
+
var dist = (a, e) => Math.hypot(a.x - e.x, a.y - e.y);
|
| 847 |
+
var hasCond = (a, type) => a.conds.some((c) => c.type === type);
|
| 848 |
+
var isKd = (b, a) => a.kd > b.t;
|
| 849 |
+
var gainAdr = (a, n) => {
|
| 850 |
+
a.adrenaline = Math.min(25, a.adrenaline + n);
|
| 851 |
+
};
|
| 852 |
+
var livingFoes = (b, a) => b.actors.filter((x) => x.alive && x.team !== a.team);
|
| 853 |
+
var alliesOf = (b, a) => b.actors.filter((x) => x.alive && x.team === a.team);
|
| 854 |
+
function nearestFoe(b, a) {
|
| 855 |
+
let best = null, bd = Infinity;
|
| 856 |
+
for (const x of livingFoes(b, a)) {
|
| 857 |
+
const d = dist(a, x);
|
| 858 |
+
if (d < bd) {
|
| 859 |
+
bd = d;
|
| 860 |
+
best = x;
|
| 861 |
+
}
|
| 862 |
+
}
|
| 863 |
+
return best;
|
| 864 |
+
}
|
| 865 |
+
function mostWoundedAlly(b, a, includeSelf = true) {
|
| 866 |
+
let best = null, bf = Infinity;
|
| 867 |
+
for (const x of alliesOf(b, a)) {
|
| 868 |
+
if (!includeSelf && x === a) continue;
|
| 869 |
+
const f = x.hp / x.maxHp;
|
| 870 |
+
if (f < bf) {
|
| 871 |
+
bf = f;
|
| 872 |
+
best = x;
|
| 873 |
+
}
|
| 874 |
+
}
|
| 875 |
+
return best;
|
| 876 |
+
}
|
| 877 |
+
var adjacentTo = (b, tgt) => b.actors.filter((x) => x.alive && x.team === tgt.team && x !== tgt && dist(x, tgt) <= ADJACENT_GW);
|
| 878 |
+
var addMod = (b, a, m) => a.mods.push({ until: b.t + (m.dur ?? 0), ...m });
|
| 879 |
+
var activeMods = (b, a, kind) => a.mods.filter((m) => m.kind === kind && m.until > b.t);
|
| 880 |
+
var hasModKind = (b, a, kind) => a.mods.some((m) => m.kind === kind && m.until > b.t);
|
| 881 |
+
var sumRegenPips = (b, a) => activeMods(b, a, "regen").reduce((n, m) => n + m.pips, 0);
|
| 882 |
+
var bonusArmor = (b, a) => activeMods(b, a, "armor").reduce((n, m) => n + m.amount, 0);
|
| 883 |
+
var attackSpeedMult = (b, a) => activeMods(b, a, "attackSpeed").reduce((n, m) => n * m.mult, 1);
|
| 884 |
+
var moveSpeedMult = (b, a) => (hasCond(a, "crippled") ? 0.5 : 1) * activeMods(b, a, "moveSpeed").reduce((n, m) => n * m.mult, 1);
|
| 885 |
+
var hasHex = (b, a) => hasModKind(b, a, "hex");
|
| 886 |
+
var countCat = (b, a, kinds) => a.mods.filter((m) => m.until > b.t && kinds.includes(m.cat)).length;
|
| 887 |
+
var mitigate = (dmg, armor) => dmg * (100 / (100 + (armor || 0)));
|
| 888 |
+
function log(b, kind, who, extra = {}) {
|
| 889 |
+
b.log.push({ t: Math.round(b.t * 100) / 100, kind, who: who?.id, ...extra });
|
| 890 |
+
}
|
| 891 |
+
function applyCondition(b, tgt, type, dur, empowered) {
|
| 892 |
+
if (!tgt.alive) return;
|
| 893 |
+
const ex = tgt.conds.find((c) => c.type === type);
|
| 894 |
+
if (ex) {
|
| 895 |
+
ex.until = Math.max(ex.until, b.t + dur);
|
| 896 |
+
return;
|
| 897 |
+
}
|
| 898 |
+
tgt.conds.push({ type, until: b.t + dur });
|
| 899 |
+
if (type === "deepWound") {
|
| 900 |
+
tgt.maxHp = Math.round(tgt.baseMaxHp * 0.8);
|
| 901 |
+
if (tgt.hp > tgt.maxHp) tgt.hp = tgt.maxHp;
|
| 902 |
+
}
|
| 903 |
+
log(b, "cond", tgt, { cond: type, amount: Math.round(dur), ...empowered ? { empowered: true } : {} });
|
| 904 |
+
}
|
| 905 |
+
function expireConds(b, a) {
|
| 906 |
+
for (const c of a.conds) if (c.until <= b.t && c.type === "deepWound") a.maxHp = a.baseMaxHp;
|
| 907 |
+
a.conds = a.conds.filter((c) => c.until > b.t);
|
| 908 |
+
for (const m of a.mods) if (m.kind === "onEnd" && m.until <= b.t && !m.fired) {
|
| 909 |
+
m.fired = true;
|
| 910 |
+
const src = b.actors.find((x) => x.id === m.srcId) || a;
|
| 911 |
+
for (const e of m.payload || []) applyEffect(b, src, a, e, "spell");
|
| 912 |
+
}
|
| 913 |
+
a.mods = a.mods.filter((m) => m.until > b.t);
|
| 914 |
+
}
|
| 915 |
+
function healActor(b, a, amount, empowered) {
|
| 916 |
+
if (!a.alive || amount <= 0) return;
|
| 917 |
+
a.hp = Math.min(a.maxHp, a.hp + Math.round(amount));
|
| 918 |
+
log(b, "heal", a, { amount: Math.round(amount), ...empowered ? { empowered: true } : {} });
|
| 919 |
+
}
|
| 920 |
+
function dealDamage(b, src, tgt, amount, label, opts = {}) {
|
| 921 |
+
if (!tgt.alive) return 0;
|
| 922 |
+
const { damageType = "physical", delivery = "spell", armorIgnoring = false, empowered = false } = opts;
|
| 923 |
+
const physical = delivery === "melee" || delivery === "projectile";
|
| 924 |
+
if (physical) {
|
| 925 |
+
const blk = blockRoll(b, tgt, delivery);
|
| 926 |
+
if (blk) {
|
| 927 |
+
if (blk.reflect && delivery === "melee") dealDamage(b, tgt, src, blk.reflect, "reflect", { delivery: "spell", armorIgnoring: true });
|
| 928 |
+
log(b, "miss", tgt, { name: label });
|
| 929 |
+
return 0;
|
| 930 |
+
}
|
| 931 |
+
}
|
| 932 |
+
let dmg = amount;
|
| 933 |
+
if (physical) {
|
| 934 |
+
for (const m of activeMods(b, tgt, "amplify")) if (m.vs === "physical") dmg += m.amount;
|
| 935 |
+
}
|
| 936 |
+
if (!(armorIgnoring || ARMOR_IGNORING.has(damageType))) dmg = mitigate(dmg, tgt.armor + bonusArmor(b, tgt));
|
| 937 |
+
const cap = activeMods(b, tgt, "cap")[0];
|
| 938 |
+
if (cap) dmg = Math.min(dmg, cap.fraction * tgt.maxHp);
|
| 939 |
+
const conv = activeMods(b, tgt, "convert").find((m) => m.charges > 0);
|
| 940 |
+
if (conv) {
|
| 941 |
+
healActor(b, tgt, Math.min(dmg, conv.cap));
|
| 942 |
+
conv.charges--;
|
| 943 |
+
dmg = 0;
|
| 944 |
+
}
|
| 945 |
+
for (const m of activeMods(b, tgt, "onIncomingHeal")) {
|
| 946 |
+
if (m.charges > 0 && dmg > m.threshold) {
|
| 947 |
+
healActor(b, tgt, m.amount);
|
| 948 |
+
m.charges--;
|
| 949 |
+
}
|
| 950 |
+
}
|
| 951 |
+
dmg = Math.max(0, Math.round(dmg));
|
| 952 |
+
tgt.hp -= dmg;
|
| 953 |
+
log(b, "hit", tgt, { src: src.id, amount: dmg, name: label, ...empowered ? { empowered: true } : {} });
|
| 954 |
+
gainAdr(tgt, 1);
|
| 955 |
+
if (physical) {
|
| 956 |
+
for (const m of activeMods(b, tgt, "armor")) if (m.attacksLeft != null && --m.attacksLeft <= 0) m.until = b.t;
|
| 957 |
+
fireTrigger(b, tgt, "onPhysicalHit");
|
| 958 |
+
}
|
| 959 |
+
if (tgt.hp <= 0) kill(b, tgt);
|
| 960 |
+
return dmg;
|
| 961 |
+
}
|
| 962 |
+
function blockRoll(b, tgt, delivery) {
|
| 963 |
+
for (const m of activeMods(b, tgt, "block")) {
|
| 964 |
+
if (m.vs === "all" || m.vs === delivery) {
|
| 965 |
+
if (b.rng() < m.chance) return m;
|
| 966 |
+
}
|
| 967 |
+
}
|
| 968 |
+
return null;
|
| 969 |
+
}
|
| 970 |
+
function kill(b, a) {
|
| 971 |
+
if (!a.alive) return;
|
| 972 |
+
a.alive = false;
|
| 973 |
+
a.hp = 0;
|
| 974 |
+
log(b, "death", a);
|
| 975 |
+
if (hasCond(a, "disease")) for (const x of adjacentTo(b, a)) applyCondition(b, x, "disease", 10);
|
| 976 |
+
}
|
| 977 |
+
function applyContainer(b, src, tgt, e) {
|
| 978 |
+
const dur = val(e.duration, src.rank);
|
| 979 |
+
const cat = e.op;
|
| 980 |
+
const p = e.payload?.[0] || {};
|
| 981 |
+
if (e.op === "hex") addMod(b, tgt, { kind: "hex", cat, dur });
|
| 982 |
+
for (const m of tgt.mods) if (m.endsOnHexEnchant && m.until > b.t) m.until = b.t;
|
| 983 |
+
if (p.op === "amplify_damage") {
|
| 984 |
+
addMod(b, tgt, { kind: "amplify", cat, vs: p.vs || "physical", amount: val(p.amount, src.rank), dur });
|
| 985 |
+
return;
|
| 986 |
+
}
|
| 987 |
+
if (p.op === "cap_damage") {
|
| 988 |
+
addMod(b, tgt, { kind: "cap", cat, fraction: p.maxFraction, dur });
|
| 989 |
+
return;
|
| 990 |
+
}
|
| 991 |
+
if (p.op === "convert_damage_to_heal") {
|
| 992 |
+
addMod(b, tgt, { kind: "convert", cat, cap: val(p.cap, src.rank), charges: e.charges ?? 1, dur });
|
| 993 |
+
return;
|
| 994 |
+
}
|
| 995 |
+
if (e.trigger === "on_end") {
|
| 996 |
+
addMod(b, tgt, { kind: "onEnd", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur });
|
| 997 |
+
return;
|
| 998 |
+
}
|
| 999 |
+
if (e.trigger === "on_incoming_damage" && p.op === "heal") {
|
| 1000 |
+
addMod(b, tgt, { kind: "onIncomingHeal", cat, amount: val(p.amount, src.rank), threshold: e.threshold?.perHitDamageOver ?? 0, charges: e.charges ?? 1, dur });
|
| 1001 |
+
return;
|
| 1002 |
+
}
|
| 1003 |
+
if (e.trigger === "on_action") {
|
| 1004 |
+
addMod(b, tgt, { kind: "onAction", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur });
|
| 1005 |
+
return;
|
| 1006 |
+
}
|
| 1007 |
+
if (e.trigger === "on_physical_hit") {
|
| 1008 |
+
addMod(b, tgt, { kind: "onPhysicalHit", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur });
|
| 1009 |
+
return;
|
| 1010 |
+
}
|
| 1011 |
+
addMod(b, tgt, { kind: "enchant", cat, dur });
|
| 1012 |
+
}
|
| 1013 |
+
function fireTrigger(b, a, kind) {
|
| 1014 |
+
for (const m of activeMods(b, a, kind)) {
|
| 1015 |
+
const fakeSrc = b.actors.find((x) => x.id === m.srcId) || { id: m.srcId, rank: m.srcRank };
|
| 1016 |
+
for (const e of m.payload || []) applyEffect(b, fakeSrc, a, e, "spell");
|
| 1017 |
+
}
|
| 1018 |
+
}
|
| 1019 |
+
function resolveScope(b, src, tgt, scope) {
|
| 1020 |
+
switch (scope) {
|
| 1021 |
+
case "self":
|
| 1022 |
+
return [src];
|
| 1023 |
+
case "party":
|
| 1024 |
+
return alliesOf(b, src);
|
| 1025 |
+
case "target_and_adjacent":
|
| 1026 |
+
return [tgt, ...adjacentTo(b, tgt)];
|
| 1027 |
+
case "adjacent_to_target":
|
| 1028 |
+
return adjacentTo(b, tgt);
|
| 1029 |
+
case "nearby":
|
| 1030 |
+
case "area":
|
| 1031 |
+
return [tgt, ...adjacentTo(b, tgt)];
|
| 1032 |
+
default:
|
| 1033 |
+
return [tgt];
|
| 1034 |
+
}
|
| 1035 |
+
}
|
| 1036 |
+
function applyEffect(b, src, tgt, e, delivery = "spell", s = null) {
|
| 1037 |
+
if (e.if && !branchOk(b, e.if, src, tgt)) return;
|
| 1038 |
+
if (e.if && s) logEmpower(b, src, tgt, s, empowerLabel(e, src.rank), reasonOf(e.if));
|
| 1039 |
+
const emp = !!e.if;
|
| 1040 |
+
const targets = resolveScope(b, src, tgt, e.scope);
|
| 1041 |
+
for (const t of targets) {
|
| 1042 |
+
if (!t || !t.alive) continue;
|
| 1043 |
+
const dur = e.duration != null ? val(e.duration, src.rank) : 0;
|
| 1044 |
+
switch (e.op) {
|
| 1045 |
+
case "damage": {
|
| 1046 |
+
const amt = val(e.amount, src.rank);
|
| 1047 |
+
const n = e.projectiles || 0;
|
| 1048 |
+
if (n > 1 || e.delivery === "projectile_spell") fireSpellProjectiles(b, src, t, amt, e, n || 1);
|
| 1049 |
+
else dealDamage(b, src, t, amt, src.name || "spell", { damageType: e.damageType, delivery, empowered: emp });
|
| 1050 |
+
break;
|
| 1051 |
+
}
|
| 1052 |
+
case "life_steal": {
|
| 1053 |
+
const dealt = dealDamage(b, src, t, val(e.amount, src.rank), src.name || "steal", { delivery, armorIgnoring: true });
|
| 1054 |
+
healActor(b, src, dealt);
|
| 1055 |
+
break;
|
| 1056 |
+
}
|
| 1057 |
+
case "heal": {
|
| 1058 |
+
let amt = val(e.amount, src.rank);
|
| 1059 |
+
let scaled = 0;
|
| 1060 |
+
if (e.plusPerMod) {
|
| 1061 |
+
scaled = countCat(b, t, e.plusPerMod.kinds) * val(e.plusPerMod.amount, src.rank);
|
| 1062 |
+
amt += scaled;
|
| 1063 |
+
}
|
| 1064 |
+
if (s && scaled > 0) logEmpower(b, src, t, s, `+${scaled} heal`, `${countCat(b, t, e.plusPerMod.kinds)} effects`);
|
| 1065 |
+
healActor(b, t, amt, emp || scaled > 0);
|
| 1066 |
+
break;
|
| 1067 |
+
}
|
| 1068 |
+
case "apply_condition":
|
| 1069 |
+
applyCondition(b, t, e.condition, dur, emp);
|
| 1070 |
+
break;
|
| 1071 |
+
case "knockdown":
|
| 1072 |
+
t.kd = Math.max(t.kd, b.t + dur);
|
| 1073 |
+
t.casting = null;
|
| 1074 |
+
break;
|
| 1075 |
+
case "interrupt":
|
| 1076 |
+
if (t.casting) {
|
| 1077 |
+
t.casting = null;
|
| 1078 |
+
log(b, "cond", t, { cond: "interrupted", amount: 0, ...emp ? { empowered: true } : {} });
|
| 1079 |
+
}
|
| 1080 |
+
break;
|
| 1081 |
+
case "regen_mod":
|
| 1082 |
+
addMod(b, t, { kind: "regen", pips: val(e.pips, src.rank), dur });
|
| 1083 |
+
break;
|
| 1084 |
+
case "attack_speed":
|
| 1085 |
+
addMod(b, t, { kind: "attackSpeed", mult: e.mult, dur });
|
| 1086 |
+
break;
|
| 1087 |
+
case "armor_mod":
|
| 1088 |
+
addMod(b, t, { kind: "armor", amount: val(e.amount, src.rank), attacksLeft: e.attacksLeft, dur });
|
| 1089 |
+
break;
|
| 1090 |
+
case "move_speed":
|
| 1091 |
+
addMod(b, src, { kind: "moveSpeed", mult: e.mult, endsOnHexEnchant: e.endsOnHexEnchant, dur });
|
| 1092 |
+
break;
|
| 1093 |
+
case "block":
|
| 1094 |
+
addMod(b, src, { kind: "block", chance: e.chance, vs: e.vs || "all", reflect: e.reflect ? val(e.reflect, src.rank) : 0, endsOnHexEnchant: e.endsOnHexEnchant, dur });
|
| 1095 |
+
break;
|
| 1096 |
+
case "shadow_step":
|
| 1097 |
+
shadowStep(b, src, t);
|
| 1098 |
+
break;
|
| 1099 |
+
case "set_combo_mark":
|
| 1100 |
+
src.marks[t.id] = { stage: e.stage, until: b.t + 20 };
|
| 1101 |
+
break;
|
| 1102 |
+
case "lose_all_adrenaline":
|
| 1103 |
+
src.adrenaline = 0;
|
| 1104 |
+
break;
|
| 1105 |
+
case "preparation":
|
| 1106 |
+
src.prep = { on_attack: e.on_attack, until: b.t + dur };
|
| 1107 |
+
break;
|
| 1108 |
+
case "hex":
|
| 1109 |
+
case "enchant":
|
| 1110 |
+
applyContainer(b, src, t, e);
|
| 1111 |
+
break;
|
| 1112 |
+
default:
|
| 1113 |
+
break;
|
| 1114 |
+
}
|
| 1115 |
+
}
|
| 1116 |
+
}
|
| 1117 |
+
function shadowStep(b, a, tgt) {
|
| 1118 |
+
const dx = a.x - tgt.x, dy = a.y - tgt.y, len = Math.hypot(dx, dy) || 1;
|
| 1119 |
+
a.x = Math.max(0, Math.min(FIELD.w, tgt.x + dx / len * (BASIC_MELEE_GW * 0.8)));
|
| 1120 |
+
a.y = Math.max(0, Math.min(FIELD.h, tgt.y + dy / len * (BASIC_MELEE_GW * 0.8)));
|
| 1121 |
+
}
|
| 1122 |
+
function fireSpellProjectiles(b, src, tgt, amt, e, n) {
|
| 1123 |
+
const base = dist(src, tgt) / 900;
|
| 1124 |
+
for (let i = 0; i < n; i++) {
|
| 1125 |
+
b.projectiles.push({
|
| 1126 |
+
srcId: src.id,
|
| 1127 |
+
tgtId: tgt.id,
|
| 1128 |
+
aimX: tgt.x,
|
| 1129 |
+
aimY: tgt.y,
|
| 1130 |
+
bornT: b.t,
|
| 1131 |
+
hitT: b.t + base + i * 0.1,
|
| 1132 |
+
spell: true,
|
| 1133 |
+
amount: amt,
|
| 1134 |
+
damageType: e.damageType,
|
| 1135 |
+
label: src.name || "spell"
|
| 1136 |
+
});
|
| 1137 |
+
}
|
| 1138 |
+
log(b, "shoot", src, { name: src.name });
|
| 1139 |
+
}
|
| 1140 |
+
function branchOk(b, req, a, tgt) {
|
| 1141 |
+
if (req.target_below_health != null) return tgt.hp / tgt.maxHp < req.target_below_health;
|
| 1142 |
+
if (req.target_health_above_self) return tgt.hp > a.hp;
|
| 1143 |
+
if (req.target === "bleeding") return hasCond(tgt, "bleeding");
|
| 1144 |
+
if (req.target === "casting_spell") return !!tgt.casting;
|
| 1145 |
+
if (req.target === "moving") return !!tgt.moving;
|
| 1146 |
+
if (req.target === "knocked_down") return isKd(b, tgt);
|
| 1147 |
+
if (req.target === "hexed") return hasHex(b, tgt);
|
| 1148 |
+
if (req.target === "attacking") return tgt.attackedAt != null && b.t - tgt.attackedAt < 1.2;
|
| 1149 |
+
if (req.self === "enchanted") return hasModKind(b, a, "cap") || hasModKind(b, a, "convert");
|
| 1150 |
+
return true;
|
| 1151 |
+
}
|
| 1152 |
+
function reasonOf(req) {
|
| 1153 |
+
if (!req) return "";
|
| 1154 |
+
if (req.target_below_health != null) return `foe <${req.target_below_health * 100}%`;
|
| 1155 |
+
if (req.target_health_above_self) return "foe has more HP";
|
| 1156 |
+
if (req.target === "bleeding") return "foe Bleeding";
|
| 1157 |
+
if (req.target === "casting_spell") return "foe casting";
|
| 1158 |
+
if (req.target === "moving") return "foe moving";
|
| 1159 |
+
if (req.target === "knocked_down") return "knocked down";
|
| 1160 |
+
if (req.target === "hexed") return "foe hexed";
|
| 1161 |
+
if (req.target === "attacking") return "foe attacking";
|
| 1162 |
+
return "";
|
| 1163 |
+
}
|
| 1164 |
+
function empowerLabel(e, rank) {
|
| 1165 |
+
switch (e.op) {
|
| 1166 |
+
case "bonus_damage":
|
| 1167 |
+
case "damage":
|
| 1168 |
+
return `+${val(e.amount, rank)} dmg`;
|
| 1169 |
+
case "apply_condition":
|
| 1170 |
+
return `+${e.condition}`;
|
| 1171 |
+
case "heal":
|
| 1172 |
+
return `+${val(e.amount, rank)} heal`;
|
| 1173 |
+
case "interrupt":
|
| 1174 |
+
return "INTERRUPT";
|
| 1175 |
+
default:
|
| 1176 |
+
return "bonus";
|
| 1177 |
+
}
|
| 1178 |
+
}
|
| 1179 |
+
function logEmpower(b, src, tgt, s, label, reason) {
|
| 1180 |
+
log(b, "empower", src, { tgt: tgt?.id, skillId: s?.id, name: s?.name, label, reason });
|
| 1181 |
+
}
|
| 1182 |
+
function strike(b, a, enemy, s) {
|
| 1183 |
+
a.attackTimer = a.weapon.interval * attackSpeedMult(b, a);
|
| 1184 |
+
a.attackedAt = b.t;
|
| 1185 |
+
if (hasCond(a, "blind") && b.rng() < 0.9) {
|
| 1186 |
+
if (a.role !== "ranged") log(b, "swing", a, { name: s ? s.name : "attack" });
|
| 1187 |
+
log(b, "miss", enemy, { name: s ? s.name : "attack" });
|
| 1188 |
+
return;
|
| 1189 |
+
}
|
| 1190 |
+
let weaponDmg = a.weapon.min + b.rng() * (a.weapon.max - a.weapon.min);
|
| 1191 |
+
if (hasCond(a, "weakness")) weaponDmg *= 0.75;
|
| 1192 |
+
let bonus = 0, empEffect = null;
|
| 1193 |
+
if (s) {
|
| 1194 |
+
for (const e of s.effects) if (e.op === "bonus_damage" && (!e.if || branchOk(b, e.if, a, enemy))) {
|
| 1195 |
+
bonus += val(e.amount, a.rank);
|
| 1196 |
+
if (e.if) empEffect = e;
|
| 1197 |
+
}
|
| 1198 |
+
}
|
| 1199 |
+
if (a.role === "ranged") {
|
| 1200 |
+
const flight = dist(a, enemy) / (a.weapon.projSpeed || 800);
|
| 1201 |
+
b.projectiles.push({ srcId: a.id, tgtId: enemy.id, fromX: a.x, fromY: a.y, aimX: enemy.x, aimY: enemy.y, bornT: b.t, hitT: b.t + flight, weaponDmg, bonus, s, empEffect });
|
| 1202 |
+
log(b, "shoot", a, { name: s ? s.name : "shot", skillId: s?.id });
|
| 1203 |
+
} else {
|
| 1204 |
+
log(b, "swing", a, { name: s ? s.name : "attack" });
|
| 1205 |
+
applyHit(b, a, enemy, s, weaponDmg, bonus, empEffect);
|
| 1206 |
+
}
|
| 1207 |
+
}
|
| 1208 |
+
function applyHit(b, a, enemy, s, weaponDmg, bonus, empEffect = null) {
|
| 1209 |
+
if (!enemy.alive) return;
|
| 1210 |
+
const delivery = a.role === "ranged" ? "projectile" : "melee";
|
| 1211 |
+
if (empEffect) logEmpower(b, a, enemy, s, empowerLabel(empEffect, a.rank), reasonOf(empEffect.if));
|
| 1212 |
+
dealDamage(b, a, enemy, weaponDmg + bonus, s ? s.name : "attack", { delivery, empowered: !!empEffect });
|
| 1213 |
+
if (s) {
|
| 1214 |
+
for (const e of s.effects) {
|
| 1215 |
+
if (e.op === "bonus_damage") continue;
|
| 1216 |
+
if (e.if && !branchOk(b, e.if, a, enemy)) continue;
|
| 1217 |
+
const emp = !!e.if;
|
| 1218 |
+
if (emp && e.op !== "damage") logEmpower(b, a, enemy, s, empowerLabel(e, a.rank), reasonOf(e.if));
|
| 1219 |
+
switch (e.op) {
|
| 1220 |
+
case "apply_condition":
|
| 1221 |
+
for (const t of resolveScope(b, a, enemy, e.scope)) applyCondition(b, t, e.condition, val(e.duration, a.rank), emp);
|
| 1222 |
+
break;
|
| 1223 |
+
case "set_combo_mark":
|
| 1224 |
+
a.marks[enemy.id] = { stage: e.stage, until: b.t + 20 };
|
| 1225 |
+
break;
|
| 1226 |
+
case "lose_all_adrenaline":
|
| 1227 |
+
a.adrenaline = 0;
|
| 1228 |
+
break;
|
| 1229 |
+
case "knockdown":
|
| 1230 |
+
enemy.kd = Math.max(enemy.kd, b.t + val(e.duration, a.rank));
|
| 1231 |
+
enemy.casting = null;
|
| 1232 |
+
break;
|
| 1233 |
+
case "interrupt":
|
| 1234 |
+
if (enemy.casting) {
|
| 1235 |
+
enemy.casting = null;
|
| 1236 |
+
log(b, "cond", enemy, { cond: "interrupted", amount: 0, ...emp ? { empowered: true } : {} });
|
| 1237 |
+
}
|
| 1238 |
+
break;
|
| 1239 |
+
case "damage":
|
| 1240 |
+
applyEffect(b, a, enemy, e, "melee", s);
|
| 1241 |
+
break;
|
| 1242 |
+
default:
|
| 1243 |
+
break;
|
| 1244 |
+
}
|
| 1245 |
+
}
|
| 1246 |
+
if (s.category === "dual_attack") delete a.marks[enemy.id];
|
| 1247 |
+
}
|
| 1248 |
+
if (a.prep && a.prep.until > b.t) for (const e of a.prep.on_attack) {
|
| 1249 |
+
if (e.op === "apply_condition") applyCondition(b, enemy, e.condition, val(e.duration, a.rank));
|
| 1250 |
+
}
|
| 1251 |
+
gainAdr(a, 1);
|
| 1252 |
+
}
|
| 1253 |
+
function advanceProjectiles(b) {
|
| 1254 |
+
const live = [];
|
| 1255 |
+
for (const p of b.projectiles) {
|
| 1256 |
+
if (b.t < p.hitT) {
|
| 1257 |
+
live.push(p);
|
| 1258 |
+
continue;
|
| 1259 |
+
}
|
| 1260 |
+
const src = b.actors.find((x) => x.id === p.srcId);
|
| 1261 |
+
const tgt = b.actors.find((x) => x.id === p.tgtId);
|
| 1262 |
+
if (!src || !tgt || !tgt.alive) continue;
|
| 1263 |
+
if (Math.hypot(tgt.x - p.aimX, tgt.y - p.aimY) > HIT_TOLERANCE) {
|
| 1264 |
+
log(b, "miss", tgt, { name: p.label || p.s?.name || "shot" });
|
| 1265 |
+
continue;
|
| 1266 |
+
}
|
| 1267 |
+
if (p.spell) dealDamage(b, src, tgt, p.amount, p.label, { damageType: p.damageType, delivery: "spell" });
|
| 1268 |
+
else applyHit(b, src, tgt, p.s, p.weaponDmg, p.bonus, p.empEffect);
|
| 1269 |
+
}
|
| 1270 |
+
b.projectiles = live;
|
| 1271 |
+
}
|
| 1272 |
+
var fireOnAction = (b, a) => fireTrigger(b, a, "onAction");
|
| 1273 |
+
function applyActivationPenalty(b, a, s, cast) {
|
| 1274 |
+
for (const e of s.whileActivating || []) {
|
| 1275 |
+
if (e.op === "armor_mod") addMod(b, a, { kind: "armor", amount: val(e.amount, a.rank), dur: cast });
|
| 1276 |
+
}
|
| 1277 |
+
}
|
| 1278 |
+
var COMBO_STAGE = { lead_attack: "lead", offhand_attack: "offhand", dual_attack: "dual" };
|
| 1279 |
+
function performSkill(b, a, tgt, s) {
|
| 1280 |
+
if (s.cost?.energy) a.energy -= s.cost.energy;
|
| 1281 |
+
if (s.cost?.adrenaline) a.adrenaline -= s.cost.adrenaline;
|
| 1282 |
+
if (s.cost?.sacrifice) a.hp = Math.max(1, a.hp - Math.round(a.maxHp * s.cost.sacrifice / 100));
|
| 1283 |
+
log(b, "cast", a, { name: s.name, elite: !!s.elite, skillId: s.id, tgt: tgt.id, ...COMBO_STAGE[s.category] ? { combo: COMBO_STAGE[s.category] } : {} });
|
| 1284 |
+
a.recharge[s.name] = b.t + (s.recharge || 0);
|
| 1285 |
+
fireOnAction(b, a);
|
| 1286 |
+
if (isAttack(s)) {
|
| 1287 |
+
strike(b, a, tgt, s);
|
| 1288 |
+
return;
|
| 1289 |
+
}
|
| 1290 |
+
for (const e of s.effects) applyEffect(b, a, tgt, e, "spell", s);
|
| 1291 |
+
}
|
| 1292 |
+
var isSupport = (s) => !!s && ["self", "ally", "other_ally", "party"].includes(s.target);
|
| 1293 |
+
var enchantModKind = (e) => {
|
| 1294 |
+
const p = e.payload?.[0] || {};
|
| 1295 |
+
if (p.op === "cap_damage") return "cap";
|
| 1296 |
+
if (p.op === "convert_damage_to_heal") return "convert";
|
| 1297 |
+
if (e.trigger === "on_incoming_damage") return "onIncomingHeal";
|
| 1298 |
+
return null;
|
| 1299 |
+
};
|
| 1300 |
+
function skillTarget(b, a, s, foe) {
|
| 1301 |
+
if (s.target === "self" || s.target === "party") return a;
|
| 1302 |
+
if (s.target === "ally") return mostWoundedAlly(b, a, true);
|
| 1303 |
+
if (s.target === "other_ally") return mostWoundedAlly(b, a, false);
|
| 1304 |
+
return foe;
|
| 1305 |
+
}
|
| 1306 |
+
function usable(b, a, s, tgt, foe) {
|
| 1307 |
+
if (!tgt) return false;
|
| 1308 |
+
if (b.t < (a.recharge[s.name] || 0)) return false;
|
| 1309 |
+
if (s.cost?.energy && a.energy < s.cost.energy) return false;
|
| 1310 |
+
if (s.cost?.adrenaline && a.adrenaline < s.cost.adrenaline) return false;
|
| 1311 |
+
if (isAttack(s) && edgeGap(a, foe) > reachOf(a)) return false;
|
| 1312 |
+
if (!isAttack(s) && !isSupport(s) && dist(a, foe) > SPELL_RANGE) return false;
|
| 1313 |
+
for (const r of s.requires || []) {
|
| 1314 |
+
if (r === "on_hit") continue;
|
| 1315 |
+
if (r.combo_follows && a.marks[foe.id]?.stage !== r.combo_follows) return false;
|
| 1316 |
+
if (r.target === "bleeding" && !hasCond(foe, "bleeding")) return false;
|
| 1317 |
+
if (r.target === "casting_spell" && !foe.casting) return false;
|
| 1318 |
+
if (r.target === "moving" && !foe.moving) return false;
|
| 1319 |
+
if (r.target === "knocked_down" && !isKd(b, foe)) return false;
|
| 1320 |
+
}
|
| 1321 |
+
if (s.effects.some((e) => e.op === "preparation") && a.prep && a.prep.until > b.t) return false;
|
| 1322 |
+
if (isSupport(s)) {
|
| 1323 |
+
if (s.effects.some((e) => e.op === "heal") && tgt.hp / tgt.maxHp >= 0.7 && s.effects.every((e) => e.op === "heal" || e.op === "shadow_step")) return false;
|
| 1324 |
+
for (const e of s.effects) {
|
| 1325 |
+
if (e.op === "enchant") {
|
| 1326 |
+
const k = enchantModKind(e);
|
| 1327 |
+
if (k && hasModKind(b, tgt, k)) return false;
|
| 1328 |
+
}
|
| 1329 |
+
const selfBuffKind = { block: "block", armor_mod: "armor", regen_mod: "regen", attack_speed: "attackSpeed", move_speed: "moveSpeed" }[e.op];
|
| 1330 |
+
if (selfBuffKind && hasModKind(b, a, selfBuffKind)) return false;
|
| 1331 |
+
}
|
| 1332 |
+
} else {
|
| 1333 |
+
const meaningful = s.effects.filter((e) => e.op !== "set_combo_mark" && e.op !== "preparation");
|
| 1334 |
+
if (meaningful.length && meaningful.every((e) => e.op === "apply_condition" && hasCond(foe, e.condition))) return false;
|
| 1335 |
+
}
|
| 1336 |
+
return true;
|
| 1337 |
+
}
|
| 1338 |
+
function chooseAction(b, a, foe) {
|
| 1339 |
+
for (const s of a.bar) {
|
| 1340 |
+
const tgt = skillTarget(b, a, s, foe);
|
| 1341 |
+
if (usable(b, a, s, tgt, foe)) return { skill: s, target: tgt };
|
| 1342 |
+
}
|
| 1343 |
+
return null;
|
| 1344 |
+
}
|
| 1345 |
+
function moveActor(b, a, enemy, dt) {
|
| 1346 |
+
const d = dist(a, enemy);
|
| 1347 |
+
let toward = 0;
|
| 1348 |
+
if (a.role === "ranged") {
|
| 1349 |
+
if (d < (a.preferredRange ?? a.weapon.range * 0.7)) toward = -1;
|
| 1350 |
+
else if (d > a.weapon.range) toward = 1;
|
| 1351 |
+
} else if (edgeGap(a, enemy) > reachOf(a)) {
|
| 1352 |
+
toward = 1;
|
| 1353 |
+
}
|
| 1354 |
+
if (!toward) {
|
| 1355 |
+
a.vx = 0;
|
| 1356 |
+
a.vy = 0;
|
| 1357 |
+
return;
|
| 1358 |
+
}
|
| 1359 |
+
const speed = a.moveSpeed * moveSpeedMult(b, a);
|
| 1360 |
+
const dx = enemy.x - a.x, dy = enemy.y - a.y, len = Math.hypot(dx, dy) || 1;
|
| 1361 |
+
const desVx = dx / len * speed * toward, desVy = dy / len * speed * toward;
|
| 1362 |
+
const [vx, vy] = avoidVelocity(b, a, enemy, desVx, desVy, speed);
|
| 1363 |
+
a.x = clampField(a.x + vx * dt, a.radius, FIELD.w);
|
| 1364 |
+
a.y = clampField(a.y + vy * dt, a.radius, FIELD.h);
|
| 1365 |
+
a.vx = vx;
|
| 1366 |
+
a.vy = vy;
|
| 1367 |
+
a.moving = true;
|
| 1368 |
+
}
|
| 1369 |
+
var RVO_TAU = 1.6;
|
| 1370 |
+
var RVO_RANGE = 280;
|
| 1371 |
+
var RVO_W = 240;
|
| 1372 |
+
var RVO_ANGLES = [0, 0.3, -0.3, 0.62, -0.62, 0.98, -0.98, 1.4, -1.4];
|
| 1373 |
+
var RVO_SPEEDS = [1, 0.6];
|
| 1374 |
+
function avoidVelocity(b, a, enemy, desVx, desVy, speed) {
|
| 1375 |
+
const KY = COLLISION_Y_WEIGHT;
|
| 1376 |
+
const obs = [];
|
| 1377 |
+
for (const o of b.actors) {
|
| 1378 |
+
if (o === a || !o.alive || o === enemy) continue;
|
| 1379 |
+
const rpx = o.x - a.x, rpy = (o.y - a.y) * KY;
|
| 1380 |
+
if (rpx * rpx + rpy * rpy > RVO_RANGE * RVO_RANGE) continue;
|
| 1381 |
+
obs.push({ rpx, rpy, ovx: o.vx || 0, ovy: (o.vy || 0) * KY, R: a.radius + o.radius });
|
| 1382 |
+
}
|
| 1383 |
+
if (!obs.length) return [desVx, desVy];
|
| 1384 |
+
const baseAng = Math.atan2(desVy, desVx);
|
| 1385 |
+
let best = [desVx, desVy], bestPen = Infinity;
|
| 1386 |
+
for (const da of RVO_ANGLES) {
|
| 1387 |
+
const ang = baseAng + da, cs = Math.cos(ang), sn = Math.sin(ang);
|
| 1388 |
+
for (const sf of RVO_SPEEDS) {
|
| 1389 |
+
const cvx = cs * speed * sf, cvy = sn * speed * sf;
|
| 1390 |
+
const cvxw = cvx, cvyw = cvy * KY;
|
| 1391 |
+
let minTtc = Infinity;
|
| 1392 |
+
for (const o of obs) {
|
| 1393 |
+
const t = timeToHit(o.rpx, o.rpy, o.ovx - cvxw, o.ovy - cvyw, o.R);
|
| 1394 |
+
if (t < minTtc) minTtc = t;
|
| 1395 |
+
}
|
| 1396 |
+
const collPen = minTtc <= RVO_TAU ? RVO_W / Math.max(minTtc, 0.05) : 0;
|
| 1397 |
+
const dev = Math.hypot(cvx - desVx, cvy - desVy);
|
| 1398 |
+
const pen = collPen + dev;
|
| 1399 |
+
if (pen < bestPen) {
|
| 1400 |
+
bestPen = pen;
|
| 1401 |
+
best = [cvx, cvy];
|
| 1402 |
+
}
|
| 1403 |
+
}
|
| 1404 |
+
}
|
| 1405 |
+
return best;
|
| 1406 |
+
}
|
| 1407 |
+
function timeToHit(px, py, rvx, rvy, R) {
|
| 1408 |
+
const c = px * px + py * py - R * R;
|
| 1409 |
+
if (c <= 0) return 0;
|
| 1410 |
+
const a2 = rvx * rvx + rvy * rvy;
|
| 1411 |
+
if (a2 < 1e-6) return Infinity;
|
| 1412 |
+
const b2 = px * rvx + py * rvy;
|
| 1413 |
+
if (b2 >= 0) return Infinity;
|
| 1414 |
+
const disc = b2 * b2 - a2 * c;
|
| 1415 |
+
if (disc <= 0) return Infinity;
|
| 1416 |
+
return (-b2 - Math.sqrt(disc)) / a2;
|
| 1417 |
+
}
|
| 1418 |
+
var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v));
|
| 1419 |
+
function resolveOverlaps(b) {
|
| 1420 |
+
const live = b.actors.filter((a) => a.alive);
|
| 1421 |
+
for (let it = 0; it < DEOVERLAP_ITERS; it++) {
|
| 1422 |
+
for (let i = 0; i < live.length; i++) {
|
| 1423 |
+
for (let j = i + 1; j < live.length; j++) {
|
| 1424 |
+
const a = live[i], o = live[j];
|
| 1425 |
+
const dx = o.x - a.x, dy = (o.y - a.y) * COLLISION_Y_WEIGHT;
|
| 1426 |
+
const d = Math.hypot(dx, dy) || 0.01;
|
| 1427 |
+
const overlap = a.radius + o.radius - d;
|
| 1428 |
+
if (overlap <= CONTACT_SLOP) continue;
|
| 1429 |
+
const ux = dx / d, uy = dy / d;
|
| 1430 |
+
const aFix = isImmovable(b, a), oFix = isImmovable(b, o);
|
| 1431 |
+
const push = overlap * DEOVERLAP_FRACTION;
|
| 1432 |
+
const aShare = aFix ? 0 : oFix ? 1 : 0.5;
|
| 1433 |
+
const oShare = oFix ? 0 : aFix ? 1 : 0.5;
|
| 1434 |
+
const yPush = uy / COLLISION_Y_WEIGHT;
|
| 1435 |
+
a.x = clampField(a.x - ux * push * aShare, a.radius, FIELD.w);
|
| 1436 |
+
a.y = clampField(a.y - yPush * push * aShare, a.radius, FIELD.h);
|
| 1437 |
+
o.x = clampField(o.x + ux * push * oShare, o.radius, FIELD.w);
|
| 1438 |
+
o.y = clampField(o.y + yPush * push * oShare, o.radius, FIELD.h);
|
| 1439 |
+
}
|
| 1440 |
+
}
|
| 1441 |
+
}
|
| 1442 |
+
}
|
| 1443 |
+
var isImmovable = (b, a) => !!a.casting || isKd(b, a);
|
| 1444 |
+
function step(b, dt) {
|
| 1445 |
+
if (b.over) return;
|
| 1446 |
+
b.t += dt;
|
| 1447 |
+
for (const a of b.actors) {
|
| 1448 |
+
if (!a.alive) continue;
|
| 1449 |
+
a.energy = Math.min(a.maxEnergy, a.energy + a.energyRegen * dt);
|
| 1450 |
+
let degen = 0;
|
| 1451 |
+
for (const c of a.conds) if (c.until > b.t) degen += DEGEN[c.type] || 0;
|
| 1452 |
+
const rate = degen - sumRegenPips(b, a) * 2;
|
| 1453 |
+
if (rate) {
|
| 1454 |
+
a.hp = Math.min(a.maxHp, a.hp - rate * dt);
|
| 1455 |
+
if (a.hp <= 0) kill(b, a);
|
| 1456 |
+
}
|
| 1457 |
+
expireConds(b, a);
|
| 1458 |
+
a.attackTimer -= dt;
|
| 1459 |
+
for (const id of Object.keys(a.marks)) if (a.marks[id]?.until <= b.t) delete a.marks[id];
|
| 1460 |
+
}
|
| 1461 |
+
advanceProjectiles(b);
|
| 1462 |
+
for (const a of b.actors) {
|
| 1463 |
+
if (!a.alive || b.over) continue;
|
| 1464 |
+
const enemy = nearestFoe(b, a);
|
| 1465 |
+
if (!enemy) continue;
|
| 1466 |
+
a.facing = enemy.x < a.x ? -1 : 1;
|
| 1467 |
+
a.faceX = a.facing;
|
| 1468 |
+
a.faceY = enemy.y < a.y ? -1 : 1;
|
| 1469 |
+
a.moving = false;
|
| 1470 |
+
if (isKd(b, a)) {
|
| 1471 |
+
a.casting = null;
|
| 1472 |
+
continue;
|
| 1473 |
+
}
|
| 1474 |
+
if (a.casting) {
|
| 1475 |
+
a.casting.left -= dt;
|
| 1476 |
+
if (a.casting.left <= 0) {
|
| 1477 |
+
const { skill, target } = a.casting;
|
| 1478 |
+
a.casting = null;
|
| 1479 |
+
performSkill(b, a, target?.alive ? target : enemy, skill);
|
| 1480 |
+
}
|
| 1481 |
+
continue;
|
| 1482 |
+
}
|
| 1483 |
+
const action = chooseAction(b, a, enemy);
|
| 1484 |
+
if (action) {
|
| 1485 |
+
const cast = (action.skill.cast || 0) * (hasCond(a, "dazed") ? 2 : 1);
|
| 1486 |
+
if (cast <= 0) performSkill(b, a, action.target, action.skill);
|
| 1487 |
+
else {
|
| 1488 |
+
applyActivationPenalty(b, a, action.skill, cast);
|
| 1489 |
+
a.casting = { skill: action.skill, target: action.target, left: cast };
|
| 1490 |
+
}
|
| 1491 |
+
} else if (edgeGap(a, enemy) <= reachOf(a) && a.attackTimer <= 0) {
|
| 1492 |
+
fireOnAction(b, a);
|
| 1493 |
+
strike(b, a, enemy, null);
|
| 1494 |
+
} else {
|
| 1495 |
+
moveActor(b, a, enemy, dt);
|
| 1496 |
+
}
|
| 1497 |
+
}
|
| 1498 |
+
resolveOverlaps(b);
|
| 1499 |
+
const playerAlive = b.actors.some((a) => a.alive && a.team === "player");
|
| 1500 |
+
const enemyAlive = b.actors.some((a) => a.alive && a.team === "enemy");
|
| 1501 |
+
if (!playerAlive || !enemyAlive) {
|
| 1502 |
+
b.over = true;
|
| 1503 |
+
b.winner = playerAlive ? "player" : enemyAlive ? "enemy" : null;
|
| 1504 |
+
} else if (b.t >= MAX_BATTLE_T) {
|
| 1505 |
+
const hp = (team) => b.actors.filter((a) => a.alive && a.team === team).reduce((n, a) => n + a.hp, 0);
|
| 1506 |
+
const ph = hp("player"), eh = hp("enemy");
|
| 1507 |
+
b.over = true;
|
| 1508 |
+
b.winner = ph === eh ? null : ph > eh ? "player" : "enemy";
|
| 1509 |
+
}
|
| 1510 |
+
}
|
| 1511 |
+
function runToEnd(opts, dt = 0.05, maxT = 120) {
|
| 1512 |
+
const b = makeTeamBattle(opts);
|
| 1513 |
+
while (!b.over && b.t < maxT) step(b, dt);
|
| 1514 |
+
return b;
|
| 1515 |
+
}
|
| 1516 |
+
export {
|
| 1517 |
+
CLASS_TEMPLATES,
|
| 1518 |
+
COLLISION_Y_WEIGHT,
|
| 1519 |
+
FIELD,
|
| 1520 |
+
isSupport,
|
| 1521 |
+
makeTeamBattle,
|
| 1522 |
+
runToEnd,
|
| 1523 |
+
skillById,
|
| 1524 |
+
step,
|
| 1525 |
+
val
|
| 1526 |
+
};
|
web/index.html
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Tiny Army</title>
|
| 7 |
+
<style>
|
| 8 |
+
html, body { margin: 0; height: 100%; background: #0b0e12; color: #f4ecd8;
|
| 9 |
+
font-family: ui-monospace, Menlo, monospace; }
|
| 10 |
+
#wrap { display: flex; flex-direction: column; height: 100vh; }
|
| 11 |
+
header { padding: 10px 14px; display: flex; justify-content: space-between;
|
| 12 |
+
align-items: center; border-bottom: 1px solid #20262e; }
|
| 13 |
+
header strong { letter-spacing: .03em; }
|
| 14 |
+
header span { color: #9aa4b2; font-size: 13px; }
|
| 15 |
+
header a { color: #ffd54a; text-decoration: none; }
|
| 16 |
+
#stage { flex: 1; min-height: 0; }
|
| 17 |
+
canvas { display: block; width: 100%; height: 100%; }
|
| 18 |
+
</style>
|
| 19 |
+
</head>
|
| 20 |
+
<body>
|
| 21 |
+
<div id="wrap">
|
| 22 |
+
<header>
|
| 23 |
+
<strong>🪖 Tiny Army</strong>
|
| 24 |
+
<span>deterministic auto-battler engine running in-browser ·
|
| 25 |
+
<a href="/app">barracks →</a></span>
|
| 26 |
+
</header>
|
| 27 |
+
<div id="stage"></div>
|
| 28 |
+
</div>
|
| 29 |
+
<script type="module" src="./main.js"></script>
|
| 30 |
+
</body>
|
| 31 |
+
</html>
|
web/main.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Tiny Army — Pixi stage driven by the real deterministic combat engine
|
| 2 |
+
// (engine.js is an esbuild bundle of the auto-battler's src/engine/teamBattle.js).
|
| 3 |
+
import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8/dist/pixi.min.mjs'
|
| 4 |
+
import { makeTeamBattle, step, FIELD } from './engine.js'
|
| 5 |
+
|
| 6 |
+
// A small demo line-up; later this comes from the player's saved roster.
|
| 7 |
+
const PLAYERS = [
|
| 8 |
+
{ profession: 'Warrior', name: 'Bram', skills: [] },
|
| 9 |
+
{ profession: 'Ranger', name: 'Sela', skills: [] },
|
| 10 |
+
{ profession: 'Monk', name: 'Oda', skills: [] },
|
| 11 |
+
{ profession: 'Assassin', name: 'Vex', skills: [] },
|
| 12 |
+
]
|
| 13 |
+
const ENEMIES = [
|
| 14 |
+
{ name: 'Orc Reaver', stats: { hp: 340, armor: 30, basicDamage: 16 }, attackType: 'melee', skills: [] },
|
| 15 |
+
{ name: 'Orc Reaver', stats: { hp: 340, armor: 30, basicDamage: 16 }, attackType: 'melee', skills: [] },
|
| 16 |
+
{ name: 'Bog Shaman', stats: { hp: 280, armor: 30, basicDamage: 12 }, attackType: 'ranged', skills: [] },
|
| 17 |
+
{ name: 'Dire Wolf', stats: { hp: 240, armor: 20, basicDamage: 14 }, attackType: 'melee', skills: [] },
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
let battle
|
| 21 |
+
let seed = 1
|
| 22 |
+
let overAt = 0
|
| 23 |
+
const fresh = () => { battle = makeTeamBattle({ seed: seed++, players: PLAYERS, enemies: ENEMIES }); overAt = 0 }
|
| 24 |
+
|
| 25 |
+
const host = document.getElementById('stage')
|
| 26 |
+
const app = new PIXI.Application()
|
| 27 |
+
await app.init({ background: 0x0b0e12, resizeTo: host, antialias: true })
|
| 28 |
+
host.appendChild(app.canvas)
|
| 29 |
+
|
| 30 |
+
const bg = new PIXI.Graphics()
|
| 31 |
+
const g = new PIXI.Graphics()
|
| 32 |
+
const labels = new PIXI.Container()
|
| 33 |
+
app.stage.addChild(bg, g, labels)
|
| 34 |
+
|
| 35 |
+
const banner = new PIXI.Text({ text: '', style: { fill: 0xffd54a, fontFamily: 'monospace', fontSize: 20, fontWeight: '700' } })
|
| 36 |
+
banner.anchor.set(0.5, 0)
|
| 37 |
+
app.stage.addChild(banner)
|
| 38 |
+
|
| 39 |
+
// One reusable name label per actor.
|
| 40 |
+
const nameStyle = { fill: 0xc9d2dd, fontFamily: 'monospace', fontSize: 11 }
|
| 41 |
+
const nameText = {}
|
| 42 |
+
fresh()
|
| 43 |
+
for (const a of battle.actors) { const t = new PIXI.Text({ text: a.name, style: nameStyle }); t.anchor.set(0.5, 1); nameText[a.id] = t; labels.addChild(t) }
|
| 44 |
+
|
| 45 |
+
app.ticker.add(() => {
|
| 46 |
+
const W = app.screen.width, H = app.screen.height
|
| 47 |
+
const sx = W / FIELD.w, sy = H / FIELD.h
|
| 48 |
+
|
| 49 |
+
if (!battle.over) { for (let i = 0; i < 3; i++) if (!battle.over) step(battle, 0.05) }
|
| 50 |
+
else if (!overAt) overAt = performance.now()
|
| 51 |
+
if (overAt && performance.now() - overAt > 3000) fresh()
|
| 52 |
+
|
| 53 |
+
bg.clear()
|
| 54 |
+
bg.rect(0, 0, W, H).fill(0x10151b)
|
| 55 |
+
bg.rect(0, H * 0.5 - 1, W, 2).fill({ color: 0xffffff, alpha: 0.04 })
|
| 56 |
+
|
| 57 |
+
g.clear()
|
| 58 |
+
for (const a of battle.actors) {
|
| 59 |
+
const cx = a.x * sx, cy = a.y * sy
|
| 60 |
+
const r = Math.max(6, (a.radius || 24) * sx)
|
| 61 |
+
const col = a.team === 'player' ? 0x4ad6ff : 0xff5a4a
|
| 62 |
+
const t = nameText[a.id]
|
| 63 |
+
if (!a.alive) {
|
| 64 |
+
g.circle(cx, cy, r).fill({ color: col, alpha: 0.1 })
|
| 65 |
+
if (t) t.visible = false
|
| 66 |
+
continue
|
| 67 |
+
}
|
| 68 |
+
g.circle(cx, cy, r).fill({ color: col, alpha: 0.85 }).stroke({ color: 0xffffff, width: 1, alpha: 0.5 })
|
| 69 |
+
const hp = Math.max(0, a.hp) / a.maxHp
|
| 70 |
+
g.rect(cx - r, cy - r - 9, r * 2, 4).fill({ color: 0x000000, alpha: 0.6 })
|
| 71 |
+
g.rect(cx - r, cy - r - 9, r * 2 * hp, 4).fill(hp > 0.35 ? 0x6ee36e : 0xe36e6e)
|
| 72 |
+
if (t) { t.visible = true; t.position.set(cx, cy - r - 11) }
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
banner.position.set(W / 2, 12)
|
| 76 |
+
banner.text = battle.over
|
| 77 |
+
? (battle.winner === 'player' ? '★ Your tiny army wins' : battle.winner === 'enemy' ? '☠ Enemies win' : 'Draw')
|
| 78 |
+
: ''
|
| 79 |
+
})
|