1.6.0 : parchemins standards + page « Trésors du Hall »

- js/scrolls.js : les 7 parchemins standards de Mountypedia (X 1-5),
  effets via le système d'effets magiques, un seul jet de D3 par parchemin
- Effets de zone (rayon 3 cases) : Rune Explosive (dégâts aux monstres),
  Yeu'Ki'Pic (VUE des monstres réduite, statut à durée)
- TOUR ±X*30 min converti en ±X PA par tour (PA min. 1)
- Drops : coffres (12 %), butin de monstres (25 %), pinceau éditeur,
  validation serveur du kind « scroll »
- js/treasures.js : page encyclopédie des 25 potions et 7 parchemins
  (niveau X, durée, formules), boutons accueil + en jeu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-11 23:35:41 +02:00
co-authored by Claude Fable 5
parent 9b3e2c8455
commit 6039497e72
10 changed files with 458 additions and 18 deletions
+7
View File
@@ -152,6 +152,13 @@ non affilié au jeu original de Mountyhall SARL.
## Versions ## Versions
- **1.6.0** (2026-06-11) — Parchemins 📜 : les 7 parchemins standards de Mountypedia
(Rune des Cyclopes, Traité de Clairvoyance, Idées Confuses, Rune Explosive,
Plan Génial, Yeu'Ki'Pic, Rune des Foins) en trésors à trouver, butin de monstre
et pinceau d'éditeur. Effets de zone (rayon 3 cases : dégâts ou aveuglement des
monstres), TOUR accéléré/ralenti converti en PA (±30 min = ±1 PA, min. 1 PA par
tour). Nouvelle page « 🧪 Trésors du Hall » : fiches des 25 potions et des
7 parchemins (niveau X, durée, formules), accessible depuis l'accueil et en jeu.
- **1.5.2** (2026-06-11) — Les bonus/malus DEG et REG des potions sont des valeurs - **1.5.2** (2026-06-11) — Les bonus/malus DEG et REG des potions sont des valeurs
fixes ajoutées au jet, plus des dés supplémentaires (seules les notations « X D3 » fixes ajoutées au jet, plus des dés supplémentaires (seules les notations « X D3 »
de Mountypedia sont des jets). Concerne Bonne Bouffe, Fertilité, Corruption, de Mountypedia sont des jets). Concerne Bonne Bouffe, Fertilité, Corruption,
+47
View File
@@ -473,3 +473,50 @@ header h1 { font-size: 1.3em; color: #8fbf5a; }
#log .l-bad { color: #e06060; } #log .l-bad { color: #e06060; }
#log .l-good { color: #8fbf5a; } #log .l-good { color: #8fbf5a; }
#log .l-info { color: #9a8a65; } #log .l-info { color: #9a8a65; }
/* ---------- Trésors du Hall (potions & parchemins) ---------- */
#treasures-body {
max-width: 1100px;
margin: 0 auto;
padding: 10px 16px 40px;
}
.tz-intro, .tz-note {
color: #b9a878;
line-height: 1.5;
font-size: .92em;
}
.tz-intro a { color: #d8a040; }
.tz-note { font-style: italic; margin-top: 18px; }
.tz-section {
color: #8fbf5a;
border-bottom: 2px solid #4a3a20;
margin: 24px 0 12px;
font-size: 1.3em;
}
.tz-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.tz-card {
background: #2a2014;
border: 2px solid #6b5430;
border-left: 6px solid var(--tz-color, #6b5430);
border-radius: 8px;
padding: 10px 12px;
font-size: .92em;
}
.tz-name { color: #d8a040; font-weight: bold; }
.tz-meta { color: #a89568; font-size: .88em; margin: 4px 0 8px; }
.tz-meta b { color: #d8c79a; }
.tz-fx-list { display: flex; flex-wrap: wrap; gap: 4px; }
.tz-fx {
background: #1f1810;
border: 1px solid #4a3a20;
border-radius: 4px;
padding: 2px 7px;
color: #d8c79a;
font-size: .9em;
}
.tz-good { color: #8fbf5a; }
.tz-bad { color: #d88080; }
+13
View File
@@ -23,6 +23,7 @@
<button id="btn-community" class="menu-btn">🌍 Niveaux de la communauté</button> <button id="btn-community" class="menu-btn">🌍 Niveaux de la communauté</button>
<button id="btn-editor" class="menu-btn">🛠️ Éditeur de niveaux</button> <button id="btn-editor" class="menu-btn">🛠️ Éditeur de niveaux</button>
<button id="btn-rules" class="menu-btn">📜 Règles des talents</button> <button id="btn-rules" class="menu-btn">📜 Règles des talents</button>
<button id="btn-treasures" class="menu-btn">🧪 Potions &amp; Parchemins</button>
</div> </div>
</div> </div>
</div> </div>
@@ -87,6 +88,7 @@
<div class="header-right"> <div class="header-right">
<div id="depth-label">Profondeur 1</div> <div id="depth-label">Profondeur 1</div>
<button id="btn-rules-game" class="menu-btn">📜 Règles</button> <button id="btn-rules-game" class="menu-btn">📜 Règles</button>
<button id="btn-treasures-game" class="menu-btn">🧪 Trésors</button>
</div> </div>
</header> </header>
<div id="layout"> <div id="layout">
@@ -142,6 +144,15 @@
<div id="rules-body"></div> <div id="rules-body"></div>
</div> </div>
<!-- Écran des trésors : potions et parchemins -->
<div id="screen-treasures" class="screen hidden">
<header>
<h1>🧪 Trésors du Hall — Potions &amp; Parchemins</h1>
<button id="treasures-back" class="menu-btn">← Retour</button>
</header>
<div id="treasures-body"></div>
</div>
<!-- Écran de fin --> <!-- Écran de fin -->
<div id="screen-end" class="screen hidden"> <div id="screen-end" class="screen hidden">
<div class="create-box"> <div class="create-box">
@@ -154,8 +165,10 @@
<footer id="app-footer" class="app-footer" aria-label="Version"></footer> <footer id="app-footer" class="app-footer" aria-label="Version"></footer>
<script src="js/potions.js"></script> <script src="js/potions.js"></script>
<script src="js/scrolls.js"></script>
<script src="js/game.js"></script> <script src="js/game.js"></script>
<script src="js/editor.js"></script> <script src="js/editor.js"></script>
<script src="js/rules.js"></script> <script src="js/rules.js"></script>
<script src="js/treasures.js"></script>
</body> </body>
</html> </html>
+4 -2
View File
@@ -122,6 +122,7 @@ function edBuildPalette() {
const itemsDiv = document.getElementById("ed-items"); const itemsDiv = document.getElementById("ed-items");
itemsDiv.innerHTML = ""; itemsDiv.innerHTML = "";
edAddBrushBtn(itemsDiv, "🧪 Potion (aléatoire)", { mode: "item", kind: "potion" }); edAddBrushBtn(itemsDiv, "🧪 Potion (aléatoire)", { mode: "item", kind: "potion" });
edAddBrushBtn(itemsDiv, "📜 Parchemin (aléatoire)", { mode: "item", kind: "scroll" });
edAddBrushBtn(itemsDiv, "💰 Mountyzédons", { mode: "item", kind: "gold" }); edAddBrushBtn(itemsDiv, "💰 Mountyzédons", { mode: "item", kind: "gold" });
WEAPONS.forEach((w, i) => edAddBrushBtn(itemsDiv, `${w.emoji} ${w.name} (+${w.bonus})`, { mode: "item", kind: "weapon", idx: i })); WEAPONS.forEach((w, i) => edAddBrushBtn(itemsDiv, `${w.emoji} ${w.name} (+${w.bonus})`, { mode: "item", kind: "weapon", idx: i }));
ARMORS.forEach((a, i) => edAddBrushBtn(itemsDiv, `${a.emoji} ${a.name} (+${a.bonus})`, { mode: "item", kind: "armor", idx: i })); ARMORS.forEach((a, i) => edAddBrushBtn(itemsDiv, `${a.emoji} ${a.name} (+${a.bonus})`, { mode: "item", kind: "armor", idx: i }));
@@ -274,9 +275,10 @@ function edRender() {
ctx.fill(); ctx.fill();
}; };
for (const i of ED.items) { for (const i of ED.items) {
disc(i.x, i.y, i.kind === "gold" ? "#caa53d" : i.kind === "potion" ? "#5d8535" : "#7a8db0"); disc(i.x, i.y, i.kind === "gold" ? "#caa53d" : i.kind === "potion" ? "#5d8535"
: i.kind === "scroll" ? "#d8c890" : "#7a8db0");
ctx.fillStyle = "#1a140e"; ctx.fillStyle = "#1a140e";
const emoji = i.kind === "potion" ? "🧪" : i.kind === "gold" ? "💰" const emoji = i.kind === "potion" ? "🧪" : i.kind === "scroll" ? "📜" : i.kind === "gold" ? "💰"
: i.kind === "weapon" ? WEAPONS[i.idx || 0].emoji : ARMORS[i.idx || 0].emoji; : i.kind === "weapon" ? WEAPONS[i.idx || 0].emoji : ARMORS[i.idx || 0].emoji;
ctx.fillText(emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1); ctx.fillText(emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1);
} }
+56 -10
View File
@@ -4,16 +4,19 @@
"use strict"; "use strict";
const APP_VERSION = "1.5.2"; const APP_VERSION = "1.6.0";
/* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */
const START_COMP_PCT = 90; const START_COMP_PCT = 90;
const START_SORT_PCT = 80; const START_SORT_PCT = 80;
/* potions.js est chargé avant ce fichier dans le navigateur ; en node, require explicite. */ /* potions.js et scrolls.js sont chargés avant ce fichier dans le navigateur ; en node, require explicite. */
if (typeof module !== "undefined" && module.exports && typeof makeRandomPotion === "undefined") { if (typeof module !== "undefined" && module.exports && typeof makeRandomPotion === "undefined") {
Object.assign(globalThis, require("./potions.js")); Object.assign(globalThis, require("./potions.js"));
} }
if (typeof module !== "undefined" && module.exports && typeof makeRandomScroll === "undefined") {
Object.assign(globalThis, require("./scrolls.js"));
}
/* ================= Dés & règles de base ================= */ /* ================= Dés & règles de base ================= */
@@ -202,6 +205,7 @@ function monsterFromSpec(spec) {
function itemFromSpec(spec) { function itemFromSpec(spec) {
const base = { x: spec.x, y: spec.y }; const base = { x: spec.x, y: spec.y };
if (spec.kind === "potion") return { ...base, ...makePotionItem(spec.potionId, spec.power) }; if (spec.kind === "potion") return { ...base, ...makePotionItem(spec.potionId, spec.power) };
if (spec.kind === "scroll") return { ...base, ...makeScrollItem(spec.scrollId, spec.power) };
if (spec.kind === "gold") { if (spec.kind === "gold") {
const gold = spec.gold || 60; const gold = spec.gold || 60;
return { ...base, kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold }; return { ...base, kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold };
@@ -296,8 +300,9 @@ const ARMORS = [
function makeItem(depth) { function makeItem(depth) {
const r = Math.random(); const r = Math.random();
if (r < 0.35) return makeRandomPotion(); if (r < 0.3) return makeRandomPotion();
if (r < 0.55) { if (r < 0.42) return makeRandomScroll();
if (r < 0.58) {
const w = WEAPONS[Math.min(WEAPONS.length - 1, Math.floor(Math.random() * (depth + 1)))]; const w = WEAPONS[Math.min(WEAPONS.length - 1, Math.floor(Math.random() * (depth + 1)))];
return { kind: "gear", ...w }; return { kind: "gear", ...w };
} }
@@ -313,7 +318,7 @@ function makeItem(depth) {
const MAX_DEPTH = 5; const MAX_DEPTH = 5;
const PA_PER_TURN = 6; const PA_PER_TURN = 6;
const COSTS = { move: 1, attack: 3, pickup: 1, equip: 2, potion: 1 }; const COSTS = { move: 1, attack: 3, pickup: 1, equip: 2, potion: 1, scroll: 1 };
let G = null; // état global de la partie let G = null; // état global de la partie
@@ -651,8 +656,11 @@ function killMonster(m, source = "attack") {
} }
// butin : les monstres lâchent parfois un trésor en mourant // butin : les monstres lâchent parfois un trésor en mourant
if (Math.random() < 0.4 && !G.items.some(i => i.x === m.x && i.y === m.y)) { if (Math.random() < 0.4 && !G.items.some(i => i.x === m.x && i.y === m.y)) {
const drop = Math.random() < 0.5 const lr = Math.random();
const drop = lr < 0.4
? { ...makeRandomPotion(), x: m.x, y: m.y } ? { ...makeRandomPotion(), x: m.x, y: m.y }
: lr < 0.65
? { ...makeRandomScroll(), x: m.x, y: m.y }
: { kind: "gold", gold: m.level * 10, name: `${m.level * 10} Mountyzédons`, emoji: "💰", x: m.x, y: m.y }; : { kind: "gold", gold: m.level * 10, name: `${m.level * 10} Mountyzédons`, emoji: "💰", x: m.x, y: m.y };
G.items.push(drop); G.items.push(drop);
cdLine(`Il a de plus laissé tomber <span class="cd-val">${drop.emoji} ${drop.name}</span>.`); cdLine(`Il a de plus laissé tomber <span class="cd-val">${drop.emoji} ${drop.name}</span>.`);
@@ -906,6 +914,7 @@ function effMonster(m) {
...m, ...m,
att: Math.max(1, m.att - (m.attDownTurns > 0 ? m.attDownDice || 0 : 0)), att: Math.max(1, m.att - (m.attDownTurns > 0 ? m.attDownDice || 0 : 0)),
esq: m.esqHalfTurns > 0 ? Math.max(1, Math.floor(m.esq / 2)) : m.esq, esq: m.esqHalfTurns > 0 ? Math.max(1, Math.floor(m.esq / 2)) : m.esq,
vue: Math.max(1, m.vue - (m.vueMalusTurns > 0 ? m.vueMalus || 0 : 0)),
}; };
} }
@@ -948,6 +957,14 @@ function useBagItem(idx) {
t.bag.splice(idx, 1); t.bag.splice(idx, 1);
if (countActiveEffects(t) > 0) switchLeftTab("effects"); if (countActiveEffects(t) > 0) switchLeftTab("effects");
updateFov(); updateFov();
} else if (item.kind === "scroll") {
if (!spendPA(COSTS.scroll)) return;
const res = readScroll(t, item, rollDice, log);
if (!res) return;
t.bag.splice(idx, 1);
if (res.zone) applyScrollZone(res.zone);
if (countActiveEffects(t) > 0) switchLeftTab("effects");
updateFov();
} else if (item.kind === "gear") { } else if (item.kind === "gear") {
if (!spendPA(COSTS.equip)) return; if (!spendPA(COSTS.equip)) return;
if (item.slot === "weapon") { if (item.slot === "weapon") {
@@ -963,6 +980,31 @@ function useBagItem(idx) {
afterAction(); afterAction();
} }
/* Effet de zone d'un parchemin : touche tous les monstres à SCROLL_ZONE_RADIUS
* cases ou moins du troll (le lecteur a déjà subi sa part dans readScroll). */
function applyScrollZone(zone) {
const t = G.troll;
const targets = G.monsters.filter(m =>
Math.max(Math.abs(m.x - t.x), Math.abs(m.y - t.y)) <= SCROLL_ZONE_RADIUS);
if (!targets.length) return;
if (zone.type === "damage") {
cdStart("💥 Effet de zone");
for (const m of targets) {
m.pv -= zone.total;
cdLine(`${m.emoji} ${m.name} subit <span class="cd-val">${zone.total} points de dégâts</span> (${zone.label}).`);
log(`💥 ${m.name} est pris dans l'explosion : ${zone.total} PV.`, "combat");
if (m.pv <= 0) killMonster(m, "scroll");
}
cdFlush();
} else if (zone.type === "vue") {
for (const m of targets) {
m.vueMalus = Math.max(m.vueMalus || 0, zone.malus);
m.vueMalusTurns = Math.max(m.vueMalusTurns || 0, zone.turns);
log(`🌶️ ${m.name} est aveuglé : VUE ${zone.malus} pendant ${zone.turns} tour(s).`, "good");
}
}
}
function descend() { function descend() {
if (G.grid[G.troll.y][G.troll.x] !== T_STAIRS) return; if (G.grid[G.troll.y][G.troll.x] !== T_STAIRS) return;
if (G.custom) { if (G.custom) {
@@ -1010,7 +1052,7 @@ function passDLA() {
continue; continue;
} }
const dist = Math.max(Math.abs(m.x - t.x), Math.abs(m.y - t.y)); const dist = Math.max(Math.abs(m.x - t.x), Math.abs(m.y - t.y));
const seesTroll = !t.camo && dist <= m.vue; const seesTroll = !t.camo && dist <= effMonster(m).vue;
if (dist <= 1 && !t.camo) { if (dist <= 1 && !t.camo) {
const eff = effMonster(m); const eff = effMonster(m);
const r = resolveAttack(eff, effTroll(t)); const r = resolveAttack(eff, effTroll(t));
@@ -1037,6 +1079,7 @@ function passDLA() {
// les statuts s'estompent à la fin de l'activation du monstre // les statuts s'estompent à la fin de l'activation du monstre
if (m.esqHalfTurns > 0) m.esqHalfTurns--; if (m.esqHalfTurns > 0) m.esqHalfTurns--;
if (m.attDownTurns > 0 && --m.attDownTurns === 0) m.attDownDice = 0; if (m.attDownTurns > 0 && --m.attDownTurns === 0) m.attDownDice = 0;
if (m.vueMalusTurns > 0 && --m.vueMalusTurns === 0) m.vueMalus = 0;
} }
// Régénération (REG D3, comme à MountyHall) — bonus potions pris en compte // Régénération (REG D3, comme à MountyHall) — bonus potions pris en compte
@@ -1051,7 +1094,8 @@ function passDLA() {
log(`💤 Tour n°${t.tour} (DLA n°${t.dla}).`, "info"); log(`💤 Tour n°${t.tour} (DLA n°${t.dla}).`, "info");
} }
t.pa = Math.min(PA_PER_TURN + 3, PA_PER_TURN + dlaBonusPA); // bonus/malus de PA (potions, parchemins) : entre 1 et PA_PER_TURN + 3
t.pa = Math.max(1, Math.min(PA_PER_TURN + 3, PA_PER_TURN + dlaBonusPA));
t.compUsed = false; t.compUsed = false;
t.compPXTurn = false; t.compPXTurn = false;
t.sortPXTurn = false; t.sortPXTurn = false;
@@ -1150,7 +1194,8 @@ function render() {
}; };
for (const i of G.items) { for (const i of G.items) {
if (!inSightAt(i.x, i.y)) continue; if (!inSightAt(i.x, i.y)) continue;
disc(i.x, i.y, i.kind === "gold" ? "#caa53d" : i.kind === "potion" ? (i.color || "#5d8535") : "#7a8db0"); disc(i.x, i.y, i.kind === "gold" ? "#caa53d"
: i.kind === "potion" || i.kind === "scroll" ? (i.color || "#5d8535") : "#7a8db0");
ctx.fillStyle = "#1a140e"; ctx.fillStyle = "#1a140e";
ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1); ctx.fillText(i.emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1);
} }
@@ -1265,7 +1310,8 @@ function renderPanels() {
if (t.bag.length === 0) inv.innerHTML = '<div class="empty">— vide —</div>'; if (t.bag.length === 0) inv.innerHTML = '<div class="empty">— vide —</div>';
t.bag.forEach((item, idx) => { t.bag.forEach((item, idx) => {
const b = document.createElement("button"); const b = document.createElement("button");
const action = item.kind === "potion" ? `boire (${COSTS.potion} PA)` : `équiper (${COSTS.equip} PA)`; const action = item.kind === "potion" ? `boire (${COSTS.potion} PA)`
: item.kind === "scroll" ? `lire (${COSTS.scroll} PA)` : `équiper (${COSTS.equip} PA)`;
b.textContent = `${item.emoji} ${item.name}${action}`; b.textContent = `${item.emoji} ${item.name}${action}`;
b.disabled = G.over; b.disabled = G.over;
b.onclick = () => useBagItem(idx); b.onclick = () => useBagItem(idx);
+3 -3
View File
@@ -518,7 +518,7 @@ function formatEffectMods(effect) {
if (effect.mmPct) parts.push(`MM ${effect.mmPct > 0 ? "+" : ""}${effect.mmPct} %`); if (effect.mmPct) parts.push(`MM ${effect.mmPct > 0 ? "+" : ""}${effect.mmPct} %`);
if (effect.rmPct) parts.push(`RM ${effect.rmPct > 0 ? "+" : ""}${effect.rmPct} %`); if (effect.rmPct) parts.push(`RM ${effect.rmPct > 0 ? "+" : ""}${effect.rmPct} %`);
if (effect.concentrationPct) parts.push(`Concentration ${effect.concentrationPct > 0 ? "+" : ""}${effect.concentrationPct} %`); if (effect.concentrationPct) parts.push(`Concentration ${effect.concentrationPct > 0 ? "+" : ""}${effect.concentrationPct} %`);
if (effect.dlaBonusPA) parts.push(`DLA +${effect.dlaBonusPA} PA/tour`); if (effect.dlaBonusPA) parts.push(`DLA ${effect.dlaBonusPA > 0 ? "+" : ""}${effect.dlaBonusPA} PA/tour`);
if (effect.blockCamo) parts.push("Visible — camouflage impossible"); if (effect.blockCamo) parts.push("Visible — camouflage impossible");
return parts; return parts;
} }
@@ -581,7 +581,7 @@ function renderEffectsPanel(troll) {
if (total.mmPct) totalLines.push(`MM ${total.mmPct > 0 ? "+" : ""}${total.mmPct} %`); if (total.mmPct) totalLines.push(`MM ${total.mmPct > 0 ? "+" : ""}${total.mmPct} %`);
if (total.rmPct) totalLines.push(`RM ${total.rmPct > 0 ? "+" : ""}${total.rmPct} %`); if (total.rmPct) totalLines.push(`RM ${total.rmPct > 0 ? "+" : ""}${total.rmPct} %`);
if (total.concentrationPct) totalLines.push(`Concentration ${total.concentrationPct > 0 ? "+" : ""}${total.concentrationPct} %`); if (total.concentrationPct) totalLines.push(`Concentration ${total.concentrationPct > 0 ? "+" : ""}${total.concentrationPct} %`);
if (total.dlaBonusPA) totalLines.push(`DLA +${total.dlaBonusPA} PA/tour`); if (total.dlaBonusPA) totalLines.push(`DLA ${total.dlaBonusPA > 0 ? "+" : ""}${total.dlaBonusPA} PA/tour`);
if (troll.blockCamoTurns > 0) totalLines.push("Camouflage bloqué"); if (troll.blockCamoTurns > 0) totalLines.push("Camouflage bloqué");
let html = ""; let html = "";
@@ -613,6 +613,6 @@ if (typeof module !== "undefined" && module.exports) {
POTION_IDS, POTION_DEFS, sumPotionMods, effTroll, formatPotionItem, POTION_IDS, POTION_DEFS, sumPotionMods, effTroll, formatPotionItem,
makeRandomPotion, makePotionItem, drinkPotion, tickPotionTurns, makeRandomPotion, makePotionItem, drinkPotion, tickPotionTurns,
describeActiveEffects, renderEffectsPanel, countActiveEffects, formatEffectMods, describeActiveEffects, renderEffectsPanel, countActiveEffects, formatEffectMods,
sharedMod, fmtStatLine, talentPctWithPotions, corruptionYZ, sharedMod, makeEffect, fmtStatLine, talentPctWithPotions, corruptionYZ,
}; };
} }
+171
View File
@@ -0,0 +1,171 @@
/* Parchemins MountyHall — trésors à lire, effets et durée en tours (1 tour = 1 DLA).
* Source : https://mountypedia.mountyhall.com/Mountyhall/Parchemins
* Convention temps → PA : 30 min de TOUR = +1 PA au début du tour (comme le
* Jus de Chronomètre) ; 15 min = +1 PA par tranche de 2 niveaux (arrondi sup.).
* Les effets de zone touchent le lecteur et tout monstre dans un rayon de
* SCROLL_ZONE_RADIUS cases. */
"use strict";
/* potions.js est chargé avant ce fichier dans le navigateur ; en node, require explicite. */
if (typeof module !== "undefined" && module.exports && typeof sharedMod === "undefined") {
Object.assign(globalThis, require("./potions.js"));
}
const SCROLL_ZONE_RADIUS = 3;
const SCROLL_IDS = [
"runeCyclopes", "clairvoyance", "ideesConfuses", "runeExplosive",
"planGenial", "yeuKiPic", "runeFoins",
];
function scrollRand(min, max) {
return min + Math.floor(Math.random() * (max - min + 1));
}
const SCROLL_DEFS = {
runeCyclopes: {
name: "Rune des Cyclopes", emoji: "👁️", color: "#c89048",
duration: 4,
build(_t, p, rollDice) {
const attR = sharedMod(rollDice(p, 3).total, p, 3, false);
const lines = [`ATT ${attR.line}`, `DEG +${p}`, `VUE ${p}`];
return {
log: `Rune des Cyclopes : ${lines.join(", ")} pendant 4 tours.`,
effects: [makeEffect("Rune des Cyclopes", "👁️", 4, {
attFlat: attR.value, degFlat: p, vue: -p,
}, lines)],
};
},
},
clairvoyance: {
name: "Traité de Clairvoyance", emoji: "🔮", color: "#88a8e0",
duration: 4,
build(_t, p) {
const lines = [`VUE +${p}`, `DLA +${p} PA/tour`];
return {
log: `Traité de Clairvoyance : VUE +${p}, TOUR accéléré (+${p} PA) pendant 4 tours.`,
effects: [makeEffect("Traité de Clairvoyance", "🔮", 4, {
vue: p, dlaBonusPA: p,
}, lines)],
};
},
},
ideesConfuses: {
name: "Idées Confuses", emoji: "💫", color: "#b078b0",
duration: 3,
build(_t, p, rollDice) {
const attR = sharedMod(rollDice(p, 3).total, p, 3, true);
const lines = [`ATT ${attR.line}`, `DLA ${p} PA/tour`];
return {
log: `Idées Confuses : ATT ${attR.line}, TOUR ralenti (${p} PA) pendant 3 tours.`,
effects: [makeEffect("Idées Confuses", "💫", 3, {
attFlat: attR.value, dlaBonusPA: -p,
}, lines)],
};
},
},
runeExplosive: {
name: "Rune Explosive", emoji: "💥", color: "#e06030",
duration: 0, zone: true,
build(troll, p, rollDice) {
const dmg = rollDice(2 * p, 3).total;
troll.pv = Math.max(1, troll.pv - dmg);
return {
log: `Rune Explosive : ${dmg} PV (${2 * p}D3), l'explosion ravage la zone !`,
effects: [],
zone: { type: "damage", total: dmg, label: `${2 * p}D3` },
};
},
},
planGenial: {
name: "Plan Génial", emoji: "💡", color: "#e0c048",
duration: 3,
build(_t, p, rollDice) {
const pa = Math.ceil(p / 2);
const attR = sharedMod(rollDice(p, 3).total, p, 3, false);
const lines = [`ATT ${attR.line}`, `DEG +${p}`, `DLA +${pa} PA/tour`];
return {
log: `Plan Génial : ${lines.join(", ")} pendant 3 tours.`,
effects: [makeEffect("Plan Génial", "💡", 3, {
attFlat: attR.value, degFlat: p, dlaBonusPA: pa,
}, lines)],
};
},
},
yeuKiPic: {
name: "Yeu'Ki'Pic", emoji: "🌶️", color: "#c04040",
duration: 2, zone: true,
build(_t, p) {
const malus = 3 * p;
return {
log: `Yeu'Ki'Pic : un nuage piquant ! VUE ${malus} pour toute la zone pendant 2 tours.`,
effects: [makeEffect("Yeu'Ki'Pic", "🌶️", 2, { vue: -malus })],
zone: { type: "vue", malus, turns: 2 },
};
},
},
runeFoins: {
name: "Rune des Foins", emoji: "🌾", color: "#a8a048",
duration: 3,
build(troll, p, rollDice) {
// « PV : -2/4 D3 » (Mountypedia) : interprété comme 2D3 ou 4D3 au hasard
const dice = Math.random() < 0.5 ? 2 : 4;
const dmg = rollDice(dice, 3).total;
troll.pv = Math.max(1, troll.pv - dmg);
const lines = [`DEG ${p}`, "VUE 1"];
return {
log: `Rune des Foins : atchoum ! ${dmg} PV (${dice}D3), ${lines.join(", ")} pendant 3 tours.`,
effects: [makeEffect("Rune des Foins", "🌾", 3, { degFlat: -p, vue: -1 }, lines)],
};
},
},
};
function formatScrollItem(scrollId, power) {
const def = SCROLL_DEFS[scrollId];
if (!def) return null;
return {
kind: "scroll",
scrollId,
power,
name: `${def.name} (niv. ${power})`,
emoji: def.emoji,
color: def.color,
};
}
function makeRandomScroll() {
const id = SCROLL_IDS[Math.floor(Math.random() * SCROLL_IDS.length)];
return formatScrollItem(id, scrollRand(1, 5));
}
function makeScrollItem(scrollId, power) {
if (!SCROLL_DEFS[scrollId]) return makeRandomScroll();
return formatScrollItem(scrollId, power != null ? power : scrollRand(1, 5));
}
/* Lit un parchemin. Retourne null si inconnu, sinon { zone } — game.js applique
* l'éventuel effet de zone aux monstres alentour. */
function readScroll(troll, item, rollDice, logFn) {
const def = SCROLL_DEFS[item.scrollId];
if (!def) {
logFn("Ce parchemin est illisible.", "info");
return null;
}
const p = item.power != null ? item.power : scrollRand(1, 5);
const result = def.build(troll, p, rollDice);
if (result.effects.length) {
troll.potionEffects = troll.potionEffects || [];
troll.potionEffects.push(...result.effects);
}
logFn(`📜 ${result.log}`, "good");
return { zone: result.zone || null };
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
SCROLL_IDS, SCROLL_DEFS, SCROLL_ZONE_RADIUS,
formatScrollItem, makeRandomScroll, makeScrollItem, readScroll,
};
}
+112
View File
@@ -0,0 +1,112 @@
/* Page « Trésors du Hall » : encyclopédie des potions et des parchemins,
* formules officielles de Mountypedia (X = niveau du trésor).
* Rappel : un trésor ne fait qu'un seul jet de dés — « X D3 » donne le même
* total partout où il s'applique. Partage les globales de potions.js/scrolls.js. */
"use strict";
/* [id, niveau X, durée affichée, effets] — ordre d'affichage de la page. */
const TREASURE_POTIONS = [
["biskot", "—", "2 tours", ["REG +2", "PV +2D3 (immédiat)"]],
["bonneBouffe", "3 à 7", "5 tours", ["DEG +X", "REG +X"]],
["calvok", "2 à 6", "1 tour", ["Concentration 5·X %"]],
["cervelle", "2 à 6", "1 tour", ["Concentration +5·X %"]],
["chronometre", "1 à 5", "3 tours", ["TOUR accéléré : +X PA par tour"]],
["corruption", "3 à 7", "5 tours", ["ATT +X D3", "ESQ X D3", "DEG +X", "REG X", "VUE X", "Armure +X", "RM Y %, MM Z % (selon X)"]],
["djhinTonik", "1 à 5", "4 tours", ["DEG +X", "REG +X", "PV +2D3 (immédiat)"]],
["doverPowa", "11 à 100", "2 tours", ["MM +Y %", "RM X %"]],
["fertilite", "3 à 7", "5 tours", ["ATT +X D3", "DEG +X"]],
["feu", "3 à 7", "5 tours", ["ESQ +X D3", "VUE +X"]],
["glacier", "3 à 7", "5 tours", ["REG +X", "Armure +X"]],
["grippe", "3 à 4", "3 tours", ["ATT X D3", "ESQ X D3", "DEG X", "REG X"]],
["guerison", "1 à 5", "immédiat", ["PV +2·X D3"]],
["kouleMann", "1 à 5", "4 tours", ["REG +X", "VUE +X", "PV +(X/2) D3 (immédiat)"]],
["longueVue", "1, 2, 3, 5 ou 8", "3 tours", ["VUE +X"]],
["metomol", "1 à 5", "2 tours", ["TOUR accéléré : +X PA par tour", "Armure 2·X"]],
["painture", "1 à 5", "X tours", ["Visible — camouflage impossible"]],
["pneumonie", "5 (fixe)", "3 tours", ["ATT 5 D3", "ESQ 5 D3", "DEG 5", "REG 5"]],
["pufPuff", "0 à 2", "3 tours", ["ATT X D3", "ESQ X D3", "VUE (X+1)", "PV 2D3 si X ≥ 2", "Effet de Zone"]],
["rhume", "1 à 2", "3 tours", ["ATT X D3", "ESQ X D3", "DEG X", "REG X"]],
["sangToh", "1 à 5", "4 tours", ["ATT +X D3", "ESQ +X D3", "VUE +X"]],
["sinneKhole", "11 à 100", "2 tours", ["RM +X %", "MM Y %"]],
["toxine", "1 à 5", "immédiat", ["PV 2·X D3"]],
["voiputrin", "1 à 5", "2 tours", ["VUE 10·X"]],
["zetCrak", "1 à 5", "3 tours", ["ATT X D3", "ESQ X D3", "VUE X", "PV +X D3 (immédiat)"]],
];
const TREASURE_SCROLLS = [
["runeCyclopes", "1 à 5", "4 tours", ["ATT +X D3", "DEG +X", "VUE X"]],
["clairvoyance", "1 à 5", "4 tours", ["VUE +X", "TOUR accéléré : +X PA par tour"]],
["ideesConfuses","1 à 5", "3 tours", ["ATT X D3", "TOUR ralenti : X PA par tour"]],
["runeExplosive","1 à 5", "immédiat", ["PV 2·X D3", "Effet de Zone (rayon 3 cases)"]],
["planGenial", "1 à 5", "3 tours", ["ATT +X D3", "DEG +X", "TOUR accéléré : +X/2 PA par tour"]],
["yeuKiPic", "1 à 5", "2 tours", ["VUE 3·X", "Effet de Zone (rayon 3 cases)"]],
["runeFoins", "1 à 5", "3 tours", ["DEG X", "VUE 1", "PV 2D3 ou 4D3 (immédiat)"]],
];
function treasureCard(def, x, duration, fx) {
const fxHtml = fx.map(f => {
const neg = f.includes("");
const pos = f.includes("+");
const cls = neg && !pos ? "tz-fx tz-bad" : pos && !neg ? "tz-fx tz-good" : "tz-fx";
return `<span class="${cls}">${f}</span>`;
}).join("");
return `
<div class="tz-card" style="--tz-color:${def.color}">
<div class="tz-head"><span class="tz-name">${def.emoji} ${def.name}</span></div>
<div class="tz-meta">Niveau X : <b>${x}</b> · Durée : <b>${duration}</b></div>
<div class="tz-fx-list">${fxHtml}</div>
</div>`;
}
function treasuresRender() {
const body = document.getElementById("treasures-body");
let html = `
<p class="tz-intro">Les trésors du Monde Souterrain, fidèles aux formules de la
<a href="https://mountypedia.mountyhall.com/Mountyhall/Potion" target="_blank" rel="noopener">Mountypedia</a>.
Un trésor ne fait qu'<b>un seul jet de dés</b> : une formule « X D3 » donne le même total
partout où elle s'applique. Les bonus « +X » sont des valeurs fixes ajoutées aux jets.
1 tour = 1 DLA. Boire une potion ou lire un parchemin coûte <b>1 PA</b>.</p>`;
html += `<h2 class="tz-section">🧪 Potions (${TREASURE_POTIONS.length})</h2><div class="tz-grid">`;
for (const [id, x, duration, fx] of TREASURE_POTIONS) {
html += treasureCard(POTION_DEFS[id], x, duration, fx);
}
html += "</div>";
html += `<h2 class="tz-section">📜 Parchemins standards (${TREASURE_SCROLLS.length})</h2><div class="tz-grid">`;
for (const [id, x, duration, fx] of TREASURE_SCROLLS) {
html += treasureCard(SCROLL_DEFS[id], x, duration, fx);
}
html += "</div>";
html += `<p class="tz-note">Effet de Zone : touche le lecteur et tous les monstres à 3 cases
ou moins. « PV 2D3 ou 4D3 » (Rune des Foins) : la Mountypedia note « -2/4 D3 », interprété
ici comme un tirage au sort entre 2D3 et 4D3.</p>`;
body.innerHTML = html;
}
let treasuresReturnTo = "create";
function treasuresShow(from) {
treasuresReturnTo = from;
document.getElementById("screen-" + from).classList.add("hidden");
document.getElementById("screen-treasures").classList.remove("hidden");
treasuresRender();
document.getElementById("screen-treasures").scrollTop = 0;
}
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("btn-treasures").onclick = () => treasuresShow("create");
document.getElementById("btn-treasures-game").onclick = () => treasuresShow("game");
document.getElementById("treasures-back").onclick = () => {
document.getElementById("screen-treasures").classList.add("hidden");
document.getElementById("screen-" + treasuresReturnTo).classList.remove("hidden");
};
if (new URLSearchParams(location.search).get("screen") === "treasures") {
treasuresShow("create");
}
});
}
+1 -1
View File
@@ -57,7 +57,7 @@ function validateLevel(l) {
if (!Array.isArray(l.items) || l.items.length > 60) return "objets invalides (max 60)"; if (!Array.isArray(l.items) || l.items.length > 60) return "objets invalides (max 60)";
for (const i of l.items) { for (const i of l.items) {
if (!inBounds(i) || tile(l, i) === "#") return "objet hors-sol"; if (!inBounds(i) || tile(l, i) === "#") return "objet hors-sol";
if (!["potion", "gold", "weapon", "armor"].includes(i.kind)) return "type d'objet invalide"; if (!["potion", "scroll", "gold", "weapon", "armor"].includes(i.kind)) return "type d'objet invalide";
} }
if (l.doors !== undefined && !Array.isArray(l.doors)) return "portes invalides"; if (l.doors !== undefined && !Array.isArray(l.doors)) return "portes invalides";
const doors = l.doors || []; const doors = l.doors || [];
+43 -1
View File
@@ -5,6 +5,7 @@ const assert = require("assert");
require("../js/potions.js"); require("../js/potions.js");
const g = require("../js/game.js"); const g = require("../js/game.js");
const p = require("../js/potions.js"); const p = require("../js/potions.js");
const sc = require("../js/scrolls.js");
// Dés // Dés
for (let i = 0; i < 200; i++) { for (let i = 0; i < 200; i++) {
@@ -211,6 +212,47 @@ assert.strictEqual(p.corruptionYZ(3).y, 0);
assert.strictEqual(p.effTroll(t).deg, 4, "Fertilité n'ajoute pas de dés de DEG"); assert.strictEqual(p.effTroll(t).deg, 4, "Fertilité n'ajoute pas de dés de DEG");
assert.strictEqual(p.effTroll(t).degFlat, 6, "Fertilité : DEG +6 fixe"); assert.strictEqual(p.effTroll(t).degFlat, 6, "Fertilité : DEG +6 fixe");
} }
// Parchemins : effets via le système d'effets magiques, un seul jet par parchemin
{
const fixedRoll = () => ({ total: 12, rolls: [4, 4, 4] });
const mk = () => ({ att: 3, esq: 3, deg: 3, reg: 1, vue: 3, armor: 0, armorDice: 0, degBonus: 0, pvMax: 30, pv: 20, potionEffects: [], blockCamoTurns: 0, tour: 1 });
const t1 = mk();
const r1 = sc.readScroll(t1, sc.makeScrollItem("runeCyclopes", 3), fixedRoll, () => {});
assert(r1 && !r1.zone, "Rune des Cyclopes : pas d'effet de zone");
const e1 = p.effTroll(t1);
assert.strictEqual(e1.attFlat, 12, "Rune des Cyclopes : ATT +3D3 fixé → +12");
assert.strictEqual(e1.degFlat, 3, "Rune des Cyclopes : DEG +3 fixe");
assert.strictEqual(e1.vue, 1, "Rune des Cyclopes : VUE 3 3 → min 1");
const t2 = mk();
sc.readScroll(t2, sc.makeScrollItem("ideesConfuses", 3), g.rollDice, () => {});
assert.strictEqual(p.sumPotionMods(t2.potionEffects).dlaBonusPA, -3, "Idées Confuses : 3 PA/tour");
assert(p.effTroll(t2).attFlat <= -3, "Idées Confuses : malus d'ATT");
const t3 = mk();
const r3 = sc.readScroll(t3, sc.makeScrollItem("runeExplosive", 2), fixedRoll, () => {});
assert.strictEqual(t3.pv, 8, "Rune Explosive : 20 12 PV");
assert(r3.zone && r3.zone.type === "damage" && r3.zone.total === 12, "Rune Explosive : zone de dégâts");
const t4 = mk();
const r4 = sc.readScroll(t4, sc.makeScrollItem("yeuKiPic", 2), g.rollDice, () => {});
assert(r4.zone && r4.zone.type === "vue" && r4.zone.malus === 6 && r4.zone.turns === 2, "Yeu'Ki'Pic : zone VUE 6, 2 tours");
const t5 = mk();
sc.readScroll(t5, sc.makeScrollItem("clairvoyance", 4), g.rollDice, () => {});
const e5 = p.effTroll(t5);
assert.strictEqual(e5.vue, 7, "Clairvoyance : VUE +4");
assert.strictEqual(e5.dlaBonusPA, 4, "Clairvoyance : +4 PA/tour");
// item aléatoire et via spec d'éditeur
const it = sc.makeRandomScroll();
assert.strictEqual(it.kind, "scroll");
assert(it.power >= 1 && it.power <= 5);
const fromSpec = g.itemFromSpec({ x: 1, y: 1, kind: "scroll" });
assert.strictEqual(fromSpec.kind, "scroll");
}
// Jet d'attaque : bonus potion ajouté au total des D6 // Jet d'attaque : bonus potion ajouté au total des D6
{ {
const r = g.resolveAttack({ att: 3, attFlat: 12, deg: 2, degBonus: 0 }, { esq: 1 }); const r = g.resolveAttack({ att: 3, attFlat: 12, deg: 2, degBonus: 0 }, { esq: 1 });
@@ -233,7 +275,7 @@ const goodLevel = {
name: "Test", author: "perco", grid: goodGrid, name: "Test", author: "perco", grid: goodGrid,
start: { x: 1, y: 1 }, start: { x: 1, y: 1 },
monsters: [{ x: 2, y: 2, type: 0, tpl: 1 }], monsters: [{ x: 2, y: 2, type: 0, tpl: 1 }],
items: [{ x: 3, y: 3, kind: "potion" }], items: [{ x: 3, y: 3, kind: "potion" }, { x: 4, y: 3, kind: "scroll" }],
}; };
assert.strictEqual(srv.validateLevel(goodLevel), null); assert.strictEqual(srv.validateLevel(goodLevel), null);
assert(srv.validateLevel({ ...goodLevel, name: "" }), "nom vide refusé"); assert(srv.validateLevel({ ...goodLevel, name: "" }), "nom vide refusé");