From cabb129fdf0cef6c3b426b3bfc54c70bae584ebf Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 13:07:40 +0000 Subject: [PATCH] feat: add floating emoji overlay for NPC social interactions Co-Authored-By: Claude Opus 4.6 --- client/src/scenes/GameScene.ts | 16 +++++ client/src/ui/InteractionEmoji.ts | 97 +++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 client/src/ui/InteractionEmoji.ts diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index b29176b..6af1ad8 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -3,6 +3,7 @@ import type { SocketClient } from '../network/SocketClient.js'; import { CharacterCompositor } from '../sprites/CharacterCompositor.js'; import { PortraitCompositor } from '../sprites/PortraitCompositor.js'; import { NpcInfoPanel } from '../ui/NpcInfoPanel.js'; +import { InteractionEmojiManager } from '../ui/InteractionEmoji.js'; import type { WorldState, StateUpdate, EntityState, Appearance } from '@dflike/shared'; import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction } from '@dflike/shared'; @@ -25,6 +26,7 @@ export class GameScene extends Phaser.Scene { private followThrottle = 0; private portraitCompositor!: PortraitCompositor; private npcInfoPanel!: NpcInfoPanel; + private emojiManager!: InteractionEmojiManager; constructor() { super({ key: 'GameScene' }); @@ -39,6 +41,7 @@ export class GameScene extends Phaser.Scene { this.compositor = new CharacterCompositor(this); this.portraitCompositor = new PortraitCompositor(); this.npcInfoPanel = new NpcInfoPanel(); + this.emojiManager = new InteractionEmojiManager(); // Draw tile grid this.drawWorld(); @@ -236,6 +239,19 @@ export class GameScene extends Phaser.Scene { } } + // Update interaction emojis + const entityPositions = new Map(); + const cam = this.cameras.main; + for (const entity of update.entities) { + const es = this.entitySprites.get(entity.id); + if (es) { + const screenX = (es.sprite.x - cam.scrollX) * cam.zoom; + const screenY = (es.sprite.y - cam.scrollY) * cam.zoom; + entityPositions.set(entity.id, { screenX, screenY }); + } + } + this.emojiManager.update(update.entities, cam, entityPositions); + // Live-update the follow panel if visible if (this.mode === 'follow' && this.npcInfoPanel.isVisible()) { const npcIds = this.getNpcIds(); diff --git a/client/src/ui/InteractionEmoji.ts b/client/src/ui/InteractionEmoji.ts new file mode 100644 index 0000000..2b83d57 --- /dev/null +++ b/client/src/ui/InteractionEmoji.ts @@ -0,0 +1,97 @@ +import type { EntityState } from '@dflike/shared'; + +const EMOJI_POSITIVE = '\u{1F60A}'; +const EMOJI_NEGATIVE = '\u{1F620}'; + +export class InteractionEmojiManager { + private activeEmojis: Map = new Map(); + + constructor() { + this.ensureStylesheet(); + } + + update( + entities: EntityState[], + _camera: Phaser.Cameras.Scene2D.Camera, + entityPositions: Map, + ): void { + const emoting = new Set(); + + for (const entity of entities) { + if (entity.socialState?.phase === 'emoting' && entity.socialState.outcome) { + emoting.add(entity.id); + if (!this.activeEmojis.has(entity.id)) { + this.createEmoji(entity.id, entity.socialState.outcome); + } + } + } + + // Remove emojis for entities no longer emoting + for (const [entityId, el] of this.activeEmojis) { + if (!emoting.has(entityId)) { + el.remove(); + this.activeEmojis.delete(entityId); + } + } + + // Update positions + for (const [entityId, el] of this.activeEmojis) { + const pos = entityPositions.get(entityId); + if (pos) { + el.style.left = `${pos.screenX}px`; + el.style.top = `${pos.screenY}px`; + } + } + } + + private createEmoji(entityId: number, outcome: string): void { + const div = document.createElement('div'); + div.style.cssText = ` + position: fixed; + font-size: 24px; + pointer-events: none; + z-index: 999; + transform: translate(-50%, -100%); + `; + div.style.animation = 'emoji-float 2s ease-out forwards'; + div.textContent = outcome === 'positive' ? EMOJI_POSITIVE : EMOJI_NEGATIVE; + + document.body.appendChild(div); + this.activeEmojis.set(entityId, div); + } + + private ensureStylesheet(): void { + if (document.getElementById('emoji-float-style')) return; + + const style = document.createElement('style'); + style.id = 'emoji-float-style'; + style.textContent = ` + @keyframes emoji-float { + 0% { + transform: translate(-50%, -100%) translateY(0); + opacity: 1; + } + 75% { + opacity: 1; + } + 100% { + transform: translate(-50%, -100%) translateY(-20px); + opacity: 0; + } + } + `; + document.head.appendChild(style); + } + + destroy(): void { + for (const [, el] of this.activeEmojis) { + el.remove(); + } + this.activeEmojis.clear(); + + const style = document.getElementById('emoji-float-style'); + if (style) { + style.remove(); + } + } +}