| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791 |
- export namespace MiniRender {
-
- export interface RendeCanvasInterface {
- initCanvas(width: number, height: number): Promise<void>;
- createImage(src: string): Promise<any>;
- 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<RenderObjectEventName, Set<RenderObjectEventHandler>>();
- 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<RenderObjectEventHandler>();
- 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<TextStyle> = {
- 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<string, AnimateSpriteAnimation>;
- playOnce?: boolean;
- currentAnimation: string;
- }
- export class AnimateSprite extends RenderObject {
- public framerate = 7;
- public images: string[] = [];
- public frames: AnimateSpriteFrame[] = [];
- public animations: Record<string, AnimateSpriteAnimation> = {};
- public playOnce = false;
- public currentAnimation = "";
- public playing = true;
- private _frameCursor = 0;
- private _accMs = 0;
- private _resolvedImages: Array<any | null> = [];
- constructor(config?: Partial<AnimateSpriteConfig>) {
- 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<string, Promise<any>>();
- private imageResolved = new Map<string, any>();
- constructor(private canvas: RendeCanvasInterface) {}
- public getImage(src: string): Promise<any> {
- 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<string, CanvasAdapterFactory>();
- 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 };
- }
- }
- }
|