175 lines
6.4 KiB
JavaScript
175 lines
6.4 KiB
JavaScript
import * as Phaser from 'phaser';
|
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../config.js';
|
|
|
|
const RADIUS = 10;
|
|
const PAD_X = 18;
|
|
const PAD_Y = 14;
|
|
const GAP = 8;
|
|
const WIDTH = 340;
|
|
const OFF = 22;
|
|
const MARGIN = 10;
|
|
const ICON_SIZE = 44;
|
|
const ICON_GAP = 12;
|
|
|
|
export const TOOLTIP_DEPTH = 70;
|
|
|
|
// Reusable floating tooltip that follows the live mouse position while any
|
|
// attached game object is hovered. One instance is meant to be shared by an
|
|
// entire screen/popup (attach many rows to it) rather than created per row —
|
|
// see src/games/civilization/CivilizationCityScreen.js for the reference
|
|
// usage. If attaching to a Container (e.g. a Button), give it an explicit
|
|
// `hitArea`/`hitAreaCallback` first per src/ui/Button.js's pattern —
|
|
// this component does not call setInteractive() for you.
|
|
export class Tooltip {
|
|
constructor(scene, options = {}) {
|
|
const { depth = TOOLTIP_DEPTH, hoverDelay = 0 } = options;
|
|
this.scene = scene;
|
|
this.hoverDelay = hoverDelay;
|
|
this._owner = null;
|
|
this._timer = null;
|
|
|
|
this.bg = scene.add.graphics();
|
|
this.title = scene.add.text(0, 0, '', {
|
|
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex,
|
|
});
|
|
this.lineTexts = [];
|
|
this.iconObjs = [];
|
|
this._iconMask = null;
|
|
this.container = scene.add.container(-9999, -9999, [this.bg, this.title])
|
|
.setDepth(depth)
|
|
.setVisible(false);
|
|
|
|
this._onMove = (ptr) => {
|
|
if (this.container.visible) this._reposition(ptr.x, ptr.y);
|
|
};
|
|
scene.input.on('pointermove', this._onMove);
|
|
|
|
this._shutdownHandler = () => this.destroy();
|
|
scene.events.once('shutdown', this._shutdownHandler);
|
|
}
|
|
|
|
setContent({ title, titleColor, lines = [], icon = null }) {
|
|
this.title.setText(title ?? '');
|
|
this.title.setColor(titleColor ?? COLORS.goldHex);
|
|
|
|
this.iconObjs.forEach((o) => o.destroy());
|
|
this._iconMask?.graphics.destroy();
|
|
this._iconMask = null;
|
|
this.iconObjs = icon ? this._buildIcon(icon) : [];
|
|
this.container.add(this.iconObjs);
|
|
const textX = icon ? PAD_X + ICON_SIZE + ICON_GAP : PAD_X;
|
|
|
|
this.lineTexts.forEach((t) => t.destroy());
|
|
this.lineTexts = lines.map((l) => this.scene.add.text(0, 0, l.text, {
|
|
fontFamily: '"Julius Sans One"', fontSize: '16px', color: l.color ?? COLORS.textHex,
|
|
wordWrap: { width: WIDTH - textX - PAD_X }, lineSpacing: 4,
|
|
}));
|
|
this.container.add(this.lineTexts);
|
|
|
|
let y = PAD_Y + (title ? this.title.height + GAP : 0);
|
|
this.title.setPosition(textX, PAD_Y);
|
|
this.lineTexts.forEach((t) => {
|
|
t.setPosition(textX, y);
|
|
y += t.height;
|
|
});
|
|
const totalH = Math.max(y + PAD_Y, icon ? PAD_Y * 2 + ICON_SIZE : 0);
|
|
|
|
this.bg.clear();
|
|
this.bg.fillStyle(COLORS.panel, 0.96);
|
|
this.bg.fillRoundedRect(0, 0, WIDTH, totalH, RADIUS);
|
|
this.bg.lineStyle(2, COLORS.accent, 1);
|
|
this.bg.strokeRoundedRect(0, 0, WIDTH, totalH, RADIUS);
|
|
this._w = WIDTH;
|
|
this._h = totalH;
|
|
}
|
|
|
|
// Small circular portrait (sprite frame, or a colored fallback disc with
|
|
// an initial letter if the texture isn't loaded) ringed in `icon.color` —
|
|
// used to show which leader/civ a hovered thing belongs to.
|
|
_buildIcon({ texture, frame, color, label }) {
|
|
const colorInt = typeof color === 'string' ? Phaser.Display.Color.HexStringToColor(color).color : color;
|
|
const cx = PAD_X + ICON_SIZE / 2;
|
|
const cy = PAD_Y + ICON_SIZE / 2;
|
|
const ring = this.scene.add.graphics();
|
|
ring.lineStyle(3, colorInt, 1);
|
|
ring.strokeCircle(cx, cy, ICON_SIZE / 2 + 3);
|
|
if (texture && this.scene.textures.exists(texture)) {
|
|
const img = this.scene.add.image(cx, cy, texture, frame).setDisplaySize(ICON_SIZE, ICON_SIZE);
|
|
// Crop the (likely square) sprite frame to a circle so it sits inside
|
|
// the ring instead of overlapping it. A GeometryMask reads the mask
|
|
// graphics' OWN transform each render, not the masked image's — since
|
|
// this tooltip's container moves with the mouse, the mask graphics is
|
|
// kept OUT of the container (scene.make.graphics(..., add:false), per
|
|
// src/ui/Portrait.js's convention) and its position is instead synced
|
|
// to the icon's on-screen position every _reposition() call below.
|
|
const maskG = this.scene.make.graphics({ x: 0, y: 0, add: false });
|
|
maskG.fillStyle(0xffffff);
|
|
maskG.fillCircle(0, 0, ICON_SIZE / 2);
|
|
img.setMask(maskG.createGeometryMask());
|
|
this._iconMask = { graphics: maskG, cx, cy };
|
|
return [ring, img];
|
|
}
|
|
const disc = this.scene.add.circle(cx, cy, ICON_SIZE / 2, colorInt, 0.3);
|
|
const letter = this.scene.add.text(cx, cy, (label ?? '?').charAt(0).toUpperCase(), {
|
|
fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#ffffff', fontStyle: 'bold',
|
|
}).setOrigin(0.5);
|
|
return [ring, disc, letter];
|
|
}
|
|
|
|
_reposition(px, py) {
|
|
let tx = px + OFF;
|
|
let ty = py + OFF;
|
|
if (tx + this._w > GAME_WIDTH - MARGIN) tx = px - this._w - OFF;
|
|
if (ty + this._h > GAME_HEIGHT - MARGIN) ty = py - this._h - OFF;
|
|
if (tx < MARGIN) tx = MARGIN;
|
|
if (ty < MARGIN) ty = MARGIN;
|
|
this.container.setPosition(tx, ty);
|
|
if (this._iconMask) {
|
|
this._iconMask.graphics.setPosition(tx + this._iconMask.cx, ty + this._iconMask.cy);
|
|
}
|
|
}
|
|
|
|
hide() {
|
|
this._clearTimer();
|
|
this._owner = null;
|
|
this.container.setVisible(false);
|
|
}
|
|
|
|
_clearTimer() {
|
|
if (this._timer) {
|
|
this._timer.remove();
|
|
this._timer = null;
|
|
}
|
|
}
|
|
|
|
// Wires pointerover/pointerout on gameObject so hovering it shows this
|
|
// shared tooltip with content from contentFn() (called lazily, on hover).
|
|
attachTo(gameObject, contentFn, opts = {}) {
|
|
const delay = opts.delay ?? this.hoverDelay;
|
|
const show = () => {
|
|
this.setContent(contentFn());
|
|
this._owner = gameObject;
|
|
const ptr = this.scene.input.activePointer;
|
|
this._reposition(ptr.x, ptr.y);
|
|
this.container.setVisible(true);
|
|
};
|
|
gameObject.on('pointerover', () => {
|
|
this._clearTimer();
|
|
if (delay > 0) this._timer = this.scene.time.delayedCall(delay, show);
|
|
else show();
|
|
});
|
|
gameObject.on('pointerout', () => {
|
|
this._clearTimer();
|
|
if (this._owner === gameObject) this.hide();
|
|
});
|
|
}
|
|
|
|
destroy() {
|
|
this._clearTimer();
|
|
this.scene.input.off('pointermove', this._onMove);
|
|
this.scene.events.off('shutdown', this._shutdownHandler);
|
|
this._iconMask?.graphics.destroy();
|
|
this.container.destroy(true);
|
|
}
|
|
}
|