diff --git a/public/assets/images/spireclimb-rogue.png b/public/assets/images/spireclimb-rogue.png new file mode 100644 index 0000000..3abe842 Binary files /dev/null and b/public/assets/images/spireclimb-rogue.png differ diff --git a/public/assets/images/spireclimb-rogue.psd b/public/assets/images/spireclimb-rogue.psd new file mode 100644 index 0000000..3be4490 Binary files /dev/null and b/public/assets/images/spireclimb-rogue.psd differ diff --git a/public/assets/images/spireclimb-warrior.png b/public/assets/images/spireclimb-warrior.png new file mode 100644 index 0000000..e3db5bb Binary files /dev/null and b/public/assets/images/spireclimb-warrior.png differ diff --git a/public/assets/images/spireclimb-warrior.psd b/public/assets/images/spireclimb-warrior.psd new file mode 100644 index 0000000..76659d2 Binary files /dev/null and b/public/assets/images/spireclimb-warrior.psd differ diff --git a/public/data/spireclimb-artwork.json b/public/data/spireclimb-artwork.json index b90432a..99e9a31 100644 --- a/public/data/spireclimb-artwork.json +++ b/public/data/spireclimb-artwork.json @@ -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, diff --git a/public/src/games/spireclimb/SpireClimbGame.js b/public/src/games/spireclimb/SpireClimbGame.js index 9f330bd..f03dcff 100644 --- a/public/src/games/spireclimb/SpireClimbGame.js +++ b/public/src/games/spireclimb/SpireClimbGame.js @@ -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(), diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 23dd10e..e832d34 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -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) {