shellquest
Version:
Terminal-based procedurally generated dungeon crawler
604 lines (514 loc) • 17.4 kB
text/typescript
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,
});