diff --git a/README.md b/README.md index c8f7db1..9ab8202 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,13 @@ non affilié au jeu original de Mountyhall SARL. ## Versions +- **1.7.0** (2026-06-11) — Équipement Mountypedia complet : 6 emplacements (Arme, + Armure, Casque, Bouclier, Talisman, Bottes) et 55 objets dropables aux valeurs + de base officielles (ATT/ESQ/DEG/REG en dés, Armure/VUE/PV fixes, RM/MM en %). + Armes à 2 mains incompatibles avec un bouclier, PV max d'équipement, tiers de + drop par profondeur, pinceaux d'éditeur par emplacement, rétrocompatibilité des + anciens niveaux publiés (specs weapon/armor mappées). Section « Équipement » + dans la page Trésors. - **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 diff --git a/css/style.css b/css/style.css index 648cc9b..330047b 100644 --- a/css/style.css +++ b/css/style.css @@ -520,3 +520,14 @@ header h1 { font-size: 1.3em; color: #8fbf5a; } } .tz-good { color: #8fbf5a; } .tz-bad { color: #d88080; } + +/* ---------- Équipement 6 emplacements ---------- */ +.eq-line { margin-bottom: 6px; line-height: 1.3; } +.eq-slot { + display: inline-block; + min-width: 72px; + color: #a89568; + font-size: .85em; +} +.eq-mods { color: #8a7a55; font-size: .78em; margin-left: 72px; } +.tz-subsection { color: #d8a040; margin: 16px 0 8px; font-size: 1.05em; } diff --git a/index.html b/index.html index 1eed649..e8c70bf 100644 --- a/index.html +++ b/index.html @@ -166,6 +166,7 @@ + diff --git a/js/editor.js b/js/editor.js index 65339d9..4d989ed 100644 --- a/js/editor.js +++ b/js/editor.js @@ -124,8 +124,9 @@ function edBuildPalette() { 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" }); - 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 })); + for (const [slot, info] of Object.entries(GEAR_SLOTS)) { + edAddBrushBtn(itemsDiv, `${info.emoji} ${info.label} (aléatoire)`, { mode: "item", kind: "gear", slot }); + } const doorsDiv = document.getElementById("ed-doors"); doorsDiv.innerHTML = ""; @@ -240,6 +241,7 @@ function edApply(x, y) { ED.items = ED.items.filter(i => i.x !== x || i.y !== y); const spec = { x, y, kind: b.kind }; if (b.idx !== undefined) spec.idx = b.idx; + if (b.slot !== undefined) spec.slot = b.slot; if (b.kind === "gold") spec.gold = 60; ED.items.push(spec); } @@ -279,7 +281,8 @@ function edRender() { : i.kind === "scroll" ? "#d8c890" : "#7a8db0"); ctx.fillStyle = "#1a140e"; 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 === "gear" ? (GEAR_SLOTS[i.slot] || GEAR_SLOTS.arme).emoji + : i.kind === "weapon" ? "🗡️" : "🦺"; ctx.fillText(emoji, i.x * TILE + TILE / 2, i.y * TILE + TILE / 2 + 1); } for (const d of ED.doors) { diff --git a/js/game.js b/js/game.js index 85e0ea3..f4c9621 100644 --- a/js/game.js +++ b/js/game.js @@ -4,7 +4,7 @@ "use strict"; -const APP_VERSION = "1.6.0"; +const APP_VERSION = "1.7.0"; /* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */ const START_COMP_PCT = 90; @@ -17,6 +17,9 @@ if (typeof module !== "undefined" && module.exports && typeof makeRandomPotion = if (typeof module !== "undefined" && module.exports && typeof makeRandomScroll === "undefined") { Object.assign(globalThis, require("./scrolls.js")); } +if (typeof module !== "undefined" && module.exports && typeof makeRandomGear === "undefined") { + Object.assign(globalThis, require("./gear.js")); +} /* ================= Dés & règles de base ================= */ @@ -201,7 +204,9 @@ function monsterFromSpec(spec) { return applyTemplate(type, tpl, spec.x, spec.y); } -/* Instancie un objet depuis une spec d'éditeur : {x, y, kind, idx?, gold?} */ +/* Instancie un objet depuis une spec d'éditeur : {x, y, kind, slot?, idx?, gold?}. + * Les specs « weapon »/« armor » à idx des anciens niveaux publiés sont mappées + * vers l'équipement Mountypedia équivalent. */ function itemFromSpec(spec) { const base = { x: spec.x, y: spec.y }; if (spec.kind === "potion") return { ...base, ...makePotionItem(spec.potionId, spec.power) }; @@ -210,8 +215,13 @@ function itemFromSpec(spec) { const gold = spec.gold || 60; return { ...base, kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold }; } - const list = spec.kind === "weapon" ? WEAPONS : ARMORS; - return { ...base, kind: "gear", ...list[(spec.idx || 0) % list.length] }; + if (spec.kind === "gear") return { ...base, ...makeRandomGear(5, spec.slot) }; + if (spec.kind === "weapon") { + const name = LEGACY_WEAPON_NAMES[(spec.idx || 0) % LEGACY_WEAPON_NAMES.length]; + return { ...base, ...gearItemByName("arme", name) }; + } + const name = LEGACY_ARMOR_NAMES[(spec.idx || 0) % LEGACY_ARMOR_NAMES.length]; + return { ...base, ...gearItemByName("armure", name) }; } /* ================= Génération du Monde Souterrain ================= */ @@ -286,30 +296,11 @@ function randomFloor(grid, taken) { /* ================= Objets ================= */ -const WEAPONS = [ - { name: "Dague Rouillée", emoji: "🗡️", slot: "weapon", bonus: 1 }, - { name: "Hache de Jet", emoji: "🪓", slot: "weapon", bonus: 2 }, - { name: "Épée Longue", emoji: "⚔️", slot: "weapon", bonus: 3 }, - { name: "Masse d'Armes", emoji: "🔨", slot: "weapon", bonus: 4 }, -]; -const ARMORS = [ - { name: "Armure de Cuir", emoji: "🦺", slot: "armor", bonus: 1 }, - { name: "Cotte de Mailles", emoji: "🥋", slot: "armor", bonus: 2 }, - { name: "Armure de Plates", emoji: "🛡️", slot: "armor", bonus: 3 }, -]; - function makeItem(depth) { const r = Math.random(); if (r < 0.3) return makeRandomPotion(); if (r < 0.42) return makeRandomScroll(); - if (r < 0.58) { - const w = WEAPONS[Math.min(WEAPONS.length - 1, Math.floor(Math.random() * (depth + 1)))]; - return { kind: "gear", ...w }; - } - if (r < 0.7) { - const a = ARMORS[Math.min(ARMORS.length - 1, Math.floor(Math.random() * depth))]; - return { kind: "gear", ...a }; - } + if (r < 0.7) return makeRandomGear(depth); const gold = rollDice(depth, 6).total * 10; return { kind: "gold", name: `${gold} Mountyzédons`, emoji: "💰", gold }; } @@ -334,7 +325,8 @@ function newGame(name, race, customLevel = null) { bought: { att: 0, esq: 0, deg: 0, reg: 0, pv: 0, vue: 0, armor: 0 }, comp: { pct: START_COMP_PCT }, sort: { pct: START_SORT_PCT }, fatigue: 0, compUsed: false, compPXTurn: false, sortPXTurn: false, - weapon: null, armorItem: null, + equip: { arme: null, armure: null, casque: null, bouclier: null, talisman: null, bottes: null }, + gearMods: null, pa: PA_PER_TURN, pi: 0, totalPI: 0, gold: 0, bag: [], camo: false, kills: 0, dla: 1, tour: 1, potionEffects: [], blockCamoTurns: 0, @@ -610,7 +602,7 @@ function attackMonster(m, opts = {}) { const cost = opts.cost ?? COSTS.attack; if (!spendPA(cost)) return false; cdStart(`⚔️ Attaque sur ${m.name}`); - cdLine(`Vous avez attaqué ${m.name} avec votre ${G.troll.weapon ? G.troll.weapon.name : "Grosse Patte de Trõll"}.`); + cdLine(`Vous avez attaqué ${m.name} avec votre ${G.troll.equip.arme ? G.troll.equip.arme.name : "Grosse Patte de Trõll"}.`); const r = resolveAttack(effTroll(G.troll), effMonster(m)); cdAttackRolls(r); if (r.hit) { @@ -967,19 +959,43 @@ function useBagItem(idx) { updateFov(); } else if (item.kind === "gear") { if (!spendPA(COSTS.equip)) return; - if (item.slot === "weapon") { - if (t.weapon) t.bag.push(t.weapon); - t.weapon = item; t.degBonus = item.bonus; - } else { - if (t.armorItem) t.bag.push(t.armorItem); - t.armorItem = item; t.armor = item.bonus; - } t.bag.splice(t.bag.indexOf(item), 1); - log(`Tu t'équipes : ${item.emoji} ${item.name} (+${item.bonus}).`, "good"); + equipGear(t, item); } afterAction(); } +/* Équipe une pièce : range l'ancienne au sac, gère l'exclusion arme à 2 mains / + * bouclier, applique le bonus de PV max et recalcule les modificateurs. */ +function equipGear(t, item) { + const unequipTo = (slot) => { + const old = t.equip[slot]; + if (!old) return; + t.equip[slot] = null; + t.bag.push(old); + if (old.mods.pv) t.pvMax = Math.max(1, t.pvMax - old.mods.pv); + log(`Tu ranges ${old.emoji} ${old.name} dans ton sac.`, "info"); + }; + if (item.slot === "arme" && item.twoHanded && t.equip.bouclier) { + unequipTo("bouclier"); + log("Une arme à deux mains ne laisse pas de place au bouclier.", "info"); + } + if (item.slot === "bouclier" && t.equip.arme && t.equip.arme.twoHanded) { + unequipTo("arme"); + log("Impossible de tenir un bouclier avec une arme à deux mains : tu la ranges.", "info"); + } + unequipTo(item.slot); + t.equip[item.slot] = item; + if (item.mods.pv) { + t.pvMax = Math.max(1, t.pvMax + item.mods.pv); + t.pv = Math.max(1, Math.min(t.pv, t.pvMax)); + } + t.gearMods = gearMods(t.equip); + const fx = formatGearMods(item.mods); + log(`Tu t'équipes : ${item.emoji} ${item.name}${fx ? ` (${fx})` : ""}.`, "good"); + if (G) updateFov(); +} + /* 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) { @@ -1301,9 +1317,14 @@ function renderPanels() { if (doorAt(t.x, t.y)) addBtn("🚪 Franchir la porte", enterDoor, true); addBtn("⏳ Passer la DLA", passDLA, true); - $("equipment").innerHTML = - `
Six emplacements : ${Object.values(GEAR_SLOTS).map(s => s.label).join(", ")}. + Les bonus ATT/ESQ/DEG/REG s'ajoutent en dés, Armure/VUE/PV sont fixes, RM/MM en %. + Une arme à 2 mains est incompatible avec un bouclier. S'équiper coûte 2 PA. + Valeurs de base de la Mountypedia (sans templates) ; les objets puissants ne se trouvent + qu'en profondeur.
`; + for (const [slot, info] of Object.entries(GEAR_SLOTS)) { + html += `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.
`; diff --git a/server.js b/server.js index 3e34486..6c55323 100644 --- a/server.js +++ b/server.js @@ -57,7 +57,7 @@ function validateLevel(l) { if (!Array.isArray(l.items) || l.items.length > 60) return "objets invalides (max 60)"; for (const i of l.items) { if (!inBounds(i) || tile(l, i) === "#") return "objet hors-sol"; - if (!["potion", "scroll", "gold", "weapon", "armor"].includes(i.kind)) return "type d'objet invalide"; + if (!["potion", "scroll", "gear", "gold", "weapon", "armor"].includes(i.kind)) return "type d'objet invalide"; } if (l.doors !== undefined && !Array.isArray(l.doors)) return "portes invalides"; const doors = l.doors || []; diff --git a/test/smoke.js b/test/smoke.js index 1642bba..692428f 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -260,9 +260,47 @@ assert.strictEqual(p.corruptionYZ(3).y, 0); assert(r.attRoll > 12, "attRoll = somme des D6 + bonus potion"); assert(r.attRoll <= 12 + 18, "attRoll borné (3D6 max 18 + 12)"); } +// Équipement Mountypedia (6 emplacements) + rétrocompat des anciens specs +const gear = require("../js/gear.js"); const sword = g.itemFromSpec({ x: 1, y: 1, kind: "weapon", idx: 2 }); assert.strictEqual(sword.kind, "gear"); -assert(sword.bonus > 0); +assert.strictEqual(sword.slot, "arme"); +assert.strictEqual(sword.name, "Épée Longue", "ancien idx 2 → Épée Longue"); +assert.strictEqual(g.itemFromSpec({ x: 1, y: 1, kind: "armor", idx: 0 }).name, "Armure de cuir"); +{ + const t = { 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, bag: [], + equip: { arme: null, armure: null, casque: null, bouclier: null, talisman: null, bottes: null }, gearMods: null }; + g.equipGear(t, gear.gearItemByName("arme", "Gourdin")); // att +2, esq −2, deg +5 + g.equipGear(t, gear.gearItemByName("armure", "Armure de cuir")); // esq +3, arm +4, rm +40 % + let e = p.effTroll(t); + assert.strictEqual(e.att, 5, "Gourdin : ATT 3 + 2 dés"); + assert.strictEqual(e.deg, 8, "Gourdin : DEG 3 + 5 dés"); + assert.strictEqual(e.esq, 4, "ESQ 3 − 2 + 3"); + assert.strictEqual(e.armor, 4, "Armure de cuir : armure +4"); + assert.strictEqual(e.rmPct, 40, "Armure de cuir : RM +40 %"); + // PV max d'équipement + g.equipGear(t, gear.gearItemByName("bouclier", "Rondache en Bois")); // pv +5 + assert.strictEqual(t.pvMax, 35, "Rondache : PV max +5"); + // arme à 2 mains : le bouclier retourne au sac + g.equipGear(t, gear.gearItemByName("arme", "Hallebarde")); + assert.strictEqual(t.equip.bouclier, null, "Hallebarde : bouclier déséquipé"); + assert(t.bag.some(i => i.name === "Rondache en Bois"), "le bouclier est dans le sac"); + assert.strictEqual(t.pvMax, 30, "PV max retombé après déséquipement de la Rondache"); + assert(t.bag.some(i => i.name === "Gourdin"), "l'ancienne arme est dans le sac"); + e = p.effTroll(t); + assert.strictEqual(e.att, 1, "Hallebarde : ATT 3 − 5 → min 1"); + assert.strictEqual(e.deg, 15, "Hallebarde : DEG 3 + 12"); + // rééquiper un bouclier range l'arme à 2 mains + g.equipGear(t, gear.gearItemByName("bouclier", "Targe")); + assert.strictEqual(t.equip.arme, null, "Targe : la Hallebarde est rangée"); + // tirage par profondeur : tier ≤ depth + 1 + for (let i = 0; i < 100; i++) { + const it = gear.makeRandomGear(1); + const def = gear.GEAR[it.slot][it.gearIdx]; + assert(def.tier <= 2, "profondeur 1 : tier ≤ 2 (reçu " + def.tier + ")"); + } +} // Validation serveur des niveaux const srv = require("../server.js");