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.");