Salahzar's Weblog

A collection of my posts in the web

Il dungeon che non ho scritto

C’è un’immagine che non se ne va: Ivrea, 1981, laboratorio Olivetti. I computer avevano ancora l’odore dei trasformatori appena scaldati, quel profumo di futuro che oggi chiameremmo “vintage” ma allora era semplicemente il presente. Mi collegavo — di nascosto, tra una pausa e l’altra — a un server a Cupertino. L’America dall’altra parte del cavo. E sullo schermo compariva Rogue: un dungeon fatto di lettere, numeri, simboli @ che combattevano E. Niente grafica, niente audio, niente installazione. Solo logica, rischio, e l’immaginazione che riempiva i buchi.

Quarantatré anni dopo ho aperto un file HTML che ho diretto ma non scritto. Non so scrivere JavaScript come si deve, non conosco i segreti del canvas, non saprei ottimizzare un algoritmo di pathfinding se mi pagassero. Eppure nel documento c’è un dungeon completo: generazione procedurale della mappa, nebbia di guerra, IA dei nemici, suoni sintetizzati al volo con oscillatori. Zero dipendenze esterne. Zero framework. Zero immagini.

L’ho costruito parlando con Opus 4.6. Non “usando un tool”. Parlando. Descrivendo cosa volevo, correggendo quando il codice deragliava, chiedendo spiegazioni su parti che non capivo, insistendo su dettagli che a un ingegnere sembrerebbero insignificanti. È una forma di regia tecnica: non devi sapere come si fa la focaccia per capire se lievita troppo poco, ma devi sapere cosa vuoi che succeda in bocca.

Ma qui la storia tocca un nervo che in aula sento vibrare dal 2011. Quando portavo Second Life nelle scuole, la prima domanda era sempre: “Ma lei sa programmare?” Come se “sapere” fosse un binario acceso/spento. Come se usare uno strumento senza capirne ogni ingranaggio fosse una forma di impostura. Oggi la domanda è cambiata — “Ma l’AI non ci ruberà il lavoro?” — ma l’ansia è la stessa: la paura di essere sorpassati da chi “sa”. Fantozzi al timbracartellino è questa ansia esatta, condensata in un gesto: il corpo ridotto a funzione, il tempo scandito da una macchina che non capisci ma che capisce te. Villaggio nel 1975 filmava l’automazione prima che avessimo il vocabolario per nominarla. La differenza con gli algoritmi di oggi? Nessuna. Allora eri ridotto a numero da un cartoncino. Oggi da una metrica su un cruscotto: produttività, coinvolgimento, tempo-su-compito. Cambia il supporto, non la sostanza. E la via d’uscita non è rifiutare la macchina — è rifiutare di essere chi la subisce passivamente.

Il codice che ho diretto, quello che non ho scritto, è una risposta pratica a questa passività. Mostra che la sovranità tecnica non sta nel conoscere ogni parola di un linguaggio, ma nel saper guidare il senso. Nell’essere capaci di dire: “Qui no, qui sì, qui più a sinistra”. Il mio ruolo è stato quello del copista medievale che non inventa il testo, ma decide quali varianti conservare e quali scartare. Lectio difficilior potior: quando l’output è troppo liscio, troppo pulito, troppo “corretto”, devi sospettare. Devi chiedere che inciampi. Che abbia un difetto che lo renda riconoscibile.

Detto così suona quasi nobile. Ma c’è una distanza enorme tra il mio “dirigere” e quello che un docente che si avvicina oggi potrebbe fare con lo stesso strumento. Io ho quarant’anni di frequentazione con le macchine. So riconoscere quando un output ha la faccia sbagliata anche senza capire perché ha la faccia sbagliata — come un medico che ha visto abbastanza pazienti da sentire che qualcosa non va prima dell’esame. Un insegnante che apre ChatGPT per la prima volta non ha questo repertorio. Rischia di accettare il primo risultato perché sembra plausibile, e il plausibile è la specialità della casa di questi modelli. La regia presuppone aver visto abbastanza film da sapere quando una scena non funziona. Non si improvvisa.

E qui entra il dungeon come oggetto, non come metafora. Ogni partita genera una mappa diversa — stanze, corridoi, angoli ciechi che non esistevano un secondo prima. Vedi solo ciò che è vicino: il resto è nebbia, si scopre camminando. Quando chiedi un suggerimento, l’algoritmo ti indica la prossima casella, non la mappa intera. E quell’estetica — il verde fosforescente, le scanline, la vignettatura — non è nostalgia. È ergonomia percettiva: l’occhio riconosce uno spazio quando somiglia ai suoi ricordi.

Ma c’è un dettaglio che mi piace particolarmente. L’IA dei nemici. È semplice, quasi ingenua: ti insegue se ti vede, si ferma se c’è un muro. Non usa il pathfinding completo che ho chiesto per i suggerimenti. È limitata, prevedibile, un po’ goffa. Eppure funziona. Ti costringe a pensare, a non dare nulla per scontato. In classe funziona allo stesso modo: non serve l’intervento sofisticato, serve quello calibrato. Quello che si attiva solo quando necessario.

Prima che questo diventi un trattato di filologia applicata all’ingegneria del prompt, il momento basso: non ho capito tutto del codice che ho diretto. Ci sono parti — quelle sulle funzioni d’onda per l’audio, o sul calcolo esatto della distanza nella proiezione delle ombre — dove mi sono fidato. Ho detto “sì, va bene”, come quando De Sica diceva “alzate la luce” senza sapere gli ampere esatti. Il risultato conta, ma conta anche l’onestà nel dire: qui ho dovuto credere al mio interlocutore artificiale. Non è un merito. È un limite che ho saputo gestire perché ne avevo altri che compensavano. Chi non ha quei quarant’anni di compensazione deve costruirseli — e il primo passo è sapere che servono.

Per gli insegnanti che leggono, questo è il punto. Non dovete diventare programmatori. Dovete diventare registi, critici, curatori. Chi sa chiedere, chi sa dire di no, chi sa riconoscere quando una risposta è troppo liscia per essere vera. Ma non fidatevi di chi vi dice che basta “chiedere bene” all’AI. Chiedere bene richiede aver capito male abbastanza volte. Richiede un repertorio di fallimenti da cui pescare. Costruitevelo — con pazienza, con errori, con la curiosità di smontare le cose anche quando funzionano.

Il file HTML è lì, leggero come una pagina di appunti. Si può aprire, smontare, ricostruire. Ogni volta che lo apro, vedo il terminale verde del 1981. E vedo anche il 2026, dove finalmente possiamo dire: so cosa voglio, anche se non so ancora come farlo. E qualcuno — o qualcosa — mi aiuta a scoprirlo.

Il dungeon è infinito. L’ingresso è sempre lo stesso: la curiosità di sapere cosa c’è dietro l’angolo.

Appendice: Il codice quasi 900 righe un miracolo di ingegneria

<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>&#x2620; DUNGEON</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap');

  :root {
    --bg:    #050a05;
    --green: #39ff5a;
    --mid:   #1a7a2a;
    --dark:  #0d3d14;
    --dim:   #0a1a0a;
    --gold:  #f0c040;
    --red:   #ff3333;
    --blue:  #44aaff;
    --purple:#cc66ff;
  }
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    background: var(--bg);
    color: var(--green);
    font-family: 'Share Tech Mono', monospace;
    min-height: 100dvh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    overflow: hidden;
  }

  body::before {
    content:''; position:fixed; inset:0; pointer-events:none; z-index:10;
    background: repeating-linear-gradient(0deg,
      transparent, transparent 2px,
      rgba(0,0,0,.15) 2px, rgba(0,0,0,.15) 4px);
  }
  body::after {
    content:''; position:fixed; inset:0; pointer-events:none; z-index:11;
    background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,.75) 100%);
  }

  #shell {
    display: flex;
    flex-direction: column;
    border: 1px solid var(--dark);
    box-shadow: 0 0 30px #39ff5a22, inset 0 0 60px #00000088;
    max-width: 100vw;
    transition: transform 0.05s;
  }

  #hud {
    display: flex;
    align-items: center;
    gap: .7rem;
    padding: .3rem .6rem;
    background: var(--dim);
    border-bottom: 1px solid var(--dark);
    font-size: clamp(.6rem, 1.8vw, .75rem);
    flex-wrap: wrap;
    white-space: nowrap;
  }
  #hud span { color: var(--mid); }
  #hud b    { color: var(--green); text-shadow: 0 0 6px #39ff5a88; }
  .hp-val   { color: var(--red)  !important; text-shadow: 0 0 8px #ff333888 !important; }
  .gd-val   { color: var(--gold) !important; text-shadow: 0 0 8px #f0c04088 !important; }
  .kill-val { color: var(--red)  !important; text-shadow: 0 0 8px #ff333888 !important; }
  .atk-val  { color: var(--purple) !important; text-shadow: 0 0 8px #cc66ff88 !important; }
  #h-msg    { margin-left: auto; font-size: .75em; min-width: 10ch; text-align: right; }

  /* HP BAR */
  #hp-bar-wrap {
    width: 60px; height: 6px;
    background: #1a0a0a; border: 1px solid #331111;
    border-radius: 2px; overflow: hidden;
  }
  #hp-bar {
    height: 100%; background: var(--red);
    transition: width 0.3s, background 0.3s;
    box-shadow: 0 0 4px #ff333388;
  }

  canvas { display: block; cursor: crosshair; }

  #bar {
    display: flex;
    padding: .25rem .6rem;
    background: var(--dim);
    border-top: 1px solid var(--dark);
    font-size: clamp(.55rem, 1.6vw, .65rem);
    color: var(--mid);
    gap: .5rem;
    flex-wrap: wrap;
    align-items: center;
  }
  #bar kbd {
    background: var(--dark); color: var(--green);
    border: 1px solid var(--mid); padding: 0 .2em; border-radius: 2px;
    font-family: inherit; font-size: .9em;
  }
  #hint-btn {
    margin-left: auto;
    background: none; border: 1px solid var(--gold); color: var(--gold);
    font-family: inherit; font-size: clamp(.55rem, 1.6vw, .65rem);
    padding: .1em .6em; cursor: pointer; border-radius: 3px;
  }
  #hint-btn:hover { background: #f0c04022; }

  /* D-PAD mobile */
  #dpad {
    display: none;
    grid-template-areas: ". u ." "l atk r" "h d x";
    grid-template-columns: repeat(3, 1fr);
    gap: 4px;
    padding: .4rem;
    background: var(--dim);
    border-top: 1px solid var(--dark);
    touch-action: none;
  }
  #dpad button {
    background: var(--dark); border: 1px solid var(--mid);
    color: var(--green); font-family: inherit;
    font-size: 1.2rem; padding: .5rem;
    cursor: pointer; border-radius: 4px;
    -webkit-tap-highlight-color: transparent;
    user-select: none;
  }
  #dpad button:active { background: #0d3d14; }
  #dpad .btn-atk  { color: var(--red);  border-color: var(--red);  font-size: .75rem; }
  #dpad .btn-hint { color: var(--gold); border-color: var(--gold); font-size: .75rem; }
  #dpad .btn-exit { color: var(--mid);  border-color: var(--mid);  font-size: .75rem; }
  @media (pointer: coarse) { #dpad { display: grid; } }

  /* OVERLAY */
  #overlay {
    position: fixed; inset: 0; z-index: 200;
    display: flex; flex-direction: column;
    align-items: center; justify-content: center;
    background: rgba(0,0,0,.92); gap: .8rem; text-align: center; padding: 1rem;
  }
  #overlay.hidden { display: none; }
  #overlay h1 {
    font-size: clamp(1.8rem, 8vw, 3.5rem);
    letter-spacing: .2em;
    animation: flicker 3s infinite;
  }
  #overlay .sub { color: var(--mid); font-size: .75rem; letter-spacing: .1em; }
  #overlay .stat { color: var(--gold); font-size: .8rem; line-height: 1.6; }
  #overlay .tip { color: var(--mid); font-size: .65rem; max-width: 32ch; line-height: 1.4; }
  #overlay button {
    padding: .5em 2.5em; background: none;
    border: 1px solid var(--green); color: var(--green);
    font-family: inherit; font-size: .9rem; letter-spacing: .1em;
    cursor: pointer; text-shadow: 0 0 6px #39ff5a88;
    box-shadow: 0 0 10px #39ff5a44;
  }
  #overlay button:hover { background: var(--dark); }

  @keyframes flicker {
    0%,94%,100%{opacity:1} 95%,97%{opacity:.15} 96%,98%{opacity:.7}
  }
</style>
</head>
<body>

<div id="overlay">
  <h1 id="ov-title" style="color:var(--green)">&#x2620; DUNGEON</h1>
  <div id="ov-sub" class="sub"></div>
  <div id="ov-stat" class="stat"></div>
  <div id="ov-tip" class="tip"></div>
  <button id="ov-btn">INIZIA</button>
</div>

<div id="shell">
  <div id="hud">
    <span>P<b id="h-lvl">1</b></span>
    <span>HP <b class="hp-val" id="h-hp">30</b></span>
    <div id="hp-bar-wrap"><div id="hp-bar"></div></div>
    <span>ORO <b class="gd-val" id="h-gold">0</b></span>
    <span>ATK <b class="atk-val" id="h-atk">10</b></span>
    <span>&#x2620;<b class="kill-val" id="h-kills">0</b></span>
    <div id="h-msg"></div>
  </div>
  <canvas id="cv"></canvas>
  <div id="bar">
    <span><kbd>&#x2191;</kbd><kbd>&#x2190;</kbd><kbd>&#x2193;</kbd><kbd>&#x2192;</kbd> muovi</span>
    <span><kbd>T</kbd> attacca</span>
    <span><kbd>H</kbd> hint</span>
    <span><kbd>X</kbd> esci</span>
    <span style="color:#f0c040">$ oro</span>
    <span style="color:#ff3333">E</span>
    <span style="color:#44aaff">&gt; scale</span>
    <span style="color:#39ff5a">+ vita</span>
    <span style="color:#cc66ff">&#x2020; arma</span>
    <button id="hint-btn">&#x1F4A1; HINT</button>
  </div>
  <div id="dpad">
    <button style="grid-area:u" data-k="w">&#x25B2;</button>
    <button style="grid-area:l" data-k="a">&#x25C0;</button>
    <button style="grid-area:r" data-k="d">&#x25B6;</button>
    <button style="grid-area:d" data-k="s">&#x25BC;</button>
    <button style="grid-area:atk" class="btn-atk" id="btn-atk">&#x2694; ATK</button>
    <button style="grid-area:h"   class="btn-hint" id="btn-hint">&#x1F4A1;</button>
    <button style="grid-area:x"   class="btn-exit" id="btn-exit">&#x2715;</button>
  </div>
</div>

<script>
/* ═══════════════════════════════════════════════════════════════════════════ */
var MW = 50, MH = 18, HP_MAX = 30, VISION = 5;
var WALL = '#', FLOOR = '.', GOLD = '$', ENEMY = 'E', STAIR = '>';
var POTION = '+', WEAPON = '\u2020';

var COLORS = {};
COLORS['#'] = '#0d2010'; COLORS['.'] = '#0d3d14'; COLORS['$'] = '#f0c040';
COLORS['E'] = '#ff3333'; COLORS['>'] = '#44aaff'; COLORS['@'] = '#39ff5a';
COLORS['+'] = '#39ff5a'; COLORS['\u2020'] = '#cc66ff';

var GLOW = {};
GLOW['$']='#f0c04099'; GLOW['E']='#ff333399'; GLOW['>']='#44aaff99';
GLOW['@']='#39ff5a99'; GLOW['+']='#39ff5a99'; GLOW['\u2020']='#cc66ff99';

var FOG_WALL = '#0a120a';
var FOG_FLOOR = '#081008';
var UNSEEN = '#050a05';

/* ═══════════════════════════════════════════════════════════════════════════ */
var cv  = document.getElementById('cv');
var ctx = cv.getContext('2d');
var CW, CH;

function resize() {
  var hudH = document.getElementById('hud').offsetHeight +
             document.getElementById('bar').offsetHeight + 2;
  var dpadH = window.matchMedia('(pointer:coarse)').matches ? 160 : 0;
  var availW = window.innerWidth - 4;
  var availH = window.innerHeight - hudH - dpadH - 8;
  var byW = Math.floor(availW / MW);
  var byH = Math.floor(availH / MH);
  CW = Math.max(7, Math.min(byW, Math.round(byH / 1.55)));
  CH = Math.max(11, Math.round(CW * 1.55));
  cv.width  = CW * MW;
  cv.height = CH * MH;
  cv.style.width  = cv.width  + 'px';
  cv.style.height = cv.height + 'px';
  ctx.textBaseline = 'top';
  setFont();
  if (map) fullDraw();
}
function setFont() {
  ctx.font = Math.floor(CH * 0.88) + "px 'Share Tech Mono', monospace";
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* AUDIO - tiny synth beeps                                                   */
/* ═══════════════════════════════════════════════════════════════════════════ */
var audioCtx = null;
function initAudio() {
  if (!audioCtx) {
    try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e) {}
  }
}
function beep(freq, dur, vol, type) {
  if (!audioCtx) return;
  try {
    var osc = audioCtx.createOscillator();
    var gain = audioCtx.createGain();
    osc.type = type || 'square';
    osc.frequency.value = freq;
    gain.gain.value = vol || 0.08;
    gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
    osc.connect(gain); gain.connect(audioCtx.destination);
    osc.start(); osc.stop(audioCtx.currentTime + dur);
  } catch(e) {}
}
function sndStep()   { beep(200, 0.04, 0.03); }
function sndGold()   { beep(880, 0.08, 0.06); beep(1100, 0.12, 0.05); }
function sndHit()    { beep(120, 0.15, 0.1, 'sawtooth'); }
function sndKill()   { beep(300, 0.06, 0.07); beep(500, 0.1, 0.06); }
function sndPotion() { beep(660, 0.1, 0.06); beep(880, 0.15, 0.05); }
function sndWeapon() { beep(440, 0.08, 0.07); beep(660, 0.08, 0.06); beep(880, 0.12, 0.05); }
function sndStair()  { beep(330, 0.1, 0.06); beep(440, 0.1, 0.05); beep(660, 0.15, 0.05); }
function sndDeath()  { beep(200, 0.2, 0.1, 'sawtooth'); beep(100, 0.4, 0.08, 'sawtooth'); }
function sndMiss()   { beep(150, 0.1, 0.06, 'sawtooth'); }

/* ═══════════════════════════════════════════════════════════════════════════ */
/* PRNG                                                                       */
/* ═══════════════════════════════════════════════════════════════════════════ */
function mkRng(seed) {
  var s = (seed | 0) || 1;
  return {
    next: function() { s ^= s<<13; s ^= s>>17; s ^= s<<5; return (s>>>0)/4294967296; },
    int: function(a,b) { return a + Math.floor(this.next()*(b-a+1)); },
    shuffle: function(arr) {
      for (var i=arr.length-1; i>0; i--) {
        var j=this.int(0,i); var tmp=arr[i]; arr[i]=arr[j]; arr[j]=tmp;
      } return arr;
    }
  };
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* MAP GEN with difficulty scaling                                            */
/* ═══════════════════════════════════════════════════════════════════════════ */
function genMap(seed, floor) {
  var rng = mkRng(seed);
  var m = [];
  for (var r=0; r<MH; r++) { m[r]=[]; for(var c=0; c<MW; c++) m[r][c]=WALL; }

  m[1][1] = FLOOR;
  var stack = [[1,1]];
  while (stack.length) {
    var cur = stack[stack.length-1];
    var cx=cur[0], cy=cur[1];
    var dirs = rng.shuffle([[0,2],[0,-2],[2,0],[-2,0]]);
    var carved = false;
    for (var di=0; di<dirs.length; di++) {
      var dx=dirs[di][0], dy=dirs[di][1];
      var nx=cx+dx, ny=cy+dy;
      if (nx>0 && nx<MW-1 && ny>0 && ny<MH-1 && m[ny][nx]===WALL) {
        m[ny][nx] = FLOOR;
        m[cy+Math.sign(dy)][cx+Math.sign(dx)] = FLOOR;
        stack.push([nx,ny]);
        carved = true;
        break;
      }
    }
    if (!carved) stack.pop();
  }

  // Difficulty scaling
  var nGold   = Math.max(4, 8 - Math.floor(floor / 3));
  var nEnemy  = Math.min(12, 4 + Math.floor(floor * 0.8));
  var nPotion = Math.max(1, 3 - Math.floor(floor / 4));
  var nWeapon = (floor % 3 === 0) ? 1 : 0; // weapon every 3 floors

  var pool = [];
  for (var y=0; y<MH; y++)
    for (var x=0; x<MW; x++)
      if (m[y][x]===FLOOR && !(x===1 && y===1)) pool.push([x,y]);
  rng.shuffle(pool);

  var idx = 0;
  for (var i=0; i<nGold   && idx<pool.length; i++, idx++) m[pool[idx][1]][pool[idx][0]] = GOLD;
  for (var i=0; i<nEnemy  && idx<pool.length; i++, idx++) m[pool[idx][1]][pool[idx][0]] = ENEMY;
  for (var i=0; i<nPotion && idx<pool.length; i++, idx++) m[pool[idx][1]][pool[idx][0]] = POTION;
  for (var i=0; i<nWeapon && idx<pool.length; i++, idx++) m[pool[idx][1]][pool[idx][0]] = WEAPON;
  if (idx < pool.length) m[pool[idx][1]][pool[idx][0]] = STAIR;

  return m;
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* ENEMY SYSTEM - enemies have HP and move toward player                      */
/* ═══════════════════════════════════════════════════════════════════════════ */
var enemies = [];

function spawnEnemies() {
  enemies = [];
  for (var y=0; y<MH; y++)
    for (var x=0; x<MW; x++)
      if (map[y][x] === ENEMY)
        enemies.push({ x:x, y:y, hp: 1 + Math.floor(lvl / 2) });
}

function enemyTurn() {
  for (var i = enemies.length-1; i >= 0; i--) {
    var e = enemies[i];
    if (e.hp <= 0) continue;
    // Only move if within extended vision range
    var dist = Math.abs(e.x - px) + Math.abs(e.y - py);
    if (dist > VISION + 2) continue;
    if (dist <= 1) {
      // Adjacent: attack player
      var dmg = 3 + Math.floor(lvl * 0.5);
      hp -= dmg;
      showMsg('Nemico attacca! -' + dmg + ' HP', '#ff3333');
      sndHit();
      screenShake();
      updateHUD();
      if (hp <= 0) { alive = false; sndDeath(); showOverlay(false); return; }
      continue;
    }
    // Simple chase: move one step closer
    var bestD = dist;
    var bestX = e.x, bestY = e.y;
    var moves = [[0,-1],[0,1],[-1,0],[1,0]];
    for (var mi=0; mi<4; mi++) {
      var nx = e.x + moves[mi][0], ny = e.y + moves[mi][1];
      if (nx<0||nx>=MW||ny<0||ny>=MH) continue;
      if (map[ny][nx] !== FLOOR) continue;
      if (nx===px && ny===py) continue; // don't move onto player
      // check no other enemy there
      var blocked = false;
      for (var j=0; j<enemies.length; j++) {
        if (j!==i && enemies[j].hp>0 && enemies[j].x===nx && enemies[j].y===ny) { blocked=true; break; }
      }
      if (blocked) continue;
      var nd = Math.abs(nx-px) + Math.abs(ny-py);
      if (nd < bestD) { bestD=nd; bestX=nx; bestY=ny; }
    }
    if (bestX !== e.x || bestY !== e.y) {
      map[e.y][e.x] = FLOOR;
      e.x = bestX; e.y = bestY;
      map[e.y][e.x] = ENEMY;
    }
  }
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* FOG OF WAR                                                                 */
/* ═══════════════════════════════════════════════════════════════════════════ */
var visible;  // currently visible
var explored; // ever seen

function resetFog() {
  visible = [];
  explored = [];
  for (var y=0; y<MH; y++) {
    visible[y] = new Uint8Array(MW);
    explored[y] = new Uint8Array(MW);
  }
}

function updateFog() {
  // Clear visible
  for (var y=0; y<MH; y++)
    for (var x=0; x<MW; x++) visible[y][x] = 0;

  // Simple shadowcast approximation: BFS from player within VISION
  var q = [[px, py]];
  visible[py][px] = 1; explored[py][px] = 1;
  var head = 0;
  while (head < q.length) {
    var cx = q[head][0], cy = q[head][1];
    head++;
    var dist = Math.abs(cx-px) + Math.abs(cy-py);
    if (dist >= VISION) continue;
    var nb = [[0,-1],[0,1],[-1,0],[1,0],[1,1],[1,-1],[-1,1],[-1,-1]];
    for (var ni=0; ni<8; ni++) {
      var nx = cx+nb[ni][0], ny = cy+nb[ni][1];
      if (nx<0||nx>=MW||ny<0||ny>=MH) continue;
      if (visible[ny][nx]) continue;
      visible[ny][nx] = 1;
      explored[ny][nx] = 1;
      if (map[ny][nx] !== WALL) q.push([nx, ny]);
    }
  }
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* BFS PATHFINDER                                                             */
/* ═══════════════════════════════════════════════════════════════════════════ */
function bfsPath(sx, sy, target) {
  var tx=-1, ty=-1;
  for (var y=0; y<MH; y++) {
    for (var x=0; x<MW; x++) {
      if (map[y][x]===target) { tx=x; ty=y; break; }
    }
    if (tx>=0) break;
  }
  if (tx<0) return null;
  var vis=[]; var prev=[];
  for (var y2=0; y2<MH; y2++) { vis[y2]=new Uint8Array(MW); prev[y2]=[]; for(var x2=0;x2<MW;x2++) prev[y2][x2]=null; }
  var q=[[sx,sy]]; vis[sy][sx]=1;
  while(q.length) {
    var c=q.shift(); var cx2=c[0], cy2=c[1];
    if(cx2===tx&&cy2===ty) {
      var path=[]; var rx=tx, ry=ty;
      while(rx!==sx||ry!==sy) { path.push([rx,ry]); var p=prev[ry][rx]; rx=p[0]; ry=p[1]; }
      path.reverse(); return path;
    }
    var nb2=[[0,-1],[0,1],[-1,0],[1,0]];
    for(var ni2=0;ni2<4;ni2++) {
      var nnx=cx2+nb2[ni2][0], nny=cy2+nb2[ni2][1];
      if(nnx>=0&&nnx<MW&&nny>=0&&nny<MH&&!vis[nny][nnx]&&map[nny][nnx]!==WALL) {
        vis[nny][nnx]=1; prev[nny][nnx]=[cx2,cy2]; q.push([nnx,nny]);
      }
    }
  }
  return null;
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* HINT                                                                       */
/* ═══════════════════════════════════════════════════════════════════════════ */
var hintCells=[], hintFade=null;

function doHint() {
  if(!alive||!map) return;
  clearHint();
  var pathS=bfsPath(px,py,STAIR);
  var pathG=bfsPath(px,py,GOLD);
  var pathP=bfsPath(px,py,POTION);
  if(!pathS&&!pathG&&!pathP) { showMsg('Nessun percorso!','#ff3333'); return; }
  var trail=pathS||pathG;
  hintCells=trail.slice(0,8);
  setFont();
  for(var i=0;i<hintCells.length;i++) drawHintDot(hintCells[i][0],hintCells[i][1]);
  var msg='';
  if(pathS) msg+='\u25B6'+pathS.length;
  if(pathG) msg+=(msg?' ':'')+'$'+pathG.length;
  if(pathP) msg+=(msg?' ':'')+'+'+pathP.length;
  showMsg(msg,'#f0c040');
  hintFade=setTimeout(clearHint,3000);
}
function drawHintDot(x,y) {
  var px2=x*CW, py2=y*CH;
  ctx.fillStyle='#3a2a00'; ctx.fillRect(px2,py2,CW,CH);
  ctx.shadowColor='#f0c040cc'; ctx.shadowBlur=8;
  ctx.fillStyle='#f0c040'; ctx.fillText('\u2022',px2+1,py2+1);
  ctx.shadowBlur=0; ctx.shadowColor='transparent';
}
function clearHint() {
  if(hintFade){clearTimeout(hintFade);hintFade=null;}
  if(!map) return;
  setFont();
  for(var i=0;i<hintCells.length;i++) {
    var hx=hintCells[i][0], hy=hintCells[i][1];
    drawCellFog(hx,hy);
  }
  hintCells=[];
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* ATTACK                                                                     */
/* ═══════════════════════════════════════════════════════════════════════════ */
function doAttack() {
  if(!alive||!map) return;
  var adj=[[0,-1],[0,1],[-1,0],[1,0]];
  var hit=false;
  for(var i=0;i<adj.length;i++) {
    var ex=px+adj[i][0], ey=py+adj[i][1];
    if(ex<0||ex>=MW||ey<0||ey>=MH) continue;
    if(map[ey][ex]!==ENEMY) continue;
    hit=true;
    // Find enemy object
    var eObj=null;
    for(var ei=0;ei<enemies.length;ei++) {
      if(enemies[ei].x===ex && enemies[ei].y===ey && enemies[ei].hp>0) { eObj=enemies[ei]; break; }
    }
    if(!eObj) { // fallback: no object found, just kill
      map[ey][ex]=FLOOR; kills++; gold+=5; sndKill();
      showMsg('\u2694 Ucciso! +5 oro','#ff3333');
      break;
    }
    if(Math.random() < 0.15) {
      // Miss
      sndMiss();
      showMsg('Mancato!','#ff3333');
      screenShake();
    } else {
      eObj.hp -= atkPow;
      if(eObj.hp <= 0) {
        map[ey][ex]=FLOOR;
        kills++;
        var reward = 5 + lvl;
        gold += reward;
        sndKill();
        showMsg('\u2694 Ucciso! +'+reward+' oro','#ff3333');
        flashCell(ex,ey);
      } else {
        sndHit();
        showMsg('Colpo! Nemico HP:'+eObj.hp,'#ff3333');
        flashCell(ex,ey);
      }
    }
    screenShake();
    break;
  }
  if(!hit) showMsg('Nessun nemico vicino','#1a7a2a');
  // Enemy turn after attack
  if(hit && alive) enemyTurn();
  updateHUD();
  if(alive) fullDraw();
  if(hp<=0) { alive=false; sndDeath(); showOverlay(false); }
}

function flashCell(x,y) {
  var px2=x*CW, py2=y*CH;
  ctx.fillStyle='#ff333366';
  ctx.fillRect(px2,py2,CW,CH);
}

function screenShake() {
  var shell = document.getElementById('shell');
  shell.style.transform = 'translate(' + (Math.random()*6-3) + 'px,' + (Math.random()*6-3) + 'px)';
  setTimeout(function() { shell.style.transform = 'none'; }, 80);
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* RENDER with FOG                                                            */
/* ═══════════════════════════════════════════════════════════════════════════ */
function drawCellFog(x,y) {
  var px2=x*CW, py2=y*CH;
  var isPlayer = (x===px && y===py);
  var ch = isPlayer ? '@' : map[y][x];

  if (visible[y][x]) {
    // Fully visible
    ctx.fillStyle='#050a05';
    ctx.fillRect(px2,py2,CW,CH);
    ctx.fillStyle=COLORS[ch]||'#39ff5a';
    if(GLOW[ch]) { ctx.shadowColor=GLOW[ch]; ctx.shadowBlur=(ch==='@')?10:6; }
    else { ctx.shadowColor='transparent'; ctx.shadowBlur=0; }
    ctx.fillText(ch,px2+1,py2+1);
    ctx.shadowBlur=0; ctx.shadowColor='transparent';
  } else if (explored[y][x]) {
    // Explored but not visible: dim
    ctx.fillStyle = (map[y][x]===WALL) ? FOG_WALL : FOG_FLOOR;
    ctx.fillRect(px2,py2,CW,CH);
    // show walls dimly, hide items/enemies
    if (map[y][x]===WALL) {
      ctx.fillStyle='#0a1a0a';
      ctx.fillText('#',px2+1,py2+1);
    } else if (map[y][x]===STAIR) {
      // stairs always visible once explored
      ctx.fillStyle='#224466';
      ctx.fillText('>',px2+1,py2+1);
    }
  } else {
    // Never seen
    ctx.fillStyle=UNSEEN;
    ctx.fillRect(px2,py2,CW,CH);
  }
}

function fullDraw() {
  updateFog();
  ctx.shadowBlur=0; ctx.shadowColor='transparent';
  ctx.fillStyle=UNSEEN;
  ctx.fillRect(0,0,cv.width,cv.height);
  setFont();
  for(var y=0;y<MH;y++)
    for(var x=0;x<MW;x++)
      drawCellFog(x,y);
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* HUD                                                                        */
/* ═══════════════════════════════════════════════════════════════════════════ */
var msgTimer;
function updateHUD() {
  document.getElementById('h-lvl').textContent = lvl;
  document.getElementById('h-hp').textContent = Math.max(0,hp)+'/'+hpMax;
  document.getElementById('h-gold').textContent = gold;
  document.getElementById('h-atk').textContent = atkPow;
  document.getElementById('h-kills').textContent = kills;
  // HP bar
  var pct = Math.max(0, hp/hpMax*100);
  var bar = document.getElementById('hp-bar');
  bar.style.width = pct + '%';
  bar.style.background = pct>50 ? '#39ff5a' : pct>25 ? '#f0c040' : '#ff3333';
}
function showMsg(txt,color) {
  var el=document.getElementById('h-msg');
  el.textContent=txt; el.style.color=color||'#f0c040';
  clearTimeout(msgTimer);
  msgTimer=setTimeout(function(){el.textContent='';},3000);
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* STATE                                                                      */
/* ═══════════════════════════════════════════════════════════════════════════ */
var map, px, py, hp, hpMax, gold, kills, atkPow, lvl, alive, runSeed, turns;

function startGame() {
  initAudio();
  hpMax=HP_MAX; hp=hpMax; gold=0; kills=0; atkPow=10; lvl=1; alive=true; turns=0;
  runSeed=Math.floor(Math.random()*1000000);
  loadLevel(); hideOverlay();
}
function loadLevel() {
  hintCells=[]; if(hintFade)clearTimeout(hintFade);
  resetFog();
  map=genMap(runSeed+lvl*997+13, lvl);
  px=1; py=1;
  spawnEnemies();
  fullDraw(); updateHUD();
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* GAME LOGIC                                                                 */
/* ═══════════════════════════════════════════════════════════════════════════ */
var DIRS = { w:[0,-1], a:[-1,0], s:[0,1], d:[1,0] };

function step(k) {
  if(!alive) return;
  if(k==='x') { alive=false; showOverlay(false); return; }
  if(k==='h') { doHint(); return; }
  if(k==='t') { doAttack(); return; }
  if(!DIRS[k]) return;
  clearHint();

  var dx=DIRS[k][0], dy=DIRS[k][1];
  var nx=px+dx, ny=py+dy;
  if(nx<0||nx>=MW||ny<0||ny>=MH||map[ny][nx]===WALL) return;

  var cell=map[ny][nx];

  // Block moving onto enemy - must use T to attack
  if(cell===ENEMY) {
    showMsg('Usa T per attaccare!','#ff3333');
    sndMiss();
    return;
  }

  var ox=px, oy=py;
  px=nx; py=ny;
  turns++;
  sndStep();

  if(cell===GOLD) {
    var amt = 10 + lvl;
    gold+=amt; map[ny][nx]=FLOOR;
    showMsg('Oro! +'+amt,'#f0c040');
    sndGold();
  }
  if(cell===POTION) {
    var heal = 10 + Math.floor(lvl/2);
    hp=Math.min(hp+heal, hpMax);
    map[ny][nx]=FLOOR;
    showMsg('Pozione! +'+heal+' HP','#39ff5a');
    sndPotion();
  }
  if(cell===WEAPON) {
    atkPow += 5;
    map[ny][nx]=FLOOR;
    showMsg('\u2020 Arma potenziata! ATK '+atkPow,'#cc66ff');
    sndWeapon();
  }
  if(cell===STAIR) {
    lvl++;
    hp=Math.min(hp+5,hpMax);
    // Every 5 floors, increase max HP
    if(lvl%5===0) { hpMax+=5; hp=Math.min(hp+5,hpMax); }
    showMsg('Piano '+lvl+'!','#44aaff');
    sndStair();
    loadLevel();
    return;
  }

  // Enemy turn
  enemyTurn();
  updateHUD();
  fullDraw();

  if(hp<=0) { alive=false; sndDeath(); showOverlay(false); }
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* INPUT                                                                      */
/* ═══════════════════════════════════════════════════════════════════════════ */
var KEY_MAP = {
  'w':'w','a':'a','s':'s','d':'d',
  'arrowup':'w','arrowleft':'a','arrowdown':'s','arrowright':'d',
  'h':'h','x':'x','t':'t',' ':'h'
};
document.addEventListener('keydown',function(e) {
  var k=KEY_MAP[e.key.toLowerCase()];
  if(k){e.preventDefault();step(k);}
});
document.getElementById('hint-btn').addEventListener('click',function(e){e.preventDefault();doHint();});
function mobBind(id,action) {
  var el=document.getElementById(id);
  el.addEventListener('pointerdown',function(e){e.preventDefault();action();});
}
mobBind('btn-hint',doHint);
mobBind('btn-atk',doAttack);
mobBind('btn-exit',function(){step('x');});
var dpadBtns=document.querySelectorAll('#dpad button[data-k]');
for(var bi=0;bi<dpadBtns.length;bi++) {
  (function(btn){
    btn.addEventListener('pointerdown',function(e){e.preventDefault();step(btn.getAttribute('data-k'));});
  })(dpadBtns[bi]);
}

/* ═══════════════════════════════════════════════════════════════════════════ */
/* OVERLAY                                                                    */
/* ═══════════════════════════════════════════════════════════════════════════ */
var TIPS = [
  'Usa T per attaccare senza subire danno',
  'I nemici ti inseguono se sei nel loro campo visivo',
  'Ogni 3 piani trovi un potenziamento arma',
  'Premi H o Spazio per un suggerimento',
  'Camminare su un nemico fa molto male. Attacca con T!',
  'Ogni 5 piani il tuo HP massimo aumenta',
  'I nemici diventano piu\u0300 forti ad ogni piano',
  'Le pozioni curano di piu\u0300 ai piani alti'
];

function showOverlay(intro) {
  document.getElementById('overlay').classList.remove('hidden');
  if(intro) {
    document.getElementById('ov-title').textContent='\u2620 DUNGEON';
    document.getElementById('ov-title').style.color='var(--green)';
    document.getElementById('ov-sub').textContent='PERMADEATH \u00B7 FOG OF WAR \u00B7 INFINITE';
    document.getElementById('ov-stat').textContent='';
    document.getElementById('ov-tip').textContent=TIPS[Math.floor(Math.random()*TIPS.length)];
    document.getElementById('ov-btn').textContent='INIZIA';
  } else {
    var dead=hp<=0;
    document.getElementById('ov-title').textContent=dead?'\u2620  SEI MORTO':'RITIRATO';
    document.getElementById('ov-title').style.color=dead?'var(--red)':'var(--mid)';
    document.getElementById('ov-sub').textContent='Piano '+lvl+' \u00B7 '+turns+' turni';
    var score = gold + kills*10 + lvl*25;
    document.getElementById('ov-stat').textContent=
      'Oro: '+gold+' \u00B7 Uccisioni: '+kills+' \u00B7 Piani: '+lvl+
      '\nPUNTEGGIO: '+score;
    document.getElementById('ov-tip').textContent=dead ?
      TIPS[Math.floor(Math.random()*TIPS.length)] : 'Sei sopravvissuto!';
    document.getElementById('ov-btn').textContent='RIPROVA';
  }
}
function hideOverlay(){document.getElementById('overlay').classList.add('hidden');}
document.getElementById('ov-btn').addEventListener('click',startGame);

/* ═══════════════════════════════════════════════════════════════════════════ */
document.fonts.ready.then(resize);
window.addEventListener('resize',resize);
resize();
showOverlay(true);
</script>
</body>
</html>

Leave a comment