feat: add player character sprites and combat animations

- Add player sprite sheets for warrior and rogue classes (300x300, 3 frames: idle/attack/hit)
- Update artwork.json with playerSheets configuration
- Replace placeholder player graphic with sprite-based rendering in combat
- Add warrior lunge attack animation that rushes toward enemies during strikes
- Add hit frame animation when player takes damage from enemy attacks
- Scale up enemy sprites and UI elements for better visibility
- Update PreloadScene to load player sprite sheets
This commit is contained in:
Brian Fertig 2026-06-27 15:35:57 -06:00
parent 0afcc167dd
commit a731fd813b
7 changed files with 94 additions and 44 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

View File

@ -12,10 +12,18 @@
" 3. Map each card id to its 0-based frame index in 'cards'.", " 3. Map each card id to its 0-based frame index in 'cards'.",
"Cards show the illustration inside the procedural card frame's art window (no", "Cards show the illustration inside the procedural card frame's art window (no",
"border/text needed). Recommended card frame 250x160 (art-window aspect). See", "border/text needed). Recommended card frame 250x160 (art-window aspect). See",
"src/games/spireclimb/sprites.md for the full sprite spec + frame maps." "src/games/spireclimb/sprites.md for the full sprite spec + frame maps.",
"PLAYER SHEETS: one horizontal strip per class (warrior, rogue), 3 frames of",
" 300x300 each (total sheet size 900x300). Frame order: 0=idle, 1=attack, 2=hit.",
" Drop spireclimb-warrior.png and spireclimb-rogue.png into /assets/images/.",
" The game reads playerSheets below to load and display them."
], ],
"cardSheet": { "key": "spireclimb-cards", "path": "/assets/images/spireclimb-cards.png", "frameWidth": 250, "frameHeight": 160 }, "cardSheet": { "key": "spireclimb-cards", "path": "/assets/images/spireclimb-cards.png", "frameWidth": 250, "frameHeight": 160 },
"creatureSheet": { "key": "spireclimb-creatures", "path": "/assets/images/spireclimb-creatures.png", "frameWidth": 300, "frameHeight": 300 }, "creatureSheet": { "key": "spireclimb-creatures", "path": "/assets/images/spireclimb-creatures.png", "frameWidth": 300, "frameHeight": 300 },
"playerSheets": {
"warrior": { "key": "spireclimb-warrior", "path": "/assets/images/spireclimb-warrior.png", "frameWidth": 300, "frameHeight": 300 },
"rogue": { "key": "spireclimb-rogue", "path": "/assets/images/spireclimb-rogue.png", "frameWidth": 300, "frameHeight": 300 }
},
"cards": { "cards": {
"strike": 0, "defend": 1, "bash": 2, "neutralize": 3, "survivor": 4, "strike": 0, "defend": 1, "bash": 2, "neutralize": 3, "survivor": 4,

View File

@ -412,6 +412,8 @@ export default class SpireClimbGame extends Phaser.Scene {
renderCombat() { renderCombat() {
const cb = this.combat; const cb = this.combat;
this._enemySprites = []; this._enemySprites = [];
this._playerSprite = null;
this._playerLungeReturn = 0;
this._barGeom = {}; this._barGeom = {};
this._targetFx = []; this._targetFx = [];
this.renderCombatHud(); this.renderCombatHud();
@ -421,10 +423,10 @@ export default class SpireClimbGame extends Phaser.Scene {
const n = alive.length; const n = alive.length;
const spread = Math.min(520, 1100 / Math.max(1, n)); const spread = Math.min(520, 1100 / Math.max(1, n));
const startX = GAME_WIDTH / 2 + 120 - (n - 1) * spread / 2; const startX = GAME_WIDTH / 2 + 120 - (n - 1) * spread / 2;
alive.forEach((e, i) => this.renderEnemy(e, startX + i * spread, 635)); alive.forEach((e, i) => this.renderEnemy(e, startX + i * spread, 600));
// ── player ── // ── player ──
this.renderPlayer(300, 635); this.renderPlayer(300, 600);
// ── hand ── // ── hand ──
this.renderHand(); this.renderHand();
@ -497,12 +499,12 @@ export default class SpireClimbGame extends Phaser.Scene {
let sprite; let sprite;
if (art) { if (art) {
sprite = this.add.image(x, y, art.key, art.frame); sprite = this.add.image(x, y, art.key, art.frame);
const sc = 200 / Math.max(sprite.width, 1); const sc = 300 / Math.max(sprite.width, 1);
sprite.setScale(sc); sprite.setScale(sc);
} else { } else {
const g = this.add.graphics(); const g = this.add.graphics();
g.fillStyle(e.color || 0x8a5a5a, 1); g.fillRoundedRect(-80, -80, 160, 160, 24); g.fillStyle(e.color || 0x8a5a5a, 1); g.fillRoundedRect(-150, -150, 300, 300, 28);
g.lineStyle(3, 0x000000, 0.3); g.strokeRoundedRect(-80, -80, 160, 160, 24); g.lineStyle(3, 0x000000, 0.3); g.strokeRoundedRect(-150, -150, 300, 300, 28);
g.setPosition(x, y); // origin at (x,y) so shake/lunge tweens work g.setPosition(x, y); // origin at (x,y) so shake/lunge tweens work
sprite = g; sprite = g;
} }
@ -517,18 +519,18 @@ export default class SpireClimbGame extends Phaser.Scene {
}); });
// name // name
this.text(x, y - 130, e.name, 22, C.ink, { ox: 0.5, oy: 0.5 }); this.text(x, y - 165, e.name, 22, C.ink, { ox: 0.5, oy: 0.5 });
// intent // intent
this.renderIntent(e, x, y - 175); this.renderIntent(e, x, y - 210);
// hp bar // hp bar
this.renderBar(x - 90, y + 110, 180, 22, e.hp, e.maxHp, e.hp <= e.maxHp * 0.3 ? C.hpLow : C.hp, e.block, 'e' + e.slot); this.renderBar(x - 100, y + 160, 200, 22, e.hp, e.maxHp, e.hp <= e.maxHp * 0.3 ? C.hpLow : C.hp, e.block, 'e' + e.slot);
// statuses // statuses
this.renderStatuses(e, x - 90, y + 142); this.renderStatuses(e, x - 100, y + 192);
// targeting / hover // targeting / hover
const pendingTargetsEnemy = this.pendingPotion || const pendingTargetsEnemy = this.pendingPotion ||
(this.pendingCard && resolvedCard(this.pendingCard).target === 'enemy'); (this.pendingCard && resolvedCard(this.pendingCard).target === 'enemy');
const hit = this.add.rectangle(x, y, 200, 230, 0xffffff, 0.001).setInteractive({ useHandCursor: true }); const hit = this.add.rectangle(x, y, 300, 330, 0xffffff, 0.001).setInteractive({ useHandCursor: true });
this.add2(hit); this.add2(hit);
if (pendingTargetsEnemy) { if (pendingTargetsEnemy) {
this.addTargetShimmer(x, y); this.addTargetShimmer(x, y);
@ -541,13 +543,13 @@ export default class SpireClimbGame extends Phaser.Scene {
addTargetShimmer(x, y) { addTargetShimmer(x, y) {
const border = this.add.graphics(); const border = this.add.graphics();
border.lineStyle(3, C.goldI, 0.95); border.lineStyle(3, C.goldI, 0.95);
border.strokeRoundedRect(x - 102, y - 112, 204, 252, 16); border.strokeRoundedRect(x - 157, y - 162, 314, 337, 18);
this.add2(border); this.add2(border);
this.tweens.add({ targets: border, alpha: 0.3, duration: 620, yoyo: true, repeat: -1, ease: 'Sine.InOut' }); this.tweens.add({ targets: border, alpha: 0.3, duration: 620, yoyo: true, repeat: -1, ease: 'Sine.InOut' });
const em = this.add.particles(x, y, 'spire-sparkle', { const em = this.add.particles(x, y, 'spire-sparkle', {
x: { min: -96, max: 96 }, x: { min: -145, max: 145 },
y: { min: 70, max: 120 }, y: { min: 155, max: 195 },
speedX: { min: -22, max: 22 }, speedX: { min: -22, max: 22 },
speedY: { min: -130, max: -60 }, speedY: { min: -130, max: -60 },
alpha: { start: 0.95, end: 0 }, alpha: { start: 0.95, end: 0 },
@ -561,6 +563,10 @@ export default class SpireClimbGame extends Phaser.Scene {
this._targetFx.push(border, em); this._targetFx.push(border, em);
} }
setPlayerFrame(frame) {
if (this._playerSprite) this._playerSprite.setFrame(frame);
}
clearTargetFx() { clearTargetFx() {
(this._targetFx || []).forEach((o) => o.destroy()); (this._targetFx || []).forEach((o) => o.destroy());
this._targetFx = []; this._targetFx = [];
@ -591,17 +597,24 @@ export default class SpireClimbGame extends Phaser.Scene {
this._playerPos = { x, y }; this._playerPos = { x, y };
const p = this.combat.player; const p = this.combat.player;
const cls = CLASSES[this.run.className]; const cls = CLASSES[this.run.className];
const g = this.add.graphics(); const sheet = this.art.playerSheets?.[this.run.className];
g.fillStyle(cls.color, 0.9); g.fillCircle(x, y, 70); if (sheet?.key && this.textures.exists(sheet.key)) {
g.lineStyle(4, 0xffffff, 0.2); g.strokeCircle(x, y, 70); const sprite = this.add.image(x, y, sheet.key, 0);
this.add2(g); this.add2(sprite);
this.add2(this.add.text(x, y, cls.name[0], { fontFamily: 'Righteous', fontSize: '60px', color: '#ffffff' }).setOrigin(0.5)); this._playerSprite = sprite;
this.text(x, y - 100, cls.name, 24, C.ink, { ox: 0.5, oy: 0.5 }); } else {
this.renderBar(x - 100, y + 90, 200, 26, p.hp, p.maxHp, p.hp <= p.maxHp * 0.3 ? C.hpLow : C.hp, p.block, 'player'); const g = this.add.graphics();
this.renderStatuses(p, x - 100, y + 128); g.fillStyle(cls.color, 0.9); g.fillCircle(x, y, 110);
g.lineStyle(4, 0xffffff, 0.2); g.strokeCircle(x, y, 110);
this.add2(g);
this.add2(this.add.text(x, y, cls.name[0], { fontFamily: 'Righteous', fontSize: '90px', color: '#ffffff' }).setOrigin(0.5));
}
this.text(x, y - 175, cls.name, 24, C.ink, { ox: 0.5, oy: 0.5 });
this.renderBar(x - 115, y + 125, 230, 26, p.hp, p.maxHp, p.hp <= p.maxHp * 0.3 ? C.hpLow : C.hp, p.block, 'player');
this.renderStatuses(p, x - 115, y + 163);
if (this.pendingCard && resolvedCard(this.pendingCard).target === 'self') { if (this.pendingCard && resolvedCard(this.pendingCard).target === 'self') {
this.addTargetShimmer(x, y); this.addTargetShimmer(x, y);
const hit = this.add.circle(x, y, 90, 0xffffff, 0.001).setInteractive({ useHandCursor: true }); const hit = this.add.circle(x, y, 130, 0xffffff, 0.001).setInteractive({ useHandCursor: true });
this.add2(hit); this.add2(hit);
hit.on('pointerdown', () => this.onPlayerTargeted()); hit.on('pointerdown', () => this.onPlayerTargeted());
} }
@ -808,11 +821,29 @@ export default class SpireClimbGame extends Phaser.Scene {
cardShimmerThenStrike(inst, slot, c, card, cx, cy) { cardShimmerThenStrike(inst, slot, c, card, cx, cy) {
// Phase B — neon shimmer + hold (1.2s) // Phase B — neon shimmer + hold (1.2s)
const sh = this.shimmer(cx, cy, 168, 232, 1.85); const sh = this.shimmer(cx, cy, 168, 232, 1.85);
const isMultiHit = (c.effects || []).some((e) => (e.op === 'damage' || e.op === 'damageAll') && (e.times || 1) > 1);
const doWarriorLunge = this.run?.className === 'warrior' && c.target === 'enemy' && !isMultiHit && this._playerSprite;
if (doWarriorLunge) this.shakeSprite(this._playerSprite, null, 12, 280);
this.time.delayedCall(1200, () => { this.time.delayedCall(1200, () => {
sh.tw.stop(); sh.g.destroy(); sh.tw.stop(); sh.g.destroy();
// Phase C — split to every affected character & shrink (0.75s) // Phase C — split to every affected character & shrink (0.75s)
const dests = this.cardDestinations(c, slot); const dests = this.cardDestinations(c, slot);
this.sfx(c.type === 'attack' ? SFX.SWORD_HIT : SFX.CARD_PLACE); this.sfx(c.type === 'attack' ? SFX.SWORD_HIT : SFX.CARD_PLACE);
this.setPlayerFrame(1);
// Warrior lunge: rush toward the enemy alongside the card, return after impact.
if (doWarriorLunge && dests.length === 1 && this._playerSprite) {
const home = { x: this._playerSprite.x, y: this._playerSprite.y };
const tx = dests[0].x - 190, ty = dests[0].y;
this._playerLungeReturn = 300;
this.tweens.add({
targets: this._playerSprite, x: tx, y: ty, duration: 750, ease: 'Cubic.easeIn',
onComplete: () => {
this.tweens.add({
targets: this._playerSprite, x: home.x, y: home.y, duration: 280, ease: 'Cubic.easeOut',
});
},
});
}
const cards = dests.map((d, i) => { const cards = dests.map((d, i) => {
if (i === 0) return card; if (i === 0) return card;
const clone = this.makeCardSprite(cx, cy, inst, 1.85, { parent: this.fxLayer }); const clone = this.makeCardSprite(cx, cy, inst, 1.85, { parent: this.fxLayer });
@ -845,26 +876,33 @@ export default class SpireClimbGame extends Phaser.Scene {
this.flushStatusFx(statusBefore); // spawn buff/debuff label animations this.flushStatusFx(statusBefore); // spawn buff/debuff label animations
this.flushDamageFx(); // floating damage numbers pop on impact this.flushDamageFx(); // floating damage numbers pop on impact
cards.forEach((cd) => this.tweens.add({ targets: cd, alpha: 0, scaleX: 0.25, scaleY: 0.25, duration: 200, onComplete: () => cd.destroy() })); cards.forEach((cd) => this.tweens.add({ targets: cd, alpha: 0, scaleX: 0.25, scaleY: 0.25, duration: 200, onComplete: () => cd.destroy() }));
this.renderView(); // bars redraw at the NEW (post-hit) values
// Flash + drain the lost slice on every surviving character that took damage. const proceed = () => {
let anyDrain = false; this.renderView(); // bars redraw at the NEW (post-hit) values
this.combat.enemies.forEach((e) => { // Flash + drain the lost slice on every surviving character that took damage.
const old = beforeHp[e.slot]; let anyDrain = false;
if (e.alive && old != null && e.hp < old) { this.combat.enemies.forEach((e) => {
const geom = this._barGeom['e' + e.slot]; const old = beforeHp[e.slot];
if (geom) { this.animateHealthLoss(geom, e.hp / e.maxHp, old / e.maxHp); anyDrain = true; } if (e.alive && old != null && e.hp < old) {
const geom = this._barGeom['e' + e.slot];
if (geom) { this.animateHealthLoss(geom, e.hp / e.maxHp, old / e.maxHp); anyDrain = true; }
}
});
const p = this.combat.player;
if (p.hp < playerBeforeHp && this._barGeom.player) {
this.animateHealthLoss(this._barGeom.player, p.hp / p.maxHp, playerBeforeHp / p.maxHp);
anyDrain = true;
} }
}); // Hold input until the drain finishes (only when something actually drained).
const p = this.combat.player; if (anyDrain) this.time.delayedCall(900, () => { this.animating = false; });
if (p.hp < playerBeforeHp && this._barGeom.player) { else this.animating = false;
this.animateHealthLoss(this._barGeom.player, p.hp / p.maxHp, playerBeforeHp / p.maxHp); };
anyDrain = true;
}
// Hold input until the drain finishes (only when something actually drained). // For warrior lunge attacks, wait for the character to return home before redrawing.
if (anyDrain) this.time.delayedCall(900, () => { this.animating = false; }); const lungeDelay = this._playerLungeReturn;
else this.animating = false; this._playerLungeReturn = 0;
if (lungeDelay > 0) this.time.delayedCall(lungeDelay, proceed);
else proceed();
} }
// Animate each hit of a multi-hit card individually, with a quick HP drain between. // Animate each hit of a multi-hit card individually, with a quick HP drain between.
@ -943,7 +981,7 @@ export default class SpireClimbGame extends Phaser.Scene {
// World positions the card should fly to, one per affected character. // World positions the card should fly to, one per affected character.
cardDestinations(c, slot) { cardDestinations(c, slot) {
if (c.target === 'self') return [this._playerPos || { x: 300, y: 635 }]; if (c.target === 'self') return [this._playerPos || { x: 300, y: 600 }];
if (c.target === 'all') { if (c.target === 'all') {
const ds = this._enemySprites.map((e) => ({ x: e.x, y: e.y })); const ds = this._enemySprites.map((e) => ({ x: e.x, y: e.y }));
return ds.length ? ds : [{ x: GAME_WIDTH / 2, y: 330 }]; return ds.length ? ds : [{ x: GAME_WIDTH / 2, y: 330 }];
@ -1041,7 +1079,7 @@ export default class SpireClimbGame extends Phaser.Scene {
// Shake → lunge at the player → resolve (damage lands) + player HP drain → return. // Shake → lunge at the player → resolve (damage lands) + player HP drain → return.
animateEnemyAttack(e, sprite, home, done) { animateEnemyAttack(e, sprite, home, done) {
this.shakeSprite(sprite, () => { this.shakeSprite(sprite, () => {
const tx = this._playerPos.x + 160, ty = this._playerPos.y - 20; const tx = this._playerPos.x + 190, ty = this._playerPos.y - 20;
this.tweens.add({ this.tweens.add({
targets: sprite, x: tx, y: ty, duration: 180, ease: 'Cubic.easeIn', targets: sprite, x: tx, y: ty, duration: 180, ease: 'Cubic.easeIn',
onComplete: () => { onComplete: () => {
@ -1051,7 +1089,11 @@ export default class SpireClimbGame extends Phaser.Scene {
this.flushStatusFx(statusBefore); this.flushStatusFx(statusBefore);
this.flushDamageFx(); this.flushDamageFx();
this.sfx(SFX.SWORD_HIT); this.sfx(SFX.SWORD_HIT);
if (this.combat.player.hp < before) this.animatePlayerHpBar(before, this.combat.player.hp); if (this.combat.player.hp < before) {
this.animatePlayerHpBar(before, this.combat.player.hp);
this.setPlayerFrame(2);
this.time.delayedCall(350, () => this.setPlayerFrame(0));
}
this.tweens.add({ this.tweens.add({
targets: sprite, x: home.x, y: home.y, duration: 240, ease: 'Cubic.easeOut', delay: 140, targets: sprite, x: home.x, y: home.y, duration: 240, ease: 'Cubic.easeOut', delay: 140,
onComplete: () => done(), onComplete: () => done(),

View File

@ -218,7 +218,7 @@ export default class PreloadScene extends Phaser.Scene {
// once the artist sets a `path` in /data/spireclimb-artwork.json; until then // once the artist sets a `path` in /data/spireclimb-artwork.json; until then
// the game renders cards/creatures procedurally. // the game renders cards/creatures procedurally.
const scArt = this.cache.json.get('spireclimb-artwork'); const scArt = this.cache.json.get('spireclimb-artwork');
const scSheets = [scArt?.cardSheet, scArt?.creatureSheet] const scSheets = [scArt?.cardSheet, scArt?.creatureSheet, ...Object.values(scArt?.playerSheets || {})]
.filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); .filter((s) => s && s.path && s.key && !this.textures.exists(s.key));
if (toLoad.length > 0 || scSheets.length > 0) { if (toLoad.length > 0 || scSheets.length > 0) {