feat: add card tooltip with hover interaction
Implements a `CardTooltip` component that displays detailed card information (name, type, stats, skills with triggers/values, and flavor text) when hovering over a card in the BattleScene. Changes include: - Created `CardTooltip.js` to render dynamic tooltips based on card data and skill definitions. - Updated `CardObject.js` to handle pointer events (`pointerover`, `pointermove`, `pointerout`) for showing/hiding the tooltip with a 500ms delay. - Refactored interaction logic in `BattleScene.js` to use the `CardObject`'s native interactive area instead of a separate invisible rectangle, consolidating hover and click handlers.
This commit is contained in:
parent
a17adba1e9
commit
315b3ddccd
|
|
@ -15,12 +15,15 @@ const FACTION_COLORS = {
|
|||
|
||||
const BANNER_COLOR = 0x0c0c1e;
|
||||
|
||||
import { CardTooltip } from './CardTooltip.js';
|
||||
|
||||
export class CardObject extends Phaser.GameObjects.Container {
|
||||
constructor(scene, x, y, cardData, options = {}) {
|
||||
super(scene, x, y);
|
||||
this.cardData = cardData;
|
||||
this.options = options;
|
||||
this._build();
|
||||
this._setupTooltip();
|
||||
scene.add.existing(this);
|
||||
}
|
||||
|
||||
|
|
@ -321,7 +324,40 @@ export class CardObject extends Phaser.GameObjects.Container {
|
|||
});
|
||||
}
|
||||
|
||||
_setupTooltip() {
|
||||
const w = this.options.width || 80;
|
||||
const h = this.options.height || 110;
|
||||
this.setInteractive(new Phaser.Geom.Rectangle(-w / 2, -h / 2, w, h), Phaser.Geom.Rectangle.Contains);
|
||||
|
||||
this._hoverTimer = null;
|
||||
this._tooltip = null;
|
||||
|
||||
this.on('pointerover', () => {
|
||||
if (this._hoverTimer) this._hoverTimer.remove(false);
|
||||
this._hoverTimer = this.scene.time.delayedCall(500, () => {
|
||||
if (!this.scene) return;
|
||||
this._tooltip = new CardTooltip(this.scene, this.cardData);
|
||||
const pointer = this.scene.input.activePointer;
|
||||
this._tooltip.positionAt(pointer.x, pointer.y);
|
||||
this._tooltip.show();
|
||||
});
|
||||
});
|
||||
|
||||
this.on('pointermove', (pointer) => {
|
||||
if (this._tooltip) {
|
||||
this._tooltip.positionAt(pointer.x, pointer.y);
|
||||
}
|
||||
});
|
||||
|
||||
this.on('pointerout', () => {
|
||||
if (this._hoverTimer) { this._hoverTimer.remove(false); this._hoverTimer = null; }
|
||||
if (this._tooltip) { this._tooltip.destroy(); this._tooltip = null; }
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._hoverTimer) { this._hoverTimer.remove(false); this._hoverTimer = null; }
|
||||
if (this._tooltip) { this._tooltip.destroy(); this._tooltip = null; }
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
const CATEGORY_COLORS = {
|
||||
offense: '#ff8877',
|
||||
support: '#88ff88',
|
||||
control: '#88ccff',
|
||||
defense: '#aaaaff',
|
||||
special: '#ffcc44'
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS = {
|
||||
preAttack: 'Pre-Attack',
|
||||
on_attack: 'On Attack',
|
||||
on_defend: 'On Defend',
|
||||
preBattle: 'Pre-Battle',
|
||||
passive: 'Passive',
|
||||
on_turn_start: 'Turn Start',
|
||||
on_kill: 'On Kill'
|
||||
};
|
||||
|
||||
export class CardTooltip extends Phaser.GameObjects.Container {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {object} cardData - full card data (same object CardObject receives)
|
||||
*/
|
||||
constructor(scene, cardData) {
|
||||
super(scene, 0, 0);
|
||||
this.cardData = cardData;
|
||||
this.setDepth(100);
|
||||
this.setAlpha(0);
|
||||
this._build();
|
||||
scene.add.existing(this);
|
||||
}
|
||||
|
||||
_build() {
|
||||
const W = 420;
|
||||
const PAD = 22;
|
||||
const skillDefs = this.scene.cache.json.get('skills') || [];
|
||||
const skillMap = {};
|
||||
for (const sd of skillDefs) skillMap[sd.name] = sd;
|
||||
|
||||
// ── Collect text lines and their styles to measure total height ──────────
|
||||
const lines = []; // { text, style, marginTop }
|
||||
|
||||
// Card name
|
||||
lines.push({
|
||||
text: this.cardData.name,
|
||||
style: { fontSize: '28px', color: '#ffffff', fontStyle: 'bold', wordWrap: { width: W - PAD * 2 }, align: 'center' },
|
||||
marginTop: 0, align: 'center'
|
||||
});
|
||||
|
||||
// Type / Faction / Rarity
|
||||
const meta = `${this._cap(this.cardData.type)} · ${this._cap(this.cardData.faction)} · ${this._cap(this.cardData.rarity)}`;
|
||||
lines.push({
|
||||
text: meta,
|
||||
style: { fontSize: '16px', color: '#999999', wordWrap: { width: W - PAD * 2 }, align: 'center' },
|
||||
marginTop: 6, align: 'center'
|
||||
});
|
||||
|
||||
// Divider
|
||||
lines.push({ divider: true, marginTop: 14 });
|
||||
|
||||
// Stats line
|
||||
const stats = `ATK ${this.cardData.attack} HP ${this.cardData.health} ARM ${this.cardData.armor} DLY ${this.cardData.delay}`;
|
||||
lines.push({
|
||||
text: stats,
|
||||
style: { fontSize: '20px', color: '#cccccc', fontStyle: 'bold', wordWrap: { width: W - PAD * 2 }, align: 'center' },
|
||||
marginTop: 12, align: 'center'
|
||||
});
|
||||
|
||||
// Divider before skills
|
||||
if (this.cardData.skills?.length) {
|
||||
lines.push({ divider: true, marginTop: 14 });
|
||||
|
||||
for (const skill of this.cardData.skills) {
|
||||
const def = skillMap[skill.name];
|
||||
const catColor = CATEGORY_COLORS[def?.category] || '#ffcc44';
|
||||
const triggerLabel = TRIGGER_LABELS[skill.trigger] || skill.trigger;
|
||||
|
||||
// Skill header: "strike 3 (Pre-Attack)"
|
||||
const valStr = skill.value != null ? ` ${skill.value}` : '';
|
||||
lines.push({
|
||||
text: `${skill.name}${valStr}`,
|
||||
style: { fontSize: '21px', color: catColor, fontStyle: 'bold', wordWrap: { width: W - PAD * 2 } },
|
||||
marginTop: 14, align: 'left',
|
||||
suffix: { text: ` ${triggerLabel}`, style: { fontSize: '15px', color: '#888888' } }
|
||||
});
|
||||
|
||||
// Skill description
|
||||
if (def?.description) {
|
||||
// Replace "value" with the actual number for clarity
|
||||
const desc = skill.value != null
|
||||
? def.description.replace(/\bvalue\b/g, `${skill.value}`)
|
||||
: def.description;
|
||||
lines.push({
|
||||
text: desc,
|
||||
style: { fontSize: '17px', color: '#bbbbbb', wordWrap: { width: W - PAD * 2 } },
|
||||
marginTop: 4, align: 'left'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flavor text
|
||||
if (this.cardData.flavorText) {
|
||||
lines.push({ divider: true, marginTop: 14 });
|
||||
lines.push({
|
||||
text: `"${this.cardData.flavorText}"`,
|
||||
style: { fontSize: '16px', color: '#777777', fontStyle: 'italic', wordWrap: { width: W - PAD * 2 }, align: 'center' },
|
||||
marginTop: 10, align: 'center'
|
||||
});
|
||||
}
|
||||
|
||||
// ── Measure total height by creating temporary text objects ──────────────
|
||||
let cursorY = PAD;
|
||||
const elements = []; // { type, y, ... }
|
||||
|
||||
for (const line of lines) {
|
||||
cursorY += line.marginTop || 0;
|
||||
if (line.divider) {
|
||||
elements.push({ type: 'divider', y: cursorY });
|
||||
cursorY += 1;
|
||||
continue;
|
||||
}
|
||||
const tmp = this.scene.add.text(0, 0, line.text, line.style);
|
||||
const h = tmp.height;
|
||||
elements.push({ type: 'text', y: cursorY, height: h, line });
|
||||
if (line.suffix) {
|
||||
// suffix is rendered inline; doesn't add extra height
|
||||
}
|
||||
cursorY += h;
|
||||
tmp.destroy();
|
||||
}
|
||||
cursorY += PAD;
|
||||
const H = cursorY;
|
||||
|
||||
// ── Background panel ─────────────────────────────────────────────────────
|
||||
const bg = this.scene.add.rectangle(W / 2, H / 2, W, H, 0x0c0c1e, 0.95)
|
||||
.setStrokeStyle(2, 0x444466);
|
||||
this.add(bg);
|
||||
|
||||
// ── Render text elements ─────────────────────────────────────────────────
|
||||
for (const el of elements) {
|
||||
if (el.type === 'divider') {
|
||||
const div = this.scene.add.rectangle(W / 2, el.y, W - PAD * 2, 1, 0x333355);
|
||||
this.add(div);
|
||||
continue;
|
||||
}
|
||||
const { line, y } = el;
|
||||
const x = line.align === 'center' ? W / 2 : PAD;
|
||||
const origin = line.align === 'center' ? 0.5 : 0;
|
||||
const txt = this.scene.add.text(x, y, line.text, line.style).setOrigin(origin, 0);
|
||||
this.add(txt);
|
||||
|
||||
if (line.suffix) {
|
||||
const sfx = this.scene.add.text(
|
||||
txt.x + txt.width + 4, y + 5,
|
||||
line.suffix.text, line.suffix.style
|
||||
).setOrigin(0, 0);
|
||||
this.add(sfx);
|
||||
}
|
||||
}
|
||||
|
||||
this._tooltipW = W;
|
||||
this._tooltipH = H;
|
||||
}
|
||||
|
||||
_cap(str) {
|
||||
if (!str) return '';
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
/** Position the tooltip near the pointer, keeping it within the game bounds. */
|
||||
positionAt(pointerX, pointerY) {
|
||||
const { width, height } = this.scene.scale;
|
||||
const OFFSET_X = 24;
|
||||
const OFFSET_Y = 0;
|
||||
|
||||
let tx = pointerX + OFFSET_X;
|
||||
let ty = pointerY + OFFSET_Y;
|
||||
|
||||
// Keep within right/left bounds
|
||||
if (tx + this._tooltipW > width - 8) {
|
||||
tx = pointerX - this._tooltipW - OFFSET_X;
|
||||
}
|
||||
if (tx < 8) tx = 8;
|
||||
|
||||
// Keep within bottom/top bounds
|
||||
if (ty + this._tooltipH > height - 8) {
|
||||
ty = height - 8 - this._tooltipH;
|
||||
}
|
||||
if (ty < 8) ty = 8;
|
||||
|
||||
this.setPosition(tx, ty);
|
||||
}
|
||||
|
||||
show() {
|
||||
this.setAlpha(1);
|
||||
this.setVisible(true);
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.setAlpha(0);
|
||||
this.setVisible(false);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -1041,25 +1041,25 @@ export class BattleScene extends Phaser.Scene {
|
|||
cardObj.setDepth(11);
|
||||
this.pickerObjects.push(cardObj);
|
||||
|
||||
// Invisible interactive hit rect on top
|
||||
const hitRect = this.add.rectangle(x, cardY, cardW, cardH, 0xffffff, 0)
|
||||
.setInteractive({ useHandCursor: true })
|
||||
.setDepth(12);
|
||||
// Use the CardObject's own interactive area for hover + click
|
||||
cardObj.setInteractive(
|
||||
new Phaser.Geom.Rectangle(-cardW / 2, -cardH / 2, cardW, cardH),
|
||||
Phaser.Geom.Rectangle.Contains
|
||||
);
|
||||
cardObj.input.cursor = 'pointer';
|
||||
|
||||
hitRect.on('pointerover', () => {
|
||||
cardObj.on('pointerover', () => {
|
||||
cardObj.setScale(1.05);
|
||||
cardObj.setDepth(13);
|
||||
});
|
||||
hitRect.on('pointerout', () => {
|
||||
cardObj.on('pointerout', () => {
|
||||
cardObj.setScale(1);
|
||||
cardObj.setDepth(11);
|
||||
});
|
||||
hitRect.on('pointerdown', () => {
|
||||
cardObj.on('pointerdown', () => {
|
||||
this._destroyCardPicker();
|
||||
this._finishTurn(card);
|
||||
});
|
||||
|
||||
this.pickerObjects.push(hitRect);
|
||||
});
|
||||
|
||||
// Pass button
|
||||
|
|
|
|||
Loading…
Reference in New Issue