@neolio42/pixel-office 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/bin.sh +16 -0
  4. package/bin.ts +162 -0
  5. package/next-env.d.ts +6 -0
  6. package/next.config.ts +7 -0
  7. package/package.json +51 -0
  8. package/postcss.config.mjs +7 -0
  9. package/public/assets/characters/char_0.png +0 -0
  10. package/public/assets/characters/char_1.png +0 -0
  11. package/public/assets/characters/char_2.png +0 -0
  12. package/public/assets/characters/char_3.png +0 -0
  13. package/public/assets/characters/char_4.png +0 -0
  14. package/public/assets/characters/char_5.png +0 -0
  15. package/public/assets/characters.png +0 -0
  16. package/public/assets/default-layout-1.json +92 -0
  17. package/public/assets/floors/floor_0.png +0 -0
  18. package/public/assets/floors/floor_1.png +0 -0
  19. package/public/assets/floors/floor_2.png +0 -0
  20. package/public/assets/floors/floor_3.png +0 -0
  21. package/public/assets/floors/floor_4.png +0 -0
  22. package/public/assets/floors/floor_5.png +0 -0
  23. package/public/assets/floors/floor_6.png +0 -0
  24. package/public/assets/floors/floor_7.png +0 -0
  25. package/public/assets/floors/floor_8.png +0 -0
  26. package/public/assets/furniture/BIN/BIN.png +0 -0
  27. package/public/assets/furniture/BIN/manifest.json +13 -0
  28. package/public/assets/furniture/BOOKSHELF/BOOKSHELF.png +0 -0
  29. package/public/assets/furniture/BOOKSHELF/manifest.json +13 -0
  30. package/public/assets/furniture/CACTUS/CACTUS.png +0 -0
  31. package/public/assets/furniture/CACTUS/manifest.json +13 -0
  32. package/public/assets/furniture/CLOCK/CLOCK.png +0 -0
  33. package/public/assets/furniture/CLOCK/manifest.json +13 -0
  34. package/public/assets/furniture/COFFEE/COFFEE.png +0 -0
  35. package/public/assets/furniture/COFFEE/manifest.json +13 -0
  36. package/public/assets/furniture/COFFEE_TABLE/COFFEE_TABLE.png +0 -0
  37. package/public/assets/furniture/COFFEE_TABLE/manifest.json +13 -0
  38. package/public/assets/furniture/CUSHIONED_BENCH/CUSHIONED_BENCH.png +0 -0
  39. package/public/assets/furniture/CUSHIONED_BENCH/manifest.json +13 -0
  40. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_BACK.png +0 -0
  41. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_FRONT.png +0 -0
  42. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_SIDE.png +0 -0
  43. package/public/assets/furniture/CUSHIONED_CHAIR/manifest.json +44 -0
  44. package/public/assets/furniture/DESK/DESK_FRONT.png +0 -0
  45. package/public/assets/furniture/DESK/DESK_SIDE.png +0 -0
  46. package/public/assets/furniture/DESK/manifest.json +33 -0
  47. package/public/assets/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png +0 -0
  48. package/public/assets/furniture/DOUBLE_BOOKSHELF/manifest.json +13 -0
  49. package/public/assets/furniture/HANGING_PLANT/HANGING_PLANT.png +0 -0
  50. package/public/assets/furniture/HANGING_PLANT/manifest.json +13 -0
  51. package/public/assets/furniture/LARGE_PAINTING/LARGE_PAINTING.png +0 -0
  52. package/public/assets/furniture/LARGE_PAINTING/manifest.json +13 -0
  53. package/public/assets/furniture/LARGE_PLANT/LARGE_PLANT.png +0 -0
  54. package/public/assets/furniture/LARGE_PLANT/manifest.json +13 -0
  55. package/public/assets/furniture/PC/PC_BACK.png +0 -0
  56. package/public/assets/furniture/PC/PC_FRONT_OFF.png +0 -0
  57. package/public/assets/furniture/PC/PC_FRONT_ON_1.png +0 -0
  58. package/public/assets/furniture/PC/PC_FRONT_ON_2.png +0 -0
  59. package/public/assets/furniture/PC/PC_FRONT_ON_3.png +0 -0
  60. package/public/assets/furniture/PC/PC_SIDE.png +0 -0
  61. package/public/assets/furniture/PC/manifest.json +88 -0
  62. package/public/assets/furniture/PLANT/PLANT.png +0 -0
  63. package/public/assets/furniture/PLANT/manifest.json +13 -0
  64. package/public/assets/furniture/PLANT_2/PLANT_2.png +0 -0
  65. package/public/assets/furniture/PLANT_2/manifest.json +13 -0
  66. package/public/assets/furniture/POT/POT.png +0 -0
  67. package/public/assets/furniture/POT/manifest.json +13 -0
  68. package/public/assets/furniture/SMALL_PAINTING/SMALL_PAINTING.png +0 -0
  69. package/public/assets/furniture/SMALL_PAINTING/manifest.json +13 -0
  70. package/public/assets/furniture/SMALL_PAINTING_2/SMALL_PAINTING_2.png +0 -0
  71. package/public/assets/furniture/SMALL_PAINTING_2/manifest.json +13 -0
  72. package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_FRONT.png +0 -0
  73. package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_SIDE.png +0 -0
  74. package/public/assets/furniture/SMALL_TABLE/manifest.json +33 -0
  75. package/public/assets/furniture/SOFA/SOFA_BACK.png +0 -0
  76. package/public/assets/furniture/SOFA/SOFA_FRONT.png +0 -0
  77. package/public/assets/furniture/SOFA/SOFA_SIDE.png +0 -0
  78. package/public/assets/furniture/SOFA/manifest.json +44 -0
  79. package/public/assets/furniture/TABLE_FRONT/TABLE_FRONT.png +0 -0
  80. package/public/assets/furniture/TABLE_FRONT/manifest.json +13 -0
  81. package/public/assets/furniture/WHITEBOARD/WHITEBOARD.png +0 -0
  82. package/public/assets/furniture/WHITEBOARD/manifest.json +13 -0
  83. package/public/assets/furniture/WOODEN_BENCH/WOODEN_BENCH.png +0 -0
  84. package/public/assets/furniture/WOODEN_BENCH/manifest.json +13 -0
  85. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_BACK.png +0 -0
  86. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png +0 -0
  87. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_SIDE.png +0 -0
  88. package/public/assets/furniture/WOODEN_CHAIR/manifest.json +44 -0
  89. package/public/assets/walls/wall_0.png +0 -0
  90. package/scripts/setup.ts +158 -0
  91. package/server.ts +53 -0
  92. package/src/app/api/focus-terminal/route.ts +65 -0
  93. package/src/app/api/hooks/notification/route.ts +19 -0
  94. package/src/app/api/hooks/post-tool-use/route.ts +26 -0
  95. package/src/app/api/hooks/pre-tool-use/route.ts +189 -0
  96. package/src/app/api/hooks/session-end/route.ts +31 -0
  97. package/src/app/api/hooks/session-start/route.ts +47 -0
  98. package/src/app/api/hooks/stop/route.ts +19 -0
  99. package/src/app/api/hooks/user-prompt/route.ts +92 -0
  100. package/src/app/favicon.ico +0 -0
  101. package/src/app/globals.css +14 -0
  102. package/src/app/layout.tsx +21 -0
  103. package/src/app/page.tsx +5 -0
  104. package/src/components/ApprovalToast.tsx +132 -0
  105. package/src/components/OfficeCanvas.tsx +311 -0
  106. package/src/components/Terminal.tsx +177 -0
  107. package/src/components/TerminalTile.tsx +181 -0
  108. package/src/components/WorkerPanel.tsx +261 -0
  109. package/src/components/WorkerPopup.tsx +116 -0
  110. package/src/game/asset-loader.ts +172 -0
  111. package/src/game/office-layout.ts +287 -0
  112. package/src/game/renderer.ts +369 -0
  113. package/src/game/sprites.ts +133 -0
  114. package/src/game/worker-entity.ts +219 -0
  115. package/src/hooks/usePixelOffice.ts +318 -0
  116. package/src/hooks/useRecentCwds.ts +27 -0
  117. package/src/lib/approval-queue.ts +67 -0
  118. package/src/lib/pty-manager.ts +267 -0
  119. package/src/lib/store.ts +181 -0
  120. package/src/lib/tool-classifier.ts +224 -0
  121. package/src/lib/transcript.ts +109 -0
  122. package/src/lib/types.ts +58 -0
  123. package/src/lib/ws-server.ts +270 -0
  124. package/tsconfig.json +34 -0
@@ -0,0 +1,369 @@
1
+ // Sprite-based renderer using PNG assets from pixel-agents
2
+ //
3
+ // Render order (painter's algorithm, back-to-front):
4
+ // 1. Floors (colorized tiles)
5
+ // 2. Walls (auto-tiled, colorized)
6
+ // 3. Back-row furniture (sorted by ty)
7
+ // 4. Desks
8
+ // 5. Workers (depth-sorted by y)
9
+ // 6. Speech bubbles
10
+
11
+ import {
12
+ TILE_SIZE, SCALE, GRID_W, GRID_H, DESK_POSITIONS,
13
+ GridCell, buildFurniturePlacements, FurniturePlacement,
14
+ } from './office-layout';
15
+ import { AssetBundle, HsbcParams, colorizeImage } from './asset-loader';
16
+ import { drawCharacter, FRAMES, FRAME_DURATIONS, AnimState } from './sprites';
17
+ import { WorkerEntity, getWorkerScreenPos } from './worker-entity';
18
+
19
+ const T = TILE_SIZE * SCALE; // rendered tile size in CSS pixels
20
+
21
+ // Cache furniture placements (rebuilt when layout module changes via HMR)
22
+ let cachedFurniture: FurniturePlacement[] | null = null;
23
+ function getCachedFurniturePlacements(): FurniturePlacement[] {
24
+ if (!cachedFurniture) cachedFurniture = buildFurniturePlacements();
25
+ return cachedFurniture;
26
+ }
27
+ // Force cache invalidation on import
28
+ cachedFurniture = null;
29
+
30
+ // Module-level asset reference set by initRenderer()
31
+ let assets: AssetBundle | null = null;
32
+
33
+ // PC animation frame counter (cycles independently of worker state)
34
+ let pcFrameTimer = 0;
35
+ let pcFrameIndex = 0;
36
+ const PC_FRAME_KEYS = ['PC/PC_FRONT_ON_1', 'PC/PC_FRONT_ON_2', 'PC/PC_FRONT_ON_3'];
37
+ const PC_FRAME_MS = 400;
38
+
39
+ // Global animation time for ambient effects
40
+ let globalTime = 0;
41
+
42
+ export function initRenderer(bundle: AssetBundle): void {
43
+ assets = bundle;
44
+ }
45
+
46
+ // ── Floor rendering ─────────────────────────────────────────────────────────
47
+
48
+ function getColorizedFloor(floorIndex: number, params: HsbcParams): HTMLCanvasElement | null {
49
+ if (!assets) return null;
50
+ const key = `${floorIndex}:${params.h}:${params.s}:${params.b}:${params.c}`;
51
+ let canvas = assets.colorizedFloors.get(key);
52
+ if (!canvas) {
53
+ // Colorize on demand and cache
54
+ canvas = colorizeImage(assets.floors[floorIndex], params);
55
+ assets.colorizedFloors.set(key, canvas);
56
+ }
57
+ return canvas;
58
+ }
59
+
60
+ function drawFloorTile(ctx: CanvasRenderingContext2D, tx: number, ty: number, cell: GridCell) {
61
+ const canvas = getColorizedFloor(cell.floorIndex, cell.floorParams);
62
+ if (!canvas) return;
63
+ ctx.drawImage(canvas, 0, 0, TILE_SIZE, TILE_SIZE, tx * T, ty * T, T, T);
64
+ }
65
+
66
+ // ── Wall rendering (auto-tiled) ─────────────────────────────────────────────
67
+ // wall_0.png is 64×128 = 4 cols × 8 rows of 16×32 tiles
68
+ // 16 bitmask configs laid out left-to-right, top-to-bottom
69
+
70
+ const WALL_TILE_W = 16;
71
+ const WALL_TILE_H = 32;
72
+ const WALL_SHEET_COLS = 4;
73
+
74
+ function drawWallTile(ctx: CanvasRenderingContext2D, tx: number, ty: number, cell: GridCell) {
75
+ if (!assets) return;
76
+
77
+ if (cell.isDoor) {
78
+ // Draw the floor through the door opening
79
+ drawFloorTile(ctx, tx, ty, cell);
80
+ return;
81
+ }
82
+
83
+ const mask = cell.wallMask;
84
+ // Clamp to 0-15 just in case
85
+ const idx = Math.min(15, Math.max(0, mask));
86
+ const col = idx % WALL_SHEET_COLS;
87
+ const row = Math.floor(idx / WALL_SHEET_COLS);
88
+
89
+ const sx = col * WALL_TILE_W;
90
+ const sy = row * WALL_TILE_H;
91
+
92
+ // Draw wall tile at double height (16×32 sprite spans 2 tile rows)
93
+ // Align bottom of sprite to bottom of this tile row
94
+ const dx = tx * T;
95
+ const dy = ty * T + T - WALL_TILE_H * SCALE; // top of sprite
96
+
97
+ ctx.drawImage(
98
+ assets.colorizedWall,
99
+ sx, sy, WALL_TILE_W, WALL_TILE_H,
100
+ dx, dy,
101
+ WALL_TILE_W * SCALE,
102
+ WALL_TILE_H * SCALE
103
+ );
104
+ }
105
+
106
+ // ── Furniture rendering ─────────────────────────────────────────────────────
107
+
108
+ function drawFurniture(
109
+ ctx: CanvasRenderingContext2D,
110
+ placement: FurniturePlacement,
111
+ assetKey?: string // override key for animation
112
+ ) {
113
+ if (!assets) return;
114
+ const key = assetKey ?? placement.assetKey;
115
+ const img = assets.furniture.get(key);
116
+ if (!img) return;
117
+
118
+ const dx = placement.tx * T + (placement.offsetX ?? 0) * SCALE;
119
+ const dy = placement.ty * T + (placement.offsetY ?? 0) * SCALE;
120
+ const dw = placement.pngW * SCALE;
121
+ const dh = placement.pngH * SCALE;
122
+
123
+ if (placement.flipX) {
124
+ ctx.save();
125
+ ctx.translate(dx + dw, dy);
126
+ ctx.scale(-1, 1);
127
+ ctx.drawImage(img, 0, 0, placement.pngW, placement.pngH, 0, 0, dw, dh);
128
+ ctx.restore();
129
+ } else {
130
+ ctx.drawImage(img, 0, 0, placement.pngW, placement.pngH, dx, dy, dw, dh);
131
+ }
132
+ }
133
+
134
+ // ── Main render ─────────────────────────────────────────────────────────────
135
+
136
+ export function renderOffice(
137
+ ctx: CanvasRenderingContext2D,
138
+ grid: GridCell[][],
139
+ workers: WorkerEntity[],
140
+ dt = 0
141
+ ): void {
142
+ if (!assets) return;
143
+
144
+ ctx.imageSmoothingEnabled = false;
145
+
146
+ // Advance global animation time
147
+ globalTime += dt;
148
+
149
+ // Advance PC screen animation
150
+ pcFrameTimer += dt * 1000;
151
+ if (pcFrameTimer >= PC_FRAME_MS) {
152
+ pcFrameTimer -= PC_FRAME_MS;
153
+ pcFrameIndex = (pcFrameIndex + 1) % PC_FRAME_KEYS.length;
154
+ }
155
+
156
+ // ── Pass 1: Floors ────────────────────────────────────────────────────────
157
+ for (let ty = 0; ty < GRID_H; ty++) {
158
+ for (let tx = 0; tx < GRID_W; tx++) {
159
+ drawFloorTile(ctx, tx, ty, grid[ty][tx]);
160
+ }
161
+ }
162
+
163
+ // ── Pass 2: Walls ─────────────────────────────────────────────────────────
164
+ for (let ty = 0; ty < GRID_H; ty++) {
165
+ for (let tx = 0; tx < GRID_W; tx++) {
166
+ const cell = grid[ty][tx];
167
+ if (cell.isWall || cell.isDoor) {
168
+ drawWallTile(ctx, tx, ty, cell);
169
+ }
170
+ }
171
+ }
172
+
173
+ // ── Pass 3: Background furniture (bookshelves, plants, whiteboard, etc.) ──
174
+ const furniturePlacements = getCachedFurniturePlacements();
175
+
176
+ // Separate desk, PC, and decorative items
177
+ const deskOnly: FurniturePlacement[] = [];
178
+ const pcItems: FurniturePlacement[] = [];
179
+ const bgItems: FurniturePlacement[] = [];
180
+
181
+ for (const p of furniturePlacements) {
182
+ if (p.type === 'desk') {
183
+ deskOnly.push(p);
184
+ } else if (p.type === 'pc') {
185
+ pcItems.push(p);
186
+ } else {
187
+ bgItems.push(p);
188
+ }
189
+ }
190
+
191
+ // Sort bg items by ty (back to front)
192
+ bgItems.sort((a, b) => a.ty - b.ty || a.tx - b.tx);
193
+ for (const p of bgItems) {
194
+ // Subtle sway for plants
195
+ const isPlant = p.type === 'plant' || p.type === 'plant_2' || p.type === 'large_plant'
196
+ || p.type === 'hanging_plant' || p.type === 'cactus';
197
+ if (isPlant) {
198
+ const dx = p.tx * T + (p.offsetX ?? 0) * SCALE;
199
+ const dy = p.ty * T + (p.offsetY ?? 0) * SCALE;
200
+ const dw = p.pngW * SCALE;
201
+ const dh = p.pngH * SCALE;
202
+ // Sway from the base (bottom-center pivot)
203
+ const sway = Math.sin(globalTime * 1.2 + p.tx * 2.1 + p.ty * 1.7) * 0.012;
204
+ ctx.save();
205
+ ctx.translate(dx + dw / 2, dy + dh);
206
+ ctx.rotate(sway);
207
+ ctx.translate(-(dx + dw / 2), -(dy + dh));
208
+ drawFurniture(ctx, p);
209
+ ctx.restore();
210
+ } else if (p.type === 'coffee') {
211
+ // Draw the mug normally, then add steam
212
+ drawFurniture(ctx, p);
213
+ drawSteam(ctx, p.tx * T + (p.offsetX ?? 0) * SCALE + 8 * SCALE, p.ty * T + (p.offsetY ?? 0) * SCALE);
214
+ } else {
215
+ drawFurniture(ctx, p);
216
+ }
217
+ }
218
+
219
+ // ── Pass 4a: Desks first ──────────────────────────────────────────────────
220
+ deskOnly.sort((a, b) => a.ty - b.ty);
221
+ for (const p of deskOnly) {
222
+ drawFurniture(ctx, p);
223
+ }
224
+
225
+ // ── Pass 4b: PCs on top of desks ──────────────────────────────────────────
226
+ for (const p of pcItems) {
227
+ // Use animated PC frame based on worker state at this desk
228
+ const deskIdx = DESK_POSITIONS.findIndex(d => d.deskX + 1 === p.tx && d.deskY - 1 === p.ty);
229
+ const worker = deskIdx >= 0
230
+ ? [...workers].find(w => w.deskIndex === deskIdx && w.arrived && !w.leaving)
231
+ : undefined;
232
+
233
+ let pcKey: string;
234
+ if (worker && (worker.state === 'typing' || worker.state === 'reading')) {
235
+ pcKey = PC_FRAME_KEYS[pcFrameIndex];
236
+ } else if (worker && worker.arrived) {
237
+ pcKey = 'PC/PC_FRONT_ON_1';
238
+ } else {
239
+ pcKey = 'PC/PC_FRONT_OFF';
240
+ }
241
+ drawFurniture(ctx, p, pcKey);
242
+ }
243
+
244
+ // ── Pass 5: Workers (depth-sorted by y) ───────────────────────────────────
245
+ const sorted = [...workers].sort((a, b) => a.y - b.y);
246
+
247
+ const SPRITE_W = 16;
248
+ const SPRITE_H = 32;
249
+
250
+ for (const worker of sorted) {
251
+ const pos = getWorkerScreenPos(worker);
252
+ const renderedW = SPRITE_W * SCALE;
253
+ const renderedH = SPRITE_H * SCALE;
254
+
255
+ // Center sprite on tile, bottom-align to tile bottom
256
+ const spx = pos.x + Math.floor((T - renderedW) / 2);
257
+ let spy = pos.y + T - renderedH;
258
+
259
+ // When seated (typing/reading/idle at desk), shift down slightly
260
+ if (worker.arrived && !worker.leaving &&
261
+ (worker.state === 'typing' || worker.state === 'reading' || worker.state === 'idle')) {
262
+ spy += 6 * SCALE; // 6 source pixels down
263
+ }
264
+
265
+ const charSheet = assets.characters[worker.charIndex % assets.characters.length];
266
+ drawCharacter(ctx, charSheet, worker.state, worker.frameIndex, spx, spy, SCALE, worker.facing);
267
+
268
+ if (worker.speechBubble) {
269
+ drawSpeechBubble(ctx, spx + renderedW / 2, spy - 4, worker.speechBubble);
270
+ }
271
+ }
272
+
273
+ // ── Pass 6: Room labels ───────────────────────────────────────────────────
274
+ drawRoomLabels(ctx);
275
+ }
276
+
277
+ // ── Coffee steam animation ────────────────────────────────────────────────
278
+
279
+ function drawSteam(ctx: CanvasRenderingContext2D, cx: number, topY: number) {
280
+ const s = SCALE;
281
+ ctx.fillStyle = 'rgba(255,255,255,0.35)';
282
+ // 3 small rising wisp pixels
283
+ for (let i = 0; i < 3; i++) {
284
+ const phase = globalTime * 1.5 + i * 2.1;
285
+ const yOff = ((phase % 3) / 3) * 12 * s; // rises over 12 source pixels
286
+ const xWobble = Math.sin(phase * 2) * 2 * s;
287
+ const alpha = 1 - (phase % 3) / 3; // fade out as it rises
288
+ ctx.globalAlpha = alpha * 0.4;
289
+ ctx.fillRect(cx + xWobble - s, topY - yOff - s, s * 2, s * 2);
290
+ }
291
+ ctx.globalAlpha = 1;
292
+ }
293
+
294
+ // ── Speech bubble ──────────────────────────────────────────────────────────
295
+
296
+ function drawSpeechBubble(ctx: CanvasRenderingContext2D, cx: number, bottomY: number, text: string) {
297
+ ctx.font = 'bold 11px "Courier New", monospace';
298
+ const metrics = ctx.measureText(text);
299
+ const padX = 8;
300
+ const padY = 5;
301
+ const w = Math.ceil(metrics.width) + padX * 2;
302
+ const h = 18 + padY * 2;
303
+ const bx = Math.floor(cx - w / 2);
304
+ const by = bottomY - h - 10;
305
+
306
+ // Drop shadow
307
+ ctx.fillStyle = 'rgba(0,0,0,0.3)';
308
+ ctx.fillRect(bx + 2, by + 2, w, h);
309
+
310
+ // Bubble background
311
+ ctx.fillStyle = '#f0ede8';
312
+ ctx.fillRect(bx, by, w, h);
313
+
314
+ // Pixel-art border
315
+ ctx.fillStyle = '#2a2a4a';
316
+ ctx.fillRect(bx + 2, by, w - 4, 2);
317
+ ctx.fillRect(bx + 2, by + h - 2, w - 4, 2);
318
+ ctx.fillRect(bx, by + 2, 2, h - 4);
319
+ ctx.fillRect(bx + w - 2, by + 2, 2, h - 4);
320
+ // Rounded corners
321
+ ctx.fillStyle = '#f0ede8';
322
+ ctx.fillRect(bx, by, 2, 2);
323
+ ctx.fillRect(bx + w - 2, by, 2, 2);
324
+ ctx.fillRect(bx, by + h - 2, 2, 2);
325
+ ctx.fillRect(bx + w - 2, by + h - 2, 2, 2);
326
+ ctx.fillStyle = '#2a2a4a';
327
+ ctx.fillRect(bx + 1, by + 1, 1, 1);
328
+ ctx.fillRect(bx + w - 2, by + 1, 1, 1);
329
+ ctx.fillRect(bx + 1, by + h - 2, 1, 1);
330
+ ctx.fillRect(bx + w - 2, by + h - 2, 1, 1);
331
+
332
+ // Tail
333
+ const tx2 = Math.floor(cx);
334
+ ctx.fillStyle = '#2a2a4a';
335
+ ctx.fillRect(tx2 - 3, by + h, 6, 2);
336
+ ctx.fillRect(tx2 - 2, by + h + 2, 4, 2);
337
+ ctx.fillRect(tx2 - 1, by + h + 4, 2, 2);
338
+ ctx.fillStyle = '#f0ede8';
339
+ ctx.fillRect(tx2 - 2, by + h, 4, 2);
340
+ ctx.fillRect(tx2 - 1, by + h + 2, 2, 2);
341
+
342
+ // Text
343
+ ctx.fillStyle = '#1a1a2e';
344
+ ctx.textAlign = 'center';
345
+ ctx.textBaseline = 'middle';
346
+ ctx.fillText(text, cx, by + h / 2);
347
+ ctx.textAlign = 'start';
348
+ ctx.textBaseline = 'alphabetic';
349
+ }
350
+
351
+ // ── Room labels ─────────────────────────────────────────────────────────────
352
+
353
+ function drawRoomLabels(ctx: CanvasRenderingContext2D) {
354
+ ctx.font = '9px "Courier New", monospace';
355
+ ctx.fillStyle = 'rgba(200,200,230,0.2)';
356
+ ctx.textAlign = 'center';
357
+ ctx.textBaseline = 'top';
358
+
359
+ ctx.fillText('WORK AREA', 6 * T + T / 2, 12 * T + 4);
360
+ ctx.fillText('BREAK ROOM', 16 * T, 5 * T + 4);
361
+ ctx.fillText('MEETING', 16 * T, 12 * T + 4);
362
+
363
+ ctx.textAlign = 'start';
364
+ ctx.textBaseline = 'alphabetic';
365
+ }
366
+
367
+ // Re-export for use in worker-entity / usePixelOffice
368
+ export type { AnimState };
369
+ export { FRAMES, FRAME_DURATIONS };
@@ -0,0 +1,133 @@
1
+ // Sprite rendering using PNG character sheets from pixel-agents
2
+ //
3
+ // Sheet layout (112x96 = 7 frames × 3 rows):
4
+ // Each frame: 16px wide × 32px tall
5
+ // Row 0 (sy=0): facing down
6
+ // Row 1 (sy=32): facing up
7
+ // Row 2 (sy=64): facing right (mirror for left)
8
+ //
9
+ // Walk cycle: frames 0-3 in each row, frame 1 = idle pose
10
+ // Typing: frames 3-4 in row 0
11
+ // Reading: frames 5-6 in row 0
12
+
13
+ // ── Palette — kept for API compatibility, no longer used for drawing ────────
14
+
15
+ export interface Palette {
16
+ skin: string;
17
+ shirt: string;
18
+ pants: string;
19
+ hair: string;
20
+ shoes: string;
21
+ eye: string;
22
+ skin_shadow: string;
23
+ shirt_shadow: string;
24
+ hair_shadow: string;
25
+ }
26
+
27
+ export const PALETTES: Palette[] = [
28
+ { skin: '#f5c9a0', shirt: '#4a7cbf', pants: '#2d3a5c', hair: '#6b3a1e', shoes: '#2a1a0e', eye: '#1a1a2e', skin_shadow: '#d4a070', shirt_shadow: '#2d5a96', hair_shadow: '#3d1e08' },
29
+ { skin: '#f5c9a0', shirt: '#bf4a4a', pants: '#2d3a5c', hair: '#1e1e3a', shoes: '#2a1a0e', eye: '#1a1a2e', skin_shadow: '#d4a070', shirt_shadow: '#962828', hair_shadow: '#0e0e22' },
30
+ { skin: '#e8b07a', shirt: '#4abf5c', pants: '#3a3a5c', hair: '#8b4513', shoes: '#1e1208', eye: '#1a1a2e', skin_shadow: '#c08050', shirt_shadow: '#2a9638', hair_shadow: '#5a2a06' },
31
+ { skin: '#f5c9a0', shirt: '#8b4abf', pants: '#2d3a5c', hair: '#d4a017', shoes: '#2a1a0e', eye: '#1a1a2e', skin_shadow: '#d4a070', shirt_shadow: '#5c2a96', hair_shadow: '#a07010' },
32
+ { skin: '#e8b07a', shirt: '#bf7a2a', pants: '#3a3a5c', hair: '#1e0e06', shoes: '#1e1208', eye: '#1a1a2e', skin_shadow: '#c08050', shirt_shadow: '#965210', hair_shadow: '#0a0400' },
33
+ { skin: '#e8c090', shirt: '#bf4a8b', pants: '#3a2a5c', hair: '#4a1a6a', shoes: '#2a1a0e', eye: '#1a1a2e', skin_shadow: '#c09060', shirt_shadow: '#962868', hair_shadow: '#2a0a42' },
34
+ ];
35
+
36
+ // ── Animation state ─────────────────────────────────────────────────────────
37
+
38
+ export type AnimState = 'idle' | 'typing' | 'reading' | 'waiting' | 'walking';
39
+
40
+ // Each entry is [frameX in sheet], using row 0 (facing down) for all states
41
+ // since this is a top-down office view
42
+ const FRAME_X: Record<AnimState, number[]> = {
43
+ idle: [1, 0], // frames 1,0 = subtle idle breathing
44
+ typing: [3, 4], // frames 3-4 = typing animation
45
+ reading: [5, 6], // frames 5-6 = reading/looking-down
46
+ waiting: [0, 1], // frames 0-1 = subtle idle sway
47
+ walking: [0, 1, 2, 3], // full walk cycle
48
+ };
49
+
50
+ // Build FRAMES as string[][] arrays (one entry per anim frame) for API compat.
51
+ // The strings are just placeholders — actual drawing uses drawCharacter().
52
+ export const FRAMES: Record<AnimState, string[][]> = Object.fromEntries(
53
+ Object.entries(FRAME_X).map(([state, frames]) => [
54
+ state,
55
+ frames.map(() => ['']),
56
+ ])
57
+ ) as Record<AnimState, string[][]>;
58
+
59
+ export const FRAME_DURATIONS: Record<AnimState, number> = {
60
+ idle: 600,
61
+ typing: 180,
62
+ reading: 1200,
63
+ waiting: 400,
64
+ walking: 150,
65
+ };
66
+
67
+ // ── drawSprite — legacy shim (no-op when charSheet rendering is active) ────
68
+
69
+ export function drawSprite(
70
+ _ctx: CanvasRenderingContext2D,
71
+ _frame: string[],
72
+ _palette: Palette,
73
+ _x: number,
74
+ _y: number,
75
+ _scale: number
76
+ ): void {
77
+ // No-op: use drawCharacter() instead
78
+ }
79
+
80
+ // ── drawCharacter — main sprite rendering function ─────────────────────────
81
+ //
82
+ // charSheet: the HTMLImageElement for this worker (char_0.png … char_5.png)
83
+ // state: animation state
84
+ // frameIndex: current frame within state (wraps within FRAME_X[state])
85
+ // x, y: top-left destination in canvas pixels
86
+ // zoom: integer zoom factor (SCALE)
87
+
88
+ export type FacingDir = 'down' | 'up' | 'right' | 'left';
89
+
90
+ export function drawCharacter(
91
+ ctx: CanvasRenderingContext2D,
92
+ charSheet: HTMLImageElement,
93
+ state: AnimState,
94
+ frameIndex: number,
95
+ x: number,
96
+ y: number,
97
+ zoom: number,
98
+ facing: FacingDir = 'down'
99
+ ): void {
100
+ const frameXList = FRAME_X[state];
101
+ const fx = frameXList[frameIndex % frameXList.length];
102
+
103
+ const FRAME_W = 16;
104
+ const FRAME_H = 32;
105
+
106
+ // Row selection based on facing direction
107
+ // Row 0 = facing down, Row 1 = facing up, Row 2 = facing right
108
+ let row: number;
109
+ let flipX = false;
110
+ switch (facing) {
111
+ case 'up': row = 1; break;
112
+ case 'right': row = 2; break;
113
+ case 'left': row = 2; flipX = true; break;
114
+ default: row = 0; break; // 'down'
115
+ }
116
+
117
+ const sx = fx * FRAME_W;
118
+ const sy = row * FRAME_H;
119
+ const dw = FRAME_W * zoom;
120
+ const dh = FRAME_H * zoom;
121
+ const dx = Math.floor(x);
122
+ const dy = Math.floor(y);
123
+
124
+ if (flipX) {
125
+ ctx.save();
126
+ ctx.translate(dx + dw, dy);
127
+ ctx.scale(-1, 1);
128
+ ctx.drawImage(charSheet, sx, sy, FRAME_W, FRAME_H, 0, 0, dw, dh);
129
+ ctx.restore();
130
+ } else {
131
+ ctx.drawImage(charSheet, sx, sy, FRAME_W, FRAME_H, dx, dy, dw, dh);
132
+ }
133
+ }