MiniRender.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791
  1. export namespace MiniRender {
  2. export interface RendeCanvasInterface {
  3. initCanvas(width: number, height: number): Promise<void>;
  4. createImage(src: string): Promise<any>;
  5. clearCanvas(): void;
  6. requestAnimationFrame(draw: (ts: number) => void): number;
  7. cancelAnimationFrame(id: number): void;
  8. getCtx(): CanvasRenderingContext2D;
  9. }
  10. export type RenderObjectId = string;
  11. export interface Disposable {
  12. dispose(): void;
  13. }
  14. export interface RenderContext {
  15. readonly scene: Scene;
  16. readonly canvas: RendeCanvasInterface;
  17. readonly ctx: CanvasRenderingContext2D;
  18. }
  19. export interface IRenderable {
  20. render(rc: RenderContext): void;
  21. }
  22. export interface IUpdatable {
  23. update(dtMs: number): void;
  24. }
  25. export interface TransformLike {
  26. x: number;
  27. y: number;
  28. width: number;
  29. height: number;
  30. rotation: number; // radians
  31. alpha: number; // 0..1
  32. scaleX: number;
  33. scaleY: number;
  34. anchorX: number; // 0..1 (relative to width)
  35. anchorY: number; // 0..1 (relative to height)
  36. }
  37. export type RenderObjectEventName = "added" | "removed" | "click" | "touchstart" | "touchmove" | "touchend" | "touchcancel";
  38. export type RenderObjectEventHandler = (obj: RenderObject, data?: any) => void;
  39. export class RenderObject implements TransformLike, IRenderable, IUpdatable {
  40. public readonly id: RenderObjectId;
  41. public name?: string;
  42. public x = 0;
  43. public y = 0;
  44. public width = 0;
  45. public height = 0;
  46. public rotation = 0;
  47. public alpha = 1;
  48. public scaleX = 1;
  49. public scaleY = 1;
  50. public anchorX = 0;
  51. public anchorY = 0;
  52. public visible = true;
  53. public zIndex = 0;
  54. public interactive = false;
  55. public parent: Container | null = null;
  56. public data: any = null;
  57. private listeners = new Map<RenderObjectEventName, Set<RenderObjectEventHandler>>();
  58. constructor(id?: RenderObjectId) {
  59. this.id = id ?? RenderObject.newId();
  60. }
  61. protected static newId(): RenderObjectId {
  62. return `ro_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
  63. }
  64. public on(event: RenderObjectEventName, handler: RenderObjectEventHandler): () => void {
  65. const set = this.listeners.get(event) ?? new Set<RenderObjectEventHandler>();
  66. set.add(handler);
  67. this.listeners.set(event, set);
  68. return () => this.off(event, handler);
  69. }
  70. public off(event: RenderObjectEventName, handler: RenderObjectEventHandler): void {
  71. this.listeners.get(event)?.delete(handler);
  72. }
  73. public emit(event: RenderObjectEventName, data?: any): void {
  74. const set = this.listeners.get(event);
  75. if (!set) return;
  76. for (const h of set) h(this, data);
  77. }
  78. public hasListener(event: RenderObjectEventName): boolean {
  79. const set = this.listeners.get(event);
  80. return !!set && set.size > 0;
  81. }
  82. protected withTransform(rc: RenderContext, draw: () => void): void {
  83. const { ctx } = rc;
  84. ctx.save();
  85. const ax = this.anchorX * this.width;
  86. const ay = this.anchorY * this.height;
  87. ctx.translate(this.x + ax, this.y + ay);
  88. if (this.rotation) ctx.rotate(this.rotation);
  89. if (this.scaleX !== 1 || this.scaleY !== 1) ctx.scale(this.scaleX, this.scaleY);
  90. const prevAlpha = ctx.globalAlpha;
  91. ctx.globalAlpha = (prevAlpha ?? 1) * this.alpha;
  92. ctx.translate(-ax, -ay);
  93. draw();
  94. ctx.restore();
  95. }
  96. protected draw(_rc: RenderContext): void {}
  97. public render(rc: RenderContext): void {
  98. if (!this.visible || this.alpha <= 0) return;
  99. this.withTransform(rc, () => this.draw(rc));
  100. }
  101. public update(_dtMs: number): void {}
  102. public parentToLocal(px: number, py: number): { x: number; y: number } | null {
  103. const ax = this.anchorX * this.width;
  104. const ay = this.anchorY * this.height;
  105. let lx = px - (this.x + ax);
  106. let ly = py - (this.y + ay);
  107. if (this.rotation) {
  108. const cos = Math.cos(-this.rotation);
  109. const sin = Math.sin(-this.rotation);
  110. const rx = lx * cos - ly * sin;
  111. const ry = lx * sin + ly * cos;
  112. lx = rx;
  113. ly = ry;
  114. }
  115. if (this.scaleX === 0 || this.scaleY === 0) return null;
  116. lx /= this.scaleX;
  117. ly /= this.scaleY;
  118. lx += ax;
  119. ly += ay;
  120. return { x: lx, y: ly };
  121. }
  122. public hitTest(px: number, py: number): RenderObject | null {
  123. if (!this.visible || this.alpha <= 0) return null;
  124. if (!this.interactive) return null;
  125. const local = this.parentToLocal(px, py);
  126. if (!local) return null;
  127. if (local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height) {
  128. return this;
  129. }
  130. return null;
  131. }
  132. }
  133. export class Container extends RenderObject {
  134. private _children: RenderObject[] = [];
  135. public get children(): readonly RenderObject[] {
  136. return this._children;
  137. }
  138. public add(child: RenderObject): this {
  139. if (child.parent) child.parent.remove(child);
  140. child.parent = this;
  141. this._children.push(child);
  142. this.sortChildren();
  143. child.emit("added");
  144. return this;
  145. }
  146. public remove(child: RenderObject): this {
  147. const idx = this._children.indexOf(child);
  148. if (idx < 0) return this;
  149. this._children.splice(idx, 1);
  150. child.parent = null;
  151. child.emit("removed");
  152. return this;
  153. }
  154. public removeAll(): this {
  155. for (const c of this._children) {
  156. c.parent = null;
  157. c.emit("removed");
  158. }
  159. this._children = [];
  160. return this;
  161. }
  162. public sortChildren(): void {
  163. this._children.sort((a, b) => a.zIndex - b.zIndex);
  164. }
  165. public override update(dtMs: number): void {
  166. for (const c of this._children) {
  167. (c as unknown as IUpdatable).update?.(dtMs);
  168. }
  169. }
  170. protected override draw(rc: RenderContext): void {
  171. for (const c of this._children) c.render(rc);
  172. }
  173. public override hitTest(px: number, py: number): RenderObject | null {
  174. if (!this.visible || this.alpha <= 0) return null;
  175. const local = this.parentToLocal(px, py);
  176. if (!local) return null;
  177. for (let i = this._children.length - 1; i >= 0; i--) {
  178. const hit = this._children[i].hitTest(local.x, local.y);
  179. if (hit) return hit;
  180. }
  181. if (this.interactive && this.hasListener("click")) {
  182. if (local.x >= 0 && local.x <= this.width && local.y >= 0 && local.y <= this.height) {
  183. return this;
  184. }
  185. }
  186. return null;
  187. }
  188. }
  189. export class Rect extends RenderObject {
  190. public fillStyle: string | null = "#000000";
  191. public strokeStyle: string | null = null;
  192. public lineWidth = 1;
  193. public radius = 0;
  194. protected override draw(rc: RenderContext): void {
  195. const { ctx } = rc;
  196. const w = this.width;
  197. const h = this.height;
  198. if (w <= 0 || h <= 0) return;
  199. if (this.radius > 0) {
  200. const r = Math.max(0, Math.min(this.radius, Math.min(w, h) / 2));
  201. ctx.beginPath();
  202. ctx.moveTo(r, 0);
  203. ctx.lineTo(w - r, 0);
  204. ctx.arcTo(w, 0, w, r, r);
  205. ctx.lineTo(w, h - r);
  206. ctx.arcTo(w, h, w - r, h, r);
  207. ctx.lineTo(r, h);
  208. ctx.arcTo(0, h, 0, h - r, r);
  209. ctx.lineTo(0, r);
  210. ctx.arcTo(0, 0, r, 0, r);
  211. ctx.closePath();
  212. if (this.fillStyle) {
  213. ctx.fillStyle = this.fillStyle;
  214. ctx.fill();
  215. }
  216. if (this.strokeStyle) {
  217. ctx.strokeStyle = this.strokeStyle;
  218. ctx.lineWidth = this.lineWidth;
  219. ctx.stroke();
  220. }
  221. return;
  222. }
  223. if (this.fillStyle) {
  224. ctx.fillStyle = this.fillStyle;
  225. ctx.fillRect(0, 0, w, h);
  226. }
  227. if (this.strokeStyle) {
  228. ctx.strokeStyle = this.strokeStyle;
  229. ctx.lineWidth = this.lineWidth;
  230. ctx.strokeRect(0, 0, w, h);
  231. }
  232. }
  233. }
  234. export class Sprite extends RenderObject {
  235. public src: string = "";
  236. public image: any | null = null;
  237. protected override draw(rc: RenderContext): void {
  238. const { ctx, scene } = rc;
  239. if (!this.src) return;
  240. const w = this.width;
  241. const h = this.height;
  242. if (w <= 0 || h <= 0) return;
  243. const img = this.image ?? scene.assets.getImageSync(this.src);
  244. if (!img) {
  245. scene.assets
  246. .getImage(this.src)
  247. .then((loaded) => {
  248. this.image = loaded;
  249. })
  250. .catch(() => {});
  251. return;
  252. }
  253. ctx.drawImage(img, 0, 0, w, h);
  254. }
  255. }
  256. export type TextAlign = CanvasTextAlign;
  257. export type TextBaseline = CanvasTextBaseline;
  258. export interface TextStyle {
  259. fontSize?: number;
  260. fontFamily?: string;
  261. fontWeight?: string | number;
  262. fontStyle?: string;
  263. color?: string;
  264. strokeColor?: string | null;
  265. strokeWidth?: number;
  266. align?: TextAlign;
  267. baseline?: TextBaseline;
  268. lineHeight?: number;
  269. /**
  270. * - "nowrap": 单行(遇到 \n 也会分行)
  271. * - "wrap": 自动按 maxWidth/width 换行
  272. */
  273. wrap?: "nowrap" | "wrap";
  274. /** 指定自动换行宽度;若不设置则使用 `width` */
  275. maxWidth?: number;
  276. }
  277. export class Text extends RenderObject {
  278. public text = "";
  279. public style: Required<TextStyle> = {
  280. fontSize: 16,
  281. fontFamily: "sans-serif",
  282. fontWeight: "normal",
  283. fontStyle: "normal",
  284. color: "#000000",
  285. strokeColor: null,
  286. strokeWidth: 1,
  287. align: "left",
  288. baseline: "top",
  289. lineHeight: 20,
  290. wrap: "nowrap",
  291. maxWidth: 0,
  292. };
  293. constructor(text?: string, style?: TextStyle) {
  294. super();
  295. if (typeof text === "string") this.text = text;
  296. if (style) this.setStyle(style);
  297. }
  298. public setStyle(style: TextStyle): void {
  299. this.style = { ...this.style, ...style };
  300. if (!style.lineHeight && style.fontSize) {
  301. // 常见经验值
  302. this.style.lineHeight = Math.round(style.fontSize * 1.25);
  303. }
  304. }
  305. private applyTextStyle(ctx: CanvasRenderingContext2D): void {
  306. const s = this.style;
  307. ctx.font = `${s.fontStyle} ${s.fontWeight} ${s.fontSize}px ${s.fontFamily}`.trim();
  308. ctx.fillStyle = s.color;
  309. ctx.textAlign = s.align;
  310. ctx.textBaseline = s.baseline;
  311. if (s.strokeColor) {
  312. ctx.strokeStyle = s.strokeColor;
  313. ctx.lineWidth = s.strokeWidth;
  314. }
  315. }
  316. private splitLines(ctx: CanvasRenderingContext2D): string[] {
  317. const raw = (this.text ?? "").toString();
  318. const baseLines = raw.split(/\r?\n/);
  319. if (this.style.wrap !== "wrap") return baseLines;
  320. const max =
  321. (this.style.maxWidth > 0 ? this.style.maxWidth : 0) ||
  322. (this.width > 0 ? this.width : 0);
  323. if (max <= 0) return baseLines;
  324. const out: string[] = [];
  325. for (const line of baseLines) {
  326. if (!line) {
  327. out.push("");
  328. continue;
  329. }
  330. let current = "";
  331. for (const ch of [...line]) {
  332. const test = current + ch;
  333. const w = ctx.measureText(test).width;
  334. if (w > max && current) {
  335. out.push(current);
  336. current = ch;
  337. } else {
  338. current = test;
  339. }
  340. }
  341. out.push(current);
  342. }
  343. return out;
  344. }
  345. private computeAutoSize(ctx: CanvasRenderingContext2D, lines: string[]): void {
  346. // 未指定 width 时,使用最长行宽;指定了 maxWidth 时以 maxWidth 为上限
  347. if (this.width <= 0) {
  348. let maxLine = 0;
  349. for (const l of lines) maxLine = Math.max(maxLine, ctx.measureText(l).width);
  350. const limit = this.style.maxWidth > 0 ? this.style.maxWidth : 0;
  351. this.width = limit > 0 ? Math.min(limit, maxLine) : maxLine;
  352. }
  353. if (this.height <= 0) {
  354. this.height = lines.length * this.style.lineHeight;
  355. }
  356. }
  357. protected override draw(rc: RenderContext): void {
  358. const { ctx } = rc;
  359. if (!this.text) return;
  360. this.applyTextStyle(ctx);
  361. const lines = this.splitLines(ctx);
  362. this.computeAutoSize(ctx, lines);
  363. const s = this.style;
  364. // 对齐偏移:以对象局部坐标系 (0,0) 为绘制起点
  365. const xBase =
  366. s.align === "center" ? this.width / 2 : s.align === "right" || s.align === "end" ? this.width : 0;
  367. let y = 0;
  368. for (const line of lines) {
  369. if (s.strokeColor) ctx.strokeText(line, xBase, y, s.maxWidth > 0 ? s.maxWidth : undefined);
  370. ctx.fillText(line, xBase, y, s.maxWidth > 0 ? s.maxWidth : undefined);
  371. y += s.lineHeight;
  372. }
  373. }
  374. }
  375. export type AnimateSpriteFrame = [
  376. x: number,
  377. y: number,
  378. width: number,
  379. height: number,
  380. originX?: number,
  381. originY?: number,
  382. imageIndex?: number,
  383. ];
  384. export interface AnimateSpriteAnimation {
  385. /** 指向 `frames` 的索引数组 */
  386. frames: number[];
  387. /** 播放速度倍率(1 = 使用 framerate) */
  388. speed?: number;
  389. /** 是否循环覆盖全局 playOnce */
  390. loop?: boolean;
  391. }
  392. export interface AnimateSpriteConfig {
  393. framerate: number;
  394. images: string[];
  395. frames: AnimateSpriteFrame[];
  396. animations: Record<string, AnimateSpriteAnimation>;
  397. playOnce?: boolean;
  398. currentAnimation: string;
  399. }
  400. export class AnimateSprite extends RenderObject {
  401. public framerate = 7;
  402. public images: string[] = [];
  403. public frames: AnimateSpriteFrame[] = [];
  404. public animations: Record<string, AnimateSpriteAnimation> = {};
  405. public playOnce = false;
  406. public currentAnimation = "";
  407. public playing = true;
  408. private _frameCursor = 0;
  409. private _accMs = 0;
  410. private _resolvedImages: Array<any | null> = [];
  411. constructor(config?: Partial<AnimateSpriteConfig>) {
  412. super();
  413. if (config) Object.assign(this, config);
  414. this._resolvedImages = new Array(this.images.length).fill(null);
  415. if (this.currentAnimation && this.animations && !this.animations?.[this.currentAnimation]) {
  416. // 若未配置 animations,则允许把 currentAnimation 当成 “默认帧序列” 的别名
  417. this.animations[this.currentAnimation] = { frames: [0] };
  418. }
  419. }
  420. public play(name?: string, opts?: { reset?: boolean; once?: boolean }): void {
  421. if (name) this.currentAnimation = name;
  422. if (opts?.reset ?? true) {
  423. this._frameCursor = 0;
  424. this._accMs = 0;
  425. }
  426. if (typeof opts?.once === "boolean") this.playOnce = opts.once;
  427. this.playing = true;
  428. }
  429. public stop(): void {
  430. this.playing = false;
  431. }
  432. public gotoAndStop(frameCursor: number): void {
  433. this._frameCursor = Math.max(0, frameCursor | 0);
  434. this._accMs = 0;
  435. this.playing = false;
  436. }
  437. public get currentFrameIndex(): number {
  438. const seq = this.getAnimationSequence();
  439. if (seq.length <= 0) return 0;
  440. const cursor = Math.max(0, Math.min(this._frameCursor, seq.length - 1));
  441. return seq[cursor] ?? 0;
  442. }
  443. private getAnimationSequence(): number[] {
  444. const anim = this.animations[this.currentAnimation];
  445. if (anim?.frames?.length) return anim.frames;
  446. // fallback:没有动画就按全部 frames 依次播放
  447. return this.frames.map((_, i) => i);
  448. }
  449. private getEffectiveFrameDurationMs(): number {
  450. const fps = Math.max(0.0001, this.framerate);
  451. const base = 1000 / fps;
  452. const anim = this.animations[this.currentAnimation];
  453. const speed = anim?.speed ?? 1;
  454. return base / Math.max(0.0001, speed);
  455. }
  456. public override update(dtMs: number): void {
  457. if (!this.playing) return;
  458. const seq = this.getAnimationSequence();
  459. if (seq.length <= 1) return;
  460. this._accMs += dtMs;
  461. const frameDur = this.getEffectiveFrameDurationMs();
  462. if (this._accMs < frameDur) return;
  463. const steps = Math.floor(this._accMs / frameDur);
  464. this._accMs = this._accMs % frameDur;
  465. this._frameCursor += steps;
  466. const anim = this.animations[this.currentAnimation];
  467. const loop = anim?.loop ?? !this.playOnce;
  468. if (loop) {
  469. this._frameCursor = this._frameCursor % seq.length;
  470. } else {
  471. if (this._frameCursor >= seq.length - 1) {
  472. this._frameCursor = seq.length - 1;
  473. this.playing = false;
  474. }
  475. }
  476. }
  477. protected override draw(rc: RenderContext): void {
  478. const { ctx, scene } = rc;
  479. if (!this.frames.length) return;
  480. const fIdx = this.currentFrameIndex;
  481. const frame = this.frames[fIdx];
  482. if (!frame) return;
  483. const [sx, sy, sw, sh, originX = 0, originY = 0, imageIndex = 0] = frame;
  484. if (sw <= 0 || sh <= 0) return;
  485. const src = this.images[imageIndex] ?? this.images[0];
  486. if (!src) return;
  487. // 默认使用帧尺寸作为显示尺寸
  488. if (this.width <= 0) this.width = sw;
  489. if (this.height <= 0) this.height = sh;
  490. let img = this._resolvedImages[imageIndex] ?? null;
  491. img = img ?? scene.assets.getImageSync(src);
  492. if (!img) {
  493. scene.assets
  494. .getImage(src)
  495. .then((loaded) => {
  496. this._resolvedImages[imageIndex] = loaded;
  497. })
  498. .catch(() => {});
  499. return;
  500. }
  501. // drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
  502. ctx.drawImage(img, sx, sy, sw, sh, -originX, -originY, this.width, this.height);
  503. }
  504. }
  505. export class AssetManager {
  506. private imageCache = new Map<string, Promise<any>>();
  507. private imageResolved = new Map<string, any>();
  508. constructor(private canvas: RendeCanvasInterface) {}
  509. public getImage(src: string): Promise<any> {
  510. if (this.imageResolved.has(src)) return Promise.resolve(this.imageResolved.get(src));
  511. const inflight = this.imageCache.get(src);
  512. if (inflight) return inflight;
  513. const p = this.canvas.createImage(src).then((img) => {
  514. this.imageResolved.set(src, img);
  515. return img;
  516. });
  517. this.imageCache.set(src, p);
  518. return p;
  519. }
  520. public getImageSync(src: string): any | null {
  521. return this.imageResolved.get(src) ?? null;
  522. }
  523. public clear(): void {
  524. this.imageCache.clear();
  525. this.imageResolved.clear();
  526. }
  527. }
  528. export type CanvasAdapterFactory = (...args: any[]) => RendeCanvasInterface;
  529. export class CanvasAdapterRegistry {
  530. private static registry = new Map<string, CanvasAdapterFactory>();
  531. public static register(name: string, factory: CanvasAdapterFactory): void {
  532. this.registry.set(name, factory);
  533. }
  534. public static get(name: string): CanvasAdapterFactory | undefined {
  535. return this.registry.get(name);
  536. }
  537. public static create(name: string, ...args: any[]): RendeCanvasInterface {
  538. const f = this.registry.get(name);
  539. if (!f) throw new Error(`CanvasAdapter 未注册: ${name}`);
  540. return f(...args);
  541. }
  542. public static has(name: string): boolean {
  543. return this.registry.has(name);
  544. }
  545. public static names(): string[] {
  546. return [...this.registry.keys()];
  547. }
  548. }
  549. export class Scene implements Disposable {
  550. public readonly root: Container = new Container("root");
  551. public readonly assets: AssetManager;
  552. private rafId: number | null = null;
  553. private lastTs: number | null = null;
  554. private currentDragObject: RenderObject | null = null;
  555. public readonly canvas: RendeCanvasInterface;
  556. public readonly width: number;
  557. public readonly height: number;
  558. public readonly backgroundColor: string;
  559. public readonly events = {
  560. handleTouchStart: (e: any) => {
  561. const pos = this.getEventPosition(e);
  562. const hit = this.root.hitTest(pos.x, pos.y);
  563. if (hit) {
  564. hit.emit("touchstart", e);
  565. this.currentDragObject = hit;
  566. }
  567. },
  568. handleTouchMove: (e: any) => {
  569. if (this.currentDragObject) {
  570. this.currentDragObject.emit("touchmove", e);
  571. }
  572. },
  573. handleTouchEnd: (e: any) => {
  574. if (this.currentDragObject) {
  575. this.currentDragObject.emit("touchend", e);
  576. this.currentDragObject.emit("click", e);
  577. this.currentDragObject = null;
  578. }
  579. },
  580. handleTouchCancel: (e: any) => {
  581. if (this.currentDragObject)
  582. this.currentDragObject.emit("touchcancel", e);
  583. this.currentDragObject = null;
  584. },
  585. };
  586. constructor(
  587. public options: {
  588. canvas: RendeCanvasInterface,
  589. width: number,
  590. height: number,
  591. backgroundColor: string,
  592. },
  593. public onInit: (this: Scene) => void,
  594. public updateFn: (this: Scene, dtMs: number) => void,
  595. ) {
  596. this.canvas = options.canvas;
  597. this.width = options.width;
  598. this.height = options.height;
  599. this.backgroundColor = options.backgroundColor;
  600. this.assets = new AssetManager(this.canvas);
  601. }
  602. public async init() {
  603. console.log(`[Canvas] init canvas ${this.width}x${this.height}`);
  604. await this.canvas.initCanvas(this.width, this.height);
  605. this.onInit.call(this);
  606. }
  607. public clear(): void {
  608. this.canvas.clearCanvas();
  609. this.canvas.getCtx().fillStyle = this.backgroundColor;
  610. this.canvas.getCtx().fillRect(0, 0, this.width, this.height);
  611. }
  612. public renderOnce(): void {
  613. this.clear();
  614. const rc: RenderContext = {
  615. scene: this,
  616. canvas: this.canvas,
  617. ctx: this.canvas.getCtx(),
  618. };
  619. this.root.render(rc);
  620. }
  621. public tick(ts: number): void {
  622. const dt = this.lastTs === null ? 16 : Math.max(0, ts - this.lastTs);
  623. this.lastTs = ts;
  624. this.updateFn.call(this, dt);
  625. this.root.update(dt);
  626. this.renderOnce();
  627. }
  628. public start(): void {
  629. if (this.rafId !== null) return;
  630. const loop = (ts: number) => {
  631. this.tick(ts);
  632. this.rafId = this.canvas.requestAnimationFrame(loop);
  633. };
  634. this.rafId = this.canvas.requestAnimationFrame(loop);
  635. }
  636. public stop(): void {
  637. if (this.rafId === null) return;
  638. this.canvas.cancelAnimationFrame(this.rafId);
  639. this.rafId = null;
  640. this.lastTs = null;
  641. }
  642. public dispose(): void {
  643. this.stop();
  644. this.assets.clear();
  645. this.root.removeAll();
  646. }
  647. public precentXToPixel(precent: number): number {
  648. return this.width * precent;
  649. }
  650. public precentYToPixel(precent: number): number {
  651. return this.height * precent;
  652. }
  653. private getEventPosition(e: any): { x: number; y: number } {
  654. if (e?.detail?.x !== undefined) return { x: e.detail.x, y: e.detail.y };
  655. if (e?.offsetX !== undefined) return { x: e.offsetX, y: e.offsetY };
  656. if (e?.touches?.[0]) return { x: e.touches[0].x, y: e.touches[0].y };
  657. return { x: 0, y: 0 };
  658. }
  659. }
  660. }