fertig-classic-games/src/ui/Tooltip.js

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);
}
}