SLOPSHOPPER

pets

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…

newpanebandguardcommandprompt
v0.2.0MITupdated 2026-10-06YeomYuJun/claude-mods/pets
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pets
│ ┃ pets ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /pets │ ┃ ⎿ pets: Opened the pets panel │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pets
README

English | 한국어

pets

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.

Install

claude plugin marketplace add YeomYuJun/claude-mods
claude plugin install pets@claude-mods

The full steps are in the repository README.

  • Drawing works only in a terminal. In the VS Code extension and the desktop app, the panel tells you to open it in a terminal.
  • If the panel is smaller than 32 columns × 24 rows, it shows the required size.
  • It works only in the fullscreen renderer (/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.

Features

Maps

  • Graph paper island (default) and seven endless worlds: meadow, mountain, forest, lakeside, beach, snowfield, desert.
  • Terrain differs per set. Meadow, forest, and snowfield have wide plains and hills; mountain has ridges and valleys; lakeside and beach have large lakes and basins; desert has sand flats and mesas.
  • Lakes and rivers, slopes and stone stairs, and per-set props such as trees, rocks, flowers, and grass tufts.
  • Zoom has four levels (map view, far, 1x, 2x).

Animals

SpeciesBreedsNotes
Dogwhite, shiba, black, dalmatian, brownDefault
Catcat, cheeseClimbs two steps at once
Turtle, duck, penguinSwim when they enter water. Duck and penguin waddle
Claude mascot

Autonomous behavior

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.

Reactions to session signals

SignalReaction
PromptStops what it is doing and runs toward you, facing you. Wakes up if it was asleep
TypingLooks at you
Tool callSniffs 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 failureBarks on an error, tilts its head on a denial
Long thinkingSits and shows an ellipsis
Subagent start and endShows musical notes
Conversation compactionShakes itself off and stretches
Turn endOn an answer, sits with hearts and a wagging tail; on an interrupt, !; on an error, droops
No signal for 3 minutesFalls asleep. After 1 minute, once its energy is restored, wakes up on its own

Commands

CommandWhat it does
/petsOpens and closes the panel
/pets zoomChanges 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.

Language

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:

  1. A language key in the settings files, if one is set ("korean", "english"). The Claude Code settings reference does not list this key
  2. The LC_ALL environment variable
  3. The LANG 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.

Development

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 $.

Source 43 files
hooks/register.ts 543 lines
1import 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}
543
hooks/band/index.ts 200 lines
1// 프롬프트 위 띠(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 '.'
200
hooks/biome/index.ts 182 lines
1// 맵 세트(순수 모듈): 이름, 별칭, 지형 색, 생성 매개변수, 맵 모양(경계 있는 섬 또는 경계 없는 월드). 세트마다 기본색 몇 개만 적고 나머지 지형 색은 규칙으로 만든다.
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 '.'
182
hooks/messages/index.ts 4 lines
1export * from './types.js'
2export * from './language.js'
3export * as default from '.'
4
hooks/names/index.ts 76 lines
1export 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 '.'
76
hooks/pet/index.ts 8 lines
1export * 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 '.'
8
hooks/rng/index.ts 56 lines
1// 시드 난수(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 '.'
56
hooks/scene/index.ts 465 lines
1// 패널 한 장(순수 모듈): 맵, 소품, 그림자, 공, 강아지, 효과를 픽셀 버퍼에 그리고 셀로 접어 인코딩한다.
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 '.'
465
hooks/species/index.ts 111 lines
1import { 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 '.'
111
hooks/view/index.ts 3 lines
1export * from './pane-view.jsx'
2export * as default from '.'
3
hooks/world/index.ts 545 lines
1// 시드와 세트 생성 매개변수로 맵을 만들고, 칸 값을 묻는 함수를 둔다(순수 모듈). 맵은 두 가지다.
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 '.'
545
hooks/pixel/index.ts 6 lines
1export * from './grid.js'
2export * from './buffer.js'
3export * from './shrink.js'
4export * as Palette from './palette.js'
5export * as default from '.'
6