Spaces:
Running on Zero
Running on Zero
| import Phaser from 'phaser'; | |
| // ============================================================================ | |
| // CORE UTILS - Universal utilities for ALL game types | |
| // ============================================================================ | |
| // ============================================================================ | |
| // ANIMATION ORIGIN SYSTEM (CRITICAL!) | |
| // ============================================================================ | |
| /** | |
| * Reset origin and offset for sprite after playing animation | |
| * | |
| * IMPORTANT: Must be called every time after playing any animation! | |
| * Also recommended to call every frame in update() for sprites with varying frame sizes. | |
| * This reads origin data from animations.json and adjusts collision body offset. | |
| * | |
| * @param sprite - The sprite to adjust | |
| * @param facingDirection - Current facing direction | |
| */ | |
| export const resetOriginAndOffset = ( | |
| sprite: any, | |
| facingDirection: 'left' | 'right' | 'up' | 'down', | |
| ): void => { | |
| if ( | |
| facingDirection !== 'up' && | |
| facingDirection !== 'down' && | |
| facingDirection !== 'left' && | |
| facingDirection !== 'right' | |
| ) { | |
| throw new Error( | |
| 'resetOriginAndOffset: facingDirection must be up, down, left, or right', | |
| ); | |
| } | |
| // STEP 1: Normalize frame size (make all frames display at same height) | |
| // This prevents visual "jumping" when switching between frames of different sizes | |
| const targetDisplayHeight = (sprite as any)._targetDisplayHeight; | |
| if (targetDisplayHeight && sprite.height > 0) { | |
| const newScale = targetDisplayHeight / sprite.height; | |
| sprite.setScale(newScale); | |
| } | |
| // STEP 2: Determine origin | |
| // Try to read per-animation origin from animations.json (optional) | |
| let baseOriginX = 0.5; | |
| let baseOriginY = 1.0; | |
| const animationsData = sprite.scene?.cache?.json?.get('animations'); | |
| if (animationsData?.anims) { | |
| const currentAnim = sprite.anims?.currentAnim; | |
| if (currentAnim) { | |
| const animConfig = animationsData.anims.find( | |
| (anim: any) => anim.key === currentAnim.key, | |
| ); | |
| if (animConfig) { | |
| baseOriginX = animConfig.originX ?? 0.5; | |
| baseOriginY = animConfig.originY ?? 1.0; | |
| } | |
| } | |
| } | |
| // Mirror origin for left-facing | |
| const animOriginX = | |
| facingDirection === 'left' ? 1 - baseOriginX : baseOriginX; | |
| const animOriginY = baseOriginY; | |
| sprite.setOrigin(animOriginX, animOriginY); | |
| // STEP 3: Adjust body offset | |
| const body = sprite.body as Phaser.Physics.Arcade.Body; | |
| if (!body) return; | |
| // Get body dimensions (these are set once in initScale and don't change) | |
| const unscaledBodyWidth = body.sourceWidth; | |
| const unscaledBodyHeight = body.sourceHeight; | |
| // Calculate offset to align body bottom-center with sprite anchor point (feet) | |
| const offsetX = sprite.width * animOriginX - unscaledBodyWidth / 2; | |
| const offsetY = sprite.height * animOriginY - unscaledBodyHeight; | |
| body.setOffset(offsetX, offsetY); | |
| }; | |
| // ============================================================================ | |
| // SAFE AUDIO LOADING (Prevents crashes from missing audio files) | |
| // ============================================================================ | |
| /** | |
| * Safely add a sound effect - returns undefined if audio key doesn't exist | |
| * | |
| * IMPORTANT: Always use this instead of scene.sound.add() directly! | |
| * This prevents game crashes when audio assets are missing. | |
| * | |
| * Usage: | |
| * this.jumpSound = safeAddSound(this.scene, "jump_sfx", { volume: 0.3 }); | |
| * // Later: this.jumpSound?.play(); // Safe to call even if undefined | |
| * | |
| * @param scene - The scene to add sound to | |
| * @param key - Audio key to load | |
| * @param config - Optional sound config | |
| * @returns Sound object or undefined if key doesn't exist | |
| */ | |
| export const safeAddSound = ( | |
| scene: Phaser.Scene, | |
| key: string, | |
| config?: Phaser.Types.Sound.SoundConfig, | |
| ): Phaser.Sound.BaseSound | undefined => { | |
| // Check if audio key exists in cache | |
| if (!scene.cache.audio.exists(key)) { | |
| // Silently return undefined - audio not loaded is common during development | |
| return undefined; | |
| } | |
| try { | |
| return scene.sound.add(key, config); | |
| } catch (e) { | |
| console.warn(`Failed to add sound: ${key}`, e); | |
| return undefined; | |
| } | |
| }; | |
| /** | |
| * Check if an audio key exists in the cache | |
| */ | |
| export const audioExists = (scene: Phaser.Scene, key: string): boolean => { | |
| return scene.cache.audio.exists(key); | |
| }; | |
| /** | |
| * Check if a texture key exists | |
| */ | |
| export const textureExists = (scene: Phaser.Scene, key: string): boolean => { | |
| return scene.textures.exists(key); | |
| }; | |
| // ============================================================================ | |
| // SPRITE SCALING SYSTEM (CRITICAL!) | |
| // ============================================================================ | |
| /** | |
| * Initialize sprite scale, size, and offset | |
| * | |
| * IMPORTANT: All image assets must use initScale for scaling! | |
| * DO NOT use setScale or setDisplaySize directly! | |
| * | |
| * This function correctly handles both DynamicBody and StaticBody: | |
| * - DynamicBody: setSize needs unscaled dimensions (body auto-scales with sprite) | |
| * - StaticBody: setSize needs scaled dimensions (body does NOT auto-scale) | |
| * - StaticBody.setOffset has a BUG - use position.set instead! | |
| */ | |
| export const initScale = ( | |
| sprite: Phaser.GameObjects.Sprite | Phaser.GameObjects.Image, | |
| origin: { x: number; y: number }, | |
| maxDisplayWidth?: number, | |
| maxDisplayHeight?: number, | |
| bodyWidthFactorToDisplayWidth?: number, | |
| bodyHeightFactorToDisplayHeight?: number, | |
| ): void => { | |
| sprite.setOrigin(origin.x, origin.y); | |
| // CRITICAL: Save initial texture dimensions for resetOriginAndOffset! | |
| // This prevents body position shifts when animation frames have different sizes. | |
| (sprite as any)._initWidth = sprite.width; | |
| (sprite as any)._initHeight = sprite.height; | |
| let displayScale: number; | |
| let displayHeight: number; | |
| let displayWidth: number; | |
| if (maxDisplayHeight && maxDisplayWidth) { | |
| if (sprite.height / sprite.width > maxDisplayHeight / maxDisplayWidth) { | |
| displayHeight = maxDisplayHeight; | |
| displayScale = maxDisplayHeight / sprite.height; | |
| displayWidth = sprite.width * displayScale; | |
| } else { | |
| displayWidth = maxDisplayWidth; | |
| displayScale = maxDisplayWidth / sprite.width; | |
| displayHeight = sprite.height * displayScale; | |
| } | |
| } else if (maxDisplayHeight) { | |
| displayHeight = maxDisplayHeight; | |
| displayScale = maxDisplayHeight / sprite.height; | |
| displayWidth = sprite.width * displayScale; | |
| } else if (maxDisplayWidth) { | |
| displayWidth = maxDisplayWidth; | |
| displayScale = maxDisplayWidth / sprite.width; | |
| displayHeight = sprite.height * displayScale; | |
| } else { | |
| throw new Error( | |
| 'initScale: maxDisplayHeight and maxDisplayWidth cannot both be undefined', | |
| ); | |
| } | |
| // CRITICAL: Save target display height for normalizing animation frames! | |
| // This allows resetOriginAndOffset to adjust scale when frame sizes differ. | |
| (sprite as any)._targetDisplayHeight = displayHeight; | |
| sprite.setScale(displayScale); | |
| // Provide default values for body factor parameters | |
| const widthFactor = bodyWidthFactorToDisplayWidth ?? 1.0; | |
| const heightFactor = bodyHeightFactorToDisplayHeight ?? 1.0; | |
| const displayBodyWidth = displayWidth * widthFactor; | |
| const displayBodyHeight = displayHeight * heightFactor; | |
| if (sprite.body instanceof Phaser.Physics.Arcade.Body) { | |
| // DynamicBody: setSize needs UNSCALED dimensions (body scales with sprite) | |
| const unscaledBodyWidth = displayBodyWidth / displayScale; | |
| const unscaledBodyHeight = displayBodyHeight / displayScale; | |
| sprite.body.setSize(unscaledBodyWidth, unscaledBodyHeight); | |
| // setOffset also needs UNSCALED values | |
| const unscaledOffsetX = | |
| sprite.width * origin.x - unscaledBodyWidth * origin.x; | |
| const unscaledOffsetY = | |
| sprite.height * origin.y - unscaledBodyHeight * origin.y; | |
| sprite.body.setOffset(unscaledOffsetX, unscaledOffsetY); | |
| } else if (sprite.body instanceof Phaser.Physics.Arcade.StaticBody) { | |
| // StaticBody: setSize needs SCALED dimensions (body does NOT scale with sprite) | |
| sprite.body.setSize(displayBodyWidth, displayBodyHeight); | |
| // BUG: Don't use StaticBody.setOffset - use position.set instead! | |
| const displayTopLeft = sprite.getTopLeft(); | |
| const bodyPositionX = | |
| displayTopLeft.x + | |
| (sprite.displayWidth * origin.x - displayBodyWidth * origin.x); | |
| const bodyPositionY = | |
| displayTopLeft.y + | |
| (sprite.displayHeight * origin.y - displayBodyHeight * origin.y); | |
| sprite.body.position.set(bodyPositionX, bodyPositionY); | |
| } | |
| }; | |
| // ============================================================================ | |
| // COLLISION SYSTEM (CRITICAL! Fixes Phaser parameter order bug) | |
| // ============================================================================ | |
| /** | |
| * Add collider with guaranteed parameter order | |
| * | |
| * IMPORTANT: Use this instead of scene.physics.add.collider! | |
| * Phaser has an internal bug where callback parameters can be swapped | |
| * when object1 is a physics group or tilemap. | |
| */ | |
| export const addCollider = ( | |
| scene: Phaser.Scene, | |
| object1: Phaser.Types.Physics.Arcade.ArcadeColliderType, | |
| object2: Phaser.Types.Physics.Arcade.ArcadeColliderType, | |
| collideCallback?: Phaser.Types.Physics.Arcade.ArcadePhysicsCallback, | |
| processCallback?: Phaser.Types.Physics.Arcade.ArcadePhysicsCallback, | |
| callbackContext?: any, | |
| ): Phaser.Physics.Arcade.Collider => { | |
| if (shouldSwap(object1, object2)) { | |
| return scene.physics.add.collider( | |
| object1, | |
| object2, | |
| (obj1: any, obj2: any) => { | |
| collideCallback?.call(callbackContext, obj2, obj1); | |
| }, | |
| (obj1: any, obj2: any) => { | |
| return processCallback?.call(callbackContext, obj2, obj1); | |
| }, | |
| callbackContext, | |
| ); | |
| } else { | |
| return scene.physics.add.collider( | |
| object1, | |
| object2, | |
| collideCallback, | |
| processCallback, | |
| callbackContext, | |
| ); | |
| } | |
| }; | |
| /** | |
| * Add overlap with guaranteed parameter order | |
| * | |
| * IMPORTANT: Use this instead of scene.physics.add.overlap! | |
| */ | |
| export const addOverlap = ( | |
| scene: Phaser.Scene, | |
| object1: Phaser.Types.Physics.Arcade.ArcadeColliderType, | |
| object2: Phaser.Types.Physics.Arcade.ArcadeColliderType, | |
| collideCallback?: Phaser.Types.Physics.Arcade.ArcadePhysicsCallback, | |
| processCallback?: Phaser.Types.Physics.Arcade.ArcadePhysicsCallback, | |
| callbackContext?: any, | |
| ): Phaser.Physics.Arcade.Collider => { | |
| if (shouldSwap(object1, object2)) { | |
| return scene.physics.add.overlap( | |
| object1, | |
| object2, | |
| (obj1: any, obj2: any) => { | |
| collideCallback?.call(callbackContext, obj2, obj1); | |
| }, | |
| (obj1: any, obj2: any) => { | |
| return processCallback?.call(callbackContext, obj2, obj1); | |
| }, | |
| callbackContext, | |
| ); | |
| } else { | |
| return scene.physics.add.overlap( | |
| object1, | |
| object2, | |
| collideCallback, | |
| processCallback, | |
| callbackContext, | |
| ); | |
| } | |
| }; | |
| /** | |
| * Determine if callback parameters should be swapped | |
| * Phaser internally swaps parameters in certain cases | |
| */ | |
| const shouldSwap = (object1: any, object2: any): boolean => { | |
| const object1IsPhysicsGroup = | |
| object1 && | |
| (object1 as any).isParent && | |
| !((object1 as any).physicsType === undefined); | |
| const object1IsTilemap = object1 && (object1 as any).isTilemap; | |
| const object2IsPhysicsGroup = | |
| object2 && | |
| (object2 as any).isParent && | |
| !((object2 as any).physicsType === undefined); | |
| const object2IsTilemap = object2 && (object2 as any).isTilemap; | |
| return ( | |
| (object1IsPhysicsGroup && !object2IsPhysicsGroup && !object2IsTilemap) || | |
| (object1IsTilemap && !object2IsPhysicsGroup && !object2IsTilemap) || | |
| (object1IsTilemap && object2IsPhysicsGroup) | |
| ); | |
| }; | |
| // ============================================================================ | |
| // UI HELPERS | |
| // ============================================================================ | |
| /** | |
| * Initialize UI DOM element for UI scenes | |
| * IMPORTANT: Always use this instead of add.dom and createFromHTML | |
| */ | |
| export const initUIDom = ( | |
| scene: Phaser.Scene, | |
| html: string, | |
| ): Phaser.GameObjects.DOMElement => { | |
| const dom = scene.add | |
| .dom(0, 0, 'div', 'width: 100%; height: 100%;') | |
| .setHTML(html); | |
| dom.pointerEvents = 'none'; | |
| dom.setOrigin(0, 0); | |
| dom.setScrollFactor(0); | |
| return dom; | |
| }; | |
| /** | |
| * Create a decoration and add it to a group | |
| * Height is relative to a standard character height of 128px | |
| */ | |
| export const createDecoration = ( | |
| scene: Phaser.Scene, | |
| group: Phaser.GameObjects.Group, | |
| key: string, | |
| x: number, | |
| y: number, | |
| maxDisplayHeight: number, | |
| ): Phaser.GameObjects.Image => { | |
| const decoration = scene.add.image(x, y, key); | |
| initScale(decoration, { x: 0.5, y: 1.0 }, undefined, maxDisplayHeight); | |
| group.add(decoration); | |
| return decoration; | |
| }; | |