feat: add floating emoji overlay for NPC social interactions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<number, { screenX: number; screenY: number }>();
|
||||
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();
|
||||
|
||||
@@ -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<number, HTMLDivElement> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.ensureStylesheet();
|
||||
}
|
||||
|
||||
update(
|
||||
entities: EntityState[],
|
||||
_camera: Phaser.Cameras.Scene2D.Camera,
|
||||
entityPositions: Map<number, { screenX: number; screenY: number }>,
|
||||
): void {
|
||||
const emoting = new Set<number>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user