"use strict"; const EnemiesManager = (function(){ const ENEMY_FACTORY = { "static_mine" : StaticMineEnemyModel, "bouncer" : typeof BouncerEnemyModel !== "undefined" ? BouncerEnemyModel : null, "chaser" : ChaserEnemyModel, "rusher" : typeof RusherEnemyModel !== "undefined" ? RusherEnemyModel : null }; const EnemiesManager = function(xgravity){ const self = this; let active_enemies = []; let damage_popups = []; let spawn_clock = 0; let game_clock = 0; let on_mothership_impact = null; let total_enemies_spawned = 0; // Caché centralizado de texturas const sprite_cache = {}; // ---------------------------------------------------- // EXPLOSIONES DE NEBULOSA CONTRA LA NAVE NODRIZA // ---------------------------------------------------- let mothership_explosions = []; const nebula_image_cache = {}; let threat_beep_timer = 0; this.get_total_spawned = () => total_enemies_spawned; // Identifica si la entidad es una nave lanzadera const is_thrower_enemy = (enemy, state) => { const raw = [ enemy.constructor ? enemy.constructor.name : "", typeof enemy.get_type === "function" ? enemy.get_type() : "", enemy.type || "", state.type || "", enemy.config ? (enemy.config.type || enemy.config.behavior || enemy.config.id || enemy.config.name || "") : "" ].join(" ").toLowerCase(); return raw.includes("thrower") || raw.includes("lanzador") || raw.includes("lanzadera"); }; // Identifica si la entidad es un asteroide (excluyendo a la nave lanzadora) const is_asteroid_rock = (enemy, state) => { const raw = [ enemy.constructor ? enemy.constructor.name : "", typeof enemy.get_type === "function" ? enemy.get_type() : "", enemy.type || "", state.type || "", enemy.config ? (enemy.config.type || enemy.config.behavior || enemy.config.id || enemy.config.name || "") : "" ].join(" ").toLowerCase(); const is_thrower = raw.includes("thrower") || raw.includes("lanzador") || raw.includes("lanzadera"); const is_rock = raw.includes("asteroid") || raw.includes("asteroide") || raw.includes("rock"); return is_rock && !is_thrower; }; // Calcula el nivel de riesgo de 0.0 a 1.0 según lanzaderas y piedras en pantalla const calculate_asteroid_threat_level = () => { let throwers_count = 0; let asteroids_count = 0; let incoming_progress = 0; for(let i = 0; i < active_enemies.length; i++){ const enemy = active_enemies[i]; const state = enemy.get_state(); if(!state.alive) continue; // Solo entidades vivas en juego // 1. ¿Hay ya un asteroide lanzado hacia la cabina? const is_heading_to_mothership = typeof enemy.is_targeted_towards_mothership === "function" && enemy.is_targeted_towards_mothership(); if(is_heading_to_mothership){ const current_size = (state.size || 2.4) * (state.scale || 1.0); incoming_progress = Math.max(incoming_progress, Math.min(1.0, current_size / 40.0)); continue; } // 2. Conteo de amenazas activas en pantalla if(is_thrower_enemy(enemy, state)){ throwers_count++; } else if(is_asteroid_rock(enemy, state)){ asteroids_count++; } } // CRÍTICO: Si ya viene una roca hacia la pantalla, la alarma tiene máxima prioridad if(incoming_progress > 0){ return 0.75 + (incoming_progress * 0.25); // 0.75 a 1.0 } // CONDICIÓN PRINCIPAL: Solo hay riesgo si coinciden lanzadores Y asteroides if(throwers_count > 0 && asteroids_count > 0){ // Cálculo de riesgo proporcional: // Base de 0.25 por la combinación, sumando peso por cada lanzador y piedra extra const threat = 0.25 + ((throwers_count - 1) * 0.15) + ((asteroids_count - 1) * 0.08); return Math.min(0.70, threat); // Tope de 0.70 antes de que el proyectil sea disparado } // Si hay asteroides solos o lanzadores sin piedras: Silencio return 0; }; const get_nebula_image = (src) => { if(!src) return null; if(!nebula_image_cache[src]){ const img = new Image(); img.src = src; nebula_image_cache[src] = img; } return nebula_image_cache[src]; }; const spawn_mothership_nebula_explosion = () => { const nebula_list = (xgravity.values && xgravity.values.bg_procedural_nebulas && Array.isArray(xgravity.values.bg_procedural_nebulas.images)) ? xgravity.values.bg_procedural_nebulas.images : []; if(nebula_list.length === 0) return; // Elegir una nebulosa aleatoria const random_src = nebula_list[Math.floor(Math.random() * nebula_list.length)]; const img = get_nebula_image(random_src); mothership_explosions.push({ image : img, size : 50.0, // Tamaño inicial: 50 celdas target_size : 80.0, // Tamaño final: 80 celdas growth_speed : 36.0,// Velocidad de expansión (dura ~0.85s) angle : Math.random() * Math.PI * 2, // Giro aleatorio inicial rot_speed : (Math.random() - 0.5) * 0.35, // Leve rotación durante la expansión alpha : 1.0 }); }; this.set_on_mothership_impact = (fn) => { on_mothership_impact = fn; }; const get_cached_image = (src) => { if(!src) return null; if(!sprite_cache[src]){ const img = new Image(); img.src = src; sprite_cache[src] = img; } return sprite_cache[src]; }; const get_cell_size = (width, height) => { const cells = xgravity.settings.get("cells", null, 40); return (width < height ? width : height) / cells; }; this.reset = () => { active_enemies = []; damage_popups = []; spawn_clock = 0; game_clock = 0; }; const get_unlocked_threshold = () => { const min_th = xgravity.settings.get("enemy_min_threshold", null, 0.04); const max_th = xgravity.settings.get("enemy_max_threshold", null, 0.11); const ramp_time = xgravity.settings.get("enemy_time_to_max_difficulty", null, 120); const progress = Math.min(game_clock / ramp_time, 1.0); return min_th + ((max_th - min_th) * progress); }; // const spawn_enemy_instance = (cfg) => { // const EnemyClass = ENEMY_FACTORY[cfg.id]; // if(EnemyClass){ // if(cfg.image) get_cached_image(cfg.image); // Pre-calentamiento de caché // active_enemies.push(new EnemyClass(xgravity, cfg)); // } // }; const spawn_enemy_instance = (config) => { let enemy = null; switch(config.id){ case "static_mine": enemy = new StaticMineEnemyModel(xgravity, config); break; case "bouncer": enemy = new BouncerEnemyModel(xgravity, config); break; case "chaser": enemy = new ChaserEnemyModel(xgravity, config); break; case "rusher": enemy = new RusherEnemyModel(xgravity, config); break; case "asteroid": enemy = new AsteroidEnemyModel(xgravity, config); break; case "asteroid_thrower": enemy = new AsteroidThrowerEnemyModel(xgravity, config); break; } if(enemy){ active_enemies.push(enemy); total_enemies_spawned++; } }; const get_current_threshold = () => { const min_th = xgravity.settings.get_effective_stat("base_enemy_min_threshold", "min_threshold_mult"); const max_th = xgravity.settings.get_effective_stat("base_enemy_max_threshold", "max_threshold_mult"); const half_life = xgravity.settings.get("difficulty_ramp_half_life", null, 90.0); const ramp_power = xgravity.settings.get("difficulty_ramp_power", null, 1.25); const preset = xgravity.settings.get_difficulty_preset(); const ramp_mult = preset ? preset.ramp_speed_mult : 1.0; // Curva asintótica: ratio / (1 + ratio) -> tiende asintóticamente a 1.0 sin sobrepasarlo const effective_time = game_clock * ramp_mult; const ratio = Math.pow(effective_time / half_life, ramp_power); const asymptotic_progress = ratio / (1.0 + ratio); return min_th + ((max_th - min_th) * asymptotic_progress); }; const process_spawner = (delta_time) => { const interval = xgravity.settings.get_effective_stat("base_enemy_spawn_interval", "spawn_interval_mult"); spawn_clock += delta_time; if(spawn_clock >= interval){ spawn_clock = 0; const roll = Math.random(); const current_threshold = get_current_threshold(); if(roll <= current_threshold){ const roster = xgravity.settings.get("enemies_roster", null, []); for(let i = 0; i < roster.length; i++){ const cfg = roster[i]; if(roll >= cfg.range[0] && roll < cfg.range[1]){ spawn_enemy_instance(cfg); break; } } } } }; // const process_spawner = (delta_time) => { // const interval = xgravity.settings.get("enemy_spawn_interval", null, 0.6); // spawn_clock += delta_time; // if(spawn_clock >= interval){ // spawn_clock = 0; // const roll = Math.random(); // const current_threshold = get_unlocked_threshold(); // if(roll <= current_threshold){ // const roster = xgravity.settings.get("enemies_roster", null, []); // for(let i = 0; i < roster.length; i++){ // const cfg = roster[i]; // if(roll >= cfg.range[0] && roll < cfg.range[1]){ // spawn_enemy_instance(cfg); // break; // } // } // } // } // }; const spawn_damage_popup = (cell_x, cell_y, amount) => { const duration = xgravity.settings.get("enemy_damage_popup_duration", null, 0.75); damage_popups.push({ x : cell_x, y : cell_y, vx : (Math.random() - 0.5) * 1.5, vy : -3.2, amount : Math.round(amount), life : duration, max_life : duration }); }; const draw_target_lock_box = (ctx, x, y, size, alpha, cell_px, color = "#f59e0b") => { ctx.save(); ctx.translate(x, y); ctx.globalAlpha = Math.max(0, Math.min(1.0, alpha)); const half = size / 2; const corner_len = Math.max(cell_px * 0.35, size * 0.22); ctx.lineWidth = cell_px * 0.14; ctx.strokeStyle = "rgba(2, 6, 23, 0.9)"; ctx.lineCap = "square"; const draw_corners = () => { ctx.beginPath(); ctx.moveTo(-half, -half + corner_len); ctx.lineTo(-half, -half); ctx.lineTo(-half + corner_len, -half); ctx.stroke(); ctx.beginPath(); ctx.moveTo(half - corner_len, -half); ctx.lineTo(half, -half); ctx.lineTo(half, -half + corner_len); ctx.stroke(); ctx.beginPath(); ctx.moveTo(half, half - corner_len); ctx.lineTo(half, half); ctx.lineTo(half - corner_len, half); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-half + corner_len, half); ctx.lineTo(-half, half); ctx.lineTo(-half, half - corner_len); ctx.stroke(); }; draw_corners(); ctx.lineWidth = cell_px * 0.08; ctx.strokeStyle = color; draw_corners(); const cross = cell_px * 0.22; ctx.beginPath(); ctx.moveTo(-cross, 0); ctx.lineTo(cross, 0); ctx.moveTo(0, -cross); ctx.lineTo(0, cross); ctx.stroke(); ctx.restore(); }; const draw_enemy_health_bar = (ctx, px, py, radius_px, health, max_health, cell_px) => { if(health >= max_health || health <= 0) return; const bar_w = cell_px * xgravity.settings.get("enemy_health_bar_width", null, 1.8); const bar_h = Math.max(3, cell_px * 0.16); const bar_x = px - (bar_w / 2); const bar_y = py - radius_px - (cell_px * 0.45); const pct = Math.max(0, Math.min(1.0, health / max_health)); ctx.save(); ctx.fillStyle = "rgba(2, 6, 23, 0.85)"; ctx.fillRect(bar_x - 1, bar_y - 1, bar_w + 2, bar_h + 2); ctx.lineWidth = 1; ctx.strokeStyle = "rgba(148, 163, 184, 0.4)"; ctx.strokeRect(bar_x - 1, bar_y - 1, bar_w + 2, bar_h + 2); let fill_color = "#38bdf8"; if(pct <= 0.35){ fill_color = "#ef4444"; } else if(pct <= 0.65){ fill_color = "#f59e0b"; } ctx.fillStyle = fill_color; ctx.fillRect(bar_x, bar_y, bar_w * pct, bar_h); ctx.restore(); }; this.update = (delta_time, drone_model, on_enemy_destroyed, on_drone_hit, on_enemy_deflected, on_mothership_impact) => { game_clock += delta_time; process_spawner(delta_time); // 1. Textos flotantes for(let i = damage_popups.length - 1; i >= 0; i--){ const pop = damage_popups[i]; pop.life -= delta_time; if(pop.life <= 0){ damage_popups.splice(i, 1); continue; } pop.x += pop.vx * delta_time; pop.y += pop.vy * delta_time; pop.vy *= 0.94; } // 2. Colisiones y dinámicas const drone_info = drone_model ? drone_model.get_position() : null; if(!drone_info) return; const drone_shield_active = drone_model.is_shield_active(); const drone_radius = drone_shield_active ? xgravity.settings.get("drone_shield_radius", null, 1.7) : (xgravity.settings.get("drone_size", null, 2.5) * 0.4); const drone_mass = typeof drone_model.get_mass === "function" ? drone_model.get_mass() : xgravity.settings.get("drone_mass", null, 3.0); const restitution = xgravity.settings.get("drone_collision_restitution", null, 0.75); const dmg_factor = xgravity.settings.get("collision_damage_factor", null, 12.0); // ---------------------------------------------------- // ALARMA TÁCTICA MODULADA POR RIESGO // ---------------------------------------------------- const current_threat = calculate_asteroid_threat_level(); if(current_threat > 0){ threat_beep_timer -= delta_time; if(threat_beep_timer <= 0){ if(xgravity.audio){ // Se reproduce el beep pasando el nivel de amenaza actual xgravity.audio.play("asteroid_threat", 1.0, current_threat); } // A menor riesgo (0.25) -> Pitido lento cada ~1.85 segundos // A mayor riesgo (0.70) -> Pitido tenso cada ~0.75 segundos // A peligro crítico (1.00) -> Pitido vertiginoso cada ~0.35 segundos const interval = 2.2 - (current_threat * 1.85); threat_beep_timer = Math.max(0.35, interval); } } else { threat_beep_timer = 0; // Se resetea el reloj al desaparecer el peligro } for(let i = active_enemies.length - 1; i >= 0; i--){ const enemy = active_enemies[i]; // 1. UN SOLO UPDATE POR ENEMIGO, pasando la lista completa enemy.update(delta_time, drone_info, active_enemies); // 2. Comprobar si ha impactado contra la nodriza (vivo o recién muerto) // Si el asteroide impacta la cabina: const did_impact = enemy.impacted_mothership || (typeof enemy.has_impacted_mothership === "function" && enemy.has_impacted_mothership()); if(did_impact){ // Genera la gran nebulosa en el espacio exterior spawn_mothership_nebula_explosion(); if(typeof on_mothership_impact === "function"){ const dmg = (enemy.config && enemy.config.mothership_damage) ? enemy.config.mothership_damage : 25; on_mothership_impact(dmg); } active_enemies.splice(i, 1); continue; } // 3. Si murió por daño u otra causa, se elimina if(!enemy.is_alive()){ active_enemies.splice(i, 1); continue; } // 4. Si está en sobrevuelo o en trayectoria a la nodriza en Z, no choca con el dron if(!enemy.is_ready()) continue; // 5. A partir de aquí: cálculo de colisión física con el Dron const bounds = enemy.get_bounds(); const dx = bounds.x - drone_info.cell_x; const dy = bounds.y - drone_info.cell_y; const dist = Math.sqrt(dx * dx + dy * dy); const min_dist = drone_radius + bounds.radius; if(dist < min_dist && dist > 0.0001){ const nx = dx / dist; const ny = dy / dist; const enemy_vel = typeof enemy.get_velocity === "function" ? enemy.get_velocity() : { vx: 0, vy: 0 }; const dvx = drone_info.vel_cell_x - enemy_vel.vx; const dvy = drone_info.vel_cell_y - enemy_vel.vy; const rel_vel = (dvx * nx) + (dvy * ny); if(rel_vel > 0){ const enemy_mass = typeof enemy.get_mass === "function" ? enemy.get_mass() : 2.0; const impulse = (-(1 + restitution) * rel_vel) / ((1 / drone_mass) + (1 / enemy_mass)); const impact_intensity = Math.max(0.5, rel_vel); const preset = xgravity.settings.get_difficulty_preset(); const drone_dmg_mult = preset ? preset.drone_damage_taken_mult : 1.0; const enemy_dmg_mult = preset ? preset.enemy_damage_taken_mult : 1.0; const base_dmg_factor = xgravity.settings.get("base_collision_damage_factor", null, 12.0); const raw_damage = impact_intensity * base_dmg_factor; const damage_to_enemy = raw_damage * enemy_dmg_mult; const damage_to_drone = raw_damage * drone_dmg_mult; const impact_x = drone_info.cell_x + (nx * drone_radius); const impact_y = drone_info.cell_y + (ny * drone_radius); spawn_damage_popup(impact_x, impact_y, damage_to_enemy); if(typeof drone_model.apply_impulse === "function"){ drone_model.apply_impulse((impulse / drone_mass) * nx, (impulse / drone_mass) * ny); } if(typeof enemy.apply_impulse === "function"){ enemy.apply_impulse(-(impulse / enemy_mass) * nx, -(impulse / enemy_mass) * ny); } const overlap = min_dist - dist; if(typeof drone_model.set_cell_position === "function"){ drone_model.set_cell_position( drone_info.cell_x - (nx * overlap * 0.5), drone_info.cell_y - (ny * overlap * 0.5) ); } if(typeof enemy.set_position === "function"){ enemy.set_position( bounds.x + (nx * overlap * 0.5), bounds.y + (ny * overlap * 0.5) ); } const enemy_alive = enemy.take_damage(damage_to_enemy); if(!enemy_alive){ active_enemies.splice(i, 1); if(drone_shield_active){ // if(typeof on_enemy_destroyed === "function") on_enemy_destroyed(enemy); if(typeof on_enemy_destroyed === "function") on_enemy_destroyed(enemy, damage_to_enemy); } else { if(typeof on_drone_hit === "function") on_drone_hit(enemy, damage_to_drone); } } else { if(typeof on_enemy_deflected === "function"){ on_enemy_deflected(impact_x, impact_y, nx, ny, impact_intensity, damage_to_drone, drone_shield_active); } } } } } // Actualizar expansión y desvanecimiento de nebulosas for(let i = mothership_explosions.length - 1; i >= 0; i--){ const neb = mothership_explosions[i]; neb.size += neb.growth_speed * delta_time; neb.angle += neb.rot_speed * delta_time; // Progreso de 50 a 80 celdas (0.0 -> 1.0) const progress = Math.min(1.0, Math.max(0, (neb.size - 50.0) / (neb.target_size - 50.0))); neb.alpha = Math.max(0, 1.0 - progress); if(neb.size >= neb.target_size || neb.alpha <= 0){ mothership_explosions.splice(i, 1); } } }; // Método auxiliar para dibujar un sprite de enemigo const draw_enemy_sprite = (ctx, state, cell_px, cx, cy) => { const px = cx + (state.x * cell_px); const py = cy + (state.y * cell_px); const size_px = state.size * state.scale * cell_px; ctx.save(); ctx.translate(px, py); ctx.rotate(state.angle); ctx.globalAlpha = Math.max(0, Math.min(1.0, state.alpha)); const img = get_cached_image(state.image_src); if(img && img.complete && img.naturalWidth > 0){ ctx.drawImage(img, -size_px / 2, -size_px / 2, size_px, size_px); } else { ctx.fillStyle = state.fallback_color; ctx.beginPath(); ctx.arc(0, 0, size_px * 0.4, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); }; // 1. CAPA INFERIOR: Enemigos en el plano de batalla (ready === true y no van a la nodriza) + Barras de vida this.render_under = (ctx, width, height) => { const cell_px = get_cell_size(width, height); const cx = width / 2; const cy = height / 2; // Dibujar sprites que están en el plano normal de combate for(let i = 0; i < active_enemies.length; i++){ const enemy = active_enemies[i]; const state = enemy.get_state(); // Si está muerto, aún no está listo, o es un asteroide volando hacia la nodriza en Z -> NO va aquí const is_heading_to_mothership = typeof enemy.is_targeted_towards_mothership === "function" && enemy.is_targeted_towards_mothership(); if(!state.alive || !state.ready || is_heading_to_mothership) continue; draw_enemy_sprite(ctx, state, cell_px, cx, cy); } // Barras de salud (solo para enemigos en combate en el plano inferior) const show_hp = xgravity.settings.get("enemy_show_health_bars", null, true); if(show_hp){ for(let i = 0; i < active_enemies.length; i++){ const enemy = active_enemies[i]; const state = enemy.get_state(); const is_heading_to_mothership = typeof enemy.is_targeted_towards_mothership === "function" && enemy.is_targeted_towards_mothership(); if(state.alive && state.ready && !is_heading_to_mothership && state.health < state.max_health){ const px = cx + (state.x * cell_px); const py = cy + (state.y * cell_px); const r_px = state.radius * cell_px; draw_enemy_health_bar(ctx, px, py, r_px, state.health, state.max_health, cell_px); } } } }; // 2. CAPA SUPERIOR: Ordenada en Z (menor escala al fondo, mayor escala al frente) this.render_over = (ctx, width, height) => { const cell_px = get_cell_size(width, height); const cx = width / 2; const cy = height / 2; // ---------------------------------------------------- // 1. CLASIFICACIÓN DE ENTIDADES // ---------------------------------------------------- const spawning_ships = []; const camera_asteroids = []; for(let i = 0; i < active_enemies.length; i++){ const enemy = active_enemies[i]; const state = enemy.get_state(); if(!state.alive) continue; const is_heading_to_mothership = typeof enemy.is_targeted_towards_mothership === "function" && enemy.is_targeted_towards_mothership(); if(!state.ready && !is_heading_to_mothership){ // Naves en transición de spawn hacia el plano de juego spawning_ships.push(state); } else if(is_heading_to_mothership){ // Asteroides proyectados en el eje Z hacia la cámara camera_asteroids.push(state); } } // ---------------------------------------------------- // 2. ORDEN INVERSO EN Z PARA ASTEROIDES // Menor escala (más lejos) se dibuja primero; // Mayor escala (más cerca) se dibuja después tapando al fondo. // ---------------------------------------------------- camera_asteroids.sort((a, b) => (a.scale || 1.0) - (b.scale || 1.0)); // ---------------------------------------------------- // CAPA 1: Naves que van al mapa en orden de aparición // ---------------------------------------------------- for(let i = 0; i < spawning_ships.length; i++){ draw_enemy_sprite(ctx, spawning_ships[i], cell_px, cx, cy); } // ---------------------------------------------------- // CAPA 2: Asteroides que van a cámara en orden inverso // ---------------------------------------------------- for(let i = 0; i < camera_asteroids.length; i++){ draw_enemy_sprite(ctx, camera_asteroids[i], cell_px, cx, cy); } // ---------------------------------------------------- // CAPA 3: Nebulosas de explosión contra la nave nodriza // ---------------------------------------------------- for(let i = 0; i < mothership_explosions.length; i++){ const neb = mothership_explosions[i]; if(neb.image && neb.image.complete && neb.image.naturalWidth !== 0 && neb.alpha > 0){ ctx.save(); ctx.translate(cx, cy); ctx.rotate(neb.angle); ctx.globalAlpha = neb.alpha; const size_px = neb.size * cell_px; ctx.drawImage(neb.image, -size_px / 2, -size_px / 2, size_px, size_px); ctx.restore(); } } // ---------------------------------------------------- // RETÍCULAS Y DAÑO FLOTANTE (Sobre las entidades) // ---------------------------------------------------- const lock_duration = xgravity.settings.get("enemy_target_lock_duration", null, 1.2); for(let i = 0; i < active_enemies.length; i++){ const enemy = active_enemies[i]; const is_heading_to_mothership = typeof enemy.is_targeted_towards_mothership === "function" && enemy.is_targeted_towards_mothership(); if(is_heading_to_mothership) continue; const age = typeof enemy.get_age === "function" ? enemy.get_age() : 0; if(age < lock_duration){ const bounds = enemy.get_bounds(); const px = cx + (bounds.x * cell_px); const py = cy + (bounds.y * cell_px); const box_size = Math.max(cell_px * 1.6, (bounds.radius * 2 * cell_px) * 1.35); const remaining_ratio = 1.0 - (age / lock_duration); const alpha = Math.min(1.0, remaining_ratio * 1.5); draw_target_lock_box(ctx, px, py, box_size, alpha, cell_px, "#f59e0b"); } } if(damage_popups.length > 0){ ctx.save(); ctx.font = `bold ${Math.round(cell_px * 0.55)}px "Courier New", monospace`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; for(let i = 0; i < damage_popups.length; i++){ const pop = damage_popups[i]; const px = cx + (pop.x * cell_px); const py = cy + (pop.y * cell_px); const alpha = Math.max(0, pop.life / pop.max_life); ctx.globalAlpha = alpha; ctx.fillStyle = "rgba(0, 0, 0, 0.85)"; ctx.fillText(`-${pop.amount}`, px + 1.5, py + 1.5); ctx.fillStyle = "#fbbf24"; ctx.fillText(`-${pop.amount}`, px, py); } ctx.restore(); } }; // Fallback de conveniencia por si se llamara al método global this.render = (ctx, width, height) => { self.render_under(ctx, width, height); self.render_over(ctx, width, height); }; }; return EnemiesManager; })();