diff --git a/game/index.html b/game/index.html index 6fb7d69..4318c5c 100644 --- a/game/index.html +++ b/game/index.html @@ -4,8 +4,8 @@ BACKROOMS MMO - - + +
@@ -391,44 +391,44 @@

Tu recorrido

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + diff --git a/game/js/engine/render.js b/game/js/engine/render.js index d827c92..854bc30 100644 --- a/game/js/engine/render.js +++ b/game/js/engine/render.js @@ -543,6 +543,7 @@ const g = world.map.grid; const cam = world.camera; const dark = world.level.oscuridad; + const apagon = world.apagonIntensidad ? world.apagonIntensidad(t) : 0; const [shx, shy] = window.NOFX ? [0, 0] : Effects.shakeOffset(t); ctx.save(); @@ -554,7 +555,7 @@ let flicker = 1; if (Math.random() < 0.012) flicker = 0.72; world._flicker = world._flicker === undefined ? 1 : world._flicker * 0.85 + flicker * 0.15; - const fl = world._flicker; + const fl = world._flicker * (1 - apagon * 0.88); const x0 = Math.max(0, Math.floor(cam.x / TILE) - 1); const y0 = Math.max(0, Math.floor(cam.y / TILE) - 1); @@ -790,6 +791,17 @@ ctx.fillRect(0, 0, W, H); } + if (apagon > 0.001) { + const px = world.player.rx * TILE - cam.x + TILE / 2; + const py = world.player.ry * TILE - cam.y + TILE / 2; + const radio = TILE * (world.player.luz ? 4.5 : 1.8); + const sombra = ctx.createRadialGradient(px, py, 8, px, py, radio); + sombra.addColorStop(0, `rgba(0,2,7,${apagon * (world.player.luz ? 0.08 : 0.34)})`); + sombra.addColorStop(1, `rgba(0,1,5,${apagon * 0.78})`); + ctx.fillStyle = sombra; + ctx.fillRect(0, 0, W, H); + } + if (!window.NOFX) { const vin = ctx.createRadialGradient(W / 2, H / 2, H * 0.36, W / 2, H / 2, H * 0.78); vin.addColorStop(0, 'rgba(0,0,0,0)'); diff --git a/game/js/engine/render3d.js b/game/js/engine/render3d.js index 2ab0c59..0251d2b 100644 --- a/game/js/engine/render3d.js +++ b/game/js/engine/render3d.js @@ -30,6 +30,9 @@ let composer = null; // postprocesado (bloom + gamma); null => render directo let bloomPass = null; // pase de bloom para el pulso dinámico por cordura baja let fogBase = 0.08; + let ambBase = 0.35; + let dlightBase = 0.35; + let exposureBase = 1.1; let nivelClaro = 0; // [0,1] cuánto se pasa de CLARA la paleta del nivel (v30.2) let glCanvas, overlay, octx, W, H; let levelKey = null; @@ -1237,10 +1240,13 @@ const cSuelo = new THREE.Color(pal.suelo); const lumSuelo = 0.2126 * cSuelo.r + 0.7152 * cSuelo.g + 0.0722 * cSuelo.b; nivelClaro = Math.max(0, Math.min(1, (lumSuelo - 0.55) / 0.35)); - amb.intensity = esLevel0 ? 0.22 + ambBase = esLevel0 ? 0.22 : Math.max(0.12, 0.55 - world.level.oscuridad * 0.4) * (1 - 0.35 * nivelClaro); - dlight.intensity = esLevel0 ? 0.14 : 0.35 * (1 - 0.3 * nivelClaro); - renderer.toneMappingExposure = esLevel0 ? 0.96 : 1.15 - 0.25 * nivelClaro; + dlightBase = esLevel0 ? 0.14 : 0.35 * (1 - 0.3 * nivelClaro); + exposureBase = esLevel0 ? 0.96 : 1.15 - 0.25 * nivelClaro; + amb.intensity = ambBase; + dlight.intensity = dlightBase; + renderer.toneMappingExposure = exposureBase; plight.color = new THREE.Color(pal.luz); plight.distance = (world.visionActual() + 3) * 1.6; plight.castShadow = !esLevel0; @@ -1501,6 +1507,7 @@ const p = world.player; const px = p.rx + 0.5, pz = p.ry + 0.5; + const apagon = world.apagonIntensidad ? world.apagonIntensidad(t) : 0; if (!playerSprite) { playerSprite = new THREE.Sprite(new THREE.SpriteMaterial({ transparent: true })); playerSprite.scale.set(1, SPRITE_H, 1); @@ -1683,6 +1690,7 @@ const locuraFondo = new THREE.Color(0x13010b); baseFondo.lerp(locuraFondo, sc * 0.85); } + if (apagon > 0) baseFondo.multiplyScalar(1 - apagon * 0.78); scene.background.copy(baseFondo); scene.fog.color.copy(baseFondo); } @@ -1699,6 +1707,7 @@ targetBloom = 0.55 + sc * 0.65 + pulso; targetThreshold = 0.82 - sc * 0.3; // con locura baja hasta 0.52: brilla, no ciega } + targetBloom *= 1 - apagon * 0.72; bloomPass.strength = targetBloom; bloomPass.threshold = targetThreshold; } @@ -1713,7 +1722,8 @@ // en paletas casi blancas la luz del jugador quema un disco alrededor // (albedo claro × 1.7 satura el ACES): se normaliza con nivelClaro const luzJugador = world.level.id === 'level-0' ? 0.72 : 1.7 * (1 - 0.5 * nivelClaro); - plight.intensity = plight.intensity * 0.85 + (luzJugador * flicker) * 0.15; + const luzApagon = p.luz ? 1 - apagon * 0.5 : 1 - apagon * 0.92; + plight.intensity = plight.intensity * 0.85 + (luzJugador * flicker * luzApagon) * 0.15; plight.position.set(px, 1.6, pz); plight.distance = (world.visionActual() + 3) * (p.luz ? 2.4 : 1.6); @@ -1746,6 +1756,7 @@ const sc = (50 - cordura) / 50; targetFogBase = fogBase * (1 + sc * 0.9); // hasta +90% de niebla (claustrofobia) } + if (!luzOn) targetFogBase *= 1 + apagon * 0.38; // espectador (v30): desde 14 de altura la niebla normal funde el suelo // a negro — casi fuera, que el guardián vea el plano entero if (world.espectador) targetFogBase = Math.min(fogBase, 0.015); @@ -1789,6 +1800,26 @@ } actualizarLucesTecho(world, px, pz, fase0); + // Apagón sincronizado de Level 1: caen todas las luminarias del mapa, + // mientras el foco de la linterna sigue siendo útil. + if (world.level.id === 'level-1') { + const corriente = Math.max(0.025, 1 - apagon); + const objetivoAmb = ambBase * (0.06 + corriente * 0.94); + amb.intensity += (objetivoAmb - amb.intensity) * 0.28; + const objetivoCenital = dlightBase * corriente; + dlight.intensity += (objetivoCenital - dlight.intensity) * 0.35; + for (const l of ceilingLights) l.intensity *= corriente; + panelMats.forEach((m, i) => { + if (!m) return; + const falla = i === flkGrupo && !flkOn ? 0.18 : 1; + const f = corriente * falla; + m.color.setRGB(f, 0.965 * f, 0.863 * f); + }); + const objetivoExposicion = exposureBase * (0.48 + corriente * 0.52); + renderer.toneMappingExposure += + (objetivoExposicion - renderer.toneMappingExposure) * 0.24; + } + // ---------- modo espectador (v30): casa de muñecas ---------- // el techo y sus fluorescentes taparían TODO desde una cámara cenital: // sus materiales se apagan mientras dure (se re-aplica cada frame porque @@ -1977,7 +2008,7 @@ } else { renderer.render(scene, camera); } - drawOverlay(world, t); + drawOverlay(world, t, apagon); if (window.DEBUG3D_ON) { window.DEBUG3D = { @@ -1999,7 +2030,7 @@ return [(v.x * 0.5 + 0.5) * W, (-v.y * 0.5 + 0.5) * H, v.z > 1]; } - function drawOverlay(world, t) { + function drawOverlay(world, t, apagon) { octx.clearRect(0, 0, W, H); if (!window.NOFX) Effects.draw(octx, 0, 0, t, 48, project); // capa social del MMO: nombres flotantes y bocadillos de chat @@ -2028,6 +2059,15 @@ octx.fillStyle = `rgba(235,110,30,${resp})`; octx.fillRect(0, 0, W, H); } + if (apagon > 0.001) { + const sombra = octx.createRadialGradient( + W / 2, H * 0.56, H * 0.08, + W / 2, H * 0.56, Math.max(W, H) * 0.72); + sombra.addColorStop(0, `rgba(0,2,8,${apagon * (world.player.luz ? 0.02 : 0.13)})`); + sombra.addColorStop(1, `rgba(0,1,6,${apagon * 0.42})`); + octx.fillStyle = sombra; + octx.fillRect(0, 0, W, H); + } const fase0 = level0Phase(world); if (fase0 > 0) { octx.fillStyle = `rgba(95,115,125,${0.055 * fase0})`; diff --git a/game/js/engine/sfx.js b/game/js/engine/sfx.js index 085913b..f0e199a 100644 --- a/game/js/engine/sfx.js +++ b/game/js/engine/sfx.js @@ -273,6 +273,43 @@ } catch (e) {} } + // Level 1: fallo de balastos, corte seco del zumbido y reencendido desigual. + // También se hunde el bus ambiental para que el silencio se note de verdad; + // los pasos, entidades y la linterna conservan su propio canal. + function level1Blackout(fase) { + try { + if (!ctx || muted) return; + const ahora = ctx.currentTime; + const destinoAmb = fase === 'oscuro' ? volAmb * 0.07 + : fase === 'pre' ? volAmb * 0.42 : volAmb; + const factor = volAmb > 0 ? destinoAmb / volAmb : 1; + ambBus.gain.cancelScheduledValues(ahora); + ambBus.gain.setValueAtTime(Math.max(0.001, ambBus.gain.value), ahora); + ambBus.gain.exponentialRampToValueAtTime( + Math.max(0.001, destinoAmb), + ahora + (fase === 'vuelve' ? 1.45 : 0.22)); + if (ambientAudioEl) + ambientAudioEl.volume = Math.min(1, 0.62 * vol * volAmb * factor); + + if (fase === 'pre') { + ruido(0.18, 3600, 0.11, 'bandpass', 700); + tono(118, 0.42, 0.075, 'square', 82); + setTimeout(() => ctx && !muted && ruido(0.08, 2600, 0.09, 'bandpass', 500), 420); + setTimeout(() => ctx && !muted && ruido(0.06, 3300, 0.075, 'bandpass', 650), 820); + } else if (fase === 'oscuro') { + ruido(0.075, 2100, 0.16, 'bandpass', 180); + tono(92, 0.28, 0.12, 'square', 38); + setTimeout(() => ctx && !muted && tono(34, 0.8, 0.06, 'sine', 27), 90); + } else if (fase === 'vuelve') { + ruido(0.09, 2900, 0.12, 'bandpass', 700); + tono(76, 0.18, 0.08, 'square', 108); + setTimeout(() => ctx && !muted && ruido(0.055, 3900, 0.08, 'bandpass', 1200), 260); + setTimeout(() => ctx && !muted && tono(120, 0.32, 0.045, 'sine'), 620); + setTimeout(() => ctx && !muted && ruido(0.05, 3400, 0.055, 'bandpass', 1000), 980); + } + } catch (e) {} + } + // ---------- recetas de ambiente por nivel ---------- // cada receta devuelve nodos; el gain común hace fade in/out const RECETAS = { @@ -541,6 +578,14 @@ try { stopAmbient(); if (muted) return; + const apagado = levelDef.id === 'level-1' && window.Game?.world?.apagon; + const factorApagon = apagado?.fase === 'oscuro' ? 0.07 + : apagado?.fase === 'pre' ? 0.42 + : apagado?.fase === 'vuelve' ? 0.7 : 1; + if (ctx && ambBus) { + ambBus.gain.cancelScheduledValues(ctx.currentTime); + ambBus.gain.setTargetAtTime(volAmb * factorApagon, ctx.currentTime, 0.08); + } const gen = ++ambientGen; // 1) archivo del nivel (del usuario o de la wiki) — SOLO rutas que // existen según los manifiestos (v30.6: antes se probaban 3 extensiones @@ -562,7 +607,7 @@ } const el = new window.Audio(candidatos[i++]); el.loop = true; - el.volume = Math.min(1, 0.62 * vol * volAmb); + el.volume = Math.min(1, 0.62 * vol * volAmb * factorApagon); // un archivo fallido dispara TANTO el evento 'error' COMO el rechazo de // play(): sin este candado la cadena se bifurcaba en dos (acumulación) let siguiente = false; @@ -601,9 +646,23 @@ } else if (canal === 'amb') { volAmb = v; try { localStorage.setItem('backrooms-volamb', String(volAmb)); } catch (e) {} - if (ambBus) ambBus.gain.value = volAmb; + if (ambBus) { + const a = window.Game?.world?.level?.id === 'level-1' + ? window.Game.world.apagon : null; + const factor = a?.fase === 'oscuro' ? 0.07 + : a?.fase === 'pre' ? 0.42 + : a?.fase === 'vuelve' ? 0.7 : 1; + ambBus.gain.value = volAmb * factor; + } + } + if (ambientAudioEl) { + const a = window.Game?.world?.level?.id === 'level-1' + ? window.Game.world.apagon : null; + const factor = a?.fase === 'oscuro' ? 0.07 + : a?.fase === 'pre' ? 0.42 + : a?.fase === 'vuelve' ? 0.7 : 1; + ambientAudioEl.volume = Math.min(1, 0.62 * vol * volAmb * factor); } - if (ambientAudioEl) ambientAudioEl.volume = Math.min(1, 0.62 * vol * volAmb); if (menuAudioEl) menuAudioEl.volume = Math.min(1, 0.62 * vol * volAmb); } @@ -693,7 +752,7 @@ window.Sfx = { unlock, cargarOverrides, play, cue, cueDist, entityLoop, updateEntityLoops, ambient, stopAmbient, toggleMute, setVolume, idle, - level0Flicker, playMenu, stopMenu, + level0Flicker, level1Blackout, playMenu, stopMenu, get muted() { return muted; }, get volumen() { return vol; }, get volumenFx() { return volFx; }, diff --git a/game/js/main.js b/game/js/main.js index 5458150..e0aa5a4 100644 --- a/game/js/main.js +++ b/game/js/main.js @@ -1,7 +1,7 @@ // Arranque: input, bucle de animación y pantalla de título. (function () { // versión visible del juego (Ajustes); súbela con cada tanda de cambios - window.VERSION_JUEGO = 'v30.12'; + window.VERSION_JUEGO = 'v30.13'; const world = Game.world; world.data = window.GAME_DATA; diff --git a/game/js/net/cliente.js b/game/js/net/cliente.js index c54a5a4..09a4117 100644 --- a/game/js/net/cliente.js +++ b/game/js/net/cliente.js @@ -132,6 +132,30 @@ return o ? [o.x, o.y, o] : null; } + function aplicarApagon(m, w, silencioso = false) { + if (w.level?.id !== 'level-1') return; + if (!['pre', 'oscuro', 'vuelve'].includes(m.fase)) return; + const ahora = performance.now(); + const duracion = Math.max(1, Number(m.duracion || m.restante || 1)); + const restante = Math.max(0, Math.min(duracion, Number(m.restante ?? duracion))); + w.apagon = { + fase: m.fase, + duracion, + desde: ahora - (duracion - restante), + hasta: ahora + restante, + secuencia: m.secuencia || 0, + }; + if (silencioso) return; + if (m.fase === 'pre') { + w.log('Los fluorescentes empiezan a fallar al mismo tiempo…', 'danger'); + if (window.Effects) + Effects.bubble(w.player.x, w.player.y, 'Las luces…', w.player); + } else if (m.fase === 'vuelve') { + w.log('La corriente regresa a golpes. Algo vuelve a esconderse.', 'event'); + } + if (window.Sfx) Sfx.level1Blackout?.(m.fase, restante); + } + function recibir(m, w) { switch (m.t) { case 'bienvenida': @@ -332,6 +356,9 @@ if (window.Sfx) Sfx.play('ui'); } else Otros.luz(m.id, m.si); break; + case 'apagon': + aplicarApagon(m, w); + break; case 'admin': // respuesta a la contraseña de guardián (Ajustes) w.esAdmin = !!m.si; if (window.onAdminCambia) window.onAdminCambia(w.esAdmin); @@ -448,6 +475,8 @@ w.player.inv = m.inv || []; w.player.manos = m.manos || [null, null]; w.player.equipo = m.equipo || { cara: null, cuerpo: null, pies: null }; + w.apagon = null; + if (m.apagon) aplicarApagon(m.apagon, w, true); w.pasosNivel = m.caminata ? m.caminata.pasos : 0; w._caminataObjetivo = m.caminata ? m.caminata.objetivo : 0; w._caminataAvisos = {}; diff --git a/game/js/net/local.js b/game/js/net/local.js index 1da4509..9dc4c2e 100644 --- a/game/js/net/local.js +++ b/game/js/net/local.js @@ -118,6 +118,7 @@ // segundo plano el navegador lo ralentiza — el mundo «se pausa» contigo. bucle = setInterval(() => { const ahora = Date.now(); + S.tickEventosGlobales(ahora); for (const s of S.todas()) { if (!s.jugadores.size) continue; try { s.tick(ahora); } catch (e) { console.error('[local] tick:', e); } diff --git a/game/js/sim/sala.js b/game/js/sim/sala.js index c625855..7cbfd49 100644 --- a/game/js/sim/sala.js +++ b/game/js/sim/sala.js @@ -55,6 +55,83 @@ const RADIO_CHAT = 14; // casillas: el chat es de PROXIMIDAD (voz, no meg // cliente interpola esas muestras a sus FPS de render sin perder fluidez. const INTERVALO_POS_MS = 100; +// Level 1: apagón ambiental GLOBAL. No pertenece a una instancia concreta: +// todas las salas públicas/privadas del nivel reciben las mismas fases a la +// vez. La linterna del jugador sigue funcionando y no se alteran spawns ni IA; +// es tensión ambiental, no una emboscada gratuita. +const APAGON_NIVEL = 'level-1'; +const APAGON_ESPERA_MIN_MS = 30 * 1000; +const APAGON_ESPERA_MAX_MS = 60 * 1000; +const APAGON_PREAVISO_MS = 1400; +const APAGON_OSCURO_MS = 5000; +const APAGON_RECUPERA_MS = 1800; + +function crearControlApagon(rng) { + let fase = 'idle'; + let hasta = 0; + let proximo = 0; + let secuencia = 0; + let duracion = 0; + + const programar = (ahora) => { + proximo = ahora + rng.int(APAGON_ESPERA_MIN_MS, APAGON_ESPERA_MAX_MS); + }; + const cambiar = (nueva, ahora, ms, emitir) => { + fase = nueva; + duracion = ms; + hasta = ahora + ms; + emitir({ + t: 'apagon', + fase: nueva, + duracion: ms, + restante: ms, + secuencia, + }); + }; + + return { + tick(ahora, hayJugadores, emitir) { + if (fase === 'idle') { + if (!hayJugadores) { proximo = 0; return; } + if (!proximo) { programar(ahora); return; } + if (ahora < proximo) return; + secuencia++; + cambiar('pre', ahora, APAGON_PREAVISO_MS, emitir); + return; + } + if (ahora < hasta) return; + if (fase === 'pre') { + cambiar('oscuro', ahora, APAGON_OSCURO_MS, emitir); + } else if (fase === 'oscuro') { + cambiar('vuelve', ahora, APAGON_RECUPERA_MS, emitir); + } else { + fase = 'idle'; + hasta = 0; + duracion = 0; + programar(ahora); + } + }, + snapshot(ahora) { + if (fase === 'idle') return null; + return { + fase, + duracion, + restante: Math.max(0, hasta - ahora), + secuencia, + }; + }, + reset() { + fase = 'idle'; + hasta = 0; + proximo = 0; + secuencia = 0; + duracion = 0; + }, + }; +} + +const controlApagon = crearControlApagon(RNG.create('mundo::level-1::apagones')); + // Base de datos INYECTABLE: el servidor real registra expedientes (usarDb en // server/sala.js); el modo local del navegador se queda con estos no-op. let db = { @@ -181,6 +258,7 @@ class Sala { uid: e.uid, id: e.id, x: e.x, y: e.y, viva: e.viva, revelada: e.revelada, })), abiertas: this.map.exits.map((ex, i) => ex.def._abierta ? i : -1).filter((i) => i >= 0), + apagon: this.nivelId === APAGON_NIVEL ? controlApagon.snapshot(Date.now()) : null, }; } @@ -274,14 +352,14 @@ class Sala { const chaqueta = jug.equipo.cuerpo === 'chaqueta' || trajeHostil; const botas = jug.equipo.pies === 'botas_reforzadas'; // sed y cordura drenan por TILES reales acumulados con la MISMA cadencia - // que el modo offline (1 turno ≈ 1 tile: sed base -1/9, calor -1/4; + // que el modo offline (1 turno ≈ 1 tile: sed base -1/11, calor -1/5; // cordura -1/20 con zumbido o -1/25 con alucinaciones/aislamiento/ // vigilado, doblado con máscara/protección). Antes se descontaba por // "pasos" de 4 tiles con Math.ceil(), 5-6× más rápido de lo debido y sin // que la máscara marcara diferencia real (ceil(1/8) y ceil(1/4) daban 1). const tiles = pasos * 4; jug._sedAcum = (jug._sedAcum || 0) + tiles; - const cadSed = reglas.includes('calor') ? 4 : 9; + const cadSed = reglas.includes('calor') ? 5 : 11; if (jug._sedAcum >= cadSed) { const n = Math.floor(jug._sedAcum / cadSed); jug._sedAcum -= n * cadSed; @@ -623,7 +701,10 @@ class Sala { if (ef.cordura) jug.cordura = Math.max(0, Math.min(100, jug.cordura + ef.cordura)); if (ef.ruido) this.hacerRuido(jug.x, jug.y, ef.ruido); this.enviarEstado(jug); - if (!jug.muerto && (jug.salud <= 0 || jug.sed <= 0 || jug.cordura <= 0)) + // La sed a 0 no mata de golpe: supervivencia() aplica el daño gradual + // por deshidratación. Usar un objeto sin efecto de sed no debe saltarse + // esa mecánica y convertirlo en la causa de una muerte instantánea. + if (!jug.muerto && (jug.salud <= 0 || jug.cordura <= 0)) this.morir(jug, def.nombre); } @@ -1140,6 +1221,16 @@ function totalJugadores() { function todas() { return [...salas.values()]; } +// Se llama UNA vez por tick de mundo (server/sala.js y net/local.js), no una +// vez por Sala. Así el evento cruza instancias y grupos privados sin duplicarse. +function tickEventosGlobales(ahora) { + const objetivo = [...salas.values()].filter( + (s) => s.nivelId === APAGON_NIVEL && s.jugadores.size); + controlApagon.tick(ahora, objetivo.length > 0, (msg) => { + for (const sala of objetivo) sala.difundir(msg); + }); +} + // ---------- cruce de salas y respawn (compartidos servidor/local) ---------- function prepararSala(sala) { @@ -1254,9 +1345,12 @@ function moverEspectador(esp, salaVieja, nueva, objetivo) { const api = { Sala, salas, crearSala, asignar, todas, totalJugadores, prepararSala, cambiarDeSala, esSinRetorno, moverEspectador, + tickEventosGlobales, crearControlApagon, usarDb, ganchos, metricas, activarRemodel, fijarSemillaBase, generarMapa, esTransitable, SALA_PUBLICA, GRACIA_SALA_VACIA, CAP_SALA, COOLDOWN_CHAT, RADIO_CHAT, INTERVALO_POS_MS, + APAGON_ESPERA_MIN_MS, APAGON_ESPERA_MAX_MS, + APAGON_PREAVISO_MS, APAGON_OSCURO_MS, APAGON_RECUPERA_MS, }; if (esNode) module.exports = api; else window.Salas = api; diff --git a/game/js/sim/sala.test.js b/game/js/sim/sala.test.js index 6b44450..fddc148 100644 --- a/game/js/sim/sala.test.js +++ b/game/js/sim/sala.test.js @@ -5,7 +5,14 @@ // server/test-posiciones-carga.js. const test = require('node:test'); const assert = require('node:assert/strict'); -const { Sala } = require('./sala'); +const { + Sala, + crearControlApagon, + APAGON_ESPERA_MIN_MS, + APAGON_PREAVISO_MS, + APAGON_OSCURO_MS, + APAGON_RECUPERA_MS, +} = require('./sala'); function socketFake() { const mensajes = []; @@ -33,3 +40,74 @@ test('el aire contaminado de Level 11 desgasta despacio y la máscara lo bloquea for (let i = 0; i < 12; i++) sala.supervivencia(jug, 4); assert.equal(jug.salud, 99, 'la máscara bloquea toda la exposición posterior'); }); + +test('usar un objeto con sed cero no causa una muerte instantánea', () => { + const sala = new Sala('level-0', 1, 'prueba-sed', 'test'); + const ws = socketFake(); + const jug = sala.entrar(ws, 'Errante', 'token-sed', {}); + jug.salud = 40; + jug.sed = 0; + jug.cordura = 50; + + sala.aplicarNumericos(jug, { + nombre: 'Botiquín de prueba', + efecto: { salud: 40 }, + }); + + assert.equal(jug.muerto, false); + assert.equal(jug.salud, 80); + assert.equal(jug.sed, 0, 'el objeto no recupera sed'); + + sala.supervivencia(jug, 4); + assert.equal(jug.salud, 79, 'la sed cero mantiene el daño gradual al moverse'); +}); + +test('la sed baja con cadencias enteras más lentas', () => { + const normal = new Sala('level-0', 1, 'prueba-sed-normal', 'test'); + const jugNormal = normal.entrar(socketFake(), 'Errante', 'token-sed-normal', {}); + normal.supervivencia(jugNormal, 44); + assert.equal(jugNormal.sed, 96, 'la sed normal baja 1 punto cada 11 tiles'); + + const calor = new Sala('level-2', 1, 'prueba-sed-calor', 'test'); + const jugCalor = calor.entrar(socketFake(), 'Errante', 'token-sed-calor', {}); + calor.supervivencia(jugCalor, 20); + assert.equal(jugCalor.sed, 96, 'con calor baja 1 punto cada 5 tiles'); +}); + +test('el apagón global de Level 1 respeta espera, oscuridad y recuperación', () => { + const control = crearControlApagon({ int: (a) => a }); + const mensajesA = []; + const mensajesB = []; + const emitir = (m) => { + mensajesA.push(m); + mensajesB.push({ ...m }); + }; + const t0 = 10_000; + + control.tick(t0, true, emitir); + control.tick(t0 + APAGON_ESPERA_MIN_MS - 1, true, emitir); + assert.equal(mensajesA.length, 0, 'no se repite antes de 30 segundos'); + + control.tick(t0 + APAGON_ESPERA_MIN_MS, true, emitir); + assert.equal(mensajesA[0].fase, 'pre'); + assert.equal(mensajesA[0].duracion, APAGON_PREAVISO_MS); + + let ahora = t0 + APAGON_ESPERA_MIN_MS + APAGON_PREAVISO_MS; + control.tick(ahora, true, emitir); + assert.equal(mensajesA[1].fase, 'oscuro'); + assert.equal(mensajesA[1].duracion, APAGON_OSCURO_MS); + + const snap = control.snapshot(ahora + 2000); + assert.equal(snap.fase, 'oscuro'); + assert.equal(snap.restante, APAGON_OSCURO_MS - 2000, + 'quien entra tarde recibe solo el tiempo restante'); + + ahora += APAGON_OSCURO_MS; + control.tick(ahora, true, emitir); + assert.equal(mensajesA[2].fase, 'vuelve'); + assert.equal(mensajesA[2].duracion, APAGON_RECUPERA_MS); + assert.deepEqual(mensajesB, mensajesA, 'todos reciben las mismas fases'); + + control.tick(ahora + APAGON_RECUPERA_MS, true, emitir); + assert.equal(control.snapshot(ahora + APAGON_RECUPERA_MS), null); +}); diff --git a/game/js/systems/game.js b/game/js/systems/game.js index 878392c..ed9dce0 100644 --- a/game/js/systems/game.js +++ b/game/js/systems/game.js @@ -27,6 +27,7 @@ over: false, visionMod: 0, luzBloqueada: false, + apagon: null, extraWorldStep: false, moving: false, tutorial: {}, @@ -213,6 +214,36 @@ return Math.max(2, v); }; + // Intensidad visual [0,1] del apagón sincronizado de Level 1. Las fases + // llegan del servidor; aquí solo se dibuja una caída/recuperación inquietante + // sin tocar la linterna ni las reglas de combate. + world.apagonIntensidad = function (t = performance.now()) { + const a = world.apagon; + if (!a || world.level?.id !== 'level-1') return 0; + const dur = Math.max(1, a.duracion || 1); + const e = Math.max(0, t - a.desde); + const p = Math.max(0, Math.min(1, e / dur)); + const suave = !!window.NOFX; + if (a.fase === 'pre') { + if (suave) return p * 0.22; + const fallo = Math.sin(e * 0.032) + Math.sin(e * 0.087) * 0.55 > + 0.82 - p * 0.65; + return Math.min(0.82, p * 0.16 + (fallo ? 0.25 + p * 0.48 : 0)); + } + if (a.fase === 'oscuro') { + return Math.min(0.96, 0.82 + e / 700); + } + if (a.fase === 'vuelve') { + if (p >= 1) { world.apagon = null; return 0; } + if (suave) return (1 - p) * 0.92; + const base = (1 - p) * 0.82; + const recaida = p < 0.78 && + Math.sin(e * 0.051) + Math.sin(e * 0.123) * 0.5 > 0.72 + p * 0.35; + return Math.min(0.95, Math.max(base, recaida ? 0.86 - p * 0.25 : 0)); + } + return 0; + }; + world.hurt = function (n, causa, ambiental) { if (world.over) return; world.player.salud = Math.max(0, world.player.salud - n); @@ -370,6 +401,7 @@ world.turnTotal = 0; world.dadosN = 0; // nº de tiradas de dado (secuencia determinista por semilla) world.over = false; + world.apagon = null; world._muerteSmiler = false; world._fuenteDano = null; // run NUEVA de verdad: si venías de morir, el nivel anterior sigue en @@ -432,6 +464,7 @@ world.turn = 0; world.visionMod = 0; world.luzBloqueada = false; + world.apagon = null; world.escondido = null; world.ruido = null; if (!world.visited.includes(id)) world.visited.push(id); @@ -798,7 +831,7 @@ // descansar en niveles seguros repone la mente (hasta 70) if (world.level.peligro <= 1 && world.player.cordura < 70 && world.turn % 25 === 0) world.sanity(1); - if (world.turn % 9 === 0) world.thirst(-1); + if (world.turn % 11 === 0) world.thirst(-1); if (world.turn % 15 === 0) world.hunger(-1); if (world.player.sed <= 0 && world.turn % 3 === 0) world.hurt(2, 'la deshidratación', true); if (world.player.hambre <= 0 && world.turn % 5 === 0) world.hurt(1, 'la inanición', true); diff --git a/game/js/systems/rules.js b/game/js/systems/rules.js index 9989fb1..6b380e6 100644 --- a/game/js/systems/rules.js +++ b/game/js/systems/rules.js @@ -34,7 +34,7 @@ icono: '🔥', desc: 'El vapor y el calor duplican tu sed.', turno(world) { - if (world.turn % 4 === 0) world.thirst(-1); + if (world.turn % 5 === 0) world.thirst(-1); }, }, aire_contaminado: { diff --git a/game/js/ui/changelog.js b/game/js/ui/changelog.js index cd9bcf8..48f6f41 100644 --- a/game/js/ui/changelog.js +++ b/game/js/ui/changelog.js @@ -4,6 +4,10 @@ // tanda de cambios (junto con VERSION_JUEGO en main.js). (function () { const CHANGELOG = [ + { v: 'v30.13', cambios: [ + 'Level 1 ahora sufre apagones globales: los fluorescentes fallan, el zumbido se corta y todas sus instancias quedan a oscuras a la vez durante unos segundos. La linterna sigue siendo útil. (josealmon)', + 'La sed baja más despacio y usar un objeto con la sed a cero ya no provoca una muerte instantánea: la deshidratación vuelve a causar daño gradual. (josealmon)', + ] }, { v: 'v30.12', cambios: [ 'Level 0 ya no es un secarral: hay más botellas de agua de almendras por el suelo, más taquillas que registrar, y un resultado medio del dado (12-13) en sus taquillas ahora encuentra agua. El resto de niveles no cambia. (josealmon)', ] }, diff --git a/server/sala.js b/server/sala.js index ce3429a..fcbabeb 100644 --- a/server/sala.js +++ b/server/sala.js @@ -13,6 +13,7 @@ const db = require('./db'); const { Sala, salas, asignar, todas, totalJugadores, prepararSala, cambiarDeSala, esSinRetorno, moverEspectador, + tickEventosGlobales, metricas, SALA_PUBLICA, GRACIA_SALA_VACIA, } = compartido; @@ -35,6 +36,7 @@ function chatReciente(nivel, desdeSeq) { function tickTodas(ahora) { const t0 = process.hrtime.bigint(); + tickEventosGlobales(ahora); for (const [clave, s] of salas) { if (!s.jugadores.size) { if (!s._vaciaDesde) s._vaciaDesde = ahora;