XGravity/Public/ecma/Managers/EnemiesManager.ecma.js

748 lines
32 KiB
JavaScript

"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;
})();