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