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% ์ด์) ๋ฌป์๋ค๋ฉด ์ค์๊ธฐ ์์ด์ฝ ๋ฑ์ฅ -> ์ค์๊ธฐ๋ก ๊ฑฐํ์ ์ป์ด๋ด๋ฉด ์ด๋ฒคํธ ์๋ฃ. ์ด๊ธฐ ํ๋ฉด์ผ๋ก ๋์๊ฐ
ํด๋น ๋ก์ง๋ค์ ๊ตฌํํ๊ธฐ ์ํด ์ฝ๋๋ฅผ ํฌ๊ฒ ๋ค ๊ฐ์ง๋ก ๋ถ๋ฅํด ์์ฑํ์์ต๋๋ค.
- Python์ฐ์ฑ : ํด ์์ ๋ฐ๋ผ ์ด๋ฒคํธ ํ๋ฅ ์ ์ฆ๊ฐ์ํค๊ณ , ํฌ์ผ๋ชฌ ์กฐ์ฐ ์ ์ฑ๊ฒฉ๊ณผ ์ฌ์ฉ์์ ์ ํ์ ๋ฐ๋ผ ํผํด·๋ณด์ ๊ฒฐ๊ณผ๋ฅผ ๊ณ์ฐํฉ๋๋ค.๋ชฉ์: ์งํ๋๋ฅผ ๋ฐ์ ์ค์๊ธฐ ํด๊ธ ์ฌ๋ถ๋ฅผ ํ๋จํ๋ ๋ก์ง์ ๋ด๋นํฉ๋๋ค.
- ์๋ฆฌ: ์ฌ์ฉ์๊ฐ ํฌ์ ํ ์ฌ๋ฃ ์กฐํฉ์ ๋ ์ํผ ํ ์ด๋ธ๊ณผ ๋น๊ตํด ๊ฒฐ๊ณผ์ ๊ธฐ๋ถ ๋ณํ๋ฅผ ๊ฒฐ์ ํฉ๋๋ค.
- ๊ฒ์์ ์ํ๋ฅผ ์ ์ฅํ๊ณ , ํ๋ก ํธ์๋์์ ํ์ํ ์ ๋ณด๋ฅผ API๋ก ์ ๊ณตํ๋ ๋ฐฑ์๋ ์ญํ ์ ๋๋ค. ๊ฐ ๋ชจ๋์์ ํด๋น ์ฝ๋๊ฐ ๋งก์ ์ญํ ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค
- HTML
- ๊ฒ์ ํ๋ฉด์ ๊ตฌ์กฐ๋ฅผ ์ ์ํ๋ ํ๋ก ํธ์๋ ๋ ์ด์์์ ๋๋ค. ์ง·์ฐ์ฑ ·์๋ฆฌ·๋ชฉ์์ ๊ฐ๊ฐ ํ๋์ section์ผ๋ก ๊ตฌ์ฑํด ํ ํ์ด์ง ์์ ๋ฐฐ์นํ๊ณ , JavaScript๊ฐ ํ์ํ ํ๋ฉด๋ง ์ ํํ๋๋ก ์ค๊ณํ์ต๋๋ค. HUD, ๋์ฌ์ฐฝ, ์ ํ ๋ฒํผ, ์งํ ๋ฐ ๋ฑ ์ฃผ์ UI ์์๋ฅผ ๋ฏธ๋ฆฌ ๋ฐฐ์นํ๋ฉฐ, ์ด๋ฏธ์ง ๋ฆฌ์์ค๋ฅผ ๋ถ๋ฌ์ค๋ ๊ฒฝ๋ก๋ ํจ๊ป ์ ์ํฉ๋๋ค.
- Javascript์ฐ์ฑ : ์ผ์ ์๊ฐ๋ง๋ค ์กฐ์ฐ ์์ฒญ + ์ ํ UI ํ์ ๋ฐ ๊ฒฐ๊ณผ ๋ฉ์์ง·์ํ ๊ฐฑ์ ์ ์ฒ๋ฆฌํฉ๋๋ค.์๋ฆฌ: ์ฌ๋ฃ ์ฌ์ฉ๋์ ์๋ฒ์ ์ ๋ฌํ๊ณ , ๊ฒฐ๊ณผ์ ๋ง๋ ๋ฐ์ ์ฐ์ถ์ ์ถ๋ ฅํฉ๋๋ค.
- ๋ชฉ์: ํฌ์ธํฐ ์ด๋์ ๋ฐ๋ผ ์งํ๋๋ฅผ ์ฆ๊ฐ์ํค๊ณ , 70% ์ด์์ผ ๊ฒฝ์ฐ ์ค์ ๋ชจ๋๋ก ์ ํํฉ๋๋ค.
- ์๋ฒ API๋ฅผ ํธ์ถํด ์ํ๋ฅผ ๋ฐ์์ค๊ณ , ์ด๋ฅผ ์ฆ์ ํ๋ฉด์ ๋ฐ์ํ๋ ์ญํ ์ ํฉ๋๋ค. ํ๋ฉด ์ ํ ๊ด๋ฆฌ์ ๋์ฌ ์ถ๋ ฅ ๋ก์ง์ ๋ด๋นํ๋ฉฐ, ๊ฐ ๊ธฐ๋ฅ๋ณ ๋์ ํ๋ฆ์ ์ ์ดํฉ๋๋ค.
- CSS
- ์ ์ฒด๋ฅผ ํ์คํฌ๋ฆฐ์ผ๋ก ๊ณ ์ ํ๊ณ ๊ฐ ์ฅ๋ฉด๋ค์ 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);
}