import { vec2 } from 'gl-matrix'; import { GardenAudio } from '../audio/garden-audio'; import { appConfig } from '../config'; import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; import { activeVibe } from '../settings'; import { BrushStrokeSmoother } from './brush-stroke-smoother'; import { type StrokeSegment } from './game-loop-types'; import { getMirroredStrokeSegments } from './stroke-mirroring'; import { type StrokeOutput } from './stroke-output'; interface GardenPointerInputOptions { canvas: HTMLCanvasElement; audio: GardenAudio; strokeOutput: StrokeOutput; getCanvasPixelRatio: () => number; getMirrorSegmentCount: () => number; onStartDrawing: () => void; onEraseGestureEnded: () => void; spawnStrokeAgents: (from: vec2, to: vec2) => void; } interface PointerSample { position: vec2; previousPosition: vec2; elapsedSeconds: number; timeStamp: number; } export class GardenPointerInput { private readonly brushSmoother: BrushStrokeSmoother; private activePointerId: number | null = null; private lastPointerPosition: vec2 | null = null; private lastPointerEventTimeMs: number | null = null; private isErasing = false; public constructor(private readonly options: GardenPointerInputOptions) { this.brushSmoother = new BrushStrokeSmoother({ getCanvasPixelRatio: options.getCanvasPixelRatio, getMirrorSegmentCount: options.getMirrorSegmentCount, }); } public attach(): void { this.canvas.addEventListener('pointerdown', this.onPointerDown); this.canvas.addEventListener('pointermove', this.onPointerMove); this.canvas.addEventListener('pointerup', this.onPointerUp); this.canvas.addEventListener('pointercancel', this.onPointerUp); } public detach(): void { this.canvas.removeEventListener('pointerdown', this.onPointerDown); this.canvas.removeEventListener('pointermove', this.onPointerMove); this.canvas.removeEventListener('pointerup', this.onPointerUp); this.canvas.removeEventListener('pointercancel', this.onPointerUp); } public setEraseMode(isErasing: boolean): void { this.isErasing = isErasing; } public clearSwipesIfIdle(): void { if (this.isSwipeActive) { return; } this.options.strokeOutput.clearSwipes(); } public scaleLastPointerPosition(scale: vec2): void { if (this.lastPointerPosition !== null) { vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale); } this.brushSmoother.scale(scale); } public get isSwipeActive(): boolean { return this.activePointerId !== null; } public get isEraseMode(): boolean { return this.isErasing; } private get canvas(): HTMLCanvasElement { return this.options.canvas; } private readonly onPointerDown = (event: PointerEvent) => { if (this.activePointerId !== null) { return; } this.options.audio.start(activeVibe, { userGesture: true }); this.options.audio.beginGesture(); this.options.onStartDrawing(); this.activePointerId = event.pointerId; this.canvas.setPointerCapture(event.pointerId); this.options.strokeOutput.clearSwipes(); this.lastPointerPosition = null; this.lastPointerEventTimeMs = null; this.brushSmoother.clear(); this.addSwipeAt(event, { emitAudio: false }); }; private readonly onPointerMove = (event: PointerEvent) => { if (event.pointerId !== this.activePointerId) { return; } this.getCoalescedPointerEvents(event).forEach((coalescedEvent) => { this.addSwipeAt(coalescedEvent); }); }; private readonly onPointerUp = (event: PointerEvent) => { if (event.pointerId !== this.activePointerId) { return; } this.options.audio.start(activeVibe, { userGesture: true }); this.addSwipeAt(event, { emitAudio: false }); this.finishBrushStroke(); this.options.audio.endGesture(); if (this.isErasing) { this.options.onEraseGestureEnded(); } this.canvas.releasePointerCapture(event.pointerId); this.activePointerId = null; this.lastPointerPosition = null; this.lastPointerEventTimeMs = null; this.brushSmoother.clear(); }; private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void { const sample = this.getPointerSample(event); if (this.isErasing) { this.addEraseSample(sample); } else { this.addBrushSample(sample); } if (options.emitAudio !== false) { this.emitStrokeAudio(sample); } this.lastPointerPosition = sample.position; this.lastPointerEventTimeMs = sample.timeStamp; } private getPointerSample(event: PointerEvent): PointerSample { const position = this.getCanvasPointerPosition(event); const previousPosition = this.lastPointerPosition ?? position; const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp; const elapsedSeconds = Math.max( appConfig.deltaTime.minDeltaTimeSeconds, (event.timeStamp - previousTimeMs) / 1000 ); return { position, previousPosition, elapsedSeconds, timeStamp: event.timeStamp, }; } private addBrushSample(sample: PointerSample): void { this.addBrushSegments(this.brushSmoother.addSample(sample.position)); this.getMirroredSegments(sample.previousPosition, sample.position).forEach( (segment) => { this.options.spawnStrokeAgents(segment.from, segment.to); } ); } private addEraseSample(sample: PointerSample): void { this.options.strokeOutput.addEraseSegment(sample.previousPosition, sample.position); } private emitStrokeAudio(sample: PointerSample): void { this.options.audio.stroke({ vibe: activeVibe, from: sample.previousPosition, to: sample.position, canvasSize: [this.canvas.width, this.canvas.height], isErasing: this.isErasing, elapsedSeconds: sample.elapsedSeconds, }); } private getCanvasPointerPosition(event: PointerEvent): vec2 { const rect = this.canvas.getBoundingClientRect(); const xScale = getSafePixelRatio(this.canvas.width / rect.width); const yScale = getSafePixelRatio(this.canvas.height / rect.height); return vec2.fromValues( (event.clientX - rect.left) * xScale, (event.clientY - rect.top) * yScale ); } private addBrushSegments(segments: Array): void { segments.forEach((segment) => { this.getMirroredSegments(segment.from, segment.to).forEach((mirroredSegment) => { this.options.strokeOutput.addBrushSegment( mirroredSegment.from, mirroredSegment.to ); }); }); } private finishBrushStroke(): void { if (this.isErasing) { return; } this.addBrushSegments(this.brushSmoother.finish()); } private getCoalescedPointerEvents(event: PointerEvent): Array { const getCoalescedEvents = ( event as PointerEvent & { getCoalescedEvents?: () => Array } ).getCoalescedEvents; const coalescedEvents = typeof getCoalescedEvents === 'function' ? getCoalescedEvents.call(event) : []; if (coalescedEvents.length === 0) { return [event]; } const lastEvent = coalescedEvents[coalescedEvents.length - 1]; return isSamePointerSample(lastEvent, event) ? coalescedEvents : [...coalescedEvents, event]; } private getMirroredSegments(from: vec2, to: vec2): Array { return getMirroredStrokeSegments( from, to, vec2.fromValues(this.canvas.width, this.canvas.height), this.options.getMirrorSegmentCount() ); } } const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean => left.clientX === right.clientX && left.clientY === right.clientY && left.buttons === right.buttons;