From 6f69ab52c1ca3e771f5bb7fa9da1300b1db3618a Mon Sep 17 00:00:00 2001 From: "Z. Cliffe Schreuders" Date: Fri, 7 Nov 2025 19:51:13 +0000 Subject: [PATCH] Enhance PersonChatMinigame and related classes to support optional background images - Added support for optional background image paths in PersonChatMinigame, PersonChatPortraits, and PersonChatUI, allowing for more dynamic visual presentations during conversations. - Updated NPCManager to handle background image paths in timed conversations, improving narrative context. - Enhanced canvas rendering logic to accommodate background images, ensuring proper scaling and alignment with character sprites. --- .../person-chat/person-chat-minigame.js | 4 +- .../person-chat/person-chat-portraits.js | 176 ++++++++++++++++-- js/minigames/person-chat/person-chat-ui.js | 4 +- js/systems/npc-manager.js | 9 +- scenarios/npc-sprite-test2.json | 3 +- 5 files changed, 173 insertions(+), 23 deletions(-) diff --git a/js/minigames/person-chat/person-chat-minigame.js b/js/minigames/person-chat/person-chat-minigame.js index 8a5e72d..250a40a 100644 --- a/js/minigames/person-chat/person-chat-minigame.js +++ b/js/minigames/person-chat/person-chat-minigame.js @@ -46,6 +46,7 @@ export class PersonChatMinigame extends MinigameScene { // Parameters this.npcId = params.npcId; this.title = params.title || 'Conversation'; + this.background = params.background; // Optional background image path from timedConversation // Verify NPC exists const npc = this.npcManager.getNPC(this.npcId); @@ -162,7 +163,8 @@ export class PersonChatMinigame extends MinigameScene { npc: this.npc, playerSprite: this.player, playerData: this.playerData, - characters: this.characters // Pass multi-character support + characters: this.characters, // Pass multi-character support + background: this.background // Optional background image path }, this.npcManager); this.ui.render(); diff --git a/js/minigames/person-chat/person-chat-portraits.js b/js/minigames/person-chat/person-chat-portraits.js index f5b4547..21af9b2 100644 --- a/js/minigames/person-chat/person-chat-portraits.js +++ b/js/minigames/person-chat/person-chat-portraits.js @@ -14,11 +14,13 @@ export default class PersonChatPortraits { * @param {Phaser.Game} game - Phaser game instance * @param {Object} npc - NPC data with sprite information * @param {HTMLElement} portraitContainer - Container for portrait canvas + * @param {string} background - Optional background image path */ - constructor(game, npc, portraitContainer) { + constructor(game, npc, portraitContainer, background = null) { this.game = game; this.npc = npc; this.portraitContainer = portraitContainer; + this.backgroundPath = background; // Optional background image path // Portrait settings this.spriteSize = 64; // Base sprite size @@ -30,6 +32,9 @@ export default class PersonChatPortraits { this.canvas = null; this.ctx = null; + // Background image + this.backgroundImage = null; // Loaded background image + // Sprite info this.spriteSheet = null; this.frameIndex = null; @@ -38,7 +43,7 @@ export default class PersonChatPortraits { this.flipped = false; // Whether to flip the sprite horizontally this.facingDirection = npc.id === 'player' ? 'right' : 'left'; - console.log(`🖼️ Portrait renderer created for NPC: ${npc.id}`); + console.log(`🖼️ Portrait renderer created for NPC: ${npc.id}${background ? ` with background: ${background}` : ''}`); } /** @@ -74,6 +79,11 @@ export default class PersonChatPortraits { // Get sprite sheet and frame this.setupSpriteInfo(); + // Load background image if provided + if (this.backgroundPath) { + this.loadBackgroundImage(); + } + // Set canvas size after it's in the DOM (container now has dimensions) // Use a small delay to ensure container is fully laid out setTimeout(() => { @@ -98,9 +108,8 @@ export default class PersonChatPortraits { /** * Calculate optimal integer scale factor for current container - * Matches base resolution (640x480) with pixel-perfect scaling - * Uses the same logic as the main game - * @returns {number} Optimal integer scale factor + * Uses 16:9 aspect ratio (640x360) for landscape, 4:3 (640x480) for portrait + * @returns {Object} Object with scale, baseWidth, and baseHeight */ calculateOptimalScale() { // Try to get the game-container (same as main game uses) @@ -108,15 +117,19 @@ export default class PersonChatPortraits { const container = gameContainer || this.portraitContainer; if (!container) { - return 2; // Default fallback + return { scale: 2, baseWidth: 640, baseHeight: 360 }; // Default fallback (landscape) } const containerWidth = container.clientWidth; const containerHeight = container.clientHeight; - // Base resolution (same as main game) + // Determine orientation: landscape (width > height) or portrait (height > width) + const isLandscape = containerWidth > containerHeight; + + // Base resolution based on orientation + // 16:9 for landscape (HD widescreen), 4:3 for portrait const baseWidth = 640; - const baseHeight = 480; + const baseHeight = isLandscape ? 360 : 480; // 16:9 for landscape, 4:3 for portrait // Calculate scale factors for both dimensions const scaleX = containerWidth / baseWidth; @@ -141,30 +154,29 @@ export default class PersonChatPortraits { } } - return bestScale; + return { scale: bestScale, baseWidth, baseHeight }; } /** * Update canvas size to match available container space with pixel-perfect scaling - * Uses the same approach as the main game + * Uses 16:9 aspect ratio for landscape, 4:3 for portrait */ updateCanvasSize() { if (!this.canvas) return; - // Calculate optimal scale for pixel-perfect rendering (same as main game) - const optimalScale = this.calculateOptimalScale(); - const baseWidth = 640; - const baseHeight = 480; + // Calculate optimal scale and base resolution based on orientation + const { scale: optimalScale, baseWidth, baseHeight } = this.calculateOptimalScale(); // Set canvas internal resolution to scaled resolution for pixel-perfect rendering - // This matches how the main game uses Phaser's scale system this.canvas.width = baseWidth * optimalScale; this.canvas.height = baseHeight * optimalScale; // CSS handles the display sizing (width/height 100% with object-fit: contain) // The canvas internal resolution is set above for pixel-perfect rendering - console.log(`🎨 Canvas scaled to ${optimalScale}x (${this.canvas.width}x${this.canvas.height}px internal, fits container)`); + const aspectRatio = baseWidth / baseHeight; + const orientation = baseHeight === 360 ? 'landscape (16:9)' : 'portrait (4:3)'; + console.log(`🎨 Canvas scaled to ${optimalScale}x (${this.canvas.width}x${this.canvas.height}px internal, ${orientation}, fits container)`); } /** @@ -192,7 +204,9 @@ export default class PersonChatPortraits { if (this.npc.spriteTalk) { console.log(`📸 Using spriteTalk image: ${this.npc.spriteTalk}`); this.useSpriteTalk = true; - this.spriteTalkImage = null; // Will be loaded in render + // Clear spriteTalkImage on speaker change to ensure correct dimensions are calculated + // This ensures background scale is recalculated for each speaker's sprite size + this.spriteTalkImage = null; // Will be loaded in render with correct dimensions // For NPCs with spriteTalk, flip the image to face right this.flipped = this.npc.id !== 'player'; return; @@ -201,6 +215,8 @@ export default class PersonChatPortraits { // Otherwise use spriteSheet with frame console.log(`🔍 No spriteTalk found, using spriteSheet`); this.useSpriteTalk = false; + // Clear spriteTalkImage when switching to spriteSheet + this.spriteTalkImage = null; if (this.npc.id === 'player') { // Player uses their sprite @@ -217,6 +233,94 @@ export default class PersonChatPortraits { } } + /** + * Load background image if path is provided + */ + loadBackgroundImage() { + if (!this.backgroundPath) return; + + const img = new Image(); + img.crossOrigin = 'anonymous'; + + img.onload = () => { + this.backgroundImage = img; + console.log(`✅ Background image loaded: ${this.backgroundPath}`); + // Re-render when background loads + this.render(); + }; + + img.onerror = () => { + console.error(`❌ Failed to load background image: ${this.backgroundPath}`); + this.backgroundImage = null; + }; + + img.src = this.backgroundPath; + } + + /** + * Draw background image at same pixel scale as character sprite + * Fills the canvas while maintaining sprite's pixel scale (may extend beyond canvas if larger) + * Aligns based on speaker position: right edge for NPCs (flipped), left edge for player (not flipped) + * @param {number} spriteScale - The scale factor used for the sprite (must match sprite scale exactly) + */ + drawBackground(spriteScale) { + if (!this.backgroundImage || !this.ctx || !this.canvas || !spriteScale) return; + + const canvasWidth = this.canvas.width; + const canvasHeight = this.canvas.height; + const imgWidth = this.backgroundImage.width; + const imgHeight = this.backgroundImage.height; + + // Use the exact same scale as the sprite + let scale = spriteScale; + + // Calculate scaled dimensions using the sprite's scale + let scaledWidth = imgWidth * scale; + let scaledHeight = imgHeight * scale; + + // If background is smaller than canvas, scale it up to fill (cover style) + // This ensures the background always fills the canvas while maintaining aspect ratio + if (scaledWidth < canvasWidth || scaledHeight < canvasHeight) { + const fillScaleX = canvasWidth / imgWidth; + const fillScaleY = canvasHeight / imgHeight; + const fillScale = Math.max(fillScaleX, fillScaleY); // Cover style to fill canvas + scale = fillScale; + scaledWidth = imgWidth * scale; + scaledHeight = imgHeight * scale; + } + + // Position based on speaker alignment to fill canvas: + // - NPC (flipped, appears on right): align right edge to canvas right edge + // - Player (not flipped, appears on left): align left edge to canvas left edge + let x; + if (this.flipped) { + // NPC on right: align background's right edge to canvas right edge + x = canvasWidth - scaledWidth; + } else { + // Player on left: align background's left edge to canvas left edge + x = 0; + } + + // Fill canvas vertically - center if larger, align to top if exactly filling + let y; + if (scaledHeight > canvasHeight) { + // Background larger than canvas: center vertically (will extend above/below) + y = (canvasHeight - scaledHeight) / 2; + } else { + // Background fills or is exactly canvas height: align to top + y = 0; + } + + // Draw background image at same pixel scale as sprite + // Note: Canvas will clip anything outside its bounds, but background may extend beyond + this.ctx.imageSmoothingEnabled = false; // Pixel-perfect rendering + this.ctx.drawImage( + this.backgroundImage, + x, y, // Destination position + scaledWidth, scaledHeight // Destination size (scaled to match sprite scale exactly) + ); + } + /** * Render the portrait using Phaser texture or spriteTalk image, scaled to fill canvas */ @@ -233,6 +337,12 @@ export default class PersonChatPortraits { // If using spriteTalk image, render that instead if (this.useSpriteTalk) { console.log(`🎨 Rendering spriteTalk image path`); + // Calculate sprite scale for spriteTalk + const spriteTalkScale = this.calculateSpriteTalkScale(); + // Draw background with sprite scale if loaded + if (this.backgroundImage && spriteTalkScale) { + this.drawBackground(spriteTalkScale); + } this.renderSpriteTalkImage(); return; } @@ -269,6 +379,11 @@ export default class PersonChatPortraits { let scaleY = canvasHeight / spriteHeight; let scale = Math.min(scaleX, scaleY); // Fit contain style - ensures full sprite visible + // Draw background with sprite scale if loaded + if (this.backgroundImage) { + this.drawBackground(scale); + } + // Calculate position to center the sprite const scaledWidth = spriteWidth * scale; const scaledHeight = spriteHeight * scale; @@ -330,6 +445,14 @@ export default class PersonChatPortraits { img.onload = () => { // Store loaded image this.spriteTalkImage = img; + // Recalculate scale now that image is loaded and redraw background + const spriteTalkScale = this.calculateSpriteTalkScale(); + if (this.backgroundImage && spriteTalkScale) { + // Clear and redraw background with correct scale + this.ctx.fillStyle = '#000'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.drawBackground(spriteTalkScale); + } this.drawSpriteTalkImage(img); }; @@ -350,6 +473,25 @@ export default class PersonChatPortraits { } } + /** + * Calculate the scale for spriteTalk image (same calculation used in drawSpriteTalkImage) + * @returns {number|null} The scale factor, or null if spriteTalk image not loaded + */ + calculateSpriteTalkScale() { + if (!this.spriteTalkImage || !this.canvas) return null; + + const canvasWidth = this.canvas.width; + const canvasHeight = this.canvas.height; + const imgWidth = this.spriteTalkImage.width; + const imgHeight = this.spriteTalkImage.height; + + // Calculate scaling to fit image within canvas while maintaining aspect ratio + // Use Math.min to ensure full sprite is visible (contain style, not cover) + let scaleX = canvasWidth / imgWidth; + let scaleY = canvasHeight / imgHeight; + return Math.min(scaleX, scaleY); // Fit contain style - ensures full sprite visible + } + /** * Draw the spriteTalk image scaled to fill canvas * @param {Image} img - The loaded image element diff --git a/js/minigames/person-chat/person-chat-ui.js b/js/minigames/person-chat/person-chat-ui.js index 89da3bf..ed83854 100644 --- a/js/minigames/person-chat/person-chat-ui.js +++ b/js/minigames/person-chat/person-chat-ui.js @@ -29,6 +29,7 @@ export default class PersonChatUI { this.playerSprite = params.playerSprite; this.playerData = params.playerData || {}; this.characters = params.characters || {}; // Multi-character support + this.background = params.background; // Optional background image path // UI elements this.elements = { @@ -187,7 +188,8 @@ export default class PersonChatUI { this.portraitRenderer = new PersonChatPortraits( this.game, this.npc, - this.elements.portraitContainer + this.elements.portraitContainer, + this.background // Optional background image path ); this.portraitRenderer.init(); diff --git a/js/systems/npc-manager.js b/js/systems/npc-manager.js index c0d2ff6..3a7ba16 100644 --- a/js/systems/npc-manager.js +++ b/js/systems/npc-manager.js @@ -98,7 +98,8 @@ export default class NPCManager { this.scheduleTimedConversation({ npcId: realId, targetKnot: entry.timedConversation.targetKnot, - delay: entry.timedConversation.delay + delay: entry.timedConversation.delay, + background: entry.timedConversation.background // Optional background image }); console.log(`[NPCManager] Scheduled timed conversation for ${realId} to knot: ${entry.timedConversation.targetKnot}`); } @@ -446,7 +447,7 @@ export default class NPCManager { // } // } scheduleTimedConversation(opts) { - const { npcId, targetKnot, triggerTime, delay } = opts; + const { npcId, targetKnot, triggerTime, delay, background } = opts; if (!npcId || !targetKnot) { console.error('[NPCManager] scheduleTimedConversation requires npcId and targetKnot'); @@ -460,6 +461,7 @@ export default class NPCManager { npcId, targetKnot, triggerTime: actualTriggerTime, // milliseconds from game start + background: background, // Optional background image path delivered: false }); @@ -563,7 +565,8 @@ export default class NPCManager { window.MinigameFramework.startMinigame('person-chat', null, { npcId: conversation.npcId, - title: npc.displayName || conversation.npcId + title: npc.displayName || conversation.npcId, + background: conversation.background // Optional background image path }); } else { console.warn(`[NPCManager] MinigameFramework not available to start person-chat for timed conversation`); diff --git a/scenarios/npc-sprite-test2.json b/scenarios/npc-sprite-test2.json index 7635c3f..d7ef4f4 100644 --- a/scenarios/npc-sprite-test2.json +++ b/scenarios/npc-sprite-test2.json @@ -48,7 +48,8 @@ "currentKnot": "hub", "timedConversation": { "delay": 3000, - "targetKnot": "group_meeting" + "targetKnot": "group_meeting", + "background": "assets/mini-games/desktop-wallpaper.png" } } ]