UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

604 lines (514 loc) 17.4 kB
import {OptimizedBuffer, BoxRenderable, type BoxOptions, type RenderContext} from '@opentui/core'; import {extend} from '@opentui/react'; import {PixelCanvas, type GameImage} from './pixelCanvas.ts'; import type {AssetData, AssetFrame} from '../../../assets/assets.ts'; /** * Animation types for sprites * Original animations: shimmer, pulse, wave (proper progress-based) * New animations: shimmer_fast, pulse_fast, wave_fast (simpler time-based) */ export type AnimationType = | 'none' | 'shimmer' | 'pulse' | 'wave' | 'frame' | 'shimmer_fast' | 'pulse_fast' | 'wave_fast'; // ============ Asset Decoding Helpers ============ function decodeBase64(base64: string): Uint8Array { return new Uint8Array(Buffer.from(base64, 'base64')); } function decodePixels(pixelsBase64: string, bytesPerPixel: number): Uint8Array | Uint16Array { const bytes = decodeBase64(pixelsBase64); if (bytesPerPixel === 1) { return bytes; } return new Uint16Array(bytes.buffer, bytes.byteOffset, bytes.length / 2); } function decodePalette(paletteBase64: string, paletteColors: number): number[][] { const bytes = decodeBase64(paletteBase64); const palette: number[][] = []; for (let i = 0; i < paletteColors * 4; i += 4) { palette.push([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]); } return palette; } /** * Convert AssetData/AssetFrame to raw RGBA pixels */ function assetFrameToPixels( width: number, height: number, paletteBase64: string, paletteColors: number, pixelsBase64: string, bytesPerPixel: number, ): Uint8Array { const palette = decodePalette(paletteBase64, paletteColors); const indices = decodePixels(pixelsBase64, bytesPerPixel); const pixels = new Uint8Array(width * height * 4); for (let i = 0; i < width * height; i++) { const paletteIndex = indices[i]; const color = palette[paletteIndex] || [0, 0, 0, 0]; pixels[i * 4] = color[0]; pixels[i * 4 + 1] = color[1]; pixels[i * 4 + 2] = color[2]; pixels[i * 4 + 3] = color[3]; } return pixels; } /** * Sprite options for rendering */ export interface SpriteOptions { flipX?: boolean; flipY?: boolean; scale?: number; opacity?: number; // 0-1 animation?: AnimationType; animationSpeed?: number; // multiplier, default 1 animationDuration?: number; // duration in ms for progress-based animations (default 2000) frameSpeed?: number; // FPS for frame animations waveAmplitude?: number; // amplitude for wave effect (default 3) } /** * Animated sprite data - contains multiple frames */ export interface AnimatedImage { width: number; height: number; frames: Uint8Array[]; // Array of RGBA pixel arrays frameCount: number; } /** * Sprite class for drawing images to PixelCanvas with effects */ export class Sprite { public readonly image: GameImage | AnimatedImage; public readonly width: number; public readonly height: number; private flipX: boolean; private flipY: boolean; private scale: number; private opacity: number; private animation: AnimationType; private animationSpeed: number; private animationDuration: number; private frameSpeed: number; private waveAmplitude: number; // Animation state private animationTime: number = 0; private animationStartTime: number = 0; private currentFrame: number = 0; private lastFrameTime: number = 0; constructor(image: GameImage | AnimatedImage, options: SpriteOptions = {}) { this.image = image; this.width = image.width; this.height = image.height; this.flipX = options.flipX ?? false; this.flipY = options.flipY ?? false; this.scale = options.scale ?? 1; this.opacity = options.opacity ?? 1; this.animation = options.animation ?? 'none'; this.animationSpeed = options.animationSpeed ?? 1; this.animationDuration = options.animationDuration ?? 2000; this.frameSpeed = options.frameSpeed ?? 10; this.waveAmplitude = options.waveAmplitude ?? 3; this.animationStartTime = performance.now(); } /** * Check if this is an animated sprite */ isAnimated(): boolean { return 'frames' in this.image && this.image.frameCount > 1; } /** * Get current frame pixels */ private getCurrentPixels(): Uint8Array { if (this.isAnimated()) { const animated = this.image as AnimatedImage; return animated.frames[this.currentFrame % animated.frameCount]; } return (this.image as GameImage).pixels; } /** * Get pixel at position, applying flip transforms */ private getPixelAt( pixels: Uint8Array, x: number, y: number, ): [number, number, number, number] | null { const srcX = this.flipX ? this.width - 1 - x : x; const srcY = this.flipY ? this.height - 1 - y : y; if (srcX < 0 || srcX >= this.width || srcY < 0 || srcY >= this.height) { return null; } const idx = (srcY * this.width + srcX) * 4; const r = pixels[idx]; const g = pixels[idx + 1]; const b = pixels[idx + 2]; let a = pixels[idx + 3]; if (a === 0) return null; a = Math.round(a * this.opacity); return [r, g, b, a]; } /** * Get animation progress (0 to 1) based on duration */ private getAnimationProgress(): number { const elapsed = performance.now() - this.animationStartTime; const animTime = (elapsed * this.animationSpeed) % this.animationDuration; return animTime / this.animationDuration; } /** * Apply animation effects to a color (original progress-based animations) */ private applyAnimation( r: number, g: number, b: number, a: number, x: number, y: number, ): [number, number, number, number] { const progress = this.getAnimationProgress(); const t = this.animationTime * this.animationSpeed; switch (this.animation) { // Original progress-based animations (matching Image.ts behavior) case 'shimmer': { const shimmerHeight = 5; const totalHeight = this.height; const shimmerCenter = Math.floor(progress * (totalHeight + shimmerHeight)) - shimmerHeight / 2; const dist = Math.abs(y - shimmerCenter); if (dist < shimmerHeight / 2) { const intensity = 1 - dist / (shimmerHeight / 2); const boost = intensity * 0.5 * 255; r = Math.min(255, r + boost); g = Math.min(255, g + boost); b = Math.min(255, b + boost); } break; } case 'pulse': { // Sine wave for smooth pulsing (matching original) const intensity = (Math.sin(progress * Math.PI * 2) + 1) / 2; const boost = intensity * 0.3 * 255; r = Math.min(255, r + boost); g = Math.min(255, g + boost); b = Math.min(255, b + boost); break; } case 'wave': { // Wave is handled separately in draw() since it affects position break; } // New simpler time-based animations case 'shimmer_fast': { const shimmerPos = ((t * 20) % (this.height + 10)) - 5; const dist = Math.abs(y - shimmerPos); if (dist < 5) { const intensity = 1 - dist / 5; const boost = intensity * 0.5 * 255; r = Math.min(255, r + boost); g = Math.min(255, g + boost); b = Math.min(255, b + boost); } break; } case 'pulse_fast': { const intensity = (Math.sin(t * 4) + 1) / 2; const boost = intensity * 0.3 * 255; r = Math.min(255, r + boost); g = Math.min(255, g + boost); b = Math.min(255, b + boost); break; } case 'wave_fast': { const wave = Math.sin(t * 3 + x * 0.5) * 0.2 * 255; r = Math.max(0, Math.min(255, r + wave)); g = Math.max(0, Math.min(255, g + wave)); b = Math.max(0, Math.min(255, b + wave)); break; } } return [r, g, b, a]; } /** * Update animation state - call this each frame */ update(deltaTime: number): void { this.animationTime += deltaTime; if (this.animation === 'frame' && this.isAnimated()) { const now = performance.now(); const frameDuration = 1000 / this.frameSpeed; if (now - this.lastFrameTime >= frameDuration) { const animated = this.image as AnimatedImage; this.currentFrame = (this.currentFrame + 1) % animated.frameCount; this.lastFrameTime = now; } } } /** * Set the current animation frame (for manual control) */ setFrame(frame: number): void { if (this.isAnimated()) { const animated = this.image as AnimatedImage; this.currentFrame = frame % animated.frameCount; } } /** * Draw sprite to a PixelCanvas */ draw(canvas: PixelCanvas, x: number, y: number): void { // Wave animation needs special handling since it distorts position if (this.animation === 'wave') { this.drawWithWave(canvas, x, y); return; } const pixels = this.getCurrentPixels(); const scaledW = Math.floor(this.width * this.scale); const scaledH = Math.floor(this.height * this.scale); for (let py = 0; py < scaledH; py++) { for (let px = 0; px < scaledW; px++) { const srcX = Math.floor(px / this.scale); const srcY = Math.floor(py / this.scale); const pixel = this.getPixelAt(pixels, srcX, srcY); if (!pixel) continue; let [r, g, b, a] = pixel; if (this.animation !== 'none' && this.animation !== 'frame') { [r, g, b, a] = this.applyAnimation(r, g, b, a, srcX, srcY); } canvas.setFillColor(r, g, b, a); canvas.setPixel(x + px, y + py); } } } /** * Draw with wave distortion effect (matching original Image.ts behavior) */ private drawWithWave(canvas: PixelCanvas, x: number, y: number): void { const pixels = this.getCurrentPixels(); const scaledW = Math.floor(this.width * this.scale); const scaledH = Math.floor(this.height * this.scale); const progress = this.getAnimationProgress(); const waveFrequency = 2; for (let py = 0; py < scaledH; py++) { for (let px = 0; px < scaledW; px++) { // Calculate wave offset using sine wave const normalizedX = px / scaledW; const wavePhase = normalizedX * waveFrequency * Math.PI * 2 + progress * Math.PI * 2; const waveOffset = Math.sin(wavePhase) * this.waveAmplitude; // Calculate source position with wave offset applied to Y const srcX = Math.floor(px / this.scale); const srcY = Math.floor((py - waveOffset) / this.scale); // Bounds check if (srcY < 0 || srcY >= this.height) continue; const pixel = this.getPixelAt(pixels, srcX, srcY); if (!pixel) continue; const [r, g, b, a] = pixel; canvas.setFillColor(r, g, b, a); canvas.setPixel(x + px, y + py); } } } // ============ Setters ============ setFlipX(flip: boolean): this { this.flipX = flip; return this; } setFlipY(flip: boolean): this { this.flipY = flip; return this; } setScale(scale: number): this { this.scale = Math.max(0.1, scale); return this; } setOpacity(opacity: number): this { this.opacity = Math.max(0, Math.min(1, opacity)); return this; } setAnimation(animation: AnimationType, speed?: number): this { this.animation = animation; this.animationStartTime = performance.now(); // Reset animation timing if (speed !== undefined) { this.animationSpeed = speed; } return this; } setAnimationDuration(durationMs: number): this { this.animationDuration = Math.max(100, durationMs); return this; } setWaveAmplitude(amplitude: number): this { this.waveAmplitude = Math.max(0, amplitude); return this; } setFrameSpeed(fps: number): this { this.frameSpeed = Math.max(1, fps); return this; } // ============ Getters ============ getScaledWidth(): number { return Math.floor(this.width * this.scale); } getScaledHeight(): number { return Math.floor(this.height * this.scale); } getTerminalHeight(): number { return Math.ceil(this.getScaledHeight() / 2); } getCurrentFrame(): number { return this.currentFrame; } getFrameCount(): number { return this.isAnimated() ? (this.image as AnimatedImage).frameCount : 1; } // ============ Static Factory Methods ============ static fromImage(image: GameImage, options?: SpriteOptions): Sprite { return new Sprite(image, options); } static fromArt( art: string[], palette: Record<string, [number, number, number, number]>, options?: SpriteOptions, ): Sprite { const image = PixelCanvas.imageFromArt(art, palette); return new Sprite(image, options); } static fromFrames( width: number, height: number, frames: Uint8Array[], options?: SpriteOptions, ): Sprite { const animatedImage: AnimatedImage = { width, height, frames, frameCount: frames.length, }; return new Sprite(animatedImage, {...options, animation: 'frame'}); } static fromArtFrames( artFrames: string[][], palette: Record<string, [number, number, number, number]>, options?: SpriteOptions, ): Sprite { const frames = artFrames.map((art) => PixelCanvas.imageFromArt(art, palette).pixels); const width = Math.max(...artFrames.map((art) => Math.max(...art.map((row) => row.length)))); const height = Math.max(...artFrames.map((art) => art.length)); return Sprite.fromFrames(width, height, frames, options); } /** * Create a sprite from AssetData (generated from images) */ static fromAsset(asset: AssetData, options?: SpriteOptions): Sprite { if (asset.isAnimation && asset.frames && asset.frames.length > 0) { // Animated asset const frames = asset.frames.map((frame) => assetFrameToPixels( asset.width, asset.height, frame.paletteBase64, frame.paletteColors, frame.pixelsBase64, frame.bytesPerPixel, ), ); return Sprite.fromFrames(asset.width, asset.height, frames, options); } else { // Static image const pixels = assetFrameToPixels( asset.width, asset.height, asset.paletteBase64!, asset.paletteColors!, asset.pixelsBase64!, asset.bytesPerPixel!, ); const image: GameImage = { width: asset.width, height: asset.height, pixels, }; return new Sprite(image, options); } } } // ============ React Component ============ export interface SpriteImageProps extends BoxOptions { sprite: Sprite; opacity?: number; // 0-1, overrides sprite's opacity } /** * React component for rendering a Sprite using PixelCanvas */ export class SpriteImage extends BoxRenderable { private sprite: Sprite; private canvas: PixelCanvas; private animationInterval: ReturnType<typeof setInterval> | null = null; private lastTime: number = performance.now(); private _opacity: number = 1; set opacity(value: number) { this._opacity = value; this.requestRender(); } constructor(ctx: RenderContext, options: SpriteImageProps) { const sprite = options.sprite; const w = sprite.getScaledWidth(); const h = sprite.getScaledHeight(); super(ctx, { ...options, width: w, height: Math.ceil(h / 2), }); this.opacity = options.opacity ?? 1; this.sprite = sprite; this.canvas = new PixelCanvas(w, h); // Apply opacity from props to sprite if (options.opacity !== undefined) { this.sprite.setOpacity(options.opacity); } if (sprite.isAnimated() || sprite['animation'] !== 'none') { this.startAnimationLoop(); } } private startAnimationLoop(): void { this.animationInterval = setInterval(() => { const now = performance.now(); const deltaTime = (now - this.lastTime) / 1000; this.lastTime = now; this.sprite.update(deltaTime); this.ctx.requestRender(); }, 33); } protected override renderSelf(buffer: OptimizedBuffer): void { super.renderSelf(buffer); this.canvas.clear(0, 0, 0, 0); this.sprite.draw(this.canvas, 0, 0); this.sprite.setOpacity(this._opacity ?? 1); this.canvas.render(buffer, this.x, this.y); } protected override destroySelf(): void { super.destroySelf(); if (this.animationInterval) { clearInterval(this.animationInterval); this.animationInterval = null; } } } declare module '@opentui/react' { interface OpenTUIComponents { spriteImage: typeof SpriteImage; } } extend({ spriteImage: SpriteImage, });