@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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin.sh +16 -0
- package/bin.ts +162 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +7 -0
- package/package.json +51 -0
- package/postcss.config.mjs +7 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/characters.png +0 -0
- package/public/assets/default-layout-1.json +92 -0
- package/public/assets/floors/floor_0.png +0 -0
- package/public/assets/floors/floor_1.png +0 -0
- package/public/assets/floors/floor_2.png +0 -0
- package/public/assets/floors/floor_3.png +0 -0
- package/public/assets/floors/floor_4.png +0 -0
- package/public/assets/floors/floor_5.png +0 -0
- package/public/assets/floors/floor_6.png +0 -0
- package/public/assets/floors/floor_7.png +0 -0
- package/public/assets/floors/floor_8.png +0 -0
- package/public/assets/furniture/BIN/BIN.png +0 -0
- package/public/assets/furniture/BIN/manifest.json +13 -0
- package/public/assets/furniture/BOOKSHELF/BOOKSHELF.png +0 -0
- package/public/assets/furniture/BOOKSHELF/manifest.json +13 -0
- package/public/assets/furniture/CACTUS/CACTUS.png +0 -0
- package/public/assets/furniture/CACTUS/manifest.json +13 -0
- package/public/assets/furniture/CLOCK/CLOCK.png +0 -0
- package/public/assets/furniture/CLOCK/manifest.json +13 -0
- package/public/assets/furniture/COFFEE/COFFEE.png +0 -0
- package/public/assets/furniture/COFFEE/manifest.json +13 -0
- package/public/assets/furniture/COFFEE_TABLE/COFFEE_TABLE.png +0 -0
- package/public/assets/furniture/COFFEE_TABLE/manifest.json +13 -0
- package/public/assets/furniture/CUSHIONED_BENCH/CUSHIONED_BENCH.png +0 -0
- package/public/assets/furniture/CUSHIONED_BENCH/manifest.json +13 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_BACK.png +0 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_FRONT.png +0 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_SIDE.png +0 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/manifest.json +44 -0
- package/public/assets/furniture/DESK/DESK_FRONT.png +0 -0
- package/public/assets/furniture/DESK/DESK_SIDE.png +0 -0
- package/public/assets/furniture/DESK/manifest.json +33 -0
- package/public/assets/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png +0 -0
- package/public/assets/furniture/DOUBLE_BOOKSHELF/manifest.json +13 -0
- package/public/assets/furniture/HANGING_PLANT/HANGING_PLANT.png +0 -0
- package/public/assets/furniture/HANGING_PLANT/manifest.json +13 -0
- package/public/assets/furniture/LARGE_PAINTING/LARGE_PAINTING.png +0 -0
- package/public/assets/furniture/LARGE_PAINTING/manifest.json +13 -0
- package/public/assets/furniture/LARGE_PLANT/LARGE_PLANT.png +0 -0
- package/public/assets/furniture/LARGE_PLANT/manifest.json +13 -0
- package/public/assets/furniture/PC/PC_BACK.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_OFF.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_ON_1.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_ON_2.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_ON_3.png +0 -0
- package/public/assets/furniture/PC/PC_SIDE.png +0 -0
- package/public/assets/furniture/PC/manifest.json +88 -0
- package/public/assets/furniture/PLANT/PLANT.png +0 -0
- package/public/assets/furniture/PLANT/manifest.json +13 -0
- package/public/assets/furniture/PLANT_2/PLANT_2.png +0 -0
- package/public/assets/furniture/PLANT_2/manifest.json +13 -0
- package/public/assets/furniture/POT/POT.png +0 -0
- package/public/assets/furniture/POT/manifest.json +13 -0
- package/public/assets/furniture/SMALL_PAINTING/SMALL_PAINTING.png +0 -0
- package/public/assets/furniture/SMALL_PAINTING/manifest.json +13 -0
- package/public/assets/furniture/SMALL_PAINTING_2/SMALL_PAINTING_2.png +0 -0
- package/public/assets/furniture/SMALL_PAINTING_2/manifest.json +13 -0
- package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_FRONT.png +0 -0
- package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_SIDE.png +0 -0
- package/public/assets/furniture/SMALL_TABLE/manifest.json +33 -0
- package/public/assets/furniture/SOFA/SOFA_BACK.png +0 -0
- package/public/assets/furniture/SOFA/SOFA_FRONT.png +0 -0
- package/public/assets/furniture/SOFA/SOFA_SIDE.png +0 -0
- package/public/assets/furniture/SOFA/manifest.json +44 -0
- package/public/assets/furniture/TABLE_FRONT/TABLE_FRONT.png +0 -0
- package/public/assets/furniture/TABLE_FRONT/manifest.json +13 -0
- package/public/assets/furniture/WHITEBOARD/WHITEBOARD.png +0 -0
- package/public/assets/furniture/WHITEBOARD/manifest.json +13 -0
- package/public/assets/furniture/WOODEN_BENCH/WOODEN_BENCH.png +0 -0
- package/public/assets/furniture/WOODEN_BENCH/manifest.json +13 -0
- package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_BACK.png +0 -0
- package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png +0 -0
- package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_SIDE.png +0 -0
- package/public/assets/furniture/WOODEN_CHAIR/manifest.json +44 -0
- package/public/assets/walls/wall_0.png +0 -0
- package/scripts/setup.ts +158 -0
- package/server.ts +53 -0
- package/src/app/api/focus-terminal/route.ts +65 -0
- package/src/app/api/hooks/notification/route.ts +19 -0
- package/src/app/api/hooks/post-tool-use/route.ts +26 -0
- package/src/app/api/hooks/pre-tool-use/route.ts +189 -0
- package/src/app/api/hooks/session-end/route.ts +31 -0
- package/src/app/api/hooks/session-start/route.ts +47 -0
- package/src/app/api/hooks/stop/route.ts +19 -0
- package/src/app/api/hooks/user-prompt/route.ts +92 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +14 -0
- package/src/app/layout.tsx +21 -0
- package/src/app/page.tsx +5 -0
- package/src/components/ApprovalToast.tsx +132 -0
- package/src/components/OfficeCanvas.tsx +311 -0
- package/src/components/Terminal.tsx +177 -0
- package/src/components/TerminalTile.tsx +181 -0
- package/src/components/WorkerPanel.tsx +261 -0
- package/src/components/WorkerPopup.tsx +116 -0
- package/src/game/asset-loader.ts +172 -0
- package/src/game/office-layout.ts +287 -0
- package/src/game/renderer.ts +369 -0
- package/src/game/sprites.ts +133 -0
- package/src/game/worker-entity.ts +219 -0
- package/src/hooks/usePixelOffice.ts +318 -0
- package/src/hooks/useRecentCwds.ts +27 -0
- package/src/lib/approval-queue.ts +67 -0
- package/src/lib/pty-manager.ts +267 -0
- package/src/lib/store.ts +181 -0
- package/src/lib/tool-classifier.ts +224 -0
- package/src/lib/transcript.ts +109 -0
- package/src/lib/types.ts +58 -0
- package/src/lib/ws-server.ts +270 -0
- 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
|
+
}
|