export namespace MiniRender { export interface RendeCanvasInterface { initCanvas(width: number, height: number): Promise; createImage(src: string): Promise; clearCanvas(): void; requestAnimationFrame(draw: (ts: number) => void): number; cancelAnimationFrame(id: number): void; getCtx(): CanvasRenderingContext2D; } export type RenderObjectId = string; export interface Disposable { dispose(): void; } export interface RenderContext { readonly scene: Scene; readonly canvas: RendeCanvasInterface; readonly ctx: CanvasRenderingContext2D; } export interface IRenderable { render(rc: RenderContext): void; } export interface IUpdatable { update(dtMs: number): void; } export interface TransformLike { x: number; y: number; width: number; height: number; rotation: number; // radians alpha: number; // 0..1 scaleX: number; scaleY: number; anchorX: number; // 0..1 (relative to width) anchorY: number; // 0..1 (relative to height) } export type RenderObjectEventName = "added" | "removed" | "click" | "touchstart" | "touchmove" | "touchend" | "touchcancel"; export type RenderObjectEventHandler = (obj: RenderObject, data?: any) => void; export class RenderObject implements TransformLike, IRenderable, IUpdatable { public readonly id: RenderObjectId; public name?: string; public x = 0; public y = 0; public width = 0; public height = 0; public rotation = 0; public alpha = 1; public scaleX = 1; public scaleY = 1; public anchorX = 0; public anchorY = 0; public visible = true; public zIndex = 0; public interactive = false; public parent: Container | null = null; public data: any = null; private listeners = new Map>(); constructor(id?: RenderObjectId) { this.id = id ?? RenderObject.newId(); } protected static newId(): RenderObjectId { return `ro_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } public on(event: RenderObjectEventName, handler: RenderObjectEventHandler): () => void { const set = this.listeners.get(event) ?? new Set(); set.add(handler); this.listeners.set(event, set); return () => this.off(event, handler); } public off(event: RenderObjectEventName, handler: RenderObjectEventHandler): void { this.listeners.get(event)?.delete(handler); } public emit(event: RenderObjectEventName, data?: any): void { const set = this.listeners.get(event); if (!set) return; for (const h of set) h(this, data); } public hasListener(event: RenderObjectEventName): boolean { const set = this.listeners.get(event); return !!set && set.size > 0; } protected withTransform(rc: RenderContext, draw: () => void): void { const { ctx } = rc; ctx.save(); const ax = this.anchorX * this.width; const ay = this.anchorY * this.height; ctx.translate(this.x + ax, this.y + ay); if (this.rotation) ctx.rotate(this.rotation); if (this.scaleX !== 1 || this.scaleY !== 1) ctx.scale(this.scaleX, this.scaleY); const prevAlpha = ctx.globalAlpha; ctx.globalAlpha = (prevAlpha ?? 1) * this.alpha; ctx.translate(-ax, -ay); draw(); ctx.restore(); } protected draw(_rc: RenderContext): void {} public render(rc: RenderContext): void { if (!this.visible || this.alpha <= 0) return; this.withTransform(rc, () => this.draw(rc)); } public update(_dtMs: number): void {} public parentToLocal(px: number, py: number): { x: number; y: number } | null { const ax = this.anchorX * this.width; const ay = this.anchorY * this.height; let lx = px - (this.x + ax); let ly = py - (this.y + ay); if (this.rotation) { const cos = Math.cos(-this.rotation); const sin = Math.sin(-this.rotation); const rx = lx * cos - ly * sin; const ry = lx * sin + ly * cos; lx = rx; ly = ry; } if (this.scaleX === 0 || this.scaleY === 0) return null; lx /= this.scaleX; ly /= this.scaleY; lx += ax; ly += ay; return { x: lx, y: ly }; } public hitTest(px: number, py: number): RenderObject | null { if (!this.visible || this.alpha <= 0) return null; if (!this.interactive) return null; const local = this.parentToLocal(px, py); if (!local) return null; if (local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height) { return this; } return null; } } export class Container extends RenderObject { private _children: RenderObject[] = []; public get children(): readonly RenderObject[] { return this._children; } public add(child: RenderObject): this { if (child.parent) child.parent.remove(child); child.parent = this; this._children.push(child); this.sortChildren(); child.emit("added"); return this; } public remove(child: RenderObject): this { const idx = this._children.indexOf(child); if (idx < 0) return this; this._children.splice(idx, 1); child.parent = null; child.emit("removed"); return this; } public removeAll(): this { for (const c of this._children) { c.parent = null; c.emit("removed"); } this._children = []; return this; } public sortChildren(): void { this._children.sort((a, b) => a.zIndex - b.zIndex); } public override update(dtMs: number): void { for (const c of this._children) { (c as unknown as IUpdatable).update?.(dtMs); } } protected override draw(rc: RenderContext): void { for (const c of this._children) c.render(rc); } public override hitTest(px: number, py: number): RenderObject | null { if (!this.visible || this.alpha <= 0) return null; const local = this.parentToLocal(px, py); if (!local) return null; for (let i = this._children.length - 1; i >= 0; i--) { const hit = this._children[i].hitTest(local.x, local.y); if (hit) return hit; } if (this.interactive && this.hasListener("click")) { if (local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height) { return this; } } return null; } } export class Rect extends RenderObject { public fillStyle: string | null = "#000000"; public strokeStyle: string | null = null; public lineWidth = 1; public radius = 0; protected override draw(rc: RenderContext): void { const { ctx } = rc; const w = this.width; const h = this.height; if (w <= 0 || h <= 0) return; if (this.radius > 0) { const r = Math.max(0, Math.min(this.radius, Math.min(w, h) / 2)); ctx.beginPath(); ctx.moveTo(r, 0); ctx.lineTo(w - r, 0); ctx.arcTo(w, 0, w, r, r); ctx.lineTo(w, h - r); ctx.arcTo(w, h, w - r, h, r); ctx.lineTo(r, h); ctx.arcTo(0, h, 0, h - r, r); ctx.lineTo(0, r); ctx.arcTo(0, 0, r, 0, r); ctx.closePath(); if (this.fillStyle) { ctx.fillStyle = this.fillStyle; ctx.fill(); } if (this.strokeStyle) { ctx.strokeStyle = this.strokeStyle; ctx.lineWidth = this.lineWidth; ctx.stroke(); } return; } if (this.fillStyle) { ctx.fillStyle = this.fillStyle; ctx.fillRect(0, 0, w, h); } if (this.strokeStyle) { ctx.strokeStyle = this.strokeStyle; ctx.lineWidth = this.lineWidth; ctx.strokeRect(0, 0, w, h); } } } export class Sprite extends RenderObject { public src: string = ""; public image: any | null = null; protected override draw(rc: RenderContext): void { const { ctx, scene } = rc; if (!this.src) return; const w = this.width; const h = this.height; if (w <= 0 || h <= 0) return; const img = this.image ?? scene.assets.getImageSync(this.src); if (!img) { scene.assets .getImage(this.src) .then((loaded) => { this.image = loaded; }) .catch(() => {}); return; } ctx.drawImage(img, 0, 0, w, h); } } export type TextAlign = CanvasTextAlign; export type TextBaseline = CanvasTextBaseline; export interface TextStyle { fontSize?: number; fontFamily?: string; fontWeight?: string | number; fontStyle?: string; color?: string; strokeColor?: string | null; strokeWidth?: number; align?: TextAlign; baseline?: TextBaseline; lineHeight?: number; /** * - "nowrap": 单行(遇到 \n 也会分行) * - "wrap": 自动按 maxWidth/width 换行 */ wrap?: "nowrap" | "wrap"; /** 指定自动换行宽度;若不设置则使用 `width` */ maxWidth?: number; } export class Text extends RenderObject { public text = ""; public style: Required = { fontSize: 16, fontFamily: "sans-serif", fontWeight: "normal", fontStyle: "normal", color: "#000000", strokeColor: null, strokeWidth: 1, align: "left", baseline: "top", lineHeight: 20, wrap: "nowrap", maxWidth: 0, }; constructor(text?: string, style?: TextStyle) { super(); if (typeof text === "string") this.text = text; if (style) this.setStyle(style); } public setStyle(style: TextStyle): void { this.style = { ...this.style, ...style }; if (!style.lineHeight && style.fontSize) { // 常见经验值 this.style.lineHeight = Math.round(style.fontSize * 1.25); } } private applyTextStyle(ctx: CanvasRenderingContext2D): void { const s = this.style; ctx.font = `${s.fontStyle} ${s.fontWeight} ${s.fontSize}px ${s.fontFamily}`.trim(); ctx.fillStyle = s.color; ctx.textAlign = s.align; ctx.textBaseline = s.baseline; if (s.strokeColor) { ctx.strokeStyle = s.strokeColor; ctx.lineWidth = s.strokeWidth; } } private splitLines(ctx: CanvasRenderingContext2D): string[] { const raw = (this.text ?? "").toString(); const baseLines = raw.split(/\r?\n/); if (this.style.wrap !== "wrap") return baseLines; const max = (this.style.maxWidth > 0 ? this.style.maxWidth : 0) || (this.width > 0 ? this.width : 0); if (max <= 0) return baseLines; const out: string[] = []; for (const line of baseLines) { if (!line) { out.push(""); continue; } let current = ""; for (const ch of [...line]) { const test = current + ch; const w = ctx.measureText(test).width; if (w > max && current) { out.push(current); current = ch; } else { current = test; } } out.push(current); } return out; } private computeAutoSize(ctx: CanvasRenderingContext2D, lines: string[]): void { // 未指定 width 时,使用最长行宽;指定了 maxWidth 时以 maxWidth 为上限 if (this.width <= 0) { let maxLine = 0; for (const l of lines) maxLine = Math.max(maxLine, ctx.measureText(l).width); const limit = this.style.maxWidth > 0 ? this.style.maxWidth : 0; this.width = limit > 0 ? Math.min(limit, maxLine) : maxLine; } if (this.height <= 0) { this.height = lines.length * this.style.lineHeight; } } protected override draw(rc: RenderContext): void { const { ctx } = rc; if (!this.text) return; this.applyTextStyle(ctx); const lines = this.splitLines(ctx); this.computeAutoSize(ctx, lines); const s = this.style; // 对齐偏移:以对象局部坐标系 (0,0) 为绘制起点 const xBase = s.align === "center" ? this.width / 2 : s.align === "right" || s.align === "end" ? this.width : 0; let y = 0; for (const line of lines) { if (s.strokeColor) ctx.strokeText(line, xBase, y, s.maxWidth > 0 ? s.maxWidth : undefined); ctx.fillText(line, xBase, y, s.maxWidth > 0 ? s.maxWidth : undefined); y += s.lineHeight; } } } export type AnimateSpriteFrame = [ x: number, y: number, width: number, height: number, originX?: number, originY?: number, imageIndex?: number, ]; export interface AnimateSpriteAnimation { /** 指向 `frames` 的索引数组 */ frames: number[]; /** 播放速度倍率(1 = 使用 framerate) */ speed?: number; /** 是否循环覆盖全局 playOnce */ loop?: boolean; } export interface AnimateSpriteConfig { framerate: number; images: string[]; frames: AnimateSpriteFrame[]; animations: Record; playOnce?: boolean; currentAnimation: string; } export class AnimateSprite extends RenderObject { public framerate = 7; public images: string[] = []; public frames: AnimateSpriteFrame[] = []; public animations: Record = {}; public playOnce = false; public currentAnimation = ""; public playing = true; private _frameCursor = 0; private _accMs = 0; private _resolvedImages: Array = []; constructor(config?: Partial) { super(); if (config) Object.assign(this, config); this._resolvedImages = new Array(this.images.length).fill(null); if (this.currentAnimation && this.animations && !this.animations?.[this.currentAnimation]) { // 若未配置 animations,则允许把 currentAnimation 当成 “默认帧序列” 的别名 this.animations[this.currentAnimation] = { frames: [0] }; } } public play(name?: string, opts?: { reset?: boolean; once?: boolean }): void { if (name) this.currentAnimation = name; if (opts?.reset ?? true) { this._frameCursor = 0; this._accMs = 0; } if (typeof opts?.once === "boolean") this.playOnce = opts.once; this.playing = true; } public stop(): void { this.playing = false; } public gotoAndStop(frameCursor: number): void { this._frameCursor = Math.max(0, frameCursor | 0); this._accMs = 0; this.playing = false; } public get currentFrameIndex(): number { const seq = this.getAnimationSequence(); if (seq.length <= 0) return 0; const cursor = Math.max(0, Math.min(this._frameCursor, seq.length - 1)); return seq[cursor] ?? 0; } private getAnimationSequence(): number[] { const anim = this.animations[this.currentAnimation]; if (anim?.frames?.length) return anim.frames; // fallback:没有动画就按全部 frames 依次播放 return this.frames.map((_, i) => i); } private getEffectiveFrameDurationMs(): number { const fps = Math.max(0.0001, this.framerate); const base = 1000 / fps; const anim = this.animations[this.currentAnimation]; const speed = anim?.speed ?? 1; return base / Math.max(0.0001, speed); } public override update(dtMs: number): void { if (!this.playing) return; const seq = this.getAnimationSequence(); if (seq.length <= 1) return; this._accMs += dtMs; const frameDur = this.getEffectiveFrameDurationMs(); if (this._accMs < frameDur) return; const steps = Math.floor(this._accMs / frameDur); this._accMs = this._accMs % frameDur; this._frameCursor += steps; const anim = this.animations[this.currentAnimation]; const loop = anim?.loop ?? !this.playOnce; if (loop) { this._frameCursor = this._frameCursor % seq.length; } else { if (this._frameCursor >= seq.length - 1) { this._frameCursor = seq.length - 1; this.playing = false; } } } protected override draw(rc: RenderContext): void { const { ctx, scene } = rc; if (!this.frames.length) return; const fIdx = this.currentFrameIndex; const frame = this.frames[fIdx]; if (!frame) return; const [sx, sy, sw, sh, originX = 0, originY = 0, imageIndex = 0] = frame; if (sw <= 0 || sh <= 0) return; const src = this.images[imageIndex] ?? this.images[0]; if (!src) return; // 默认使用帧尺寸作为显示尺寸 if (this.width <= 0) this.width = sw; if (this.height <= 0) this.height = sh; let img = this._resolvedImages[imageIndex] ?? null; img = img ?? scene.assets.getImageSync(src); if (!img) { scene.assets .getImage(src) .then((loaded) => { this._resolvedImages[imageIndex] = loaded; }) .catch(() => {}); return; } // drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh) ctx.drawImage(img, sx, sy, sw, sh, -originX, -originY, this.width, this.height); } } export class AssetManager { private imageCache = new Map>(); private imageResolved = new Map(); constructor(private canvas: RendeCanvasInterface) {} public getImage(src: string): Promise { if (this.imageResolved.has(src)) return Promise.resolve(this.imageResolved.get(src)); const inflight = this.imageCache.get(src); if (inflight) return inflight; const p = this.canvas.createImage(src).then((img) => { this.imageResolved.set(src, img); return img; }); this.imageCache.set(src, p); return p; } public getImageSync(src: string): any | null { return this.imageResolved.get(src) ?? null; } public clear(): void { this.imageCache.clear(); this.imageResolved.clear(); } } export type CanvasAdapterFactory = (...args: any[]) => RendeCanvasInterface; export class CanvasAdapterRegistry { private static registry = new Map(); public static register(name: string, factory: CanvasAdapterFactory): void { this.registry.set(name, factory); } public static get(name: string): CanvasAdapterFactory | undefined { return this.registry.get(name); } public static create(name: string, ...args: any[]): RendeCanvasInterface { const f = this.registry.get(name); if (!f) throw new Error(`CanvasAdapter 未注册: ${name}`); return f(...args); } public static has(name: string): boolean { return this.registry.has(name); } public static names(): string[] { return [...this.registry.keys()]; } } export class Scene implements Disposable { public readonly root: Container = new Container("root"); public readonly assets: AssetManager; private rafId: number | null = null; private lastTs: number | null = null; private currentDragObject: RenderObject | null = null; public readonly canvas: RendeCanvasInterface; public readonly width: number; public readonly height: number; public readonly backgroundColor: string; public readonly events = { handleTouchStart: (e: any) => { const pos = this.getEventPosition(e); const hit = this.root.hitTest(pos.x, pos.y); if (hit) { hit.emit("touchstart", e); this.currentDragObject = hit; } }, handleTouchMove: (e: any) => { if (this.currentDragObject) { this.currentDragObject.emit("touchmove", e); } }, handleTouchEnd: (e: any) => { if (this.currentDragObject) { this.currentDragObject.emit("touchend", e); this.currentDragObject.emit("click", e); this.currentDragObject = null; } }, handleTouchCancel: (e: any) => { if (this.currentDragObject) this.currentDragObject.emit("touchcancel", e); this.currentDragObject = null; }, }; constructor( public options: { canvas: RendeCanvasInterface, width: number, height: number, backgroundColor: string, }, public onInit: (this: Scene) => void, public updateFn: (this: Scene, dtMs: number) => void, ) { this.canvas = options.canvas; this.width = options.width; this.height = options.height; this.backgroundColor = options.backgroundColor; this.assets = new AssetManager(this.canvas); } public async init() { console.log(`[Canvas] init canvas ${this.width}x${this.height}`); await this.canvas.initCanvas(this.width, this.height); this.onInit.call(this); } public clear(): void { this.canvas.clearCanvas(); this.canvas.getCtx().fillStyle = this.backgroundColor; this.canvas.getCtx().fillRect(0, 0, this.width, this.height); } public renderOnce(): void { this.clear(); const rc: RenderContext = { scene: this, canvas: this.canvas, ctx: this.canvas.getCtx(), }; this.root.render(rc); } public tick(ts: number): void { const dt = this.lastTs === null ? 16 : Math.max(0, ts - this.lastTs); this.lastTs = ts; this.updateFn.call(this, dt); this.root.update(dt); this.renderOnce(); } public start(): void { if (this.rafId !== null) return; const loop = (ts: number) => { this.tick(ts); this.rafId = this.canvas.requestAnimationFrame(loop); }; this.rafId = this.canvas.requestAnimationFrame(loop); } public stop(): void { if (this.rafId === null) return; this.canvas.cancelAnimationFrame(this.rafId); this.rafId = null; this.lastTs = null; } public dispose(): void { this.stop(); this.assets.clear(); this.root.removeAll(); } public precentXToPixel(precent: number): number { return this.width * precent; } public precentYToPixel(precent: number): number { return this.height * precent; } private getEventPosition(e: any): { x: number; y: number } { if (e?.detail?.x !== undefined) return { x: e.detail.x, y: e.detail.y }; if (e?.offsetX !== undefined) return { x: e.offsetX, y: e.offsetY }; if (e?.touches?.[0]) return { x: e.touches[0].x, y: e.touches[0].y }; return { x: 0, y: 0 }; } } }