detach.test.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. import { describe, expect, it } from 'vitest';
  2. import { TerrainBitmap } from '../src/shared/terrain/bitmap';
  3. import { DetachFinder } from '../src/shared/terrain/detach';
  4. import { applyTerrainEvent, rasterizeChunk } from '../src/shared/terrain/events';
  5. import { rleDecode, rleEncode } from '../src/shared/terrain/rle';
  6. import { boxesForMask } from '../src/server/physics/fallingChunk';
  7. import { bitmapFrom, realMaterials } from './helpers';
  8. const materials = realMaterials();
  9. const all = (bm: TerrainBitmap) => ({ x0: 0, y0: 0, x1: bm.width, y1: bm.height });
  10. describe('detached components', () => {
  11. it('everything connected to a plate stays', () => {
  12. const bm = bitmapFrom(['......', '.11...', '.1....', '.1111.', '######']);
  13. expect(new DetachFinder(bm).find(all(bm))).toEqual([]);
  14. });
  15. it('a floating island detaches with its exact mask', () => {
  16. const bm = bitmapFrom(['.......', '..23...', '..1....', '.......', '#######']);
  17. const [c] = new DetachFinder(bm).find(all(bm));
  18. expect(c.bounds).toEqual({ x0: 2, y0: 1, x1: 4, y1: 3 });
  19. expect([...c.mask]).toEqual([2, 3, 1, 0]);
  20. expect(c.pixelCount).toBe(3);
  21. });
  22. it('diagonal contact does not anchor (4-connectivity)', () => {
  23. const bm = bitmapFrom(['.1...', '..1..', '..1..', '#####']);
  24. const comps = new DetachFinder(bm).find(all(bm));
  25. expect(comps).toHaveLength(1);
  26. expect(comps[0].pixelCount).toBe(1);
  27. });
  28. it('finds every separate island in the region, once each', () => {
  29. const bm = bitmapFrom(['1.1.1', '.....', '1.1.1', '.....', '#####']);
  30. expect(new DetachFinder(bm).find(all(bm))).toHaveLength(6);
  31. });
  32. it('only looks around the changed region', () => {
  33. const bm = bitmapFrom(['1......', '.......', '.....1.', '.....1.', '#######']);
  34. const comps = new DetachFinder(bm).find({ x0: 0, y0: 0, x1: 1, y1: 1 });
  35. expect(comps).toHaveLength(1);
  36. expect(comps[0].bounds.x0).toBe(0);
  37. });
  38. it('cutting the only support of a ledge detaches the ledge', () => {
  39. const rows = ['..........', '.1111111..', '.1........', '.1........', '##########'];
  40. const bm = bitmapFrom(rows);
  41. const finder = new DetachFinder(bm);
  42. expect(finder.find(all(bm))).toEqual([]);
  43. bm.data[3 * bm.width + 1] = 0; // cut the pillar
  44. const [c] = finder.find({ x0: 1, y0: 3, x1: 2, y1: 4 });
  45. expect(c.pixelCount).toBe(8);
  46. expect(finder.isAnchored(1 * bm.width + 3)).toBe(false);
  47. });
  48. it('a detach event removes exactly the masked pixels', () => {
  49. const bm = bitmapFrom(['.....', '.12..', '..1..', '.....', '#####']);
  50. const [c] = new DetachFinder(bm).find(all(bm));
  51. const { x0, y0, x1, y1 } = c.bounds;
  52. applyTerrainEvent(bm, { kind: 'detach', id: 1, x: x0, y: y0, w: x1 - x0, h: y1 - y0, mask: rleEncode(c.mask), debris: false }, materials);
  53. expect([...bm.data].filter((v) => v !== 0 && v !== 255)).toEqual([]);
  54. });
  55. });
  56. describe('RLE', () => {
  57. it('round-trips and rejects malformed input', () => {
  58. const data = new Uint8Array([0, 0, 0, 5, 5, 1, 0, 0]);
  59. expect(rleEncode(data)).toEqual([0, 3, 5, 2, 1, 1, 0, 2]);
  60. expect([...rleDecode(rleEncode(data), data.length)]).toEqual([...data]);
  61. expect(() => rleDecode([1, 3], 2)).toThrow();
  62. expect(() => rleDecode([1], 1)).toThrow();
  63. });
  64. });
  65. describe('chunk rasterization', () => {
  66. it('writes an unrotated chunk back where it was, into empty pixels only', () => {
  67. const bm = new TerrainBitmap(8, 8);
  68. bm.data[5 * 8 + 3] = 4; // obstacle inside the chunk area
  69. const mask = new Uint8Array([1, 1, 1, 1, 1, 1]); // 3 × 2
  70. const patch = rasterizeChunk(bm, mask, 3, 2, 2, 4, 0, 0)!;
  71. expect(patch.pixelCount).toBe(5);
  72. applyTerrainEvent(bm, { kind: 'merge', id: 1, x: patch.x, y: patch.y, w: patch.w, h: patch.h, patch: rleEncode(patch.cells) }, materials);
  73. expect(bm.get(2, 4)).toBe(1);
  74. expect(bm.get(3, 5)).toBe(4);
  75. expect(bm.get(4, 5)).toBe(1);
  76. });
  77. it('a quarter turn keeps the pixel count and leaves no holes', () => {
  78. const bm = new TerrainBitmap(32, 32);
  79. const mask = new Uint8Array(10 * 4).fill(1);
  80. const patch = rasterizeChunk(bm, mask, 10, 4, 20, 5, Math.PI / 2, 0)!;
  81. expect(patch.pixelCount).toBe(40);
  82. });
  83. it('glues a chunk resting a couple of pixels above the ground', () => {
  84. const bm = new TerrainBitmap(6, 8);
  85. for (let x = 0; x < 6; x++) bm.data[7 * 6 + x] = 255;
  86. const patch = rasterizeChunk(bm, new Uint8Array([1, 1]), 2, 1, 2, 4, 0, 3)!;
  87. applyTerrainEvent(bm, { kind: 'merge', id: 1, x: patch.x, y: patch.y, w: patch.w, h: patch.h, patch: rleEncode(patch.cells) }, materials);
  88. expect(bm.get(2, 5)).toBe(1);
  89. expect(bm.get(2, 6)).toBe(1);
  90. expect(new DetachFinder(bm).isAnchored(4 * 6 + 2)).toBe(true);
  91. });
  92. });
  93. describe('chunk collision boxes', () => {
  94. it('covers a rectangle with one box and an L with two', () => {
  95. expect(boxesForMask(new Uint8Array(24 * 12).fill(1), 24, 12)).toEqual([{ x0: 0, y0: 0, x1: 24, y1: 12 }]);
  96. const l = new Uint8Array(24 * 24);
  97. for (let y = 0; y < 24; y++) for (let x = 0; x < 24; x++) if (x < 12 || y >= 12) l[y * 24 + x] = 1;
  98. expect(boxesForMask(l, 24, 24)).toHaveLength(2);
  99. });
  100. });