๊ณต๋ถ€

์›น๊ฒŒ์ž„?

sourceoftax 2026. 2. 11. 17:56

http://youtube.com/watch?v=iYbAqAclhuk&feature=youtu.be

 

ํ”Œ๋ ˆ์ด ์˜์ƒ

ํ•™๊ต ๋™์•„๋ฆฌ ์ดˆ๋ณด ์ฝ”๋”ฉ ๋Œ€ํšŒ ๋ƒˆ๋˜ ๊ฑฐ

 

์ปจ์…‰

์ดˆ๊ธฐ ๊ธฐํš์•ˆ์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค

์ดˆ๊ธฐ ํ™”๋ฉด

  • ์ง‘ ๋ฐฐ๊ฒฝ
  • ๋Œ์•„๋‹ค๋‹ˆ๋Š” ๋งˆ๋ฆด๋ฆฌ
  • ํœด๋Œ€ํฐ ์†์— ์ฅ๊ณ  ์žˆ์Œ(์ด๊ฒŒ ๋ฉ”๋‰ด ํ™”๋ฉด) -> ํœด๋Œ€ํฐ ์•ฑ์„ ํด๋ฆญํ•˜๋ฉด ์ด๋ฒคํŠธ๋กœ ๋„˜์–ด๊ฐˆ ์ˆ˜ ์žˆ์Œ

์ด๋ฒคํŠธ 1(์‚ฐ์ฑ…)

  • ๊ณตํ†ต: ํ™”๋ฉด์„ ๊ฑธ์–ด๊ฐ€๋ฉฐ ๋žœ๋ค ์ด๋ฒคํŠธ ์กฐ์šฐ ํ˜•์‹. ๋งˆ๋ฆด๋ฆฌ์˜ ์ด ์ฒด๋ ฅ 5. ๋‹ค๋‹ˆ๋ฉฐ ๋‹ค๋ฅธ ํฌ์ผ“๋ชฌ๋“ค์„ ์กฐ์šฐ ๊ฐ€๋Šฅ. ์กฐ์šฐ์‹œ '์„ ๊ณต/์ง€์ผœ๋ณด๊ธฐ/๋„๋ง' ์ค‘ ์„ ํƒ ๊ฐ€๋Šฅ. ํ˜ธ์ „์ ์ธ ํฌ์ผ“๋ชฌ์„ ๋งŒ๋‚ฌ์„ ์‹œ: '์„ ๊ณต'์‹œ ํ•ด๋‹น ํฌ์ผ“๋ชฌ์ด ๊ฐ€์ง„ ๋‚˜๋ฌด์—ด๋งคor์กฐ๋ฏธ๋ฃŒ ์Šต๋“ ๊ฐ€๋Šฅ. '์ง€์ผœ๋ณด๊ธฐ' ์‹œ 50% ํ™•๋ฅ ๋กœ ๊ณต๊ฒฉ๋ฐ›์Œ(๋งˆ๋ฆด๋ฆฌ์˜ ์ฒด๋ ฅ-1), 50% ํ™•๋ฅ ๋กœ ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์Œ. '๋„๋ง' ์„ ํƒ์‹œ 25% ํ™•๋ฅ ๋กœ ๊ณต๊ฒฉ๋ฐ›์Œ(๋งˆ๋ฆด๋ฆฌ์˜ ์ฒด๋ ฅ -1) ์ค‘๋ฆฝ์ ์ธ ํฌ์ผ“๋ชฌ์„ ๋งŒ๋‚ฌ์„ ์‹œ: '์„ ๊ณต'์‹œ ํ•ด๋‹น ํฌ์ผ“๋ชฌ์ด ๊ฐ€์ง„ ๋‚˜๋ฌด์—ด๋งคor์กฐ๋ฏธ๋ฃŒ ์Šต๋“ ๊ฐ€๋Šฅ. '์ง€์ผœ๋ณด๊ธฐ' ์‹œ 50% ํ™•๋ฅ ๋กœ ๊ณต๊ฒฉ๋ฐ›์Œ(๋งˆ๋ฆด๋ฆฌ์˜ ์ฒด๋ ฅ-1), 50% ํ™•๋ฅ ๋กœ ์˜จ์ˆœํ•œ ํฌ์ผ“๋ชฌ์ด ๋‹ค๊ฐ€์™€ ๋‚˜๋ฌด์—ด๋งค๋ฅผ ๊ฑด๋„ค์คŒ. '๋„๋ง' ์„ ํƒ์‹œ ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์Œ ์˜จ์ˆœํ•œ ํฌ์ผ“๋ชฌ์„ ๋งŒ๋‚ฌ์„ ์‹œ: '์„ ๊ณต'์‹œ ํ•ด๋‹น ํฌ์ผ“๋ชฌ์ด ๊ฐ€์ง„ ๋‚˜๋ฌด์—ด๋งค ์Šต๋“ ๊ฐ€๋Šฅ, '์ฃ„์ฑ…๊ฐ์ด ๋“ ๋‹ค...'๋ผ๋Š” ๋ฉ”์‹œ์ง€ ๋‚˜์˜ด. '์ง€์ผœ๋ณด๊ธฐ' ์‹œ 50% ํ™•๋ฅ ๋กœ ์˜จ์ˆœํ•œ ํฌ์ผ“๋ชฌ์ด ๋‹ค๊ฐ€์™€ ๋‚˜๋ฌด์—ด๋งค๋ฅผ ๊ฑด๋„ค์คŒ, 50% ํ™•๋ฅ ๋กœ ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์Œ. '๋„๋ง' ์„ ํƒ์‹œ ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์Œ

๋งˆ๋ฆด๋ฆฌ์˜ ์ฒด๋ ฅ์ด 0์ด ๋˜๋ฉด ๊ฐ•์ œ ๊ท€๊ฐ€. ์•„๋ž˜์˜ ์ด๋ฒคํŠธ๊ฐ€ ๋‚˜์˜ค๋ฉด(์กฐ์šฐ ํ™•๋ฅ  ์•ฝ 10%) ์‚ฐ์ฑ… ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌํ•จ. ์ดํ›„ ์ดˆ๊ธฐ ํ™”๋ฉด์œผ๋กœ ๋Œ์•„๊ฐ

1-1. ์ˆฒ

  • ๋Œ€๋Ÿ‰์˜ ๋‚˜๋ฌด์—ด๋งค๋ฅผ ํš๋“ ๊ฐ€๋Šฅ

1-2. ๊ฐ•

  • ๋Œ€๋Ÿ‰์˜ ์กฐ๋ฏธ๋ฃŒ ํš๋“ ๊ฐ€๋Šฅ. ๋งˆ๋ฆด๋ฆฌ์˜ ๊ธฐ๋ถ„์ด ์ข‹์•„์ง

์ด๋ฒคํŠธ 2(์š”๋ฆฌ)

  • ๋ณด์œ ํ•œ ๋‚˜๋ฌด์—ด๋งค์™€ ์กฐ๋ฏธ๋ฃŒ๋ฅผ ์„ ํƒํ•ด ์ง„์ž… ๊ฐ€๋Šฅ. ์†Œ์ง€ํ•œ ๋‚˜๋ฌด์—ด๋งค or ์กฐ๋ฏธ๋ฃŒ๊ฐ€ ํ•˜๋‚˜๋„ ์—†๋‹ค๋ฉด ์ง„์ž… ๋ถˆ๊ฐ€ 2-1. ๋ถˆ ์ง€ํ”ผ๊ธฐ: ํด๋ฆญ or ์ŠคํŽ˜์ด์Šค๋ฐ” ์—ฐํƒ€๋ฅผ ํ†ตํ•ด ๋ถˆ์ด ๊บผ์ง€์ง€ ์•Š๊ฒŒ ์œ ์ง€ํ•ด์•ผ ํ•จ
  • ์™„์„ฑ๋œ ์š”๋ฆฌ๋ฅผ ๋งˆ๋ฆด๋ฆฌ๊ฐ€ ๋จน๋Š” CG. ์ดํ›„ ์ดˆ๊ธฐ ํ™”๋ฉด์œผ๋กœ ๋Œ์•„๊ฐ
  • ์—ฌ๊ธฐ์— ๋‚˜๋ฌด์—ด๋งค์™€ ์กฐ๋ฏธ๋ฃŒ ์กฐํ•ฉ์— ๋”ฐ๋ผ ๋งˆ๋ฆด๋ฆฌ์˜ ์„ ํ˜ธ๊ฐ€ ๋‹ฌ๋ผ์ง€๋Š” ๋ณ€๋™์„ฑ ๋„ฃ์–ด๋„ ์ข‹์„๋“ฏ?

์ด๋ฒคํŠธ 3(๋ชฉ์š•)

  • ๋งˆ๋ฆด๋ฆฌ ์ผ๋Ÿฌ์ŠคํŠธ์— ์ŠคํŽ€์ง€(๋งˆ์šฐ์Šค ์ปค์„œ)๋ฅผ ๋Œ€๊ณ  ํด๋ฆญํ•˜๋ฉด ๊ฑฐํ’ˆ ๋ฌปํžˆ๊ธฐ ๊ฐ€๋Šฅ -> ์ดํ›„ ๊ฑฐํ’ˆ์ด ์ถฉ๋ถ„ํžˆ(์‹œ์Šคํ…œ ์ตœ๋Œ€ ๊ฑฐํ’ˆ ๊ธฐ์ค€ 70% ์ด์ƒ) ๋ฌป์—ˆ๋‹ค๋ฉด ์ƒค์›Œ๊ธฐ ์•„์ด์ฝ˜ ๋“ฑ์žฅ -> ์ƒค์›Œ๊ธฐ๋กœ ๊ฑฐํ’ˆ์„ ์”ป์–ด๋‚ด๋ฉด ์ด๋ฒคํŠธ ์™„๋ฃŒ. ์ดˆ๊ธฐ ํ™”๋ฉด์œผ๋กœ ๋Œ์•„๊ฐ

ํ•ด๋‹น ๋กœ์ง๋“ค์„ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•ด ์ฝ”๋“œ๋ฅผ ํฌ๊ฒŒ ๋„ค ๊ฐ€์ง€๋กœ ๋ถ„๋ฅ˜ํ•ด ์ž‘์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค.

  1. Python์‚ฐ์ฑ…: ํ„ด ์ˆ˜์— ๋”ฐ๋ผ ์ด๋ฒคํŠธ ํ™•๋ฅ ์„ ์ฆ๊ฐ€์‹œํ‚ค๊ณ , ํฌ์ผ“๋ชฌ ์กฐ์šฐ ์‹œ ์„ฑ๊ฒฉ๊ณผ ์‚ฌ์šฉ์ž์˜ ์„ ํƒ์— ๋”ฐ๋ผ ํ”ผํ•ด·๋ณด์ƒ ๊ฒฐ๊ณผ๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.๋ชฉ์š•: ์ง„ํ–‰๋„๋ฅผ ๋ฐ›์•„ ์ƒค์›Œ๊ธฐ ํ•ด๊ธˆ ์—ฌ๋ถ€๋ฅผ ํŒ๋‹จํ•˜๋Š” ๋กœ์ง์„ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค.
  2. ์š”๋ฆฌ: ์‚ฌ์šฉ์ž๊ฐ€ ํˆฌ์ž…ํ•œ ์žฌ๋ฃŒ ์กฐํ•ฉ์„ ๋ ˆ์‹œํ”ผ ํ…Œ์ด๋ธ”๊ณผ ๋น„๊ตํ•ด ๊ฒฐ๊ณผ์™€ ๊ธฐ๋ถ„ ๋ณ€ํ™”๋ฅผ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.
  3. ๊ฒŒ์ž„์˜ ์ƒํƒœ๋ฅผ ์ €์žฅํ•˜๊ณ , ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ํ•„์š”ํ•œ ์ •๋ณด๋ฅผ API๋กœ ์ œ๊ณตํ•˜๋Š” ๋ฐฑ์—”๋“œ ์—ญํ• ์ž…๋‹ˆ๋‹ค. ๊ฐ ๋ชจ๋“ˆ์—์„œ ํ•ด๋‹น ์ฝ”๋“œ๊ฐ€ ๋งก์€ ์—ญํ• ์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค
  4. HTML
  5. ๊ฒŒ์ž„ ํ™”๋ฉด์˜ ๊ตฌ์กฐ๋ฅผ ์ •์˜ํ•˜๋Š” ํ”„๋ก ํŠธ์—”๋“œ ๋ ˆ์ด์•„์›ƒ์ž…๋‹ˆ๋‹ค. ์ง‘·์‚ฐ์ฑ…·์š”๋ฆฌ·๋ชฉ์š•์„ ๊ฐ๊ฐ ํ•˜๋‚˜์˜ section์œผ๋กœ ๊ตฌ์„ฑํ•ด ํ•œ ํŽ˜์ด์ง€ ์•ˆ์— ๋ฐฐ์น˜ํ•˜๊ณ , JavaScript๊ฐ€ ํ•„์š”ํ•œ ํ™”๋ฉด๋งŒ ์ „ํ™˜ํ•˜๋„๋ก ์„ค๊ณ„ํ–ˆ์Šต๋‹ˆ๋‹ค. HUD, ๋Œ€์‚ฌ์ฐฝ, ์„ ํƒ ๋ฒ„ํŠผ, ์ง„ํ–‰ ๋ฐ” ๋“ฑ ์ฃผ์š” UI ์š”์†Œ๋ฅผ ๋ฏธ๋ฆฌ ๋ฐฐ์น˜ํ•˜๋ฉฐ, ์ด๋ฏธ์ง€ ๋ฆฌ์†Œ์Šค๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๊ฒฝ๋กœ๋„ ํ•จ๊ป˜ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.
  6. Javascript์‚ฐ์ฑ…: ์ผ์ • ์‹œ๊ฐ„๋งˆ๋‹ค ์กฐ์šฐ ์š”์ฒญ + ์„ ํƒ UI ํ‘œ์‹œ ๋ฐ ๊ฒฐ๊ณผ ๋ฉ”์‹œ์ง€·์ƒํƒœ ๊ฐฑ์‹ ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.์š”๋ฆฌ: ์žฌ๋ฃŒ ์‚ฌ์šฉ๋Ÿ‰์„ ์„œ๋ฒ„์— ์ „๋‹ฌํ•˜๊ณ , ๊ฒฐ๊ณผ์— ๋งž๋Š” ๋ฐ˜์‘ ์—ฐ์ถœ์„ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค.
  7. ๋ชฉ์š•: ํฌ์ธํ„ฐ ์ด๋™์— ๋”ฐ๋ผ ์ง„ํ–‰๋„๋ฅผ ์ฆ๊ฐ€์‹œํ‚ค๊ณ , 70% ์ด์ƒ์ผ ๊ฒฝ์šฐ ์ƒค์›Œ ๋ชจ๋“œ๋กœ ์ „ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
  8. ์„œ๋ฒ„ API๋ฅผ ํ˜ธ์ถœํ•ด ์ƒํƒœ๋ฅผ ๋ฐ›์•„์˜ค๊ณ , ์ด๋ฅผ ์ฆ‰์‹œ ํ™”๋ฉด์— ๋ฐ˜์˜ํ•˜๋Š” ์—ญํ• ์„ ํ•ฉ๋‹ˆ๋‹ค. ํ™”๋ฉด ์ „ํ™˜ ๊ด€๋ฆฌ์™€ ๋Œ€์‚ฌ ์ถœ๋ ฅ ๋กœ์ง์„ ๋‹ด๋‹นํ•˜๋ฉฐ, ๊ฐ ๊ธฐ๋Šฅ๋ณ„ ๋™์ž‘ ํ๋ฆ„์„ ์ œ์–ดํ•ฉ๋‹ˆ๋‹ค.
  9. CSS
  10. ์ „์ฒด๋ฅผ ํ’€์Šคํฌ๋ฆฐ์œผ๋กœ ๊ณ ์ •ํ•˜๊ณ  ๊ฐ ์žฅ๋ฉด๋“ค์„ absolute๋กœ ๊ฒน์ณ ์˜ฌ๋ฆฐ ๋‹ค์Œ ํ•„์š”ํ•œ ์žฅ๋ฉด๋“ค๋งŒ ๋ณด์ด๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค. HUD๋Š” ๋ฐ˜ํˆฌ๋ช… ํŒจ๋„์„ ์‚ฌ์šฉํ•ด ๋‹ค๋ฅธ ํ™”๋ฉด์„ ์™„์ „ํžˆ ๊ฐ€๋ฆฌ์ง€ ์•Š๊ฒŒ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ์บ๋ฆญํ„ฐ์˜ ํ”๋“ค๋ฆผ ์• ๋‹ˆ๋ฉ”์ด์…˜, ๋ชฉ์š• ์ปค์„œ ๋ณ€๊ฒฝ ๋“ฑ ์‹œ๊ฐ์ ์ธ ํšจ๊ณผ๋ฅผ ๋‹ด๋‹นํ•˜๋Š” ํŒŒํŠธ์ž…๋‹ˆ๋‹ค.

 

ํ›„๊ธฐ

์ฝ”๋“œ ์ „๋ฐ˜์„ ์ž‘์„ฑํ•  ๋•Œ ๋™์ผํ•œ ํฌ์ผ“๋ชฌ ์†Œ์žฌ+์›น๊ฒŒ์ž„์œผ๋กœ ์ œ์ž‘๋œ POKEROGUE์˜ ์ฝ”๋“œ(https://github.com/pagefaultgames/pokerogue, ๊ณต์‹ ๊นƒํ—ˆ๋ธŒ)๋ฅผ ์ฐธ์กฐํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฏธ์ง€๋Š” ๊ณต์‹ ์ด๋ฏธ์ง€/

javascripts ์ฝ”๋“œ๊ฐ€ ์ƒ๋‹นํžˆ ๊ธด๋ฐ, ๋งŒ๋“œ๋Š” ์ค‘๊ฐ„์— ๊ฐœ๋ณ„ ๊ธฐ๋Šฅ๋“ค์„ ๋ชจ๋“ˆํ™”ํ•˜๋Š”๊ฒŒ ํ›จ์”ฌ ํŽธํ–ˆ์„ ๊ฑฐ๋ผ๋Š” ์ƒ๊ฐ์ด ๋“ค์–ด ์•„์‰ฌ์›€์ด ๋‚จ์Šต๋‹ˆ๋‹ค.

๋” ๋„ฃ๊ณ  ์‹ถ์€ ๊ธฐ๋Šฅ์ด ๋งŽ์•˜์ง€๋งŒ ์ž๊พธ๋งŒ ์˜ค๋ฅ˜๊ฐ€ ๋‚˜ ์‚ญ์ œํ•˜๊ฒŒ ๋œ ๊ธฐ๋Šฅ๋“ค์ด ๋‹ค์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ์•Œ๊ณ ๋ฆฌ์ฆ˜์— ๋Œ€ํ•œ ๊ณต๋ถ€์™€ ๊ฒŒ์ž„ ์—”์ง„์„ ์‚ฌ์šฉํ•˜๋Š” ๋ฒ•์„ ๊ณต๋ถ€ํ•ด์„œ ๋‹ค์Œ์—๋Š” ๋” ํ€„๋ฆฌํ‹ฐ ๋†’์€ ๊ฒŒ์ž„์„ ๋งŒ๋“ค์–ด๋ณด๊ณ  ์‹ถ์Šต๋‹ˆ๋‹ค

 

์ฝ”๋“œ

 

1. py

from flask import Flask, render_template, jsonify, request, session
import random

app = Flask(__name__)
app.secret_key = "dev-secret-change-me"

POKEMON_DB = [
    {"id": "mankey", "name": "๋งํ‚ค", "temper": "aggressive", "loot": {"berries": (0, 1), "spices": (1, 2)}, "weight": 12},
    {"id": "ekans",  "name": "์•„๋ณด", "temper": "aggressive", "loot": {"berries": (0, 1), "spices": (1, 1)}, "weight": 10},
    {"id": "zubat",  "name": "์ฃผ๋ฑƒ", "temper": "aggressive", "loot": {"berries": (0, 1), "spices": (1, 1)}, "weight": 8},

    {"id": "pidgey", "name": "๊ตฌ๊ตฌ", "temper": "neutral", "loot": {"berries": (1, 2), "spices": (0, 1)}, "weight": 14},
    {"id": "oddish", "name": "๋šœ๋ฒ…์ตธ", "temper": "neutral", "loot": {"berries": (1, 1), "spices": (0, 2)}, "weight": 10},
    {"id": "sandshrew", "name": "๋ชจ๋ž˜๋‘์ง€", "temper": "neutral", "loot": {"berries": (1, 2), "spices": (0, 1)}, "weight": 8},

    {"id": "togepi", "name": "ํ† ๊ฒŒํ”ผ", "temper": "gentle", "loot": {"berries": (1, 2)}, "weight": 10},
    {"id": "chansey","name": "๋Ÿญํ‚ค", "temper": "gentle", "loot": {"berries": (2, 3)}, "weight": 6},
    {"id": "eevee",  "name": "์ด๋ธŒ์ด", "temper": "gentle", "loot": {"berries": (1, 3)}, "weight": 9},
]

RECIPE_TABLE = {
    (1, 0): {"name": "๋‹ฌ์ฝคํ•œ ์ˆ˜ํ”„", "pref": +1, "message": "ํ‰๋ฒ”ํ•œ ์กฐํ•ฉ์ด๋‹ค. ๋งˆ๋ฆด๋ฆฌ๊ฐ€ ์ฆ๊ฑฐ์›Œํ•˜๋ฉฐ ๋จน์—ˆ๋‹ค."},
    (0, 1): {"name": "์ง ๋ง› ์ˆ˜ํ”„", "pref": 0,  "message": "์ง  ๋ง›์ด ๋А๊ปด์ง„๋‹ค... ๋งˆ๋ฆด๋ฆฌ์˜ ์ทจํ–ฅ์€ ์•„๋‹Œ ๊ฒƒ ๊ฐ™๋‹ค."},
    (1, 1): {"name": "์ด์ƒ์ ์ธ ์ˆ˜ํ”„", "pref": +2, "message": "์„ฑ๊ณต์ ์ด๋‹ค! ๋งˆ๋ฆด๋ฆฌ๊ฐ€ ๋งค์šฐ ๊ธฐ์œ ๊ฒƒ ๊ฐ™๋‹ค."},
    (2, 1): {"name": "์ง„ํ•œ ์ˆ˜ํ”„", "pref": +3, "message": "์ง„ํ•œ ๋ง›์ด ๋А๊ปด์ง„๋‹ค. ๋งˆ๋ฆด๋ฆฌ๋Š” ๊ธฐ์˜๊ฒŒ ๋จน์—ˆ๋‹ค."},
}

DEFAULT_STATE = {
    "hp": 5,
    "berries": 0,
    "spices": 0,
    "mood": "idle",
    "status": "home",
    "walk_turn": 0
}

def clamp_int(x, lo, hi):
    try:
        x = int(x)
    except:
        x = lo
    return max(lo, min(hi, x))

def pick_pokemon():
    weights = [p["weight"] for p in POKEMON_DB]
    return random.choices(POKEMON_DB, weights=weights, k=1)[0]

def roll_loot(pokemon):
    loot = {}
    for k, (a, b) in pokemon["loot"].items():
        loot[k] = random.randint(a, b)
    return loot

def get_state():
    if "state" not in session:
        session["state"] = DEFAULT_STATE.copy()
    return session["state"]

def save_state(state):
    session["state"] = state

@app.route('/')
def home():
    return render_template('index.html')

@app.get('/api/status')
def get_status():
    return jsonify(get_state())

@app.post('/api/reset')
def api_reset():
    session["state"] = DEFAULT_STATE.copy()
    return jsonify(session["state"])

@app.post('/api/walk/encounter')
def walk_event():
    state = get_state()

    if state["hp"] <= 0:
        return jsonify({"type": "forced_home", "reason": "hp_zero", "state": state})

    state["walk_turn"] = int(state.get("walk_turn", 0)) + 1
    turn = state["walk_turn"]

    chance = 0.0
    if turn >= 4:
        chance = 0.10 * (turn - 3)    
        chance = min(chance, 1.0)

    if random.random() < chance:
        gained = random.randint(2,4)
        state["spices"] += gained
        msg = f"๊ฐ•์„ ๋ฐœ๊ฒฌํ–ˆ๋‹ค! ์กฐ๋ฏธ๋ฃŒ๋ฅผ {gained}๋งŒํผ ์–ป์—ˆ๋‹ค."

        state["status"] = "home"
        save_state(state)
        return jsonify({"type": "success_spot", "spot": "river", "message": msg, "state": state})

    pokemon = pick_pokemon()
    save_state(state)  
    return jsonify({
        "type": "encounter",
        "pokemon": {"id": pokemon["id"], "name": pokemon["name"], "temper": pokemon["temper"]},
        "state": state
    })

@app.post("/api/walk/start")
def walk_start():
    state = get_state()
    state["walk_turn"] = 0
    save_state(state)
    return jsonify({"ok": True, "state": state})

@app.post("/api/walk/resolve")
def walk_resolve():
    state = get_state()
    if state["hp"] <= 0:
        state["status"] = "home"
        save_state(state)
        return jsonify({"ended": True, "message": "์ง‘์— ๊ฐˆ ์‹œ๊ฐ„์ด๋‹ค", "state": state})

    payload = request.get_json(force=True)
    pokemon_id = payload.get("pokemon_id")
    action = payload.get("action")

    pokemon = next((p for p in POKEMON_DB if p["id"] == pokemon_id), None)
    if not pokemon:
        return jsonify({"error": "invalid_pokemon"}), 400

    temper = pokemon["temper"]
    hp_delta = 0
    loot = {"berries": 0, "spices": 0}
    message = ""

    def take_damage():
        nonlocal hp_delta
        hp_delta -= 1

    def gain_loot():
        rolled = roll_loot(pokemon)
        for k, v in rolled.items():
            loot[k] += v

    if action == "attack":
        gain_loot()
        message = f"{pokemon['name']}์—๊ฒŒ์„œ ์žฌ๋ฃŒ๋ฅผ ์–ป์—ˆ๋‹ค!" if temper != "gentle" else f"{pokemon['name']}์—๊ฒŒ์„œ ์žฌ๋ฃŒ๋ฅผ ์–ป์—ˆ๋‹ค. ์™œ์ธ์ง€ ์ฃ„์ฑ…๊ฐ์ด ๋“ ๋‹ค..."
    elif action == "watch":
        if temper in ("aggressive", "neutral"):
            if random.random() < 0.5:
                take_damage()
                message = f"์ง€์ผœ๋ณด๋‹ค๊ฐ€ {pokemon['name']}์—๊ฒŒ ๊ณต๊ฒฉ๋ฐ›์•˜๋‹ค!"
            else:
                if temper == "neutral":
                    gain_loot()
                    message = f"{pokemon['name']}์ด(๊ฐ€) ๋‹ค๊ฐ€์™€ ์žฌ๋ฃŒ๋ฅผ ๊ฑด๋„ค์คฌ๋‹ค!"
                else:
                    message = "์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์•˜๋‹ค."
        else:
            if random.random() < 0.5:
                gain_loot()
                message = f"{pokemon['name']}์ด(๊ฐ€) ๋‹ค๊ฐ€์™€ ๋‚˜๋ฌด์—ด๋งค๋ฅผ ๊ฑด๋„ค์คฌ๋‹ค!"
            else:
                message = "์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์•˜๋‹ค."
    elif action == "run":
        if temper == "aggressive":
            if random.random() < 0.25:
                take_damage()
                message = f"๋„๋ง์น˜๋‹ค {pokemon['name']}์—๊ฒŒ ๊ณต๊ฒฉ๋ฐ›์•˜๋‹ค!"
            else:
                message = "๋ฌด์‚ฌํžˆ ๋„๋ง์ณค๋‹ค."
        else:
            message = "๋„๋ง์ณค๋‹ค. ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š์•˜๋‹ค."
    else:
        return jsonify({"error": "invalid_action"}), 400

    state["hp"] = max(0, state["hp"] + hp_delta)
    state["berries"] += loot["berries"]
    state["spices"] += loot["spices"]

    ended = False
    if state["hp"] <= 0:
        ended = True
        state["status"] = "home"
        message += " ๋งˆ๋ฆด๋ฆฌ๊ฐ€ ๋งŽ์ด ํ”ผ๊ณคํ•œ ๊ฒƒ ๊ฐ™๋‹ค..."

    save_state(state)
    return jsonify({"ended": ended, "hp_delta": hp_delta, "loot": loot, "message": message, "state": state})

@app.get("/api/cook/can_enter")
def cook_can_enter():
    state = get_state()
    can = (state["berries"] > 0 or state["spices"] > 0)
    return jsonify({"can_enter": can, "state": state})

@app.post("/api/cook/finish")
def cook_finish():
    state = get_state()
    payload = request.get_json(force=True)

    berries_used = clamp_int(payload.get("berries_used", 0), 0, state["berries"])
    spices_used = clamp_int(payload.get("spices_used", 0), 0, state["spices"])

    if berries_used == 0 and spices_used == 0:
        return jsonify({"ok": False, "message": "์žฌ๋ฃŒ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", "state": state})

    state["berries"] -= berries_used
    state["spices"] -= spices_used

    recipe = RECIPE_TABLE.get((berries_used, spices_used)) or {"name": "์ด์ƒํ•œ ์š”๋ฆฌ", "pref": 0, "message": "๋งˆ๋ฆด๋ฆฌ๊ฐ€ ์• ๋งคํ•œ ํ‘œ์ •์œผ๋กœ ๋จน์—ˆ๋‹ค."}

    if recipe["pref"] >= 2:
        state["mood"] = "happy"
    elif recipe["pref"] <= -1:
        state["mood"] = "sad"
    else:
        state["mood"] = "idle"

    save_state(state)
    return jsonify({"ok": True, "recipe": recipe, "used": {"berries": berries_used, "spices": spices_used}, "state": state})

@app.post("/api/bath/progress")
def bath_progress():
    state = get_state()
    payload = request.get_json(force=True)
    percent = int(payload.get("percent", 0))
    percent = max(0, min(100, percent))
    return jsonify({"percent": percent, "unlock_shower": percent >= 70, "state": state})

if __name__ == "__main__":
    app.run(debug=True)

 

2. html

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>project 1</title>
  <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
  <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
  <audio id="bgmPlayer" loop>
    <source id="bgmSource" src="" type="audio/mpeg">
  </audio>

  <audio id="marillSound">
    <source src="{{ url_for('static', filename='sounds/marill_cry.mp3') }}" type="audio/mpeg">
  </audio>

  <div id="hud">
    <div id="hud-hp"></div>
    <div id="hud-items">
      <span id="hud-berries">๐Ÿ“ 0</span>
      <span id="hud-spices">๐Ÿง‚ 0</span>
    </div>
  </div>

<div id="dialogue-layer" class="ui-layer" style="display:none;">
  <div id="dialogue-box">
    <div id="dialogue-name" style="display:none;"></div>
    <div id="dialogue-text"></div>
    <div id="dialogue-progress" class="dialogue-progress" style="display:none;">
      <div id="dialogue-progress-fill"></div>
    </div>
    <div id="dialogue-hint">โ–ถ</div>
  </div>
</div>


  <div id="game-viewport">
    <section id="screen-home" class="screen" style="display:block;">
      <img src="{{ url_for('static', filename='images/bg_home.png') }}" class="bg">

      <div id="wandering-marill-container">
        <img src="{{ url_for('static', filename='images/marill_idle.png') }}" id="wandering-marill" class="character">
      </div>

      <div id="smartphone-container">
        <img src="{{ url_for('static', filename='images/ui/smartphone_frame.png') }}" id="phone-frame-img">
        <div class="app-screen-overlay">
          <div class="app-grid">
            <button class="app-btn" data-event="walk">
              <img src="{{ url_for('static', filename='images/ui/icon_walk.png') }}" alt="์‚ฐ์ฑ…">
              <span>์‚ฐ์ฑ…</span>
            </button>
            <button class="app-btn" data-event="cook">
              <img src="{{ url_for('static', filename='images/ui/icon_cook.png') }}" alt="์š”๋ฆฌ">
              <span>์š”๋ฆฌ</span>
            </button>
            <button class="app-btn" data-event="bath">
              <img src="{{ url_for('static', filename='images/ui/icon_bath.png') }}" alt="๋ชฉ์š•">
              <span>๋ชฉ์š•</span>
            </button>
          </div>
        </div>
      </div>
    </section>

    <section id="screen-walk" class="screen" style="display:none;">
      <img src="{{ url_for('static', filename='images/bg_forest.png') }}" id="walk-bg" class="bg">
      <img id = "walk-marill" src="{{ url_for('static', filename='images/marill_back.png') }}" class="character walk-ani">
      <img id="enemy-img" class="enemy-img" style="display:none;" />

      <div id="walk-info" class="toast">๊ฑท๋Š” ์ค‘…</div>
      <button id = "walk-exit" class="walk-exit">๋‚˜๊ฐ€๊ธฐ</button>

      <div id="encounter-ui" class="modal" style="display:none;">
        <p id="encounter-text"></p>
        <div class="row">
          <button id="btn-attack">์„ ๊ณต</button>
          <button id="btn-watch">์ง€์ผœ๋ณด๊ธฐ</button>
          <button id="btn-run">๋„๋ง</button>
        </div>
      </div>
    </section>


<section id="screen-cook" class="screen" style="display:none;">
  <img src="{{ url_for('static', filename='images/bg_home.png') }}" class="bg">

  <div id="cook-select" class="panel">
    <h3>์š”๋ฆฌ</h3>

    <div class="row" style="align-items:center;">
      <label>๐Ÿ“ <input id="cook-berries" type="number" min="0" value="1" /></label>
      <label>๐Ÿง‚ <input id="cook-spices" type="number" min="0" value="0" /></label>
    </div>

    <div class="row">
      <button id="cook-confirm">์‹œ์ž‘</button>
      <button id="cook-recipe">๋ ˆ์‹œํ”ผ ํ™•์ธ</button>
      <button id="cook-exit">๋‚˜๊ฐ€๊ธฐ</button>
    </div>

    <div class="hint" id="cook-select-hint">์žฌ๋ฃŒ๋ฅผ ๊ณ ๋ฅด๊ณ  'ํ™•์ธ'์„ ๋ˆŒ๋Ÿฌ์•ผ ์š”๋ฆฌ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค</div>
  </div>

  <div id="cook-play" class="cook-stage" style="display:none;">
    <img id="cook-stage-img" class="cook-stage-img"
         src="{{ url_for('static', filename='images/cooking.png') }}" alt="cooking">

    <div id="gauge" class="cook-gauge">
      <div id="gauge-fill"></div>
    </div>

    <div id="cook-phase" class="cook-phase">๋ถˆ ์ง€ํ”ผ๊ธฐ</div>
    <div class="hint" id="cook-hint">ํ™”๋ฉด์„ ๋ˆ„๋ฅด๋ฉด ๊ฒŒ์ด์ง€๊ฐ€ ์˜ฌ๋ผ๊ฐ‘๋‹ˆ๋‹ค</div>

    <div class="row cook-bottom">
      <button id="cook-play-exit">๋‚˜๊ฐ€๊ธฐ</button>
    </div>
  </div>

  <div id="cook-result" class="cook-stage" style="display:none;">
    <img id="cook-finish-img" class="cook-stage-img"
         src="{{ url_for('static', filename='images/cooking_finish.png') }}" alt="finish">

    <img id="cook-reaction-img" class="cook-reaction-img" style="display:none;" alt="reaction">

    <div id="cook-result-text" class="cook-result-text"></div>

    <div class="row cook-bottom">
      <button id="cook-result-exit">๋‚˜๊ฐ€๊ธฐ</button>
    </div>
  </div>
</section>




  <section id="screen-bath" class="screen" style="display:none;">
  <img src="{{ url_for('static', filename='images/bg_bathroom.png') }}" class="bg">

  <div id="bubble-layer">
    <div class="bubble"></div>
    <div class="bubble"></div>
    <div class="bubble"></div>
    <div class="bubble"></div>
    <div class="bubble"></div>
    <div class="bubble"></div>
  </div>

  <div class="bath-center">
    <img id="bath-marill" class="bath-marill"
         src="{{ url_for('static', filename='images/marill_idle.png') }}">

    <div id="bath-progress" class="bath-progress">
      <div id="bath-fill"></div>
    </div>
  </div>

  <div class="panel bath-panel">
    <h3>๋ชฉ์š•</h3>
    <div class="hint">ํ™”๋ฉด์„ ๋ฌธ์ง€๋ฅด๋ฉด ๋งˆ๋ฆด๋ฆฌ๋ฅผ ์”ป๊ธธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค</div>

    <button id="shower-icon" style="display:none;">์ƒค์›Œ๊ธฐ</button>
    <div class="row">
      <button id="bath-exit">๋‚˜๊ฐ€๊ธฐ</button>
    </div>
  </div>
</section>


  <script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>

 

3. js

async function apiGet(url) {
  const r = await fetch(url, { credentials: "same-origin" });
  return r.json();
}
async function apiPost(url, bodyObj = {}) {
  const r = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "same-origin",
    body: JSON.stringify(bodyObj)
  });
  return r.json();
}

function renderHUD(state) {
  const hpWrap = document.getElementById("hud-hp");
  if (!hpWrap) return;

  const hp = Number(state?.hp ?? 0);
  hpWrap.innerHTML = "";
  for (let i = 0; i < 5; i++) {
    const span = document.createElement("span");
    span.className = "heart" + (i < hp ? " on" : " off");
    span.textContent = "♥";
    hpWrap.appendChild(span);
  }

  const b = document.getElementById("hud-berries");
  const s = document.getElementById("hud-spices");
  if (b) b.textContent = `๐Ÿ“ ${Number(state?.berries ?? 0)}`;
  if (s) s.textContent = `๐Ÿง‚ ${Number(state?.spices ?? 0)}`;
}

let currentScreen = "home";

function showScreen(name) {
  document.querySelectorAll(".screen").forEach(s => (s.style.display = "none"));
  const target = document.getElementById(`screen-${name}`);
  if (target) target.style.display = "block";
  currentScreen = name;

  if (name === "home") startWanderingMarill();
  else stopWandering();
}


let wanderTimer = null;
const wander = { x: 50, y: 60, vx: 0, vy: 0 };

function clamp(v, lo, hi) {
  return Math.max(lo, Math.min(hi, v));
}

function startWanderingMarill() {
  const c = document.getElementById("wandering-marill-container");
  const img = document.getElementById("wandering-marill");
  if (!c || !img) return;

  stopWandering();

  wander.x = parseFloat(c.style.left || "50");
  wander.y = parseFloat(c.style.top || "60");
  wander.vx = 0;
  wander.vy = 0;

  wanderTimer = setInterval(() => stepWander(c, img), 120);
}

function stopWandering() {
  if (wanderTimer) clearInterval(wanderTimer);
  wanderTimer = null;
}

function stepWander(container, img) {
  const minX = 10, maxX = 80;
  const minY = 40, maxY = 75;

  const ax = (Math.random() - 0.5) * 0.67;
  const ay = (Math.random() - 0.5) * 0.5;

  wander.vx = (wander.vx + ax) * 0.82;
  wander.vy = (wander.vy + ay) * 0.82;

  wander.vx = clamp(wander.vx, -0.9, 0.9);
  wander.vy = clamp(wander.vy, -0.6, 0.6);

  const prevX = wander.x;
  wander.x += wander.vx;
  wander.y += wander.vy;

  if (wander.x < minX) { wander.x = minX; wander.vx *= -0.8; }
  if (wander.x > maxX) { wander.x = maxX; wander.vx *= -0.8; }
  if (wander.y < minY) { wander.y = minY; wander.vy *= -0.8; }
  if (wander.y > maxY) { wander.y = maxY; wander.vy *= -0.8; }

  if (wander.x < prevX) img.classList.add("facing-left");
  else img.classList.remove("facing-left");

  container.style.left = `${wander.x}%`;
  container.style.top = `${wander.y}%`;
}

const dialogue = {
  currentFull: "",
  active: false,
  queue: [],
  typing: false,
  typeTimer: null,
  resolveWait: null,

  elLayer: null,
  elBox: null,
  elName: null,
  elText: null,
  elHint: null,

  init() {
    this.elLayer = document.getElementById("dialogue-layer");
    this.elBox   = document.getElementById("dialogue-box");
    this.elName  = document.getElementById("dialogue-name");
    this.elText  = document.getElementById("dialogue-text");
    this.elHint  = document.getElementById("dialogue-hint");

    if (!this.elLayer || !this.elBox || !this.elText || !this.elHint || !this.elName) {
      console.warn("Dialogue DOM missing");
      return;
    }

    this.elLayer.style.display = "none";
    this.elLayer.style.pointerEvents = "none";
    this.elBox.addEventListener("click", () => this.advance());

    window.addEventListener("keydown", (e) => {
      if (!this.active) return;
      if (e.key === " " || e.key === "Enter") {
        e.preventDefault();
        this.advance();
      }
    });
  },

  async show(lines, opts = {}) {
    if (!this.elLayer) this.init();
    if (!this.elLayer) return; 

    const arr = Array.isArray(lines) ? lines : [{ text: String(lines) }];
    for (const it of arr) {
      if (typeof it === "string") this.queue.push({ text: it, name: "" });
      else this.queue.push({ text: String(it.text ?? ""), name: it.name ? String(it.name) : "" });
    }

    if (this.active) return;

    this.active = true;
    this.elLayer.style.display = "block";
    this.elLayer.style.pointerEvents = "none";
    this.elBox.style.pointerEvents = "auto";

    while (this.queue.length > 0) {
      const item = this.queue.shift();
      await this.playOne(item, opts);
    }

    this.elLayer.style.display = "none";
    this.elLayer.style.pointerEvents = "none";
    this.active = false;
  },

  playOne(item, opts) {
    const speed = Number.isFinite(opts.speed) ? opts.speed : 22;

    if (item.name) {
      this.elName.style.display = "block";
      this.elName.textContent = item.name;
    } else {
      this.elName.style.display = "none";
      this.elName.textContent = "";
    }

    this.elText.textContent = "";
    this.elHint.style.display = "none";
    this.typing = true;

    const full = item.text;
    this.currentFull = full;
    let i = 0;

    clearInterval(this.typeTimer);
    this.typeTimer = setInterval(() => {
      i++;
      this.elText.textContent = full.slice(0, i);
      if (i >= full.length) {
        clearInterval(this.typeTimer);
        this.typeTimer = null;
        this.typing = false;
        this.elHint.style.display = "block";
      }
    }, speed);

    return new Promise((resolve) => {
      this.resolveWait = resolve;
    });
  },

  advance() {
    if (!this.active) return;

    if (this.typing) {
      clearInterval(this.typeTimer);
      this.typeTimer = null;
      this.typing = false;
      this.elText.textContent = this.currentFull;
      this.elHint.style.display = "block";
      return;
    }

    if (this.resolveWait && !this.typing) {
      const r = this.resolveWait;
      this.resolveWait = null;
      r();
    }
  }
};

//์‚ฐ์ฑ… ๋ชจ๋“ˆ


const walk = {
  timer: null,
  pending: null,
  exitBound: false,
  _busy: false,

  async start() {
    this.stopTimerOnly();
    this.pending = null;

    const bg = document.getElementById("walk-bg");
    const mar = document.getElementById("walk-marill");
    if (bg) bg.src = "/static/images/bg_forest.png";
    if (mar) {
      mar.src = "/static/images/marill_back.png";
      mar.classList.add("walk-ani");
      mar.style.left = "";
      mar.style.bottom = "";
      mar.style.transform = "";
    }

    const enc = document.getElementById("encounter-ui");
    const enemy = document.getElementById("enemy-img");
    if (enc) enc.style.display = "none";
    if (enemy) enemy.style.display = "none";

    if (!this.exitBound) {
      this.exitBound = true;
      const exitBtn = document.getElementById("walk-exit");
      if (exitBtn) {
        exitBtn.addEventListener("click", () => {
          this.stop();
          showScreen("home");
        });
      }
    }

    this.timer = setInterval(async () => {
      if (this._busy) return;
      this._busy = true;

      let res = null;
      try {
        res = await apiPost("/api/walk/encounter", {});
      } catch (e) {
        console.error("walk encounter failed", e);
      } finally {
        this._busy = false;
      }
      if (!res) return;

      if (res.type === "success_spot" && res.spot === "river") {
        if (bg) bg.src = "/static/images/bg_river.png";
        if (mar) {
          mar.src = "/static/images/marill_happy.png";
          mar.classList.remove("walk-ani");
          mar.style.left = "50%";
          mar.style.bottom = "22%";
          mar.style.transform = "translateX(-50%)";
        }

        renderHUD(res.state);
        await dialogue.show(res.message || "");

        this.stop();
        showScreen("home");
        return;
      }

      if (res.type === "encounter") {
        this.pending = {
          pokemon_id: res.pokemon.id,
          pokemon_name: res.pokemon.name,
          temper: res.pokemon.temper
        };
        this.showEncounter(res.pokemon);
        this.stopTimerOnly();
      }
    }, 3000);
  },

  stopTimerOnly() {
    if (this.timer) clearInterval(this.timer);
    this.timer = null;
  },

  stop() {
    this.stopTimerOnly();
    this.pending = null;
    const enc = document.getElementById("encounter-ui");
    const enemy = document.getElementById("enemy-img");
    if (enc) enc.style.display = "none";
    if (enemy) enemy.style.display = "none";
  },

  showEncounter(p) {
    const map = { aggressive: "ํ˜ธ์ „์ ์ธ", neutral: "์ค‘๋ฆฝ์ ์ธ", gentle: "์˜จ์ˆœํ•œ" };
    const t = document.getElementById("encounter-text");
    if (t) t.textContent = `${map[p.temper]} ${p.name}์ด(๊ฐ€) ๋‚˜ํƒ€๋‚ฌ๋‹ค!`;

    const enemy = document.getElementById("enemy-img");
    if (enemy) {
      enemy.src = `/static/images/walk/p_${p.id}.png`;
      enemy.style.display = "block";
    }

    const enc = document.getElementById("encounter-ui");
    if (enc) enc.style.display = "block";
  },

  async choose(action) {
    if (!this.pending) return;

    let res;
    try {
      res = await apiPost("/api/walk/resolve", {
        pokemon_id: this.pending.pokemon_id,
        action
      });
    } catch (e) {
      console.error("walk resolve failed", e);
      this.pending = null;
      this.start();
      return;
    }

    if (res?.error) {
      console.warn(res);
      this.pending = null;
      this.start();
      return;
    }

    const enc = document.getElementById("encounter-ui");
    const enemy = document.getElementById("enemy-img");
    if (enc) enc.style.display = "none";
    if (enemy) enemy.style.display = "none";

    renderHUD(res.state);
    await dialogue.show(res.message || "");

    this.pending = null;

    if (res.ended) {
      this.stop();
      showScreen("home");
      return;
    }

    this.start();
  }
};

//์š”๋ฆฌ ๋ชจ๋“ˆ

const cook = {
  gauge: 0,
  onTap: null,
  _finishing: false,
  berriesUsed: 0,
  spicesUsed: 0,
  els: null,

  bindEls() {
    if (this.els) return;
    this.els = {
      select: document.getElementById("cook-select"),
      play: document.getElementById("cook-play"),
      result: document.getElementById("cook-result"),

      bInp: document.getElementById("cook-berries"),
      sInp: document.getElementById("cook-spices"),

      btnConfirm: document.getElementById("cook-confirm"),
      btnRecipe: document.getElementById("cook-recipe"),
      btnExitSelect: document.getElementById("cook-exit"),

      stageImg: document.getElementById("cook-stage-img"),
      phase: document.getElementById("cook-phase"),
      hint: document.getElementById("cook-hint"),
      gaugeFill: document.getElementById("gauge-fill"),

      btnExitPlay: document.getElementById("cook-play-exit"),

      finishImg: document.getElementById("cook-finish-img"),
      reactImg: document.getElementById("cook-reaction-img"),
      resultText: document.getElementById("cook-result-text"),
      btnResultExit: document.getElementById("cook-result-exit"),
    };

    const must = [
      "select","play","result",
      "bInp","sInp",
      "btnConfirm","btnRecipe","btnExitSelect",
      "stageImg","phase","hint","gaugeFill",
      "btnExitPlay",
      "finishImg","reactImg","resultText","btnResultExit"
    ];
    for (const k of must) {
      if (!this.els[k]) throw new Error("COOK DOM missing: " + k);
    }

    this.els.btnConfirm.addEventListener("click", () => this.confirmEnter());
    this.els.btnRecipe.addEventListener("click", () => this.showRecipe());

    this.els.btnExitSelect.addEventListener("click", () => this.exitToHome());
    this.els.btnExitPlay.addEventListener("click", () => this.exitToHome());
    this.els.btnResultExit.addEventListener("click", () => this.exitToHome());
  },

  showOnly(which) {
    this.els.select.style.display = (which === "select") ? "block" : "none";
    this.els.play.style.display   = (which === "play")   ? "flex"  : "none";
    this.els.result.style.display = (which === "result") ? "flex"  : "none";
  },

  async start() {
    this.bindEls();
    this.stop();

    const can = await apiGet("/api/cook/can_enter");
    renderHUD(can.state);

    if (!can.can_enter) {
      await dialogue.show("์žฌ๋ฃŒ๊ฐ€ ํ•˜๋‚˜๋„ ์—†์œผ๋ฉด ์š”๋ฆฌํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.");
      showScreen("home");
      return;
    }

    this.els.bInp.max = String(can.state.berries);
    this.els.sInp.max = String(can.state.spices);

    this.els.bInp.value = can.state.berries > 0 ? "1" : "0";
    this.els.sInp.value = "0";

    this.showOnly("select");
  },

  async showRecipe() {
    const text =
`๋ ˆ์‹œํ”ผ
๐Ÿ“1 ๐Ÿง‚0 : ๋‹ฌ์ฝคํ•œ ์ˆ˜ํ”„
๐Ÿ“0 ๐Ÿง‚1 : ์ง ๋ง› ์ˆ˜ํ”„
๐Ÿ“1 ๐Ÿง‚1 : ์ด์ƒ์ ์ธ ์ˆ˜ํ”„
๐Ÿ“2 ๐Ÿง‚1 : ์ง„ํ•œ ์ˆ˜ํ”„
๊ทธ ์™ธ ์กฐํ•ฉ : ์ด์ƒํ•œ ์š”๋ฆฌ`;
    await dialogue.show(text);
  },

  confirmEnter() {
    const b = parseInt(this.els.bInp.value || "0", 10) || 0;
    const s = parseInt(this.els.sInp.value || "0", 10) || 0;

    if (b === 0 && s === 0) {
      alert("์žฌ๋ฃŒ๋ฅผ 1๊ฐœ ์ด์ƒ ์„ ํƒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.");
      return;
    }

    this.berriesUsed = b;
    this.spicesUsed = s;

    this.enterCooking();
  },

  enterCooking() {
    this.stop();
    this.gauge = 0;
    this.renderGauge();

    this.els.stageImg.src = "/static/images/cooking.png";
    this.els.phase.textContent = "์š”๋ฆฌ ์ค‘";
    this.els.hint.textContent = "ํ™”๋ฉด์„ ๋ˆ„๋ฅด๋ฉด ๊ฒŒ์ด์ง€๊ฐ€ ์˜ฌ๋ผ๊ฐ‘๋‹ˆ๋‹ค";

    this.showOnly("play");

    const screen = document.getElementById("screen-cook");
    this.onTap = () => {
      this.gauge = Math.min(100, this.gauge + 4.5);
      this.renderGauge();
      if (this.gauge >= 100) this.finish();
    };
    screen.addEventListener("pointerdown", this.onTap);
  },

  renderGauge() {
    if (this.els?.gaugeFill) this.els.gaugeFill.style.width = `${this.gauge}%`;
  },

  stop() {
    const screen = document.getElementById("screen-cook");
    if (this.onTap) screen.removeEventListener("pointerdown", this.onTap);
    this.onTap = null;
    this._finishing = false;
  },

  reactionAssetByPref(pref) {
    if (pref >= 2) return "/static/images/cooking_react_happy.png";
    if (pref <= -1) return "/static/images/cooking_react_sad.png";
    return "/static/images/cooking_react_idle.png";
  },

  async finish() {
    if (this._finishing) return;
    this._finishing = true;

    this.stop();

    const res = await apiPost("/api/cook/finish", {
      berries_used: this.berriesUsed,
      spices_used: this.spicesUsed
    });

    this._finishing = false;

    if (!res.ok) {
      alert(res.message);
      showScreen("home");
      return;
    }

    renderHUD(res.state);

    this.showOnly("result");
    this.els.finishImg.src = "/static/images/cooking_finish.png";

    const pref = Number(res.recipe?.pref ?? 0);
    const reactSrc = this.reactionAssetByPref(pref);

    this.els.reactImg.style.display = "block";
    this.els.reactImg.src = reactSrc;
    this.els.reactImg.onerror = () => {
      this.els.reactImg.style.display = "none";
    };

    this.els.resultText.textContent = "";
    this.els.resultText.style.display = "none";

    await dialogue.show([
      { text: `${res.recipe.name}๋ฅผ ๋งŒ๋“ค์—ˆ๋‹ค.` },
      { text: res.recipe.message }
    ]);
  },

  exitToHome() {
    this.stop();
    showScreen("home");
  }
};

// ๋ชฉ์š• ๋ชจ๋“ˆ


const bath = {
  percent: 0,
  unlocked: false,
  showerMode: false,
  washProgress: 0,
  onMove: null,
  smileTimer: null,


  dProg: null,
  dFill: null,

  isNearEl(e, el, threshold = 90) {
    const r = el.getBoundingClientRect();
    const cx = r.left + r.width / 2;
    const cy = r.top + r.height / 2;
    return Math.hypot(e.clientX - cx, e.clientY - cy) <= threshold;
  },

  placeBubblesNearMarill() {
    const marill = document.getElementById("bath-marill");
    const layer = document.getElementById("bubble-layer");
    if (!marill || !layer) return;

    const mr = marill.getBoundingClientRect();
    const lr = layer.getBoundingClientRect();
    const cx = (mr.left + mr.right) / 2 - lr.left;
    const cy = (mr.top + mr.bottom) / 2 - lr.top;

    const bubbles = Array.from(layer.querySelectorAll(".bubble"));
    bubbles.forEach((b) => {
      const dx = (Math.random() - 0.5) * 260;
      const dy = (Math.random() - 0.5) * 180;
      b.style.left = `${cx + dx}px`;
      b.style.top = `${cy + dy}px`;
    });
  },

  start() {
    this.stop();

    this.dProg = document.getElementById("dialogue-progress");
    this.dFill = document.getElementById("dialogue-progress-fill");
    if (this.dProg) this.dProg.style.display = "block";
    if (this.dFill) this.dFill.style.width = "0%";

    this.percent = 0;
    this.unlocked = false;
    this.showerMode = false;
    this.washProgress = 0;

    const layer = document.getElementById("bubble-layer");
    if (layer) layer.style.display = "none";

    const bathFill = document.getElementById("bath-fill");
    if (bathFill) bathFill.style.width = "0%";

    const screen = document.getElementById("screen-bath");
    const showerBtn = document.getElementById("shower-icon");
    const marill = document.getElementById("bath-marill");
    if (!screen || !showerBtn || !marill) return;

    showerBtn.style.display = "none";

    screen.classList.add("sponge-cursor");
    screen.classList.remove("shower-cursor");

    const idleSrc = "/static/images/marill_idle.png";
    const smileSrc = "/static/images/marill_smile.png";
    this.smileTimer = setInterval(() => {
      marill.src = smileSrc;
      setTimeout(() => { marill.src = idleSrc; }, 700);
    }, 4500);

    let last = 0;

    this.onMove = async (e) => {
      const now = performance.now();
      if (now - last < 30) return;
      last = now;

      if (!this.showerMode) {
        if (!this.isNearEl(e, marill, 130)) return;

        this.percent = Math.min(100, this.percent + 1);

        if (bathFill) bathFill.style.width = `${this.percent}%`;

        if (this.dFill) this.dFill.style.width = `${this.percent}%`;

        if (layer && this.percent > 0) {
          layer.style.display = "block";
          this.placeBubblesNearMarill();
        }

        const res = await apiPost("/api/bath/progress", { percent: this.percent });
        if (res.unlock_shower && !this.unlocked) {
          this.unlocked = true;
          showerBtn.style.display = "inline-block";
          
        }
      } else {
        await this.washMove(e);
      }
    };

    screen.addEventListener("pointermove", this.onMove);
    showerBtn.onclick = () => this.enableShowerMode();
  },

  enableShowerMode() {
    const screen = document.getElementById("screen-bath");
    const layer = document.getElementById("bubble-layer");
    if (layer) layer.style.display = "block";

    this.showerMode = true;
    this.washProgress = 0;

    if (this.dFill) this.dFill.style.width = "0%";

    if (screen) {
      screen.classList.remove("sponge-cursor");
      screen.classList.add("shower-cursor");
    }
  },

  async washMove(e) {
    const bubbles = Array.from(document.querySelectorAll("#bubble-layer .bubble"));
    if (bubbles.length === 0) return;

    const threshold = 110;
    let near = false;
    for (const b of bubbles) {
      if (this.isNearEl(e, b, threshold)) { near = true; break; }
    }
    if (!near) return;

    this.washProgress = Math.min(100, this.washProgress + 2);

    const bathFill = document.getElementById("bath-fill");
    if (bathFill) bathFill.style.width = `${this.washProgress}%`;

    if (this.dFill) this.dFill.style.width = `${this.washProgress}%`;

    if (this.washProgress >= 100) {
      await dialogue.show([{ text: "๋ชฉ์š• ์™„๋ฃŒ." }]);
      this.stop();
      showScreen("home");
    }
  },

  stop() {
    const screen = document.getElementById("screen-bath");
    if (this.smileTimer) clearInterval(this.smileTimer);
    this.smileTimer = null;

    if (screen && this.onMove) screen.removeEventListener("pointermove", this.onMove);
    this.onMove = null;

    if (screen) {
      screen.classList.remove("sponge-cursor");
      screen.classList.remove("shower-cursor");
    }

    const dProg = document.getElementById("dialogue-progress");
    if (dProg) dProg.style.display = "none";
  }
};


window.addEventListener("load", async () => {
  try {
    const state = await apiGet("/api/status");
    renderHUD(state);
  } catch (e) {
    console.error("status fetch failed", e);
  }

  try {
    document.querySelectorAll(".app-btn").forEach(btn => {
      btn.addEventListener("click", async () => {
        const type = btn.dataset.event;

        if (type === "walk") {
          showScreen("walk");
          await apiPost("/api/walk/start", {});
          walk.start();
        } else if (type === "cook") {
          showScreen("cook");
          cook.start();
        } else if (type === "bath") {
          showScreen("bath");
          bath.start();
        }
      });
    });

    const a = document.getElementById("btn-attack");
    const w = document.getElementById("btn-watch");
    const r = document.getElementById("btn-run");
    if (a) a.addEventListener("click", () => walk.choose("attack"));
    if (w) w.addEventListener("click", () => walk.choose("watch"));
    if (r) r.addEventListener("click", () => walk.choose("run"));

    const bathExit = document.getElementById("bath-exit");
    if (bathExit) {
      bathExit.addEventListener("click", () => {
        bath.stop();
        showScreen("home");
      });
    }

    showScreen("home");
  } catch (e) {
    console.error("boot bind failed", e);
  }
});

 

4. css

body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }

#walk-info { display: none !important; }
.walk-exit{
  position:absolute;
  right:16px;
  top:16px;
  z-index: 60;
}

.ui-layer{
  position:absolute;
  inset:0;
  z-index:1000;
  pointer-events: none; 
}


#dialogue-layer{
  display:block;
  pointer-events: none;
  background: rgba(0,0,0,0);
}

.dialogue-progress{
  margin-top: 10px;
  width: 100%;
  height: 10px;
  background: rgba(255,255,255,0.18);
  border-radius: 999px;
  overflow: hidden;
}
#dialogue-progress-fill{
  width: 0%;
  height: 100%;
  background: rgba(120,200,255,0.9);
}


#dialogue-box{
   pointer-events:auto;
  position:absolute;
  left:50%;
  bottom:6px;
  transform:translateX(-50%);
  width:min(640px, 94vw);
  min-height:92px;
  background:rgba(0,0,0,0.75);
  border:2px solid rgba(255,255,255,0.85);
  border-radius:12px;
  padding:12px 14px;
  color:#fff;
  font-family: system-ui, -apple-system, sans-serif;
  box-sizing:border-box;
}

#encounter-ui { pointer-events: auto; z-index: 200; }
#screen-walk { z-index: 1; } 

#dialogue-name{
  font-size:13px;
  opacity:0.9;
  margin-bottom:6px;
}

#dialogue-text{
  font-size:16px;
  line-height:1.35;
  white-space:pre-wrap;
  word-break:keep-all;
}


#dialogue-hint{
  position:absolute;
  right:12px;
  bottom:10px;
  font-size:14px;
  opacity:0.85;
  animation: blink 0.9s infinite steps(2, end);
}

@keyframes blink{
  50%{ opacity:0.15; }
}


#screen-bath.sponge-cursor { cursor: url("/static/images/ui/cursor_sponge.png") 16 16, auto; }
#screen-bath.shower-cursor { cursor: url("/static/images/ui/cursor_shower.png") 16 16, auto; }

#screen-bath .bg{ z-index: 1;}
#bubble-layer { display: none;position:absolute;
  inset:0;
  z-index:20;
  pointer-events:none;}

.bubble{ position:absolute;
  width:72px;
  height:72px;
  background: url("/static/images/ui/bubble.png") center/contain no-repeat;
  opacity: 0.95;}
  
#bath-marill{position:absolute;
  left:50%;
  top:56%;
  transform:translate(-50%,-50%);
  width:220px;
  z-index:40;}
#bath-ui{ position:absolute;
  left:50%;
  top:72%;
  transform:translateX(-50%);
  width:min(520px, 92vw);
  display:flex;
  flex-direction:column;
  gap:10px;
  align-items:flex-start;
  pointer-events:auto;}



#game-viewport { position: relative; width: 100vw; height: 100vh; }
.screen { position: absolute; inset: 0; }
.bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }

.character { position: absolute; bottom: 20%; left: 45%; width: 140px; }
.walk-ani { animation: walkbob 0.6s infinite alternate; }
@keyframes walkbob { from { transform: translateY(0); } to { transform: translateY(-6px); } }

.walk-marill{
  position:absolute;
  left:50%;
  top:60%;
  transform:translate(-50%,-50%);
  width:160px;
  animation: walkJitter 0.35s infinite alternate;
  z-index: 30;
}

@keyframes walkJitter{
  from { transform: translate(-50%,-50%) translate(-2px,-2px); }
  to   { transform: translate(-50%,-50%) translate(2px,2px); }
}

.enemy-img{
  position:absolute;
  left:50%;
  top:18%;
  transform:translateX(-50%);
  width:140px;
  z-index: 35;
}

#wandering-marill-container {
  position: absolute;
  top: 60%;
  left: 50%;
  transform: translate(-50%, -50%);
  transition: top 0.25s linear, left 0.25s linear;
  transform-origin: 50% 70%;
  animation: waddle 0.9s infinite ease-in-out;
}

@keyframes waddle {
  0%   { transform: translate(-50%, -50%) translateY(0) rotate(-3deg); }
  50%  { transform: translate(-50%, -50%) translateY(-2px) rotate(3deg); }
  100% { transform: translate(-50%, -50%) translateY(0) rotate(-3deg); }
}

#wandering-marill.facing-left { transform: scaleX(-1); }

.bubble {
  position: absolute;
  width: 72px;
  height: 72px;
  border-radius: 50%;
  background: url("/static/images/ui/bubble.png") center/contain no-repeat;
  opacity: 0.95;
}

#smartphone-container {
  position: absolute;
  bottom: 20px;
  right: 20px;
  width: 300px;
  height: auto;
}
#phone-frame-img { width: 100%; display: block; }

.app-screen-overlay {
  position: absolute;
  top: 12%;
  left: 8%;
  width: 84%;
  height: 75%;
  overflow: hidden;
}
.app-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 15px;
  padding: 10px;
}
.app-btn {
  background: none;
  border: none;
  cursor: pointer;
  transition: transform 0.1s;
}
.app-btn img { width: 100%; max-width: 60px; -webkit-user-drag: none; user-select: none; }
.app-btn span { display: block; color: white; font-size: 12px; margin-top: 5px; }
.app-btn:active { transform: scale(0.9); filter: brightness(0.8); }

#hud {
  position: absolute;
  top: 10px; left: 10px;
  z-index: 50;
  background: rgba(0,0,0,0.35);
  padding: 8px 10px;
  border-radius: 10px;
  color: #fff;
  font-family: system-ui, -apple-system, sans-serif;
  display: flex;
  align-items: center;
  gap: 12px;
}
#hud-hp { display: flex; gap: 3px; }
.heart { font-size: 18px; }
.heart.off { opacity: 0.25; }
#hud-items { display: flex; gap: 10px; }

.modal {
  position: absolute;
  left: 50%; top: 55%;
  transform: translate(-50%, -50%);
  background: rgba(0,0,0,0.65);
  color: white;
  padding: 14px 16px;
  border-radius: 12px;
  z-index: 40;
  min-width: 260px;
}
.panel {
  position: absolute;
  left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  background: rgba(0,0,0,0.55);
  color: white;
  padding: 16px 16px;
  border-radius: 12px;
  z-index: 30;
  width: min(420px, 92vw);
}
.row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
button { padding: 10px 12px; border-radius: 10px; border: none; cursor: pointer; }
.hint { opacity: 0.9; font-size: 13px; margin-top: 6px; }

.toast {
  position: absolute;
  left: 50%; top: 20%;
  transform: translateX(-50%);
  background: rgba(0,0,0,0.45);
  color: white;
  padding: 10px 14px;
  border-radius: 999px;
  z-index: 25;
}


#gauge { width: 100%; height: 16px; background: rgba(255,255,255,0.2); border-radius: 999px; overflow: hidden; margin-top: 10px; }
#gauge-fill { width: 50%; height: 100%; background: rgba(255,200,0,0.9); }

.feed .post { display: grid; gap: 8px; }
.post-img { width: 100%; height: 180px; object-fit: cover; border-radius: 10px; opacity: 0.9; }

.bath-center{
  position:absolute;
  left:50%;
  top:54%;
  transform:translate(-50%,-50%);
  z-index: 45;
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:12px;
  pointer-events:none; 
}

.bath-marill{
  width:220px; 
  display:block; position: static; transform: none;
}

.bath-progress{
  display:block;
  width:min(360px, 86vw);
  height:16px;
  background: rgba(255,255,255,0.35);
  border-radius:999px;
  overflow:hidden;
  z-index:9999;
  position:relative;
}

#bath-fill{
  display:block;
  width:0%;
  height:100%;
  background: rgba(120,200,255,0.9);
}

#bath-marill{
  position: static !important;
  left:auto !important;
  top:auto !important;
  transform:none !important;
  z-index: auto !important;
}

.bath-panel{
  top:78% !important;
}

.cook-stage{
  position:absolute;
  left:50%;
  top:50%;
  transform:translate(-50%,-50%);
  width:min(520px, 92vw);
  z-index: 35;
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:12px;
  pointer-events:auto;
  background: rgba(0,0,0,0.55);
  border-radius:12px;
  padding:16px;
  box-sizing:border-box;
}

.cook-stage-img{
  width:min(320px, 70vw);
  display:block;
}

.cook-gauge{
  width:100%;
  height:16px;
  background: rgba(255,255,255,0.2);
  border-radius:999px;
  overflow:hidden;
}

.cook-phase{
  font-size:14px;
  opacity:0.95;
}

.cook-bottom{
  justify-content:center;
}

.cook-reaction-img{
  width:min(220px, 50vw);
  display:block;
  opacity:0.98;
}

.cook-result-text{
  width:100%;
  white-space:pre-wrap;
  word-break:keep-all;
  font-size:14px;
  line-height:1.35;
  opacity:0.95;
}

#cook-phase,
#cook-hint,
.cook-result-text {
  color: #ffffff;
  text-shadow:
    0 1px 2px rgba(0,0,0,0.8),
    0 0 6px rgba(0,0,0,0.6);
}

.cook-stage {
  background: rgba(0, 0, 0, 0.65);
}