|
|
@@ -0,0 +1,782 @@
|
|
|
+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;
|
|
|
+
|
|
|
+ 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.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: () => void,
|
|
|
+ public updateFn: (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();
|
|
|
+ }
|
|
|
+
|
|
|
+ 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(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();
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 };
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|