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;
|
const BANNER_COLOR = 0x0c0c1e;
|
||||||
|
|
||||||
|
import { CardTooltip } from './CardTooltip.js';
|
||||||
|
|
||||||
export class CardObject extends Phaser.GameObjects.Container {
|
export class CardObject extends Phaser.GameObjects.Container {
|
||||||
constructor(scene, x, y, cardData, options = {}) {
|
constructor(scene, x, y, cardData, options = {}) {
|
||||||
super(scene, x, y);
|
super(scene, x, y);
|
||||||
this.cardData = cardData;
|
this.cardData = cardData;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this._build();
|
this._build();
|
||||||
|
this._setupTooltip();
|
||||||
scene.add.existing(this);
|
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() {
|
destroy() {
|
||||||
|
if (this._hoverTimer) { this._hoverTimer.remove(false); this._hoverTimer = null; }
|
||||||
|
if (this._tooltip) { this._tooltip.destroy(); this._tooltip = null; }
|
||||||
super.destroy();
|
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);
|
cardObj.setDepth(11);
|
||||||
this.pickerObjects.push(cardObj);
|
this.pickerObjects.push(cardObj);
|
||||||
|
|
||||||
// Invisible interactive hit rect on top
|
// Use the CardObject's own interactive area for hover + click
|
||||||
const hitRect = this.add.rectangle(x, cardY, cardW, cardH, 0xffffff, 0)
|
cardObj.setInteractive(
|
||||||
.setInteractive({ useHandCursor: true })
|
new Phaser.Geom.Rectangle(-cardW / 2, -cardH / 2, cardW, cardH),
|
||||||
.setDepth(12);
|
Phaser.Geom.Rectangle.Contains
|
||||||
|
);
|
||||||
|
cardObj.input.cursor = 'pointer';
|
||||||
|
|
||||||
hitRect.on('pointerover', () => {
|
cardObj.on('pointerover', () => {
|
||||||
cardObj.setScale(1.05);
|
cardObj.setScale(1.05);
|
||||||
cardObj.setDepth(13);
|
cardObj.setDepth(13);
|
||||||
});
|
});
|
||||||
hitRect.on('pointerout', () => {
|
cardObj.on('pointerout', () => {
|
||||||
cardObj.setScale(1);
|
cardObj.setScale(1);
|
||||||
cardObj.setDepth(11);
|
cardObj.setDepth(11);
|
||||||
});
|
});
|
||||||
hitRect.on('pointerdown', () => {
|
cardObj.on('pointerdown', () => {
|
||||||
this._destroyCardPicker();
|
this._destroyCardPicker();
|
||||||
this._finishTurn(card);
|
this._finishTurn(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.pickerObjects.push(hitRect);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pass button
|
// Pass button
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue