diff --git a/css/style.css b/css/style.css index c479d2d..dca11dc 100644 --- a/css/style.css +++ b/css/style.css @@ -129,11 +129,87 @@ header h1 { font-size: 1.3em; color: #8fbf5a; } border-bottom: 1px solid #4a3a20; } +.panel-tabs { + display: flex; + gap: 4px; + margin: 8px 0 10px; + border-bottom: 1px solid #4a3a20; + padding-bottom: 6px; +} +.panel-tab { + flex: 1; + padding: 5px 4px; + font-size: 0.78em; + font-family: inherit; + background: #1f1810; + border: 1px solid #4a3a20; + border-radius: 5px 5px 0 0; + color: #a89568; + cursor: pointer; +} +.panel-tab:hover { color: #e8d9b0; border-color: #6b5430; } +.panel-tab.active { + background: #28341c; + border-color: #8fbf5a; + color: #8fbf5a; + font-weight: bold; +} +.fx-badge { + display: inline-block; + min-width: 1.1em; + padding: 0 4px; + margin-left: 2px; + border-radius: 8px; + background: #8fbf5a; + color: #1a140e; + font-size: 0.85em; + font-weight: bold; +} +.fx-badge.hidden { display: none; } + #stats div { display: flex; justify-content: space-between; padding: 2px 0; } #stats .stat-val { font-family: monospace; color: #f0e6c0; } -#stats .stat-val small { color: #8a7a55; font-size: 0.85em; } -.potion-fx { margin-top: 8px; color: #d8a040; font-size: 0.9em; border-top: 1px solid #4a3a20; padding-top: 6px; } -.potion-fx-line { font-size: 0.82em; color: #a89568; padding: 1px 0 1px 8px; } + +#effects-panel { margin-top: 4px; } +.fx-empty { color: #8a7a55; font-style: italic; font-size: 0.88em; line-height: 1.4; } +.fx-total { + background: #1f1810; + border: 1px solid #4a3a20; + border-radius: 6px; + padding: 8px; + margin-bottom: 10px; +} +.fx-total-title { color: #d8a040; font-size: 0.85em; margin-bottom: 6px; font-weight: bold; } +.fx-card { + background: #1f1810; + border: 1px solid #4a3a20; + border-radius: 6px; + padding: 8px; + margin-bottom: 8px; +} +.fx-card-buff { border-color: #5d8535; } +.fx-card-debuff { border-color: #a04848; } +.fx-card-head { + display: flex; + justify-content: space-between; + gap: 6px; + color: #e8d9b0; + font-size: 0.88em; + margin-bottom: 4px; +} +.fx-turns { color: #8a7a55; font-size: 0.9em; white-space: nowrap; } +.fx-card-mods { display: flex; flex-wrap: wrap; gap: 4px; } +.fx-mod { + display: inline-block; + font-size: 0.78em; + font-family: monospace; + padding: 2px 5px; + border-radius: 4px; + background: #2a2014; + color: #c9b685; +} +.fx-card-buff .fx-mod { color: #8fbf5a; } +.fx-card-debuff .fx-mod { color: #d88080; } .hp-bar-wrap { background: #3a2a1a; border-radius: 4px; height: 14px; margin: 6px 0; overflow: hidden; } .hp-bar { background: #b03030; height: 100%; transition: width .2s; } diff --git a/index.html b/index.html index dc22870..8699366 100644 --- a/index.html +++ b/index.html @@ -92,9 +92,18 @@
diff --git a/js/game.js b/js/game.js index a6393d4..f0eaa6c 100644 --- a/js/game.js +++ b/js/game.js @@ -6,6 +6,10 @@ const APP_VERSION = "0.1.5"; +/* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ +const START_COMP_PCT = 90; +const START_SORT_PCT = 80; + /* potions.js est chargé avant ce fichier dans le navigateur ; en node, require explicite. */ if (typeof module !== "undefined" && module.exports && typeof makeRandomPotion === "undefined") { Object.assign(globalThis, require("./potions.js")); @@ -96,7 +100,7 @@ function killPX(trollLevel, targetLevel) { /* ================= Les 5 races ================= */ /* Profils de base officiels (mountyhall.com/MH_Rules/Races_*.php) : - * chaque race a sa compétence et son sortilège réservés, démarrés à 15 % de maîtrise. */ + * chaque race a sa compétence et son sortilège réservés (START_COMP_PCT / START_SORT_PCT). */ const RACES = { Skrim: { emoji: "🟢", favored: "att", @@ -317,7 +321,7 @@ function newGame(name, race, customLevel = null) { pv: s.pvMax, pvMax: s.pvMax, vue: s.vue, degBonus: 0, armor: 0, armorDice: 0, bought: { att: 0, esq: 0, deg: 0, reg: 0, pv: 0, vue: 0, armor: 0 }, - comp: { pct: 15 }, sort: { pct: 15 }, fatigue: 0, compUsed: false, + comp: { pct: START_COMP_PCT }, sort: { pct: START_SORT_PCT }, fatigue: 0, compUsed: false, compPXTurn: false, sortPXTurn: false, weapon: null, armorItem: null, pa: PA_PER_TURN, pi: 0, totalPI: 0, gold: 0, @@ -425,23 +429,44 @@ function buildLevel() { /* ================= Champ de vision (la Vue du troll) ================= */ -function updateFov() { +/* Portée en cases (Chebyshev), comme pour la Vue des monstres à MH. */ +function tileDist(x, y) { + const t = G.troll; + return Math.max(Math.abs(x - t.x), Math.abs(y - t.y)); +} + +function currentVue() { + return effTroll(G.troll).vue; +} + +function inSightAt(x, y) { + return tileDist(x, y) <= currentVue(); +} + +function inSight(e) { + return inSightAt(e.x, e.y); +} + +/* Met à jour le brouillard : retire les cases hors de la Vue actuelle + * (ex. quand un bonus de potion expire) puis révèle la zone portée. */ +function refreshFov() { + const r = currentVue(); + for (const key of [...G.seen]) { + const tx = key % MAP_W, ty = Math.floor(key / MAP_W); + if (tileDist(tx, ty) > r) G.seen.delete(key); + } const { x, y } = G.troll; - const r = effTroll(G.troll).vue; for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dy)) > r) continue; const tx = x + dx, ty = y + dy; if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) continue; - if (dx * dx + dy * dy <= r * r) G.seen.add(ty * MAP_W + tx); + G.seen.add(ty * MAP_W + tx); } } } -function inSight(e) { - const dx = e.x - G.troll.x, dy = e.y - G.troll.y; - const vue = effTroll(G.troll).vue; - return dx * dx + dy * dy <= vue * vue; -} +const updateFov = refreshFov; function spellMM() { const eff = effTroll(G.troll); @@ -906,6 +931,7 @@ function useBagItem(idx) { if (!spendPA(COSTS.potion)) return; if (!drinkPotion(t, item, rollDice, log)) return; t.bag.splice(idx, 1); + if (countActiveEffects(t) > 0) switchLeftTab("effects"); updateFov(); } else if (item.kind === "gear") { if (!spendPA(COSTS.equip)) return; @@ -1013,6 +1039,7 @@ function passDLA() { t.compPXTurn = false; t.sortPXTurn = false; t.fatigue = Math.floor(t.fatigue / 1.25); // la fatigue du Kastar retombe à chaque DLA + refreshFov(); afterAction(); } @@ -1085,8 +1112,7 @@ function render() { const key = y * MAP_W + x; const px = x * TILE, py = y * TILE; if (!G.seen.has(key)) { ctx.fillStyle = "#0d0a06"; ctx.fillRect(px, py, TILE, TILE); continue; } - const vue = effTroll(G.troll).vue; - const visible = (x - G.troll.x) ** 2 + (y - G.troll.y) ** 2 <= vue ** vue; + const visible = inSightAt(x, y); const t = G.grid[y][x]; if (t === T_WALL) ctx.fillStyle = visible ? "#4a3a22" : "#2c2315"; else ctx.fillStyle = visible ? "#7a6a45" : "#3d3522"; @@ -1106,13 +1132,13 @@ function render() { ctx.fill(); }; for (const i of G.items) { - if (!G.seen.has(i.y * MAP_W + i.x)) continue; + if (!inSightAt(i.x, i.y)) continue; disc(i.x, i.y, i.kind === "gold" ? "#caa53d" : i.kind === "potion" ? (i.color || "#5d8535") : "#7a8db0"); ctx.fillStyle = "#1a140e"; ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1); } for (const d of G.doors || []) { - if (!G.seen.has(d.y * MAP_W + d.x)) continue; + if (!inSightAt(d.x, d.y)) continue; disc(d.x, d.y, "#a06a28"); ctx.fillStyle = "#1a140e"; ctx.fillText("🚪", d.x * TILE + TILE / 2, d.y * TILE + TILE / 2 + 1); @@ -1144,24 +1170,18 @@ function renderPanels() { $("troll-title").textContent = `${RACES[t.race].emoji} ${t.name}, ${t.race} niv. ${levelFromTotalPI(t.totalPI)}`; const te = effTroll(t); - const statFmt = (base, eff, suffix) => { - const d = eff - base; - return d ? `${eff}${suffix} (${base}${d > 0 ? "+" : ""}${d})` : `${base}${suffix}`; - }; const pct = Math.max(0, t.pv / t.pvMax); const hpClass = pct > 0.6 ? "high" : pct > 0.3 ? "mid" : ""; - const potionLines = describeActiveEffects(t); $("stats").innerHTML = `
Tour${t.tour}
PV${t.pv} / ${t.pvMax}
-
Attaque${statFmt(t.att, te.att, "D6")}
-
Esquive${statFmt(t.esq, te.esq, "D6")}
-
Dégâts${statFmt(t.deg, te.deg, "D3")}${te.degBonus ? "+" + te.degBonus : ""}
-
Régénération${statFmt(t.reg, te.reg, "D3")}
-
Armure${statFmt(t.armor, te.armor, "")}${t.armorDice ? "+" + t.armorDice + "D3" : ""}
-
Vue${statFmt(t.vue, te.vue, "")}
- ${potionLines.length ? `
🧪 Effets
${potionLines.map(l => `
${l}
`).join("")}` : ""} +
Attaque${te.att}D6
+
Esquive${te.esq}D6
+
Dégâts${te.deg}D3${te.degBonus ? "+" + te.degBonus : ""}
+
Régénération${te.reg}D3
+
Armure${te.armor}${t.armorDice ? "+" + t.armorDice + "D3" : ""}
+
Vue${te.vue}
${RACES[t.race].comp.name}${t.comp.pct} %
${RACES[t.race].sort.name}${t.sort.pct} %
${t.race === "Kastar" ? `
Fatigue${t.fatigue}
` : ""} @@ -1170,6 +1190,19 @@ function renderPanels() {
Mountyzédons${t.gold}
Monstres tués${t.kills}
`; + const fxCount = countActiveEffects(t); + const badge = $("fx-badge"); + if (badge) { + if (fxCount > 0) { + badge.textContent = fxCount; + badge.classList.remove("hidden"); + } else { + badge.classList.add("hidden"); + } + } + const fxPanel = $("effects-panel"); + if (fxPanel) fxPanel.innerHTML = renderEffectsPanel(t); + const improve = $("improve"); improve.innerHTML = ""; const labels = { att: "Attaque +1D6", esq: "Esquive +1D6", deg: "Dégâts +1D3", reg: "Régén. +1D3", pv: "PV max +10", vue: "Vue +1", armor: "Armure +1D3" }; @@ -1251,6 +1284,21 @@ function showEnd(victory, killer) { /* ================= Création du personnage ================= */ let selectedRace = "Skrim"; +let activeLeftTab = "stats"; + +function switchLeftTab(tab) { + activeLeftTab = tab; + const statsBtn = $("tab-stats"), fxBtn = $("tab-effects"); + const statsPanel = $("panel-tab-stats"), fxPanel = $("panel-tab-effects"); + if (!statsBtn || !fxBtn) return; + const isStats = tab === "stats"; + statsBtn.classList.toggle("active", isStats); + fxBtn.classList.toggle("active", !isStats); + statsBtn.setAttribute("aria-selected", isStats); + fxBtn.setAttribute("aria-selected", !isStats); + statsPanel.classList.toggle("hidden", !isStats); + fxPanel.classList.toggle("hidden", isStats); +} function initCreateScreen() { const list = $("race-list"); @@ -1306,6 +1354,8 @@ if (typeof document !== "undefined") { if (footer) footer.textContent = `MountyCrawl v${APP_VERSION}`; initCreateScreen(); bindKeys(); + $("tab-stats")?.addEventListener("click", () => switchLeftTab("stats")); + $("tab-effects")?.addEventListener("click", () => switchLeftTab("effects")); $("btn-start").onclick = () => startGame(); // ?autostart=1&race=Durakuir&name=Grosbill : lance directement une partie const params = new URLSearchParams(location.search); @@ -1349,5 +1399,7 @@ if (typeof module !== "undefined" && module.exports) { RACES, MONSTER_TYPES, BOSS, TEMPLATES, makeMonster, monsterFromSpec, itemFromSpec, generateCavern, largestRegion, MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS, + newGame, refreshFov, inSightAt, + get state() { return G; }, }; } diff --git a/js/potions.js b/js/potions.js index b19ca5f..27c6983 100644 --- a/js/potions.js +++ b/js/potions.js @@ -422,6 +422,32 @@ function tickPotionTurns(troll, logFn) { return sumPotionMods(troll.potionEffects).dlaBonusPA; } +const EFFECT_MOD_LABELS = { + att: ["ATT", "D6"], esq: ["ESQ", "D6"], deg: ["DEG", "D3"], reg: ["REG", "D3"], + vue: ["VUE", ""], armor: ["Armure", ""], +}; + +function formatEffectMods(effect) { + const parts = []; + for (const [key, [label, suffix]] of Object.entries(EFFECT_MOD_LABELS)) { + const v = effect[key]; + if (!v) continue; + parts.push(`${label} ${v > 0 ? "+" : ""}${v}${suffix}`); + } + if (effect.mmPct) parts.push(`MM ${effect.mmPct > 0 ? "+" : ""}${effect.mmPct} %`); + if (effect.rmPct) parts.push(`RM ${effect.rmPct > 0 ? "+" : ""}${effect.rmPct} %`); + if (effect.concentrationPct) parts.push(`Concentration ${effect.concentrationPct > 0 ? "+" : ""}${effect.concentrationPct} %`); + if (effect.dlaBonusPA) parts.push(`DLA +${effect.dlaBonusPA} PA/tour`); + if (effect.blockCamo) parts.push("Visible — camouflage impossible"); + return parts; +} + +function countActiveEffects(troll) { + let n = (troll.potionEffects || []).length; + if (troll.blockCamoTurns > 0 && !(troll.potionEffects || []).some(e => e.blockCamo)) n += 1; + return n; +} + function describeActiveEffects(troll) { const lines = []; if (troll.blockCamoTurns > 0) { @@ -434,6 +460,55 @@ function describeActiveEffects(troll) { return lines; } +function renderEffectsPanel(troll) { + const effects = [...(troll.potionEffects || [])]; + const cards = []; + + if (troll.blockCamoTurns > 0 && !effects.some(e => e.blockCamo)) { + cards.push({ + emoji: "🎨", name: "Pàïntûré", turnsLeft: troll.blockCamoTurns, + mods: ["Visible — camouflage impossible"], + positive: false, + }); + } + + for (const e of effects) { + const mods = formatEffectMods(e); + const positive = mods.some(m => m.includes("+") && !m.includes("−")); + cards.push({ + emoji: e.emoji, name: e.name, + turnsLeft: e.blockCamo ? troll.blockCamoTurns : e.turnsLeft, + mods, positive: mods.length ? positive : null, + }); + } + + if (!cards.length) { + return '

Aucun bonus ni malus magique actif.

'; + } + + const total = sumPotionMods(troll.potionEffects); + const totalLines = formatEffectMods(total); + if (troll.blockCamoTurns > 0) totalLines.push("Camouflage bloqué"); + + let html = ""; + if (totalLines.length) { + html += `
Total des modificateurs
`; + html += totalLines.map(m => `${m}`).join(""); + html += "
"; + } + + for (const c of cards) { + const cls = c.positive === true ? "fx-card-buff" : c.positive === false ? "fx-card-debuff" : "fx-card"; + html += `
`; + html += `
${c.emoji} ${c.name}${c.turnsLeft} tour(s)
`; + if (c.mods.length) { + html += `
${c.mods.map(m => `${m}`).join("")}
`; + } + html += "
"; + } + return html; +} + function talentPctWithPotions(troll, talent, cap) { const eff = effTroll(troll); return Math.max(0, Math.min(cap, talent.pct + eff.concentrationPct)); @@ -443,6 +518,7 @@ if (typeof module !== "undefined" && module.exports) { module.exports = { POTION_IDS, POTION_DEFS, sumPotionMods, effTroll, formatPotionItem, makeRandomPotion, makePotionItem, drinkPotion, tickPotionTurns, - describeActiveEffects, talentPctWithPotions, corruptionYZ, + describeActiveEffects, renderEffectsPanel, countActiveEffects, formatEffectMods, + talentPctWithPotions, corruptionYZ, }; } diff --git a/test/smoke.js b/test/smoke.js index 3443eac..3acb108 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -207,4 +207,18 @@ assert(srv.validateLevel({ ...goodLevel, grid: goodGrid.slice(1) }), "grille tro assert(srv.validateLevel({ ...goodLevel, monsters: [{ x: 0, y: 0, type: 0, tpl: 0 }] }), "monstre dans un mur refusé"); assert(srv.validateLevel({ ...goodLevel, items: [{ x: 3, y: 3, kind: "nawak" }] }), "objet inconnu refusé"); +// Vue : le brouillard est rogné quand la portée diminue (fin de potion) +{ + g.newGame("Test", "Skrim"); + const t = g.state.troll; + t.x = 5; t.y = 5; + t.potionEffects = [{ name: "Test", emoji: "🔭", turnsLeft: 1, vue: 5 }]; + g.refreshFov(); + assert(g.inSightAt(10, 5), "vue 8 (3+5) doit voir à 5 cases"); + t.potionEffects = []; + g.refreshFov(); + assert(!g.inSightAt(10, 5), "vue 3 seule ne doit plus voir à 5 cases"); + assert(g.inSightAt(8, 5), "vue 3 voit encore à 3 cases"); +} + console.log("✅ Tous les tests de fumée passent.");