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:
parent
0afcc167dd
commit
a731fd813b
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.
|
|
@ -12,10 +12,18 @@
|
|||
" 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",
|
||||
"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 },
|
||||
"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": {
|
||||
"strike": 0, "defend": 1, "bash": 2, "neutralize": 3, "survivor": 4,
|
||||
|
|
|
|||
|
|
@ -412,6 +412,8 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
renderCombat() {
|
||||
const cb = this.combat;
|
||||
this._enemySprites = [];
|
||||
this._playerSprite = null;
|
||||
this._playerLungeReturn = 0;
|
||||
this._barGeom = {};
|
||||
this._targetFx = [];
|
||||
this.renderCombatHud();
|
||||
|
|
@ -421,10 +423,10 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
const n = alive.length;
|
||||
const spread = Math.min(520, 1100 / Math.max(1, n));
|
||||
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 ──
|
||||
this.renderPlayer(300, 635);
|
||||
this.renderPlayer(300, 600);
|
||||
|
||||
// ── hand ──
|
||||
this.renderHand();
|
||||
|
|
@ -497,12 +499,12 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
let sprite;
|
||||
if (art) {
|
||||
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);
|
||||
} else {
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(e.color || 0x8a5a5a, 1); g.fillRoundedRect(-80, -80, 160, 160, 24);
|
||||
g.lineStyle(3, 0x000000, 0.3); g.strokeRoundedRect(-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(-150, -150, 300, 300, 28);
|
||||
g.setPosition(x, y); // origin at (x,y) so shake/lunge tweens work
|
||||
sprite = g;
|
||||
}
|
||||
|
|
@ -517,18 +519,18 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
});
|
||||
|
||||
// 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
|
||||
this.renderIntent(e, x, y - 175);
|
||||
this.renderIntent(e, x, y - 210);
|
||||
// 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
|
||||
this.renderStatuses(e, x - 90, y + 142);
|
||||
this.renderStatuses(e, x - 100, y + 192);
|
||||
|
||||
// targeting / hover
|
||||
const pendingTargetsEnemy = this.pendingPotion ||
|
||||
(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);
|
||||
if (pendingTargetsEnemy) {
|
||||
this.addTargetShimmer(x, y);
|
||||
|
|
@ -541,13 +543,13 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
addTargetShimmer(x, y) {
|
||||
const border = this.add.graphics();
|
||||
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.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', {
|
||||
x: { min: -96, max: 96 },
|
||||
y: { min: 70, max: 120 },
|
||||
x: { min: -145, max: 145 },
|
||||
y: { min: 155, max: 195 },
|
||||
speedX: { min: -22, max: 22 },
|
||||
speedY: { min: -130, max: -60 },
|
||||
alpha: { start: 0.95, end: 0 },
|
||||
|
|
@ -561,6 +563,10 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
this._targetFx.push(border, em);
|
||||
}
|
||||
|
||||
setPlayerFrame(frame) {
|
||||
if (this._playerSprite) this._playerSprite.setFrame(frame);
|
||||
}
|
||||
|
||||
clearTargetFx() {
|
||||
(this._targetFx || []).forEach((o) => o.destroy());
|
||||
this._targetFx = [];
|
||||
|
|
@ -591,17 +597,24 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
this._playerPos = { x, y };
|
||||
const p = this.combat.player;
|
||||
const cls = CLASSES[this.run.className];
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(cls.color, 0.9); g.fillCircle(x, y, 70);
|
||||
g.lineStyle(4, 0xffffff, 0.2); g.strokeCircle(x, y, 70);
|
||||
this.add2(g);
|
||||
this.add2(this.add.text(x, y, cls.name[0], { fontFamily: 'Righteous', fontSize: '60px', color: '#ffffff' }).setOrigin(0.5));
|
||||
this.text(x, y - 100, cls.name, 24, C.ink, { ox: 0.5, oy: 0.5 });
|
||||
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');
|
||||
this.renderStatuses(p, x - 100, y + 128);
|
||||
const sheet = this.art.playerSheets?.[this.run.className];
|
||||
if (sheet?.key && this.textures.exists(sheet.key)) {
|
||||
const sprite = this.add.image(x, y, sheet.key, 0);
|
||||
this.add2(sprite);
|
||||
this._playerSprite = sprite;
|
||||
} else {
|
||||
const g = this.add.graphics();
|
||||
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') {
|
||||
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);
|
||||
hit.on('pointerdown', () => this.onPlayerTargeted());
|
||||
}
|
||||
|
|
@ -808,11 +821,29 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
cardShimmerThenStrike(inst, slot, c, card, cx, cy) {
|
||||
// Phase B — neon shimmer + hold (1.2s)
|
||||
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, () => {
|
||||
sh.tw.stop(); sh.g.destroy();
|
||||
// Phase C — split to every affected character & shrink (0.75s)
|
||||
const dests = this.cardDestinations(c, slot);
|
||||
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) => {
|
||||
if (i === 0) return card;
|
||||
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.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() }));
|
||||
this.renderView(); // bars redraw at the NEW (post-hit) values
|
||||
|
||||
// Flash + drain the lost slice on every surviving character that took damage.
|
||||
let anyDrain = false;
|
||||
this.combat.enemies.forEach((e) => {
|
||||
const old = beforeHp[e.slot];
|
||||
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 proceed = () => {
|
||||
this.renderView(); // bars redraw at the NEW (post-hit) values
|
||||
// Flash + drain the lost slice on every surviving character that took damage.
|
||||
let anyDrain = false;
|
||||
this.combat.enemies.forEach((e) => {
|
||||
const old = beforeHp[e.slot];
|
||||
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;
|
||||
}
|
||||
});
|
||||
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).
|
||||
if (anyDrain) this.time.delayedCall(900, () => { this.animating = false; });
|
||||
else this.animating = false;
|
||||
};
|
||||
|
||||
// Hold input until the drain finishes (only when something actually drained).
|
||||
if (anyDrain) this.time.delayedCall(900, () => { this.animating = false; });
|
||||
else this.animating = false;
|
||||
// For warrior lunge attacks, wait for the character to return home before redrawing.
|
||||
const lungeDelay = this._playerLungeReturn;
|
||||
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.
|
||||
|
|
@ -943,7 +981,7 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
|
||||
// World positions the card should fly to, one per affected character.
|
||||
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') {
|
||||
const ds = this._enemySprites.map((e) => ({ x: e.x, y: e.y }));
|
||||
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.
|
||||
animateEnemyAttack(e, sprite, home, done) {
|
||||
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({
|
||||
targets: sprite, x: tx, y: ty, duration: 180, ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
|
|
@ -1051,7 +1089,11 @@ export default class SpireClimbGame extends Phaser.Scene {
|
|||
this.flushStatusFx(statusBefore);
|
||||
this.flushDamageFx();
|
||||
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({
|
||||
targets: sprite, x: home.x, y: home.y, duration: 240, ease: 'Cubic.easeOut', delay: 140,
|
||||
onComplete: () => done(),
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
// once the artist sets a `path` in /data/spireclimb-artwork.json; until then
|
||||
// the game renders cards/creatures procedurally.
|
||||
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));
|
||||
|
||||
if (toLoad.length > 0 || scSheets.length > 0) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue