748 lines
32 KiB
JavaScript
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;
|
|
})(); |