2.4.0 : gestion du sac — déséquiper, goinfrer (MIAM/CLONK/GRRROUAR), jeter à terre, sac classé par type avec effets visibles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-12 23:23:01 +02:00
co-authored by Claude Fable 5
parent 031d64830c
commit 0ac2971f03
8 changed files with 392 additions and 46 deletions
+172 -25
View File
@@ -4,7 +4,7 @@
"use strict";
const APP_VERSION = "2.3.0";
const APP_VERSION = "2.4.0";
/* Alpha : maîtrise initiale haute pour les tests. Remettre 15 % / 15 % à la v1.0 officielle. */
const START_COMP_PCT = 90;
@@ -334,7 +334,7 @@ function makeItem(depth) {
const MAX_DEPTH = 5;
const PA_PER_TURN = 6;
const COSTS = { move: 1, attack: 3, pickup: 1, equip: 2, potion: 1, scroll: 1 };
const COSTS = { move: 1, attack: 3, pickup: 1, equip: 2, potion: 1, scroll: 1, unequip: 1, eat: 1, drop: 1 };
let G = null; // état global de la partie
@@ -987,17 +987,112 @@ function useBagItem(idx) {
afterAction();
}
/* Déséquipe une pièce : elle revient dans le sac. */
function unequipSlot(slot) {
const t = G.troll;
if (!t.equip[slot]) return;
if (!spendPA(COSTS.unequip)) return;
unequipToBag(t, slot);
t.gearMods = gearMods(t.equip);
updateFov();
afterAction();
}
/* « Goinfrer » une pièce d'équipement du sac : détruite, petit bonus aléatoire. */
function eatBagItem(idx) {
const t = G.troll;
const item = t.bag[idx];
if (!item || item.kind !== "gear") return;
if (!spendPA(COSTS.eat)) return;
t.bag.splice(idx, 1);
const r = goinfreItem(t, rollDice);
cdStart(`🍴 Goinfre : ${item.emoji} ${item.name}`);
cdLine(`<b>« ${r.cry} »</b> ${r.effect}.`);
cdLine(`<i>${r.flavor}</i>`);
cdFlush();
log(`🍴 Tu goinfres ${item.name} : « ${r.cry} » ${r.effect}.`, "good");
if (countActiveEffects(t) > 0) switchLeftTab("effects");
afterAction();
}
/* Jette un objet du sac à terre (sur la case du troll). */
function dropBagItem(idx) {
const t = G.troll;
const item = t.bag[idx];
if (!item) return;
if (G.items.some(i => i.x === t.x && i.y === t.y)) {
log("Il y a déjà quelque chose à terre ici.", "info");
return;
}
if (!spendPA(COSTS.drop)) return;
t.bag.splice(idx, 1);
G.items.push({ ...item, x: t.x, y: t.y });
log(`Tu jettes ${item.emoji} ${item.name} à terre.`, "info");
afterAction();
}
/* ---------- Sac : tri par type et effets des objets ---------- */
/* Groupes d'affichage du sac, dans l'ordre : potions, parchemins, puis
* l'équipement par emplacement. Retourne [{label, entries: [{item, idx}]}]. */
function bagGroups(bag) {
const groups = [
{ key: "potion", label: "🧪 Potions", match: i => i.kind === "potion" },
{ key: "scroll", label: "📜 Parchemins", match: i => i.kind === "scroll" },
...Object.entries(GEAR_SLOTS).map(([slot, info]) => ({
key: slot,
label: `${info.emoji} ${info.label.endsWith("s") ? info.label : info.label + "s"}`,
match: i => i.kind === "gear" && i.slot === slot,
})),
];
const out = [];
for (const grp of groups) {
const entries = bag.map((item, idx) => ({ item, idx })).filter(e => grp.match(e.item));
if (entries.length) out.push({ label: grp.label, entries });
}
// sécurité : tout objet d'un type inattendu reste visible
const known = new Set(out.flatMap(grp => grp.entries.map(e => e.idx)));
const rest = bag.map((item, idx) => ({ item, idx })).filter(e => !known.has(e.idx));
if (rest.length) out.push({ label: "❓ Divers", entries: rest });
return out;
}
/* Lignes d'effets d'un objet du sac (X remplacé par le niveau du trésor). */
function itemEffectLines(item) {
const fromTable = (table, idField) => {
const row = (table || []).find(r => r[0] === item[idField]);
if (!row) return [];
const [, , duration, lines] = row;
return [lines.map(l => l.replace(/\bX\b/g, item.power)).join(" · ") + ` (${duration})`];
};
if (item.kind === "potion") return fromTable(typeof TREASURE_POTIONS !== "undefined" ? TREASURE_POTIONS : null, "potionId");
if (item.kind === "scroll") return fromTable(typeof TREASURE_SCROLLS !== "undefined" ? TREASURE_SCROLLS : null, "scrollId");
if (item.kind === "gear") {
const fx = formatGearMods(item.mods);
return [(fx || "aucun bonus") + (item.twoHanded ? " · 2 mains" : "")];
}
return [];
}
/* Range une pièce équipée dans le sac (retire son bonus de PV max).
* Ne recalcule PAS gearMods : l'appelant s'en charge. */
function unequipToBag(t, slot, logFn = log) {
const old = t.equip[slot];
if (!old) return null;
t.equip[slot] = null;
t.bag.push(old);
if (old.mods.pv) {
t.pvMax = Math.max(1, t.pvMax - old.mods.pv);
t.pv = Math.max(1, Math.min(t.pv, t.pvMax));
}
logFn(`Tu ranges ${old.emoji} ${old.name} dans ton sac.`, "info");
return old;
}
/* É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");
};
const unequipTo = (slot) => unequipToBag(t, slot);
if (item.slot === "arme" && item.twoHanded && t.equip.bouclier) {
unequipTo("bouclier");
log("Une arme à deux mains ne laisse pas de place au bouclier.", "info");
@@ -1345,30 +1440,68 @@ function renderPanels() {
if (doorAt(t.x, t.y)) addBtn("🚪 Franchir la porte", enterDoor, true);
addBtn("⏳ Passer la DLA", passDLA, true);
$("equipment").innerHTML = Object.entries(GEAR_SLOTS).map(([slot, info]) => {
const equipEl = $("equipment");
equipEl.innerHTML = Object.entries(GEAR_SLOTS).map(([slot, info]) => {
const it = t.equip[slot];
if (!it) return `<div class="eq-line"><span class="eq-slot">${info.label}</span> —</div>`;
const fx = formatGearMods(it.mods);
return `<div class="eq-line"><span class="eq-slot">${info.label}</span> ${it.emoji} ${it.name}` +
return `<div class="eq-line"><span class="eq-slot">${info.label}</span> ${it.emoji} ${esc(it.name)}` +
`${it.twoHanded ? " <small>(2 mains)</small>" : ""}` +
` <button class="unequip-btn" data-slot="${slot}" title="Déséquiper (${COSTS.unequip} PA) : revient dans le sac">↩️</button>` +
`${fx ? `<div class="eq-mods">${fx}</div>` : ""}</div>`;
}).join("");
for (const b of equipEl.querySelectorAll(".unequip-btn")) {
b.disabled = G.over || t.pa < COSTS.unequip;
b.onclick = () => unequipSlot(b.dataset.slot);
}
const inv = $("inventory");
inv.innerHTML = "";
if (t.bag.length === 0) inv.innerHTML = '<div class="empty">— vide —</div>';
t.bag.forEach((item, idx) => {
const b = document.createElement("button");
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}`;
if (item.kind === "gear") b.title = formatGearMods(item.mods) + (item.twoHanded ? " · 2 mains" : "");
b.disabled = G.over;
b.onclick = () => useBagItem(idx);
inv.appendChild(b);
renderBag($("inventory"), t.bag, {
disabled: G.over,
pa: t.pa,
onUse: useBagItem, onEat: eatBagItem, onDrop: dropBagItem,
});
}
/* Rendu partagé du sac (solo et multi) : groupé par type, effets visibles,
* actions utiliser / goinfrer / jeter. */
function renderBag(inv, bag, opts) {
inv.innerHTML = "";
if (!bag.length) { inv.innerHTML = '<div class="empty">— vide —</div>'; return; }
for (const grp of bagGroups(bag)) {
const head = document.createElement("div");
head.className = "bag-group";
head.textContent = grp.label;
inv.appendChild(head);
for (const { item, idx } of grp.entries) {
const row = document.createElement("div");
row.className = "bag-item";
const fx = itemEffectLines(item).join(" · ");
const useLabel = item.kind === "potion" ? `🥤 boire (${COSTS.potion} PA)`
: item.kind === "scroll" ? `👁️ lire (${COSTS.scroll} PA)` : `🛡️ équiper (${COSTS.equip} PA)`;
row.innerHTML = `<div class="bag-name">${item.emoji} ${esc(item.name)}</div>` +
(fx ? `<div class="eq-mods">${esc(fx)}</div>` : "") +
`<div class="bag-actions"></div>`;
const actions = row.querySelector(".bag-actions");
const addBtn = (label, title, fn, enabled) => {
const b = document.createElement("button");
b.textContent = label;
b.title = title;
b.disabled = opts.disabled || !enabled;
b.onclick = fn;
actions.appendChild(b);
};
addBtn(useLabel, "", () => opts.onUse(idx), opts.pa >= (item.kind === "gear" ? COSTS.equip : 1));
if (item.kind === "gear") {
addBtn(`🍴 goinfrer (${COSTS.eat} PA)`, "Dévorer l'objet (détruit) : petit bonus aléatoire MIAM / CLONK / GRRROUAR",
() => opts.onEat(idx), opts.pa >= COSTS.eat);
}
addBtn(`🗑️ jeter (${COSTS.drop} PA)`, "Jeter l'objet à terre sur ta case",
() => opts.onDrop(idx), opts.pa >= COSTS.drop);
inv.appendChild(row);
}
}
}
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
@@ -1491,6 +1624,20 @@ if (typeof document !== "undefined") {
if (G.monsters.includes(m)) useSort();
}
}
// ?demo=sac : remplit le sac et équipe deux pièces (captures du sac trié)
if (params.get("demo") === "sac") {
const t = G.troll;
equipGear(t, gearItemByName("arme", "Gourdin"));
equipGear(t, gearItemByName("armure", "Armure de cuir"));
t.bag.push(
makePotionItem("guerison", 3), makePotionItem("feu", 5),
formatScrollItem("runeExplosive", 2),
gearItemByName("arme", "Épée Courte"), gearItemByName("casque", "Casque à Cornes"),
gearItemByName("bottes", GEAR.bottes[0].name),
);
render();
renderPanels();
}
}
$("btn-restart").onclick = () => {
$("screen-end").classList.add("hidden");
@@ -1509,7 +1656,7 @@ if (typeof document !== "undefined") {
if (typeof module !== "undefined" && module.exports) {
module.exports = {
APP_VERSION, rollDice, resolveAttack, resolveSpell, masteryRoll, improveCost, levelFromTotalPI, killPX,
RACES, MONSTER_TYPES, BOSS, TEMPLATES, applyTemplate, makeMonster, monsterFromSpec, itemFromSpec, equipGear,
RACES, MONSTER_TYPES, BOSS, TEMPLATES, applyTemplate, makeMonster, monsterFromSpec, itemFromSpec, equipGear, unequipToBag,
generateCavern, largestRegion,
MAP_W, MAP_H, T_WALL, T_FLOOR, T_STAIRS,
COSTS, PA_PER_TURN, START_COMP_PCT, START_SORT_PCT,