eojeon-cli 0.0.0-stage → 0.2.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/CHANGELOG.md +39 -0
- package/LICENSE +26 -0
- package/README.md +251 -2
- package/cli/eojeon.js +104 -0
- package/court.config.json +325 -0
- package/package.json +60 -4
- package/public/app.js +405 -0
- package/public/court-types.js +33 -0
- package/public/demo.js +100 -0
- package/public/fonts/D2Coding-Ver1.4.0-20261003.ttf +0 -0
- package/public/fonts/Galmuri11-Bold.woff2 +0 -0
- package/public/fonts/Galmuri11.woff2 +0 -0
- package/public/fonts/Galmuri7.woff2 +0 -0
- package/public/fonts/Galmuri9.woff2 +0 -0
- package/public/fonts/LICENSE-D2Coding.txt +97 -0
- package/public/fonts/LICENSE-Galmuri.txt +93 -0
- package/public/index.html +98 -0
- package/public/scene.js +1082 -0
- package/public/style.css +319 -0
- package/scripts/fix-pty.js +25 -0
- package/server/agents.js +78 -0
- package/server/hook.js +68 -0
- package/server/index.js +541 -0
- package/server/sanitize.js +155 -0
- package/server/sessions.js +115 -0
- package/server/watch.js +96 -0
- package/templates/agents/studio/animation-expert.md +29 -0
- package/templates/agents/studio/apple-designer.md +28 -0
- package/templates/agents/studio/branding-expert.md +29 -0
- package/templates/agents/studio/copywriter.md +28 -0
- package/templates/agents/studio/data-analyst.md +29 -0
- package/templates/agents/studio/marketer.md +28 -0
- package/templates/agents/studio/pr-lead.md +28 -0
- package/templates/agents/studio/product-designer.md +29 -0
- package/templates/agents/studio/sns-manager.md +28 -0
- package/templates/agents/studio/uiux-expert.md +31 -0
- package/templates/agents/studio/video-producer.md +29 -0
- package/templates/agents/yukjo/byeongjo.md +29 -0
- package/templates/agents/yukjo/gongjo.md +29 -0
- package/templates/agents/yukjo/hojo.md +29 -0
- package/templates/agents/yukjo/hyeongjo.md +29 -0
- package/templates/agents/yukjo/ijo.md +29 -0
- package/templates/agents/yukjo/yejo.md +29 -0
package/public/scene.js
ADDED
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
// Pixel court scene: canvas for the drawing, a DOM overlay (same logical
|
|
2
|
+
// coordinates) for text, bubbles, the petition panel and stamps.
|
|
3
|
+
// Everything here is hand-drawn rectangles — no traced photos, no real seals.
|
|
4
|
+
|
|
5
|
+
import { lookupOfficial } from './court-types.js';
|
|
6
|
+
|
|
7
|
+
export const W = 400;
|
|
8
|
+
export const H = 224;
|
|
9
|
+
|
|
10
|
+
const FPS = 8; // sprite frame rate (concept: 8fps, walk 4 frames, brush 2 frames)
|
|
11
|
+
const WALK_SPEED = 64; // logical px per second
|
|
12
|
+
const MAX_BUBBLES = 3; // more than this at once → only the newest show; the rest are in the 사관 일지
|
|
13
|
+
// petition panel sits top-right over the back wall, clear of every desk and the throne
|
|
14
|
+
export const PETITION_BOX = { x: 238, y: 4, w: 158 };
|
|
15
|
+
|
|
16
|
+
const EMBLEMS = {
|
|
17
|
+
scrolls: ['1111', '1001', '1111', '1001'],
|
|
18
|
+
grid: ['1111', '1010', '1111', '1010'],
|
|
19
|
+
abacus: ['1010', '1111', '0101', '1111'],
|
|
20
|
+
flag: ['1110', '1100', '1000', '1000'],
|
|
21
|
+
brush: ['0001', '0010', '0100', '1100'],
|
|
22
|
+
seal: ['1111', '1001', '1001', '1111'],
|
|
23
|
+
scale: ['1001', '1111', '0110', '0110'],
|
|
24
|
+
hoof: ['0110', '1001', '1001', '1001'],
|
|
25
|
+
square: ['1000', '1000', '1000', '1111'],
|
|
26
|
+
beam: ['1111', '0110', '0110', '0110'],
|
|
27
|
+
drum: ['0110', '1111', '1111', '0110'],
|
|
28
|
+
album: ['1110', '1010', '1110', '0000'],
|
|
29
|
+
plain: ['0000', '0110', '0110', '0000'],
|
|
30
|
+
steps: ['0001', '0011', '0111', '1111'],
|
|
31
|
+
shield: ['1111', '1111', '1111', '0110'],
|
|
32
|
+
lens: ['0110', '1001', '0110', '0001'],
|
|
33
|
+
hammer: ['1111', '1111', '0100', '0100'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// seat index -> position. Row-major, nearest the throne first; inner column first.
|
|
37
|
+
const SEATS = {
|
|
38
|
+
east: [[124, 104], [62, 104], [124, 140], [62, 140], [124, 176], [62, 176]],
|
|
39
|
+
west: [[276, 104], [338, 104], [276, 140], [338, 140], [276, 176], [338, 176]],
|
|
40
|
+
};
|
|
41
|
+
const GUEST_SPOTS = [[168, 206], [232, 206], [146, 214], [254, 214], [190, 218], [210, 218], [102, 214], [298, 214]];
|
|
42
|
+
const DOOR = [200, H + 24];
|
|
43
|
+
const THRONE_FRONT = [200, 92];
|
|
44
|
+
const HISTORIAN = [84, 76];
|
|
45
|
+
|
|
46
|
+
function shade(hex, f) {
|
|
47
|
+
const n = parseInt(hex.slice(1), 16);
|
|
48
|
+
const r = Math.max(0, Math.min(255, Math.round(((n >> 16) & 255) * f)));
|
|
49
|
+
const g = Math.max(0, Math.min(255, Math.round(((n >> 8) & 255) * f)));
|
|
50
|
+
const b = Math.max(0, Math.min(255, Math.round((n & 255) * f)));
|
|
51
|
+
return '#' + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fmt(tpl, vars) {
|
|
55
|
+
return String(tpl || '').replace(/\{(\w+)\}/g, (_, k) => (vars[k] == null ? '' : String(vars[k])));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function el(tag, cls, parent, text) {
|
|
59
|
+
const e = document.createElement(tag);
|
|
60
|
+
if (cls) e.className = cls;
|
|
61
|
+
if (text != null) e.textContent = text;
|
|
62
|
+
if (parent) parent.appendChild(e);
|
|
63
|
+
return e;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function elapsedText(ms) {
|
|
67
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
68
|
+
if (s < 60) return `${s}초째`;
|
|
69
|
+
const m = Math.floor(s / 60);
|
|
70
|
+
if (m < 60) return `${m}분째`;
|
|
71
|
+
return `${Math.floor(m / 60)}시간 ${m % 60}분째`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class Official {
|
|
75
|
+
constructor(court, { key, def, kind, id }) {
|
|
76
|
+
this.court = court;
|
|
77
|
+
this.key = key; // agent type ('main' for Claude itself)
|
|
78
|
+
this.id = id || key;
|
|
79
|
+
this.kind = kind; // 'main' | 'seat' | 'guest' | 'historian'
|
|
80
|
+
this.title = def.title;
|
|
81
|
+
this.role = def.role || '';
|
|
82
|
+
this.robe = def.robe || 'nok';
|
|
83
|
+
this.emblem = def.emblem || 'plain';
|
|
84
|
+
this.home = [0, 0];
|
|
85
|
+
this.x = DOOR[0];
|
|
86
|
+
this.y = DOOR[1];
|
|
87
|
+
this.path = [];
|
|
88
|
+
this.onArrive = null;
|
|
89
|
+
this.state = 'away'; // away | walking | seated | working | standing
|
|
90
|
+
this.workUntil = 0;
|
|
91
|
+
this.workStart = 0;
|
|
92
|
+
this.carry = false;
|
|
93
|
+
this.blotUntil = 0;
|
|
94
|
+
this.visible = false;
|
|
95
|
+
this.blinkSeed = Math.random() * 4000;
|
|
96
|
+
this.tag = el('div', `tag robe-${this.robe}`, court.overlay, this.title);
|
|
97
|
+
this.bubble = el('div', 'bubble', court.overlay);
|
|
98
|
+
this.bubbleMain = el('div', 'b-main', this.bubble);
|
|
99
|
+
this.bubbleSub = el('div', 'b-sub', this.bubble);
|
|
100
|
+
this.bubbleUntil = 0;
|
|
101
|
+
this.bubbleSticky = false;
|
|
102
|
+
this.tag.title = this.role;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
place(x, y) {
|
|
106
|
+
this.x = x;
|
|
107
|
+
this.y = y;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
walk(points, onArrive) {
|
|
111
|
+
this.path = points.map((p) => [...p]);
|
|
112
|
+
this.onArrive = onArrive || null;
|
|
113
|
+
this.state = 'walking';
|
|
114
|
+
this.visible = true;
|
|
115
|
+
if (this.court.reduced) {
|
|
116
|
+
const last = this.path[this.path.length - 1];
|
|
117
|
+
this.x = last[0];
|
|
118
|
+
this.y = last[1];
|
|
119
|
+
this.path = [];
|
|
120
|
+
this.arrive();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
arrive() {
|
|
125
|
+
const cb = this.onArrive;
|
|
126
|
+
this.onArrive = null;
|
|
127
|
+
this.state = this.atHome() && this.kind !== 'guest' ? 'seated' : 'standing';
|
|
128
|
+
if (cb) cb();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
atHome() {
|
|
132
|
+
return Math.abs(this.x - this.home[0]) < 1 && Math.abs(this.y - this.home[1]) < 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
say(main, sub = '', ms = 3200, sticky = false) {
|
|
136
|
+
this.bubbleMain.textContent = main;
|
|
137
|
+
this.bubbleSub.textContent = sub;
|
|
138
|
+
this.bubbleSub.style.display = sub ? '' : 'none';
|
|
139
|
+
this.bubbleUntil = performance.now() + ms;
|
|
140
|
+
this.bubbleSticky = sticky;
|
|
141
|
+
this.bubbleAt = performance.now();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
hush() {
|
|
145
|
+
this.bubbleUntil = 0;
|
|
146
|
+
this.bubbleSticky = false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
work(text) {
|
|
150
|
+
this.workStart = performance.now();
|
|
151
|
+
this.workUntil = Infinity;
|
|
152
|
+
this.workText = text;
|
|
153
|
+
if (this.state === 'seated' || this.state === 'standing') this.state = 'working';
|
|
154
|
+
this.say(text, '', 60_000, true);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
doneWork(ok = true, failText = '') {
|
|
158
|
+
this.workUntil = performance.now() + 700;
|
|
159
|
+
this.bubbleSticky = false;
|
|
160
|
+
if (!ok) {
|
|
161
|
+
this.blotUntil = performance.now() + 4000;
|
|
162
|
+
this.say(failText, '두루마리에 먹물이 번졌사옵니다', 4000);
|
|
163
|
+
} else {
|
|
164
|
+
this.bubbleUntil = performance.now() + 1400;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
step(dt, now) {
|
|
169
|
+
if (this.path.length) {
|
|
170
|
+
const [tx, ty] = this.path[0];
|
|
171
|
+
const dx = tx - this.x;
|
|
172
|
+
const dy = ty - this.y;
|
|
173
|
+
const d = Math.hypot(dx, dy);
|
|
174
|
+
const v = WALK_SPEED * dt;
|
|
175
|
+
if (d <= v) {
|
|
176
|
+
this.x = tx;
|
|
177
|
+
this.y = ty;
|
|
178
|
+
this.path.shift();
|
|
179
|
+
if (!this.path.length) this.arrive();
|
|
180
|
+
} else {
|
|
181
|
+
this.x += (dx / d) * v;
|
|
182
|
+
this.y += (dy / d) * v;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (this.state === 'working' && now > this.workUntil) this.state = this.atHome() && this.kind !== 'guest' ? 'seated' : 'standing';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
updateDom(now) {
|
|
189
|
+
const show = this.visible;
|
|
190
|
+
const bx = Math.round(this.x);
|
|
191
|
+
const by = Math.round(this.y);
|
|
192
|
+
this.tag.style.display = show && this.state !== 'walking' ? '' : 'none';
|
|
193
|
+
this.tag.style.transform = `translate(${bx}px, ${by + 2}px) translateX(-50%)`;
|
|
194
|
+
// position is decided by Court.layoutBubbles (overlap avoidance, max 3 at once)
|
|
195
|
+
this.bubbleOn = show && (this.bubbleSticky || now < this.bubbleUntil);
|
|
196
|
+
this.anchor = [bx, by - 34];
|
|
197
|
+
if (!this.bubbleOn) this.bubble.classList.remove('on');
|
|
198
|
+
if (this.bubbleOn) {
|
|
199
|
+
if (this.state === 'working' && this.workUntil === Infinity) {
|
|
200
|
+
this.bubbleSub.style.display = '';
|
|
201
|
+
this.bubbleSub.textContent = `${this.court.ph.workingSuffix || '중이옵니다'} · ${elapsedText(now - this.workStart)}`;
|
|
202
|
+
}
|
|
203
|
+
this.bubble.classList.toggle('blot', now < this.blotUntil);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
draw(ctx, now, px) {
|
|
208
|
+
if (!this.visible) return;
|
|
209
|
+
const seated = this.state === 'seated' || (this.state === 'working' && this.atHome() && this.kind !== 'guest');
|
|
210
|
+
const frame = Math.floor((now / 1000) * FPS);
|
|
211
|
+
const walking = this.state === 'walking';
|
|
212
|
+
const working = this.state === 'working';
|
|
213
|
+
const wf = walking ? frame % 4 : 0;
|
|
214
|
+
const bob = walking && (wf === 1 || wf === 3) ? -1 : 0;
|
|
215
|
+
const ox = Math.round(this.x) - 16;
|
|
216
|
+
const oy = Math.round(this.y) - 31 + bob + (seated ? 3 : 0);
|
|
217
|
+
const P = this.court.pix;
|
|
218
|
+
const robe = P[this.robe] || P.nok;
|
|
219
|
+
const robeD = shade(robe, 0.72);
|
|
220
|
+
const O = P.outline;
|
|
221
|
+
const r = (x, y, w, h, c) => px(ox + x, oy + y, w, h, c);
|
|
222
|
+
|
|
223
|
+
// shadow
|
|
224
|
+
px(Math.round(this.x) - 9, Math.round(this.y) + (seated ? 2 : 0), 18, 2, 'rgba(0,0,0,0.35)');
|
|
225
|
+
|
|
226
|
+
// samo (official's hat): crown + wings
|
|
227
|
+
r(12, 0, 8, 1, O);
|
|
228
|
+
r(11, 1, 10, 6, O);
|
|
229
|
+
r(12, 1, 8, 5, P.heuk);
|
|
230
|
+
r(13, 1, 6, 1, shade(P.heuk, 1.5));
|
|
231
|
+
r(5, 4, 22, 3, O);
|
|
232
|
+
r(6, 5, 5, 1, P.heuk);
|
|
233
|
+
r(21, 5, 5, 1, P.heuk);
|
|
234
|
+
// face
|
|
235
|
+
r(11, 7, 10, 7, O);
|
|
236
|
+
r(12, 7, 8, 6, P.skin);
|
|
237
|
+
r(12, 12, 8, 1, P.skinShade);
|
|
238
|
+
const blink = (now + this.blinkSeed) % 4000 < 130;
|
|
239
|
+
if (blink) {
|
|
240
|
+
r(14, 10, 1, 1, P.skinShade);
|
|
241
|
+
r(17, 10, 1, 1, P.skinShade);
|
|
242
|
+
} else {
|
|
243
|
+
r(14, 9, 1, 2, O);
|
|
244
|
+
r(17, 9, 1, 2, O);
|
|
245
|
+
}
|
|
246
|
+
// robe (dallyeong)
|
|
247
|
+
const rows = [
|
|
248
|
+
[10, 12], [9, 14], [9, 14], [8, 16], [8, 16], [8, 16], [7, 18], [7, 18],
|
|
249
|
+
[7, 18], [7, 18], [6, 20], [6, 20], [6, 20], [6, 20], [6, 20], [6, 20],
|
|
250
|
+
];
|
|
251
|
+
rows.forEach(([x0, w], i) => {
|
|
252
|
+
r(x0 - 1, 14 + i, w + 2, 1, O);
|
|
253
|
+
r(x0, 14 + i, w, 1, robe);
|
|
254
|
+
});
|
|
255
|
+
r(5, 29, 22, 1, O);
|
|
256
|
+
// round collar
|
|
257
|
+
r(13, 14, 6, 1, P.paper);
|
|
258
|
+
r(14, 15, 4, 1, robeD);
|
|
259
|
+
// sleeves (darker), hands
|
|
260
|
+
const brushUp = working && frame % 2 === 0;
|
|
261
|
+
// walk cycle (4 frames): arms swing opposite to the stepping foot
|
|
262
|
+
const swing = walking ? [0, -1, 0, 1][wf] : 0;
|
|
263
|
+
r(6, 18 + swing, 3, 8, robeD);
|
|
264
|
+
r(6, 26 + swing, 3, 2, P.skin);
|
|
265
|
+
if (working) {
|
|
266
|
+
// right arm works the brush over the desk / scroll
|
|
267
|
+
r(21, 18, 4, 4, robeD);
|
|
268
|
+
r(22, brushUp ? 20 : 22, 3, 2, P.skin);
|
|
269
|
+
r(24, brushUp ? 16 : 18, 1, 5, O); // brush
|
|
270
|
+
r(24, brushUp ? 21 : 23, 1, 1, P.heuk);
|
|
271
|
+
} else {
|
|
272
|
+
r(23, 18 - swing, 3, 8, robeD);
|
|
273
|
+
r(23, 26 - swing, 3, 2, P.skin);
|
|
274
|
+
}
|
|
275
|
+
// hyungbae (chest square): job motif, border 2 lines for hong(당상관), 1 for others
|
|
276
|
+
const border = (this.court.cfg.robes?.[this.robe]?.border) || 1;
|
|
277
|
+
r(12, 16, 8, 8, P.hwang);
|
|
278
|
+
if (border >= 2) {
|
|
279
|
+
r(13, 17, 6, 6, P.heuk);
|
|
280
|
+
r(13, 17, 6, 1, P.hwang);
|
|
281
|
+
r(13, 22, 6, 1, P.hwang);
|
|
282
|
+
r(13, 17, 1, 6, P.hwang);
|
|
283
|
+
r(18, 17, 1, 6, P.hwang);
|
|
284
|
+
r(14, 18, 4, 4, P.heuk);
|
|
285
|
+
} else {
|
|
286
|
+
r(13, 17, 6, 6, P.heuk);
|
|
287
|
+
}
|
|
288
|
+
const em = EMBLEMS[this.emblem] || EMBLEMS.plain;
|
|
289
|
+
em.forEach((row, yy) => [...row].forEach((b, xx) => b === '1' && r(14 + xx, 18 + yy, 1, 1, P.paper)));
|
|
290
|
+
// belt (seodae)
|
|
291
|
+
r(8, 25, 16, 1, P.hwang);
|
|
292
|
+
// feet
|
|
293
|
+
const lf = walking && wf === 1 ? -1 : 0;
|
|
294
|
+
const rf = walking && wf === 3 ? -1 : 0;
|
|
295
|
+
r(10, 30 + lf, 5, 2, O);
|
|
296
|
+
r(17, 30 + rf, 5, 2, O);
|
|
297
|
+
// carried scroll
|
|
298
|
+
if (this.carry) {
|
|
299
|
+
r(4, 23, 3, 7, O);
|
|
300
|
+
r(4, 24, 3, 5, P.paper);
|
|
301
|
+
r(3, 23, 5, 1, P.hwang);
|
|
302
|
+
r(3, 29, 5, 1, P.hwang);
|
|
303
|
+
}
|
|
304
|
+
// desk in front when seated
|
|
305
|
+
if (seated) this.court.drawDesk(Math.round(this.home[0]), Math.round(this.home[1]), working && frame % 2 === 0);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
remove() {
|
|
309
|
+
this.tag.remove();
|
|
310
|
+
this.bubble.remove();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export class Court {
|
|
315
|
+
constructor({ canvas, overlay, config, onPetitionAction }) {
|
|
316
|
+
this.canvas = canvas;
|
|
317
|
+
this.ctx = canvas.getContext('2d');
|
|
318
|
+
this.overlay = overlay;
|
|
319
|
+
this.cfg = config;
|
|
320
|
+
this.pix = config.pixel;
|
|
321
|
+
this.ph = config.phrases || {};
|
|
322
|
+
this.onPetitionAction = onPetitionAction;
|
|
323
|
+
this.reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
324
|
+
canvas.width = W;
|
|
325
|
+
canvas.height = H;
|
|
326
|
+
this.officials = new Map(); // id -> Official
|
|
327
|
+
this.byAgent = new Map(); // agent_id -> official id
|
|
328
|
+
this.guestUsed = new Set();
|
|
329
|
+
this.seatUsed = new Set(); // 'east:0' ...
|
|
330
|
+
this.fixedSeats = new Map(); // 'east:1' -> config key that owns that desk
|
|
331
|
+
for (const [k, d] of Object.entries(config.officials || {})) {
|
|
332
|
+
if (!k.startsWith('_') && d && d.side && Number.isInteger(d.seat)) this.fixedSeats.set(`${d.side}:${d.seat}`, k);
|
|
333
|
+
}
|
|
334
|
+
this.walkQueue = [];
|
|
335
|
+
this.petition = null;
|
|
336
|
+
this.stats = { petitions: 0, ok: 0, no: 0, hold: 0 };
|
|
337
|
+
this.kingAnim = null;
|
|
338
|
+
this.dusk = 0;
|
|
339
|
+
this.duskTarget = 0;
|
|
340
|
+
this.historianWriteUntil = 0;
|
|
341
|
+
this.bg = this.drawBackground();
|
|
342
|
+
this.buildOverlay();
|
|
343
|
+
this.spawnMain();
|
|
344
|
+
this.last = performance.now();
|
|
345
|
+
this.paused = false;
|
|
346
|
+
const loop = (t) => {
|
|
347
|
+
this.frame(t);
|
|
348
|
+
requestAnimationFrame(loop);
|
|
349
|
+
};
|
|
350
|
+
requestAnimationFrame(loop);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------- static background ----------
|
|
354
|
+
drawBackground() {
|
|
355
|
+
const c = document.createElement('canvas');
|
|
356
|
+
c.width = W;
|
|
357
|
+
c.height = H;
|
|
358
|
+
const g = c.getContext('2d');
|
|
359
|
+
const P = this.pix;
|
|
360
|
+
const pal = this.cfg.palette;
|
|
361
|
+
const R = (x, y, w, h, col) => {
|
|
362
|
+
g.fillStyle = col;
|
|
363
|
+
g.fillRect(x, y, w, h);
|
|
364
|
+
};
|
|
365
|
+
// night court base
|
|
366
|
+
R(0, 0, W, H, pal.paper);
|
|
367
|
+
// back wall of the hall
|
|
368
|
+
R(0, 0, W, 78, '#1A1D24');
|
|
369
|
+
// lattice doors on the back wall
|
|
370
|
+
for (let x = 8; x < W; x += 24) {
|
|
371
|
+
if (x > 136 && x < 256) continue;
|
|
372
|
+
R(x, 16, 16, 50, '#20242C');
|
|
373
|
+
for (let yy = 18; yy < 64; yy += 6) R(x + 1, yy, 14, 1, '#2A2F38');
|
|
374
|
+
for (let xx = x + 3; xx < x + 16; xx += 5) R(xx, 17, 1, 48, '#2A2F38');
|
|
375
|
+
}
|
|
376
|
+
// dancheong band: one line of nok / cheong / hong (concept 4-3)
|
|
377
|
+
const band = [P.nok, P.cheong, P.hong];
|
|
378
|
+
for (let x = 0; x < W; x += 6) R(x, 4, 6, 4, band[(x / 6) % 3]);
|
|
379
|
+
R(0, 2, W, 2, P.heuk);
|
|
380
|
+
R(0, 8, W, 1, P.outline);
|
|
381
|
+
// pillars
|
|
382
|
+
for (const x of [40, 112, 280, 352]) {
|
|
383
|
+
R(x - 1, 9, 10, 72, P.outline);
|
|
384
|
+
R(x, 9, 8, 72, P.hong);
|
|
385
|
+
R(x + 1, 9, 2, 72, shade(P.hong, 1.25));
|
|
386
|
+
R(x - 2, 78, 12, 4, P.stoneShade);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---- Irworobongdo (sun, moon, five peaks), redrawn as plain shapes ----
|
|
390
|
+
const sx = 144;
|
|
391
|
+
const sy = 12;
|
|
392
|
+
const sw = 112;
|
|
393
|
+
const sh = 50;
|
|
394
|
+
R(sx - 2, sy - 2, sw + 4, sh + 4, P.outline);
|
|
395
|
+
R(sx - 1, sy - 1, sw + 2, sh + 2, P.hwang);
|
|
396
|
+
R(sx, sy, sw, sh, '#1C3F5C'); // night-blue sky
|
|
397
|
+
// panel seams of the folding screen
|
|
398
|
+
for (let i = 1; i < 5; i++) R(sx + (sw / 5) * i, sy, 1, sh, 'rgba(0,0,0,0.25)');
|
|
399
|
+
// moon (left, white) and sun (right, red)
|
|
400
|
+
const disc = (cx, cy, rad, col) => {
|
|
401
|
+
for (let yy = -rad; yy <= rad; yy++) {
|
|
402
|
+
const w = Math.round(Math.sqrt(rad * rad - yy * yy));
|
|
403
|
+
R(cx - w, cy + yy, w * 2 + 1, 1, col);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
disc(sx + 20, sy + 10, 5, P.paper);
|
|
407
|
+
disc(sx + sw - 20, sy + 10, 5, P.inju);
|
|
408
|
+
// five peaks
|
|
409
|
+
const peak = (cx, base, hgt, half, col) => {
|
|
410
|
+
for (let yy = 0; yy < hgt; yy++) {
|
|
411
|
+
const w = Math.round((half * yy) / hgt);
|
|
412
|
+
R(cx - w, base - hgt + yy, w * 2 + 1, 1, col);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
const pk = P.nok;
|
|
416
|
+
const pkL = shade(P.nok, 1.35);
|
|
417
|
+
peak(sx + 56, sy + 40, 30, 16, pk);
|
|
418
|
+
peak(sx + 56, sy + 40, 30, 6, pkL);
|
|
419
|
+
peak(sx + 30, sy + 40, 22, 14, pk);
|
|
420
|
+
peak(sx + 82, sy + 40, 22, 14, pk);
|
|
421
|
+
peak(sx + 12, sy + 42, 16, 12, shade(pk, 0.85));
|
|
422
|
+
peak(sx + 100, sy + 42, 16, 12, shade(pk, 0.85));
|
|
423
|
+
// waterfalls
|
|
424
|
+
R(sx + 43, sy + 26, 1, 14, P.paper);
|
|
425
|
+
R(sx + 69, sy + 26, 1, 14, P.paper);
|
|
426
|
+
// water: two wave lines
|
|
427
|
+
R(sx, sy + 40, sw, 10, P.cheong);
|
|
428
|
+
for (let x = sx; x < sx + sw; x += 8) {
|
|
429
|
+
R(x + 1, sy + 42, 4, 1, P.paper);
|
|
430
|
+
R(x + 5, sy + 46, 4, 1, P.paper);
|
|
431
|
+
}
|
|
432
|
+
// two pines
|
|
433
|
+
for (const tx of [sx + 4, sx + sw - 8]) {
|
|
434
|
+
R(tx + 1, sy + 22, 2, 20, P.hong);
|
|
435
|
+
R(tx - 2, sy + 18, 8, 3, P.nok);
|
|
436
|
+
R(tx - 1, sy + 14, 6, 3, P.nok);
|
|
437
|
+
R(tx - 3, sy + 24, 9, 2, P.nok);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// canopy (datjip) outline above the throne
|
|
441
|
+
R(sx - 8, 9, sw + 16, 3, P.outline);
|
|
442
|
+
R(sx - 6, 10, sw + 12, 1, P.hwang);
|
|
443
|
+
|
|
444
|
+
// throne: three steps + chair (no dragon carving)
|
|
445
|
+
R(176, 50, 48, 14, P.outline);
|
|
446
|
+
R(177, 51, 46, 12, P.hong);
|
|
447
|
+
R(179, 52, 42, 2, P.hwang);
|
|
448
|
+
R(180, 44, 40, 8, P.outline);
|
|
449
|
+
R(181, 45, 38, 6, P.hong);
|
|
450
|
+
R(181, 45, 38, 1, P.hwang);
|
|
451
|
+
R(176, 62, 4, 6, P.hwang);
|
|
452
|
+
R(220, 62, 4, 6, P.hwang);
|
|
453
|
+
const steps = [[168, 66, 64], [160, 70, 80], [152, 74, 96]];
|
|
454
|
+
for (const [x, y, w] of steps) {
|
|
455
|
+
R(x, y, w, 4, P.stoneShade);
|
|
456
|
+
R(x, y, w, 1, P.stone);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// courtyard: night-toned stone slabs (bakseok)
|
|
460
|
+
for (let y = 78; y < H; y += 8) {
|
|
461
|
+
for (let x = ((y / 8) % 2) * 6; x < W; x += 12) {
|
|
462
|
+
const v = ((x * 7 + y * 13) % 5) / 100;
|
|
463
|
+
R(x, y, 11, 7, shade('#2A2C31', 0.92 + v));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
R(0, 78, W, 2, P.outline);
|
|
467
|
+
// royal road (eodo) in the middle
|
|
468
|
+
R(182, 80, 36, H - 80, '#3A3933');
|
|
469
|
+
for (let y = 80; y < H; y += 10) R(183, y, 34, 1, '#46443D');
|
|
470
|
+
R(181, 80, 1, H - 80, P.outline);
|
|
471
|
+
R(218, 80, 1, H - 80, P.outline);
|
|
472
|
+
|
|
473
|
+
// rank stones: 6 per side, labelled in the overlay with the office's first letter
|
|
474
|
+
for (const side of ['east', 'west']) {
|
|
475
|
+
for (const [x, y] of SEATS[side]) {
|
|
476
|
+
const stx = side === 'east' ? x + 22 : x - 30;
|
|
477
|
+
R(stx, y - 14, 8, 14, P.outline);
|
|
478
|
+
R(stx + 1, y - 13, 6, 13, P.stone);
|
|
479
|
+
R(stx + 1, y - 13, 6, 1, '#D8D2C6');
|
|
480
|
+
R(stx + 1, y - 1, 6, 1, P.stoneShade);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// gate at the bottom
|
|
484
|
+
R(176, H - 4, 48, 4, P.hong);
|
|
485
|
+
R(176, H - 5, 48, 1, P.hwang);
|
|
486
|
+
return c;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
drawDesk(x, y, wet) {
|
|
490
|
+
const P = this.pix;
|
|
491
|
+
const g = this.ctx;
|
|
492
|
+
const R = (a, b, w, h, col) => {
|
|
493
|
+
g.fillStyle = col;
|
|
494
|
+
g.fillRect(a, b, w, h);
|
|
495
|
+
};
|
|
496
|
+
R(x - 11, y - 9, 22, 8, P.outline);
|
|
497
|
+
R(x - 10, y - 8, 20, 6, '#6E4B2A');
|
|
498
|
+
R(x - 10, y - 8, 20, 1, '#8A5F36');
|
|
499
|
+
R(x - 9, y - 2, 2, 3, P.outline);
|
|
500
|
+
R(x + 7, y - 2, 2, 3, P.outline);
|
|
501
|
+
R(x - 6, y - 9, 10, 3, P.paper); // paper on the desk
|
|
502
|
+
if (wet) R(x - 3, y - 8, 3, 1, P.heuk);
|
|
503
|
+
R(x + 5, y - 9, 3, 2, P.heuk); // inkstone
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
buildOverlay() {
|
|
507
|
+
const o = this.overlay;
|
|
508
|
+
o.style.width = W + 'px';
|
|
509
|
+
o.style.height = H + 'px';
|
|
510
|
+
// stone labels: fixed desks show their owner's letter; free desks get one when someone sits
|
|
511
|
+
const all = this.cfg.officials || {};
|
|
512
|
+
this.stoneLabels = new Map();
|
|
513
|
+
for (const side of ['east', 'west']) {
|
|
514
|
+
SEATS[side].forEach(([x, y], i) => {
|
|
515
|
+
const k = `${side}:${i}`;
|
|
516
|
+
const stx = side === 'west' ? x - 30 : x + 22;
|
|
517
|
+
const s = el('div', 'stone-label', o, '');
|
|
518
|
+
s.style.transform = `translate(${stx + 4}px, ${y - 12}px) translateX(-50%)`;
|
|
519
|
+
const owner = all[this.fixedSeats.get(k)];
|
|
520
|
+
s.dataset.text = owner ? owner.short || owner.title[0] : '';
|
|
521
|
+
s.dataset.title = owner ? `${owner.title} — ${owner.role || ''}` : '';
|
|
522
|
+
s.textContent = s.dataset.text;
|
|
523
|
+
s.title = s.dataset.title;
|
|
524
|
+
this.stoneLabels.set(k, s);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
this.more = el('div', 'bubble-more', o);
|
|
528
|
+
// decree scroll
|
|
529
|
+
this.decree = el('div', 'decree', o);
|
|
530
|
+
el('div', 'd-head', this.decree, '교지');
|
|
531
|
+
this.decreeText = el('div', 'd-text', this.decree);
|
|
532
|
+
this.decreeFoot = el('div', 'd-foot', this.decree, this.ph.decree || '교지를 반포하였사옵니다');
|
|
533
|
+
// petition panel
|
|
534
|
+
this.pet = el('div', 'petition', o);
|
|
535
|
+
this.petHead = el('div', 'p-head', this.pet);
|
|
536
|
+
this.petAsk = el('div', 'p-ask', this.pet, this.ph.permission || '윤허를 청하옵니다');
|
|
537
|
+
this.petWait = el('div', 'p-wait', this.pet);
|
|
538
|
+
const btns = el('div', 'p-btns', this.pet);
|
|
539
|
+
for (const k of ['ok', 'no', 'hold']) {
|
|
540
|
+
const st = (this.cfg.stamps || {})[k] || { label: k };
|
|
541
|
+
const b = el('button', `p-btn p-${k}`, btns);
|
|
542
|
+
el('span', 'mk', b, st.mark || '');
|
|
543
|
+
el('span', 'lb', b, st.label);
|
|
544
|
+
b.addEventListener('click', () => this.petitionAction(k));
|
|
545
|
+
}
|
|
546
|
+
el('div', 'p-hint', this.pet, this.ph.permissionHint || '');
|
|
547
|
+
el('div', 'p-ext', this.pet, '바깥 터미널의 조회입니다 — 그 터미널에서 답하소서');
|
|
548
|
+
this.pet.title = this.ph.permissionHint || '';
|
|
549
|
+
// stamp + plaque
|
|
550
|
+
this.stamp = el('div', 'stamp', o);
|
|
551
|
+
this.stampMark = el('div', 'st-mark', this.stamp);
|
|
552
|
+
this.stampLabel = el('div', 'st-label', this.stamp);
|
|
553
|
+
this.stampFull = el('div', 'stamp-full', o);
|
|
554
|
+
this.plaque = el('div', 'plaque', o);
|
|
555
|
+
this.toast = el('div', 'toast', o);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
spawnMain() {
|
|
559
|
+
const def = this.cfg.officials.main;
|
|
560
|
+
const m = new Official(this, { key: 'main', def, kind: 'main', id: 'main' });
|
|
561
|
+
m.home = [...SEATS[def.side][def.seat]];
|
|
562
|
+
m.seat = `${def.side}:${def.seat}`;
|
|
563
|
+
this.seatUsed.add(m.seat);
|
|
564
|
+
m.place(...m.home);
|
|
565
|
+
m.visible = true;
|
|
566
|
+
m.state = 'seated';
|
|
567
|
+
this.officials.set('main', m);
|
|
568
|
+
this.main = m;
|
|
569
|
+
const hdef = this.cfg.historian || { title: '사관', robe: 'nok' };
|
|
570
|
+
const h = new Official(this, { key: 'historian', def: { ...hdef, emblem: 'brush' }, kind: 'historian', id: 'historian' });
|
|
571
|
+
h.home = [...HISTORIAN];
|
|
572
|
+
h.place(...HISTORIAN);
|
|
573
|
+
h.visible = true;
|
|
574
|
+
h.state = 'standing';
|
|
575
|
+
h.tag.classList.add('small');
|
|
576
|
+
this.officials.set('historian', h);
|
|
577
|
+
this.historian = h;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---------- helpers ----------
|
|
581
|
+
officialFor(agentType, agentId) {
|
|
582
|
+
if (!agentId && !agentType) return this.main;
|
|
583
|
+
if (agentId && this.byAgent.has(agentId)) return this.officials.get(this.byAgent.get(agentId)) || this.main;
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// A desk for this official: its own fixed desk, else a free unowned desk (own side first),
|
|
588
|
+
// else a fixed desk whose owner is away. null → stands in the courtyard.
|
|
589
|
+
takeSeat(def) {
|
|
590
|
+
const sides = def.side === 'west' ? ['west', 'east'] : ['east', 'west'];
|
|
591
|
+
const free = (k) => !this.seatUsed.has(k);
|
|
592
|
+
let k = null;
|
|
593
|
+
if (def.side && Number.isInteger(def.seat) && free(`${def.side}:${def.seat}`)) k = `${def.side}:${def.seat}`;
|
|
594
|
+
for (const pass of [0, 1]) {
|
|
595
|
+
for (const side of sides) {
|
|
596
|
+
for (let i = 0; !k && i < SEATS[side].length; i++) {
|
|
597
|
+
const c = `${side}:${i}`;
|
|
598
|
+
if (free(c) && (pass === 1 || !this.fixedSeats.has(c))) k = c;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (!k) return null;
|
|
603
|
+
this.seatUsed.add(k);
|
|
604
|
+
const [side, i] = k.split(':');
|
|
605
|
+
const label = this.stoneLabels.get(k);
|
|
606
|
+
if (label) {
|
|
607
|
+
label.textContent = def.short || def.title[0];
|
|
608
|
+
label.title = `${def.title} — ${def.role || ''}`;
|
|
609
|
+
}
|
|
610
|
+
return { k, pos: SEATS[side][Number(i)] };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
releaseSeat(k) {
|
|
614
|
+
if (!k) return;
|
|
615
|
+
this.seatUsed.delete(k);
|
|
616
|
+
const label = this.stoneLabels.get(k);
|
|
617
|
+
if (label) {
|
|
618
|
+
label.textContent = label.dataset.text;
|
|
619
|
+
label.title = label.dataset.title;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
enterOfficial(agentType, agentId) {
|
|
624
|
+
const found = lookupOfficial(agentType, this.cfg);
|
|
625
|
+
let def = found.def;
|
|
626
|
+
const dup = found.kind === 'seat' && this.officials.has(found.key);
|
|
627
|
+
const seat = found.kind === 'seat' && !dup ? this.takeSeat(def) : null;
|
|
628
|
+
const kind = seat ? 'seat' : 'guest';
|
|
629
|
+
const id = seat ? found.key : `guest:${agentId || Math.random()}`;
|
|
630
|
+
if (dup) def = { ...def, title: `${def.title}(둘째)` }; // parallel agent of the same type
|
|
631
|
+
const o = new Official(this, { key: found.key, def, kind, id });
|
|
632
|
+
if (seat) {
|
|
633
|
+
o.home = [...seat.pos];
|
|
634
|
+
o.seat = seat.k;
|
|
635
|
+
} else {
|
|
636
|
+
const i = GUEST_SPOTS.findIndex((_, k) => !this.guestUsed.has(k));
|
|
637
|
+
const k = i < 0 ? Math.floor(Math.random() * GUEST_SPOTS.length) : i;
|
|
638
|
+
this.guestUsed.add(k);
|
|
639
|
+
o.guestSpot = k;
|
|
640
|
+
o.home = [...GUEST_SPOTS[k]];
|
|
641
|
+
}
|
|
642
|
+
o.place(...DOOR);
|
|
643
|
+
this.officials.set(id, o);
|
|
644
|
+
if (agentId) this.byAgent.set(agentId, id);
|
|
645
|
+
const route = kind === 'seat' ? [[200, DOOR[1] - 30], [200, o.home[1] + 6], [o.home[0], o.home[1] + 6], o.home] : [[200, DOOR[1] - 30], o.home];
|
|
646
|
+
this.queueWalk(o, route, () => {
|
|
647
|
+
// it may have started a tool while still walking in: keep showing that work, not the greeting
|
|
648
|
+
if (o.workUntil === Infinity) {
|
|
649
|
+
o.state = 'working';
|
|
650
|
+
o.say(o.workText, '', 60_000, true);
|
|
651
|
+
} else o.say(fmt(this.ph.enter, { title: o.title }), o.role, 2600);
|
|
652
|
+
});
|
|
653
|
+
return o;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
leaveOfficial(o, report) {
|
|
657
|
+
if (!o || o === this.main) return;
|
|
658
|
+
const mainSpot = [this.main.home[0] + 18, this.main.home[1] + 8];
|
|
659
|
+
o.carry = true;
|
|
660
|
+
o.hush();
|
|
661
|
+
const toMain = [[o.x, o.home[1] + 6], [200, o.home[1] + 6], [200, mainSpot[1]], mainSpot];
|
|
662
|
+
this.queueWalk(o, toMain, () => {
|
|
663
|
+
o.say(report ? fmt(this.ph.report, { report }) : this.ph.reportEmpty, '', 2600);
|
|
664
|
+
if (!this.main.bubbleSticky) this.main.say('받았소', '', 1400); // never hide a petition or work bubble
|
|
665
|
+
setTimeout(() => {
|
|
666
|
+
o.carry = false;
|
|
667
|
+
this.queueWalk(o, [[200, mainSpot[1]], [200, DOOR[1] - 20], DOOR], () => {
|
|
668
|
+
o.visible = false;
|
|
669
|
+
o.remove();
|
|
670
|
+
this.officials.delete(o.id);
|
|
671
|
+
if (o.guestSpot != null) this.guestUsed.delete(o.guestSpot);
|
|
672
|
+
this.releaseSeat(o.seat);
|
|
673
|
+
});
|
|
674
|
+
}, this.reduced ? 1200 : 2200);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// concept: one walker at a time so each move reads clearly
|
|
679
|
+
queueWalk(o, route, cb) {
|
|
680
|
+
this.walkQueue.push({ o, route, cb });
|
|
681
|
+
this.pumpWalks();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
pumpWalks() {
|
|
685
|
+
if (this.walking) return;
|
|
686
|
+
const next = this.walkQueue.shift();
|
|
687
|
+
if (!next) return;
|
|
688
|
+
this.walking = next.o;
|
|
689
|
+
next.o.walk(next.route, () => {
|
|
690
|
+
this.walking = null;
|
|
691
|
+
try {
|
|
692
|
+
next.cb && next.cb();
|
|
693
|
+
} finally {
|
|
694
|
+
this.pumpWalks();
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
showDecree(text, slash) {
|
|
700
|
+
this.decreeText.textContent = text || '…';
|
|
701
|
+
this.decreeFoot.textContent = slash ? fmt(this.ph.decreeSlash, { prompt: text }) : this.ph.decree;
|
|
702
|
+
this.decree.classList.remove('on');
|
|
703
|
+
void this.decree.offsetWidth;
|
|
704
|
+
this.decree.classList.add('on');
|
|
705
|
+
clearTimeout(this.decreeTimer);
|
|
706
|
+
this.decreeTimer = setTimeout(() => this.decree.classList.remove('on'), slash ? 2600 : 4200);
|
|
707
|
+
this.historianWrite();
|
|
708
|
+
this.kingDo('decree', 2.2);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
historianWrite() {
|
|
712
|
+
this.historian.workStart = performance.now();
|
|
713
|
+
this.historian.workUntil = performance.now() + 1600;
|
|
714
|
+
this.historian.state = 'working';
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
showToast(text, ms = 2400) {
|
|
718
|
+
this.toast.textContent = text;
|
|
719
|
+
this.toast.classList.add('on');
|
|
720
|
+
clearTimeout(this.toastTimer);
|
|
721
|
+
this.toastTimer = setTimeout(() => this.toast.classList.remove('on'), ms);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
showPlaque(text, ms = 3200) {
|
|
725
|
+
this.plaque.textContent = text;
|
|
726
|
+
this.plaque.classList.remove('on');
|
|
727
|
+
void this.plaque.offsetWidth;
|
|
728
|
+
this.plaque.classList.add('on');
|
|
729
|
+
clearTimeout(this.plaqueTimer);
|
|
730
|
+
this.plaqueTimer = setTimeout(() => this.plaque.classList.remove('on'), ms);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ---------- petition (permission request) ----------
|
|
734
|
+
openPetition({ tool, summary, message, actor, ext = false }) {
|
|
735
|
+
const who = actor || this.main;
|
|
736
|
+
const head = tool ? fmt(this.ph.petition, { tool, summary: summary || '' }) : `아뢰옵니다 — ${message || '권한이 필요합니다'}`;
|
|
737
|
+
if (this.petition && !this.petition.closed) {
|
|
738
|
+
// a Notification right after PermissionRequest: keep the richer one
|
|
739
|
+
if (!tool) return;
|
|
740
|
+
} else {
|
|
741
|
+
this.stats.petitions++;
|
|
742
|
+
}
|
|
743
|
+
this.petition = { tool, summary, since: performance.now(), actor: who, closed: false, ext };
|
|
744
|
+
this.petHead.textContent = head;
|
|
745
|
+
// a session outside the court (eojeon watch): the stamps can't reach its terminal
|
|
746
|
+
this.pet.classList.toggle('ext', ext);
|
|
747
|
+
this.pet.classList.add('on');
|
|
748
|
+
this.pet.classList.remove('min');
|
|
749
|
+
who.carry = true;
|
|
750
|
+
who.workUntil = performance.now(); // waiting, not working
|
|
751
|
+
who.say(this.ph.permission || '윤허를 청하옵니다', '', 60_000, true);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
petitionAction(kind) {
|
|
755
|
+
if (!this.petition || this.petition.closed || this.petition.ext) return;
|
|
756
|
+
if (kind === 'hold') {
|
|
757
|
+
this.pet.classList.add('min');
|
|
758
|
+
this.stampVerdict('hold');
|
|
759
|
+
this.onPetitionAction && this.onPetitionAction('hold');
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
this.onPetitionAction && this.onPetitionAction(kind);
|
|
763
|
+
this.closePetition(kind);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
closePetition(verdict) {
|
|
767
|
+
const p = this.petition;
|
|
768
|
+
if (!p || p.closed) return;
|
|
769
|
+
p.closed = true;
|
|
770
|
+
this.pet.classList.remove('on', 'min');
|
|
771
|
+
p.actor.carry = false;
|
|
772
|
+
p.actor.hush();
|
|
773
|
+
if (verdict) this.stampVerdict(verdict);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
stampVerdict(kind) {
|
|
777
|
+
const st = (this.cfg.stamps || {})[kind] || { label: kind, mark: '' };
|
|
778
|
+
if (kind in this.stats) this.stats[kind]++;
|
|
779
|
+
this.stamp.className = `stamp shape-${st.shape || 'circle'} k-${kind}`;
|
|
780
|
+
this.stampMark.textContent = st.mark || '';
|
|
781
|
+
this.stampLabel.textContent = st.label;
|
|
782
|
+
this.stampFull.textContent = st.full || st.label;
|
|
783
|
+
this.stampFull.className = `stamp-full k-${kind}`;
|
|
784
|
+
void this.stamp.offsetWidth;
|
|
785
|
+
if (kind === 'ok') this.kingDo('nod', 1.1);
|
|
786
|
+
else if (kind === 'no') this.kingDo('wave', 1.2);
|
|
787
|
+
const delay = this.reduced ? 0 : 160;
|
|
788
|
+
setTimeout(() => {
|
|
789
|
+
this.stamp.classList.add('on');
|
|
790
|
+
this.stampFull.classList.add('on');
|
|
791
|
+
}, delay);
|
|
792
|
+
clearTimeout(this.stampTimer);
|
|
793
|
+
this.stampTimer = setTimeout(() => {
|
|
794
|
+
this.stamp.classList.remove('on');
|
|
795
|
+
this.stampFull.classList.remove('on');
|
|
796
|
+
}, 2600);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// ---------- event handling ----------
|
|
800
|
+
handle(ev, { replay = false } = {}) {
|
|
801
|
+
if (replay) return; // history is shown in the log only; the scene starts fresh
|
|
802
|
+
const ph = this.ph;
|
|
803
|
+
const actor = ev.agentId ? this.officialFor(ev.agentType, ev.agentId) || this.enterOfficial(ev.agentType, ev.agentId) : this.main;
|
|
804
|
+
switch (ev.type) {
|
|
805
|
+
case 'SessionStart': {
|
|
806
|
+
this.duskTarget = 0;
|
|
807
|
+
const resumed = ev.source === 'resume' || ev.source === 'fork';
|
|
808
|
+
if (ev.source === 'clear' || ev.source === 'compact') this.showToast(ev.source === 'clear' ? '조정을 새로 엽니다 (/clear)' : '기록을 줄였사옵니다 (compact)');
|
|
809
|
+
else this.showToast(fmt(resumed ? ph.sessionResume : ph.sessionStart, { folder: ev.folder || '' }));
|
|
810
|
+
this.main.visible = true;
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
case 'UserPromptSubmit':
|
|
814
|
+
if (this.petition && !this.petition.closed) this.closePetition(null);
|
|
815
|
+
this.showDecree(ev.prompt, ev.slash);
|
|
816
|
+
this.main.say('교지를 받드옵니다', '', 1800);
|
|
817
|
+
break;
|
|
818
|
+
case 'PreToolUse': {
|
|
819
|
+
if (ev.spawnType) {
|
|
820
|
+
actor.say(`${ev.tool}: ${ev.spawnType} 부르는 중`, '', 2000);
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
actor.work(fmt(ph.working, { tool: ev.tool, summary: ev.summary || '' }).replace(/:\s*$/, ''));
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
case 'PostToolUse':
|
|
827
|
+
if (this.petition && !this.petition.closed && this.petition.tool === ev.tool) this.closePetition('ok');
|
|
828
|
+
if (!ev.spawnType) actor.doneWork(true);
|
|
829
|
+
break;
|
|
830
|
+
case 'PostToolUseFailure':
|
|
831
|
+
if (this.petition && !this.petition.closed) this.closePetition(null);
|
|
832
|
+
actor.doneWork(false, fmt(ph.failed, { tool: ev.tool }));
|
|
833
|
+
break;
|
|
834
|
+
case 'PermissionRequest':
|
|
835
|
+
this.openPetition({ tool: ev.tool, summary: ev.summary, actor, ext: !!ev.ext });
|
|
836
|
+
break;
|
|
837
|
+
case 'PermissionDenied':
|
|
838
|
+
if (this.petition && !this.petition.closed) this.closePetition('no');
|
|
839
|
+
actor.doneWork(false, fmt(ph.denied, { tool: ev.tool }));
|
|
840
|
+
break;
|
|
841
|
+
case 'Notification':
|
|
842
|
+
if (ev.kind === 'permission_prompt') {
|
|
843
|
+
if (!this.petition || this.petition.closed) this.openPetition({ message: ev.message, actor, ext: !!ev.ext });
|
|
844
|
+
} else if (ev.kind === 'idle_prompt') {
|
|
845
|
+
this.main.say(ph.idle || '분부를 기다리옵니다', '', 120_000);
|
|
846
|
+
} else if (ev.message) {
|
|
847
|
+
this.showToast(ev.message);
|
|
848
|
+
}
|
|
849
|
+
break;
|
|
850
|
+
case 'SubagentStart':
|
|
851
|
+
if (!this.byAgent.has(ev.agentId)) this.enterOfficial(ev.agentType, ev.agentId);
|
|
852
|
+
break;
|
|
853
|
+
case 'SubagentStop': {
|
|
854
|
+
const o = this.officialFor(ev.agentType, ev.agentId);
|
|
855
|
+
if (o && o !== this.main) {
|
|
856
|
+
this.byAgent.delete(ev.agentId);
|
|
857
|
+
this.leaveOfficial(o, ev.report);
|
|
858
|
+
}
|
|
859
|
+
break;
|
|
860
|
+
}
|
|
861
|
+
case 'Stop':
|
|
862
|
+
case 'StopFailure': {
|
|
863
|
+
if (this.petition && !this.petition.closed) this.closePetition(null);
|
|
864
|
+
const m = this.main;
|
|
865
|
+
const fail = ev.type === 'StopFailure';
|
|
866
|
+
m.carry = true;
|
|
867
|
+
this.queueWalk(m, [[m.home[0], m.home[1] + 8], [200, m.home[1] + 8], THRONE_FRONT], () => {
|
|
868
|
+
m.say(fail ? '아뢰옵니다 — 일을 끝내지 못했사옵니다' : ph.stopLine, '', 2600);
|
|
869
|
+
if (!fail) this.showPlaque(ph.stop || '상소 완료', 2800);
|
|
870
|
+
setTimeout(() => {
|
|
871
|
+
m.carry = false;
|
|
872
|
+
this.queueWalk(m, [[200, m.home[1] + 8], [m.home[0], m.home[1] + 8], m.home]);
|
|
873
|
+
}, this.reduced ? 1500 : 2400);
|
|
874
|
+
});
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
case 'SessionEnd': {
|
|
878
|
+
this.duskTarget = 1;
|
|
879
|
+
const s = this.stats;
|
|
880
|
+
this.showPlaque(`오늘 상소 ${s.petitions}건 · 윤허 ${s.ok} · 불허 ${s.no} · 보류 ${s.hold} · ${ph.sessionEnd || '퇴청하옵니다'}`, 6000);
|
|
881
|
+
for (const o of [...this.officials.values()]) if (o.kind === 'seat' || o.kind === 'guest') this.leaveOfficial(o, '');
|
|
882
|
+
break;
|
|
883
|
+
}
|
|
884
|
+
case 'PtyExit':
|
|
885
|
+
this.duskTarget = 1;
|
|
886
|
+
break;
|
|
887
|
+
case 'PtyStart':
|
|
888
|
+
this.duskTarget = 0;
|
|
889
|
+
break;
|
|
890
|
+
default:
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
reset() {
|
|
896
|
+
for (const o of [...this.officials.values()]) {
|
|
897
|
+
if (o.kind === 'seat' || o.kind === 'guest') {
|
|
898
|
+
o.remove();
|
|
899
|
+
this.officials.delete(o.id);
|
|
900
|
+
this.releaseSeat(o.seat);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
this.byAgent.clear();
|
|
904
|
+
this.guestUsed.clear();
|
|
905
|
+
this.walkQueue = [];
|
|
906
|
+
this.walking = null;
|
|
907
|
+
this.main.path = [];
|
|
908
|
+
this.main.place(...this.main.home);
|
|
909
|
+
this.main.state = 'seated';
|
|
910
|
+
this.main.carry = false;
|
|
911
|
+
this.main.hush();
|
|
912
|
+
this.closePetition(null);
|
|
913
|
+
this.stats = { petitions: 0, ok: 0, no: 0, hold: 0 };
|
|
914
|
+
this.duskTarget = 0;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ---------- frame ----------
|
|
918
|
+
frame(now) {
|
|
919
|
+
const dt = Math.min(0.1, (now - this.last) / 1000);
|
|
920
|
+
this.last = now;
|
|
921
|
+
const g = this.ctx;
|
|
922
|
+
g.imageSmoothingEnabled = false;
|
|
923
|
+
g.drawImage(this.bg, 0, 0);
|
|
924
|
+
const px = (x, y, w, h, c) => {
|
|
925
|
+
g.fillStyle = c;
|
|
926
|
+
g.fillRect(x, y, w, h);
|
|
927
|
+
};
|
|
928
|
+
// throne glow when a petition is waiting
|
|
929
|
+
if (this.petition && !this.petition.closed) {
|
|
930
|
+
const a = 0.12 + 0.06 * Math.sin(now / 300);
|
|
931
|
+
px(150, 40, 100, 40, `rgba(217,174,82,${a.toFixed(3)})`);
|
|
932
|
+
}
|
|
933
|
+
this.drawKing(now, px);
|
|
934
|
+
const list = [...this.officials.values()];
|
|
935
|
+
for (const o of list) o.step(dt, now);
|
|
936
|
+
list.sort((a, b) => a.y - b.y);
|
|
937
|
+
for (const o of list) o.draw(g, now, px);
|
|
938
|
+
// dusk tint (session end)
|
|
939
|
+
this.dusk += (this.duskTarget - this.dusk) * Math.min(1, dt * 1.5);
|
|
940
|
+
if (this.dusk > 0.01) px(0, 0, W, H, `rgba(8,10,20,${(this.dusk * 0.45).toFixed(3)})`);
|
|
941
|
+
for (const o of list) o.updateDom(now);
|
|
942
|
+
this.layoutBubbles();
|
|
943
|
+
if (this.petition && !this.petition.closed) {
|
|
944
|
+
this.petWait.textContent = fmt(this.ph.waiting, { elapsed: elapsedText(now - this.petition.since).replace('째', '') });
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Speech bubbles: newest first (the petitioner always), at most MAX_BUBBLES.
|
|
949
|
+
// Each one tries a few spots around its speaker and takes the first that overlaps
|
|
950
|
+
// neither an earlier bubble nor the petition panel. Everything is also in the 사관 일지.
|
|
951
|
+
layoutBubbles() {
|
|
952
|
+
const on = [...this.officials.values()].filter((o) => o.bubbleOn);
|
|
953
|
+
const asker = this.petition && !this.petition.closed ? this.petition.actor : null;
|
|
954
|
+
on.sort((a, b) => (b === asker) - (a === asker) || (b.bubbleAt || 0) - (a.bubbleAt || 0));
|
|
955
|
+
const placed = [];
|
|
956
|
+
if (this.pet.classList.contains('on')) {
|
|
957
|
+
placed.push({ x: PETITION_BOX.x, y: PETITION_BOX.y, w: PETITION_BOX.w, h: this.pet.offsetHeight || 64 });
|
|
958
|
+
}
|
|
959
|
+
const hit = (r) => placed.some((p) => r.x < p.x + p.w + 2 && r.x + r.w + 2 > p.x && r.y < p.y + p.h + 2 && r.y + r.h + 2 > p.y);
|
|
960
|
+
let hidden = 0;
|
|
961
|
+
for (const o of on) {
|
|
962
|
+
if (placed.length - (this.pet.classList.contains('on') ? 1 : 0) >= MAX_BUBBLES) {
|
|
963
|
+
o.bubble.classList.remove('on');
|
|
964
|
+
hidden++;
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
o.bubble.classList.add('on');
|
|
968
|
+
const w = o.bubble.offsetWidth;
|
|
969
|
+
const h = o.bubble.offsetHeight;
|
|
970
|
+
const [ax, ay] = o.anchor;
|
|
971
|
+
const step = h + 3;
|
|
972
|
+
const side = Math.round(w * 0.55);
|
|
973
|
+
const tries = [[0, 0], [0, -step], [-side, 0], [side, 0], [-side, -step], [side, -step], [0, -2 * step], [0, step + 30]];
|
|
974
|
+
let best = null;
|
|
975
|
+
for (const [dx, dy] of tries) {
|
|
976
|
+
const x = Math.max(2, Math.min(W - 2 - w, ax + dx - w / 2));
|
|
977
|
+
const y = Math.max(2, Math.min(H - 2 - h, ay + dy - h));
|
|
978
|
+
const r = { x, y, w, h };
|
|
979
|
+
if (!best) best = r;
|
|
980
|
+
if (!hit(r)) {
|
|
981
|
+
best = r;
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
placed.push(best);
|
|
986
|
+
// the tail only makes sense when the bubble sits right above its speaker
|
|
987
|
+
o.bubble.classList.toggle('loose', Math.abs(best.x + w / 2 - ax) > w / 2 - 4 || Math.abs(best.y + h - ay) > 2);
|
|
988
|
+
o.bubble.style.transform = `translate(${Math.round(best.x)}px, ${Math.round(best.y)}px)`;
|
|
989
|
+
}
|
|
990
|
+
this.more.textContent = hidden ? `말풍선 +${hidden} · 사관 일지에 있사옵니다` : '';
|
|
991
|
+
this.more.classList.toggle('on', hidden > 0);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// The king: a generic pixel monarch on the throne (no historical likeness).
|
|
995
|
+
// Frames: idle (blink), decree (holds up a scroll), nod (approve), wave (refuse).
|
|
996
|
+
kingDo(kind, dur) {
|
|
997
|
+
this.kingAnim = { kind, start: performance.now(), dur };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
drawKing(now, px) {
|
|
1001
|
+
const P = this.pix;
|
|
1002
|
+
const kc = this.cfg.king || {};
|
|
1003
|
+
const robe = /^#/.test(kc.robe || '') ? kc.robe : P[kc.robe || 'hong'] || P.hong;
|
|
1004
|
+
const robeD = shade(robe, 0.72);
|
|
1005
|
+
const O = P.outline;
|
|
1006
|
+
const ox = 184;
|
|
1007
|
+
const oy = 33;
|
|
1008
|
+
const r = (x, y, w, h, c) => px(ox + x, oy + y, w, h, c);
|
|
1009
|
+
let a = this.kingAnim;
|
|
1010
|
+
if (a && (now - a.start) / 1000 > a.dur) a = this.kingAnim = null;
|
|
1011
|
+
const t = a ? (now - a.start) / 1000 : 0;
|
|
1012
|
+
const f = Math.floor(t * FPS);
|
|
1013
|
+
const kind = a ? a.kind : 'idle';
|
|
1014
|
+
const nod = kind === 'nod' && !this.reduced && (f % 4 === 1 || f % 4 === 2) ? 2 : 0;
|
|
1015
|
+
// robe (gonryongpo), seated
|
|
1016
|
+
const rows = [[10, 12], [8, 16], [7, 18], [7, 18], [6, 20], [6, 20], [6, 20], [6, 20], [6, 20], [5, 22], [5, 22], [5, 22], [5, 22], [5, 22], [5, 22], [5, 22]];
|
|
1017
|
+
rows.forEach(([x0, w], i) => {
|
|
1018
|
+
r(x0 - 1, 14 + i, w + 2, 1, O);
|
|
1019
|
+
r(x0, 14 + i, w, 1, robe);
|
|
1020
|
+
});
|
|
1021
|
+
r(4, 30, 24, 1, O);
|
|
1022
|
+
r(13, 14, 6, 1, P.paper); // collar
|
|
1023
|
+
// shoulder roundels + chest roundel (plain gold circle — no dragon)
|
|
1024
|
+
r(8, 15, 3, 3, P.hwang);
|
|
1025
|
+
r(21, 15, 3, 3, P.hwang);
|
|
1026
|
+
r(13, 16, 6, 1, P.hwang);
|
|
1027
|
+
r(12, 17, 8, 5, P.hwang);
|
|
1028
|
+
r(13, 22, 6, 1, P.hwang);
|
|
1029
|
+
r(14, 18, 4, 3, shade(robe, 0.85));
|
|
1030
|
+
r(15, 19, 2, 1, P.hwang);
|
|
1031
|
+
// jade belt
|
|
1032
|
+
r(6, 24, 20, 1, P.hwang);
|
|
1033
|
+
for (let x = 7; x < 26; x += 3) r(x, 24, 1, 1, P.paper);
|
|
1034
|
+
// arms
|
|
1035
|
+
if (kind === 'decree') {
|
|
1036
|
+
// both hands hold an opened scroll in front of the chest
|
|
1037
|
+
r(4, 15, 3, 6, robeD);
|
|
1038
|
+
r(25, 15, 3, 6, robeD);
|
|
1039
|
+
r(3, 13, 26, 8, O);
|
|
1040
|
+
r(4, 14, 24, 6, P.paper);
|
|
1041
|
+
r(3, 13, 2, 8, P.hwang);
|
|
1042
|
+
r(27, 13, 2, 8, P.hwang);
|
|
1043
|
+
for (let x = 8; x < 25; x += 4) r(x, 16, 2, 1, '#8A8378');
|
|
1044
|
+
r(5, 20, 2, 2, P.skin);
|
|
1045
|
+
r(25, 20, 2, 2, P.skin);
|
|
1046
|
+
} else if (kind === 'wave') {
|
|
1047
|
+
r(5, 18, 3, 7, robeD);
|
|
1048
|
+
r(5, 25, 3, 2, P.skin);
|
|
1049
|
+
const up = f % 2 === 0 ? 0 : 2;
|
|
1050
|
+
r(24, 18, 6, 3, robeD);
|
|
1051
|
+
r(29, 15 + up, 3, 3, P.skin);
|
|
1052
|
+
} else {
|
|
1053
|
+
// hands resting on the knees
|
|
1054
|
+
r(5, 18, 3, 7, robeD);
|
|
1055
|
+
r(24, 18, 3, 7, robeD);
|
|
1056
|
+
r(8, 26, 3, 2, P.skin);
|
|
1057
|
+
r(21, 26, 3, 2, P.skin);
|
|
1058
|
+
}
|
|
1059
|
+
// head (moves for the nod)
|
|
1060
|
+
const hy = nod;
|
|
1061
|
+
r(11, 7 + hy, 10, 7, O);
|
|
1062
|
+
r(12, 7 + hy, 8, 6, P.skin);
|
|
1063
|
+
r(12, 12 + hy, 8, 1, P.skinShade);
|
|
1064
|
+
const blink = now % 5000 < 140;
|
|
1065
|
+
if (blink || nod) {
|
|
1066
|
+
r(14, 10 + hy, 1, 1, P.skinShade);
|
|
1067
|
+
r(17, 10 + hy, 1, 1, P.skinShade);
|
|
1068
|
+
} else {
|
|
1069
|
+
r(14, 9 + hy, 1, 2, O);
|
|
1070
|
+
r(17, 9 + hy, 1, 2, O);
|
|
1071
|
+
}
|
|
1072
|
+
// ikseongwan: rounded crown + two upright back flaps
|
|
1073
|
+
r(11, 1 + hy, 10, 7, O);
|
|
1074
|
+
r(12, 2 + hy, 8, 5, P.heuk);
|
|
1075
|
+
r(13, 2 + hy, 6, 1, shade(P.heuk, 1.6));
|
|
1076
|
+
r(12, 6 + hy, 8, 1, shade(P.heuk, 1.3));
|
|
1077
|
+
r(8, 0 + hy, 4, 5, O);
|
|
1078
|
+
r(9, 1 + hy, 2, 3, P.heuk);
|
|
1079
|
+
r(20, 0 + hy, 4, 5, O);
|
|
1080
|
+
r(21, 1 + hy, 2, 3, P.heuk);
|
|
1081
|
+
}
|
|
1082
|
+
}
|