|
1 | 1 | import { beforeAll, describe, expect, it, vi } from "vitest"; |
2 | | -import { boot, game, ImageLayer, Rect, video } from "../src/index.js"; |
| 2 | +import { boot, game, ImageLayer, Matrix2d, Rect, video } from "../src/index.js"; |
3 | 3 |
|
4 | 4 | describe("ImageLayer", () => { |
5 | 5 | let testImage; |
@@ -314,4 +314,253 @@ describe("ImageLayer", () => { |
314 | 314 | expect(setMaskIdx).toBeGreaterThan(zoomScaleIdx); |
315 | 315 | }); |
316 | 316 | }); |
| 317 | + |
| 318 | + describe("draw screen-space transform math (replays renderer calls)", () => { |
| 319 | + // Replays the recorded translate/scale calls into a real Matrix2d, |
| 320 | + // then applies the matrix to the corners of `drawPattern(0,0,W*2,H*2)` |
| 321 | + // to compute their final screen-space positions. This tests the actual |
| 322 | + // composed transform — not just that individual calls happened. |
| 323 | + function replayInto(matrix, events) { |
| 324 | + for (const e of events) { |
| 325 | + if (e.kind === "translate") { |
| 326 | + matrix.translate(e.args[0], e.args[1]); |
| 327 | + } else if (e.kind === "scale") { |
| 328 | + matrix.scale(e.args[0], e.args[1]); |
| 329 | + } |
| 330 | + } |
| 331 | + return matrix; |
| 332 | + } |
| 333 | + |
| 334 | + function makeRendererStub(events) { |
| 335 | + return { |
| 336 | + save: vi.fn(), |
| 337 | + translate: vi.fn((x, y) => { |
| 338 | + events.push({ kind: "translate", args: [x, y] }); |
| 339 | + }), |
| 340 | + scale: vi.fn((x, y) => { |
| 341 | + events.push({ kind: "scale", args: [x, y] }); |
| 342 | + }), |
| 343 | + setMask: vi.fn(), |
| 344 | + drawPattern: vi.fn(), |
| 345 | + }; |
| 346 | + } |
| 347 | + |
| 348 | + function makeLayer() { |
| 349 | + const layer = new ImageLayer(0, 0, { |
| 350 | + image: testImage, |
| 351 | + name: "test", |
| 352 | + repeat: "no-repeat", |
| 353 | + }); |
| 354 | + // pin the layer geometry — `pos = (0,0)`, anchor `(0,0)`, ratio `(0,0)` |
| 355 | + // (static), so the computed `x` / `y` in draw() are both 0 |
| 356 | + layer.width = 64; |
| 357 | + layer.height = 64; |
| 358 | + return layer; |
| 359 | + } |
| 360 | + |
| 361 | + function makeViewport({ zoom = 1, width = 800, height = 600 } = {}) { |
| 362 | + return { |
| 363 | + zoom, |
| 364 | + width, |
| 365 | + height, |
| 366 | + pos: { x: 0, y: 0 }, |
| 367 | + bounds: { width, height }, |
| 368 | + }; |
| 369 | + } |
| 370 | + |
| 371 | + // Corners of drawPattern's destination rect in local coordinates, |
| 372 | + // before any of the recorded transforms are applied. drawPattern is |
| 373 | + // always called with `(0, 0, viewport.width * 2, viewport.height * 2)`. |
| 374 | + function corners(viewport) { |
| 375 | + const W2 = viewport.width * 2; |
| 376 | + const H2 = viewport.height * 2; |
| 377 | + return { |
| 378 | + topLeft: { x: 0, y: 0 }, |
| 379 | + topRight: { x: W2, y: 0 }, |
| 380 | + bottomLeft: { x: 0, y: H2 }, |
| 381 | + bottomRight: { x: W2, y: H2 }, |
| 382 | + }; |
| 383 | + } |
| 384 | + |
| 385 | + // At pos=(0,0) anchor=(0,0) static, the unflipped pattern occupies |
| 386 | + // the screen-space rect (0, 0) → (W*2*Z, H*2*Z). |
| 387 | + function expectedUnflipped(viewport) { |
| 388 | + const W2 = viewport.width * 2; |
| 389 | + const H2 = viewport.height * 2; |
| 390 | + const Z = viewport.zoom; |
| 391 | + return { |
| 392 | + topLeft: { x: 0, y: 0 }, |
| 393 | + topRight: { x: W2 * Z, y: 0 }, |
| 394 | + bottomLeft: { x: 0, y: H2 * Z }, |
| 395 | + bottomRight: { x: W2 * Z, y: H2 * Z }, |
| 396 | + }; |
| 397 | + } |
| 398 | + |
| 399 | + function transformCorners(matrix, c) { |
| 400 | + const out = {}; |
| 401 | + for (const k of Object.keys(c)) { |
| 402 | + out[k] = { x: c[k].x, y: c[k].y }; |
| 403 | + matrix.apply(out[k]); |
| 404 | + } |
| 405 | + return out; |
| 406 | + } |
| 407 | + |
| 408 | + function expectClose(actual, expected) { |
| 409 | + expect(actual.x).toBeCloseTo(expected.x, 6); |
| 410 | + expect(actual.y).toBeCloseTo(expected.y, 6); |
| 411 | + } |
| 412 | + |
| 413 | + it("zoom=1, no flip: pattern occupies (0,0) → (2W, 2H) in screen space", () => { |
| 414 | + const events = []; |
| 415 | + const layer = makeLayer(); |
| 416 | + const viewport = makeViewport({ zoom: 1 }); |
| 417 | + layer.draw(makeRendererStub(events), viewport); |
| 418 | + |
| 419 | + const actual = transformCorners( |
| 420 | + replayInto(new Matrix2d(), events), |
| 421 | + corners(viewport), |
| 422 | + ); |
| 423 | + const exp = expectedUnflipped(viewport); |
| 424 | + expectClose(actual.topLeft, exp.topLeft); |
| 425 | + expectClose(actual.topRight, exp.topRight); |
| 426 | + expectClose(actual.bottomLeft, exp.bottomLeft); |
| 427 | + expectClose(actual.bottomRight, exp.bottomRight); |
| 428 | + }); |
| 429 | + |
| 430 | + it("zoom=2, no flip: pattern occupies (0,0) → (2W*2, 2H*2) in screen space", () => { |
| 431 | + const events = []; |
| 432 | + const layer = makeLayer(); |
| 433 | + const viewport = makeViewport({ zoom: 2 }); |
| 434 | + layer.draw(makeRendererStub(events), viewport); |
| 435 | + |
| 436 | + const actual = transformCorners( |
| 437 | + replayInto(new Matrix2d(), events), |
| 438 | + corners(viewport), |
| 439 | + ); |
| 440 | + const exp = expectedUnflipped(viewport); |
| 441 | + expectClose(actual.topLeft, exp.topLeft); |
| 442 | + expectClose(actual.topRight, exp.topRight); |
| 443 | + expectClose(actual.bottomLeft, exp.bottomLeft); |
| 444 | + expectClose(actual.bottomRight, exp.bottomRight); |
| 445 | + }); |
| 446 | + |
| 447 | + it("flipX at zoom=1: covers SAME screen rect as unflipped, but corners swap horizontally", () => { |
| 448 | + const events = []; |
| 449 | + const layer = makeLayer(); |
| 450 | + layer.flipX(true); |
| 451 | + const viewport = makeViewport({ zoom: 1 }); |
| 452 | + layer.draw(makeRendererStub(events), viewport); |
| 453 | + |
| 454 | + const actual = transformCorners( |
| 455 | + replayInto(new Matrix2d(), events), |
| 456 | + corners(viewport), |
| 457 | + ); |
| 458 | + const exp = expectedUnflipped(viewport); |
| 459 | + |
| 460 | + // the screen-space footprint is identical (mirror, not translate) |
| 461 | + const actualXs = [ |
| 462 | + actual.topLeft.x, |
| 463 | + actual.topRight.x, |
| 464 | + actual.bottomLeft.x, |
| 465 | + actual.bottomRight.x, |
| 466 | + ]; |
| 467 | + const expectedXs = [ |
| 468 | + exp.topLeft.x, |
| 469 | + exp.topRight.x, |
| 470 | + exp.bottomLeft.x, |
| 471 | + exp.bottomRight.x, |
| 472 | + ]; |
| 473 | + expect(Math.min(...actualXs)).toBeCloseTo(Math.min(...expectedXs), 6); |
| 474 | + expect(Math.max(...actualXs)).toBeCloseTo(Math.max(...expectedXs), 6); |
| 475 | + |
| 476 | + // the LEFT corner of the original rect now maps to the RIGHT side |
| 477 | + expectClose(actual.topLeft, exp.topRight); |
| 478 | + expectClose(actual.topRight, exp.topLeft); |
| 479 | + expectClose(actual.bottomLeft, exp.bottomRight); |
| 480 | + expectClose(actual.bottomRight, exp.bottomLeft); |
| 481 | + }); |
| 482 | + |
| 483 | + it("flipX at zoom=2: still covers the SAME screen rect (this is the fix)", () => { |
| 484 | + // Regression test for the original bug: at non-1 zoom, the pre-zoom |
| 485 | + // flip pivot would land in the wrong screen-space location. |
| 486 | + // With flip moved to draw() (post-zoom), the mirrored pattern still |
| 487 | + // occupies the SAME screen rect as the unflipped pattern at zoom=2. |
| 488 | + const events = []; |
| 489 | + const layer = makeLayer(); |
| 490 | + layer.flipX(true); |
| 491 | + const viewport = makeViewport({ zoom: 2 }); |
| 492 | + layer.draw(makeRendererStub(events), viewport); |
| 493 | + |
| 494 | + const actual = transformCorners( |
| 495 | + replayInto(new Matrix2d(), events), |
| 496 | + corners(viewport), |
| 497 | + ); |
| 498 | + const exp = expectedUnflipped(viewport); |
| 499 | + |
| 500 | + const actualXs = [ |
| 501 | + actual.topLeft.x, |
| 502 | + actual.topRight.x, |
| 503 | + actual.bottomLeft.x, |
| 504 | + actual.bottomRight.x, |
| 505 | + ]; |
| 506 | + const expectedXs = [ |
| 507 | + exp.topLeft.x, |
| 508 | + exp.topRight.x, |
| 509 | + exp.bottomLeft.x, |
| 510 | + exp.bottomRight.x, |
| 511 | + ]; |
| 512 | + expect(Math.min(...actualXs)).toBeCloseTo(Math.min(...expectedXs), 6); |
| 513 | + expect(Math.max(...actualXs)).toBeCloseTo(Math.max(...expectedXs), 6); |
| 514 | + }); |
| 515 | + |
| 516 | + it("flipY at zoom=2: covers SAME screen rect (vertical mirror)", () => { |
| 517 | + const events = []; |
| 518 | + const layer = makeLayer(); |
| 519 | + layer.flipY(true); |
| 520 | + const viewport = makeViewport({ zoom: 2 }); |
| 521 | + layer.draw(makeRendererStub(events), viewport); |
| 522 | + |
| 523 | + const actual = transformCorners( |
| 524 | + replayInto(new Matrix2d(), events), |
| 525 | + corners(viewport), |
| 526 | + ); |
| 527 | + const exp = expectedUnflipped(viewport); |
| 528 | + |
| 529 | + const actualYs = [ |
| 530 | + actual.topLeft.y, |
| 531 | + actual.topRight.y, |
| 532 | + actual.bottomLeft.y, |
| 533 | + actual.bottomRight.y, |
| 534 | + ]; |
| 535 | + const expectedYs = [ |
| 536 | + exp.topLeft.y, |
| 537 | + exp.topRight.y, |
| 538 | + exp.bottomLeft.y, |
| 539 | + exp.bottomRight.y, |
| 540 | + ]; |
| 541 | + expect(Math.min(...actualYs)).toBeCloseTo(Math.min(...expectedYs), 6); |
| 542 | + expect(Math.max(...actualYs)).toBeCloseTo(Math.max(...expectedYs), 6); |
| 543 | + }); |
| 544 | + |
| 545 | + it("flipX+flipY at zoom=2: corners rotate 180° around the rect centre", () => { |
| 546 | + const events = []; |
| 547 | + const layer = makeLayer(); |
| 548 | + layer.flipX(true); |
| 549 | + layer.flipY(true); |
| 550 | + const viewport = makeViewport({ zoom: 2 }); |
| 551 | + layer.draw(makeRendererStub(events), viewport); |
| 552 | + |
| 553 | + const actual = transformCorners( |
| 554 | + replayInto(new Matrix2d(), events), |
| 555 | + corners(viewport), |
| 556 | + ); |
| 557 | + const exp = expectedUnflipped(viewport); |
| 558 | + |
| 559 | + // top-left ↔ bottom-right, top-right ↔ bottom-left |
| 560 | + expectClose(actual.topLeft, exp.bottomRight); |
| 561 | + expectClose(actual.topRight, exp.bottomLeft); |
| 562 | + expectClose(actual.bottomLeft, exp.topRight); |
| 563 | + expectClose(actual.bottomRight, exp.topLeft); |
| 564 | + }); |
| 565 | + }); |
317 | 566 | }); |
0 commit comments