Spaces:
Running
Running
| // ../auto-battler/src/render/spriteSheet.js | |
| var SHEET_ROWS = 4; | |
| var cellOf = (height) => Math.round(height / SHEET_ROWS); | |
| function sliceGridWith(pixi, texture, cell = cellOf(texture.source.height)) { | |
| const { Texture, Rectangle } = pixi; | |
| const src = texture.source; | |
| const rows = Math.max(1, Math.round(src.height / cell)); | |
| const cols = Math.max(1, Math.round(src.width / cell)); | |
| return Array.from({ length: rows }, (_, r) => Array.from({ length: cols }, (_2, c) => new Texture({ source: src, frame: new Rectangle(c * cell, r * cell, cell, cell) }))); | |
| } | |
| var ROW_FOR = { "front-right": 0, "front-left": 1, "back-right": 2, "back-left": 3 }; | |
| var rowFor = (grid, facing) => grid[ROW_FOR[facing]] ?? grid[0]; | |
| // ../auto-battler/src/render/spriteScene.js | |
| var SPEED = 3; | |
| var PROJ_SPEED = 5; | |
| var SCALE = 4; | |
| var ROWS = 4; | |
| var ARRIVE = SPEED; | |
| var ANIM_SPEED = { idle: 0.12, walk: 0.18, attack: 0.22, dmg: 0.2, die: 0.16, jump: 0.2 }; | |
| var SCENE_ACTIONS = [ | |
| { state: "attack", code: "Space", label: "Space", verb: "attack" }, | |
| { state: "dmg", code: "KeyH", label: "H", verb: "hurt" }, | |
| { state: "die", code: "KeyK", label: "K", verb: "die" }, | |
| { state: "jump", code: "KeyJ", label: "J", verb: "jump" } | |
| ]; | |
| var STATE_KEYS = ["idle", "walk", ...SCENE_ACTIONS.map((a) => a.state), "attackDiagonal"]; | |
| var orthoRow = (d) => d.x > 0 ? 0 : d.x < 0 ? 1 : d.y > 0 ? 2 : 3; | |
| var diagRow = (d) => d.y > 0 ? d.x > 0 ? 0 : 1 : d.x > 0 ? 2 : 3; | |
| var usesAimedAttack = (c) => c?.attackVerb === "shoot" || !!c?.attackOrtho; | |
| function createSpriteScene(pixi, host, opts = {}) { | |
| const { Application, Assets, AnimatedSprite, Graphics } = pixi; | |
| const urlFor = opts.urlFor || ((u) => u); | |
| const anim = { ...ANIM_SPEED, ...opts.anim || {} }; | |
| const sliceGrid = (texture, cell) => sliceGridWith(pixi, texture, cell); | |
| let app = null; | |
| let sprite = null; | |
| let shadow = null; | |
| let overlay = null; | |
| let frames = null; | |
| let keys = { x: 0, y: 0 }; | |
| let moveTarget = null; | |
| let action = null; | |
| let pendingAction = null; | |
| let dieHold = false; | |
| let side = "right"; | |
| let depth = "front"; | |
| let dir = { x: 1, y: 1 }; | |
| let shoot = false; | |
| let applied = ""; | |
| let extras = []; | |
| let effectsOn = true; | |
| let shadowsOn = true; | |
| let flying = []; | |
| let marker = null; | |
| let markerLife = 0; | |
| let currentKind = "idle"; | |
| let lastAimKey = ""; | |
| let changeCb = null; | |
| let destroyed = false; | |
| function snapshot() { | |
| return { | |
| kind: currentKind, | |
| facing: `${depth}-${side}`, | |
| aim: `${dir.x},${dir.y}`, | |
| shoot, | |
| moving: !!moveTarget || keys.x !== 0 || keys.y !== 0, | |
| x: sprite ? sprite.x : null, | |
| y: sprite ? sprite.y : null, | |
| toggles: { effects: effectsOn, shadows: shadowsOn } | |
| }; | |
| } | |
| function emit() { | |
| if (changeCb) changeCb(snapshot()); | |
| } | |
| const ready = (async () => { | |
| const a = new Application(); | |
| await a.init({ background: 15524556, antialias: false, resizeTo: host }); | |
| if (destroyed) { | |
| a.destroy(true, { children: true }); | |
| return; | |
| } | |
| app = a; | |
| app.canvas.setAttribute("data-testid", "pixi-canvas"); | |
| app.canvas.style.touchAction = "none"; | |
| app.canvas.addEventListener("pointerdown", onPointerDown); | |
| app.canvas.addEventListener("click", onPointerDown); | |
| host.appendChild(app.canvas); | |
| app.ticker.add(tick); | |
| })(); | |
| function onPointerDown(e) { | |
| if (!app) return; | |
| const rect = app.canvas.getBoundingClientRect(); | |
| if (!rect.width || !rect.height) return; | |
| const x = (e.clientX - rect.left) / rect.width * app.screen.width; | |
| const y = (e.clientY - rect.top) / rect.height * app.screen.height; | |
| moveTo(x, y); | |
| } | |
| function moveTo(x, y) { | |
| if (!app) return; | |
| const tx = Math.max(0, Math.min(app.screen.width, x)); | |
| const ty = Math.max(0, Math.min(app.screen.height, y)); | |
| moveTarget = { x: tx, y: ty }; | |
| if (Graphics) { | |
| if (!marker) { | |
| marker = new Graphics(); | |
| app.stage.addChildAt(marker, 0); | |
| } | |
| marker.clear(); | |
| marker.circle(0, 0, 11).stroke({ color: 7035903, width: 2, alpha: 1 }); | |
| marker.position.set(tx, ty); | |
| marker.alpha = 0.9; | |
| markerLife = 30; | |
| } | |
| } | |
| function tick(ticker) { | |
| const s = sprite; | |
| const f = frames; | |
| if (!s || !f) return; | |
| let vx = keys.x; | |
| let vy = keys.y; | |
| let usingTarget = false; | |
| let seekDX = 0; | |
| let seekDY = 0; | |
| if (vx === 0 && vy === 0 && moveTarget) { | |
| seekDX = moveTarget.x - s.x; | |
| seekDY = moveTarget.y - s.y; | |
| const d = Math.hypot(seekDX, seekDY); | |
| if (d <= ARRIVE) { | |
| s.x = moveTarget.x; | |
| s.y = moveTarget.y; | |
| moveTarget = null; | |
| } else { | |
| vx = seekDX; | |
| vy = seekDY; | |
| usingTarget = true; | |
| } | |
| } | |
| const wantMove = vx !== 0 || vy !== 0; | |
| if (pendingAction) { | |
| const pend = pendingAction; | |
| pendingAction = null; | |
| if (f[pend] && (action === null || dieHold)) { | |
| action = pend; | |
| dieHold = false; | |
| applied = ""; | |
| } | |
| } | |
| if (dieHold && wantMove) { | |
| action = null; | |
| dieHold = false; | |
| } | |
| const acting = action !== null; | |
| if (!acting && wantMove) { | |
| let sgx, sgy; | |
| if (usingTarget) { | |
| sgx = Math.abs(seekDX) > 4 ? Math.sign(seekDX) : 0; | |
| sgy = Math.abs(seekDY) > 4 ? Math.sign(seekDY) : 0; | |
| } else { | |
| sgx = Math.sign(vx); | |
| sgy = Math.sign(vy); | |
| } | |
| if (sgx > 0) side = "right"; | |
| else if (sgx < 0) side = "left"; | |
| if (sgy > 0) depth = "front"; | |
| else if (sgy < 0) depth = "back"; | |
| if (sgx || sgy) dir = { x: sgx, y: sgy }; | |
| const len = Math.hypot(vx, vy) || 1; | |
| const step = SPEED * ticker.deltaTime; | |
| s.x += vx / len * step; | |
| s.y += vy / len * step; | |
| const halfW = s.width / 2; | |
| const halfH = s.height / 2; | |
| s.x = Math.max(halfW, Math.min(app.screen.width - halfW, s.x)); | |
| s.y = Math.max(halfH, Math.min(app.screen.height - halfH, s.y)); | |
| } | |
| const aimKey = `${dir.x},${dir.y}`; | |
| if (aimKey !== lastAimKey) { | |
| lastAimKey = aimKey; | |
| emit(); | |
| } | |
| if (shadow) { | |
| shadow.x = s.x; | |
| shadow.y = s.y; | |
| } | |
| if (overlay?.visible) { | |
| overlay.x = s.x; | |
| overlay.y = s.y; | |
| } | |
| if (marker && markerLife > 0) { | |
| markerLife -= ticker.deltaTime; | |
| const t = Math.max(0, markerLife / 30); | |
| marker.alpha = t * 0.9; | |
| marker.scale.set(1 + (1 - t) * 0.6); | |
| } | |
| for (let i = flying.length - 1; i >= 0; i--) { | |
| const fl = flying[i]; | |
| fl.sprite.x += fl.dx * PROJ_SPEED * ticker.deltaTime; | |
| fl.sprite.y += fl.dy * PROJ_SPEED * ticker.deltaTime; | |
| if (!fl.impFired && fl.impGrid) { | |
| const dist = Math.hypot(fl.sprite.x - fl.startX, fl.sprite.y - fl.startY); | |
| if (dist > 150) { | |
| fl.impFired = true; | |
| const imp = new AnimatedSprite(fl.impGrid[0]); | |
| imp.anchor.set(0.5); | |
| imp.scale.set(SCALE); | |
| imp.loop = false; | |
| imp.animationSpeed = anim.attack; | |
| imp.x = fl.sprite.x; | |
| imp.y = fl.sprite.y; | |
| imp.onComplete = () => { | |
| if (imp.parent) imp.parent.removeChild(imp); | |
| imp.destroy(); | |
| }; | |
| app.stage.addChild(imp); | |
| imp.gotoAndPlay(0); | |
| } | |
| } | |
| const os = fl.sprite.x < -128 || fl.sprite.x > app.screen.width + 128 || fl.sprite.y < -128 || fl.sprite.y > app.screen.height + 128; | |
| if (os) { | |
| if (fl.sprite.parent) fl.sprite.parent.removeChild(fl.sprite); | |
| fl.sprite.destroy(); | |
| flying.splice(i, 1); | |
| } | |
| } | |
| const kind = acting ? action : wantMove ? "walk" : "idle"; | |
| currentKind = kind; | |
| let fr, key; | |
| if (kind === "attack" && shoot) { | |
| const d = dir; | |
| const useDiag = d.x !== 0 && d.y !== 0 && !!f.attackDiagonal; | |
| const grid = useDiag ? f.attackDiagonal : f.attack; | |
| const row = useDiag ? diagRow(d) : orthoRow(d); | |
| fr = grid[row] ?? grid[0]; | |
| key = `shoot:${useDiag ? "d" : "o"}${row}`; | |
| } else { | |
| const facing = `${depth}-${side}`; | |
| fr = rowFor(f[kind], facing); | |
| key = `${kind}:${facing}`; | |
| } | |
| if (key !== applied) { | |
| applied = key; | |
| const oneShot = kind !== "idle" && kind !== "walk"; | |
| s.textures = fr; | |
| s.loop = !oneShot; | |
| s.animationSpeed = anim[kind] ?? (oneShot ? anim.attack : anim.walk); | |
| s.gotoAndPlay(0); | |
| const actionKey = action ?? kind; | |
| const facing = `${depth}-${side}`; | |
| if (shadow) { | |
| let shadGrid; | |
| if (kind === "attack" && shoot) { | |
| const d = dir; | |
| const useDiag = d.x !== 0 && d.y !== 0 && !!f["shd:attackDiagonal"]; | |
| shadGrid = useDiag ? f["shd:attackDiagonal"] : f["shd:attack"]; | |
| if (shadGrid) { | |
| const row = useDiag ? diagRow(d) : orthoRow(d); | |
| shadow.textures = shadGrid[row] ?? shadGrid[0]; | |
| } | |
| } else { | |
| shadGrid = f["shd:" + actionKey]; | |
| if (shadGrid) shadow.textures = rowFor(shadGrid, facing); | |
| } | |
| if (shadGrid && shadowsOn) { | |
| shadow.loop = s.loop; | |
| shadow.animationSpeed = s.animationSpeed; | |
| shadow.visible = true; | |
| shadow.gotoAndPlay(0); | |
| } else { | |
| shadow.visible = false; | |
| } | |
| } | |
| if (overlay) { | |
| const effGrid = effectsOn && f["eff:" + actionKey]; | |
| if (effGrid && oneShot) { | |
| overlay.textures = rowFor(effGrid, facing); | |
| overlay.loop = false; | |
| overlay.animationSpeed = s.animationSpeed; | |
| overlay.x = s.x; | |
| overlay.y = s.y; | |
| overlay.visible = true; | |
| overlay.gotoAndPlay(0); | |
| } else { | |
| overlay.visible = false; | |
| } | |
| } | |
| if (oneShot && effectsOn) { | |
| const projGrid = f["proj:" + actionKey]; | |
| if (projGrid) { | |
| const d = dir; | |
| const row = orthoRow(d); | |
| const proj = new AnimatedSprite(projGrid[row] ?? projGrid[0]); | |
| proj.anchor.set(0.5); | |
| proj.scale.set(SCALE); | |
| proj.loop = true; | |
| proj.animationSpeed = anim.attack; | |
| proj.x = s.x; | |
| proj.y = s.y; | |
| proj.gotoAndPlay(0); | |
| app.stage.addChild(proj); | |
| const len = Math.hypot(d.x, d.y) || 1; | |
| flying.push({ | |
| sprite: proj, | |
| dx: d.x / len, | |
| dy: d.y / len, | |
| startX: s.x, | |
| startY: s.y, | |
| impGrid: f["imp:" + actionKey] ?? null, | |
| impFired: false | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| async function setCharacter(active) { | |
| await ready; | |
| if (!app || !active) return; | |
| const sheets = [ | |
| ...STATE_KEYS.filter((k) => active[k]).map((k) => ({ gridKey: k, url: active[k], type: "body" })), | |
| ...(active.extras ?? []).map((e) => ({ gridKey: "x:" + e.key, url: e.url, type: "body" })), | |
| ...active.attackEffect ? [{ gridKey: "eff:attack", url: active.attackEffect, type: "eff" }] : [], | |
| ...active.attackProjectile ? [{ gridKey: "proj:attack", url: active.attackProjectile, type: "proj" }] : [], | |
| ...active.attackImpact ? [{ gridKey: "imp:attack", url: active.attackImpact, type: "imp" }] : [], | |
| ...Object.entries(active.shadows ?? {}).map(([k, url]) => ({ gridKey: "shd:" + k, url, type: "body" })), | |
| ...(active.extras ?? []).flatMap((e) => [ | |
| e.effect ? { gridKey: "eff:x:" + e.key, url: e.effect, type: "eff" } : null, | |
| e.projectile ? { gridKey: "proj:x:" + e.key, url: e.projectile, type: "proj" } : null, | |
| e.impact ? { gridKey: "imp:x:" + e.key, url: e.impact, type: "imp" } : null, | |
| e.shadow ? { gridKey: "shd:x:" + e.key, url: e.shadow, type: "body" } : null | |
| ].filter(Boolean)) | |
| ]; | |
| const texs = await Promise.all(sheets.map((s) => Assets.load(urlFor(s.url)).then((t) => t, () => null))); | |
| if (!app) return; | |
| const idleIdx = sheets.findIndex((s) => s.gridKey === "idle"); | |
| if (idleIdx < 0 || !texs[idleIdx]) return; | |
| const grids = {}; | |
| const cell = cellOf(texs[idleIdx].source.height); | |
| sheets.forEach((s, i) => { | |
| const t = texs[i]; | |
| if (!t) return; | |
| t.source.scaleMode = "nearest"; | |
| if (s.type === "proj") grids[s.gridKey] = sliceGrid(t, Math.max(1, Math.round(t.source.height / ROWS))); | |
| else if (s.type === "imp") grids[s.gridKey] = sliceGrid(t, Math.max(1, t.source.height)); | |
| else grids[s.gridKey] = sliceGrid(t, cell); | |
| }); | |
| frames = grids; | |
| extras = active.extras ?? []; | |
| shoot = usesAimedAttack(active); | |
| action = null; | |
| pendingAction = null; | |
| dieHold = false; | |
| applied = ""; | |
| lastAimKey = ""; | |
| const startFrames = rowFor(grids.idle, `${depth}-${side}`); | |
| if (!sprite) { | |
| const shd = new AnimatedSprite(startFrames); | |
| shd.anchor.set(0.5); | |
| shd.scale.set(SCALE); | |
| shd.loop = true; | |
| shd.visible = false; | |
| shd.animationSpeed = anim.idle; | |
| shd.x = app.screen.width / 2; | |
| shd.y = app.screen.height / 2; | |
| app.stage.addChild(shd); | |
| shadow = shd; | |
| const s = new AnimatedSprite(startFrames); | |
| s.anchor.set(0.5); | |
| s.scale.set(SCALE); | |
| s.loop = true; | |
| s.animationSpeed = anim.idle; | |
| s.x = app.screen.width / 2; | |
| s.y = app.screen.height / 2; | |
| s.onComplete = () => { | |
| if (action === "die") dieHold = true; | |
| else action = null; | |
| }; | |
| app.stage.addChild(s); | |
| s.gotoAndPlay(0); | |
| sprite = s; | |
| const ov = new AnimatedSprite(startFrames); | |
| ov.anchor.set(0.5); | |
| ov.scale.set(SCALE); | |
| ov.loop = false; | |
| ov.visible = false; | |
| ov.animationSpeed = anim.attack; | |
| ov.onComplete = () => { | |
| ov.visible = false; | |
| }; | |
| app.stage.addChild(ov); | |
| overlay = ov; | |
| } else { | |
| const s = sprite; | |
| s.loop = true; | |
| s.textures = startFrames; | |
| s.animationSpeed = anim.idle; | |
| s.gotoAndPlay(0); | |
| if (shadow) shadow.visible = false; | |
| if (overlay) overlay.visible = false; | |
| for (const fl of flying) { | |
| if (fl.sprite.parent) fl.sprite.parent.removeChild(fl.sprite); | |
| fl.sprite.destroy(); | |
| } | |
| flying = []; | |
| } | |
| emit(); | |
| } | |
| function setVelocity(v) { | |
| keys = { x: v?.x || 0, y: v?.y || 0 }; | |
| if (keys.x || keys.y) moveTarget = null; | |
| } | |
| function triggerAction(stateKey) { | |
| if (stateKey) pendingAction = stateKey; | |
| } | |
| function setToggles(t) { | |
| if (t && "effects" in t) effectsOn = !!t.effects; | |
| if (t && "shadows" in t) { | |
| shadowsOn = !!t.shadows; | |
| if (shadow && !shadowsOn) shadow.visible = false; | |
| } | |
| emit(); | |
| } | |
| return { | |
| ready, | |
| setCharacter, | |
| setVelocity, | |
| triggerAction, | |
| moveTo, | |
| setToggles, | |
| getSnapshot: snapshot, | |
| onChange: (cb) => { | |
| changeCb = cb; | |
| }, | |
| resize: () => { | |
| if (app) app.resize(); | |
| }, | |
| // Re-place the character at the centre of the current canvas — used by hosts | |
| // that mount the stage in a hidden/0-size tab and reveal it later (the Space). | |
| recenter: () => { | |
| if (!app || !sprite) return; | |
| sprite.x = app.screen.width / 2; | |
| sprite.y = app.screen.height / 2; | |
| if (shadow) { | |
| shadow.x = sprite.x; | |
| shadow.y = sprite.y; | |
| } | |
| moveTarget = null; | |
| }, | |
| destroy: () => { | |
| destroyed = true; | |
| const a = app; | |
| app = null; | |
| if (a) { | |
| a.canvas.removeEventListener("pointerdown", onPointerDown); | |
| a.canvas.removeEventListener("click", onPointerDown); | |
| a.destroy(true, { children: true }); | |
| } | |
| sprite = shadow = overlay = frames = marker = null; | |
| flying = []; | |
| } | |
| }; | |
| } | |
| export { | |
| SCENE_ACTIONS, | |
| createSpriteScene, | |
| diagRow, | |
| orthoRow, | |
| usesAimedAttack | |
| }; | |