tiny-army / web /scene.js
polats's picture
Shared: also bind click (not just pointerdown) for tap-to-move on touch devices
180b28b
// ../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
};