An animal (dog, cat, turtle, duck, penguin or the Claude mascot) roams an isometric world (8 map sets) in a docked terminal panel and reacts to session signals…

English | 한국어
Draws a seeded isometric map in the /pets docked panel, and an animal roams it on its own. The animal reacts to session signals (prompts, typing, tool calls, turn ends, and more). The panel has no buttons or keys; the only way to make it do something is /pets do.
The panel text and command replies are in English or Korean. See Language. /pets world, /pets pet and /pets do accept English and Korean names in either language.
claude plugin marketplace add YeomYuJun/claude-mods
claude plugin install pets@claude-mods
The full steps are in the repository README.
/tui fullscreen). In the classic renderer (/tui default) the panel opens above the input box, but it is too small and shows only a size notice.| Species | Breeds | Notes |
|---|---|---|
| Dog | white, shiba, black, dalmatian, brown | Default |
| Cat | cat, cheese | Climbs two steps at once |
| Turtle, duck, penguin | Swim when they enter water. Duck and penguin waddle | |
| Claude mascot |
Curiosity, playfulness, wanting to be near you, thirst, and energy are kept as values, and the next action is chosen from them. It walks, sniffs, plays with a ball, explores, wanders far, circles a block, climbs high places, sits facing you, naps, drinks water, splashes, chews a stick, digs, and more.
| Signal | Reaction |
|---|---|
| Prompt | Stops what it is doing and runs toward you, facing you. Wakes up if it was asleep |
| Typing | Looks at you |
| Tool call | Sniffs or perks its ears depending on the tool type. If the call runs longer than 4 seconds, it comes to the front and sits waiting |
| Tool failure | Barks on an error, tilts its head on a denial |
| Long thinking | Sits and shows an ellipsis |
| Subagent start and end | Shows musical notes |
| Conversation compaction | Shakes itself off and stretches |
| Turn end | On an answer, sits with hearts and a wagging tail; on an interrupt, !; on an error, droops |
| No signal for 3 minutes | Falls asleep. After 1 minute, once its energy is restored, wakes up on its own |
| Command | What it does | |
|---|---|---|
/pets | Opens and closes the panel | |
/pets zoom | Changes zoom by one level. in and out move one step; wide, far, 1, 2 pick a level directly | |
/pets world [set] | Shows or changes the map set. paper meadow mountain forest lake beach snow desert, an English or Korean name (such as grass or 초원 for meadow), or auto (picked per session) | |
/pets pet [breed] | Shows or changes the animal. dog shiba blackdog dalmatian brown cat cheese turtle duck penguin claude, an English or Korean name (such as ginger or 치즈 for cheese), or auto | |
| `/pets idle [on\ | off]` | Shows or toggles stay-awake mode |
/pets do <action> | Makes it act now: sleep wake sit lie play ball fetch zoomies roam explore come, or a name such as down or 앉아 |
The chosen zoom, set, breed, and stay-awake setting are saved and carry over to the next session.
The size and surface notices, the /pets description and argument hint, and the command replies, including set, breed and action names, follow the plugin's language option: auto (default), en or ko. Change it in /config; the plugin reloads with the new value. Names typed after /pets world, /pets pet and /pets do are accepted in both languages whatever this option is.
auto takes the first of these that names Korean or English, and falls back to English:
language key in the settings files, if one is set ("korean", "english"). The Claude Code settings reference does not list this keyLC_ALL environment variableLANG environment variable (ko_KR.UTF-8)On Windows LC_ALL and LANG are usually unset, so without such a key the plugin shows English. A change to the settings key takes effect in the next session.
claude plugin test "<repo-path>/pets"
claude plugin validate "<repo-path>/pets"
Modules other than hooks/register.ts and hooks/view/ are pure modules that do not use $.
hooks/register.ts 543 lines1import type { EngineInterface, On, PluginOptions, Timer } from 'claude-code'
2
3import Band from './band'
4import Biome, { type BiomeId } from './biome'
5import Messages, { type Language } from './messages'
6import Names from './names'
7import Pet from './pet'
8import Rng from './rng'
9import Scene, { type Zoom } from './scene'
10import Species, { type BreedId } from './species'
11import View from './view'
12import World from './world'
13
14type Host = {
15 now: () => Promise<number>
16 every: (ms: number, fn: () => void) => Timer
17 blit: (requestId: string, key: string, cells: string, cols: number, rows: number) => Promise<{ deny?: string }>
18 invalidate: () => void
19 store: (key: string, value: unknown) => Promise<void>
20}
21
22type Mount = { requestId: string; cols: number; rows: number; lastCells: string; lastAt: number }
23
24// userConfig 의 language 가 auto 면 settings 의 language, LC_ALL, LANG 순으로 본다. $ 는 훅 안에만 있어 session.start 에서 부른다
25async function languageIn($: Pick<EngineInterface, 'settings' | 'env'>, option: unknown): Promise<Language> {
26 if (option === 'en' || option === 'ko') return option
27 const valueOf = async (read: () => Promise<unknown>) => {
28 try {
29 return await read()
30 } catch {
31 return undefined
32 }
33 }
34 return Messages.languageOf(option, [
35 await valueOf(async () => (await $.settings.read()).language),
36 await valueOf(() => $.env.get('LC_ALL')),
37 await valueOf(() => $.env.get('LANG')),
38 ])
39}
40
41// 세션 신호로 강아지를 움직이는 일은 부가 기능이다. 여기서 난 오류가 세션의 스트림·도구 호출을 막지 않게 삼킨다
42function safely(fn: () => void): void {
43 try { fn() } catch { /* 강아지 쪽 오류는 무시한다 */ }
44}
45
46// 저장한 맵 세트 값을 읽는다. 모르는 모양·버전·세트면 기본 세트
47// 저장한 품종 값을 읽는다. 모르는 모양·버전·품종이면 기본 품종
48function petChoiceOf(value: unknown): BreedId | 'auto' {
49 if (typeof value !== 'object' || value === null) return Species.DEFAULT_BREED
50 const v = value as { v?: unknown; list?: unknown }
51 if (v.v !== Names.STORE_PETS_VERSION) return Species.DEFAULT_BREED
52 if (v.list === 'auto') return 'auto'
53 return Array.isArray(v.list) && Species.isBreedId(v.list[0]) ? v.list[0] : Species.DEFAULT_BREED
54}
55
56// 저장한 잠들지 않기 값을 읽는다. 모르는 모양·버전이면 꺼진 것으로 본다
57function idleOf(value: unknown): boolean {
58 if (typeof value !== 'object' || value === null) return false
59 const v = value as { v?: unknown; on?: unknown }
60 return v.v === Names.STORE_IDLE_VERSION && v.on === true
61}
62
63// Claude Code 테마가 밝은 계열이면 띠를 밝은 터미널용 색으로 그린다
64function isLightTheme(value: unknown): boolean {
65 return typeof value === 'string' && value.startsWith('light')
66}
67
68function worldChoiceOf(value: unknown): BiomeId | 'auto' {
69 if (typeof value !== 'object' || value === null) return Biome.DEFAULT
70 const v = value as { v?: unknown; set?: unknown }
71 if (v.v !== Names.STORE_WORLD_VERSION) return Biome.DEFAULT
72 return v.set === 'auto' || Biome.isId(v.set) ? v.set : Biome.DEFAULT
73}
74
75// 엔진이 기록한 이 플러그인의 열린 패널. 모듈을 다시 불러와도(언어 변경 등) 패널은 떠 있고 이 기록에 남는다
76async function isPaneUp($: Pick<EngineInterface, 'ui'>, id: string): Promise<boolean> {
77 try {
78 return (await $.ui.panes()).some(pane => pane.id === id)
79 } catch {
80 return false
81 }
82}
83
84export function register(on: On, options: PluginOptions) {
85 let messages = Messages.messagesOf(Messages.languageOf(options.language))
86 let host: Host | undefined
87 let isOpen = false
88 // 프롬프트 위 띠는 쓰지 않는 기능으로 둔다: 켜는 명령이 없고 예전 저장값(band 키)도 읽지 않아 늘 꺼져 있다
89 let isBandOn = false
90 let zoom: Zoom = Scene.DEFAULT_ZOOM
91 let scene = Scene.create(0x5eed, 0)
92 let band = Band.create(0x5eed, 0)
93 let timer: Timer | undefined
94 let timerMs = 0
95 let pane: Mount | undefined
96 let strip: Mount | undefined
97 let isWorking = false
98 let lastAt: number | undefined
99 // 고른 맵 세트('auto' 면 세션 시드로 고른다)와 세션 시드
100 let worldChoice: BiomeId | 'auto' = Biome.DEFAULT
101 let petChoice: BreedId | 'auto' = Species.DEFAULT_BREED
102 let isIdle = false
103 let isLight = false
104 let sessionSeed = 0x5eed
105 // 마지막으로 저장한 편집 목록(JSON)과 저장을 마지막으로 살핀 시각. 목록이 같으면 다시 쓰지 않는다.
106 // 지금 키에 모르는 버전·모양의 값이 있으면 이 세트·세션 동안 그 키에 쓰지 않는다(다른 버전의 저장값을 덮지 않는다)
107 let editsSaved = ''
108 let editsAt = 0
109 let editsLocked = false
110
111 function biomeOf(choice: BiomeId | 'auto'): BiomeId {
112 return choice === 'auto' ? Biome.autoOf(sessionSeed) : choice
113 }
114
115 function breedOf(choice: BreedId | 'auto'): BreedId {
116 return choice === 'auto' ? Species.autoBreed(sessionSeed) : choice
117 }
118
119 // 띠 강아지를 지금 품종과 터미널 밝기에 맞춘다
120 function lookBand(): void {
121 const breed = Species.breedOf(scene.breed)
122 Band.setLook(band, Species.lookOf(breed.id), isLight ? breed.band.light : breed.band.dark)
123 if (strip) strip.lastCells = ''
124 }
125
126 function editsKey(): string {
127 return Names.STORE_EDITS_KEY(scene.biome, sessionSeed)
128 }
129
130 // 지금 세트·시드의 편집(옮긴 나뭇가지, 구덩이 흔적)을 읽는다. 모르는 버전·모양이면 빈 표로 두고 저장값은 지우지 않는다
131 async function loadEdits(get: (key: string) => Promise<unknown>, now: number): Promise<void> {
132 editsSaved = ''
133 editsLocked = false
134 try {
135 const value = await get(editsKey())
136 const edits = World.editsFromSave(value)
137 if (!edits) {
138 editsLocked = value !== undefined
139 return
140 }
141 scene.world.edits = edits
142 World.expireEdits(scene.world, now)
143 editsSaved = JSON.stringify(World.editsToSave(scene.world, now).list)
144 } catch { /* 못 읽으면 빈 표 */ }
145 }
146
147 // 편집이 바뀌었으면 저장한다
148 async function saveEdits(h: Host, now: number): Promise<void> {
149 if (editsLocked) return
150 const value = World.editsToSave(scene.world, now)
151 const text = JSON.stringify(value.list)
152 if (text === editsSaved) return
153 editsSaved = text
154 await h.store(editsKey(), value)
155 }
156
157 // 편집 키는 지금 것 말고 가장 최근 것 EDITS_KEYS - 1 개까지만 남긴다(저장값의 at 이 오래된 것부터 지운다).
158 // 모르는 버전·모양의 값은 세지도 지우지도 않는다
159 async function pruneEdits(store: { keys: () => Promise<string[]>; get: (key: string) => Promise<unknown>; delete: (key: string) => Promise<void> }): Promise<void> {
160 const mine = editsKey()
161 const others = (await store.keys()).filter(k => k.startsWith(Names.STORE_EDITS_PREFIX) && k !== mine)
162 if (others.length < Names.EDITS_KEYS) return
163 const dated: Array<[string, number]> = []
164 for (const k of others) {
165 const v = await store.get(k)
166 if (!World.editsFromSave(v)) continue
167 const at = (v as { at?: unknown }).at
168 dated.push([k, typeof at === 'number' ? at : 0])
169 }
170 dated.sort((a, b) => b[1] - a[1])
171 for (const [k] of dated.slice(Names.EDITS_KEYS - 1)) await store.delete(k)
172 }
173
174 // 패널이 열려 있으면 강아지가 반응하고, 닫혀 있으면 신호 시각만 적는다(시뮬레이션도 멈춰 있다)
175 function signal(now: number, react: () => void): void {
176 safely(() => {
177 if (isOpen) react()
178 else scene.pet.lastSignalAt = now
179 })
180 }
181
182 async function paint(h: Host, m: Mount, key: string, cells: string, now: number): Promise<void> {
183 m.lastAt = now
184 if (cells === m.lastCells) return
185 m.lastCells = cells
186 const result = await h.blit(m.requestId, key, cells, m.cols, m.rows)
187 // 크기가 바뀌었거나 아직 붙지 않았다: 다시 그려 달라고 한다
188 if (result.deny !== undefined) { m.lastCells = ''; h.invalidate() }
189 }
190
191 async function onFrame() {
192 const h = host
193 if (!h) return
194 const now = await h.now()
195 const dt = lastAt === undefined ? 0 : now - lastAt
196 lastAt = now
197 const p = pane
198 if (isOpen && p) {
199 Scene.step(scene, dt, now)
200 Scene.prefetch(scene, p.cols, p.rows, zoom)
201 if (now - p.lastAt >= Scene.renderEveryMs(scene) - 1) await paint(h, p, Names.RASTER_KEY, Scene.compose(scene, p.cols, p.rows, zoom), now)
202 if (now - editsAt >= Names.EDITS_SAVE_MS) {
203 editsAt = now
204 try { await saveEdits(h, now) } catch { /* 다음에 다시 */ }
205 }
206 }
207 const s = strip
208 if (isBandOn && s) {
209 Band.step(band, dt, now, isWorking)
210 if (now - s.lastAt >= Band.renderEveryMs(band) - 1) await paint(h, s, Names.BAND_KEY, Band.compose(band, s.cols), now)
211 }
212 }
213
214 // 타이머는 하나만 둔다. 패널이 열려 있으면 33 ms, 띠만 있으면 66 ms, 둘 다 없으면 멈춘다
215 function syncTimer() {
216 const want = isOpen && pane ? Names.FRAME_MS : isBandOn && strip ? Names.BAND_ONLY_FRAME_MS : 0
217 if (want === timerMs) return
218 timer?.cancel()
219 timer = undefined
220 timerMs = want
221 lastAt = undefined
222 if (want > 0 && host) timer = host.every(want, () => { void onFrame() })
223 }
224
225 on('session.start', async ($, e, next) => {
226 host = {
227 now: () => $.clock.now(),
228 every: (ms, fn) => $.clock.every(ms, fn),
229 blit: (requestId, key, cells, columns, rows) => $.ui.blit({ requestId, key, cells, columns, rows }),
230 invalidate: () => $.ui.invalidate('ui.render'),
231 store: (key, value) => $.store.set(key, value),
232 }
233 const now = await $.clock.now()
234 let seed = (now | 0) ^ 0x5eed
235 try { seed = Rng.hashOf(await $.session.id()) } catch { /* 시각으로 만든 시드 그대로 */ }
236 sessionSeed = seed
237 try {
238 zoom = Scene.zoomOf(await $.store.get(Names.STORE_SCALE))
239 worldChoice = worldChoiceOf(await $.store.get(Names.STORE_WORLD))
240 petChoice = petChoiceOf(await $.store.get(Names.STORE_PETS))
241 isIdle = idleOf(await $.store.get(Names.STORE_IDLE))
242 } catch { /* 저장한 값이 없으면 기본값 */ }
243 try { isLight = isLightTheme((await $.config.list()).find(row => row.key === 'theme')?.value) } catch { /* 모르면 어두운 터미널로 본다 */ }
244 scene = Scene.create(seed, now, biomeOf(worldChoice), breedOf(petChoice))
245 scene.pet.keepAwake = isIdle
246 editsAt = now
247 await loadEdits(key => $.store.get(key), now)
248 try { await pruneEdits({ keys: () => $.store.keys(), get: key => $.store.get(key), delete: key => $.store.delete(key) }) } catch { /* 지우지 못하면 다음 세션에 */ }
249 band = Band.create(seed, now)
250 lookBand()
251 messages = Messages.messagesOf(await languageIn($, options.language))
252 await $.command.register({ name: Names.COMMAND_NAME, description: messages.commandDescription, argumentHint: messages.argumentHint })
253 // 다시 불러온 모듈은 패널을 닫힌 것으로 안다. 패널이 떠 있으면 열린 상태로 되돌리고 다시 그려 프레임을 잇는다
254 if (await isPaneUp($, Names.PANE_ID)) {
255 isOpen = true
256 scene.pet.lastSignalAt = now
257 $.ui.invalidate('ui.render')
258 }
259 return next(e)
260 })
261
262 on('command.run', { command: Names.COMMAND_NAME }, async ($, e) => {
263 const [arg = '', sub = '', ...rest] = e.args.trim().toLowerCase().split(/\s+/)
264 // zoom 은 맵 보기 → 원거리 → 1 배 → 2 배 를 돌고, zoom in · out 은 한 단계씩 가다 끝에서 멈추고, zoom <wide|far|1|2> 는 그 단계로 바로 간다
265 const named = arg === 'zoom' ? Scene.zoomNamed(sub) : undefined
266 if (arg === 'zoom' && (sub === '' || sub === 'in' || sub === 'out' || named !== undefined) && rest.length === 0) {
267 const next = named ?? (sub === '' ? Scene.zoomNext(zoom) : Scene.zoomBy(zoom, sub === 'in' ? 1 : -1))
268 if (next === zoom) return { text: named !== undefined ? messages.zoomSame(zoom) : messages.zoomLimit(zoom) }
269 zoom = next
270 await host?.store(Names.STORE_SCALE, zoom)
271 if (pane) {
272 Scene.ensureVisible(scene, pane.cols, pane.rows, zoom)
273 pane.lastCells = ''
274 }
275 $.ui.invalidate('ui.render')
276 return { text: messages.zoomChanged(zoom) }
277 }
278 // world 는 지금 세트와 목록을 보이고, world <세트|auto> 는 세트를 바꿔 저장한다. 모르는 세트면 아무것도 바꾸지 않는다
279 if (arg === 'world' && rest.length === 0) {
280 const choices = messages.worldChoices(Biome.BIOMES.map(b => b.id))
281 if (sub === '') return { text: messages.worldNow(messages.setLabels[scene.biome], worldChoice === 'auto', choices) }
282 const picked = sub === 'auto' ? 'auto' : Biome.find(sub)?.id
283 if (picked === undefined) return { text: messages.worldUnknown(sub, choices) }
284 worldChoice = picked
285 const now = await $.clock.now()
286 if (host) {
287 try { await saveEdits(host, now) } catch { /* 옛 세트 편집을 못 쓰면 버린다 */ }
288 }
289 Scene.setBiome(scene, biomeOf(picked), now)
290 await loadEdits(key => $.store.get(key), now)
291 await host?.store(Names.STORE_WORLD, { v: Names.STORE_WORLD_VERSION, set: picked })
292 if (pane) {
293 Scene.ensureVisible(scene, pane.cols, pane.rows, zoom)
294 pane.lastCells = ''
295 }
296 $.ui.invalidate('ui.render')
297 return { text: messages.worldChanged(messages.setLabels[scene.biome], picked === 'auto') }
298 }
299 // pet 은 지금 품종과 목록을 보이고, pet <품종…|auto> 는 품종을 바꿔 저장한다. 지금은 한 마리라 첫 번째만 쓴다
300 if (arg === 'pet') {
301 const choices = messages.petChoices(Species.BREEDS.map(b => b.id))
302 if (sub === '') return { text: messages.petNow(messages.breedLabels[scene.breed], petChoice === 'auto', choices) }
303 const picked = sub === 'auto' ? 'auto' : Species.findBreed(sub)?.id
304 if (picked === undefined) return { text: messages.petUnknown(sub, choices) }
305 petChoice = picked
306 Scene.setBreed(scene, breedOf(picked))
307 lookBand()
308 await host?.store(Names.STORE_PETS, { v: Names.STORE_PETS_VERSION, list: picked === 'auto' ? 'auto' : [picked] })
309 if (pane) pane.lastCells = ''
310 $.ui.invalidate('ui.render')
311 return { text: messages.petChanged(messages.breedLabels[scene.breed], picked === 'auto', rest.length) }
312 }
313 // idle 은 지금 상태를 보이고, idle on · off 는 잠들지 않기를 켜고 꺼 저장한다
314 if (arg === 'idle' && rest.length === 0) {
315 if (sub === '') return { text: messages.idleNow(isIdle, Names.SLEEP_AFTER_MS / 60_000, Names.SLEEP_MS / 60_000) }
316 if (sub !== 'on' && sub !== 'off') return { text: messages.idleUsage }
317 isIdle = sub === 'on'
318 scene.pet.keepAwake = isIdle
319 await host?.store(Names.STORE_IDLE, { v: Names.STORE_IDLE_VERSION, on: isIdle })
320 return { text: messages.idleChanged(isIdle) }
321 }
322 // do <행동> 은 패널이 열려 있을 때 그 행동을 지금 시킨다. 못 하면 이유를 답하고 아무것도 바꾸지 않는다. id 나 별칭으로 받는다
323 if (arg === 'do' && rest.length === 0) {
324 const choices = messages.doChoices(Pet.ORDERS.map(o => o.id))
325 if (sub === '') return { text: messages.doUsage(choices) }
326 const name = Pet.findOrder(sub)
327 if (name === undefined) return { text: messages.doUnknown(sub, choices) }
328 if (!isOpen) return { text: messages.doClosed }
329 const refusal = Pet.order(scene.pet, scene.world, await $.clock.now(), name)
330 if (refusal) return { text: messages.doRefused[refusal] }
331 return { text: messages.doDone(messages.orderLabels[name]) }
332 }
333 if (arg !== '') return { text: messages.usage }
334 if (isOpen) {
335 await $.ui.close({ id: Names.PANE_ID })
336 isOpen = false
337 pane = undefined
338 syncTimer()
339 return { text: messages.panelHidden }
340 }
341 const docked = e.presentation?.isFullscreen === true
342 await $.ui.open(docked
343 ? { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true, columns: Names.COLS }
344 : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true, rows: Names.INLINE_ROWS })
345 isOpen = true
346 // 패널을 여는 것도 신호로 친다. 오래 닫아 두었다 열었을 때 강아지가 보자마자 잠자리로 가지 않는다
347 scene.pet.lastSignalAt = await $.clock.now()
348 return { text: messages.panelShown }
349 })
350
351 // 테마를 바꾸면 띠 색 벌도 바꾼다(거절된 변경은 넘긴다)
352 on('config.set', { key: 'theme' }, async ($, e, next) => {
353 const result = await next(e)
354 if (result.deny === undefined) {
355 safely(() => {
356 isLight = isLightTheme(result.value)
357 lookBand()
358 })
359 }
360 return result
361 })
362
363 on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
364 const result = await next(e)
365 if (result.deny === undefined) { isOpen = false; pane = undefined; syncTimer() }
366 return result
367 })
368
369 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
370 if (e.requestId !== Names.PANE_ID) return next(e)
371 const cols = e.props.bodyColumns
372 const rows = e.props.scroll.bodyRows
373 // Raster 는 터미널 표면에만 있다. 요소 표에 이름이 있는지로는 가를 수 없어 표면으로 가른다
374 if (e.surface !== 'terminal') {
375 pane = undefined
376 syncTimer()
377 return View.noticeView($.ui.resolve(e), [messages.notTerminal])
378 }
379 const kit = $.ui.resolve(e)
380 if (cols < Names.MIN_COLS || rows < Names.MIN_ROWS) {
381 pane = undefined
382 syncTimer()
383 return View.noticeView(kit, messages.tooSmall(Names.MIN_COLS, Names.MIN_ROWS, cols, rows))
384 }
385 const now = await $.clock.now()
386 Scene.ensureVisible(scene, cols, rows, zoom)
387 const cells = Scene.compose(scene, cols, rows, zoom)
388 pane = { requestId: e.requestId, cols, rows, lastCells: cells, lastAt: now }
389 if (isOpen) syncTimer()
390 return View.rasterView(kit.Raster, Names.RASTER_KEY, cols, rows, cells)
391 })
392
393 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
394 isWorking = e.props.isWorking
395 // 설문이 띠를 쓰는 동안이나 띠가 3 행이 안 될 때는 비켜 준다
396 if (!isBandOn || e.props.hasSurvey || e.props.maxRows < Names.BAND_ROWS) {
397 strip = undefined
398 syncTimer()
399 return next(e)
400 }
401 if (e.surface !== 'terminal') return next(e)
402 const kit = $.ui.resolve(e)
403 const cols = Math.max(1, Math.min(512, e.props.bodyColumns))
404 const now = await $.clock.now()
405 const cells = Band.compose(band, cols)
406 strip = { requestId: e.requestId, cols, rows: Names.BAND_ROWS, lastCells: cells, lastAt: now }
407 syncTimer()
408 return View.rasterView(kit.Raster, Names.BAND_KEY, cols, Names.BAND_ROWS, cells)
409 })
410
411 // 세션이 끝날 때 바뀐 편집을 저장한다
412 on('session.end', async ($, e, next) => {
413 try {
414 if (host) await saveEdits(host, await $.clock.now())
415 } catch { /* 못 쓰면 버린다 */ }
416 return next(e)
417 })
418
419 on('turn.start', async ($, e, next) => {
420 try {
421 const now = await $.clock.now()
422 isWorking = true
423 signal(now, () => Scene.onTurnStart(scene, now, e.text))
424 safely(() => Band.onTurnStart(band, now))
425 } catch { /* 강아지 쪽 오류는 무시한다 */ }
426 return next(e)
427 })
428
429 // 글이 흐르는 동안은 바쁜 것으로 본다(걷기·킁킁 비중이 오른다). 조각마다 시계를 읽지 않고 응답 시작과 조각 16 개마다 적는다
430 async function markBusy(now: () => Promise<number>): Promise<void> {
431 try {
432 const t = await now()
433 safely(() => { Pet.onStream(scene.pet, t); band.lastSignalAt = t })
434 } catch { /* 시계를 못 읽으면 이번에는 건너뛴다 */ }
435 }
436
437 // 메인 루프 생각 조각은 첫 조각과 16 개마다 시각을 읽어 넘긴다. 글·도구 조각이 오면 생각이 끝난 것이다
438 async function markThinking(now: () => Promise<number>): Promise<void> {
439 try {
440 const t = await now()
441 signal(t, () => Pet.onThinkPiece(scene.pet, scene.world, t))
442 } catch { /* 시계를 못 읽으면 이번에는 건너뛴다 */ }
443 }
444
445 on('turn.step', async function* ($, e, next) {
446 const clock = () => $.clock.now()
447 const isMain = e.agentId === undefined
448 await markBusy(clock)
449 const stream = next(e)
450 let pieces = 0
451 let thoughts = 0
452 for await (const chunk of stream) {
453 if ((chunk.kind === 'text' || chunk.kind === 'thinking' || chunk.kind === 'input') && ++pieces % 16 === 0) await markBusy(clock)
454 if (isMain && chunk.kind === 'thinking' && thoughts++ % 16 === 0) await markThinking(clock)
455 if (isMain && (chunk.kind === 'text' || chunk.kind === 'tool') && thoughts > 0) {
456 thoughts = 0
457 safely(() => Pet.onThinkEnd(scene.pet))
458 }
459 yield chunk
460 }
461 return await stream.result
462 })
463
464 // 메인 턴 끝에는 끝난 이유대로 반응하고, 서브에이전트의 턴 끝에는 잠깐 사람 쪽만 본다
465 on('turn.complete', async ($, e, next) => {
466 try {
467 const now = await $.clock.now()
468 if (e.agentId === undefined) {
469 isWorking = false
470 signal(now, () => Scene.onTurnEnd(scene, now, e.reason, e.durationMs))
471 safely(() => Band.onTurnEnd(band, now))
472 } else {
473 signal(now, () => Pet.onSubagentEnd(scene.pet, scene.world, now))
474 }
475 } catch { /* 강아지 쪽 오류는 무시한다 */ }
476 return next(e)
477 })
478
479 // 타이핑·턴 도중 입력은 키 입력 경로라 시계를 기다리지 않고 마지막 프레임 시각을 쓴다. 패널이 닫혀 있으면 아무것도 하지 않는다
480 on('prompt.edit', async ($, e, next) => {
481 const t = lastAt
482 if (isOpen && t !== undefined) safely(() => Pet.onTyping(scene.pet, scene.world, t))
483 return next(e)
484 })
485
486 on('prompt.submit', async ($, e, next) => {
487 const t = lastAt
488 if (isOpen && t !== undefined && e.turnId !== undefined && e.origin.kind === 'composer') safely(() => Pet.onPromptQueued(scene.pet, scene.world, t))
489 return next(e)
490 })
491
492 on('agent.spawn', async ($, e, next) => {
493 try {
494 const now = await $.clock.now()
495 signal(now, () => Pet.onSubagentStart(scene.pet, scene.world, now))
496 } catch { /* 강아지 쪽 오류는 무시한다 */ }
497 return next(e)
498 })
499
500 // 사람이 부른 압축과 자동 압축만. 미리 계산해 두는 압축과 서브에이전트의 압축은 넘긴다
501 on('session.compact', async ($, e, next) => {
502 if (e.agentId === undefined && (e.trigger === 'manual' || e.trigger === 'auto')) {
503 try {
504 const now = await $.clock.now()
505 signal(now, () => Pet.onCompact(scene.pet, scene.world, now))
506 } catch { /* 강아지 쪽 오류는 무시한다 */ }
507 }
508 return next(e)
509 })
510
511 // 메인 루프 도구는 이름에 따라 반응하고 오류·거부에도 반응한다. 서브에이전트 도구는 얼굴만 돌린다
512 on('tool.call', async ($, e, next) => {
513 const isMain = e.agentId === undefined
514 let started = false
515 try {
516 const now = await $.clock.now()
517 safely(() => Pet.onToolStart(scene.pet, now, isMain))
518 signal(now, () => isMain ? Scene.onTool(scene, now, e.tool) : Pet.onSubTool(scene.pet, now))
519 safely(() => { Band.onToolStart(band, now); Band.onTool(band, now) })
520 started = true
521 } catch { /* 강아지 쪽 오류는 무시한다 */ }
522 try {
523 const result = await next(e)
524 const fail = result.deny !== undefined ? 'deny' : result.isError === true ? 'error' : undefined
525 if (isMain && fail) {
526 try {
527 const now = await $.clock.now()
528 signal(now, () => Pet.onToolFail(scene.pet, scene.world, now, fail))
529 } catch { /* 강아지 쪽 오류는 무시한다 */ }
530 }
531 return result
532 } finally {
533 // 도구가 도는 동안은 프레임마다 마지막 신호 시각이 갱신되므로 끝날 때는 세기만 줄인다
534 if (started) {
535 safely(() => {
536 Pet.onToolEnd(scene.pet, scene.pet.lastSignalAt, isMain)
537 Band.onToolEnd(band, band.lastSignalAt)
538 })
539 }
540 }
541 })
542}
543hooks/band/index.ts 200 lines1// 프롬프트 위 띠(AbovePrompt): 3 행 = 세로 6 픽셀짜리 옆모습 강아지가 가로로 걷고, 앉고, 킁킁대고, 잔다(순수 모듈).
2// 패널의 강아지와 상태를 나누지 않는다. 세션 신호는 같은 것을 받는다.
3
4import Names from '../names'
5import Pixel, { Palette, type Buffer, type Grid } from '../pixel'
6import Rng from '../rng'
7import Species, { type BandColors, type BandFrame, type BandRole, type SpeciesDef } from '../species'
8
9type Mode = 'idle' | 'walk' | 'run' | 'sit' | 'sniff' | 'sleep'
10
11export type Band = {
12 species: SpeciesDef
13 // 번호마다의 RGB(품종의 띠 색 한 벌로 만든다)
14 rgb: readonly number[]
15 x: number
16 // 1 = 오른쪽, -1 = 왼쪽
17 dir: 1 | -1
18 mode: Mode
19 targetX: number
20 // 지금 모드가 끝나는 시각까지 남은 시간(ms). 걷기·뛰기는 목적지에 닿으면 끝난다
21 left: number
22 // 걸은 거리(px). 발 모양을 거리로 바꾼다
23 walked: number
24 clock: number
25 zAt: number
26 rng: Rng.Rng
27 lastSignalAt: number
28 toolsRunning: number
29 lastSniffAt: number
30 width: number
31 buf?: Buffer
32 grid?: Grid
33}
34
35const H = Names.BAND_ROWS * 2
36const FLOOR_Y = H - 1
37const SNIFF_GAP_MS = 1500
38
39// 띠는 패널 세트·품종 색 표를 빌리지 않고 자기 번호 표와 색 표로 접는다
40export const FLOOR = 1
41export const BODY = 2
42export const UNDER = 3
43export const LINE = 4
44export const EYE = 5
45export const EAR = 6
46export const EAR_DARK = 7
47export const COLLAR = 8
48export const Z = 9
49// 바닥 점선과 z 는 품종과 상관없는 회색이다
50const FLOOR_RGB = 0x8f8b8d
51const Z_RGB = 0xa29ea0
52
53// 품종 띠 색 한 벌로 번호 표를 만든다. 패널 강아지와 같은 개로 보이도록 귀·꼬리·목줄 색도 품종이 정한다
54export function rgbOf(c: BandColors): readonly number[] {
55 return [Palette.DEFAULT_COLOR, FLOOR_RGB, c.body, c.under, c.line, c.eye, c.ear, c.earDark, c.collar, Z_RGB]
56}
57
58// 기본(흰 강아지, 어두운 터미널용)
59export const RGB: readonly number[] = rgbOf(Species.DOG.breeds[0]!.band.dark)
60const INDEX: Readonly<Record<BandRole, number>> = { body: BODY, under: UNDER, line: LINE, eye: EYE, ear: EAR, earDark: EAR_DARK, collar: COLLAR }
61
62// 프레임(종 정의의 band.frames)은 오른쪽 보기, 아래 줄이 바닥 바로 위(y = 4). 가로 가운데가 x
63export function create(seed: number, now: number, species: SpeciesDef = Species.DOG, colors: BandColors = Species.DOG.breeds[0]!.band.dark): Band {
64 const rng = Rng.create(seed ^ 0xba5d)
65 return { species, rgb: rgbOf(colors), x: 20, dir: 1, mode: 'idle', targetX: 20, left: 1500, walked: 0, clock: 0, zAt: 0, rng, lastSignalAt: now, toolsRunning: 0, lastSniffAt: -Infinity, width: 80 }
66}
67
68// 품종이나 터미널 밝기가 바뀌면 모습과 색만 바꾼다(움직임 상태는 그대로)
69export function setLook(band: Band, species: SpeciesDef, colors: BandColors): void {
70 band.species = species
71 band.rgb = rgbOf(colors)
72}
73
74function go(band: Band, mode: 'walk' | 'run', minFrac: number): void {
75 const lo = 6
76 const hi = Math.max(lo + 1, band.width - 7)
77 let x = Rng.range(band.rng, lo, hi)
78 for (let i = 0; i < 6 && Math.abs(x - band.x) < band.width * minFrac; i++) x = Rng.range(band.rng, lo, hi)
79 band.targetX = x
80 band.dir = x >= band.x ? 1 : -1
81 band.mode = mode
82}
83
84function next(band: Band, isWorking: boolean): void {
85 const w = isWorking ? band.species.band.weights.busy : band.species.band.weights.calm
86 const pick = Rng.weighted(band.rng, [['walk', w.walk], ['idle', w.idle], ['sit', w.sit], ['sniff', w.sniff]] as const)
87 if (pick === 'walk') { go(band, 'walk', 0.1); return }
88 band.mode = pick
89 band.left = pick === 'idle' ? Rng.range(band.rng, 1500, 4000) : pick === 'sit' ? Rng.range(band.rng, 3000, 6000) : Rng.range(band.rng, 1200, 2200)
90}
91
92export function onTurnStart(band: Band, now: number): void {
93 band.lastSignalAt = now
94 go(band, 'run', 0.25)
95}
96
97export function onTool(band: Band, now: number): void {
98 band.lastSignalAt = now
99 if (band.mode === 'sleep' || band.mode === 'run' || now - band.lastSniffAt < SNIFF_GAP_MS) return
100 band.lastSniffAt = now
101 band.mode = 'sniff'
102 band.left = 1000
103}
104
105export function onTurnEnd(band: Band, now: number): void {
106 band.lastSignalAt = now
107 if (band.mode === 'sleep') return
108 band.mode = 'sit'
109 band.left = 5000
110}
111
112export function onToolStart(band: Band, now: number): void {
113 band.lastSignalAt = now
114 band.toolsRunning++
115}
116
117export function onToolEnd(band: Band, now: number): void {
118 band.lastSignalAt = now
119 band.toolsRunning = Math.max(0, band.toolsRunning - 1)
120}
121
122export function step(band: Band, dtMs: number, now: number, isWorking: boolean): void {
123 const dt = Math.min(Names.MAX_CATCHUP_MS, Math.max(0, dtMs))
124 band.clock += dt
125 if (band.toolsRunning > 0 || isWorking) band.lastSignalAt = Math.max(band.lastSignalAt, now - 1)
126 band.x = Math.min(Math.max(band.x, 5), Math.max(5, band.width - 6))
127 if (band.mode !== 'sleep' && now - band.lastSignalAt >= Names.SLEEP_AFTER_MS) { band.mode = 'sleep'; band.zAt = band.clock }
128 if (band.mode === 'walk' || band.mode === 'run') {
129 const v = band.mode === 'run' ? band.species.band.runPxS : band.species.band.walkPxS
130 const move = Math.min(Math.abs(band.targetX - band.x), (v * dt) / 1000)
131 band.x += band.dir * move
132 band.walked += move
133 if (Math.abs(band.targetX - band.x) < 0.01) { band.mode = 'idle'; band.left = Rng.range(band.rng, 800, 2000) }
134 return
135 }
136 if (band.mode === 'sleep') return
137 band.left -= dt
138 if (band.left <= 0) next(band, isWorking)
139}
140
141function frameOf(band: Band): BandFrame {
142 const f = band.species.band.frames
143 switch (band.mode) {
144 case 'walk': return Math.floor(band.walked / 3) % 2 === 0 ? f.walkA : f.walkB
145 case 'run': return Math.floor(band.walked / 4) % 2 === 0 ? f.runA : f.runB
146 case 'sniff': return f.sniff
147 case 'sit': return Math.floor(band.clock / 250) % 2 === 0 ? f.sitA : f.sitB
148 case 'sleep': return f.sleep
149 case 'idle': return Math.floor(band.clock / 600) % 5 === 0 ? f.walkB : f.walkA
150 }
151}
152
153// 다음 그리기까지 간격
154export function renderEveryMs(band: Band): number {
155 if (band.mode === 'walk' || band.mode === 'run') return 66
156 if (band.mode === 'sleep') return 400
157 return 150
158}
159
160function buffersFor(band: Band, cols: number): [Buffer, Grid] {
161 const b = band.buf
162 const g = band.grid
163 if (b && g && g.cols === cols) return [b, g]
164 const nb = Pixel.bufferOf(cols, Names.BAND_ROWS, 1)
165 const ng = Pixel.gridOf(cols, Names.BAND_ROWS)
166 band.buf = nb
167 band.grid = ng
168 return [nb, ng]
169}
170
171export function compose(band: Band, cols: number): string {
172 band.width = cols
173 const [b, g] = buffersFor(band, cols)
174 Pixel.clear(b)
175 // 바닥 점선
176 for (let x = 0; x < cols; x += 2) Pixel.plot(b, x, FLOOR_Y, FLOOR)
177 const f = frameOf(band)
178 const left = Math.round(band.x) - Math.floor(f.w / 2)
179 const top = FLOOR_Y - f.rows.length
180 f.rows.forEach((row, y) => {
181 for (let i = 0; i < row.length; i++) {
182 const role = band.species.band.codes[row[i]!]
183 if (role === undefined) continue
184 const c = INDEX[role]
185 const x = band.dir === 1 ? left + i : left + (f.w - 1 - i)
186 Pixel.plot(b, x, top + y, c)
187 }
188 })
189 if (band.mode === 'sleep') {
190 // z 가 머리 위로 한 칸씩 올라갔다 사라진다
191 const phase = Math.floor((band.clock - band.zAt) / 700) % 3
192 const hx = Math.round(band.x) + band.dir * 3
193 if (phase < 2) Pixel.plot(b, hx + band.dir * phase, 1 - phase, Z)
194 }
195 Pixel.fold(b, g, band.rgb)
196 return Pixel.encode(g)
197}
198
199export * as default from '.'
200hooks/biome/index.ts 182 lines1// 맵 세트(순수 모듈): 이름, 별칭, 지형 색, 생성 매개변수, 맵 모양(경계 있는 섬 또는 경계 없는 월드). 세트마다 기본색 몇 개만 적고 나머지 지형 색은 규칙으로 만든다.
2// 생성 매개변수(높이 범위·노이즈·장애물 밀도·나무 종류·호수)는 world/gen.ts 가 읽는다.
3//
4// 세트마다 지표 종류(풀·흙·낙엽·돌·눈, 호수 둘레 모래) 몇 개와 옆면 재질을 두고, 종류마다 가장 낮은·높은 윗면 색만 적는다.
5// 물 색(얕음·깊음·반짝임·거품)은 세트가 적고, 수면 그림자는 팔레트가 얕음·깊음 × 0.8 로 만든다. 물보라는 반짝임 색이다.
6// 만드는 규칙(채널마다 곱, 종류마다): 윗면 그림자 = 윗면 × 0.9, 모눈선 = 가장 낮은 윗면 × 0.84, 선 그림자 = 선 × 0.9,
7// 앞 모서리 하이라이트 = 가장 높은 윗면과 흰색의 가운데, 윗면 색 1~3 은 0 과 4 사이를 채널마다 고르게 나눈다.
8// 무늬는 풀·흙·돌이 가장 낮은 윗면 × 0.8, 낙엽은 주황 잎, 눈은 흰 반짝임.
9// 세트 공통: 굵은 선 = 첫 종류(산악은 돌)의 가장 낮은 윗면 × 0.70, 흙 = 첫 재질 왼쪽 옆면 × 0.72.
10// 재질마다: 오른쪽 옆면 = 왼쪽 옆면 × 0.84, 단 줄 = 그 옆면 × 0.92.
11// 가장자리와 테는 어느 터미널 배경(검정·짙은 회색·흰색)에서도 대비 3 이상이 나오게 상대 휘도 약 0.20 으로 맞춘 색이다.
12
13import { Palette } from '../pixel'
14import { PROP_COLORS } from '../prop/art.js'
15import World, { type Gen, type World as WorldState } from '../world'
16
17export type BiomeId = 'paper' | 'meadow' | 'mountain' | 'forest' | 'lake' | 'beach' | 'snow' | 'desert'
18
19// open: 경계 없는 월드(모눈종이 밖의 세트). 아니면 경계 있는 섬이고 size 는 한 변 칸 수(없으면 시드로 20~32)
20export type Biome = { id: BiomeId; aliases: readonly string[]; colors: Palette.SetColors; gen: Gen.GenParams; size?: number; open?: boolean }
21
22// 지형 1~6 단(0 은 호수 바닥). 격자 간격·워핑·높이 대비와 장애물 밀도·종류 비율이 세트마다 다르다.
23// 바탕은 풀, 블록은 돌, 호수 둘레는 모래(surface 가 바탕·물가 종류·높이 상한을 바꿀 수 있다).
24// surface 는 지표 종류 규칙(돌·눈 높이, 얼룩), 걷는 꾸밈 확률, 호수(셀당 확률·반지름), 지형 모양(바탕 단 수, 스탬프, 능선 띠, 잔기복, 호수 분지),
25// 장애물 밀도 얼룩, 물가 띠 폭, 강(초원·산악·숲·호숫가·설원. 수면이 세트 최저 단인 얕은 물 띠)
26type Surface = Partial<Pick<Gen.GenParams, 'ground' | 'shore' | 'hmax'>> & Pick<Gen.GenParams, 'rockLine' | 'snow' | 'patch' | 'flowerP' | 'tuftP' | 'mushroomP' | 'lake' | 'reedP' | 'lilyP' | 'branchP' | 'shellP' | 'dryBushP' | 'levels' | 'baseMin' | 'forms' | 'crest' | 'detail' | 'basin' | 'obsField' | 'obsNear' | 'shoreWidth' | 'river' | 'extraLakes'>
27const gen = (spacing: readonly number[], amp: readonly number[], warp: number, warpSpacing: number, gain: number, bias: number, obsP: number, treeW: number, rockW: number, blockW: number, tree: number, surface: Surface): Gen.GenParams =>
28 ({ spacing, amp, warp, warpSpacing, gain, bias, hmin: 1, hmax: 6, obsP, treeW, rockW, blockW, tree, ground: World.Gen.KIND_GRASS, blockKind: World.Gen.KIND_STONE, shore: World.Gen.KIND_SAND, ...surface })
29
30// 지표 종류 하나: 맵 번호, 가장 낮은·높은 윗면 색, 옆면 재질 순번(0 세트 옆면, 1 돌, 2 눈)
31type KindBase = { id: number; lo: number; hi: number; material: number }
32// heavy: 굵은 모눈선을 만들 종류 순번(없으면 첫 종류). 화면 대부분을 덮는 종류로 둔다
33// water: 호수 수면 색(얕음·깊음·반짝임·거품), sand: 호수 테 모래의 가장 낮은·높은 윗면 색(물가 종류가 kinds 에 있으면 없다). 물보라는 반짝임 색
34// stoneSide: 돌 재질 옆면(없으면 회색)
35type Base = { kinds: readonly KindBase[]; heavy?: number; side: number; stoneSide?: number; edge: number; dust: number; water: Palette.WaterColors; sand?: readonly [number, number] }
36
37const K = World.Gen
38// 돌·눈 옆면(왼쪽 면 색). 돌은 세로 줄을 긋는다
39const STONE_SIDE = 0x8a867e
40const SNOW_SIDE = 0xc4ccd4
41const stone = (): KindBase => ({ id: K.KIND_STONE, lo: 0x9a968e, hi: 0xbab6ae, material: 1 })
42
43// 높이마다 윗면 5 색 중 하나. 지형 1~6 단에 맞춰 가장 흔한 2·3·4 단은 서로 다른 색, 4·5 단은 같은 색, 6 단 이상(봉우리·높은 블록)은 가장 밝은 색.
44// 0 은 호수 바닥이라(물 칸은 수면만 그린다) 1 단과 같은 색
45const SIX_BAND: readonly number[] = [0, 0, 1, 2, 3, 3, 4, 4, 4]
46
47function mix(a: number, b: number, t: number): number {
48 const c = (shift: number) => Math.round(((a >> shift) & 255) * (1 - t) + ((b >> shift) & 255) * t)
49 return (c(16) << 16) | (c(8) << 8) | c(0)
50}
51
52function accentOf(k: KindBase): number {
53 if (k.id === K.KIND_SNOW || k.id === K.KIND_ICE) return 0xffffff
54 if (k.id === K.KIND_LITTER) return 0xc0703a
55 return Palette.scale(k.lo, 0.8)
56}
57
58function kindColorsOf(k: KindBase): Palette.KindColors {
59 const top = [0, 1, 2, 3, 4].map(i => mix(k.lo, k.hi, i / 4)) as unknown as readonly [number, number, number, number, number]
60 const line = Palette.scale(k.lo, 0.84)
61 return { id: k.id, material: k.material, top, topSh: [Palette.scale(top[0], 0.9), Palette.scale(top[1], 0.9)], hilite: mix(top[4], 0xffffff, 0.5), line, lineSh: Palette.scale(line, 0.9), accent: accentOf(k) }
62}
63
64function materialOf(side: number, brick = false): Palette.MaterialColors {
65 const sideR = Palette.scale(side, 0.84)
66 return { sideL: side, sideR, lStep: Palette.scale(side, 0.92), rStep: Palette.scale(sideR, 0.92), brick }
67}
68
69function colorsOf(base: Base, props: Palette.SetColors['props']): Palette.SetColors {
70 const lineHeavy = Palette.scale(base.kinds[base.heavy ?? 0]!.lo, 0.7)
71 const sand: KindBase[] = base.sand ? [{ id: K.KIND_SAND, lo: base.sand[0], hi: base.sand[1], material: 0 }] : []
72 return {
73 kinds: [...base.kinds, ...sand].map(kindColorsOf),
74 topBand: SIX_BAND,
75 lineHeavy,
76 lineHeavySh: Palette.scale(lineHeavy, 0.9),
77 edge: base.edge,
78 rim: base.edge,
79 materials: [materialOf(base.side), materialOf(base.stoneSide ?? STONE_SIDE, true), materialOf(SNOW_SIDE)],
80 props,
81 water: base.water,
82 dust: base.dust,
83 dirt: Palette.scale(base.side, 0.72),
84 splash: base.water.shimmer,
85 }
86}
87
88// 순서가 목록·자동 선택의 순서다. 모눈종이는 팔레트의 모눈종이 색에 모눈종이 나무 색을 더한다
89export const BIOMES: readonly Biome[] = [
90 { id: 'paper', aliases: ['모눈종이', '종이', 'grid', 'graph'], colors: { ...Palette.PAPER, props: PROP_COLORS.paper }, gen: World.PAPER_GEN },
91 // 넓은 2·3·4 단 풀밭(3 단이 약 60 %)에 40 칸 셀마다 둥근 언덕이 드문드문 솟는다. 못은 언덕 위에 두지 않는다(중심의 분지 전 높이 3 단 이하)
92 {
93 id: 'meadow', aliases: ['초원', 'grass', 'grassland', 'field'], open: true,
94 colors: colorsOf({ kinds: [{ id: K.KIND_GRASS, lo: 0x7fb35a, hi: 0xa6cf78, material: 0 }, { id: K.KIND_DIRT, lo: 0xa88a5c, hi: 0xc4aa7c, material: 0 }, stone()], side: 0x8a6a48, edge: 0x937657, dust: 0xc8b48c, water: { shallow: 0x3f7fb8, deep: 0x2c6096, shimmer: 0xcfeaff, foam: 0x9cc2e2 }, sand: [0xd8c890, 0xebdfb2] }, PROP_COLORS.meadow),
95 gen: gen([48, 16], [1, 0.35], 6, 23, 0.9, 0, 0.22, 3, 1, 1, World.Gen.PROP_BROADLEAF, { levels: 3, baseMin: 2, forms: [{ shape: 'dome', cell: 40, p: 0.6, r: [6, 11], h: [2, 3.5] }], patch: { kind: K.KIND_DIRT, wet: 0.4, p: 0.55 }, flowerP: 0.06, tuftP: 0.09, branchP: 0.004, lake: { p: 0.35, r: [3, 6], lowOnly: 2 }, extraLakes: [{ p: 0.5, r: [8, 12], cell: 96, lowOnly: 2 }], river: { spacing: 64, width: 0.02 } }),
96 },
97 // 1~3 단 골짜기 사이로 능선 두 벌(큰 줄기 셀 56, 작은 줄기 셀 36)이 길게 뻗고, 마루를 따라 봉우리와 고개가 번갈아 난다. 호수는 골짜기(2 단 이하)에만.
98 // 낮은 곳은 풀, 3 단 이상은 돌, 5 단 이상의 추운 곳은 눈
99 {
100 id: 'mountain', aliases: ['산악', '산', 'mountains', 'hill', 'hills'], open: true,
101 colors: colorsOf({ kinds: [{ id: K.KIND_GRASS, lo: 0x8fae6a, hi: 0xa9bf84, material: 0 }, stone(), { id: K.KIND_SNOW, lo: 0xdde3e8, hi: 0xf4f7f9, material: 2 }], heavy: 1, side: 0x7b766f, edge: 0x807b74, dust: 0xb8b2a8, water: { shallow: 0x4f82a6, deep: 0x3f6c8c, shimmer: 0xd8ecf6, foam: 0xa4c0d4 }, sand: [0xcfc6a6, 0xe2dcc4] }, PROP_COLORS.mountain),
102 gen: gen([40, 40 / 3], [1, 0.35], 9, 29, 0.9, -0.05, 0.28, 2, 2, 1, World.Gen.PROP_PINE, { levels: 3, baseMin: 1, detail: { spacing: 9, amp: 0.6 }, forms: [{ shape: 'ridge', cell: 56, p: 0.85, r: [30, 44], h: [4.5, 5.5], slope: 0.75 }, { shape: 'ridge', cell: 36, p: 0.55, r: [14, 22], h: [2.5, 3.5], slope: 0.9, shift: 18 }], rockLine: 3, snow: { line: 0.18, min: 5 }, flowerP: 0.03, tuftP: 0.045, branchP: 0.003, lake: { p: 0.2, r: [3, 5], lowOnly: 1 }, extraLakes: [{ p: 0.45, r: [7, 11], cell: 96, lowOnly: 1 }], river: { spacing: 64, width: 0.02 } }),
103 },
104 // 낮고 넓은 2·3·4 단 평지에 48 칸 셀마다 완만한 언덕. 나무가 빽빽한 곳과 빈터가 번갈아 난다(후보 확률 × 0.35~1.3). 못은 언덕 위에 두지 않는다(3 단 이하)
105 {
106 id: 'forest', aliases: ['숲', 'woods', 'wood'], open: true,
107 colors: colorsOf({ kinds: [{ id: K.KIND_GRASS, lo: 0x5f9448, hi: 0x7bac5c, material: 0 }, { id: K.KIND_LITTER, lo: 0xa0804c, hi: 0xbc9c68, material: 0 }, stone()], side: 0x6b4f35, edge: 0x8e7864, dust: 0xa08a60, water: { shallow: 0x3f6f94, deep: 0x305a7c, shimmer: 0xc8e0f0, foam: 0x92b0c8 }, sand: [0xccb482, 0xdfcca0] }, PROP_COLORS.forest),
108 gen: gen([40, 40 / 3], [1, 0.35], 6, 23, 0.9, 0, 0.46, 6, 1, 0.3, World.Gen.PROP_OAK, { levels: 3, baseMin: 2, forms: [{ shape: 'dome', cell: 48, p: 0.5, r: [7, 12], h: [2, 3.5] }], obsField: { spacing: 24, lo: 0.35, hi: 1.3 }, patch: { kind: K.KIND_LITTER, wet: 0.55, p: 0.45 }, tuftP: 0.03, mushroomP: 0.02, branchP: 0.008, lake: { p: 0.25, r: [3, 5], lowOnly: 2 }, extraLakes: [{ p: 0.4, r: [7, 11], cell: 96, lowOnly: 2 }], river: { spacing: 64, width: 0.02 } }),
109 },
110 // 64 칸 셀마다 큰 호수(반지름 9~16) 하나, 둘레는 분지라 물가에서 멀어질수록 3·4·5 단 바탕으로 오른다. 분지 바닥 2 단(수면 2, 깊은 물 바닥 0)
111 {
112 id: 'lake', aliases: ['호숫가', '물가', '호수', 'lakeside', 'pond', 'water'], open: true,
113 colors: colorsOf({ kinds: [{ id: K.KIND_GRASS, lo: 0x86b865, hi: 0xa4cf80, material: 0 }, stone()], side: 0x7a5c3e, edge: 0x90775e, dust: 0xd0c090, water: { shallow: 0x3f86c6, deep: 0x2c6aa4, shimmer: 0xcfeaff, foam: 0xa0c8e8 }, sand: [0xdccb94, 0xece0b4] }, PROP_COLORS.lake),
114 gen: gen([44, 44 / 3], [1, 0.35], 7, 25, 0.9, 0, 0.18, 2, 1, 0.5, World.Gen.PROP_WILLOW, { levels: 3, baseMin: 3, basin: { extra: 4, depth: 3.5, floor: 2 }, flowerP: 0.04, tuftP: 0.06, branchP: 0.004, lake: { p: 0.85, r: [9, 16], cell: 64 }, river: { spacing: 64, width: 0.02 }, reedP: 0.35, lilyP: 0.12 }),
115 },
116 // 바탕이 모래이고 2·3 단으로 낮다. 64 칸 셀마다 큰 호수(바다 대신, 끝이 있어 헤엄 못 하는 동물도 돌아간다)가 있고 둘레 분지는 2 단으로 평평하며
117 // (수면 2 라 가운데에 깊은 물이 있다),
118 // 테는 두 칸 폭의 젖은 모래, 야자는 분지 안에 몰린다. 물에서 먼 곳에 한 방향으로 긴 사구 띠(2~4 단). 블록이 없어 돌 종류를 두지 않는다.
119 // 밝은 바탕에서 만든 굵은 선은 그림자가 다른 선보다 밝아지므로 굵은 선은 젖은 모래(설원·사막은 돌)에서 만든다
120 {
121 id: 'beach', aliases: ['해변', '바다', '모래사장', 'sea', 'shore', 'coast'], open: true,
122 colors: colorsOf({ kinds: [{ id: K.KIND_SAND, lo: 0xeadba8, hi: 0xf5ecc8, material: 0 }, { id: K.KIND_WET_SAND, lo: 0xcdb88a, hi: 0xdcc9a0, material: 0 }], heavy: 1, side: 0xcdb57c, edge: 0x8a7a54, dust: 0xe8dcb4, water: { shallow: 0x3a9ccc, deep: 0x2c7eae, shimmer: 0xf4fbff, foam: 0xa8dcf0 } }, PROP_COLORS.beach),
123 gen: gen([40, 40 / 3], [1, 0.35], 6, 25, 0.8, 0, 0.12, 3, 1, 0, World.Gen.PROP_PALM, { ground: K.KIND_SAND, shore: K.KIND_WET_SAND, shoreWidth: 2, hmax: 4, levels: 2, baseMin: 2, crest: { salt: 23, spacing: 12, stretch: 4, cut: 0.5, gain: 5, h: 0 }, basin: { extra: 4, depth: 3, floor: 2 }, obsNear: { hi: 2.5, lo: 0.5 }, lake: { p: 0.9, r: [10, 15], cell: 64 }, reedP: 0.08, shellP: 0.03, branchP: 0.004 }),
124 },
125 // 바탕이 눈이고 2·3 단 설원에 36 칸 셀마다 눈더미 언덕. 얼음 얼룩은 2 단(설원 바닥)에만 걷는 지표로 깔린다. 작은 호수(열린 물)는 2 단에만 있고 테도 얼음
126 {
127 id: 'snow', aliases: ['설원', '눈', '눈밭', 'snowfield', 'winter', 'ice'], open: true,
128 colors: colorsOf({ kinds: [{ id: K.KIND_SNOW, lo: 0xe2e9ef, hi: 0xf7fafc, material: 0 }, { id: K.KIND_ICE, lo: 0xbfe0f0, hi: 0xd6ecf6, material: 0 }, stone()], heavy: 2, side: 0x9fb3c4, edge: 0x6f7d89, dust: 0xf4f8fb, water: { shallow: 0x5b90b8, deep: 0x467aa0, shimmer: 0xffffff, foam: 0xd8ecf6 } }, PROP_COLORS.snow),
129 gen: gen([56, 56 / 3], [1, 0.35], 7, 25, 0.8, 0, 0.26, 4, 2, 1, World.Gen.PROP_SNOW_PINE, { levels: 2, baseMin: 2, forms: [{ shape: 'dome', cell: 36, p: 0.55, r: [5, 10], h: [2, 3.5] }], ground: K.KIND_SNOW, shore: K.KIND_ICE, patch: { kind: K.KIND_ICE, wet: 0.45, p: 0.55, maxH: 2 }, lake: { p: 0.15, r: [3, 5], lowOnly: 1 }, extraLakes: [{ p: 0.35, r: [7, 11], cell: 112, lowOnly: 1 }], river: { spacing: 64, width: 0.02 }, branchP: 0.004 }),
130 },
131 // 바탕이 1·2 단 모래이고 사구 마루가 가는 1 단 선으로 한 방향으로 줄지어 난다. 44 칸 셀마다 메사(평평한 꼭대기, 옆은 한 칸에 한 단 계단)가 서고
132 // 4 단 이상은 붉은 바위. 드문 작은 호수(오아시스)는 2 단 이하에만 있고 테는 풀이며 풀포기·갈대가 난다
133 {
134 id: 'desert', aliases: ['사막', 'dunes', 'dune'], open: true,
135 colors: colorsOf({ kinds: [{ id: K.KIND_SAND, lo: 0xe3c07e, hi: 0xefd89c, material: 0 }, { id: K.KIND_STONE, lo: 0xb07850, hi: 0xc89468, material: 1 }, { id: K.KIND_GRASS, lo: 0x7fa84a, hi: 0x9cc466, material: 0 }], heavy: 1, side: 0xc49a5a, stoneSide: 0x9a6a48, edge: 0x967645, dust: 0xe0c89a, water: { shallow: 0x2c7a94, deep: 0x236478, shimmer: 0xd8f4f8, foam: 0xa8dce4 } }, PROP_COLORS.desert),
136 gen: gen([48, 16], [1, 0.35], 8, 27, 0.8, 0, 0.14, 3, 3, 1, World.Gen.PROP_CACTUS, { levels: 2, baseMin: 1, crest: { salt: 23, spacing: 8, stretch: 5, cut: 0.78, gain: 0, h: 1 }, forms: [{ shape: 'mesa', cell: 44, p: 0.55, r: [5, 9], h: [3, 5] }], ground: K.KIND_SAND, shore: K.KIND_GRASS, rockLine: 4, lake: { p: 0.12, r: [3, 4], lowOnly: 1 }, reedP: 0.3, tuftP: 0.3, flowerP: 0.05, dryBushP: 0.02, branchP: 0.004 }),
137 },
138]
139
140export const DEFAULT: BiomeId = 'paper'
141
142export function byId(id: BiomeId): Biome {
143 return BIOMES.find(b => b.id === id)!
144}
145
146// 세트의 맵(나무·바위를 둔다). 경계 없는 월드는 시작 칸의 청크만 만든다
147export function worldOf(id: BiomeId, seed: number): WorldState {
148 const b = byId(id)
149 return World.create(seed, b.gen, { size: b.size, open: b.open, props: true })
150}
151
152// 명령 인자(id 나 한국어·영어 별칭, 소문자로 받는다)로 세트를 찾는다
153export function find(word: string): Biome | undefined {
154 return BIOMES.find(b => b.id === word || b.aliases.includes(word))
155}
156
157export function isId(value: unknown): value is BiomeId {
158 return BIOMES.some(b => b.id === value)
159}
160
161// 자동: 세션 시드로 모눈종이를 뺀 세트 중 하나. 같은 세션이면 같은 세트다
162export function autoOf(seed: number): BiomeId {
163 const choices = BIOMES.filter(b => b.id !== DEFAULT)
164 return choices[((seed ^ 0xb10e) >>> 0) % choices.length]!.id
165}
166
167const themes = new Map<Palette.BankColors, Map<BiomeId, Palette.Theme>>()
168
169// 세트 색과 동물 뱅크 0 의 품종 색으로 만든 색 표. 세트·품종 조합마다 한 번만 만든다
170export function themeOf(id: BiomeId, bank: Palette.BankColors = Palette.WHITE_DOG): Palette.Theme {
171 let forBank = themes.get(bank)
172 if (!forBank) { forBank = new Map(); themes.set(bank, forBank) }
173 let t = forBank.get(id)
174 if (!t) {
175 t = Palette.themeOf(byId(id).colors, [bank])
176 forBank.set(id, t)
177 }
178 return t
179}
180
181export * as default from '.'
182hooks/messages/index.ts 4 lines1export * from './types.js'
2export * from './language.js'
3export * as default from '.'
4hooks/names/index.ts 76 lines1export const PLUGIN_NAME = 'pets'
2export const PANE_ID = 'pets'
3export const COMMAND_NAME = 'pets'
4export const PANE_TITLE = 'pets'
5export const RASTER_KEY = 'map'
6export const BAND_KEY = 'band'
7// 줌 단계('wide' | 'far' | 1 | 2). 값이 없거나 모르는 값이면 맵 보기이고, 이 키에 숫자 1·2 만 저장된 세션도 그대로 읽는다(scene 의 zoomOf)
8export const STORE_SCALE = 'scale'
9// 맵 세트: { v: 1, set: 세트 id | 'auto' }. 모르는 모양·버전·id 면 모눈종이로 보고 지우지 않는다
10export const STORE_WORLD = 'world'
11export const STORE_WORLD_VERSION = 1
12// 동물 품종: { v: 1, list: [품종 id] | 'auto' }. 지금은 한 마리. 모르는 모양·버전·id 면 흰 강아지로 보고 지우지 않는다
13export const STORE_PETS = 'pets'
14export const STORE_PETS_VERSION = 1
15// 잠들지 않기: { v: 1, on: boolean }. 모르는 모양·버전이면 꺼진 것으로 보고 지우지 않는다
16export const STORE_IDLE = 'idle'
17export const STORE_IDLE_VERSION = 1
18// 강아지가 바꾼 칸(옮긴 나뭇가지, 구덩이 흔적): 'edits:<세트>:<세션 시드 16 진 8 자>' 키에 { v: 1, at, list }. 키는 가장 최근 것 EDITS_KEYS 개까지 남기고,
19// 바뀌었으면 EDITS_SAVE_MS 에 한 번과 세션 끝에 쓴다. 모르는 모양·버전이면 빈 표로 보고 그 키에는 쓰지도 지우지도 않는다
20export const STORE_EDITS_PREFIX = 'edits:'
21export const STORE_EDITS_KEY = (set: string, seed: number) => `${STORE_EDITS_PREFIX}${set}:${(seed >>> 0).toString(16).padStart(8, '0')}`
22export const EDITS_KEYS = 8
23export const EDITS_SAVE_MS = 60_000
24
25// 패널: 요청 폭과 최소 크기(셀). 사람이 끈 폭이 이긴다
26export const COLS = 40
27export const MIN_COLS = 32
28export const MIN_ROWS = 24
29// 인라인(전체 화면 레이아웃이 아닐 때) 패널이 원하는 높이
30export const INLINE_ROWS = 30
31
32// 띠: 3 행 = 세로 6 픽셀(강아지 5 + 바닥 1)
33export const BAND_ROWS = 3
34
35// 타일: 12 × 6 픽셀 다이아몬드, 높이 한 단 4 픽셀, 맵 밑판 두께 3 픽셀
36export const TILE_W = 12
37export const TILE_H = 6
38export const STEP_PX = 4
39export const BASE_PX = 3
40// 원거리 줌: 그릴 때만 쓰는 8 × 4 픽셀 다이아몬드, 한 단 3 픽셀, 밑판 2 픽셀. 모눈선은 3 칸마다만 긋는다
41export const FAR_TILE_W = 8
42export const FAR_TILE_H = 4
43export const FAR_STEP_PX = 3
44export const FAR_BASE_PX = 2
45export const FAR_LINE_EVERY = 3
46// 맵 보기 줌(가장 넓게, 처음 단계): 그릴 때만 쓰는 4 × 2 픽셀 다이아몬드, 한 단 2 픽셀, 밑판 1 픽셀. 모눈선은 6 칸마다만 긋는다
47export const WIDE_TILE_W = 4
48export const WIDE_TILE_H = 2
49export const WIDE_STEP_PX = 2
50export const WIDE_BASE_PX = 1
51export const WIDE_LINE_EVERY = 6
52// 원거리·맵 보기에서 동물을 그리는 크기. 개발용 전환이다: 명령·저장값이 없어 사용자는 바꿀 수 없고, 이 줄을 고쳐 바꾼다.
53// 'full' 원래 크기, 'twoThirds' 2/3(3 × 3 픽셀을 2 × 2 로), 'half' 절반(2 × 2 픽셀을 한 픽셀로). 1 배·2 배는 늘 원래 크기다.
54// 바꾸면 tests/frames.test.ts 의 원거리·맵 보기 셀 해시 18 개가 달라져 다시 기록해야 한다
55export type PetScale = 'full' | 'twoThirds' | 'half'
56export const FAR_PET_SCALE: PetScale = 'full'
57
58// 맵 크기(타일): 시드로 MIN~MAX 사이에서 정한다
59export const MAP_MIN = 20
60export const MAP_MAX = 32
61
62// 프레임 타이머. 그리기는 상태에 따라 더 드물게 한다(scene 의 renderEveryMs)
63export const FRAME_MS = 33
64export const BAND_ONLY_FRAME_MS = 66
65// 시뮬레이션 고정 스텝. 프레임 사이 시간을 이 크기로 잘라 돌린다(목적지·홉 호를 넘지 않게)
66export const SIM_STEP_MS = 1000 / 60
67// 한 프레임에 따라잡는 시간의 상한. 패널을 다시 열었을 때 한꺼번에 몰리지 않게 한다
68export const MAX_CATCHUP_MS = 1000
69
70// 신호 없이 이만큼 지나면 잠든다. 도구가 실행 중인 동안은 신호가 이어지는 것으로 본다
71export const SLEEP_AFTER_MS = 180_000
72// 잠든 지 이만큼 지나고 기운이 가득 차면 스스로 깬다. 깨면 신호 없는 시간을 새로 잰다
73export const SLEEP_MS = 60_000
74
75export * as default from '.'
76hooks/pet/index.ts 8 lines1export * from './state.js'
2export * from './brain.js'
3export * from './pose.js'
4export * as Motion from './motion.js'
5export * as Ball from './ball.js'
6export * as Fx from './fx.js'
7export * as default from '.'
8hooks/rng/index.ts 56 lines1// 시드 난수(mulberry32). 상태를 객체에 두어 같은 시드면 같은 수열을 낸다(순수 모듈).
2
3export type Rng = { s: number }
4
5export function create(seed: number): Rng {
6 return { s: seed >>> 0 }
7}
8
9export function next(r: Rng): number {
10 r.s = (r.s + 0x6d2b79f5) >>> 0
11 let t = r.s
12 t = Math.imul(t ^ (t >>> 15), t | 1)
13 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
14 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
15}
16
17export function range(r: Rng, lo: number, hi: number): number {
18 return lo + next(r) * (hi - lo)
19}
20
21export function int(r: Rng, lo: number, hi: number): number {
22 return lo + Math.floor(next(r) * (hi - lo + 1))
23}
24
25export function chance(r: Rng, p: number): boolean {
26 return next(r) < p
27}
28
29export function pick<T>(r: Rng, items: readonly T[]): T {
30 return items[Math.floor(next(r) * items.length)]!
31}
32
33// 가중치 무작위. 가중치가 모두 0 이면 첫 항목
34export function weighted<K extends string>(r: Rng, weights: ReadonlyArray<readonly [K, number]>): K {
35 let sum = 0
36 for (const [, w] of weights) sum += Math.max(0, w)
37 let x = next(r) * sum
38 for (const [k, w] of weights) {
39 x -= Math.max(0, w)
40 if (x < 0) return k
41 }
42 return weights[0]![0]
43}
44
45// 문자열(세션 id)을 32 비트 시드로 바꾼다. FNV-1a
46export function hashOf(text: string): number {
47 let h = 0x811c9dc5
48 for (let i = 0; i < text.length; i++) {
49 h ^= text.charCodeAt(i)
50 h = Math.imul(h, 0x01000193)
51 }
52 return h >>> 0
53}
54
55export * as default from '.'
56hooks/scene/index.ts 465 lines1// 패널 한 장(순수 모듈): 맵, 소품, 그림자, 공, 강아지, 효과를 픽셀 버퍼에 그리고 셀로 접어 인코딩한다.
2// 시뮬레이션은 고정 스텝으로 돌리고, 그리기는 지금 모습에 맞는 간격(renderEveryMs)으로만 한다.
3// 경계 없는 월드는 장면을 만들 때 시작 칸 둘레 3 × 3 청크를 만들고, 그 뒤로는 틱마다 패널 크기에서 계산한
4// 보이는 청크 + 한 겹 중 없는 것을 조금씩 만든다(prefetch). 패널이 붙거나 줌이 바뀔 때는 보이는 청크를 그 자리에서 다 만든다(ensureVisible).
5
6import Biome, { type BiomeId } from '../biome'
7import Iso, { type Camera, type Proj, type View } from '../iso'
8import Names from '../names'
9import Path from '../path'
10import Pet, { type Pet as PetState, type TurnEnd } from '../pet'
11import Pixel, { Palette, type Buffer, type Grid } from '../pixel'
12import Prop from '../prop'
13import Species, { type BreedId } from '../species'
14import Sprite from '../sprite'
15import World, { type World as WorldState } from '../world'
16
17export type Scene = {
18 seed: number
19 world: WorldState
20 cam: Camera
21 pet: PetState
22 // 맵 세트와 그 색 표(번호마다의 색과 그림자 사슬). 색 표는 세트·품종이 바뀔 때만 새로 만든다
23 biome: BiomeId
24 breed: BreedId
25 theme: Palette.Theme
26 // 고정 스텝에 못 미친 나머지 시간(ms)
27 acc: number
28 // 물 반짝임 위상과 마지막으로 바뀐 뒤 흐른 시뮬레이션 시간(ms)
29 glint: { phase: number; ms: number }
30 // 카메라가 앞서 보는 양(세계 픽셀). 강아지 화면 속도에 LEAD_S 를 곱해 부드럽게 따라간다
31 lead: { x: number; y: number }
32 prev: { sx: number; sy: number }
33 // 붙은 크기가 바뀔 때만 새로 만든다
34 buf?: Buffer
35 grid?: Grid
36 // 원거리·맵 보기에서 동물을 원래 크기로 그려 두는 버퍼(줄여서 패널 버퍼로 옮긴다)
37 fig?: Buffer
38 // 마지막 미리 만들기 때 본 A* 횟수(그 뒤 A* 가 돌았으면 이번 틱은 청크를 만들지 않는다)
39 searches: number
40 // 마지막으로 계산한 미리 만들 청크 목록과 그 계산의 열쇠(패널 크기·줌, 8 픽셀로 자른 화면 원점, 강아지 창)
41 want?: { key: string; list: ChunkXY[]; visible: ChunkXY[]; ring: ChunkXY[] }
42 // 사라질 구덩이 흔적을 다음에 거를 실제 시각
43 expireAt: number
44}
45
46// 카메라는 발이 아니라 몸통 가운데(종마다 발에서 위로 lookUpPx)를 보고, 달리는 쪽을 조금 앞서 본다(가는 쪽 화면이 더 넓게 보인다)
47const LEAD_S = 0.35
48const LEAD_TAU_MS = 350
49const LEAD_MAX_PX = 12
50
51function focusOf(pet: PetState): { sx: number; sy: number } {
52 const s = Iso.screenOf(pet.x, pet.y, pet.z)
53 return { sx: s.sx, sy: s.sy - pet.species.sprite.lookUpPx }
54}
55
56// 세트의 맵(나무·바위를 둔다). 경계 없는 월드면 시작 칸 둘레 3 × 3 청크를 만든다
57function worldOf(seed: number, biome: BiomeId): WorldState {
58 const world = Biome.worldOf(biome, seed)
59 const s = world.chunks
60 if (s) {
61 const cx = World.Chunks.chunkOf(world.spawn.x)
62 const cy = World.Chunks.chunkOf(world.spawn.y)
63 s.center = { cx, cy }
64 for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) World.Chunks.make(s, cx + dx, cy + dy)
65 }
66 return world
67}
68
69export function create(seed: number, now: number, biome: BiomeId = Biome.DEFAULT, breed: BreedId = Species.DEFAULT_BREED): Scene {
70 const world = worldOf(seed, biome)
71 const pet = Pet.create(world, seed, now, Species.lookOf(breed))
72 const f = focusOf(pet)
73 return {
74 seed, world, cam: Iso.cameraOf(f.sx, f.sy), pet, biome, breed, theme: Biome.themeOf(biome, Species.breedOf(breed).bank), acc: 0, glint: { phase: 0, ms: 0 }, lead: { x: 0, y: 0 }, prev: f,
75 searches: Path.stats.searches, expireAt: now + EXPIRE_EVERY_MS,
76 }
77}
78
79// 구덩이 흔적은 이 간격으로 거른다(실제 시각)
80const EXPIRE_EVERY_MS = 60_000
81
82// 패널 크기·줌의 버퍼 크기(픽셀)
83function sizeOf(cols: number, rows: number, zoom: Zoom): { w: number; h: number } {
84 return zoom === 2 ? { w: Math.ceil(cols / 2), h: rows } : { w: cols, h: rows * 2 }
85}
86
87type ChunkXY = [number, number]
88
89// 화면 원점을 이 픽셀 단위로 잘라 목록을 다시 계산한다. 잘린 만큼 화면 상자를 넓혀 빠지는 청크가 없다
90const WANT_SNAP = 8
91// 미리 만들 청크: 보이는 청크(강아지가 선 청크 포함)와 그 둘레 한 겹(ring).
92// 강아지 청크와 그 둘레 한 겹이 늘 들어 강아지 둘레 32 칸 이상이 덮이므로, 길 찾기 창(±16)과 행동 판정이 읽는 칸은 늘 캐시에 있다.
93// 각각 강아지 청크에서 가까운 순(체비셰프 거리, 같으면 유클리드 거리, 같으면 좌표 순)
94export function chunksFor(scene: Scene, cols: number, rows: number, zoom: Zoom): { visible: ChunkXY[]; ring: ChunkXY[] } {
95 const proj = projOf(zoom)
96 const { w, h } = sizeOf(cols, rows, zoom)
97 const exact = Iso.viewOf(scene.cam, { w, h }, proj)
98 const view = { ox: Math.floor(exact.ox / WANT_SNAP) * WANT_SNAP, oy: Math.floor(exact.oy / WANT_SNAP) * WANT_SNAP }
99 const visible = Iso.visibleChunks(w + WANT_SNAP, h + WANT_SNAP, view, proj, World.MAX_HEIGHT, World.Chunks.SIZE)
100 const K = World.Chunks.keyOf
101 const seen = new Set(visible.map(([x, y]) => K(x, y)))
102 const cx = World.Chunks.chunkOf(Math.round(scene.pet.x))
103 const cy = World.Chunks.chunkOf(Math.round(scene.pet.y))
104 if (!seen.has(K(cx, cy))) {
105 seen.add(K(cx, cy))
106 visible.push([cx, cy])
107 }
108 const ring: ChunkXY[] = []
109 for (const [x, y] of visible) {
110 for (let dy = -1; dy <= 1; dy++) {
111 for (let dx = -1; dx <= 1; dx++) {
112 const key = K(x + dx, y + dy)
113 if (seen.has(key)) continue
114 seen.add(key)
115 ring.push([x + dx, y + dy])
116 }
117 }
118 }
119 const order = (a: ChunkXY, b: ChunkXY) => {
120 const da = Math.max(Math.abs(a[0] - cx), Math.abs(a[1] - cy))
121 const db = Math.max(Math.abs(b[0] - cx), Math.abs(b[1] - cy))
122 if (da !== db) return da - db
123 const ea = (a[0] - cx) * (a[0] - cx) + (a[1] - cy) * (a[1] - cy)
124 const eb = (b[0] - cx) * (b[0] - cx) + (b[1] - cy) * (b[1] - cy)
125 return ea !== eb ? ea - eb : a[1] !== b[1] ? a[1] - b[1] : a[0] - b[0]
126 }
127 return { visible: visible.sort(order), ring: ring.sort(order) }
128}
129
130// 캐시 기준(강아지 청크)과 버리지 않을 청크(보이는 청크 + 한 겹), 상한(그 수와 25 중 큰 값)을 맞춘다
131function aim(scene: Scene, want: { visible: ChunkXY[]; ring: ChunkXY[] }): void {
132 const s = scene.world.chunks!
133 const keep = new Set<number>()
134 for (const [x, y] of [...want.visible, ...want.ring]) keep.add(World.Chunks.keyOf(x, y))
135 s.keep = keep
136 s.cap = Math.max(World.Chunks.CAP_MIN, keep.size)
137 s.center = { cx: World.Chunks.chunkOf(Math.round(scene.pet.x)), cy: World.Chunks.chunkOf(Math.round(scene.pet.y)) }
138}
139
140// 미리 만들 목록. 패널 크기·줌·잘린 화면 원점·강아지 청크가 그대로면 지난 계산을 쓴다
141function wantOf(scene: Scene, cols: number, rows: number, zoom: Zoom): { list: ChunkXY[]; visible: ChunkXY[]; ring: ChunkXY[] } {
142 const proj = projOf(zoom)
143 const { w, h } = sizeOf(cols, rows, zoom)
144 const v = Iso.viewOf(scene.cam, { w, h }, proj)
145 const C = World.Chunks
146 const key = [cols, rows, zoom, Math.floor(v.ox / WANT_SNAP), Math.floor(v.oy / WANT_SNAP), C.chunkOf(Math.round(scene.pet.x)), C.chunkOf(Math.round(scene.pet.y))].join(',')
147 if (scene.want?.key === key) return scene.want
148 const got = chunksFor(scene, cols, rows, zoom)
149 scene.want = { key, list: [...got.visible, ...got.ring], ...got }
150 aim(scene, got)
151 return scene.want
152}
153
154// 한 틱의 미리 만들기. 지난 미리 만들기 뒤 A* 가 돌았으면 건너뛴다(한 틱에 둘 중 하나만). 한 일의 점을 돌려준다
155export function prefetch(scene: Scene, cols: number, rows: number, zoom: Zoom): number {
156 const s = scene.world.chunks
157 if (!s) return 0
158 const want = wantOf(scene, cols, rows, zoom)
159 const searches = Path.stats.searches
160 if (searches !== scene.searches) {
161 scene.searches = searches
162 return 0
163 }
164 return World.Chunks.pump(s, want.list)
165}
166
167// 보이는 청크를 모두 그 자리에서 만든다(패널이 붙을 때, 줌이 바뀔 때). 만든 청크 수를 돌려준다
168export function ensureVisible(scene: Scene, cols: number, rows: number, zoom: Zoom): number {
169 const s = scene.world.chunks
170 if (!s) return 0
171 const want = wantOf(scene, cols, rows, zoom)
172 let n = 0
173 for (const [x, y] of want.visible) {
174 if (World.Chunks.has(s, x, y)) continue
175 World.Chunks.make(s, x, y)
176 n++
177 }
178 return n
179}
180
181// 세트를 바꾼다: 그 세트의 맵을 새로 만들고 강아지를 새 시작 칸에 둔다. 대기열·길·방문 기록은 옛 맵 것이라 버리고,
182// 기운·들뜸·욕구, 신호 시각, 돌고 있는 도구·턴은 이어 간다(도구가 도는 중이면 계속 잠들지 않고 기다리러 온다). 목마름은 새 맵에서 물가를 다시 판정해
183// 16 걸음 안에 물가가 없으면 0 이 된다. 카메라는 강아지로 옮긴다
184export function setBiome(scene: Scene, biome: BiomeId, now: number): void {
185 const old = scene.pet
186 scene.biome = biome
187 scene.theme = Biome.themeOf(biome, Species.breedOf(scene.breed).bank)
188 scene.world = worldOf(scene.seed, biome)
189 scene.want = undefined
190 const pet = Pet.create(scene.world, scene.seed, now, old.species)
191 pet.energy = old.energy
192 pet.excite = old.excite
193 pet.needs = { ...old.needs }
194 pet.lastSignalAt = old.lastSignalAt
195 pet.lastHumanAt = old.lastHumanAt
196 pet.toolsRunning = old.toolsRunning
197 pet.mainTools = old.mainTools
198 pet.toolSince = old.toolSince
199 pet.turnStartedAt = old.turnStartedAt
200 pet.keepAwake = old.keepAwake
201 scene.pet = pet
202 const f = focusOf(pet)
203 scene.cam = Iso.cameraOf(f.sx, f.sy)
204 scene.lead = { x: 0, y: 0 }
205 scene.prev = f
206}
207
208// 품종을 바꾼다. 동물의 자리·행동은 그대로이고, 종이 바뀌면 걸음·그림·능력이 새 종의 것이 된다(지금 자세도 새 종이 하는 자세로)
209export function setBreed(scene: Scene, breed: BreedId): void {
210 scene.breed = breed
211 scene.pet.species = Species.lookOf(breed)
212 scene.pet.pose = Pet.poseFor(scene.pet.species, scene.pet.pose)
213 scene.theme = Biome.themeOf(scene.biome, Species.breedOf(breed).bank)
214}
215
216// 물 반짝임은 화면이 어차피 매 프레임 바뀌는 동안(움직임·효과가 있음)에는 GLINT_BUSY_MS 마다, 가만히 있을 때는 GLINT_IDLE_MS 마다만 옮긴다.
217// 가만히 있을 때 반짝임 때문에 더 보내는 프레임은 초당 1000 / GLINT_IDLE_MS 번 이하다. 자는 동안은 멈춘다
218export const GLINT_BUSY_MS = 600
219export const GLINT_IDLE_MS = 1200
220
221function stepGlint(scene: Scene): void {
222 const g = scene.glint
223 if (scene.pet.isAsleep) return
224 g.ms += Names.SIM_STEP_MS
225 if (g.ms >= (renderEveryMs(scene) === Names.FRAME_MS ? GLINT_BUSY_MS : GLINT_IDLE_MS)) {
226 g.phase = (g.phase + 1) & 1023
227 g.ms = 0
228 }
229}
230
231// 실제로 흐른 시간(상한 MAX_CATCHUP_MS)을 고정 스텝으로 잘라 돌린다
232export function step(scene: Scene, dtMs: number, now: number): void {
233 scene.acc += Math.min(Names.MAX_CATCHUP_MS, Math.max(0, dtMs))
234 while (scene.acc >= Names.SIM_STEP_MS) {
235 scene.acc -= Names.SIM_STEP_MS
236 Pet.update(scene.pet, scene.world, Names.SIM_STEP_MS, now)
237 const f = focusOf(scene.pet)
238 const dt = Names.SIM_STEP_MS / 1000
239 const clamp = (v: number) => Math.max(-LEAD_MAX_PX, Math.min(LEAD_MAX_PX, v))
240 // 홉의 수직 움직임은 앞서 보지 않는다
241 const vx = (f.sx - scene.prev.sx) / dt
242 const vy = scene.pet.hop ? 0 : (f.sy - scene.prev.sy) / dt
243 const k = 1 - Math.exp(-Names.SIM_STEP_MS / LEAD_TAU_MS)
244 scene.lead.x += (clamp(vx * LEAD_S) - scene.lead.x) * k
245 scene.lead.y += (clamp(vy * LEAD_S) - scene.lead.y) * k
246 scene.prev = f
247 Iso.follow(scene.cam, f.sx + scene.lead.x, f.sy + scene.lead.y, Names.SIM_STEP_MS)
248 stepGlint(scene)
249 }
250 if (now >= scene.expireAt) {
251 scene.expireAt = now + EXPIRE_EVERY_MS
252 World.expireEdits(scene.world, now)
253 }
254}
255
256// 다음 그리기까지 간격: 움직이는 것이 있으면 매 프레임, 가만히 있으면 꼬리·숨만 보이게 드물게
257export function renderEveryMs(scene: Scene): number {
258 const p = scene.pet
259 // 자는 동안 떠오르는 z 는 느리게 움직여 드물게 그려도 된다
260 if (p.pose === 'sleep' && !Pet.isMoving(p) && scene.cam.isSettled && p.particles.every(q => q.kind === 'z')) return 150
261 if (Pet.isMoving(p) || p.particles.length > 0 || p.emote || !scene.cam.isSettled || Pet.Ball.isRolling(p.ball) || p.turnMs > 0 || p.landMs > 0) return Names.FRAME_MS
262 if (p.pose === 'dig' || p.pose === 'shake' || p.pose === 'scratch' || p.pose === 'pant' || p.act.steps[p.act.i]?.k === 'spin') return Names.FRAME_MS
263 return 66
264}
265
266// 보기 단계: 맵 보기(4 × 2 타일) · 원거리(8 × 4 타일) · 1 배 · 2 배(논리 픽셀 하나 = 2 열 × 1 행). 맵 보기·원거리는 도트 1 배다
267export type Zoom = 'wide' | 'far' | 1 | 2
268export const ZOOMS: readonly Zoom[] = ['wide', 'far', 1, 2]
269// 처음 단계(저장한 값이 없을 때): 맵이 가장 넓게 보이는 단계
270export const DEFAULT_ZOOM: Zoom = 'wide'
271
272// 저장된 값을 읽는다. 원거리·맵 보기가 생기기 전에 저장한 1·2 도 그대로 읽히고, 값이 없거나 모르는 값이면 처음 단계다
273export function zoomOf(value: unknown): Zoom {
274 return value === 'wide' || value === 'far' || value === 1 || value === 2 ? value : DEFAULT_ZOOM
275}
276
277// 줌 단계가 그리는 투영
278export function projOf(zoom: Zoom): Proj {
279 return zoom === 'wide' ? Iso.WIDE : zoom === 'far' ? Iso.FAR : Iso.NEAR
280}
281
282// 한 단계 안(+1)·밖(-1)으로. 끝에서는 그대로 둔다
283export function zoomBy(zoom: Zoom, delta: 1 | -1): Zoom {
284 const i = ZOOMS.indexOf(zoom) + delta
285 return ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, i))]!
286}
287
288// 명령에서 부른 단계 이름(wide, far, 1, 2). 모르는 이름이면 undefined
289export function zoomNamed(word: string): Zoom | undefined {
290 return word === 'wide' || word === 'far' ? word : word === '1' ? 1 : word === '2' ? 2 : undefined
291}
292
293// 맵 보기 → 원거리 → 1 배 → 2 배 → 맵 보기
294export function zoomNext(zoom: Zoom): Zoom {
295 return ZOOMS[(ZOOMS.indexOf(zoom) + 1) % ZOOMS.length]!
296}
297
298function buffersFor(scene: Scene, cols: number, rows: number, scale: 1 | 2): [Buffer, Grid] {
299 const b = scene.buf
300 const g = scene.grid
301 if (b && g && g.cols === cols && g.rows === rows && b.scale === scale) return [b, g]
302 const nb = Pixel.bufferOf(cols, rows, scale)
303 const ng = Pixel.gridOf(cols, rows)
304 scene.buf = nb
305 scene.grid = ng
306 return [nb, ng]
307}
308
309// 원거리·맵 보기의 동물: 이 버퍼(FIG_COLS 열 = 가로 FIG_COLS, 세로 2 × FIG_ROWS 픽셀)의 (FIG_X, FIG_Y) 를 바닥 점으로
310// 원래 크기로 그린 뒤 Names.FAR_PET_SCALE 만큼 줄여 옮긴다. 가장 큰 모습(뛰어오른 자세, 입에 문 나뭇가지)도 들어가는 크기다
311const FIG_COLS = 40
312const FIG_ROWS = 20
313const FIG_X = 20
314const FIG_Y = 32
315
316// 1 배·2 배는 동물을 원래 크기로, 원거리·맵 보기는 Names.FAR_PET_SCALE 크기로 그린다. 가까이 보는 것은 동물을 보려는 것이고 멀리 보는 것은 맵을 보려는 것이다.
317// 줄이는 비를 돌려준다(1 이면 줄이지 않는다)
318const SHRINK_BY: Readonly<Record<Names.PetScale, number>> = { full: 1, twoThirds: 1.5, half: 2 }
319
320function shrinkOf(proj: Proj): number {
321 return proj === Iso.NEAR ? 1 : SHRINK_BY[Names.FAR_PET_SCALE]
322}
323
324// 정면 모습은 좌우 반전하지 않는다(sprite 의 paintPet 과 같은 규칙)
325function isMirrored(pose: Sprite.PetPose): boolean {
326 return pose.flip && !pose.body.startsWith('front')
327}
328
329// 동물 그림의 바닥 점(버퍼 좌표). 물에 뜬 동물은 수면점에서 가라앉은 만큼(1 배 픽셀, 줄여 그리면 같은 비로) 아래에 둔다.
330// 그림은 줌과 상관없이 같은 크기인데 원거리·맵 보기 투영은 가라앉은 높이를 줄이므로, 투영대로 두면 물속 줄이 수면 위로 올라온다(1 배·2 배는 같은 자리)
331export function figureAt(p: PetState, world: WorldState, view: View, proj: Proj): { x: number; y: number } {
332 const s = Iso.screenOf(p.x, p.y, p.z, proj)
333 const x = Math.round(s.sx) - view.ox
334 if (!p.swimming || p.hop) return { x, y: Math.round(s.sy) - view.oy }
335 const surface = World.walkHeightAt(world, Math.round(p.x), Math.round(p.y)) * Names.STEP_PX
336 const w = Iso.screenOf(p.x, p.y, surface, proj)
337 return { x, y: Math.round(w.sy + (surface - p.z) / shrinkOf(proj)) - view.oy }
338}
339
340// 동물과 입에 문 공·나뭇가지. 문 것은 얼굴이 보이면 동물 다음에 그려 머리 앞에 오고,
341// 뒷모습이면 먼저 그려 머리에 가린다(동물 그림은 깊이를 쓰지 않는다). 공은 입 칸에서 보는 쪽으로 한 칸 나가 가운데가 입 칸 줄에 온다
342function drawFigure(b: Buffer, p: PetState, pose: Sprite.PetPose, x: number, y: number, depth: number): void {
343 const mouth = Sprite.mouthOf(p.species, pose)
344 const drawHeld = () => {
345 if (!mouth) return
346 const held = depth + (mouth.facing ? 0.01 : -0.01)
347 if (p.carrying) Sprite.drawBall(b, x + mouth.x + (pose.flip ? -1 : 1), y + mouth.y + 2, p.ball.roll, held)
348 if (p.holding) Prop.drawHeldBranch(b, x + mouth.x, y + mouth.y, held)
349 }
350 if (mouth && !mouth.facing) drawHeld()
351 Sprite.drawPet(b, p.species, pose, x, y, depth)
352 if (mouth?.facing) drawHeld()
353}
354
355// 동물 그림이 차지하는 상자(바닥 점 기준, 양 끝 포함). 줄여 그리면 줄인 픽셀로 잰다(원래 픽셀 [a, a + 1) 이 걸치는 줄인 픽셀 모두)
356function figureBox(p: PetState, pose: Sprite.PetPose, by: number): { x0: number; y0: number; x1: number; y1: number } {
357 if (by === 1) return Sprite.boundsOf(p.species, pose)
358 const u = Sprite.boundsOf(p.species, { ...pose, flip: false })
359 const lo = (a: number) => Math.floor(a / by)
360 const hi = (a: number) => Math.ceil((a + 1) / by) - 1
361 const x0 = lo(u.x0)
362 const x1 = hi(u.x1)
363 const box = { x0, y0: lo(u.y0), x1, y1: hi(u.y1) }
364 return isMirrored(pose) ? { ...box, x0: -x1, x1: -x0 } : box
365}
366
367// 시뮬레이션 값은 NEAR 픽셀이다. 위치는 투영으로 옮기고, 동물·공·감정 표시·그림자 크기는 1 배·2 배에서 스프라이트 픽셀 그대로,
368// 원거리·맵 보기에서 동물과 같은 비로 줄인다(감정 표시 그림은 어느 줌이든 원래 크기)
369function drawPet(b: Buffer, scene: Scene, view: View, proj: Proj): void {
370 const { ox, oy } = view
371 const dz = view.dz ?? 0
372 const p = scene.pet
373 const world = scene.world
374 const shadowOf = scene.theme.shadowOf
375 const by = shrinkOf(proj)
376 const k = 1 / by
377 const s = Iso.screenOf(p.x, p.y, p.z, proj)
378 const { x, y } = figureAt(p, world, view, proj)
379 // 그림자는 땅(물이면 수면)에 붙어 있고, 높이 뛸수록 작아진다
380 const tx = Math.round(p.x)
381 const ty = Math.round(p.y)
382 // 경사 칸에서는 선 자리의 경사 윗면(World.surfaceZ)
383 const wet = World.isWater(world, tx, ty)
384 const groundZ = wet ? World.walkHeightAt(world, tx, ty) * Names.STEP_PX : World.surfaceZ(world, p.x, p.y)
385 const g = Iso.screenOf(p.x, p.y, groundZ, proj)
386 const lift = Math.max(0, p.z - (wet ? World.standZ(world, tx, ty) : groundZ))
387 const curled = p.pose === 'sleep' && !Pet.isMoving(p)
388 const sprite = p.species.sprite
389 Iso.drawShadow(b, g.sx - ox + 0.5, g.sy - oy - 0.5, Math.max(2.5, (curled ? sprite.shadow.rxCurl : sprite.shadow.rx) - lift * 0.3) * k, sprite.shadow.ry * k, World.walkHeightAt(world, tx, ty) + 1, shadowOf)
390 // 공과 공 그림자. 공의 청크가 캐시에 없으면(강아지가 멀리 두고 온 공) 화면 밖이라 그리지 않는다
391 const ball = p.ball
392 const bt = Pet.Ball.tileOf(ball)
393 const pose = Pet.poseOf(p)
394 const depth = p.x + p.y + 0.4 - dz
395 // 문 공은 입 칸에 그린다(drawFigure). 입 칸이 없는 모습(웅크린 잠)이면 공의 자리에 그대로 둔다
396 const inMouth = p.carrying && Sprite.mouthOf(p.species, pose) !== undefined
397 if (!inMouth && World.isLoaded(world, bt.x, bt.y)) {
398 const bg = Iso.screenOf(ball.x, ball.y, World.isWater(world, bt.x, bt.y) ? World.walkHeightAt(world, bt.x, bt.y) * Names.STEP_PX : World.surfaceZ(world, ball.x, ball.y), proj)
399 Iso.drawShadow(b, bg.sx - ox + 0.5, bg.sy - oy - 0.5, 2 * k, 1 * k, World.walkHeightAt(world, bt.x, bt.y) + 1, shadowOf)
400 const bs = Iso.screenOf(ball.x, ball.y, ball.z, proj)
401 const ballX = Math.round(bs.sx) - ox
402 const ballY = Math.round(bs.sy) - oy
403 if (by > 1) Sprite.drawBallSmall(b, ballX, ballY, ball.roll, ball.x + ball.y + 0.25 - dz, by)
404 else Sprite.drawBall(b, ballX, ballY, ball.roll, ball.x + ball.y + 0.25 - dz)
405 }
406 if (by > 1) {
407 // 반전하지 않은 모습을 원래 크기로 그려 줄인 뒤 반전해 옮긴다
408 scene.fig ??= Pixel.bufferOf(FIG_COLS, FIG_ROWS, 1)
409 Pixel.clear(scene.fig)
410 drawFigure(scene.fig, p, { ...pose, flip: false }, FIG_X, FIG_Y, 0)
411 Sprite.drawSmall(b, scene.fig, FIG_X, FIG_Y, x, y, isMirrored(pose), depth, p.species, by)
412 } else drawFigure(b, p, pose, x, y, depth)
413 // 흙·물방울·물보라·z 는 강아지가 멈춰 있을 때만 생겨 강아지 기준 오프셋을 그대로 둔다(줄여 그리면 같은 비로 줄인다).
414 // 먼지는 달리거나 내려앉은 자리에 남으므로 카메라와 같은 비율로 옮긴다
415 const near = Iso.screenOf(p.x, p.y, p.z)
416 for (const q of p.particles) {
417 const m = q.kind === 'dust' ? Iso.mapOf(q.x, q.y, proj) : { sx: s.sx + (q.x - near.sx) * k, sy: s.sy + (q.y - near.sy) * k }
418 const px = Math.round(m.sx) - ox
419 const py = Math.round(m.sy) - oy
420 if (q.kind === 'z') Sprite.drawEmote(b, 'z', px, py)
421 else Pixel.plot(b, px, py, q.kind === 'dirt' ? Palette.DIRT : q.kind === 'splash' ? Palette.SPLASH : Palette.DUST)
422 }
423 if (p.emote) {
424 // 떠오르며 나타났다가 제자리에서 머문다
425 const rise = Math.max(0, 3 - Math.floor(p.emote.age / 50))
426 const side = Math.round((Pet.isRight(p.dir) ? sprite.emoteAt.side : -sprite.emoteAt.side) * k)
427 const front = pose.body.startsWith('front')
428 Sprite.drawEmote(b, p.emote.kind, x + (front ? 0 : side), y - Math.round(sprite.emoteAt.up * k) + rise)
429 }
430}
431
432// 지형과 소품. 키 큰 소품은 동물 화면 상자 안에서 동물보다 앞이면 한 칸 걸러 비운다
433export function drawGround(b: Buffer, scene: Scene, view: View, proj: Proj): void {
434 Iso.drawWorld(b, scene.world, view, proj, scene.theme, scene.glint.phase)
435 const p = scene.pet
436 const { x, y } = figureAt(p, scene.world, view, proj)
437 const box = figureBox(p, Pet.poseOf(p), shrinkOf(proj))
438 Prop.drawProps(b, scene.world, view, proj, { x0: x + box.x0, y0: y + box.y0, x1: x + box.x1, y1: y + box.y1, depth: p.x + p.y + 0.4 - (view.dz ?? 0) })
439}
440
441export function compose(scene: Scene, cols: number, rows: number, zoom: Zoom): string {
442 const proj = projOf(zoom)
443 const [b, g] = buffersFor(scene, cols, rows, zoom === 2 ? 2 : 1)
444 Pixel.clear(b)
445 const view = Iso.viewOf(scene.cam, b, proj)
446 drawGround(b, scene, view, proj)
447 drawPet(b, scene, view, proj)
448 Pixel.fold(b, g, scene.theme.rgb)
449 return Pixel.encode(g)
450}
451
452export function onTurnStart(scene: Scene, now: number, text?: string): void {
453 Pet.onTurnStart(scene.pet, scene.world, now, text)
454}
455
456export function onTool(scene: Scene, now: number, tool?: string): void {
457 Pet.onTool(scene.pet, scene.world, now, tool)
458}
459
460export function onTurnEnd(scene: Scene, now: number, reason?: TurnEnd, durationMs?: number): void {
461 Pet.onTurnEnd(scene.pet, scene.world, now, reason, durationMs)
462}
463
464export * as default from '.'
465hooks/species/index.ts 111 lines1import { CAT } from './cat.js'
2import { CLAUDE } from './claude.js'
3import { DOG } from './dog.js'
4import { DUCK } from './duck.js'
5import { PENGUIN } from './penguin.js'
6import { TURTLE } from './turtle.js'
7import { BASE_BODIES, EXTRA_BODIES, HEAD_KINDS, type BitmapSpec, type BodyKind, type BreedDef, type BreedId, type HeadKind, type SpeciesDef, type SpeciesId } from './types.js'
8
9export * from './types.js'
10export { CAT } from './cat.js'
11export { CLAUDE } from './claude.js'
12export { DOG } from './dog.js'
13export { DUCK } from './duck.js'
14export { PENGUIN } from './penguin.js'
15export { TURTLE } from './turtle.js'
16
17export const SPECIES: Readonly<Record<SpeciesId, SpeciesDef>> = { dog: DOG, cat: CAT, turtle: TURTLE, duck: DUCK, penguin: PENGUIN, claude: CLAUDE }
18
19// 모든 품종(강아지, 고양이, 거북이, 오리, 펭귄, 마스코트 순). 첫 번째가 기본
20export const BREEDS: readonly BreedDef[] = [...DOG.breeds, ...CAT.breeds, ...TURTLE.breeds, ...DUCK.breeds, ...PENGUIN.breeds, ...CLAUDE.breeds]
21export const DEFAULT_BREED: BreedId = BREEDS[0]!.id
22
23export function breedOf(id: BreedId): BreedDef {
24 return BREEDS.find(b => b.id === id)!
25}
26
27// 명령 인자(id 나 한국어·영어 별칭, 소문자로 받는다)로 품종을 찾는다
28export function findBreed(word: string): BreedDef | undefined {
29 return BREEDS.find(b => b.id === word || b.aliases.includes(word))
30}
31
32export function isBreedId(value: unknown): value is BreedId {
33 return BREEDS.some(b => b.id === value)
34}
35
36// 자동: 세션 시드로 기본 품종을 뺀 품종 중 하나. 같은 세션이면 같은 품종이다
37export function autoBreed(seed: number): BreedId {
38 const choices = BREEDS.filter(b => b.id !== DEFAULT_BREED)
39 return choices[((seed ^ 0x5bec) >>> 0) % choices.length]!.id
40}
41
42// 칸 좌표 해시(0~1). 무늬 자리를 고른다. 좌우 반전 전 좌표라 방향을 바꿔도 무늬가 따라간다
43function spotAt(x: number, y: number, salt: number): number {
44 let h = Math.imul(x + 0x9e37, 0x85ebca6b) ^ Math.imul(y + 0x79b9, 0xc2b2ae35) ^ salt
45 h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d)
46 h ^= h >>> 13
47 return (h >>> 0) / 4294967296
48}
49
50const SPOT = 'P'
51
52// 그림마다 점 자리를 고르는 소금. 같은 모습의 그림(웅크린 잠 두 장, 정면 몸, 옆머리)은 같은 값이라 점이 같은 자리에 남는다
53const SALT: Readonly<Record<BodyKind | HeadKind, number>> = {
54 stand: 1, sit: 2, lie: 3, curlA: 4, curlB: 4, frontSit: 5, frontStand: 5, bellyA: 8, bellyB: 8, frontBeg: 5, stoop: 1, swim: 9,
55 face: 6, back: 7, alert: 6, droop: 6, tilt: 5,
56}
57
58// 몸 털 칸(w)을 무늬 역할로 바꾼다. 점박이는 칸 해시 비율, 줄무늬는 x 가 간격의 배수인 세로 줄(바닥 점 기준 좌표)
59function patterned(spec: BitmapSpec, breed: BreedDef, salt: number): BitmapSpec {
60 const hit = (x: number, y: number): boolean => breed.spots !== undefined
61 ? spotAt(x, y, salt) < breed.spots
62 : ((x % breed.stripes!) + breed.stripes!) % breed.stripes! === 0
63 return { ...spec, rows: spec.rows.map((row, y) => row.replace(/w/g, (ch, x: number) => hit(x - spec.ax, y - spec.ay) ? SPOT : ch)) }
64}
65
66// 품종의 종
67export function speciesOf(id: BreedId): SpeciesDef {
68 return SPECIES[breedOf(id).species]
69}
70
71const looks = new Map<BreedId, SpeciesDef>()
72
73// 품종의 모습: 그 종의 정의. 무늬가 있는 품종은 몸 털 칸 일부를 무늬 역할로 바꾼 비트맵을 가진 사본. 품종마다 한 번만 만든다
74export function lookOf(id: BreedId): SpeciesDef {
75 const done = looks.get(id)
76 if (done) return done
77 const breed = breedOf(id)
78 const species = SPECIES[breed.species]
79 let look = species
80 if (breed.spots !== undefined || breed.stripes !== undefined) {
81 const s = species.sprite
82 const src: Readonly<Partial<Record<BodyKind, BitmapSpec>>> = s.bodies
83 const bodies: Partial<Record<BodyKind, BitmapSpec>> = {}
84 for (const kind of [...BASE_BODIES, ...EXTRA_BODIES]) {
85 const spec = src[kind]
86 if (spec) bodies[kind] = patterned(spec, breed, SALT[kind])
87 }
88 let heads: Partial<Record<HeadKind, BitmapSpec>> | undefined
89 if (s.heads) {
90 heads = {}
91 for (const kind of HEAD_KINDS) {
92 const spec = s.heads[kind]
93 if (spec) heads[kind] = patterned(spec, breed, SALT[kind])
94 }
95 }
96 look = {
97 ...species,
98 sprite: {
99 ...s,
100 codes: { ...s.codes, [SPOT]: 'pattern' },
101 bodies: bodies as SpeciesDef['sprite']['bodies'],
102 heads: heads as SpeciesDef['sprite']['heads'],
103 },
104 }
105 }
106 looks.set(id, look)
107 return look
108}
109
110export * as default from '.'
111hooks/view/index.ts 3 lines1export * from './pane-view.jsx'
2export * as default from '.'
3hooks/world/index.ts 545 lines1// 시드와 세트 생성 매개변수로 맵을 만들고, 칸 값을 묻는 함수를 둔다(순수 모듈). 맵은 두 가지다.
2// 경계 있는 섬(모눈종이 세트, 합성·테스트 맵): w × h 배열
3// 1. 좌표 해시 생성기(gen.ts)로 높이·장애물(나무·바위·블록)·지표 종류·걷는 꾸밈·호수를 만든다. 맵 가장자리로 갈수록 지형이 낮아져 섬 모양이 된다
4// 2. 나무·바위는 켤 때만 두고(패널의 맵은 켠다), 켜지 않으면 빈 땅으로 둔다(블록 자리는 켜든 끄든 같다)
5// 3. 걸을 수 있는 칸(이웃과 걷는 높이 차 ≤ 1, 장애물 소품·깊은 물이 아닌 칸)을 BFS 로 묶어 가장 큰 덩어리만 걷는 칸으로 둔다.
6// 걷는 높이는 땅이면 높이, 물이면 수면이다. 생성기가 장애물 덩어리를 서로 떨어뜨려 두므로 지형 칸과 얕은 물은 모두 이 덩어리에 들고,
7// 빠지는 것은 오를 수 없는 블록과 깊은 물뿐이다
8// 경계 없는 월드(모눈종이 밖의 세트): 32 × 32 청크를 만들어 캐시에 둔다(chunks.ts). 좌표는 음수도 된다. 걷는 칸은 청크마다 칸 단위로 정한다
9//
10// 강아지가 바꾼 칸(옮긴 나뭇가지, 구덩이 흔적)은 맵이 아니라 편집 표에 둔다. 청크를 버렸다 다시 만들어도 편집은 남고, 질의 함수가 편집을 먼저 본다.
11// 다른 모듈은 World 의 배열을 직접 읽지 않고 아래 질의 함수(heightAt, flagsAt, isWalkable, canStep 등)만 쓴다.
12// 칸 플래그는 물리 사실(물, 깊은 물, 장애물 소품, 블록, 경사 방향)만 담고, 종마다 다른 통과 여부는 이동 규칙(Rules)으로 계산한다.
13
14import Names from '../names'
15import * as Chunks from './chunks.js'
16import { BLOCK, DEEP, OBSTACLE, WALK, WATER } from './flags.js'
17import * as Gen from './gen.js'
18import * as Slope from './slope.js'
19
20export { Chunks, Gen, Slope }
21export { BLOCK, DEEP, OBSTACLE, WALK, WATER }
22
23// 이동 규칙: 한 걸음에 오르내릴 수 있는 단 수, 깊은 물을 건널 수 있는지
24export type Rules = { climb: number; swim: boolean }
25export const DEFAULT_RULES: Rules = { climb: 1, swim: false }
26
27// 강아지가 바꾼 칸 하나. deco: 그 칸의 걷는 꾸밈을 이 값으로 덮는다(0 이면 치움). pit: 구덩이 흔적이 사라지는 실제 시각(ms)
28export type Edit = { deco?: number; pit?: number }
29
30export type World = {
31 // 경계 있는 섬의 크기. 경계 없는 월드는 0 이고 칸 값은 chunks 에 있다
32 w: number
33 h: number
34 // 칸마다 높이(단)와 플래그
35 height: Uint8Array
36 flags: Uint8Array
37 // 칸마다 장애물 소품 번호(Gen.PROP_*, 나무·바위). 0 은 없음
38 prop: Uint8Array
39 // 칸마다 지표 종류(Gen.KIND_*)와 걷는 꾸밈(Gen.DECO_*, 걸음을 막지 않는다)
40 kind: Uint8Array
41 deco: Uint8Array
42 spawn: { x: number; y: number }
43 seed: number
44 // 맵에서 가장 높은 칸의 높이. 그릴 때 화면 밖 타일을 거르는 여유로 쓴다
45 maxHeight: number
46 // 경계 없는 월드의 청크 저장소. 없으면 경계 있는 섬
47 chunks?: Chunks.Store
48 // 칸 키(cellKeyOf)마다 편집. 넣은 순서가 오래된 순서다
49 edits: Map<number, Edit>
50}
51
52// 칸 좌표 범위(양 끝 포함)
53export type Bounds = { x0: number; y0: number; x1: number; y1: number }
54
55export const MAX_HEIGHT = Gen.MAX_HEIGHT
56// 단차를 오르내리는 걸음의 추가 비용. 한 칸 돌아가는 것보다 싸다
57export const CLIMB_COST = 0.4
58
59// 경계 없는 월드는 어느 칸이든 안이다
60export function inside(world: World, x: number, y: number): boolean {
61 return world.chunks !== undefined || (x >= 0 && y >= 0 && x < world.w && y < world.h)
62}
63
64export function isOpen(world: World): boolean {
65 return world.chunks !== undefined
66}
67
68// 칸 범위. 경계 없는 월드는 끝이 없다
69export function bounds(world: World): Bounds {
70 if (world.chunks) return { x0: -Infinity, y0: -Infinity, x1: Infinity, y1: Infinity }
71 return { x0: 0, y0: 0, x1: world.w - 1, y1: world.h - 1 }
72}
73
74// 가장 높은 칸 높이(단). 경계 없는 월드는 지금까지 만든 청크에서
75export function maxHeightOf(world: World): number {
76 return world.chunks ? world.chunks.maxHeight : world.maxHeight
77}
78
79// 칸의 청크가 캐시에 있는지(경계 있는 섬은 늘 참). 그리기는 없는 청크의 타일을 그리지 않는다
80export function isLoaded(world: World, x: number, y: number): boolean {
81 const s = world.chunks
82 return !s || Chunks.has(s, Chunks.chunkOf(x), Chunks.chunkOf(y))
83}
84
85// 그리는 동안은 없는 청크를 만들지 않는다(빈 청크로 읽고, 다음 틱에 미리 만들기가 만든다)
86export function peek<T>(world: World, draw: () => T): T {
87 const s = world.chunks
88 if (!s) return draw()
89 s.peek = true
90 try {
91 return draw()
92 } finally {
93 s.peek = false
94 }
95}
96
97export function heightAt(world: World, x: number, y: number): number {
98 const s = world.chunks
99 if (s) return Chunks.at(s, x, y).height[Chunks.idx(x, y)]!
100 return inside(world, x, y) ? world.height[y * world.w + x]! : 0
101}
102
103export function flagsAt(world: World, x: number, y: number): number {
104 const s = world.chunks
105 if (s) return Chunks.at(s, x, y).flags[Chunks.idx(x, y)]!
106 return inside(world, x, y) ? world.flags[y * world.w + x]! : 0
107}
108
109export function propAt(world: World, x: number, y: number): number {
110 const s = world.chunks
111 if (s) return Chunks.at(s, x, y).prop[Chunks.idx(x, y)]!
112 return inside(world, x, y) ? world.prop[y * world.w + x]! : 0
113}
114
115export function kindAt(world: World, x: number, y: number): number {
116 const s = world.chunks
117 if (s) return Chunks.at(s, x, y).kind[Chunks.idx(x, y)]!
118 return inside(world, x, y) ? world.kind[y * world.w + x]! : 0
119}
120
121// 걷는 꾸밈. 편집이 덮었으면 그 값(칸이 물·장애물 소품이면 편집을 적용하지 않는다, editApplies)
122export function decoAt(world: World, x: number, y: number): number {
123 if (world.edits.size > 0) {
124 const e = world.edits.get(cellKeyOf(x, y))
125 if (e?.deco !== undefined && editApplies(world, x, y)) return e.deco
126 }
127 return madeDecoAt(world, x, y)
128}
129
130// 생성기가 둔 꾸밈(편집 전)
131function madeDecoAt(world: World, x: number, y: number): number {
132 const s = world.chunks
133 if (s) return Chunks.at(s, x, y).deco[Chunks.idx(x, y)]!
134 return inside(world, x, y) ? world.deco[y * world.w + x]! : 0
135}
136
137// 칸 좌표를 한 수로. |좌표| < 32768 에서 겹치지 않는다
138export function cellKeyOf(x: number, y: number): number {
139 return (x & 0xffff) | (y << 16)
140}
141
142export function cellOfKey(key: number): { x: number; y: number } {
143 return { x: (key << 16) >> 16, y: key >> 16 }
144}
145
146// 편집 표 크기 상한. 꽉 차면 구덩이 흔적만 있는 칸을 오래된 것부터 버린다
147export const EDITS_MAX = 256
148// 구덩이 흔적이 남는 시간(실제 ms)
149export const PIT_MS = 30 * 60_000
150
151export function hasPit(world: World, x: number, y: number): boolean {
152 return world.edits.size > 0 && world.edits.get(cellKeyOf(x, y))?.pit !== undefined && editApplies(world, x, y)
153}
154
155// 편집(나뭇가지·구덩이)을 이 칸에 적용하는지. 지형 생성기가 바뀌면 저장된 편집이 이제 물(강·호수)이나 나무·바위 칸을 가리킬 수 있어,
156// 그런 칸에서는 읽을 때 적용하지 않는다. 편집 표와 저장값에서는 지우지 않는다
157function editApplies(world: World, x: number, y: number): boolean {
158 return (flagsAt(world, x, y) & (WATER | OBSTACLE)) === 0
159}
160
161// 새 칸을 넣을 자리를 만든다. 구덩이만 있는 칸을 오래된 것부터 버리고, 그래도 없으면 false
162function roomFor(world: World, key: number): boolean {
163 if (world.edits.has(key) || world.edits.size < EDITS_MAX) return true
164 for (const [k, e] of world.edits) {
165 if (e.deco === undefined) {
166 world.edits.delete(k)
167 return true
168 }
169 }
170 return false
171}
172
173// 편집을 새것으로 넣는다(넣은 순서가 오래된 순서라 지웠다 다시 넣는다)
174function put(world: World, key: number, e: Edit): void {
175 world.edits.delete(key)
176 if (e.deco !== undefined || e.pit !== undefined) world.edits.set(key, e)
177}
178
179// 칸의 꾸밈을 덮는다. 생성기가 둔 값과 같으면 덮개를 뺀다. 표가 꽉 차 넣지 못하면 false
180export function setDeco(world: World, x: number, y: number, deco: number): boolean {
181 const key = cellKeyOf(x, y)
182 const e = { ...world.edits.get(key) }
183 if (madeDecoAt(world, x, y) === deco) delete e.deco
184 else if (!roomFor(world, key)) return false
185 else e.deco = deco
186 put(world, key, e)
187 return true
188}
189
190// 구덩이 흔적을 남긴다. now 는 실제 시각(ms)
191export function addPit(world: World, x: number, y: number, now: number): boolean {
192 const key = cellKeyOf(x, y)
193 if (!roomFor(world, key)) return false
194 put(world, key, { ...world.edits.get(key), pit: now + PIT_MS })
195 return true
196}
197
198// 사라질 때가 지난 구덩이 흔적을 뺀다. 바뀐 게 있으면 true
199export function expireEdits(world: World, now: number): boolean {
200 let changed = false
201 for (const [k, e] of [...world.edits]) {
202 if (e.pit === undefined || e.pit > now) continue
203 const rest = { ...e }
204 delete rest.pit
205 if (rest.deco === undefined) world.edits.delete(k)
206 else world.edits.set(k, rest)
207 changed = true
208 }
209 return changed
210}
211
212// 저장 모양: { v: 1, at, list: [x, y, 종류, 값][] }. 종류 1 = 꾸밈 덮기(값: 꾸밈 번호), 2 = 구덩이(값: 사라지는 실제 시각)
213export const EDITS_VERSION = 1
214export type EditsSave = { v: number; at: number; list: Array<[number, number, number, number]> }
215
216export function editsToSave(world: World, now: number): EditsSave {
217 const list: Array<[number, number, number, number]> = []
218 for (const [k, e] of world.edits) {
219 const { x, y } = cellOfKey(k)
220 if (e.deco !== undefined) list.push([x, y, 1, e.deco])
221 if (e.pit !== undefined) list.push([x, y, 2, e.pit])
222 }
223 return { v: EDITS_VERSION, at: now, list }
224}
225
226// 저장값을 편집 표로. 모르는 버전·모양이면 undefined(부르는 쪽은 빈 표로 쓰고 저장값을 지우지 않는다). 모르는 항목은 건너뛴다
227export function editsFromSave(value: unknown): Map<number, Edit> | undefined {
228 if (typeof value !== 'object' || value === null) return undefined
229 const v = value as { v?: unknown; list?: unknown }
230 if (v.v !== EDITS_VERSION || !Array.isArray(v.list)) return undefined
231 const out = new Map<number, Edit>()
232 for (const item of v.list as unknown[]) {
233 if (!Array.isArray(item) || item.length !== 4 || !item.every(n => Number.isInteger(n))) continue
234 const [x, y, kind, val] = item as [number, number, number, number]
235 if (Math.abs(x) >= 32768 || Math.abs(y) >= 32768 || (kind !== 1 && kind !== 2)) continue
236 const key = cellKeyOf(x, y)
237 if (!out.has(key) && out.size >= EDITS_MAX) continue
238 const e = out.get(key) ?? {}
239 if (kind === 1) e.deco = val
240 else e.pit = val
241 out.set(key, e)
242 }
243 return out
244}
245
246// 걸을 때 발이 닿는 높이. 물 칸은 수면(바닥 + 1, 깊으면 + 2)
247export function walkHeightAt(world: World, x: number, y: number): number {
248 const f = flagsAt(world, x, y)
249 const h = heightAt(world, x, y)
250 return f & WATER ? h + 1 + (f & DEEP ? 1 : 0) : h
251}
252
253export function isWater(world: World, x: number, y: number): boolean {
254 return (flagsAt(world, x, y) & WATER) !== 0
255}
256
257export function isDeep(world: World, x: number, y: number): boolean {
258 return (flagsAt(world, x, y) & DEEP) !== 0
259}
260
261// 칸의 겉: 땅, 얕은 물(걷는다), 깊은 물(헤엄치는 종만), 블록, 막힘(맵 밖·나무·바위)
262export type Surface = 'ground' | 'shallow' | 'deep' | 'block' | 'blocked'
263
264export function surfaceAt(world: World, x: number, y: number): Surface {
265 if (!inside(world, x, y)) return 'blocked'
266 const f = flagsAt(world, x, y)
267 if (f & OBSTACLE) return 'blocked'
268 if (f & WATER) return f & DEEP ? 'deep' : 'shallow'
269 return f & BLOCK ? 'block' : 'ground'
270}
271
272// 얕은 물에 선 동물은 다리 아래가, 깊은 물에서 헤엄치는 동물은 몸 아래가 수면에 잠긴다(1 배 픽셀)
273export const WADE_PX = 3
274export const SWIM_PX = 6
275
276// 칸 가운데에 선 발의 높이(1 배 픽셀): 땅은 높이 × 한 단, 물은 수면에서 잠기는 만큼 아래
277export function standZ(world: World, x: number, y: number): number {
278 const f = flagsAt(world, x, y)
279 const z = walkHeightAt(world, x, y) * Names.STEP_PX
280 return f & WATER ? z - (f & DEEP ? SWIM_PX : WADE_PX) : z
281}
282
283// 경사 방향(Slope.DIRS 번호 1..4, 평지 0). 경사 칸은 걷는 높이가 칸 높이 h 이고 윗면이 방향 쪽 변에서 h + 1 까지 오른다
284export function slopeDirAt(world: World, x: number, y: number): number {
285 return (flagsAt(world, x, y) & Slope.FORM_MASK) >> Slope.FORM_SHIFT
286}
287
288// 실수 좌표 (fx, fy) 에 선 발의 높이(1 배 픽셀). 평지·물은 칸 가운데의 standZ, 경사는 선 자리의 윗면 높이
289export function surfaceZ(world: World, fx: number, fy: number): number {
290 const x = Math.round(fx)
291 const y = Math.round(fy)
292 const dir = slopeDirAt(world, x, y)
293 if (!dir) return standZ(world, x, y)
294 return (heightAt(world, x, y) + Slope.riseOf(dir, fx - x, fy - y)) * Names.STEP_PX
295}
296
297// 칸이 (dx, dy) 쪽 이웃과 맞닿는 변 가운데의 걷는 높이(단). 평지는 걷는 높이, 경사는 방향 쪽 변 h + 1, 반대쪽 h, 옆 변 h + 0.5
298export function edgeHeightAt(world: World, x: number, y: number, dx: number, dy: number): number {
299 const dir = slopeDirAt(world, x, y)
300 if (!dir) return walkHeightAt(world, x, y)
301 return heightAt(world, x, y) + Slope.riseOf(dir, dx / 2, dy / 2)
302}
303
304// 물가: 물이 아닌 걷는 칸 중 4 방향 이웃에 물이 있는 칸과 그 물 쪽 방향(이웃 순서 NEIGHBORS 의 첫 물).
305// from 둘레 ±r 칸 상자를 행 우선으로 훑는다
306export type WaterEdge = { x: number; y: number; dx: number; dy: number }
307
308export function waterEdgesWithin(world: World, from: { x: number; y: number }, r: number, rules: Rules = DEFAULT_RULES): WaterEdge[] {
309 const out: WaterEdge[] = []
310 for (let y = from.y - r; y <= from.y + r; y++) {
311 for (let x = from.x - r; x <= from.x + r; x++) {
312 if (isWater(world, x, y) || !isWalkable(world, x, y, rules)) continue
313 for (const [dx, dy] of NEIGHBORS) {
314 if (isWater(world, x + dx, y + dy)) { out.push({ x, y, dx, dy }); break }
315 }
316 }
317 }
318 return out
319}
320
321export function isBlock(world: World, x: number, y: number): boolean {
322 return (flagsAt(world, x, y) & BLOCK) !== 0
323}
324
325export function isObstacle(world: World, x: number, y: number): boolean {
326 return (flagsAt(world, x, y) & OBSTACLE) !== 0
327}
328
329// 규칙과 상관없이 칸 자체가 들어갈 수 있는 곳인지: 맵 안, 장애물 소품이 아니고, 깊은 물이면 헤엄칠 수 있을 때
330export function passable(world: World, x: number, y: number, rules: Rules = DEFAULT_RULES): boolean {
331 if (!inside(world, x, y)) return false
332 const f = flagsAt(world, x, y)
333 return (f & OBSTACLE) === 0 && ((f & DEEP) === 0 || rules.swim)
334}
335
336function isDefault(rules: Rules): boolean {
337 return rules.climb === DEFAULT_RULES.climb && rules.swim === DEFAULT_RULES.swim
338}
339
340// 기본 규칙이면 가장 큰 덩어리(WALK), 다른 규칙이면 들어갈 수 있는 칸 전부
341export function isWalkable(world: World, x: number, y: number, rules: Rules = DEFAULT_RULES): boolean {
342 if (isDefault(rules)) return (flagsAt(world, x, y) & WALK) !== 0
343 return passable(world, x, y, rules)
344}
345
346// 두 칸 사이를 걸어 건널 때 발 높이가 끊기는 폭(단): 맞닿는 변 가운데 높이의 차. 평지끼리는 걷는 높이 차, 경사를 따라서는 0, 경사 옆 변은 반 단이 섞인다.
347// 이웃 칸이 아니면 걷는 높이 차
348export function stepGapOf(world: World, x0: number, y0: number, x1: number, y1: number): number {
349 const dx = x1 - x0
350 const dy = y1 - y0
351 if (Math.abs(dx) + Math.abs(dy) !== 1) return Math.abs(walkHeightAt(world, x1, y1) - walkHeightAt(world, x0, y0))
352 return Math.abs(edgeHeightAt(world, x0, y0, dx, dy) - edgeHeightAt(world, x1, y1, -dx, -dy))
353}
354
355export function canStep(world: World, x0: number, y0: number, x1: number, y1: number, rules: Rules = DEFAULT_RULES): boolean {
356 return isWalkable(world, x1, y1, rules) && Math.abs(walkHeightAt(world, x1, y1) - walkHeightAt(world, x0, y0)) <= rules.climb
357}
358
359// 한 걸음의 경로 비용: 1, 뛰어야 하는 단차(맞닿는 변 높이 차 ≥ 1, stepGapOf)면 CLIMB_COST, 얕은 물로 들어가면 WADE_COST, 깊은 물(헤엄)이면 SWIM_COST 를 더한다.
360// 경사를 따라 오르내리는 걸음은 뛰지 않아 단차 비용이 없다. 그래서 길 찾기가 되도록 경사로 돈다
361export const WADE_COST = 0.5
362export const SWIM_COST = 2
363
364export function stepCost(world: World, x0: number, y0: number, x1: number, y1: number): number {
365 const f = flagsAt(world, x1, y1)
366 const wet = f & WATER ? (f & DEEP ? SWIM_COST : WADE_COST) : 0
367 return 1 + (stepGapOf(world, x0, y0, x1, y1) >= 1 ? CLIMB_COST : 0) + wet
368}
369
370export const NEIGHBORS: ReadonlyArray<readonly [number, number]> = [[1, 0], [0, 1], [-1, 0], [0, -1]]
371
372// 높이 차 ≤ 1 로 이어진 덩어리마다 번호를 매긴다. 장애물 칸(obstacle[i] = 1)은 어느 덩어리에도 넣지 않고 -1 로 둔다.
373// 돌려주는 값은 [번호 배열, 덩어리별 칸 수]
374export function componentsOf(height: Uint8Array, w: number, h: number, obstacle?: Uint8Array): [Int32Array, number[]] {
375 const label = new Int32Array(w * h).fill(-1)
376 const sizes: number[] = []
377 const queue = new Int32Array(w * h)
378 const isOpen = (i: number) => !obstacle || obstacle[i] !== 1
379 for (let start = 0; start < w * h; start++) {
380 if (label[start] !== -1 || !isOpen(start)) continue
381 const id = sizes.length
382 let head = 0
383 let tail = 0
384 queue[tail++] = start
385 label[start] = id
386 while (head < tail) {
387 const i = queue[head++]!
388 const x = i % w
389 const y = (i - x) / w
390 for (const [dx, dy] of NEIGHBORS) {
391 const nx = x + dx
392 const ny = y + dy
393 if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue
394 const j = ny * w + nx
395 if (label[j] !== -1 || !isOpen(j) || Math.abs(height[j]! - height[i]!) > 1) continue
396 label[j] = id
397 queue[tail++] = j
398 }
399 }
400 sizes.push(tail)
401 }
402 return [label, sizes]
403}
404
405// 높이와 블록·장애물·물 표시로 월드를 만든다. 걷는 칸은 가장 큰 덩어리(깊은 물은 빼고 수면 높이로 이웃을 잇는다),
406// 시작 칸은 가운데에서 가장 가까운 걷는 칸(물이 아닌 칸이 있으면 그중에서).
407// water: 칸마다 0 땅, WATER_SHALLOW 얕은 물, WATER_DEEP 깊은 물. 물 칸의 height 는 바닥 높이다
408export const WATER_SHALLOW = Gen.WATER_SHALLOW
409export const WATER_DEEP = Gen.WATER_DEEP
410// slopes: 경사 칸을 둔다(합성 맵용. 모눈종이 섬은 두지 않는다). 맵 가장자리 칸은 평지로 둔다
411export type Spec = { w: number; h: number; height: Uint8Array; block?: Uint8Array; obstacle?: Uint8Array; prop?: Uint8Array; kind?: Uint8Array; deco?: Uint8Array; water?: Uint8Array; seed?: number; slopes?: boolean }
412
413export function build(spec: Spec): World {
414 const { w, h, height } = spec
415 const flags = new Uint8Array(w * h)
416 const walkH = new Uint8Array(w * h)
417 const closed = new Uint8Array(w * h)
418 for (let i = 0; i < w * h; i++) {
419 if (spec.block?.[i]) flags[i]! |= BLOCK
420 if (spec.obstacle?.[i]) { flags[i]! |= OBSTACLE; closed[i] = 1 }
421 const wet = spec.water?.[i] ?? 0
422 if (wet) flags[i]! |= wet === WATER_DEEP ? WATER | DEEP : WATER
423 if (wet === WATER_DEEP) closed[i] = 1
424 walkH[i] = height[i]! + (wet ? wet : 0)
425 }
426 const [label, sizes] = componentsOf(walkH, w, h, closed)
427 let best = 0
428 for (let i = 1; i < sizes.length; i++) if (sizes[i]! > sizes[best]!) best = i
429 if (sizes.length > 0) for (let i = 0; i < w * h; i++) if (label[i] === best) flags[i]! |= WALK
430 if (spec.slopes) {
431 const solid = new Uint8Array(w * h)
432 for (let i = 0; i < w * h; i++) solid[i] = spec.block?.[i] || spec.obstacle?.[i] ? 1 : 0
433 const wet = spec.water ?? new Uint8Array(w * h)
434 const base = new Uint8Array(w * h)
435 for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) base[y * w + x] = Slope.slopeOf(walkH, wet, solid, y * w + x, w)
436 for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) flags[y * w + x]! |= Slope.stripOf(base, y * w + x, w) << Slope.FORM_SHIFT
437 }
438 let spawn = { x: 0, y: 0 }
439 let near = Infinity
440 for (const dry of [true, false]) {
441 for (let y = 0; y < h; y++) {
442 for (let x = 0; x < w; x++) {
443 const f = flags[y * w + x]!
444 if (!(f & WALK) || (dry && f & WATER)) continue
445 const d = Math.abs(x - w / 2) + Math.abs(y - h / 2)
446 if (d < near) { near = d; spawn = { x, y } }
447 }
448 }
449 if (near < Infinity) break
450 }
451 let maxHeight = 0
452 for (let i = 0; i < w * h; i++) maxHeight = Math.max(maxHeight, height[i]!)
453 return { w, h, height, flags, prop: spec.prop ?? new Uint8Array(w * h), kind: spec.kind ?? new Uint8Array(w * h), deco: spec.deco ?? new Uint8Array(w * h), spawn, seed: spec.seed ?? 0, maxHeight, edits: new Map() }
454}
455
456// 모눈종이 기본 세트의 생성 매개변수: 지형 0~2 단, 블록 +1·+2(최대 4 단), 나무는 드물게
457export const PAPER_GEN: Gen.GenParams = {
458 spacing: [7, 3], amp: [1, 0.45], warp: 0, warpSpacing: 16, gain: 1, bias: 0.05, hmin: 0, hmax: 2,
459 obsP: 0.16, treeW: 1, rockW: 0, blockW: 2, tree: Gen.PROP_PAPER_TREE,
460}
461
462// size: 한 변 칸 수. 없으면 시드로 MAP_MIN~MAP_MAX 에서 가로·세로를 따로 고른다.
463// props: 나무·바위를 둘지(패널의 맵은 켠다. 합성·행동 테스트 맵은 기본값 끔).
464// open: 경계 없는 월드(size 는 쓰지 않는다)
465export type CreateOptions = { size?: number; props?: boolean; open?: boolean }
466
467export function create(seed: number, gen: Gen.GenParams = PAPER_GEN, opts: CreateOptions = {}): World {
468 if (opts.open) return createOpen(seed, gen, opts.props === true)
469 const span = Names.MAP_MAX - Names.MAP_MIN + 1
470 const w = opts.size ?? Names.MAP_MIN + (Gen.hash(seed, 0, 0, 1) % span)
471 const h = opts.size ?? Names.MAP_MIN + (Gen.hash(seed, 0, 0, 2) % span)
472 // 맵 밖은 가장 낮게, 안쪽은 가장자리에서 한 칸마다 한 단까지만 오른다
473 const raw = (x: number, y: number) => {
474 const edge = Math.min(x, y, w - 1 - x, h - 1 - y)
475 return edge < 0 ? gen.hmin : Math.min(Gen.rawHeight(seed, gen, x, y), gen.hmin + edge)
476 }
477 const r = Gen.region(seed, gen, 0, 0, w, h, { raw, inside: { x0: 0, y0: 0, x1: w - 1, y1: h - 1 } })
478 const obstacle = new Uint8Array(w * h)
479 const prop = new Uint8Array(w * h)
480 if (opts.props) for (let i = 0; i < w * h; i++) if (r.prop[i]) { obstacle[i] = 1; prop[i] = r.prop[i]! }
481 return build({ w, h, height: r.height, block: r.block, obstacle, prop, kind: r.kind, deco: r.deco, water: r.water, seed })
482}
483
484// 시작 칸을 찾을 나선의 최대 반지름(칸)
485const SPAWN_REACH = 96
486
487// 경계 없는 월드. 시작 칸은 (0, 0) 에서 나선으로 돌며 찾은 첫 땅 걷는 칸(물·블록이 아닌 칸). 나선은 지나간 칸의 청크만 만든다
488export function createOpen(seed: number, gen: Gen.GenParams, props: boolean): World {
489 const store = Chunks.create(seed, gen, props)
490 const empty = new Uint8Array(0)
491 const world: World = { w: 0, h: 0, height: empty, flags: empty, prop: empty, kind: empty, deco: empty, spawn: { x: 0, y: 0 }, seed, maxHeight: 0, chunks: store, edits: new Map() }
492 world.spawn = spiralSpawn(world)
493 store.syncMade = 0
494 return world
495}
496
497function spiralSpawn(world: World): { x: number; y: number } {
498 const isGround = (x: number, y: number) => {
499 const f = flagsAt(world, x, y)
500 return (f & WALK) !== 0 && (f & (WATER | BLOCK)) === 0
501 }
502 for (let r = 0; r <= SPAWN_REACH; r++) {
503 if (r === 0) {
504 if (isGround(0, 0)) return { x: 0, y: 0 }
505 continue
506 }
507 for (let x = -r; x <= r; x++) if (isGround(x, -r)) return { x, y: -r }
508 for (let y = -r + 1; y <= r; y++) if (isGround(r, y)) return { x: r, y }
509 for (let x = r - 1; x >= -r; x--) if (isGround(x, r)) return { x, y: r }
510 for (let y = r - 1; y >= -r + 1; y--) if (isGround(-r, y)) return { x: -r, y }
511 }
512 return { x: 0, y: 0 }
513}
514
515// 칸 (x, y) 앞쪽(화면 아래) reach 대각선 안, 좌우로 한 칸 폭 안에 나무가 있는지.
516// 대각선 d 앞의 칸은 d × (반높이) 픽셀 아래에 서므로, 나무 키가 그만큼보다 크면 그 칸에 누운 동물을 가린다
517export function hasTreeInFront(world: World, x: number, y: number, reach: number): boolean {
518 for (let d = 1; d <= reach; d++) {
519 for (let e = -1; e <= 1; e++) {
520 if (((d + e) & 1) !== 0) continue
521 if (Gen.isTree(propAt(world, x + (d + e) / 2, y + (d - e) / 2))) return true
522 }
523 }
524 return false
525}
526
527// 화면에서 뒤쪽인 두 이웃(NW = x - 1, NE = y - 1)
528const BACK: ReadonlyArray<readonly [number, number]> = [[-1, 0], [0, -1]]
529
530// 뒤쪽에 블록이나 높은 칸을 등진 곳(아늑한 곳). 잠잘 자리를 고를 때 쓴다.
531// 앞쪽 이웃은 세지 않는다. 앞이 높은 칸은 화면에서 그 칸이 강아지를 가려서다
532export function cozinessOf(world: World, x: number, y: number): number {
533 let n = 0
534 const here = heightAt(world, x, y)
535 for (const [dx, dy] of BACK) {
536 const nx = x + dx
537 const ny = y + dy
538 if (!inside(world, nx, ny)) continue
539 if (heightAt(world, nx, ny) > here) n++
540 }
541 return n
542}
543
544export * as default from '.'
545hooks/pixel/index.ts 6 lines1export * from './grid.js'
2export * from './buffer.js'
3export * from './shrink.js'
4export * as Palette from './palette.js'
5export * as default from '.'
6