From b892747b1a52c8ea97cc442ff5116af10d54964d Mon Sep 17 00:00:00 2001 From: KyMAN <0kyman0@gmail.com> Date: Fri, 16 Oct 2026 16:02:29 +0200 Subject: [PATCH] feat(ecma): Base game done. --- .gitignore | 4 + Public/ecma/Application/Values.ecma.js | 102 +++ Public/ecma/Application/XGravity.ecma.js | 145 ++++ .../ecma/Backgrounds/ImageBackground.ecma.js | 76 ++ .../Backgrounds/ProceduralBackground.ecma.js | 276 +++++++ .../ecma/Backgrounds/StarsBackground.ecma.js | 66 ++ Public/ecma/Fx/CockpitImpactFx.ecma.js | 87 ++ Public/ecma/Fx/DeflectionSparksFx.ecma.js | 77 ++ Public/ecma/Fx/HullHitExplosionsFx.ecma.js | 134 ++++ Public/ecma/Fx/ShieldExplosionsFx.ecma.js | 138 ++++ Public/ecma/Managers/AudioManager.ecma.js | 119 +++ Public/ecma/Managers/EnemiesManager.ecma.js | 748 ++++++++++++++++++ .../ecma/Managers/ExplosionsManager.ecma.js | 70 ++ Public/ecma/Managers/I18NManager.ecma.js | 81 ++ Public/ecma/Managers/SettingsManager.ecma.js | 40 + Public/ecma/Models/AsteroidEnemyModel.ecma.js | 174 ++++ .../Models/AsteroidThrowerEnemyModel.ecma.js | 112 +++ Public/ecma/Models/BouncerEnemyModel.ecma.js | 105 +++ Public/ecma/Models/ChaserEnemyModel.ecma.js | 111 +++ Public/ecma/Models/CockpitModel.ecma.js | 162 ++++ Public/ecma/Models/DroneModel.ecma.js | 442 +++++++++++ Public/ecma/Models/ExplosionsModel.ecma.js | 272 +++++++ Public/ecma/Models/RusherEnemyModel.ecma.js | 105 +++ .../ecma/Models/StaticMineEnemyModel.ecma.js | 105 +++ Public/ecma/Models/TelemetryHudModel.ecma.js | 442 +++++++++++ .../Sounds/AsteroidThreatAlarmSound.ecma.js | 82 ++ Public/ecma/Sounds/HitExplosionSound.ecma.js | 52 ++ .../ecma/Sounds/MothershipImpactSound.ecma.js | 136 ++++ .../ecma/Sounds/PerimeterWarningSound.ecma.js | 51 ++ .../ecma/Sounds/ShieldExplosionSound.ecma.js | 87 ++ .../ecma/Sounds/ShieldLowAlarmSound.ecma.js | 94 +++ .../Strategies/BehaviorStrategies.ecma.js | 327 ++++++++ .../ecma/Strategies/SpawnStrategies.ecma.js | 153 ++++ Public/ecma/Utils/Common.ecma.js | 200 +++++ Public/ecma/Utils/I18N.ecma.js | 78 ++ Public/ecma/Utils/Settings.ecma.js | 390 +++++++++ Public/ecma/Utils/Styles.ecma.js | 392 +++++++++ Public/ecma/Views/CreditsView.ecma.js | 80 ++ .../ecma/Views/DifficultySelectView.ecma.js | 89 +++ Public/ecma/Views/FpsOverlayView.ecma.js | 78 ++ Public/ecma/Views/GameOverView.ecma.js | 107 +++ Public/ecma/Views/GameView.ecma.js | 683 ++++++++++++++++ Public/ecma/Views/MainMenuView.ecma.js | 101 +++ Public/ecma/Views/OptionsView.ecma.js | 150 ++++ Public/ecma/Views/RankingView.ecma.js | 45 ++ Public/images/logo.svg | 61 ++ Public/index.html | 93 +++ Public/md/ai-dev-rules.md | 13 + Public/md/base-dev.md | 470 +++++++++++ 49 files changed, 8205 insertions(+) create mode 100644 .gitignore create mode 100644 Public/ecma/Application/Values.ecma.js create mode 100644 Public/ecma/Application/XGravity.ecma.js create mode 100644 Public/ecma/Backgrounds/ImageBackground.ecma.js create mode 100644 Public/ecma/Backgrounds/ProceduralBackground.ecma.js create mode 100644 Public/ecma/Backgrounds/StarsBackground.ecma.js create mode 100644 Public/ecma/Fx/CockpitImpactFx.ecma.js create mode 100644 Public/ecma/Fx/DeflectionSparksFx.ecma.js create mode 100644 Public/ecma/Fx/HullHitExplosionsFx.ecma.js create mode 100644 Public/ecma/Fx/ShieldExplosionsFx.ecma.js create mode 100644 Public/ecma/Managers/AudioManager.ecma.js create mode 100644 Public/ecma/Managers/EnemiesManager.ecma.js create mode 100644 Public/ecma/Managers/ExplosionsManager.ecma.js create mode 100644 Public/ecma/Managers/I18NManager.ecma.js create mode 100644 Public/ecma/Managers/SettingsManager.ecma.js create mode 100644 Public/ecma/Models/AsteroidEnemyModel.ecma.js create mode 100644 Public/ecma/Models/AsteroidThrowerEnemyModel.ecma.js create mode 100644 Public/ecma/Models/BouncerEnemyModel.ecma.js create mode 100644 Public/ecma/Models/ChaserEnemyModel.ecma.js create mode 100644 Public/ecma/Models/CockpitModel.ecma.js create mode 100644 Public/ecma/Models/DroneModel.ecma.js create mode 100644 Public/ecma/Models/ExplosionsModel.ecma.js create mode 100644 Public/ecma/Models/RusherEnemyModel.ecma.js create mode 100644 Public/ecma/Models/StaticMineEnemyModel.ecma.js create mode 100644 Public/ecma/Models/TelemetryHudModel.ecma.js create mode 100644 Public/ecma/Sounds/AsteroidThreatAlarmSound.ecma.js create mode 100644 Public/ecma/Sounds/HitExplosionSound.ecma.js create mode 100644 Public/ecma/Sounds/MothershipImpactSound.ecma.js create mode 100644 Public/ecma/Sounds/PerimeterWarningSound.ecma.js create mode 100644 Public/ecma/Sounds/ShieldExplosionSound.ecma.js create mode 100644 Public/ecma/Sounds/ShieldLowAlarmSound.ecma.js create mode 100644 Public/ecma/Strategies/BehaviorStrategies.ecma.js create mode 100644 Public/ecma/Strategies/SpawnStrategies.ecma.js create mode 100644 Public/ecma/Utils/Common.ecma.js create mode 100644 Public/ecma/Utils/I18N.ecma.js create mode 100644 Public/ecma/Utils/Settings.ecma.js create mode 100644 Public/ecma/Utils/Styles.ecma.js create mode 100644 Public/ecma/Views/CreditsView.ecma.js create mode 100644 Public/ecma/Views/DifficultySelectView.ecma.js create mode 100644 Public/ecma/Views/FpsOverlayView.ecma.js create mode 100644 Public/ecma/Views/GameOverView.ecma.js create mode 100644 Public/ecma/Views/GameView.ecma.js create mode 100644 Public/ecma/Views/MainMenuView.ecma.js create mode 100644 Public/ecma/Views/OptionsView.ecma.js create mode 100644 Public/ecma/Views/RankingView.ecma.js create mode 100644 Public/images/logo.svg create mode 100644 Public/index.html create mode 100644 Public/md/ai-dev-rules.md create mode 100644 Public/md/base-dev.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15d6282 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/Data +/Public/data +/Public/images/backgrounds +/Public/images/game \ No newline at end of file diff --git a/Public/ecma/Application/Values.ecma.js b/Public/ecma/Application/Values.ecma.js new file mode 100644 index 0000000..79a8c14 --- /dev/null +++ b/Public/ecma/Application/Values.ecma.js @@ -0,0 +1,102 @@ +"use strict"; + +const Values = (function(){ + + const Values = function(xgravity){ + + const self = this; + + // XGravity + this.cells = 40; + this.quality = 100; + this.position = "body"; + this.class_name = "xgravity"; + this.fps = 60; + + // Background stars + this.bg_stars_count = 140; + this.bg_stars_size_minimum = .5; + this.bg_stars_size_maximum = 2.2; + this.bg_stars_speed_minimum = .1; + this.bg_stars_speed_maximum = .6; + this.bg_stars_direction_x = 0; + this.bg_stars_direction_y = 1; + this.bg_stars_color = "rgba(186, 230, 253, {alpha})"; + + // Background image + this.bg_image_url = ""; + this.bg_image_pan_x = 5; + this.bg_image_pan_y = 2; + + // Background procedural + this.bg_procedural_oversize = 1.5; + this.bg_procedural_far_stars = { + probablity : .96, + size : [.2, .7], + images : [ + "images/game/Far-Star-1.webp", + "images/game/Far-Star-2.webp", + "images/game/Far-Star-3.webp" + ] + }; + this.bg_procedural_nebulas = { + probablity : .87, + size : [3, 60], + images : [ + "images/game/Blue-Dust.webp", + "images/game/Cold-Nebula.webp", + "images/game/Dust.webp", + "images/game/Grey-Asteroid-Dust.webp", + "images/game/Hot-Nebula.webp", + "images/game/Violet-Dust.webp", + "images/game/Yellow-Dust.webp" + ] + }; + this.bg_procedural_near_stars = { + probablity : .21, + size : [.5, 1.7], + images : [ + "images/game/Layer.webp", + "images/game/Layer2.webp" + ], + }; + this.bg_procedural_suns = { + probablity : .32, + timer : [2, 10], + size : [10, 50], + images : [ + "images/game/Sun.fixed.webp", + "images/game/Red-Giant.webp" + ], + } + this.bg_procedural_planets = { + probablity : .17, + size : [7, 30], + images : [ + "images/game/Ice-Giant.webp", + "images/game/Gas-Giant.webp", + "images/game/Exoplanet3.webp", + "images/game/Exoplanet2.webp", + "images/game/Exoplanet.webp" + ] + }; + + // Audio + this.audio_samples = 44100; + this.audio_default_amount = 20; + + this.set = () => { + Object.keys(self).forEach(key => { + if(!["set"].includes(key)) + self[key] = xgravity.settings.get(key, null, self[key]); + }); + }; + + // Enemies + this.enemy_overflight_initial_scale = 20; + this.enemy_overflight_scale_speed = 1.8; + + }; + + return Values; +})(); \ No newline at end of file diff --git a/Public/ecma/Application/XGravity.ecma.js b/Public/ecma/Application/XGravity.ecma.js new file mode 100644 index 0000000..d332f2a --- /dev/null +++ b/Public/ecma/Application/XGravity.ecma.js @@ -0,0 +1,145 @@ +"use strict"; + +const XGravity = (function(){ + + const XGravity = function(){ + + const self = this, + screen = {x : 0, y : 0}; + let thread_interval = null, + fps = 12; + this.cells = 40; + this.canvas_quality = 100; + + this.item_self = document; + + this.values = new Values(self); + this.settings = new SettingsManager(self); + this.i18n = new I18NManager(self); + this.audio = new AudioManager(this); + this.explosions = new ExplosionsManager(this); + this.views = { + main_menu : new MainMenuView(self), + difficulty_select : new DifficultySelectView(this), + options : new OptionsView(self), + ranking : new RankingView(self), + credits : new CreditsView(self), + fps : new FpsOverlayView(this), + game : new GameView(self), + game_over : new GameOverView(this) + }; + + const constructor = () => { + self.values.set(); + + let target, preload = setInterval(() => { + if(target = document.querySelector(self.values.position)){ + clearInterval(preload); + + self.item_self = target.appendChild(document.createElement("div")); + self.item_self.className = self.item_self.id = self.values.class_name; + self.item_self.setAttribute("data-cells", self.values.cells); + + thread_interval = setInterval(thread, 1000 / fps); + + build(); + + }; + }, self.values.fps); + + Common.SCSS(SCSS); + + }; + + const thread = () => { + if(screen.x != self.item_self.offsetWidth || screen.y != self.item_self.offsetHeight){ + screen.x = self.item_self.offsetWidth; + screen.y = self.item_self.offsetHeight; + + self.item_self.style.fontSize = ((screen.x < screen.y ? screen.x : screen.y) / self.cells) + "px"; + + }; + }; + + const build = () => { + self.views.main_menu.go(); + }; + + /** + * @param {...(HTMLElement|string|Array.)} inputs + * @returns {Array.} + * @access public + * @static + */ + this.html = (...inputs) => { + const fragment = new DocumentFragment(), + master = ( + inputs[0] && (inputs[0].tagName || inputs[0].nodeName) ? inputs[0] : + typeof inputs[0] == "string" ? document.querySelector(inputs[0]) : + null), + items = []; + + (master ? inputs.slice(1) : inputs).forEach(subinputs => { + if(subinputs && (subinputs.tagName || subinputs.nodeName)) + fragment.appendChild(subinputs); + else if(typeof subinputs == "string") + fragment.appendChild(document.createTextNode(subinputs)); + else if(subinputs instanceof Array){ + const [tag, attributes, children] = subinputs.concat([null, null]).slice(0, 3), + item = document.createElement(tag); + + attributes && attributes.constructor == Object && Object.entries(attributes).forEach(([key, value]) => { + if(/^on[_\-]?/i.test(key) && typeof value == "function") + item.addEventListener(key.replace(/^on[_\-]?/i, "").replace(/[^a-z0-9]+/gi, "").toLowerCase(), event => { + value(item, event); + }); + else if(value !== null) + item.setAttribute(key.replace(/[^a-z0-9]+/gi, "-"), value); + }); + + if(children instanceof Array) + self.html(...children).forEach(child => item.appendChild(child)); + else if(typeof children == "string") + item.innerHTML += children; + + fragment.appendChild(item); + }; + }); + + items.push(...fragment.childNodes); + master && master.appendChild(fragment); + + return items; + }; + + constructor(); + + }; + + // XGravity.drone_velocity = (px, py, dx, dy, ax, ay, fx, fy, max_a = 40) => { + // // 1. Sumar o restar según la posición del cursor (atracción) + // ax += (px - dx > 0 ? 1 : -1); + // ay += (py - dy > 0 ? 1 : -1); + + // // 2. Limitar el acumulador para que la órbita sea elástica pero controlable + // if(ax > max_a) ax = max_a; + // else if(ax < -max_a) ax = -max_a; + + // if(ay > max_a) ay = max_a; + // else if(ay < -max_a) ay = -max_a; + + // return { + // ax : ax, + // ay : ay, + // x : ax * fx, + // y : ay * fy + // }; + // }; + + XGravity.drone_velocity = (px, py, dx, dy, vx, vy, f, delta = 0) => [ + vx += (px - dx) * f, + vy += (py - dy) * f + ]; + + return XGravity; +})(); \ No newline at end of file diff --git a/Public/ecma/Backgrounds/ImageBackground.ecma.js b/Public/ecma/Backgrounds/ImageBackground.ecma.js new file mode 100644 index 0000000..e72cd9e --- /dev/null +++ b/Public/ecma/Backgrounds/ImageBackground.ecma.js @@ -0,0 +1,76 @@ +"use strict"; + +const ImageBackground = (function(){ + + const ImageBackground = function(xgravity){ + + const self = this; + let image = null, + is_loaded = false, + offset_x = 0, + offset_y = 0; + + const constructor = () => { + if(xgravity.values.bg_image_url){ + + image = new Image(); + + image.onload = () => { + is_loaded = true; + }; + image.onerror = event => { + console.error("[XGravity] Error al cargar bg_image_url:", xgravity.values.bg_image_url, event); + }; + + image.src = xgravity.values.bg_image_url; + if(image.complete && image.naturalWidth > 0) + is_loaded = true; + + }; + }; + + this.resize = (width, height) => {}; + + this.render = (context, width, height, delta_time) => { + if(!is_loaded || !image || !image.naturalWidth){ + context.fillStyle = "#020408"; + context.fillRect(0, 0, width, height); + return; + }; + + // Relación de aspecto cover + const canvas_ratio = width / height, + image_ratio = image.naturalWidth / image.naturalHeight; + let draw_width, draw_height; + + if(canvas_ratio > image_ratio){ + draw_width = width; + draw_height = width / image_ratio; + }else{ + draw_height = height; + draw_width = height * image_ratio; + }; + + // Si es completamente estática (como en el juego original) + if(xgravity.values.bg_image_pan_x === 0 && xgravity.values.bg_image_pan_y === 0){ + context.drawImage(image, (width - draw_width) / 2, (height - draw_height) / 2, draw_width, draw_height); + return; + }; + + // Si tiene movimiento + const start_x = ((width - draw_width) / 2) + (offset_x = (offset_x + xgravity.values.bg_image_pan_x * delta_time) % draw_width), + start_y = ((height - draw_height) / 2) + (offset_y = (offset_y + xgravity.values.bg_image_pan_y * delta_time) % draw_height); + + // Dibujar la cuadrícula de repetición necesaria para cubrir cualquier espacio + for(let x = start_x - draw_width; x < width; x += draw_width) + for(let y = start_y - draw_height; y < height; y += draw_height) + context.drawImage(image, x, y, draw_width, draw_height); + + }; + + constructor(); + + }; + + return ImageBackground; +})(); \ No newline at end of file diff --git a/Public/ecma/Backgrounds/ProceduralBackground.ecma.js b/Public/ecma/Backgrounds/ProceduralBackground.ecma.js new file mode 100644 index 0000000..b6c9a3c --- /dev/null +++ b/Public/ecma/Backgrounds/ProceduralBackground.ecma.js @@ -0,0 +1,276 @@ +"use strict"; + +const ProceduralBackground = (function(){ + + const ProceduralBackground = function(xgravity){ + + const self = this, + image_cache = {}, + active_suns = []; + let is_ready = false, + universe_size = 0, + // Lienzos intermedios pre-renderizados + base_canvas = null, + planets_canvas = null; + + const constructor = () => { + preload_images(get_all_configured_urls(), () => { + build_universe( + Math.floor(window.innerWidth * xgravity.values.quality / 100) || 800, + Math.floor(window.innerHeight * xgravity.values.quality / 100) || 600 + ); + }); + }; + + const preload_images = (urls, callback) => { + + let pending = urls.length; + + if(pending === 0){ + callback(); + return; + }; + + urls.forEach(url => { + if(image_cache[url]) + -- pending || callback(); + else{ + + const image = new Image(); + + image.onload = () => { + image_cache[url] = image; + -- pending || callback(); + }; + image.onerror = () => { + console.warn("[XGravity] ProceduralBackground: No se pudo cargar", url); + -- pending || callback(); + }; + image.src = url; + + }; + }); + }; + + const get_all_configured_urls = () => { + + const urls = []; + + for(const key of [ + "bg_procedural_far_stars", + "bg_procedural_nebulas", + "bg_procedural_near_stars", + "bg_procedural_suns", + "bg_procedural_planets" + ]){ + + const configuration = {...xgravity.values[key]}; + + if(configuration && configuration.images instanceof Array) + configuration.images.forEach(url => { + urls.includes(url) || urls.push(url); + }); + + }; + + return urls; + }; + + const get_cell_size = (width, height) => (width < height ? width : height) / xgravity.cells; + + // Dibuja un elemento radialmente desde el centro hacia afuera + const draw_element_from_center = (target_context, image, center_x, center_y, maximum_radius, size_range, cell_px) => { + + const display_angle = Math.random() * Math.PI * 2, + distance = Math.sqrt(Math.random()) * maximum_radius, // Distribución de área uniforme + // Tamaño en celdas + size_minimum = size_range ? size_range[0] : 10, + target_px = (Math.random() * ((size_range ? size_range[1] : size_minimum) - size_minimum) + size_minimum) * cell_px, + // Mantener proporción de aspecto + image_ratio = image.naturalWidth / image.naturalHeight, + [width, height] = image_ratio >= 1 ? [target_px, target_px / image_ratio] : [target_px / image_ratio, target_px]; + + target_context.save(); + target_context.translate( + center_x + Math.cos(display_angle) * distance, + center_y + Math.sin(display_angle) * distance + ); + target_context.rotate(Math.random() * Math.PI * 2); + target_context.drawImage(image, -width / 2, -height / 2, width, height); + target_context.restore(); + + }; + + const build_universe = (width, height) => { + + const cell_px = get_cell_size(width, height), + center_x = (universe_size = Math.floor(Math.max(width, height) * 1.5)) / 2, + center_y = universe_size / 2, + maximum_radius = universe_size * 0.45, + base_context = (base_canvas = document.createElement("canvas")).getContext("2d"), + planets_context = (planets_canvas = document.createElement("canvas")).getContext("2d"); + let count; + + // 1. Offscreen base (Capas 1, 2 y 3) + base_canvas.width = universe_size; + base_canvas.height = universe_size; + + base_context.fillStyle = "#020408"; + base_context.fillRect(0, 0, universe_size, universe_size); + + for(const partial_key of ["far_stars", "nebulas", "near_stars"]){ + + const key = "bg_procedural_" + partial_key; + + count = 0; + while(Math.random() <= (xgravity.values[key].probablity || 0) && count ++ < ProceduralBackground.MAXIMUM_ITERATIONS){ + + const chosen = xgravity.values[key].images[Math.floor(Math.random() * xgravity.values[key].images.length)]; + + image_cache[chosen] && + draw_element_from_center(base_context, image_cache[chosen], center_x, center_y, maximum_radius * .5, xgravity.values[key].size, cell_px); + + }; + + }; + + // 2. Offscreen Capa 5: Planetas (Bucle While) + planets_canvas.width = universe_size; + planets_canvas.height = universe_size; + // const planets_context = (planets_canvas = document.createElement("canvas")).getContext("2d"); + + count = 0; + while(Math.random() <= (xgravity.values.bg_procedural_planets.probablity || 0) && count ++ < ProceduralBackground.MAXIMUM_ITERATIONS){ + + const chosen = xgravity.values.bg_procedural_planets.images[Math.floor(Math.random() * xgravity.values.bg_procedural_planets.images.length)]; + + image_cache[chosen] && + draw_element_from_center(planets_context, image_cache[chosen], center_x, center_y, maximum_radius, xgravity.values.bg_procedural_planets.size, cell_px); + + }; + + // 3. Capa 4: Soles vivos con carrusel de 3 sprites + active_suns.length = 0; + count = 0; + while(Math.random() <= (xgravity.values.bg_procedural_suns.probablity || 0) && count ++ < ProceduralBackground.MAXIMUM_ITERATIONS){ + + const chosen = xgravity.values.bg_procedural_suns.images[Math.floor(Math.random() * xgravity.values.bg_procedural_suns.images.length)]; + + if(image_cache[chosen]){ + + const timer_range = xgravity.values.bg_procedural_suns.timer || [2, 10], + size_minimum = xgravity.values.bg_procedural_suns.size ? xgravity.values.bg_procedural_suns.size[0] : 10, + sun_px = (Math.random() * (( + xgravity.values.bg_procedural_suns.size ? xgravity.values.bg_procedural_suns.size[1] : + size_minimum) - size_minimum) + size_minimum) * cell_px, + ratio = image_cache[chosen].naturalWidth / image_cache[chosen].naturalHeight, + display_angle = Math.random() * Math.PI * 2, + distance = Math.random() * (maximum_radius * .65); + + active_suns.push({ + image : image_cache[chosen], + offset_x : Math.cos(display_angle) * distance, + offset_y : Math.sin(display_angle) * distance, + w : ratio >= 1 ? sun_px : sun_px * ratio, + h : ratio >= 1 ? sun_px / ratio : sun_px, + // Array ordenado: [0]=fondo, [1]=medio, [2]=frente + sprites : [ + { angle : Math.random() * Math.PI * 2, alpha : 1.0 }, // Fondo: baja de 1 a 0 + { angle : Math.random() * Math.PI * 2, alpha : 1.0 }, // Medio: base opaca constante a 1 + { angle : Math.random() * Math.PI * 2, alpha : 0.0 } // Frente: sube de 0 a 1 + ], + duration : Math.random() * (timer_range[1] - timer_range[0]) + timer_range[0], + elapsed : 0, + timer_range : timer_range + }); + + }; + + }; + + is_ready = true; + + }; + + this.resize = (width, height) => { + is_ready || build_universe(width, height); + }; + + this.render = (context, width, height, delta_time) => { + if(!is_ready || !base_canvas){ + context.fillStyle = "#020408"; + context.fillRect(0, 0, width, height); + return; + }; + + const screen_cx = width / 2, + screen_cy = height / 2, + draw_x = screen_cx - (universe_size / 2), + draw_y = screen_cy - (universe_size / 2); + + // 1. Capas 1, 2 y 3 pre-renderizadas + context.drawImage(base_canvas, draw_x, draw_y); + + // 2. Capa 4: Renderizado de soles vivos + for(let i = 0; i < active_suns.length; i ++){ + + const sun = active_suns[i], + progress = Math.min((sun.elapsed += delta_time) / sun.duration, 1); + + // Sprite 0 (fondo) baja de 1 a 0 + sun.sprites[0].alpha = 1.0 - progress; + // Sprite 1 (medio) se mantiene siempre sólido al 100% + sun.sprites[1].alpha = 1.0; + // Sprite 2 (frente) sube de 0 a 1 al unísono + sun.sprites[2].alpha = progress; + + // Al completar el tiempo + if(progress >= 1){ + + // 1. Extraemos el sprite del fondo (que ya está a alpha 0) + const old_bottom = sun.sprites.shift(); + + // 2. Le asignamos un nuevo ángulo aleatorio (totalmente invisible porque alpha = 0) + old_bottom.angle = Math.random() * Math.PI * 2; + old_bottom.alpha = 0.0; + + // 3. Pasa al frente de la pila (nuevo Sprite 2) + sun.sprites.push(old_bottom); + + // 4. Reiniciamos temporizador con nueva duración aleatoria + sun.elapsed = 0; + sun.duration = Math.random() * (sun.timer_range[1] - sun.timer_range[0]) + sun.timer_range[0]; + + }; + + // Dibujar los 3 sprites en orden estricto de profundidad + // for(let sprite = 0; sprite < sun.sprites.length; sprite++){ + for(const sprite of sun.sprites) + // const sp = sun.sprites[s]; + if(sprite.alpha > 0){ + context.save(); + context.globalAlpha = sprite.alpha; + context.translate(screen_cx + sun.offset_x, screen_cy + sun.offset_y); + context.rotate(sprite.angle); + context.drawImage(sun.image, -sun.w / 2, -sun.h / 2, sun.w, sun.h); + context.restore(); + }; + + context.globalAlpha = 1.0; + + }; + + // 3. Capa 5: Planetas pre-renderizados + planets_canvas && context.drawImage(planets_canvas, draw_x, draw_y); + + }; + + constructor(); + + }; + + // Límite de seguridad por capa para evitar bucles infinitos en caso de probablity >= 1 + ProceduralBackground.MAXIMUM_ITERATIONS = 50; + + return ProceduralBackground; +})(); \ No newline at end of file diff --git a/Public/ecma/Backgrounds/StarsBackground.ecma.js b/Public/ecma/Backgrounds/StarsBackground.ecma.js new file mode 100644 index 0000000..ebd2f86 --- /dev/null +++ b/Public/ecma/Backgrounds/StarsBackground.ecma.js @@ -0,0 +1,66 @@ +"use strict"; + +const StarsBackground = (function(){ + + const StarsBackground = function(xgravity){ + + const self = this; + const stars = []; + + const init_stars = (width, height) => { + stars.length = 0; + for(let i = 0; i < xgravity.values.bg_stars_count; i++) + stars.push({ + x : Math.random() * width, + y : Math.random() * height, + size : Math.random() * (xgravity.values.bg_stars_size_maximum - xgravity.values.bg_stars_size_minimum) + xgravity.values.bg_stars_size_minimum, + speed : Math.random() * (xgravity.values.bg_stars_speed_maximum - xgravity.values.bg_stars_speed_minimum) + xgravity.values.bg_stars_speed_minimum, + alpha : Math.random() * 0.8 + 0.2 + }); + }; + + this.resize = (width, height) => { + init_stars(width, height); + }; + + this.render = (ctx, width, height, delta_time) => { + + ctx.fillStyle = "#020408"; + ctx.fillRect(0, 0, width, height); + + stars.length || init_stars(width, height); + + for(const star of stars){ + + star.x += xgravity.values.bg_stars_direction_x * star.speed * (delta_time * 60); + star.y += xgravity.values.bg_stars_direction_y * star.speed * (delta_time * 60); + + if(star.y > height){ + star.y = 0; + star.x = Math.random() * width; + }else if(star.y < 0){ + star.y = height; + star.x = Math.random() * width; + }; + + if(star.x > width){ + star.x = 0; + star.y = Math.random() * height; + }else if(star.x < 0){ + star.x = width; + star.y = Math.random() * height; + }; + + ctx.fillStyle = Common.string_variables(xgravity.values.bg_stars_color, { + alpha : star.alpha.toFixed(2) + }); + ctx.fillRect(star.x, star.y, star.size, star.size); + + }; + + }; + + }; + + return StarsBackground; +})(); \ No newline at end of file diff --git a/Public/ecma/Fx/CockpitImpactFx.ecma.js b/Public/ecma/Fx/CockpitImpactFx.ecma.js new file mode 100644 index 0000000..02b7ec2 --- /dev/null +++ b/Public/ecma/Fx/CockpitImpactFx.ecma.js @@ -0,0 +1,87 @@ +"use strict"; + +const CockpitImpactFx = (function(){ + + const CockpitImpactFx = function(xgravity){ + + let flash_alpha = 0; + let shards = []; + + this.trigger = () => { + flash_alpha = 0.55; // Fogonazo rápido sobre el cristal + shards = []; // Sin metralla de piedras + }; + + this.update = (delta_time) => { + if(flash_alpha > 0){ + flash_alpha = Math.max(0, flash_alpha - (3.2 * delta_time)); + } + + for(let i = shards.length - 1; i >= 0; i--){ + const s = shards[i]; + s.life -= s.decay * delta_time; + if(s.life <= 0){ + shards.splice(i, 1); + continue; + } + s.x += s.vx * delta_time; + s.y += s.vy * delta_time; + s.angle += s.angular_vel * delta_time; + s.vx *= 0.96; + s.vy *= 0.96; + } + }; + + this.render = (ctx, width, height) => { + const cx = width / 2; + const cy = height / 2; + + // Flash blanco en pantalla completa + if(flash_alpha > 0){ + ctx.save(); + ctx.fillStyle = "#ffffff"; + ctx.globalAlpha = Math.min(0.7, flash_alpha); + ctx.fillRect(0, 0, width, height); + ctx.restore(); + } + + // Esquirlas de roca sólidas + if(shards.length > 0){ + ctx.save(); + ctx.translate(cx, cy); + + for(let i = 0; i < shards.length; i++){ + const s = shards[i]; + ctx.save(); + ctx.translate(s.x, s.y); + ctx.rotate(s.angle); + ctx.fillStyle = s.color; + ctx.globalAlpha = Math.max(0, s.life); + + ctx.beginPath(); + ctx.moveTo(s.vertices[0].x, s.vertices[0].y); + for(let v = 1; v < s.vertices.length; v++){ + ctx.lineTo(s.vertices[v].x, s.vertices[v].y); + } + ctx.closePath(); + ctx.fill(); + + ctx.lineWidth = 1.0; + ctx.strokeStyle = "rgba(15, 23, 42, 0.85)"; + ctx.stroke(); + + ctx.restore(); + } + + ctx.restore(); + } + }; + + this.reset = () => { + flash_alpha = 0; + shards = []; + }; + }; + + return CockpitImpactFx; +})(); \ No newline at end of file diff --git a/Public/ecma/Fx/DeflectionSparksFx.ecma.js b/Public/ecma/Fx/DeflectionSparksFx.ecma.js new file mode 100644 index 0000000..6535a07 --- /dev/null +++ b/Public/ecma/Fx/DeflectionSparksFx.ecma.js @@ -0,0 +1,77 @@ +"use strict"; + +const DeflectionSparksFx = (function(){ + + const DeflectionSparksFx = function(xgravity){ + + let sparks = []; + + this.add = (cell_x, cell_y, normal_x, normal_y, intensity = 1.0) => { + const base_count = Math.floor(10 + (intensity * 25)); + const count = Math.min(60, base_count); + const base_angle = Math.atan2(normal_y, normal_x); + + for(let i = 0; i < count; i++){ + const spread = (Math.random() - 0.5) * (Math.PI * 0.7); + const spark_angle = base_angle + spread; + const speed = (Math.random() * 12 + 6) * Math.min(2.5, intensity); + const life = (Math.random() * 0.18) + 0.08; + + sparks.push({ + x : cell_x, + y : cell_y, + vx : Math.cos(spark_angle) * speed, + vy : Math.sin(spark_angle) * speed, + len : (Math.random() * 0.3) + 0.15, + life : life, + max_life : life, + color : Math.random() > 0.3 ? "#fef08a" : "#f97316" + }); + } + }; + + this.update = (delta_time) => { + for(let i = sparks.length - 1; i >= 0; i--){ + const s = sparks[i]; + s.life -= delta_time; + if(s.life <= 0){ + sparks.splice(i, 1); + continue; + } + s.x += s.vx * delta_time; + s.y += s.vy * delta_time; + s.vx *= 0.90; + s.vy *= 0.90; + } + }; + + this.render = (ctx, cell_px, cx, cy) => { + for(let i = 0; i < sparks.length; i++){ + const s = sparks[i]; + const px = cx + (s.x * cell_px); + const py = cy + (s.y * cell_px); + const progress = Math.max(0, s.life / s.max_life); + + const spd = Math.sqrt(s.vx * s.vx + s.vy * s.vy); + const dir_x = spd > 0.001 ? s.vx / spd : 0; + const dir_y = spd > 0.001 ? s.vy / spd : 0; + const tail_length_px = (s.len || 0.25) * cell_px * Math.min(2.5, spd * 0.15); + + ctx.lineWidth = cell_px * 0.06; + ctx.strokeStyle = s.color; + ctx.globalAlpha = progress; + + ctx.beginPath(); + ctx.moveTo(px, py); + ctx.lineTo(px - (dir_x * tail_length_px), py - (dir_y * tail_length_px)); + ctx.stroke(); + } + }; + + this.reset = () => { + sparks = []; + }; + }; + + return DeflectionSparksFx; +})(); \ No newline at end of file diff --git a/Public/ecma/Fx/HullHitExplosionsFx.ecma.js b/Public/ecma/Fx/HullHitExplosionsFx.ecma.js new file mode 100644 index 0000000..081ac85 --- /dev/null +++ b/Public/ecma/Fx/HullHitExplosionsFx.ecma.js @@ -0,0 +1,134 @@ +"use strict"; + +const HullHitExplosionsFx = (function(){ + + const HullHitExplosionsFx = function(xgravity){ + + let shockwaves = []; + let particles = []; + + this.add = (cell_x, cell_y) => { + if(xgravity.audio){ + xgravity.audio.play("hit_explosion"); + } + + const particles_range = xgravity.settings.get("explosion_particles_range", null, [45, 75]); + const spread_reach = xgravity.settings.get("explosion_particles_spread", null, 11.0); + const base_shockwave_radius = xgravity.settings.get("explosion_shockwave_radius", null, 4.5); + + const min_p = Math.min(particles_range[0], particles_range[1]); + const max_p = Math.max(particles_range[0], particles_range[1]); + const count = Math.floor(min_p + Math.random() * (max_p - min_p + 1)); + + // Onda roja más grande + shockwaves.push({ + x : cell_x, + y : cell_y, + radius : 0.4, + max_radius : base_shockwave_radius * 1.15, + speed : 8.0, + angle : Math.random() * Math.PI * 2, + aspect_ratio : 0.42 + (Math.random() * 0.18), + alpha : 1.0, + color_rgb : "239, 68, 68" + }); + + // Metralla naranja/roja/amarilla + for(let i = 0; i < count; i++){ + const angle = Math.random() * Math.PI * 2; + const speed = (Math.random() * spread_reach) + 1.2; + const life = (Math.random() * 0.38) + 0.28; + const rim_rgb = Math.random() > 0.4 ? "239, 68, 68" : (Math.random() > 0.5 ? "249, 115, 22" : "251, 191, 36"); + + particles.push({ + x : cell_x, + y : cell_y, + vx : Math.cos(angle) * speed, + vy : Math.sin(angle) * speed, + size : (Math.random() * 0.16) + 0.08, + life : life, + max_life : life, + core_rgb : "255, 255, 255", + rim_rgb : rim_rgb + }); + } + }; + + this.update = (delta_time) => { + for(let i = shockwaves.length - 1; i >= 0; i--){ + const s = shockwaves[i]; + s.radius += s.speed * delta_time; + s.alpha = Math.max(0, 1.0 - (s.radius / s.max_radius)); + if(s.radius >= s.max_radius){ + shockwaves.splice(i, 1); + } + } + + for(let i = particles.length - 1; i >= 0; i--){ + const p = particles[i]; + p.life -= delta_time; + if(p.life <= 0){ + particles.splice(i, 1); + continue; + } + p.x += p.vx * delta_time; + p.y += p.vy * delta_time; + p.vx *= 0.93; + p.vy *= 0.93; + } + }; + + this.render = (ctx, cell_px, cx, cy) => { + for(let i = 0; i < shockwaves.length; i++){ + const s = shockwaves[i]; + const px = cx + (s.x * cell_px); + const py = cy + (s.y * cell_px); + const r_px = s.radius * cell_px; + + ctx.save(); + ctx.translate(px, py); + ctx.rotate(s.angle); + ctx.scale(1.0, s.aspect_ratio); + + ctx.lineWidth = cell_px * 0.14 * s.alpha; + ctx.strokeStyle = `rgba(${s.color_rgb}, ${s.alpha * 0.85})`; + ctx.beginPath(); + ctx.arc(0, 0, r_px, 0, Math.PI * 2); + ctx.stroke(); + + ctx.lineWidth = cell_px * 0.05 * s.alpha; + ctx.strokeStyle = `rgba(255, 255, 255, ${s.alpha * 0.6})`; + ctx.beginPath(); + ctx.arc(0, 0, r_px * 0.82, 0, Math.PI * 2); + ctx.stroke(); + + ctx.restore(); + } + + for(let i = 0; i < particles.length; i++){ + const p = particles[i]; + const px = cx + (p.x * cell_px); + const py = cy + (p.y * cell_px); + const r_px = p.size * cell_px; + const progress = Math.max(0, p.life / p.max_life); + + const grad = ctx.createRadialGradient(px, py, 0, px, py, r_px); + grad.addColorStop(0.0, `rgba(${p.core_rgb}, ${progress})`); + grad.addColorStop(0.45, `rgba(${p.rim_rgb}, ${progress * 0.75})`); + grad.addColorStop(1.0, `rgba(${p.rim_rgb}, 0.0)`); + + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(px, py, r_px, 0, Math.PI * 2); + ctx.fill(); + } + }; + + this.reset = () => { + shockwaves = []; + particles = []; + }; + }; + + return HullHitExplosionsFx; +})(); \ No newline at end of file diff --git a/Public/ecma/Fx/ShieldExplosionsFx.ecma.js b/Public/ecma/Fx/ShieldExplosionsFx.ecma.js new file mode 100644 index 0000000..e1b0a7a --- /dev/null +++ b/Public/ecma/Fx/ShieldExplosionsFx.ecma.js @@ -0,0 +1,138 @@ +"use strict"; + +const ShieldExplosionsFx = (function(){ + + const ShieldExplosionsFx = function(xgravity){ + + let shockwaves = []; + let particles = []; + + this.add = (cell_x, cell_y) => { + if(xgravity.audio){ + xgravity.audio.play("shield_explosion"); + } + + const particles_range = xgravity.settings.get("explosion_particles_range", null, [45, 75]); + const spread_reach = xgravity.settings.get("explosion_particles_spread", null, 11.0); + const base_shockwave_radius = xgravity.settings.get("explosion_shockwave_radius", null, 4.5); + + const min_p = Math.min(particles_range[0], particles_range[1]); + const max_p = Math.max(particles_range[0], particles_range[1]); + const count = Math.floor(min_p + Math.random() * (max_p - min_p + 1)); + + // Onda elíptica azulada + shockwaves.push({ + x : cell_x, + y : cell_y, + radius : 0.4, + max_radius : base_shockwave_radius, + speed : 9.5, + angle : Math.random() * Math.PI * 2, + aspect_ratio : 0.42 + (Math.random() * 0.18), + alpha : 1.0, + color_rgb : "56, 189, 248" + }); + + // Micropartículas de plasma azul + for(let i = 0; i < count; i++){ + const angle = Math.random() * Math.PI * 2; + const speed = (Math.random() * spread_reach) + 1.2; + const life = (Math.random() * 0.38) + 0.28; + const rim_rgb = Math.random() > 0.4 ? "56, 189, 248" : (Math.random() > 0.5 ? "186, 230, 253" : "2, 132, 199"); + + particles.push({ + x : cell_x, + y : cell_y, + vx : Math.cos(angle) * speed, + vy : Math.sin(angle) * speed, + size : (Math.random() * 0.16) + 0.08, + life : life, + max_life : life, + core_rgb : "255, 255, 255", + rim_rgb : rim_rgb + }); + } + }; + + this.update = (delta_time) => { + // Ondas + for(let i = shockwaves.length - 1; i >= 0; i--){ + const s = shockwaves[i]; + s.radius += s.speed * delta_time; + s.alpha = Math.max(0, 1.0 - (s.radius / s.max_radius)); + if(s.radius >= s.max_radius){ + shockwaves.splice(i, 1); + } + } + + // Partículas + for(let i = particles.length - 1; i >= 0; i--){ + const p = particles[i]; + p.life -= delta_time; + if(p.life <= 0){ + particles.splice(i, 1); + continue; + } + p.x += p.vx * delta_time; + p.y += p.vy * delta_time; + p.vx *= 0.93; + p.vy *= 0.93; + } + }; + + this.render = (ctx, cell_px, cx, cy) => { + // Ondas + for(let i = 0; i < shockwaves.length; i++){ + const s = shockwaves[i]; + const px = cx + (s.x * cell_px); + const py = cy + (s.y * cell_px); + const r_px = s.radius * cell_px; + + ctx.save(); + ctx.translate(px, py); + ctx.rotate(s.angle); + ctx.scale(1.0, s.aspect_ratio); + + ctx.lineWidth = cell_px * 0.14 * s.alpha; + ctx.strokeStyle = `rgba(${s.color_rgb}, ${s.alpha * 0.85})`; + ctx.beginPath(); + ctx.arc(0, 0, r_px, 0, Math.PI * 2); + ctx.stroke(); + + ctx.lineWidth = cell_px * 0.05 * s.alpha; + ctx.strokeStyle = `rgba(255, 255, 255, ${s.alpha * 0.6})`; + ctx.beginPath(); + ctx.arc(0, 0, r_px * 0.82, 0, Math.PI * 2); + ctx.stroke(); + + ctx.restore(); + } + + // Partículas + for(let i = 0; i < particles.length; i++){ + const p = particles[i]; + const px = cx + (p.x * cell_px); + const py = cy + (p.y * cell_px); + const r_px = p.size * cell_px; + const progress = Math.max(0, p.life / p.max_life); + + const grad = ctx.createRadialGradient(px, py, 0, px, py, r_px); + grad.addColorStop(0.0, `rgba(${p.core_rgb}, ${progress})`); + grad.addColorStop(0.45, `rgba(${p.rim_rgb}, ${progress * 0.75})`); + grad.addColorStop(1.0, `rgba(${p.rim_rgb}, 0.0)`); + + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(px, py, r_px, 0, Math.PI * 2); + ctx.fill(); + } + }; + + this.reset = () => { + shockwaves = []; + particles = []; + }; + }; + + return ShieldExplosionsFx; +})(); \ No newline at end of file diff --git a/Public/ecma/Managers/AudioManager.ecma.js b/Public/ecma/Managers/AudioManager.ecma.js new file mode 100644 index 0000000..a38cd09 --- /dev/null +++ b/Public/ecma/Managers/AudioManager.ecma.js @@ -0,0 +1,119 @@ +"use strict"; + +const AudioManager = (function(){ + + const AudioManager = function(xgravity){ + + const self = this; + let context = null; + let sfx_bus = null; + let is_muted = false; + + const sound_registry = {}; + + const init_audio = () => { + if(!context){ + + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + + if(AudioContextClass){ + + // Cadena Master: Bus -> Saturador -> Compresor/Limitador -> Altavoces + const limiter = (context = new AudioContextClass()).createDynamicsCompressor(), + saturator = context.createWaveShaper(); + + limiter.threshold.setValueAtTime(-4, context.currentTime); + limiter.knee.setValueAtTime(6, context.currentTime); + limiter.ratio.setValueAtTime(16, context.currentTime); + limiter.attack.setValueAtTime(0.003, context.currentTime); + limiter.release.setValueAtTime(0.18, context.currentTime); + + saturator.curve = make_distortion_curve(15); + saturator.oversample = "2x"; + + sfx_bus = context.createGain(); + sfx_bus.gain.setValueAtTime(1.0, context.currentTime); + + sfx_bus.connect(saturator); + saturator.connect(limiter); + limiter.connect(context.destination); + + }; + + }; + + context && context.state === "suspended" && context.resume(); + + }; + + const make_distortion_curve = (amount = null) => { + + const curve = new Float32Array(xgravity.values.audio_samples); + const degrees = Math.PI / 180; + + if(amount === null) + amount = xgravity.values.audio_default_amount; + + for(let i = 0; i < xgravity.values.audio_samples; ++ i){ + + const x = (i * 2) / xgravity.values.audio_samples - 1; + + curve[i] = ((3 + amount) * x * 20 * degrees) / (Math.PI + amount * Math.abs(x)); + + }; + + return curve; + }; + + this.register_sound = (id, generator_fn) => { + sound_registry[id] = generator_fn; + }; + + this.play = (sound_id, custom_volume = null) => { + if(is_muted) return; + init_audio(); + if(!context || !sfx_bus) return; + + const generator = sound_registry[sound_id]; + if(typeof generator === "function"){ + const master_vol = custom_volume !== null + ? custom_volume + : xgravity.settings.get("explosion_volume", null, 1.0); + + generator(context, sfx_bus, master_vol); + } + }; + + this.set_mute = (state) => { + is_muted = Boolean(state); + if(sfx_bus && context){ + sfx_bus.gain.setValueAtTime(is_muted ? 0 : 1.0, context.currentTime); + } + }; + + const constructor = () => { + if(typeof ShieldExplosionSound !== "undefined"){ + this.register_sound("shield_explosion", ShieldExplosionSound); + } + if(typeof HitExplosionSound !== "undefined"){ + this.register_sound("hit_explosion", HitExplosionSound); + } + if(typeof ShieldLowAlarmSound !== "undefined"){ + this.register_sound("shield_low_alarm", ShieldLowAlarmSound); + } + if(typeof PerimeterWarningSound !== "undefined"){ + this.register_sound("perimeter_warning", PerimeterWarningSound); + } + if(typeof MothershipImpactSound !== "undefined"){ + this.register_sound("mothership_impact", MothershipImpactSound); + } + if(typeof AsteroidThreatAlarmSound !== "undefined"){ + this.register_sound("asteroid_threat", AsteroidThreatAlarmSound); + } + }; + + constructor(); + }; + + return AudioManager; +})(); \ No newline at end of file diff --git a/Public/ecma/Managers/EnemiesManager.ecma.js b/Public/ecma/Managers/EnemiesManager.ecma.js new file mode 100644 index 0000000..ec4ed47 --- /dev/null +++ b/Public/ecma/Managers/EnemiesManager.ecma.js @@ -0,0 +1,748 @@ +"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; +})(); \ No newline at end of file diff --git a/Public/ecma/Managers/ExplosionsManager.ecma.js b/Public/ecma/Managers/ExplosionsManager.ecma.js new file mode 100644 index 0000000..b88e89a --- /dev/null +++ b/Public/ecma/Managers/ExplosionsManager.ecma.js @@ -0,0 +1,70 @@ +"use strict"; + +const ExplosionsManager = (function(){ + + const ExplosionsManager = function(xgravity){ + + const self = this; + + const shield_fx = new ShieldExplosionsFx(xgravity); + const hull_fx = new HullHitExplosionsFx(xgravity); + const sparks_fx = new DeflectionSparksFx(xgravity); + const cockpit_fx = new CockpitImpactFx(xgravity); + + const get_cell_size = (width, height) => { + const cells = xgravity.settings.get("cells", null, 40); + return (width < height ? width : height) / cells; + }; + + this.add = (cell_x, cell_y, type = "shield") => { + if(type === "shield"){ + shield_fx.add(cell_x, cell_y); + } else { + hull_fx.add(cell_x, cell_y); + } + }; + + this.add_sparks = (cell_x, cell_y, normal_x, normal_y, intensity) => { + sparks_fx.add(cell_x, cell_y, normal_x, normal_y, intensity); + }; + + this.add_mothership_impact = () => { + cockpit_fx.trigger(); + }; + + this.update = (delta_time) => { + shield_fx.update(delta_time); + hull_fx.update(delta_time); + sparks_fx.update(delta_time); + cockpit_fx.update(delta_time); + }; + + // Renderizado del plano de batalla (ondas y chispas espaciales) + this.render = (ctx, width, height) => { + const cell_px = get_cell_size(width, height); + const cx = width / 2; + const cy = height / 2; + + ctx.save(); + ctx.globalCompositeOperation = "lighter"; + shield_fx.render(ctx, cell_px, cx, cy); + hull_fx.render(ctx, cell_px, cx, cy); + sparks_fx.render(ctx, cell_px, cx, cy); + ctx.restore(); + }; + + // Renderizado exclusivo de la cabina (pantalla completa y rocas sólidas) + this.render_cockpit = (ctx, width, height) => { + cockpit_fx.render(ctx, width, height); + }; + + this.reset = () => { + shield_fx.reset(); + hull_fx.reset(); + sparks_fx.reset(); + cockpit_fx.reset(); + }; + }; + + return ExplosionsManager; +})(); \ No newline at end of file diff --git a/Public/ecma/Managers/I18NManager.ecma.js b/Public/ecma/Managers/I18NManager.ecma.js new file mode 100644 index 0000000..d07ea40 --- /dev/null +++ b/Public/ecma/Managers/I18NManager.ecma.js @@ -0,0 +1,81 @@ +"use strict"; + +const I18NManager = (function(){ + + const I18NManager = function(xgravity){ + + const self = this, + sentences = {}; + let language = "espanol"; + + const constructor = () => { + [i18n, typeof custom_i18n != "undefined" ? custom_i18n : {}].forEach(block => { + self.add(block, true); + }); + self.set_language(xgravity.settings.get("language"), null, Object.keys(sentences)[0] || language); + }; + + this.add = (new_sentences, overwrite = true) => { + for(const language in new_sentences){ + if(!sentences[language]) + sentences[language] = {}; + for(const key in new_sentences[language]) + if(overwrite || sentences[language][key] === undefined) + sentences[language][key] = new_sentences[language][key]; + }; + }; + + const get_sentence = (strings, _default) => { + + const done = []; + + if(typeof strings == "string") + strings = [strings]; + + for(const current of [language].concat(Object.keys(sentences))) + if(!done.includes(current)){ + done.push(current); + for(const key of strings) + if(sentences[current][key] !== undefined) + return sentences[current][key]; + }; + if(_default !== null) + return _default; + for(const text of strings) + return text; + return ""; + }; + + this.get = (strings, inputs = null, _default = null) => Common.string_variables(get_sentence(strings, _default), inputs); + + this.update = () => { + document.querySelectorAll("[data-i18n]").forEach(element => { + + const text = self.get(element.getAttribute("data-i18n")); + + if(element.getAttribute("data-i18n-without") != "true") + element.textContent = text; + ["title", "alt", "placeholder"].forEach(key => { + element.hasAttribute(key) && element.setAttribute(key, text); + }); + + }) + }; + + this.set_language = new_language => { + if(language != new_language && sentences[new_language]){ + language = new_language; + self.update(); + }; + }; + + this.get_languages = () => Object.keys(sentences); + + this.get_current_language = () => language; + + constructor(); + + }; + + return I18NManager; +})(); \ No newline at end of file diff --git a/Public/ecma/Managers/SettingsManager.ecma.js b/Public/ecma/Managers/SettingsManager.ecma.js new file mode 100644 index 0000000..eb489e7 --- /dev/null +++ b/Public/ecma/Managers/SettingsManager.ecma.js @@ -0,0 +1,40 @@ +"use strict"; + +const SettingsManager = (function(){ + + const SettingsManager = function(xgravity){ + + const self = this, + data = {}; + + const constructor = () => { + self.add(settings, true); + }; + + this.add = (block, overwrite = true) => { + for(const key in block) + if(overwrite || data[key] === undefined) + data[key] = block[key]; + }; + + this.get = (keys, inputs = null, _default = null) => Common.get_value(keys, [inputs, data], _default); + + this.get_difficulty_preset = () => { + const diff_key = self.get("current_difficulty", null, "average"); + const presets = self.get("difficulty_presets", null, {}); + return presets[diff_key] || presets["average"]; + }; + + this.get_effective_stat = (base_key, mult_key) => { + const base_val = self.get(base_key, null, 1.0); + const preset = self.get_difficulty_preset(); + const mult = (preset && typeof preset[mult_key] === "number") ? preset[mult_key] : 1.0; + return base_val * mult; + }; + + constructor(); + + }; + + return SettingsManager; +})(); \ No newline at end of file diff --git a/Public/ecma/Models/AsteroidEnemyModel.ecma.js b/Public/ecma/Models/AsteroidEnemyModel.ecma.js new file mode 100644 index 0000000..8fd6ad6 --- /dev/null +++ b/Public/ecma/Models/AsteroidEnemyModel.ecma.js @@ -0,0 +1,174 @@ +"use strict"; + +const AsteroidEnemyModel = (function(){ + + const AsteroidEnemyModel = function(xgravity, config = {}){ + + const self = this; + + const resolve_image = (img) => { + if(Array.isArray(img) && img.length > 0){ + return img[Math.floor(Math.random() * img.length)]; + } + return typeof img === "string" ? img : null; + }; + + const image_src = resolve_image(config.image); + const base_size = config.size || 2.4; + const points = config.points || 80; + const mass = config.mass || 7.0; + let health = config.health || 50; + const max_health = health; + + let towards_mothership = false; + const rotation_speed = (Math.random() - 0.5) * 1.5; + + const entity = { + x : 0, + y : 0, + vx : 0, + vy : 0, + angle : Math.random() * Math.PI * 2, + scale : 1.0, + alpha : 1.0, + ready : false, + alive : true, + age : 0, + config : config + }; + + const spawn_strategy = SpawnStrategies.get(config.spawn_mode || "perimeter"); + const behavior_strategy = BehaviorStrategies.get(config.behavior || "cross_screen"); + + this.is_asteroid = true; + this.is_targeted_towards_mothership = () => towards_mothership; + this.has_impacted_mothership = () => Boolean(entity.impacted_mothership); + + // Lanzamiento hacia el centro + this.launch_towards_mothership = (lvx, lvy) => { + entity.vx = lvx; + entity.vy = lvy; + towards_mothership = true; + entity.alpha = 1.0; // Garantiza 100% de opacidad sin transparencia de spawn + }; + + // Si vuela hacia la nodriza, queremos que se pinte en render_over por encima del plano de combate + // Cuando towards_mothership = true, se fuerza a FALSE para que el gestor lo pinte en render_over + this.is_ready = () => entity.ready && !towards_mothership; + this.is_alive = () => entity.alive; + this.get_points = () => points; + this.get_age = () => entity.age; + this.get_mass = () => mass; + this.get_health = () => health; + this.get_max_health = () => max_health; + this.get_velocity = () => ({ vx: entity.vx, vy: entity.vy }); + + this.get_state = () => ({ + x : entity.x, + y : entity.y, + angle : entity.angle, + scale : entity.scale, + alpha : 1.0, // Siempre nítido en el eje Z + size : base_size, + image_src : image_src, + ready : entity.ready && !towards_mothership, // En render_over cuando vuela al cristal + alive : entity.alive, + health : health, + max_health : max_health, + radius : (base_size * entity.scale) * 0.45, + fallback_color : towards_mothership ? "#ef4444" : "#94a3b8" + }); + + this.get_bounds = () => ({ + x : entity.x, + y : entity.y, + radius : (base_size * entity.scale) * 0.45 + }); + + this.apply_impulse = (ivx, ivy) => { + entity.vx += ivx; + entity.vy += ivy; + // Si el dron lo choca con escudo, desvía la trayectoria y frena el crecimiento + towards_mothership = false; + entity.scale = Math.min(entity.scale, 2.5); + }; + + this.set_velocity = (nvx, nvy) => { + entity.vx = nvx; + entity.vy = nvy; + }; + + this.set_position = (nx, ny) => { + entity.x = nx; + entity.y = ny; + }; + + this.take_damage = (amount) => { + health = Math.max(0, health - amount); + if(health <= 0){ + entity.alive = false; + } + return entity.alive; + }; + + this.on_hit = () => { + entity.alive = false; + }; + + this.update = (delta_time, drone_info, all_enemies) => { + if(!entity.alive) return; + entity.age += delta_time; + + if(!entity.ready){ + spawn_strategy.update(entity, delta_time); + } else if(towards_mothership){ + // 1. VIAJE HACIA EL CRISTAL DE LA CABINA (EJE Z) + entity.x += entity.vx * delta_time; + entity.y += entity.vy * delta_time; + + // Atracción suave hacia el centro absoluto (0, 0) + const dist_center = Math.sqrt(entity.x * entity.x + entity.y * entity.y); + if(dist_center > 0.1){ + const pull_speed = 6.0; + entity.vx = -(entity.x / dist_center) * pull_speed; + entity.vy = -(entity.y / dist_center) * pull_speed; + } + + // Crecimiento de escala hasta llegar a un tamaño de 40 celdas + // Tamaño actual = base_size * entity.scale + const target_cells = 40.0; + const scale_target = target_cells / base_size; // ~16.66 + const growth_speed = 10.5; // Rapidez de expansión visual hacia la cámara + + entity.scale += growth_speed * delta_time; + + // Al alcanzar el tamaño gigante, se estrella en la pantalla + if((base_size * entity.scale) >= target_cells){ + entity.alive = false; + entity.impacted_mothership = true; + self.impacted_mothership = true; // <-- Asignado también directamente a la instancia + } + } else { + // Vuelo normal + behavior_strategy(entity, delta_time, drone_info, all_enemies); + + // Si se sale del marco de juego (> 24.5 celdas) se elimina definitivamente + const dist_center = Math.sqrt(entity.x * entity.x + entity.y * entity.y); + if(dist_center > 24.5 && entity.age > 1.2){ + entity.alive = false; + } + } + + // Giro sobre su eje + entity.angle += rotation_speed * delta_time; + }; + + const constructor = () => { + spawn_strategy.init(entity, config, xgravity); + }; + + constructor(); + }; + + return AsteroidEnemyModel; +})(); \ No newline at end of file diff --git a/Public/ecma/Models/AsteroidThrowerEnemyModel.ecma.js b/Public/ecma/Models/AsteroidThrowerEnemyModel.ecma.js new file mode 100644 index 0000000..cf0a570 --- /dev/null +++ b/Public/ecma/Models/AsteroidThrowerEnemyModel.ecma.js @@ -0,0 +1,112 @@ +"use strict"; + +const AsteroidThrowerEnemyModel = (function(){ + + const AsteroidThrowerEnemyModel = function(xgravity, config = {}){ + + const self = this; + + const resolve_image = (img) => { + if(Array.isArray(img) && img.length > 0){ + return img[Math.floor(Math.random() * img.length)]; + } + return typeof img === "string" ? img : null; + }; + + const image_src = resolve_image(config.image); + const base_size = config.size || 2.0; + const points = config.points || 250; + const mass = config.mass || 2.2; + let health = config.health || 45; + const max_health = health; + + const entity = { + x : 0, + y : 0, + vx : 0, + vy : 0, + angle : 0, + scale : 1.0, + alpha : 1.0, + ready : false, + alive : true, + age : 0, + config : config + }; + + const spawn_strategy = SpawnStrategies.get(config.spawn_mode || "perimeter"); + const behavior_strategy = BehaviorStrategies.get(config.behavior || "thrower_ai"); + + this.is_ready = () => entity.ready; + this.is_alive = () => entity.alive; + this.get_points = () => points; + this.get_age = () => entity.age; + this.get_mass = () => mass; + this.get_health = () => health; + this.get_max_health = () => max_health; + this.get_velocity = () => ({ vx: entity.vx, vy: entity.vy }); + + this.get_state = () => ({ + x : entity.x, + y : entity.y, + angle : entity.angle, + scale : entity.scale, + alpha : entity.alpha, + size : base_size, + image_src : image_src, + ready : entity.ready, + alive : entity.alive, + health : health, + max_health : max_health, + radius : (base_size * entity.scale) * 0.45, + fallback_color : entity.ready ? "#eab308" : "#fef08a" + }); + + this.get_bounds = () => ({ + x : entity.x, + y : entity.y, + radius : (base_size * entity.scale) * 0.45 + }); + + this.apply_impulse = (ivx, ivy) => { + entity.vx += ivx; + entity.vy += ivy; + }; + + this.set_position = (nx, ny) => { + entity.x = nx; + entity.y = ny; + }; + + this.take_damage = (amount) => { + health = Math.max(0, health - amount); + if(health <= 0){ + entity.alive = false; + } + return entity.alive; + }; + + this.on_hit = () => { + entity.alive = false; + }; + + this.update = (delta_time, drone_info, all_enemies) => { + if(!entity.alive) return; + entity.age += delta_time; + + if(!entity.ready){ + spawn_strategy.update(entity, delta_time); + } else { + behavior_strategy(entity, delta_time, drone_info, all_enemies); + } + }; + + const constructor = () => { + spawn_strategy.init(entity, config, xgravity); + }; + + constructor(); + }; + + return AsteroidThrowerEnemyModel; +})(); \ No newline at end of file diff --git a/Public/ecma/Models/BouncerEnemyModel.ecma.js b/Public/ecma/Models/BouncerEnemyModel.ecma.js new file mode 100644 index 0000000..5aa1290 --- /dev/null +++ b/Public/ecma/Models/BouncerEnemyModel.ecma.js @@ -0,0 +1,105 @@ +"use strict"; + +const BouncerEnemyModel = (function(){ + + const BouncerEnemyModel = function(xgravity, config = {}){ + + const self = this; + + const entity = { + x : 0, + y : 0, + vx : 0, + vy : 0, + angle : 0, + scale : 1.0, + alpha : 1.0, + ready : false, + alive : true, + age : 0, + config : config + }; + + const base_size = config.size || 2.2; + const points = config.points || 100; + const mass = config.mass || 2.5; + let health = config.health || 60; + const max_health = health; + const image_src = config.image || null; + + const spawn_strategy = SpawnStrategies.get(config.spawn_mode || "perimeter"); + const behavior_strategy = BehaviorStrategies.get(config.behavior || "bounce_screen"); + + this.is_ready = () => entity.ready; + this.is_alive = () => entity.alive; + this.get_points = () => points; + this.get_age = () => entity.age; + this.get_mass = () => mass; + this.get_health = () => health; + this.get_max_health = () => max_health; + this.get_velocity = () => ({ vx: entity.vx, vy: entity.vy }); + + this.get_state = () => ({ + x : entity.x, + y : entity.y, + angle : entity.angle, + scale : entity.scale, + alpha : entity.alpha, + size : base_size, + image_src : image_src, + ready : entity.ready, + alive : entity.alive, + health : health, + max_health : max_health, + radius : (base_size * entity.scale) * 0.45, + fallback_color : entity.ready ? "#10b981" : "#34d399" + }); + + this.get_bounds = () => ({ + x : entity.x, + y : entity.y, + radius : (base_size * entity.scale) * 0.45 + }); + + this.apply_impulse = (ivx, ivy) => { + entity.vx += ivx; + entity.vy += ivy; + }; + + this.set_position = (nx, ny) => { + entity.x = nx; + entity.y = ny; + }; + + this.take_damage = (amount) => { + health = Math.max(0, health - amount); + if(health <= 0){ + entity.alive = false; + } + return entity.alive; + }; + + this.on_hit = () => { + entity.alive = false; + }; + + this.update = (delta_time, drone_info) => { + if(!entity.alive) return; + entity.age += delta_time; + + if(!entity.ready){ + spawn_strategy.update(entity, delta_time); + } else { + behavior_strategy(entity, delta_time, drone_info); + } + }; + + const constructor = () => { + spawn_strategy.init(entity, config, xgravity); + }; + + constructor(); + }; + + return BouncerEnemyModel; +})(); \ No newline at end of file diff --git a/Public/ecma/Models/ChaserEnemyModel.ecma.js b/Public/ecma/Models/ChaserEnemyModel.ecma.js new file mode 100644 index 0000000..e473c6f --- /dev/null +++ b/Public/ecma/Models/ChaserEnemyModel.ecma.js @@ -0,0 +1,111 @@ +"use strict"; + +const ChaserEnemyModel = (function(){ + + const ChaserEnemyModel = function(xgravity, config = {}){ + + const self = this; + + // Objeto de contexto mutable que manipulan las estrategias + const entity = { + x : 0, + y : 0, + vx : 0, + vy : 0, + angle : 0, + scale : 1.0, + alpha : 1.0, + ready : false, + alive : true, + age : 0, + config : config + }; + + const base_size = config.size || 1.8; + const points = config.points || 150; + const mass = config.mass || 1.8; + let health = config.health || 30; + const max_health = health; + const image_src = config.image || null; + + // Enlace con las estrategias según config + const spawn_strategy = SpawnStrategies.get(config.spawn_mode || "overflight"); + const behavior_strategy = BehaviorStrategies.get(config.behavior || "track_drone"); + + // Accesores de estado para EnemiesManager y físicas + this.is_ready = () => entity.ready; + this.is_alive = () => entity.alive; + this.get_points = () => points; + this.get_age = () => entity.age; + this.get_mass = () => mass; + this.get_health = () => health; + this.get_max_health = () => max_health; + this.get_velocity = () => ({ vx: entity.vx, vy: entity.vy }); + + this.get_state = () => ({ + x : entity.x, + y : entity.y, + angle : entity.angle, + scale : entity.scale, + alpha : entity.alpha, + size : base_size, + image_src : image_src, + ready : entity.ready, + alive : entity.alive, + health : health, + max_health : max_health, + radius : (base_size * entity.scale) * 0.45, + fallback_color : entity.ready ? "#a855f7" : "#c084fc" + }); + + this.get_bounds = () => ({ + x : entity.x, + y : entity.y, + radius : (base_size * entity.scale) * 0.45 + }); + + this.apply_impulse = (ivx, ivy) => { + entity.vx += ivx; + entity.vy += ivy; + }; + + this.set_position = (nx, ny) => { + entity.x = nx; + entity.y = ny; + }; + + this.take_damage = (amount) => { + health = Math.max(0, health - amount); + if(health <= 0){ + entity.alive = false; + } + return entity.alive; + }; + + this.on_hit = () => { + entity.alive = false; + }; + + this.update = (delta_time, drone_info) => { + if(!entity.alive) return; + entity.age += delta_time; + + if(!entity.ready){ + // Ejecuta la cinemática de spawn hasta que marque ready = true + spawn_strategy.update(entity, delta_time); + } else { + // Ejecuta la IA de combate + behavior_strategy(entity, delta_time, drone_info); + } + }; + + const constructor = () => { + // Inicializa la posición y cinemática de entrada según la estrategia configurada + spawn_strategy.init(entity, config, xgravity); + }; + + constructor(); + }; + + return ChaserEnemyModel; +})(); \ No newline at end of file diff --git a/Public/ecma/Models/CockpitModel.ecma.js b/Public/ecma/Models/CockpitModel.ecma.js new file mode 100644 index 0000000..63c4ea9 --- /dev/null +++ b/Public/ecma/Models/CockpitModel.ecma.js @@ -0,0 +1,162 @@ +"use strict"; + +const CockpitModel = (function(){ + + const CockpitModel = function(xgravity){ + + const self = this; + let blink_timer = 0; + let alert_state = false; + const max_hull = xgravity.settings.get("base_mothership_hull_max", null, 100.0); + let current_hull = max_hull; + + this.get_health_percentage = () => Math.max(0, current_hull / max_hull); + this.get_hull = () => current_hull; + this.get_max_hull = () => max_hull; + + this.take_damage = (amount) => { + current_hull = Math.max(0, current_hull - amount); + self.set_alert(true); + return current_hull <= 0; // true si explota la cabina (Game Over) + }; + + this.repair = (amount) => { + current_hull = Math.min(max_hull, current_hull + amount); + }; + + this.update = (delta_time) => { + // Autoreparación progresiva regulada por dificultad + const base_repair = xgravity.settings.get("base_mothership_repair_rate", null, 0.75); + const preset = xgravity.settings.get_difficulty_preset(); + const repair_mult = preset ? preset.mothership_repair_mult : 1.0; + + if(current_hull < max_hull && current_hull > 0){ + current_hull = Math.min(max_hull, current_hull + (base_repair * repair_mult * delta_time)); + } + }; + + const get_cell_size = (width, height) => { + const cells = xgravity.settings.get("cells", null, 40); + return (width < height ? width : height) / cells; + }; + + this.set_alert = (state) => { + alert_state = Boolean(state); + }; + + this.get_alert = () => { + return alert_state; + }; + + this.render = (ctx, width, height, delta_time) => { + const cell_px = get_cell_size(width, height); + const cx = width / 2; + const cy = height / 2; + const min_dim = Math.min(width, height); + const max_dim = Math.max(width, height); + + blink_timer += delta_time; + + // Escotilla panorámica ampliada: desborda la dimensión menor + // para que el espacio exterior sea el protagonista absoluto. + const inner_radius = (min_dim / 2) * 1.18; + const outer_radius = max_dim * 0.85; + const frame_thickness = cell_px * 0.6; + + ctx.save(); + + // 1. Efecto sutil de cristal panorámico en el área de combate + const glass_grad = ctx.createLinearGradient(cx - inner_radius, cy - inner_radius, cx + inner_radius, cy + inner_radius); + glass_grad.addColorStop(0, "rgba(56, 189, 248, 0.04)"); + glass_grad.addColorStop(0.5, "rgba(255, 255, 255, 0.01)"); + glass_grad.addColorStop(1, "rgba(2, 132, 199, 0.03)"); + + ctx.fillStyle = glass_grad; + ctx.beginPath(); + ctx.arc(cx, cy, inner_radius, 0, Math.PI * 2); + ctx.fill(); + + // 2. Anillo de la cúpula (Marco perimetral reforzado) + ctx.lineWidth = frame_thickness; + ctx.strokeStyle = "#0b1120"; + ctx.beginPath(); + ctx.arc(cx, cy, inner_radius + (frame_thickness / 2), 0, Math.PI * 2); + ctx.stroke(); + + // Bisel fino interior iluminado + ctx.lineWidth = cell_px * 0.06; + ctx.strokeStyle = "rgba(56, 189, 248, 0.3)"; + ctx.beginPath(); + ctx.arc(cx, cy, inner_radius, 0, Math.PI * 2); + ctx.stroke(); + + // 3. Montantes / Vigas radiales exteriores (8 vigas perimetrales) + const SECTORS = 8; + const beam_width = frame_thickness * 0.75; + + for(let i = 0; i < SECTORS; i++){ + const angle = (i * Math.PI * 2) / SECTORS + (Math.PI / 8); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + const start_x = cx + cos * inner_radius; + const start_y = cy + sin * inner_radius; + const end_x = cx + cos * outer_radius; + const end_y = cy + sin * outer_radius; + + // Viga estructural + ctx.lineWidth = beam_width; + ctx.strokeStyle = "#090d16"; + ctx.beginPath(); + ctx.moveTo(start_x, start_y); + ctx.lineTo(end_x, end_y); + ctx.stroke(); + + // Detalle central de viga + ctx.lineWidth = beam_width * 0.2; + ctx.strokeStyle = "#1e293b"; + ctx.beginPath(); + ctx.moveTo(start_x, start_y); + ctx.lineTo(end_x, end_y); + ctx.stroke(); + + // 4. Sensores / Remaches LED en la unión del anillo + const light_dist = inner_radius + (frame_thickness * 0.5); + const lx = cx + cos * light_dist; + const ly = cy + sin * light_dist; + const light_radius = cell_px * 0.12; + + const is_lit = alert_state ? (Math.sin(blink_timer * 14) > 0) : ((Math.sin(blink_timer * 2 + i) > 0.2)); + const glow_color = alert_state ? "#ef4444" : (i % 2 === 0 ? "#10b981" : "#0284c7"); + + ctx.fillStyle = is_lit ? glow_color : "#1e293b"; + ctx.beginPath(); + ctx.arc(lx, ly, light_radius, 0, Math.PI * 2); + ctx.fill(); + + if(is_lit){ + ctx.shadowColor = glow_color; + ctx.shadowBlur = cell_px * 0.4; + ctx.fillStyle = glow_color; + ctx.beginPath(); + ctx.arc(lx, ly, light_radius * 0.6, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + } + } + + // 5. Viñeteado de cabina muy sutil en los extremos de la pantalla + const vig_grad = ctx.createRadialGradient(cx, cy, inner_radius * 0.95, cx, cy, outer_radius); + vig_grad.addColorStop(0, "rgba(5, 7, 13, 0)"); + vig_grad.addColorStop(0.6, "rgba(5, 7, 13, 0.25)"); + vig_grad.addColorStop(1, "rgba(2, 4, 8, 0.7)"); + ctx.fillStyle = vig_grad; + ctx.fillRect(0, 0, width, height); + + ctx.restore(); + }; + + }; + + return CockpitModel; +})(); \ No newline at end of file diff --git a/Public/ecma/Models/DroneModel.ecma.js b/Public/ecma/Models/DroneModel.ecma.js new file mode 100644 index 0000000..83d099f --- /dev/null +++ b/Public/ecma/Models/DroneModel.ecma.js @@ -0,0 +1,442 @@ +"use strict"; + +const DroneModel = (function(){ + + const DroneModel = function(xgravity){ + + const self = this; + + let image = null; + let is_loaded = false; + + // Posición y velocidad en CELDAS relativas al centro + let cell_x = 0; + let cell_y = 0; + let vel_cell_x = 0; + let vel_cell_y = 0; + + let angle = 0; + + // Objetivo en CELDAS + let target_cell_x = 0; + let target_cell_y = 0; + let has_cursor = false; + + // Animación cinemática de Despliegue / Lanzamiento + let is_deploying = true; + let deploy_timer = 0; + const deploy_duration = 1.25; // Duración en segundos de la transición + const start_scale = 10.0; // Tamaño inicial x10 emergiendo de cabina + let current_scale = start_scale; + let deploy_start_x = 0; + let deploy_start_y = 24.0; // Emerge por debajo de la pantalla + let deploy_target_x = 0; + let deploy_target_y = 0; + + // Estado del Escudo + let shield = 100; + const max_shield = xgravity.settings.get_effective_stat("base_drone_shield_max", "shield_capacity_mult"); + let shield_energy = max_shield; + let shield_active = false; + let shield_pulse_timer = 0; + let is_critical = false; + let beacon_angle = 0; + let alarm_audio_timer = 0; + + // Estados dinámicos de alerta + let shield_risk = 0.0; // 0.0 = seguro, 1.0 = colapso inminente + let perimeter_risk = 0.0; // 0.0 = seguro, 1.0 = desintegración perimetral + + let shield_alarm_timer = 0; + let perimeter_alarm_timer = 0; + + this.is_deploying = () => is_deploying; + this.get_perimeter_risk = () => perimeter_risk; + this.get_shield_risk = () => shield_risk; + + this.get_mass = () => xgravity.settings.get("drone_mass", null, 3.0); + + this.get_shield_percentage = () => Math.max(0, shield_energy / max_shield); + + this.apply_impulse = (impulse_vx, impulse_vy) => { + if(is_deploying) return; + vel_cell_x += impulse_vx; + vel_cell_y += impulse_vy; + }; + + this.set_cell_position = (nx, ny) => { + cell_x = nx; + cell_y = ny; + }; + + this.restore_shield = (amount) => { + if(amount > 0){ + shield = Math.min(max_shield, shield + amount); + } + }; + + const get_cell_size = (width, height) => { + const cells = xgravity.settings.get("cells", null, 40); + return (width < height ? width : height) / cells; + }; + + this.set_shield_active = (state) => { + if(is_deploying) return; + // Solo se puede encender si le queda energía disponible + if(state && shield <= 0){ + shield_active = false; + return; + } + shield_active = Boolean(state); + }; + + this.is_shield_active = () => !is_deploying && shield_active && shield > 0; + + this.set_target_pixels = (pixel_x, pixel_y, canvas_w, canvas_h) => { + const cell_px = get_cell_size(canvas_w, canvas_h); + const center_x = canvas_w / 2; + const center_y = canvas_h / 2; + + target_cell_x = (pixel_x - center_x) / cell_px; + target_cell_y = (pixel_y - center_y) / cell_px; + has_cursor = true; + }; + + this.start_deployment = () => { + is_deploying = true; + deploy_timer = 0; + current_scale = start_scale; + + // Aterrizaje aleatorio en la zona central + deploy_target_x = (Math.random() - 0.5) * 6.0; // Entre -3 y +3 celdas + deploy_target_y = (Math.random() - 0.5) * 5.0; // Entre -2.5 y +2.5 celdas + + // Punto de despegue: parte inferior de la pantalla + deploy_start_x = deploy_target_x * 0.35; + deploy_start_y = 23.5; + + cell_x = deploy_start_x; + cell_y = deploy_start_y; + vel_cell_x = 0; + vel_cell_y = 0; + + // Apuntar morro en la dirección de eyección (hacia arriba) + angle = Math.atan2(deploy_target_y - deploy_start_y, deploy_target_x - deploy_start_x) + (Math.PI / 2); + }; + + this.init_position_cells = (cx = 0, cy = 0) => { + this.start_deployment(); + }; + + this.init_position = (x, y) => { + this.start_deployment(); + }; + + this.get_position = () => { + return { + cell_x : cell_x, + cell_y : cell_y, + vel_cell_x : vel_cell_x, + vel_cell_y : vel_cell_y, + target_cell_x : target_cell_x, + target_cell_y : target_cell_y, + angle : angle, + shield : shield, + max_shield : max_shield, + is_shield_active : !is_deploying && shield_active && shield > 0, + is_deploying : is_deploying, + scale : current_scale + }; + }; + + this.damage_shield = (amount) => { + if(is_deploying) return; + shield = Math.max(0, shield - amount); + if(shield <= 0) shield_active = false; + }; + + this.update = (delta_time) => { + // ---------------------------------------------------- + // FASE 1: ANIMACIÓN DE LANZAMIENTO DESDE CABINA + // ---------------------------------------------------- + if(is_deploying){ + deploy_timer += delta_time; + const t = Math.min(1.0, deploy_timer / deploy_duration); + + // Desaceleración suave (Cubic Ease-Out) + const ease = 1.0 - Math.pow(1.0 - t, 3); + + // Interpolar posición y escala (de x10 a x1.0) + cell_x = deploy_start_x + ((deploy_target_x - deploy_start_x) * ease); + cell_y = deploy_start_y + ((deploy_target_y - deploy_start_y) * ease); + current_scale = start_scale + ((1.0 - start_scale) * ease); + + angle = Math.atan2(deploy_target_y - deploy_start_y, deploy_target_x - deploy_start_x) + (Math.PI / 2); + + if(t >= 1.0){ + is_deploying = false; + current_scale = 1.0; + cell_x = deploy_target_x; + cell_y = deploy_target_y; + vel_cell_x = 0; + vel_cell_y = 0; + if(!has_cursor){ + target_cell_x = deploy_target_x; + target_cell_y = deploy_target_y; + } + } + return; // Bloquea consumo de escudo y avisos durante el despegue + } + + // ---------------------------------------------------- + // FASE 2: CONTROL Y FÍSICAS DE JUEGO NORMAL + // ---------------------------------------------------- + if(!has_cursor){ + target_cell_x = 0; + target_cell_y = 0; + } + + // Dinámica del Escudo (Consumo y Recarga) + shield_pulse_timer += delta_time; + + const drain_rate = xgravity.settings.get("drone_shield_drain", null, 25); + const recharge_time = Math.max(0.1, xgravity.settings.get("drone_shield_recharge_time", null, 8.0)); + const recovery_rate = max_shield / recharge_time; + const shield_pct = (shield / max_shield) * 100; + const shield_th = xgravity.settings.get("drone_shield_low_threshold", null, 30); + + if(shield_active){ + shield = Math.max(0, shield - (drain_rate * delta_time)); + if(shield <= 0){ + shield_active = false; + } + } else { + shield = Math.min(max_shield, shield + (recovery_rate * delta_time)); + } + + // Físicas orbitales hacia el cursor + const dcx = target_cell_x - cell_x; + const dcy = target_cell_y - cell_y; + + angle = Math.atan2(dcy, dcx) + (Math.PI / 2); + + const f = xgravity.settings.get("drone_force", null, 3.0); + + const [new_vx, new_vy] = DroneModel.drone_velocity( + target_cell_x, target_cell_y, + cell_x, cell_y, + vel_cell_x, vel_cell_y, + f, + delta_time + ); + + if(shield_pct <= shield_th){ + shield_risk = (shield_th - shield_pct) / shield_th; + is_critical = true; + } else { + shield_risk = 0.0; + is_critical = false; + } + + // Alarma de escudo progresiva + if(shield_risk > 0.0){ + const beacon_speed = 5.0 + (shield_risk * 10.0); + beacon_angle += beacon_speed * delta_time; + + const max_int = xgravity.settings.get("drone_shield_alarm_interval_max", null, 0.65); + const min_int = xgravity.settings.get("drone_shield_alarm_interval_min", null, 0.18); + const current_interval = max_int - (shield_risk * (max_int - min_int)); + const current_vol = 0.2 + (shield_risk * 0.8); + + shield_alarm_timer += delta_time; + if(shield_alarm_timer >= current_interval){ + shield_alarm_timer = 0; + if(xgravity.audio){ + xgravity.audio.play("shield_low_alarm", current_vol); + } + } + } else { + shield_alarm_timer = 0; + } + + // Alarma de perímetro + const dist_center = Math.sqrt(cell_x * cell_x + cell_y * cell_y); + const safe_r = xgravity.settings.get("drone_perimeter_safe_radius", null, 15.0); + const crit_r = xgravity.settings.get("drone_perimeter_critical_radius", null, 18.5); + + if(dist_center > safe_r){ + perimeter_risk = Math.min(1.0, (dist_center - safe_r) / (crit_r - safe_r)); + } else { + perimeter_risk = 0.0; + } + + if(perimeter_risk > 0.0){ + const max_p_int = xgravity.settings.get("drone_perimeter_alarm_interval_max", null, 0.7); + const min_p_int = xgravity.settings.get("drone_perimeter_alarm_interval_min", null, 0.2); + const p_interval = max_p_int - (perimeter_risk * (max_p_int - min_p_int)); + const p_vol = 0.2 + (perimeter_risk * 0.8); + + perimeter_alarm_timer += delta_time; + if(perimeter_alarm_timer >= p_interval){ + perimeter_alarm_timer = 0; + if(xgravity.audio){ + xgravity.audio.play("perimeter_warning", p_vol); + } + } + } else { + perimeter_alarm_timer = 0; + } + + vel_cell_x = new_vx; + vel_cell_y = new_vy; + + cell_x += vel_cell_x * delta_time; + cell_y += vel_cell_y * delta_time; + }; + + this.render = (ctx, width, height) => { + const cell_px = get_cell_size(width, height); + const center_x = width / 2; + const center_y = height / 2; + + const px = center_x + (cell_x * cell_px); + const py = center_y + (cell_y * cell_px); + + // Tamaño base del dron escalado según la fase de vuelo (x10 -> x1.0) + const drone_cells = xgravity.settings.get("drone_size", null, 2.5); + const drone_size_px = drone_cells * cell_px * current_scale; + + ctx.save(); + ctx.translate(px, py); + + // 1. Burbuja del Escudo (solo en combate normal con escudo encendido) + if(!is_deploying && shield_active && shield > 0){ + const shield_radius_cells = xgravity.settings.get("drone_shield_radius", null, 1.7); + const shield_radius_px = shield_radius_cells * cell_px; + + const pulse = Math.sin(shield_pulse_timer * 18) * (cell_px * 0.08); + const r = shield_radius_px + pulse; + + ctx.save(); + const shield_grad = ctx.createRadialGradient(0, 0, r * 0.5, 0, 0, r); + shield_grad.addColorStop(0, "rgba(56, 189, 248, 0.05)"); + shield_grad.addColorStop(0.7, "rgba(56, 189, 248, 0.2)"); + shield_grad.addColorStop(1, "rgba(56, 189, 248, 0.6)"); + + ctx.fillStyle = shield_grad; + ctx.beginPath(); + ctx.arc(0, 0, r, 0, Math.PI * 2); + ctx.fill(); + + ctx.lineWidth = cell_px * 0.12; + ctx.strokeStyle = "rgba(186, 230, 253, 0.9)"; + ctx.shadowColor = "#38bdf8"; + ctx.shadowBlur = cell_px * 0.5; + ctx.beginPath(); + ctx.arc(0, 0, r, 0, Math.PI * 2); + ctx.stroke(); + + ctx.lineWidth = cell_px * 0.06; + ctx.setLineDash([cell_px * 0.3, cell_px * 0.2]); + ctx.strokeStyle = "rgba(56, 189, 248, 0.7)"; + ctx.beginPath(); + ctx.arc(0, 0, r + (cell_px * 0.2), 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + + // 2. Renderizado del Sprite + ctx.rotate(angle); + + if(is_loaded && image){ + const ratio = image.naturalWidth / image.naturalHeight; + let w = drone_size_px; + let h = drone_size_px; + + if(ratio >= 1){ + h = drone_size_px / ratio; + } else { + w = drone_size_px * ratio; + } + + ctx.drawImage(image, -w / 2, -h / 2, w, h); + } else { + ctx.fillStyle = "#38bdf8"; + ctx.beginPath(); + ctx.moveTo(0, -drone_size_px / 2); + ctx.lineTo(drone_size_px / 3, drone_size_px / 2); + ctx.lineTo(-drone_size_px / 3, drone_size_px / 2); + ctx.closePath(); + ctx.fill(); + } + + // Girofaro crítico + if(!is_deploying && is_critical){ + const pulse = 0.5 + Math.sin(beacon_angle * 2) * 0.5; + const halo_radius = cell_px * (2.4 + (pulse * 0.6)); + + ctx.save(); + ctx.globalCompositeOperation = "lighter"; + + const halo = ctx.createRadialGradient(0, 0, cell_px * 0.3, 0, 0, halo_radius); + halo.addColorStop(0.0, `rgba(239, 68, 68, ${0.45 + pulse * 0.35})`); + halo.addColorStop(0.5, `rgba(220, 38, 38, ${0.2 + pulse * 0.15})`); + halo.addColorStop(1.0, "rgba(239, 68, 68, 0.0)"); + + ctx.fillStyle = halo; + ctx.beginPath(); + ctx.arc(0, 0, halo_radius, 0, Math.PI * 2); + ctx.fill(); + + ctx.save(); + ctx.rotate(beacon_angle); + + const beam_length = cell_px * 3.6; + const beam_grad = ctx.createRadialGradient(0, 0, 0, 0, 0, beam_length); + beam_grad.addColorStop(0.0, "rgba(254, 202, 202, 0.7)"); + beam_grad.addColorStop(0.4, "rgba(239, 68, 68, 0.45)"); + beam_grad.addColorStop(1.0, "rgba(239, 68, 68, 0.0)"); + + ctx.fillStyle = beam_grad; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.arc(0, 0, beam_length, -0.4, 0.4); + ctx.closePath(); + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.arc(0, 0, beam_length, Math.PI - 0.4, Math.PI + 0.4); + ctx.closePath(); + ctx.fill(); + + ctx.restore(); + ctx.restore(); + } + + ctx.restore(); + }; + + const constructor = () => { + const src = xgravity.settings.get("drone_image", null, "images/game/Louse.webp"); + image = new Image(); + image.onload = () => { + is_loaded = true; + }; + image.src = src; + + // Iniciar la animación de lanzamiento al crear la nave + self.start_deployment(); + }; + + constructor(); + + }; + + DroneModel.drone_velocity = (px, py, dx, dy, vx, vy, f, delta = 0) => [ + vx += (px - dx) * f * (delta > 0 ? delta : 1), + vy += (py - dy) * f * (delta > 0 ? delta : 1) + ]; + + return DroneModel; +})(); \ No newline at end of file diff --git a/Public/ecma/Models/ExplosionsModel.ecma.js b/Public/ecma/Models/ExplosionsModel.ecma.js new file mode 100644 index 0000000..bf3e211 --- /dev/null +++ b/Public/ecma/Models/ExplosionsModel.ecma.js @@ -0,0 +1,272 @@ +"use strict"; + +const ExplosionsModel = (function(){ + + const ExplosionsModel = function(xgravity){ + + const self = this; + + let particles = []; + let shockwaves = []; + let sparks = []; + + this.add_sparks = (cell_x, cell_y, normal_x, normal_y, intensity = 1.0) => { + // A mayor intensidad de impacto, más chispas y más velocidad + const base_count = Math.floor(10 + (intensity * 25)); + const count = Math.min(60, base_count); + const base_angle = Math.atan2(normal_y, normal_x); + + for(let i = 0; i < count; i++){ + // Cono de dispersión reflejado con +- 60 grados + const spread = (Math.random() - 0.5) * (Math.PI * 0.7); + const spark_angle = base_angle + spread; + const speed = (Math.random() * 12 + 6) * Math.min(2.5, intensity); + const life = (Math.random() * 0.18) + 0.08; + + sparks.push({ + x : cell_x, + y : cell_y, + vx : Math.cos(spark_angle) * speed, + vy : Math.sin(spark_angle) * speed, + len : (Math.random() * 0.3) + 0.15, + life : life, + max_life : life, + color : Math.random() > 0.3 ? "#fef08a" : "#f97316" // Chispas blanco-amarillo y naranja + }); + } + }; + + const get_cell_size = (width, height) => { + const cells = xgravity.settings.get("cells", null, 40); + return (width < height ? width : height) / cells; + }; + + this.add = (cell_x, cell_y, type = "shield") => { + // 1. Delegar el audio al servicio central + if(xgravity.audio){ + xgravity.audio.play(type === "shield" ? "shield_explosion" : "hit_explosion"); + } + + const is_shield = type === "shield"; + + // 2. Extraer parámetros visuales de Settings + const particles_range = xgravity.settings.get("explosion_particles_range", null, [45, 75]); + const spread_reach = xgravity.settings.get("explosion_particles_spread", null, 11.0); + const base_shockwave_radius = xgravity.settings.get("explosion_shockwave_radius", null, 4.5); + + const min_p = Math.min(particles_range[0], particles_range[1]); + const max_p = Math.max(particles_range[0], particles_range[1]); + const count = Math.floor(min_p + Math.random() * (max_p - min_p + 1)); + + const max_radius = is_shield ? base_shockwave_radius : (base_shockwave_radius * 1.15); + + // 3. Onda expansiva elíptica con inclinación aleatoria + shockwaves.push({ + x : cell_x, + y : cell_y, + radius : 0.4, + max_radius : max_radius, + speed : is_shield ? 9.5 : 8.0, + angle : Math.random() * Math.PI * 2, + aspect_ratio : 0.42 + (Math.random() * 0.18), + alpha : 1.0, + color_rgb : is_shield ? "56, 189, 248" : "239, 68, 68" + }); + + // 4. Micropartículas esféricas difuminadas + for(let i = 0; i < count; i++){ + const angle = Math.random() * Math.PI * 2; + const speed = (Math.random() * spread_reach) + 1.2; + const life = (Math.random() * 0.38) + 0.28; + + let core_rgb = "255, 255, 255"; + let rim_rgb = "56, 189, 248"; + + if(is_shield){ + rim_rgb = Math.random() > 0.4 ? "56, 189, 248" : (Math.random() > 0.5 ? "186, 230, 253" : "2, 132, 199"); + } else { + rim_rgb = Math.random() > 0.4 ? "239, 68, 68" : (Math.random() > 0.5 ? "249, 115, 22" : "251, 191, 36"); + } + + particles.push({ + x : cell_x, + y : cell_y, + vx : Math.cos(angle) * speed, + vy : Math.sin(angle) * speed, + size : (Math.random() * 0.16) + 0.08, + life : life, + max_life : life, + core_rgb : core_rgb, + rim_rgb : rim_rgb + }); + } + }; + + this.add_mothership_impact = (cx, cy) => { + // Flash de impacto en la cabina + particles.push({ + type : "screen_flash", + alpha : 0.85, + color : "#ffffff", + fade_speed : 2.5 + }); + + // 60-80 Fragmentos de roca y metralla de diversos tamaños + const count = 75; + for(let i = 0; i < count; i++){ + const angle = Math.random() * Math.PI * 2; + const speed = 120 + (Math.random() * 650); // Píxeles/segundo + const size = 3 + (Math.random() * 18); // Desde gravilla hasta peñascos + const is_rock = Math.random() > 0.3; + + particles.push({ + type : is_rock ? "rock_shard" : "plasma_spark", + x : cx + ((Math.random() - 0.5) * 40), + y : cy + ((Math.random() - 0.5) * 40), + vx : Math.cos(angle) * speed, + vy : Math.sin(angle) * speed, + angle : Math.random() * Math.PI * 2, + angular_vel : (Math.random() - 0.5) * 12.0, + size : size, + life : 1.0, + decay : 0.45 + (Math.random() * 0.75), + color : is_rock ? (Math.random() > 0.5 ? "#78716c" : "#a8a29e") : (Math.random() > 0.5 ? "#f97316" : "#facc15"), + vertices : is_rock ? [ + { x: -size * 0.5, y: -size * 0.4 }, + { x: size * 0.6, y: -size * 0.3 }, + { x: size * 0.4, y: size * 0.5 }, + { x: -size * 0.3, y: size * 0.6 } + ] : null + }); + } + }; + + this.update = (delta_time) => { + for(let i = particles.length - 1; i >= 0; i--){ + const p = particles[i]; + p.life -= delta_time; + if(p.life <= 0){ + particles.splice(i, 1); + continue; + } + p.x += p.vx * delta_time; + p.y += p.vy * delta_time; + p.vx *= 0.93; + p.vy *= 0.93; + } + + for(let i = shockwaves.length - 1; i >= 0; i--){ + const s = shockwaves[i]; + s.radius += s.speed * delta_time; + s.alpha = Math.max(0, 1.0 - (s.radius / s.max_radius)); + if(s.radius >= s.max_radius){ + shockwaves.splice(i, 1); + } + } + + for(let i = sparks.length - 1; i >= 0; i--){ + const s = sparks[i]; + s.life -= delta_time; + if(s.life <= 0){ + sparks.splice(i, 1); + continue; + } + s.x += s.vx * delta_time; + s.y += s.vy * delta_time; + s.vx *= 0.90; + s.vy *= 0.90; + } + }; + + this.render = (ctx, width, height) => { + const cell_px = get_cell_size(width, height); + const cx = width / 2; + const cy = height / 2; + + ctx.save(); + ctx.globalCompositeOperation = "lighter"; + + // Ondas de choque elípticas + for(let i = 0; i < shockwaves.length; i++){ + const s = shockwaves[i]; + const px = cx + (s.x * cell_px); + const py = cy + (s.y * cell_px); + const r_px = s.radius * cell_px; + + ctx.save(); + ctx.translate(px, py); + ctx.rotate(s.angle); + ctx.scale(1.0, s.aspect_ratio); + + ctx.lineWidth = cell_px * 0.14 * s.alpha; + ctx.strokeStyle = `rgba(${s.color_rgb}, ${s.alpha * 0.85})`; + ctx.beginPath(); + ctx.arc(0, 0, r_px, 0, Math.PI * 2); + ctx.stroke(); + + ctx.lineWidth = cell_px * 0.05 * s.alpha; + ctx.strokeStyle = `rgba(255, 255, 255, ${s.alpha * 0.6})`; + ctx.beginPath(); + ctx.arc(0, 0, r_px * 0.82, 0, Math.PI * 2); + ctx.stroke(); + + ctx.restore(); + } + + // Micropartículas + for(let i = 0; i < particles.length; i++){ + const p = particles[i]; + const px = cx + (p.x * cell_px); + const py = cy + (p.y * cell_px); + const r_px = p.size * cell_px; + const progress = Math.max(0, p.life / p.max_life); + + const grad = ctx.createRadialGradient(px, py, 0, px, py, r_px); + grad.addColorStop(0.0, `rgba(${p.core_rgb}, ${progress})`); + grad.addColorStop(0.45, `rgba(${p.rim_rgb}, ${progress * 0.75})`); + grad.addColorStop(1.0, `rgba(${p.rim_rgb}, 0.0)`); + + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(px, py, r_px, 0, Math.PI * 2); + ctx.fill(); + } + + // Renderizado de Chispas Cinéticas (sin delta_time) + for(let i = 0; i < sparks.length; i++){ + const s = sparks[i]; + const px = cx + (s.x * cell_px); + const py = cy + (s.y * cell_px); + const progress = Math.max(0, s.life / s.max_life); + + // Calculamos la longitud y dirección de la estela en base a su velocidad + const spd = Math.sqrt(s.vx * s.vx + s.vy * s.vy); + const dir_x = spd > 0.001 ? s.vx / spd : 0; + const dir_y = spd > 0.001 ? s.vy / spd : 0; + + // Longitud visible de la estela en píxeles (usa s.len escalado por la velocidad y vida) + const tail_length_px = (s.len || 0.25) * cell_px * Math.min(2.5, spd * 0.15); + + ctx.lineWidth = cell_px * 0.06; + ctx.strokeStyle = s.color; + ctx.globalAlpha = progress; + + ctx.beginPath(); + ctx.moveTo(px, py); + // La estela se proyecta hacia atrás respecto a la trayectoria + ctx.lineTo(px - (dir_x * tail_length_px), py - (dir_y * tail_length_px)); + ctx.stroke(); + } + + ctx.restore(); + }; + + this.reset = () => { + particles = []; + shockwaves = []; + }; + + }; + + return ExplosionsModel; +})(); \ No newline at end of file diff --git a/Public/ecma/Models/RusherEnemyModel.ecma.js b/Public/ecma/Models/RusherEnemyModel.ecma.js new file mode 100644 index 0000000..0864cfd --- /dev/null +++ b/Public/ecma/Models/RusherEnemyModel.ecma.js @@ -0,0 +1,105 @@ +"use strict"; + +const RusherEnemyModel = (function(){ + + const RusherEnemyModel = function(xgravity, config = {}){ + + const self = this; + + const entity = { + x : 0, + y : 0, + vx : 0, + vy : 0, + angle : 0, + scale : 1.0, + alpha : 1.0, + ready : false, + alive : true, + age : 0, + config : config + }; + + const base_size = config.size || 1.5; + const points = config.points || 200; + const mass = config.mass || 4.0; + let health = config.health || 20; + const max_health = health; + const image_src = config.image || null; + + const spawn_strategy = SpawnStrategies.get(config.spawn_mode || "perimeter"); + const behavior_strategy = BehaviorStrategies.get(config.behavior || "cross_screen"); + + this.is_ready = () => entity.ready; + this.is_alive = () => entity.alive; + this.get_points = () => points; + this.get_age = () => entity.age; + this.get_mass = () => mass; + this.get_health = () => health; + this.get_max_health = () => max_health; + this.get_velocity = () => ({ vx: entity.vx, vy: entity.vy }); + + this.get_state = () => ({ + x : entity.x, + y : entity.y, + angle : entity.angle, + scale : entity.scale, + alpha : entity.alpha, + size : base_size, + image_src : image_src, + ready : entity.ready, + alive : entity.alive, + health : health, + max_health : max_health, + radius : (base_size * entity.scale) * 0.45, + fallback_color : entity.ready ? "#e11d48" : "#fb7185" + }); + + this.get_bounds = () => ({ + x : entity.x, + y : entity.y, + radius : (base_size * entity.scale) * 0.45 + }); + + this.apply_impulse = (ivx, ivy) => { + entity.vx += ivx; + entity.vy += ivy; + }; + + this.set_position = (nx, ny) => { + entity.x = nx; + entity.y = ny; + }; + + this.take_damage = (amount) => { + health = Math.max(0, health - amount); + if(health <= 0){ + entity.alive = false; + } + return entity.alive; + }; + + this.on_hit = () => { + entity.alive = false; + }; + + this.update = (delta_time, drone_info) => { + if(!entity.alive) return; + entity.age += delta_time; + + if(!entity.ready){ + spawn_strategy.update(entity, delta_time); + } else { + behavior_strategy(entity, delta_time, drone_info); + } + }; + + const constructor = () => { + spawn_strategy.init(entity, config, xgravity); + }; + + constructor(); + }; + + return RusherEnemyModel; +})(); \ No newline at end of file diff --git a/Public/ecma/Models/StaticMineEnemyModel.ecma.js b/Public/ecma/Models/StaticMineEnemyModel.ecma.js new file mode 100644 index 0000000..d39f6d5 --- /dev/null +++ b/Public/ecma/Models/StaticMineEnemyModel.ecma.js @@ -0,0 +1,105 @@ +"use strict"; + +const StaticMineEnemyModel = (function(){ + + const StaticMineEnemyModel = function(xgravity, config = {}){ + + const self = this; + + const entity = { + x : 0, + y : 0, + vx : 0, + vy : 0, + angle : Math.random() * Math.PI * 2, + scale : 0.0, + alpha : 0.3, + ready : false, + alive : true, + age : 0, + config : config + }; + + const base_size = config.size || 2.0; + const points = config.points || 50; + const mass = config.mass || 5.0; + let health = config.health || 40; + const max_health = health; + const image_src = config.image || null; + + const spawn_strategy = SpawnStrategies.get(config.spawn_mode || "deep_space"); + const behavior_strategy = BehaviorStrategies.get(config.behavior || "static"); + + this.is_ready = () => entity.ready; + this.is_alive = () => entity.alive; + this.get_points = () => points; + this.get_age = () => entity.age; + this.get_mass = () => mass; + this.get_health = () => health; + this.get_max_health = () => max_health; + this.get_velocity = () => ({ vx: entity.vx, vy: entity.vy }); + + this.get_state = () => ({ + x : entity.x, + y : entity.y, + angle : entity.angle, + scale : entity.scale, + alpha : entity.alpha, + size : base_size, + image_src : image_src, + ready : entity.ready, + alive : entity.alive, + health : health, + max_health : max_health, + radius : (base_size * entity.scale) * 0.45, + fallback_color : entity.ready ? "#ef4444" : "#f59e0b" + }); + + this.get_bounds = () => ({ + x : entity.x, + y : entity.y, + radius : (base_size * entity.scale) * 0.45 + }); + + this.apply_impulse = (ivx, ivy) => { + entity.vx += ivx; + entity.vy += ivy; + }; + + this.set_position = (nx, ny) => { + entity.x = nx; + entity.y = ny; + }; + + this.take_damage = (amount) => { + health = Math.max(0, health - amount); + if(health <= 0){ + entity.alive = false; + } + return entity.alive; + }; + + this.on_hit = () => { + entity.alive = false; + }; + + this.update = (delta_time, drone_info) => { + if(!entity.alive) return; + entity.age += delta_time; + + if(!entity.ready){ + spawn_strategy.update(entity, delta_time); + } else { + behavior_strategy(entity, delta_time, drone_info); + } + }; + + const constructor = () => { + spawn_strategy.init(entity, config, xgravity); + }; + + constructor(); + }; + + return StaticMineEnemyModel; +})(); \ No newline at end of file diff --git a/Public/ecma/Models/TelemetryHudModel.ecma.js b/Public/ecma/Models/TelemetryHudModel.ecma.js new file mode 100644 index 0000000..b8de71f --- /dev/null +++ b/Public/ecma/Models/TelemetryHudModel.ecma.js @@ -0,0 +1,442 @@ +"use strict"; + +const TelemetryHudModel = (function(){ + + const TelemetryHudModel = function(xgravity){ + + const self = this; + let blink_timer = 0; + let current_score = 0; + + // Vidas dinámicas según dificultad o configuración + const get_initial_lives = () => { + const preset = (xgravity.settings && typeof xgravity.settings.get_difficulty_preset === "function") + ? xgravity.settings.get_difficulty_preset() + : null; + return (preset && preset.drone_lives !== undefined) + ? preset.drone_lives + : xgravity.settings.get("drone_lives", null, 3); + }; + + let current_lives = get_initial_lives(); + let max_lives = current_lives; + + const get_cell_size = (width, height) => { + const cells = xgravity.settings.get("cells", null, 40); + return (width < height ? width : height) / cells; + }; + + this.set_score = (val) => { + current_score = Math.max(0, Math.floor(val)); + }; + + this.add_score = (val) => { + current_score += Math.max(0, Math.floor(val)); + }; + + this.get_score = () => current_score; + + this.set_lives = (lives, max = null) => { + current_lives = Math.max(0, Math.floor(lives)); + if(max !== null){ + max_lives = Math.max(1, Math.floor(max)); + } + }; + + this.get_lives = () => current_lives; + + // Dibuja texto con contorno perimetral oscuro de alto contraste + const draw_hud_text = (ctx, text, x, y, fill_color, cell_px) => { + ctx.save(); + ctx.lineWidth = cell_px * 0.28; + ctx.strokeStyle = "rgba(2, 6, 23, 0.95)"; + ctx.lineJoin = "round"; + ctx.strokeText(text, x, y); + ctx.fillStyle = fill_color; + ctx.fillText(text, x, y); + ctx.restore(); + }; + + // Dibuja silueta táctica de caza/dron en la barra de reservas + const draw_drone_life_pip = (ctx, x, y, size, is_active, color, cell_px) => { + ctx.save(); + ctx.translate(x, y); + + ctx.beginPath(); + ctx.moveTo(0, -size * 0.45); + ctx.lineTo(size * 0.42, size * 0.45); + ctx.lineTo(0, size * 0.22); + ctx.lineTo(-size * 0.42, size * 0.45); + ctx.closePath(); + + if(is_active){ + ctx.fillStyle = color; + ctx.shadowColor = color; + ctx.shadowBlur = cell_px * 0.35; + ctx.fill(); + ctx.lineWidth = cell_px * 0.08; + ctx.strokeStyle = "rgba(255, 255, 255, 0.85)"; + ctx.stroke(); + } else { + ctx.fillStyle = "rgba(2, 6, 23, 0.75)"; + ctx.fill(); + ctx.lineWidth = cell_px * 0.07; + ctx.strokeStyle = "rgba(56, 189, 248, 0.25)"; + ctx.stroke(); + } + + ctx.restore(); + }; + + // Dibuja una barra táctica con centro en 0 (bipolar: -1 a +1) + const draw_bipolar_bar = (ctx, x, y, width, height, value, fill_color, cell_px) => { + ctx.save(); + + ctx.fillStyle = "rgba(2, 6, 23, 0.88)"; + ctx.fillRect(x - (cell_px * 0.1), y - (cell_px * 0.1), width + (cell_px * 0.2), height + (cell_px * 0.2)); + + ctx.strokeStyle = "rgba(56, 189, 248, 0.65)"; + ctx.lineWidth = cell_px * 0.09; + ctx.strokeRect(x, y, width, height); + + const mid_x = x + (width / 2); + ctx.beginPath(); + ctx.moveTo(mid_x, y); + ctx.lineTo(mid_x, y + height); + ctx.strokeStyle = "rgba(255, 255, 255, 0.85)"; + ctx.lineWidth = cell_px * 0.08; + ctx.stroke(); + + const clamped = Math.max(-1.0, Math.min(1.0, value)); + const bar_length = (width / 2) * Math.abs(clamped); + + ctx.fillStyle = fill_color; + if(clamped > 0){ + ctx.fillRect(mid_x, y, bar_length, height); + } else if(clamped < 0){ + ctx.fillRect(mid_x - bar_length, y, bar_length, height); + } + + ctx.restore(); + }; + + // Dibuja una barra segmentada genérica (Escudo y Nodriza) + const draw_segmented_bar = (ctx, x, y, width, height, pct, fill_color, cell_px, segments = 10) => { + ctx.save(); + const gap = cell_px * 0.12; + const seg_width = (width - (gap * (segments - 1))) / segments; + + ctx.fillStyle = "rgba(2, 6, 23, 0.88)"; + ctx.fillRect(x - (cell_px * 0.1), y - (cell_px * 0.1), width + (cell_px * 0.2), height + (cell_px * 0.2)); + + for(let s = 0; s < segments; s++){ + const seg_x = x + (s * (seg_width + gap)); + const seg_threshold = (s + 1) / segments; + + ctx.strokeStyle = "rgba(56, 189, 248, 0.4)"; + ctx.lineWidth = cell_px * 0.07; + ctx.strokeRect(seg_x, y, seg_width, height); + + if(pct >= seg_threshold - 0.05){ + ctx.fillStyle = fill_color; + ctx.fillRect(seg_x, y, seg_width, height); + } + } + ctx.restore(); + }; + + // Dibuja las marcas perimétricas que siguen a la nave en X e Y + const draw_axis_tracking_ticks = (ctx, cx, cy, drone_px_x, drone_px_y, radius_px, color, cell_px) => { + ctx.save(); + + const tick_len = cell_px * 1.0; + const gap = cell_px * 0.4; + + ctx.strokeStyle = color; + ctx.lineWidth = cell_px * 0.12; + ctx.lineCap = "round"; + + const x_pos = drone_px_x; + const top_base_y = cy - radius_px - gap; + ctx.beginPath(); + ctx.moveTo(x_pos, top_base_y); + ctx.lineTo(x_pos, top_base_y - tick_len); + ctx.stroke(); + + const btm_base_y = cy + radius_px + gap; + ctx.beginPath(); + ctx.moveTo(x_pos, btm_base_y); + ctx.lineTo(x_pos, btm_base_y + tick_len); + ctx.stroke(); + + const y_pos = drone_px_y; + const left_base_x = cx - radius_px - gap; + ctx.beginPath(); + ctx.moveTo(left_base_x, y_pos); + ctx.lineTo(left_base_x - tick_len, y_pos); + ctx.stroke(); + + const right_base_x = cx + radius_px + gap; + ctx.beginPath(); + ctx.moveTo(right_base_x, y_pos); + ctx.lineTo(right_base_x + tick_len, y_pos); + ctx.stroke(); + + ctx.restore(); + }; + + this.render = (ctx, width, height, delta_time, drone_model, cockpit_model, score_val = null, lives_val = null, max_lives_val = null) => { + if(!drone_model) return; + + if(score_val !== null){ + current_score = score_val; + } + if(lives_val !== null){ + current_lives = Math.max(0, Math.floor(lives_val)); + } + if(max_lives_val !== null){ + max_lives = Math.max(1, Math.floor(max_lives_val)); + } + + const cell_px = get_cell_size(width, height); + const cx = width / 2; + const cy = height / 2; + + blink_timer += delta_time; + + const safe_radius_cells = xgravity.settings.get("safe_zone_radius", null, 18.5); + const warn_radius_cells = xgravity.settings.get("safe_zone_warning", null, 13.5); + const safe_radius_px = safe_radius_cells * cell_px; + + const drone_info = drone_model.get_position(); + + // 1. Coordenadas y distancias en celdas + const dcx = drone_info.cell_x || 0; + const dcy = drone_info.cell_y || 0; + const dist_cells = Math.sqrt(dcx * dcx + dcy * dcy); + + const ptr_cx = drone_info.target_cell_x || 0; + const ptr_cy = drone_info.target_cell_y || 0; + const ptr_dist_cells = Math.sqrt(ptr_cx * ptr_cx + ptr_cy * ptr_cy); + + const v_cell_x = drone_info.vel_cell_x || 0; + const v_cell_y = drone_info.vel_cell_y || 0; + + const drone_px_x = cx + (dcx * cell_px); + const drone_px_y = cy + (dcy * cell_px); + + // 2. Alertas + let drone_danger_alpha = 0; + const is_drone_lost = dist_cells > safe_radius_cells; + if(dist_cells > warn_radius_cells){ + drone_danger_alpha = Math.min((dist_cells - warn_radius_cells) / (safe_radius_cells - warn_radius_cells), 1.0); + } + + let ptr_danger_alpha = 0; + const is_ptr_lost = ptr_dist_cells > safe_radius_cells; + if(ptr_dist_cells > warn_radius_cells){ + ptr_danger_alpha = Math.min((ptr_dist_cells - warn_radius_cells) / (safe_radius_cells - warn_radius_cells), 1.0); + } + + if(cockpit_model && typeof cockpit_model.set_alert === "function"){ + cockpit_model.set_alert(drone_danger_alpha > 0.4 || is_drone_lost || ptr_danger_alpha > 0.4 || is_ptr_lost || current_lives <= 1); + } + + // Colores dinámicos + const drone_color = is_drone_lost + ? ((Math.sin(blink_timer * 14) > 0) ? "#ef4444" : "#7f1d1d") + : (drone_danger_alpha > 0 ? "#f59e0b" : "#38bdf8"); + + const ptr_color = is_ptr_lost + ? ((Math.sin(blink_timer * 14) > 0) ? "#ef4444" : "#7f1d1d") + : (ptr_danger_alpha > 0 ? "#f59e0b" : "#c084fc"); + + ctx.save(); + + // 3. Perímetro táctico centrado + ctx.save(); + ctx.lineWidth = cell_px * 0.16; + ctx.strokeStyle = "rgba(0, 0, 0, 0.7)"; + ctx.beginPath(); + ctx.arc(cx, cy, safe_radius_px, 0, Math.PI * 2); + ctx.stroke(); + + ctx.lineWidth = cell_px * 0.1; + ctx.setLineDash([cell_px * 0.5, cell_px * 0.35]); + ctx.strokeStyle = (drone_danger_alpha > 0 || ptr_danger_alpha > 0) + ? `rgba(239, 68, 68, ${0.35 + Math.max(drone_danger_alpha, ptr_danger_alpha) * 0.65})` + : "rgba(56, 189, 248, 0.45)"; + ctx.stroke(); + ctx.restore(); + + // 4. LÍNEAS DE POSICIÓN PERIMÉTRICAS + draw_axis_tracking_ticks(ctx, cx, cy, drone_px_x, drone_px_y, safe_radius_px, drone_color, cell_px); + + // 5. Degradado de escape + if(drone_danger_alpha > 0){ + const drone_angle = Math.atan2(dcy, dcx); + + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(drone_angle); + + const grad_x = safe_radius_px; + const grad_radius = cell_px * 18.5; + const grad = ctx.createRadialGradient(grad_x, 0, 0, grad_x, 0, grad_radius); + + const peak_color = is_drone_lost ? "rgba(220, 38, 38, " : "rgba(239, 68, 68, "; + grad.addColorStop(0, `${peak_color}${drone_danger_alpha * 0.55})`); + grad.addColorStop(0.5, `${peak_color}${drone_danger_alpha * 0.22})`); + grad.addColorStop(1, "rgba(239, 68, 68, 0)"); + + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(grad_x, 0, grad_radius, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + + // 6. Bloque de telemetría en esquina inferior izquierda (8 líneas) + const margin = cell_px * 1.0; + const hud_x = margin; + const line_spacing = cell_px * 1.35; + const hud_y = height - margin - (line_spacing * 7); + + ctx.font = `bold ${cell_px * 0.95}px monospace`; + + // Línea 0: Puntuación (SCR) + const score_str = current_score.toString().padStart(8, "0"); + draw_hud_text(ctx, `SCR : [${score_str}]`, hud_x, hud_y, "#38bdf8", cell_px); + + // Línea 1: Vidas de Drones (LVS) + const bar_w = cell_px * 8.5; + const bar_h = cell_px * 0.5; + const bar_start_x = hud_x + (cell_px * 16.5); + const lvs_y = hud_y + line_spacing; + + let lvs_color = "#38bdf8"; + if(current_lives <= 1){ + lvs_color = (Math.sin(blink_timer * 12) > 0) ? "#ef4444" : "#7f1d1d"; + } + draw_hud_text(ctx, `LVS : [ ${current_lives} ]`, hud_x, lvs_y, lvs_color, cell_px); + + // Iconos de cazas/drones en la barra táctica de reservas + const slot_w = Math.min(cell_px * 1.6, bar_w / Math.max(1, max_lives)); + const icon_size = cell_px * 0.9; + for(let i = 0; i < max_lives; i++){ + const pip_x = bar_start_x + (i * slot_w) + (slot_w * 0.5); + const pip_y = lvs_y - (cell_px * 0.32); + const is_active = i < current_lives; + draw_drone_life_pip(ctx, pip_x, pip_y, icon_size, is_active, lvs_color, cell_px); + } + + // Línea 2: Dron (DRN) + const pos_cells_x = dcx.toFixed(2); + const pos_cells_y = dcy.toFixed(2); + draw_hud_text(ctx, `DRN : X[${pos_cells_x >= 0 ? "+" : ""}${pos_cells_x}] Y[${pos_cells_y >= 0 ? "+" : ""}${pos_cells_y}]`, hud_x, hud_y + (line_spacing * 2), drone_color, cell_px); + draw_bipolar_bar(ctx, bar_start_x, (hud_y + (line_spacing * 2)) - (cell_px * 0.75), bar_w, bar_h, dcx / safe_radius_cells, drone_color, cell_px); + + // Línea 3: Puntero (PTR) + const ptr_x = ptr_cx.toFixed(2); + const ptr_y = ptr_cy.toFixed(2); + draw_hud_text(ctx, `PTR : X[${ptr_x >= 0 ? "+" : ""}${ptr_x}] Y[${ptr_y >= 0 ? "+" : ""}${ptr_y}]`, hud_x, hud_y + (line_spacing * 3), ptr_color, cell_px); + draw_bipolar_bar(ctx, bar_start_x, (hud_y + (line_spacing * 3)) - (cell_px * 0.75), bar_w, bar_h, ptr_cx / safe_radius_cells, ptr_color, cell_px); + + // Línea 4: Velocidad (SPD) + const spd_x = v_cell_x.toFixed(2); + const spd_y = v_cell_y.toFixed(2); + draw_hud_text(ctx, `SPD : X[${spd_x >= 0 ? "+" : ""}${spd_x}] Y[${spd_y >= 0 ? "+" : ""}${spd_y}]`, hud_x, hud_y + (line_spacing * 4), "#10b981", cell_px); + + const current_speed_cells = Math.sqrt(v_cell_x * v_cell_x + v_cell_y * v_cell_y); + const spd_pct = Math.min(current_speed_cells / 15.0, 1.0); + + ctx.fillStyle = "rgba(2, 6, 23, 0.88)"; + ctx.fillRect(bar_start_x - (cell_px * 0.1), (hud_y + (line_spacing * 4)) - (cell_px * 0.75) - (cell_px * 0.1), bar_w + (cell_px * 0.2), bar_h + (cell_px * 0.2)); + ctx.strokeStyle = "rgba(56, 189, 248, 0.65)"; + ctx.lineWidth = cell_px * 0.09; + ctx.strokeRect(bar_start_x, (hud_y + (line_spacing * 4)) - (cell_px * 0.75), bar_w, bar_h); + ctx.fillStyle = "#10b981"; + ctx.fillRect(bar_start_x, (hud_y + (line_spacing * 4)) - (cell_px * 0.75), bar_w * spd_pct, bar_h); + + // Línea 5: Bloque de Escudo del Dron (SHD) + const shield_info = drone_info.shield !== undefined + ? { current : drone_info.shield, max : drone_info.max_shield || 100 } + : { current : 100, max : 100 }; + + const shield_pct = Math.max(0, Math.min(1.0, shield_info.current / shield_info.max)); + const shield_y = hud_y + (line_spacing * 5); + + let shield_color = "#38bdf8"; + let is_critical_shield = false; + + if(shield_pct <= 0.25){ + shield_color = (Math.sin(blink_timer * 12) > 0) ? "#ef4444" : "#7f1d1d"; + is_critical_shield = true; + } else if(shield_pct <= 0.55){ + shield_color = "#f59e0b"; + } + + draw_hud_text(ctx, `SHD : [${(shield_pct * 100).toFixed(0).padStart(3, " ")}%]`, hud_x, shield_y, shield_color, cell_px); + draw_segmented_bar(ctx, bar_start_x, shield_y - (cell_px * 0.75), bar_w, bar_h, shield_pct, shield_color, cell_px, 10); + + // Línea 6: Integridad de la Nave Nodriza / Cabina (MTH) + let mothership_pct = 1.0; + if(cockpit_model && typeof cockpit_model.get_health_percentage === "function"){ + mothership_pct = cockpit_model.get_health_percentage(); + } else if(cockpit_model && typeof cockpit_model.get_hull === "function"){ + mothership_pct = cockpit_model.get_hull() / 100.0; + } else if(typeof drone_info.mothership_pct === "number"){ + mothership_pct = drone_info.mothership_pct; + } + + mothership_pct = Math.max(0, Math.min(1.0, mothership_pct)); + const mth_y = hud_y + (line_spacing * 6); + + let mth_color = "#10b981"; + let is_critical_mth = false; + + if(mothership_pct <= 0.25){ + mth_color = (Math.sin(blink_timer * 12) > 0) ? "#ef4444" : "#7f1d1d"; + is_critical_mth = true; + } else if(mothership_pct <= 0.55){ + mth_color = "#f59e0b"; + } + + draw_hud_text(ctx, `MTH : [${(mothership_pct * 100).toFixed(0).padStart(3, " ")}%]`, hud_x, mth_y, mth_color, cell_px); + draw_segmented_bar(ctx, bar_start_x, mth_y - (cell_px * 0.75), bar_w, bar_h, mothership_pct, mth_color, cell_px, 10); + + // Línea 7: Estado del Enlace (LINK) + const link_y = hud_y + (line_spacing * 7); + let status_text = "LINK: OPTIMAL"; + let status_color = "#38bdf8"; + + if(current_lives <= 0){ + status_text = "DRONES DEPLETED - MISSION FAILED"; + status_color = "#ef4444"; + } else if(current_lives === 1){ + status_text = (Math.sin(blink_timer * 12) > 0) ? "WARN: LAST DRONE STANDING!" : "WARN: CRITICAL RESERVES"; + status_color = "#ef4444"; + } else if(is_drone_lost || is_ptr_lost){ + status_text = (Math.sin(blink_timer * 15) > 0) ? "LINK: SIGNAL LOST!" : ""; + status_color = "#ef4444"; + } else if(drone_danger_alpha > 0 || ptr_danger_alpha > 0){ + status_text = (Math.sin(blink_timer * 8) > 0) ? "LINK: BOUNDARY WARNING" : "LINK: UNSTABLE"; + status_color = "#f59e0b"; + } else if(is_critical_mth){ + status_text = "WARN: MOTHERSHIP CRITICAL"; + status_color = "#ef4444"; + } else if(is_critical_shield){ + status_text = "WARN: SHIELD CRITICAL"; + status_color = "#ef4444"; + } + + draw_hud_text(ctx, status_text, hud_x, link_y, status_color, cell_px); + + ctx.restore(); + }; + + }; + + return TelemetryHudModel; +})(); \ No newline at end of file diff --git a/Public/ecma/Sounds/AsteroidThreatAlarmSound.ecma.js b/Public/ecma/Sounds/AsteroidThreatAlarmSound.ecma.js new file mode 100644 index 0000000..2f334de --- /dev/null +++ b/Public/ecma/Sounds/AsteroidThreatAlarmSound.ecma.js @@ -0,0 +1,82 @@ +"use strict"; + +const AsteroidThreatAlarmSound = (function(){ + + return function(audio_ctx, output_node, volume_multiplier = 1.0, threat_level = 0.5){ + const now = audio_ctx.currentTime; + const threat = Math.max(0.0, Math.min(1.0, threat_level)); + + // Bus maestro de cabina + const master_bus = audio_ctx.createGain(); + master_bus.gain.setValueAtTime(volume_multiplier * 0.5, now); + + // Filtro de megafonía/altavoz de instrumentación (elimina graves y agudos molestos) + const horn_filter = audio_ctx.createBiquadFilter(); + horn_filter.type = "bandpass"; + horn_filter.frequency.setValueAtTime(1150, now); + horn_filter.Q.setValueAtTime(1.8, now); + + master_bus.connect(horn_filter); + horn_filter.connect(output_node); + + // Función que sintetiza un 'BEEP' limpio de aviónica + const trigger_avionics_beep = (start_time, freq) => { + const beep_duration = 0.065; // 65ms: corto, clínico y penetrante + + // Envolvente de amplitud trapezoidal (ataque 4ms, salida 8ms) + const env = audio_ctx.createGain(); + env.gain.setValueAtTime(0.0001, start_time); + env.gain.linearRampToValueAtTime(0.85, start_time + 0.004); + env.gain.setValueAtTime(0.85, start_time + beep_duration - 0.008); + env.gain.exponentialRampToValueAtTime(0.0001, start_time + beep_duration); + + // 1. Tono fundamental limpio + const osc_main = audio_ctx.createOscillator(); + osc_main.type = "sine"; + osc_main.frequency.setValueAtTime(freq, start_time); + + // 2. Segundo armónico discreto (le da presencia de instrumento de cabina) + const osc_harm = audio_ctx.createOscillator(); + const harm_gain = audio_ctx.createGain(); + osc_harm.type = "sine"; + osc_harm.frequency.setValueAtTime(freq * 2, start_time); + harm_gain.gain.setValueAtTime(0.18, start_time); + + // 3. Toque de onda triangular sutil para cuerpo mecánico + const osc_tri = audio_ctx.createOscillator(); + const tri_gain = audio_ctx.createGain(); + osc_tri.type = "triangle"; + osc_tri.frequency.setValueAtTime(freq, start_time); + tri_gain.gain.setValueAtTime(0.12, start_time); + + osc_main.connect(env); + osc_harm.connect(harm_gain); + harm_gain.connect(env); + osc_tri.connect(tri_gain); + tri_gain.connect(env); + + env.connect(master_bus); + + osc_main.start(start_time); + osc_harm.start(start_time); + osc_tri.start(start_time); + + osc_main.stop(start_time + beep_duration); + osc_harm.stop(start_time + beep_duration); + osc_tri.stop(start_time + beep_duration); + }; + + // Frecuencia estándar de advertencia: 1020 Hz (tono aeronáutico estándar) + // Si la piedra viene de camino a la cabina (> 0.6), sube ligeramente a 1280 Hz + const base_freq = threat > 0.6 ? 1280 : 1020; + + // Primer beep + trigger_avionics_beep(now, base_freq); + + // Si el peligro es inminente (la roca ya fue lanzada a cabina), emite un doble beep militar ("PIP-PIP") + if(threat > 0.6){ + trigger_avionics_beep(now + 0.09, base_freq); + } + }; + +})(); \ No newline at end of file diff --git a/Public/ecma/Sounds/HitExplosionSound.ecma.js b/Public/ecma/Sounds/HitExplosionSound.ecma.js new file mode 100644 index 0000000..c5fe99e --- /dev/null +++ b/Public/ecma/Sounds/HitExplosionSound.ecma.js @@ -0,0 +1,52 @@ +"use strict"; + +const HitExplosionSound = (function(){ + + return function(audio_ctx, output_node, volume_multiplier = 1.0){ + const now = audio_ctx.currentTime; + + const buffer_size = audio_ctx.sampleRate * 0.48; + const buffer = audio_ctx.createBuffer(1, buffer_size, audio_ctx.sampleRate); + const data = buffer.getChannelData(0); + for(let i = 0; i < buffer_size; i++){ + data[i] = (Math.random() * 2) - 1; + } + + const noise = audio_ctx.createBufferSource(); + noise.buffer = buffer; + + const filter = audio_ctx.createBiquadFilter(); + const gain = audio_ctx.createGain(); + + // Ruido sordo filtrado en graves + filter.type = "lowpass"; + filter.frequency.setValueAtTime(420, now); + filter.frequency.exponentialRampToValueAtTime(32, now + 0.48); + + gain.gain.setValueAtTime(0.9 * volume_multiplier, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.48); + + noise.connect(filter); + filter.connect(gain); + gain.connect(output_node); + + // Subgrave sintetizado para dar golpe de casco en el vacío + const osc = audio_ctx.createOscillator(); + const osc_gain = audio_ctx.createGain(); + osc.type = "triangle"; + osc.frequency.setValueAtTime(130, now); + osc.frequency.exponentialRampToValueAtTime(25, now + 0.35); + + osc_gain.gain.setValueAtTime(0.8 * volume_multiplier, now); + osc_gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35); + + osc.connect(osc_gain); + osc_gain.connect(output_node); + + osc.start(now); + osc.stop(now + 0.35); + noise.start(now); + noise.stop(now + 0.48); + }; + +})(); \ No newline at end of file diff --git a/Public/ecma/Sounds/MothershipImpactSound.ecma.js b/Public/ecma/Sounds/MothershipImpactSound.ecma.js new file mode 100644 index 0000000..a2cff3a --- /dev/null +++ b/Public/ecma/Sounds/MothershipImpactSound.ecma.js @@ -0,0 +1,136 @@ +"use strict"; + +const MothershipImpactSound = (function(){ + + // Curva de saturación analógica para darle máxima pegada sin romper el audio + const make_distortion_curve = (amount = 20) => { + const samples = 44100; + const curve = new Float32Array(samples); + const deg = Math.PI / 180; + for(let i = 0; i < samples; ++i){ + const x = (i * 2) / samples - 1; + curve[i] = ((3 + amount) * x * 20 * deg) / (Math.PI + amount * Math.abs(x)); + } + return curve; + }; + + return function(audio_ctx, output_node, volume_multiplier = 1.0){ + const now = audio_ctx.currentTime; + const duration = 2.4; // Retumbe largo y cavernoso + + // Bus maestro del efecto con booster de presencia + const master_bus = audio_ctx.createGain(); + master_bus.gain.setValueAtTime(volume_multiplier * 1.35, now); + + // Saturador suave para sonido "aplastante" + const shaper = audio_ctx.createWaveShaper(); + shaper.curve = make_distortion_curve(18); + shaper.oversample = "2x"; + + // ======================================================== + // CAPA 1: SUB-GRAVE SÍSMICO (Terremoto acústico en cabina) + // ======================================================== + const sub_osc = audio_ctx.createOscillator(); + const sub_gain = audio_ctx.createGain(); + + sub_osc.type = "sine"; + sub_osc.frequency.setValueAtTime(145, now); + // Caída drástica a frecuencias casi inaudibles pero que mueven aire + sub_osc.frequency.exponentialRampToValueAtTime(22, now + 0.45); + sub_osc.frequency.setValueAtTime(20, now + duration); + + sub_gain.gain.setValueAtTime(0.001, now); + sub_gain.gain.linearRampToValueAtTime(1.0, now + 0.015); + sub_gain.gain.exponentialRampToValueAtTime(0.001, now + 1.8); + + sub_osc.connect(sub_gain); + sub_gain.connect(shaper); + + // ======================================================== + // CAPA 2: CRUJIDO DE CASCO Y METRALLA (Ráfaga de ruido blanco) + // ======================================================== + const buffer_size = Math.floor(audio_ctx.sampleRate * 1.5); + const noise_buffer = audio_ctx.createBuffer(1, buffer_size, audio_ctx.sampleRate); + const noise_data = noise_buffer.getChannelData(0); + for(let i = 0; i < buffer_size; i++){ + noise_data[i] = (Math.random() * 2 - 1); + } + + const noise_src = audio_ctx.createBufferSource(); + noise_src.buffer = noise_buffer; + + const noise_filter = audio_ctx.createBiquadFilter(); + noise_filter.type = "lowpass"; + noise_filter.frequency.setValueAtTime(4200, now); + noise_filter.frequency.exponentialRampToValueAtTime(110, now + 1.2); + noise_filter.Q.setValueAtTime(2.5, now); + + const noise_env = audio_ctx.createGain(); + noise_env.gain.setValueAtTime(0.001, now); + noise_env.gain.linearRampToValueAtTime(0.9, now + 0.02); + noise_env.gain.exponentialRampToValueAtTime(0.001, now + 1.4); + + noise_src.connect(noise_filter); + noise_filter.connect(noise_env); + noise_env.connect(shaper); + + // ======================================================== + // CAPA 3: DEFORMACIÓN METÁLICA (Resonador desafinado) + // ======================================================== + const metal_osc_a = audio_ctx.createOscillator(); + const metal_osc_b = audio_ctx.createOscillator(); + const metal_gain = audio_ctx.createGain(); + + metal_osc_a.type = "sawtooth"; + metal_osc_b.type = "triangle"; + + metal_osc_a.frequency.setValueAtTime(78, now); + metal_osc_a.frequency.exponentialRampToValueAtTime(32, now + 0.9); + + metal_osc_b.frequency.setValueAtTime(114, now); + metal_osc_b.frequency.exponentialRampToValueAtTime(38, now + 0.9); + + metal_gain.gain.setValueAtTime(0.001, now); + metal_gain.gain.linearRampToValueAtTime(0.55, now + 0.04); + metal_gain.gain.exponentialRampToValueAtTime(0.001, now + 1.1); + + metal_osc_a.connect(metal_gain); + metal_osc_b.connect(metal_gain); + metal_gain.connect(shaper); + + // ======================================================== + // CAPA 4: COLA DE REVERBERACIÓN DE LA NAVE (Eco retumbante) + // ======================================================== + const rumble_delay = audio_ctx.createDelay(); + rumble_delay.delayTime.setValueAtTime(0.075, now); + + const rumble_fb = audio_ctx.createGain(); + rumble_fb.gain.setValueAtTime(0.48, now); + + const rumble_filter = audio_ctx.createBiquadFilter(); + rumble_filter.type = "lowpass"; + rumble_filter.frequency.setValueAtTime(320, now); + + rumble_delay.connect(rumble_filter); + rumble_filter.connect(rumble_fb); + rumble_fb.connect(rumble_delay); + rumble_filter.connect(master_bus); + + // Conexiones de salida + shaper.connect(master_bus); + shaper.connect(rumble_delay); + master_bus.connect(output_node); + + // Disparo coordinado de los generadores + sub_osc.start(now); + noise_src.start(now); + metal_osc_a.start(now); + metal_osc_b.start(now); + + sub_osc.stop(now + duration); + noise_src.stop(now + 1.5); + metal_osc_a.stop(now + 1.2); + metal_osc_b.stop(now + 1.2); + }; + +})(); \ No newline at end of file diff --git a/Public/ecma/Sounds/PerimeterWarningSound.ecma.js b/Public/ecma/Sounds/PerimeterWarningSound.ecma.js new file mode 100644 index 0000000..bdbc0d4 --- /dev/null +++ b/Public/ecma/Sounds/PerimeterWarningSound.ecma.js @@ -0,0 +1,51 @@ +"use strict"; + +const PerimeterWarningSound = (function(){ + + return function(audio_ctx, output_node, volume_multiplier = 1.0){ + const now = audio_ctx.currentTime; + const duration = 0.14; + + const preset_bus = audio_ctx.createGain(); + preset_bus.gain.setValueAtTime(volume_multiplier * 0.5, now); + + // Envolvente seca estilo sonar + const env = audio_ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.85, now + 0.01); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + // Tono puro resonante de telemetría militar (960Hz descendiendo a 820Hz) + const osc = audio_ctx.createOscillator(); + osc.type = "sine"; + osc.frequency.setValueAtTime(960, now); + osc.frequency.exponentialRampToValueAtTime(820, now + duration); + + // Armónico metálico sutil + const harm = audio_ctx.createOscillator(); + const harm_gain = audio_ctx.createGain(); + harm.type = "triangle"; + harm.frequency.setValueAtTime(1920, now); + harm_gain.gain.setValueAtTime(0.2, now); + + // Filtro de mampara + const filter = audio_ctx.createBiquadFilter(); + filter.type = "bandpass"; + filter.frequency.setValueAtTime(900, now); + filter.Q.setValueAtTime(2.5, now); + + osc.connect(env); + harm.connect(harm_gain); + harm_gain.connect(env); + + env.connect(filter); + filter.connect(preset_bus); + preset_bus.connect(output_node); + + osc.start(now); + harm.start(now); + osc.stop(now + duration); + harm.stop(now + duration); + }; + +})(); \ No newline at end of file diff --git a/Public/ecma/Sounds/ShieldExplosionSound.ecma.js b/Public/ecma/Sounds/ShieldExplosionSound.ecma.js new file mode 100644 index 0000000..d5481db --- /dev/null +++ b/Public/ecma/Sounds/ShieldExplosionSound.ecma.js @@ -0,0 +1,87 @@ +"use strict"; + +const ShieldExplosionSound = (function(){ + + return function(audio_ctx, output_node, volume_multiplier = 1.0){ + const now = audio_ctx.currentTime; + const duration = 1.6; // Explosión profunda y larga + + const preset_bus = audio_ctx.createGain(); + preset_bus.gain.setValueAtTime(volume_multiplier, now); + + // 1. RUIDO BROWNIANO / ROJO (El trueno profundo) + // Genera una acumulación continua de bajas frecuencias (cero agudos) + const buffer_size = Math.floor(audio_ctx.sampleRate * duration); + const buffer = audio_ctx.createBuffer(1, buffer_size, audio_ctx.sampleRate); + const data = buffer.getChannelData(0); + let last_val = 0.0; + + for(let i = 0; i < buffer_size; i++){ + const white = (Math.random() * 2) - 1; + // Integración para convertir ruido blanco en browniano (grave puro) + last_val = (last_val + (0.02 * white)) / 1.02; + data[i] = last_val * 3.5; + } + + const rumble_source = audio_ctx.createBufferSource(); + rumble_source.buffer = buffer; + + // Filtro paso-bajo muy estricto: solo deja pasar el retumbe sordo + const rumble_filter = audio_ctx.createBiquadFilter(); + rumble_filter.type = "lowpass"; + rumble_filter.frequency.setValueAtTime(140, now); + rumble_filter.frequency.exponentialRampToValueAtTime(30, now + duration); + + const rumble_gain = audio_ctx.createGain(); + rumble_gain.gain.setValueAtTime(1.2, now); + // El estruendo decae lentamente como una onda expansiva real + rumble_gain.gain.exponentialRampToValueAtTime(0.001, now + duration); + + rumble_source.connect(rumble_filter); + rumble_filter.connect(rumble_gain); + rumble_gain.connect(preset_bus); + + rumble_source.start(now); + rumble_source.stop(now + duration); + + // 2. ONDA DE CHOQUE SUBGRAVE (El golpe sísmico en el pecho) + // Desciende a frecuencias casi inaudibles pero que mueven la membrana de los cascos + const sub_osc = audio_ctx.createOscillator(); + const sub_gain = audio_ctx.createGain(); + + sub_osc.type = "sine"; + sub_osc.frequency.setValueAtTime(68, now); + sub_osc.frequency.exponentialRampToValueAtTime(18, now + 0.85); + + sub_gain.gain.setValueAtTime(1.4, now); + sub_gain.gain.exponentialRampToValueAtTime(0.001, now + 0.85); + + sub_osc.connect(sub_gain); + sub_gain.connect(preset_bus); + + sub_osc.start(now); + sub_osc.stop(now + 0.85); + + // 3. SEGUNDO OSCILADOR DE GRUÑIDO (Rumble modulado) + // Aporta fricción y textura de metal/masa estallando en el vacío + const rumble_osc = audio_ctx.createOscillator(); + const rumble_osc_gain = audio_ctx.createGain(); + + rumble_osc.type = "triangle"; + rumble_osc.frequency.setValueAtTime(45, now); + rumble_osc.frequency.exponentialRampToValueAtTime(12, now + 1.2); + + rumble_osc_gain.gain.setValueAtTime(0.7, now); + rumble_osc_gain.gain.exponentialRampToValueAtTime(0.001, now + 1.2); + + rumble_osc.connect(rumble_osc_gain); + rumble_osc_gain.connect(preset_bus); + + rumble_osc.start(now); + rumble_osc.stop(now + 1.2); + + // Salida directa hacia el bus maestro y limitador + preset_bus.connect(output_node); + }; + +})(); \ No newline at end of file diff --git a/Public/ecma/Sounds/ShieldLowAlarmSound.ecma.js b/Public/ecma/Sounds/ShieldLowAlarmSound.ecma.js new file mode 100644 index 0000000..d8803ba --- /dev/null +++ b/Public/ecma/Sounds/ShieldLowAlarmSound.ecma.js @@ -0,0 +1,94 @@ +"use strict"; + +const ShieldLowAlarmSound = (function(){ + + return function(audio_ctx, output_node, volume_multiplier = 1.0){ + const now = audio_ctx.currentTime; + const duration = 0.22; // Pulso de sirena industrial rápido + + const preset_bus = audio_ctx.createGain(); + preset_bus.gain.setValueAtTime(volume_multiplier * 0.45, now); + + // 1. REVERBERACIÓN CORTA DE MAMPARA (Eco de pasillo de nave) + const room_delay = audio_ctx.createDelay(); + room_delay.delayTime.setValueAtTime(0.045, now); + + const room_feedback = audio_ctx.createGain(); + room_feedback.gain.setValueAtTime(0.35, now); + + const room_filter = audio_ctx.createBiquadFilter(); + room_filter.type = "lowpass"; + room_filter.frequency.setValueAtTime(1200, now); + + room_delay.connect(room_filter); + room_filter.connect(room_feedback); + room_feedback.connect(room_delay); + room_filter.connect(preset_bus); + + // 2. ENVOLVENTE DINÁMICA DE BOCINA (Ataque percusivo + cuerpo) + const env = audio_ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + // Ataque rápido simulando apertura de válvula + env.gain.linearRampToValueAtTime(0.85, now + 0.02); + env.gain.setValueAtTime(0.85, now + 0.14); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + // 3. GENERADOR DE SIRENA INDUSTRIAL (Síntesis FM limpia) + // Transportadora principal: Tono puro de bocina (senoidal limpia) + const carrier = audio_ctx.createOscillator(); + carrier.type = "sine"; + + // Barrido característico de evacuación: empieza arriba y resbala hacia abajo + carrier.frequency.setValueAtTime(740, now); + carrier.frequency.exponentialRampToValueAtTime(460, now + duration); + + // Modulador FM: Añade el zumbido de vibración de la membrana metálica + const modulator = audio_ctx.createOscillator(); + const mod_gain = audio_ctx.createGain(); + + modulator.type = "sine"; + // Modulación a 45Hz para simular el traqueteo del motor de la sirena + modulator.frequency.setValueAtTime(45, now); + mod_gain.gain.setValueAtTime(140, now); + mod_gain.gain.exponentialRampToValueAtTime(40, now + duration); + + modulator.connect(mod_gain); + mod_gain.connect(carrier.frequency); + + // 4. SEGUNDO TONO ARMÓNICO (Cuerpo de resonador hueco) + const sub_carrier = audio_ctx.createOscillator(); + const sub_gain = audio_ctx.createGain(); + sub_carrier.type = "triangle"; + sub_carrier.frequency.setValueAtTime(370, now); + sub_carrier.frequency.exponentialRampToValueAtTime(230, now + duration); + + sub_gain.gain.setValueAtTime(0.3, now); + sub_carrier.connect(sub_gain); + sub_gain.connect(env); + + // 5. FILTRO PASO-BANDA (Emula el megáfono físico de cabina) + const megaphone_filter = audio_ctx.createBiquadFilter(); + megaphone_filter.type = "bandpass"; + megaphone_filter.frequency.setValueAtTime(650, now); + megaphone_filter.Q.setValueAtTime(1.8, now); + + carrier.connect(env); + env.connect(megaphone_filter); + + // Enviamos al bus directo y una copia a la reverberación de mampara + megaphone_filter.connect(preset_bus); + megaphone_filter.connect(room_delay); + + preset_bus.connect(output_node); + + // Disparo coordinado + modulator.start(now); + carrier.start(now); + sub_carrier.start(now); + + modulator.stop(now + duration); + carrier.stop(now + duration); + sub_carrier.stop(now + duration); + }; + +})(); \ No newline at end of file diff --git a/Public/ecma/Strategies/BehaviorStrategies.ecma.js b/Public/ecma/Strategies/BehaviorStrategies.ecma.js new file mode 100644 index 0000000..470a5a5 --- /dev/null +++ b/Public/ecma/Strategies/BehaviorStrategies.ecma.js @@ -0,0 +1,327 @@ +"use strict"; + +const BehaviorStrategies = (function(){ + + const STRATEGIES = { + + // 1. PERSECUCIÓN DEL JUGADOR CON ROTACIÓN Y ACELERACIÓN SUAVES + track_drone : (enemy, delta_time, drone_info) => { + if(drone_info){ + const dx = drone_info.cell_x - enemy.x; + const dy = drone_info.cell_y - enemy.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const accel = enemy.config.accel || 3.5; + + if(dist > 0.1){ + enemy.vx += (dx / dist) * accel * delta_time; + enemy.vy += (dy / dist) * accel * delta_time; + } + + const max_speed = enemy.config.max_speed || 5.2; + const spd = Math.sqrt(enemy.vx * enemy.vx + enemy.vy * enemy.vy); + if(spd > max_speed){ + enemy.vx = (enemy.vx / spd) * max_speed; + enemy.vy = (enemy.vy / spd) * max_speed; + } + } + + enemy.x += enemy.vx * delta_time; + enemy.y += enemy.vy * delta_time; + + // ROTACIÓN PROGRESIVA Y SUAVE (Giro de timón realista) + const speed_sq = enemy.vx * enemy.vx + enemy.vy * enemy.vy; + if(speed_sq > 0.05){ + const target_angle = Math.atan2(enemy.vy, enemy.vx) + (Math.PI / 2); + + let diff = target_angle - enemy.angle; + while(diff < -Math.PI) diff += Math.PI * 2; + while(diff > Math.PI) diff -= Math.PI * 2; + + const turn_speed = enemy.config.turn_speed || 4.2; // Radianes/segundo + const max_step = turn_speed * delta_time; + + if(Math.abs(diff) <= max_step){ + enemy.angle = target_angle; + } else { + enemy.angle += Math.sign(diff) * max_step; + } + } + }, + + // 2. ESTÁTICO CON ROTACIÓN Y DERIVA INERCIAL + static : (enemy, delta_time) => { + enemy.angle += 0.5 * delta_time; + enemy.x += enemy.vx * delta_time; + enemy.y += enemy.vy * delta_time; + enemy.vx *= 0.98; + enemy.vy *= 0.98; + }, + + // 3. REBOTE EN LOS MÁRGENES DE LA CABINA + // 3. PATRULLA CON FRENADO EN BORDES, GIRO SUAVE Y ACELERACIÓN + bounce_screen : (enemy, delta_time) => { + // Inicialización de estado interno de maniobra + if(!enemy.maneuver_state){ + enemy.maneuver_state = { + phase : "cruising", // "cruising" | "braking_and_turning" | "accelerating" + target_vx : enemy.vx || 3.5, + target_vy : enemy.vy || 3.5, + target_angle : enemy.angle || 0, + cruise_speed : Math.sqrt((enemy.vx * enemy.vx) + (enemy.vy * enemy.vy)) || 4.2 + }; + } + + const m = enemy.maneuver_state; + const boundary_limit = 15.5; // Distancia límite en celdas desde el centro + const turn_speed = enemy.config.turn_speed || 4.5; // Velocidad de giro en rad/s + + // FASE 1: VUELO DE CRUCERO + if(m.phase === "cruising"){ + enemy.x += enemy.vx * delta_time; + enemy.y += enemy.vy * delta_time; + + // Comprobamos si rebasa el margen perimetral + const hit_x = (enemy.x > boundary_limit && enemy.vx > 0) || (enemy.x < -boundary_limit && enemy.vx < 0); + const hit_y = (enemy.y > boundary_limit && enemy.vy > 0) || (enemy.y < -boundary_limit && enemy.vy < 0); + + if(hit_x || hit_y){ + // Calculamos la nueva dirección vectorial reflejada + const new_dir_x = hit_x ? -Math.sign(enemy.vx) : (enemy.vx > 0 ? 1 : -1); + const new_dir_y = hit_y ? -Math.sign(enemy.vy) : (enemy.vy > 0 ? 1 : -1); + + // Pequeña variación angular aleatoria para que no rebote siempre en el mismo bucle + const jitter = (Math.random() - 0.5) * 0.4; + const base_angle = Math.atan2(new_dir_y, new_dir_x) + jitter; + + m.target_vx = Math.cos(base_angle) * m.cruise_speed; + m.target_vy = Math.sin(base_angle) * m.cruise_speed; + m.target_angle = base_angle + (Math.PI / 2); + + m.phase = "braking_and_turning"; + } else { + // Mantiene la orientación alineada con la dirección de avance + if(Math.abs(enemy.vx) > 0.05 || Math.abs(enemy.vy) > 0.05){ + enemy.angle = Math.atan2(enemy.vy, enemy.vx) + (Math.PI / 2); + } + } + } + + // FASE 2: FRENADO Y GIRO SUAVE EN EL MARGEN + else if(m.phase === "braking_and_turning"){ + // Desaceleración progresiva (retrocohetes) + enemy.vx *= Math.pow(0.08, delta_time); + enemy.vy *= Math.pow(0.08, delta_time); + + enemy.x += enemy.vx * delta_time; + enemy.y += enemy.vy * delta_time; + + // Viraje suave hacia el nuevo ángulo objetivo + let diff = m.target_angle - enemy.angle; + while(diff < -Math.PI) diff += Math.PI * 2; + while(diff > Math.PI) diff -= Math.PI * 2; + + const max_step = turn_speed * delta_time; + if(Math.abs(diff) <= max_step){ + enemy.angle = m.target_angle; + } else { + enemy.angle += Math.sign(diff) * max_step; + } + + const current_speed = Math.sqrt((enemy.vx * enemy.vx) + (enemy.vy * enemy.vy)); + + // Si ha frenado a casi cero y ya completó el giro, iniciamos aceleración + if(current_speed < 0.35 && Math.abs(diff) < 0.25){ + enemy.vx = 0; + enemy.vy = 0; + m.phase = "accelerating"; + } + } + + // FASE 3: ACELERACIÓN HACIA LA NUEVA DIRECCIÓN + else if(m.phase === "accelerating"){ + const forward_angle = enemy.angle - (Math.PI / 2); + const accel_rate = enemy.config.accel || 5.0; // Tasa de aceleración + + enemy.vx += Math.cos(forward_angle) * accel_rate * delta_time; + enemy.vy += Math.sin(forward_angle) * accel_rate * delta_time; + + enemy.x += enemy.vx * delta_time; + enemy.y += enemy.vy * delta_time; + + const current_speed = Math.sqrt((enemy.vx * enemy.vx) + (enemy.vy * enemy.vy)); + + // Al alcanzar la velocidad de crucero, vuelve al ciclo normal + if(current_speed >= m.cruise_speed){ + const factor = m.cruise_speed / current_speed; + enemy.vx *= factor; + enemy.vy *= factor; + m.phase = "cruising"; + } + } + }, + + // 4. BALÍSTICO / CRUCE DIRECTO + cross_screen : (enemy, delta_time) => { + enemy.x += enemy.vx * delta_time; + enemy.y += enemy.vy * delta_time; + enemy.angle = Math.atan2(enemy.vy, enemy.vx) + (Math.PI / 2); + + const dist_center = Math.sqrt(enemy.x * enemy.x + enemy.y * enemy.y); + if(enemy.age > 1.5 && dist_center > 24.0){ + enemy.alive = false; + } + }, + + // 5. IA LANZADOR DE ASTEROIDES (Confinado en el área jugable) + thrower_ai : (enemy, delta_time, drone_info, all_enemies) => { + const turn_speed = enemy.config.turn_speed || 4.2; + const cruise_speed = enemy.config.max_speed || 4.8; + const flee_distance = enemy.config.flee_distance || 7.5; + const max_patrol_radius = 16.5; // Radio límite donde el Dron siempre puede alcanzarlo + + let target_vx = enemy.vx; + let target_vy = enemy.vy; + + // 1. EVALUAR AMENAZA DEL DRON (Prioridad 1: Huir sin salirse del mapa) + let is_fleeing = false; + if(drone_info){ + const dx_drone = enemy.x - drone_info.cell_x; + const dy_drone = enemy.y - drone_info.cell_y; + const dist_drone = Math.sqrt(dx_drone * dx_drone + dy_drone * dy_drone); + + if(dist_drone < flee_distance && dist_drone > 0.05){ + is_fleeing = true; + + // Dirección de alejamiento puro + let flee_x = dx_drone / dist_drone; + let flee_y = dy_drone / dist_drone; + + // Si está cerca del borde, no puede seguir retrocediendo: se desvía en ángulo tangente + const dist_center = Math.sqrt(enemy.x * enemy.x + enemy.y * enemy.y); + if(dist_center > 13.5){ + // Vector hacia el centro + const to_center_x = -enemy.x / dist_center; + const to_center_y = -enemy.y / dist_center; + + // Mezcla huida con escape lateral (tangente) para rodear al dron hacia el centro + const tangent_x = -flee_y; + const tangent_y = flee_x; + + // Elegir el sentido tangencial que más se acerque al centro + const dot = (tangent_x * to_center_x) + (tangent_y * to_center_y); + const side = dot >= 0 ? 1 : -1; + + flee_x = (flee_x * 0.3) + (tangent_x * side * 0.7) + (to_center_x * 0.5); + flee_y = (flee_y * 0.3) + (tangent_y * side * 0.7) + (to_center_y * 0.5); + + const norm = Math.sqrt(flee_x * flee_x + flee_y * flee_y) || 1; + flee_x /= norm; + flee_y /= norm; + } + + target_vx = flee_x * cruise_speed * 1.2; + target_vy = flee_y * cruise_speed * 1.2; + } + } + + // 2. SI NO ESTÁ HUYENDO: BUSCAR Y DISPARAR ASTEROIDES + if(!is_fleeing){ + let closest_ast = null; + let min_ast_dist = Infinity; + + if(Array.isArray(all_enemies)){ + for(let i = 0; i < all_enemies.length; i++){ + const other = all_enemies[i]; + if(other && other.is_alive() && other.is_asteroid && !other.is_targeted_towards_mothership()){ + const st = other.get_state(); + + // Ignora asteroides que ya se hayan salido + const d_center = Math.sqrt(st.x * st.x + st.y * st.y); + if(d_center > 18.5) continue; + + const dx = st.x - enemy.x; + const dy = st.y - enemy.y; + const d = Math.sqrt(dx * dx + dy * dy); + if(d < min_ast_dist){ + min_ast_dist = d; + closest_ast = other; + } + } + } + } + + if(closest_ast){ + const ast_st = closest_ast.get_state(); + const dx = ast_st.x - enemy.x; + const dy = ast_st.y - enemy.y; + + // Si está pegado al asteroide: lo dispara hacia el centro + if(min_ast_dist < 2.2){ + const to_center_dist = Math.sqrt(ast_st.x * ast_st.x + ast_st.y * ast_st.y); + if(to_center_dist > 0.1){ + const launch_speed = 7.5; + const launch_vx = -(ast_st.x / to_center_dist) * launch_speed; + const launch_vy = -(ast_st.y / to_center_dist) * launch_speed; + + closest_ast.launch_towards_mothership(launch_vx, launch_vy); + } + + // Rebote inercial tras el tiro + target_vx = (dx / (min_ast_dist || 1)) * -cruise_speed; + target_vy = (dy / (min_ast_dist || 1)) * -cruise_speed; + } else { + target_vx = (dx / min_ast_dist) * cruise_speed; + target_vy = (dy / min_ast_dist) * cruise_speed; + } + } else { + // Si no hay asteroides y está muy lejos, patrulla de vuelta hacia el radio seguro + const cur_dist = Math.sqrt(enemy.x * enemy.x + enemy.y * enemy.y); + if(cur_dist > 14.0){ + target_vx = (-enemy.x / cur_dist) * (cruise_speed * 0.7); + target_vy = (-enemy.y / cur_dist) * (cruise_speed * 0.7); + } else { + enemy.angle += 0.8 * delta_time; + } + } + } + + // Aplicación de aceleración + const accel = 4.2; + enemy.vx += (target_vx - enemy.vx) * Math.min(1.0, accel * delta_time); + enemy.vy += (target_vy - enemy.vy) * Math.min(1.0, accel * delta_time); + + enemy.x += enemy.vx * delta_time; + enemy.y += enemy.vy * delta_time; + + // Freno y clamp perimetral absoluto: el lanzador NUNCA puede rebasar las 17.0 celdas + const dist_edge = Math.sqrt(enemy.x * enemy.x + enemy.y * enemy.y); + if(dist_edge > max_patrol_radius){ + const factor = max_patrol_radius / dist_edge; + enemy.x *= factor; + enemy.y *= factor; + // Anular componente de velocidad que lo empuja hacia afuera + const dot_out = (enemy.vx * (enemy.x / max_patrol_radius)) + (enemy.vy * (enemy.y / max_patrol_radius)); + if(dot_out > 0){ + enemy.vx -= dot_out * (enemy.x / max_patrol_radius); + enemy.vy -= dot_out * (enemy.y / max_patrol_radius); + } + } + + // Giro hacia donde avanza + const spd = Math.sqrt(enemy.vx * enemy.vx + enemy.vy * enemy.vy); + if(spd > 0.1){ + const target_angle = Math.atan2(enemy.vy, enemy.vx) + (Math.PI / 2); + let diff = target_angle - enemy.angle; + while(diff < -Math.PI) diff += Math.PI * 2; + while(diff > Math.PI) diff -= Math.PI * 2; + const max_step = turn_speed * delta_time; + enemy.angle += Math.sign(diff) * Math.min(Math.abs(diff), max_step); + } + }, + }; + + return { + get : (name) => STRATEGIES[name] || STRATEGIES.static, + register : (name, strategy) => { STRATEGIES[name] = strategy; } + }; +})(); \ No newline at end of file diff --git a/Public/ecma/Strategies/SpawnStrategies.ecma.js b/Public/ecma/Strategies/SpawnStrategies.ecma.js new file mode 100644 index 0000000..f292aa0 --- /dev/null +++ b/Public/ecma/Strategies/SpawnStrategies.ecma.js @@ -0,0 +1,153 @@ +"use strict"; + +const SpawnStrategies = (function(){ + + const STRATEGIES = { + + // 1. SOBREVUELO RASANTE CON EASE-OUT CÚBICO + overflight : { + init : (enemy, config, xgravity) => { + const base_size = config.size || 1.8; + const duration = (typeof config.duration === "number") + ? config.duration + : xgravity.settings.get("enemy_overflight_duration", null, 1.4); + + const initial_scale = (typeof config.initial_scale === "number") + ? config.initial_scale + : xgravity.settings.get("enemy_overflight_initial_scale", null, 6.5); + + const target_angle = Math.random() * Math.PI * 2; + const target_dist = 4.0 + (Math.random() * 7.0); + const target_x = Math.cos(target_angle) * target_dist; + const target_y = Math.sin(target_angle) * target_dist; + + const spawn_angle = Math.random() * Math.PI * 2; + const giant_half_size = (base_size * initial_scale) / 2; + const spawn_distance = 22.0 + giant_half_size + 2.0; + + const spawn_x = Math.cos(spawn_angle) * spawn_distance; + const spawn_y = Math.sin(spawn_angle) * spawn_distance; + + enemy.spawn_data = { + spawn_x, spawn_y, + target_x, target_y, + duration : Math.max(0.1, duration), + initial_scale + }; + + enemy.x = spawn_x; + enemy.y = spawn_y; + enemy.scale = initial_scale; + enemy.alpha = 0.4; + enemy.ready = false; + + const dx = target_x - spawn_x; + const dy = target_y - spawn_y; + enemy.angle = Math.atan2(dy, dx) + (Math.PI / 2); + }, + + update : (enemy, delta_time) => { + const data = enemy.spawn_data; + const t = Math.min(1.0, enemy.age / data.duration); + + const inv_t = 1.0 - t; + const ease_progress = 1.0 - (inv_t * inv_t * inv_t); + + enemy.x = data.spawn_x + ((data.target_x - data.spawn_x) * ease_progress); + enemy.y = data.spawn_y + ((data.target_y - data.spawn_y) * ease_progress); + + const speed_factor = (3.0 * inv_t * inv_t) / data.duration; + enemy.vx = (data.target_x - data.spawn_x) * speed_factor; + enemy.vy = (data.target_y - data.spawn_y) * speed_factor; + + enemy.scale = data.initial_scale - ((data.initial_scale - 1.0) * ease_progress); + enemy.alpha = 0.4 + (ease_progress * 0.6); + + if(t >= 1.0){ + enemy.scale = 1.0; + enemy.alpha = 1.0; + enemy.ready = true; + + const spd = Math.sqrt(enemy.vx * enemy.vx + enemy.vy * enemy.vy); + if(spd > 0.001){ + enemy.vx = (enemy.vx / spd) * 3.2; + enemy.vy = (enemy.vy / spd) * 3.2; + } + } + } + }, + + // 2. EMERGENCIA DESDE EL FONDO ESPACIAL (DEEP SPACE) + deep_space : { + init : (enemy, config) => { + const spawn_angle = Math.random() * Math.PI * 2; + const r = Math.random() * 15.0; + enemy.x = Math.cos(spawn_angle) * r; + enemy.y = Math.sin(spawn_angle) * r; + enemy.scale = 0.0; + enemy.alpha = 0.3; + enemy.ready = false; + enemy.scale_speed = config.scale_speed || 0.65; + enemy.angle = Math.random() * Math.PI * 2; + }, + + update : (enemy, delta_time) => { + enemy.scale += enemy.scale_speed * delta_time; + enemy.alpha = 0.3 + (enemy.scale * 0.7); + + if(enemy.scale >= 1.0){ + enemy.scale = 1.0; + enemy.alpha = 1.0; + enemy.ready = true; + } + } + }, + + // 3. ENTRADA PERIMETRAL EXTERIOR (100% FUERA DE PANTALLA) + perimeter : { + init : (enemy, config) => { + const base_size = config.size || 1.8; + const spawn_angle = Math.random() * Math.PI * 2; + // Calculado holgadamente para que el sprite nunca corte el borde al nacer + const spawn_distance = 22.0 + (base_size / 2) + 2.0; + + enemy.x = Math.cos(spawn_angle) * spawn_distance; + enemy.y = Math.sin(spawn_angle) * spawn_distance; + + // Apunta hacia el cuadrante interior visible con ligera dispersión + const target_angle = spawn_angle + Math.PI + ((Math.random() - 0.5) * 0.7); + const speed = config.initial_speed || 4.2; + + enemy.vx = Math.cos(target_angle) * speed; + enemy.vy = Math.sin(target_angle) * speed; + enemy.scale = 1.0; + enemy.alpha = 1.0; + enemy.ready = false; + enemy.angle = Math.atan2(enemy.vy, enemy.vx) + (Math.PI / 2); + }, + + update : (enemy, delta_time) => { + enemy.x += enemy.vx * delta_time; + enemy.y += enemy.vy * delta_time; + + // Al entrar en el radio de combate (16 celdas), se activa para combate + const dist = Math.sqrt(enemy.x * enemy.x + enemy.y * enemy.y); + if(dist <= 16.0){ + enemy.ready = true; + } + } + } + }; + + return { + get : (mode) => { + // Si le pasamos un array de modos, elige uno aleatoriamente + if(Array.isArray(mode)){ + const pick = mode[Math.floor(Math.random() * mode.length)]; + return STRATEGIES[pick] || STRATEGIES.overflight; + } + return STRATEGIES[mode] || STRATEGIES.overflight; + }, + register : (name, strategy) => { STRATEGIES[name] = strategy; } + }; +})(); \ No newline at end of file diff --git a/Public/ecma/Utils/Common.ecma.js b/Public/ecma/Utils/Common.ecma.js new file mode 100644 index 0000000..eb84861 --- /dev/null +++ b/Public/ecma/Utils/Common.ecma.js @@ -0,0 +1,200 @@ +"use strict"; + +const Common = (function(){ + + const Common = function(){}; + + Common.get_keys = (...items) => items.reduce((keys, item) => { + + if(item){ + if(typeof item == "string" && /^[a-z_][a-z0-9_]*$/i.test(item) && !keys.includes(item)) + keys.push(item); + else if(item instanceof Array) + for(const key of Common.get_keys(...item)) + keys.includes(key) || keys.push(key); + }; + + return keys; + }, []); + + Common.get_dictionaries = (...items) => items.reduce((dictionaries, item) => { + + if(item){ + if(item.constructor == Object) + dictionaries.push(item); + else if(item instanceof Array) + dictionaries.push(...Common.get_dictionaries(...item)); + }; + + return dictionaries; + }, []); + + Common.get_value = (keys, inputs, _default = null) => { + if((keys = Common.get_keys(keys)).length) + for(const subinputs of Common.get_dictionaries(inputs)) + for(const key of keys) + if(subinputs[key] !== undefined) + return subinputs[key]; + return _default; + }; + + Common.get_dictionary = (items, overwrite = false) => { + + if(items){ + if(items.constructor == Object) + return items; + else if(items instanceof Array) + return items.reduce((dictionary, item) => { + Object.entries(Common.get_dictionary(item)).forEach(([key, value]) => { + if(overwrite || dictionary[key] === undefined) + dictionary[key] = value; + }); + return dictionary; + }, {}); + }; + + return {}; + }; + + Common.string_variables = (string, variables, _default = null) => { + + variables = Common.get_dictionary(variables); + + return ("" + string).replace(/\{([^\{\}]+)\}/g, (all, key) => ( + variables[key] !== undefined ? variables[key] : + _default !== null ? _default : + all)); + }; + + Common.execute_array = (array, each_callback, end_callback, i = 0) => { + if(i == array.length) + end_callback(); + else + each_callback(array[i], () => { + Common.execute_array(array, each_callback, end_callback, i + 1); + }); + }; + + Common.SCSS = (data, selector = "") => { + + /** @type {Arrya.} */ + const childs = []; + /** @type {string} */ + let css = ``; + + Object.entries(data).forEach(([key, value]) => { + if(value && value.constructor == Object) + key.split(",").forEach(key => { + + /** @type {string} */ + const subselector = `${(selector ? selector + ( + key[0] == "&" ? "" : " " + ) : "") + ( + key[0] == "&" ? key.substring(1) : key + )}`, + /** @type {Array} */ + subcss = Common.SCSS(value, subselector); + + childs.push(`\n\n${subselector}{${subcss[0]}\n}`, ...subcss.slice(1)); + + }); + else + css += `\n ${key.replace(/[^a-z0-9]+/gi, "-")} : ${value === null ? "none" : value};`; + }); + + if(!selector){ + + /** @type {HTMLStyleElement} */ + let style = document.querySelector("head>style"); + + if(!style){ + style = document.querySelector("head").appendChild(document.createElement("style")); + [ + ["data-type", "text/css;charset=utf-8"], + ["data-language", "CSS3"], + ["charset", "utf-8"] + ].forEach(([key, value]) => style.setAttribute(key, value === null ? "none" : value)); + }; + + style.appendChild(document.createTextNode(css + childs.join(""))); + + }; + + return [css, ...childs]; + }; + + /** + * @param {!string} target + * @param {!string} query + * @returns {boolean} + */ + Common.match_query = function(target, query){ + if(!query || !query.trim()) + return true; + + const raw_query = query.trim(), + regex_match = raw_query.match(/^\/(.+)\/([gimsuy]*)$/); + + if(!target) + target = ""; + + if(regex_match) + try{ + const pattern = regex_match[1], + flags = regex_match[2] || "i", + regular_expression = new RegExp(pattern, flags); + return regular_expression.test(target); + }catch(exception){ + return false; + }; + + const target_lower = ("" + target).toLowerCase(), + or_clauses = raw_query.split("||").map(s => s.trim()).filter(Boolean); + + if(!or_clauses.length) + return true; + + return or_clauses.some(or_clause => { + + const and_terms = or_clause.split("&&").map(s => s.trim()).filter(Boolean); + + if(!and_terms.length) + return true; + + return and_terms.every(term => { + + const is_negated = term.startsWith("!"), + clean_term = (is_negated ? term.slice(1) : term).trim().toLowerCase(); + + if(!clean_term) + return true; + + const matches = target_lower.includes(clean_term); + + return is_negated ? !matches : matches; + }); + }); + }; + + Common.get_minutes_diff = (start_time, end_time) => { + if(!start_time || !end_time || start_time === "--:--" || end_time === "--:--") + return null; + + const [sh, sm] = start_time.split(":").map(Number), + [eh, em] = end_time.split(":").map(Number); + + if(isNaN(sh) || isNaN(sm) || isNaN(eh) || isNaN(em)) + return null; + + let start_mins = sh * 60 + sm, + end_mins = eh * 60 + em; + + // Si cruza medianoche (ej. 23:50 a 00:15) + if(end_mins < start_mins) + end_mins += 24 * 60; + + return end_mins - start_mins; + }; + + return Common; +})(); \ No newline at end of file diff --git a/Public/ecma/Utils/I18N.ecma.js b/Public/ecma/Utils/I18N.ecma.js new file mode 100644 index 0000000..0e47631 --- /dev/null +++ b/Public/ecma/Utils/I18N.ecma.js @@ -0,0 +1,78 @@ +"use strict"; + +/** @type {Dictionary.>} */ +const i18n = { + espanol : { + xgravity : "XGravity", + + // Botones y navegación + btn_play : "Iniciar partida", + btn_options : "Opciones", + btn_ranking : "Ranking", + btn_credits : "Créditos", + btn_back : "Volver", + btn_resume : "Reanudar", + btn_exit : "Salir al menú", + btn_press_key : "Presiona una tecla...", + btn_new_game: "Nueva Partida", + + // Opciones + opt_title : "Opciones", + opt_canvas_quality : "Calidad del Canvas ({quality}%)", + opt_action_key : "Tecla de acción", + opt_background : "Fondo espacial", + opt_bg_image : "Imagen fija (Retro 2004)", + opt_bg_stars : "Campo de estrellas (Dinámico)", + opt_bg_procedural : "Sector procedural (Avanzado)", + opt_difficulty : "Dificultad", + opt_show_fps : "Mostrar FPS", + + // Niveles de dificultad + diff_training : "Entrenamiento", + diff_newbie : "Novato", + diff_lazy : "Vago", + diff_average : "Promedio", + diff_hard : "Durillo", + diff_berserk : "Engorilado", + diff_insane : "Insano", + + // Ranking + rank_title : "Ranking", + rank_empty : "Aún no hay puntuaciones registradas.", + rank_col_rank : "#", + rank_col_name : "Piloto", + rank_col_score : "Puntos", + + // Créditos + cred_title : "Créditos", + cred_original_concept : "Concepto original y programación (2004):", + cred_author : "KyMAN", + cred_original_tech : "Desarrollado originalmente en ActionScript 2.0 para Macromedia Flash 8 (obsoleto).", + cred_old_urls : "Alojado originalmente en Tripod Lycos (https://tripod.lycos.es/miglug/xgravity) y posteriormente en ATSpace.", + cred_remake : "Revival web actual (HTML5 / ES6):", + cred_remake_authors : "KyMAN & Gemini (Google AI)", + cred_assets : "Recursos y Arte:", + cred_bg_photo : "Fondo espacial: Fotografía por Vincentiu Solomon en Unsplash (Licencia libre Unsplash).", + cred_tatermand_art : "Set espacial y planetas: Space Game Art Pack (Extended) por Tatermand en OpenGameArt.org (Licencia CC-BY-SA 3.0).", + cred_links : "Enlaces:", + cred_web : "Web oficial: {url}", + cred_git : "Repositorio: {url}", + + // HUD & Partida + hud_score : "Puntos: {score}", + hud_shield : "Escudo:", + hud_pause_title : "Pausa", + + // Game Over + game_over_title : "MISIÓN FALLIDA", + btn_retry : "REINTENTAR", + btn_main_menu : "MENÚ PRINCIPAL", + stat_final_score : "PUNTUACIÓN", + stat_difficulty : "DIFICULTAD", + stat_enemies_spawned : "CONTACTOS DETECTADOS", + stat_enemies_destroyed : "TOTAL ELIMINADOS", + stat_shield_kills : "BAJAS POR ESCUDO", + stat_clear_rate : "EFECTIVIDAD TÁCTICA" + + } +}; \ No newline at end of file diff --git a/Public/ecma/Utils/Settings.ecma.js b/Public/ecma/Utils/Settings.ecma.js new file mode 100644 index 0000000..0bdb62e --- /dev/null +++ b/Public/ecma/Utils/Settings.ecma.js @@ -0,0 +1,390 @@ +"use strict"; + +/** @type {Dictionary.} */ +const settings = { + cells : 40, + position : "body", + class_name : "xgravity", + frames_per_second : 12, + language : "espanol", + show_fps : true, + + // Configuraciones de juego y pantalla + canvas_quality : 100, + action_key : "Space", + background_mode : "procedural", // "image" | "stars" | "procedural" + + // Parámetros para fondo con imagen ("image") + // bg_image_url : "https://images.unsplash.com/photo-1506703719100-a0f3a48c0f86?q=80&w=1600&auto=format&fit=crop", + bg_image_url : "images/backgrounds/photo-1506703719100-a0f3a48c0f86.jpeg", + bg_image_pan_speed_x : 5, // px por segundo (0 para totalmente estático) + bg_image_pan_speed_y : 2, + + // Parámetros para el generador de estrellas ("stars") + bg_stars_count : 140, + bg_stars_size_min : 0.5, + bg_stars_size_max : 2.2, + bg_stars_speed_min : 0.1, + bg_stars_speed_max : 0.6, + bg_stars_dir_x : 0, // -1 (izq) a 1 (der) + bg_stars_dir_y : 1, // -1 (arriba) a 1 (abajo) + bg_stars_color : "rgba(186, 230, 253, {alpha})", + + // Paŕametros para fondo dinámico. + bg_procedural_far_stars : { + probablity : .96, + size : [.2, .7], + images : [ + "images/game/Far-Star-1.webp", + "images/game/Far-Star-2.webp", + "images/game/Far-Star-3.webp" + ] + }, + bg_procedural_nebulas : { + probablity : .87, + size : [3, 60], + images : [ + "images/game/Blue-Dust.webp", + "images/game/Cold-Nebula.webp", + "images/game/Dust.webp", + "images/game/Grey-Asteroid-Dust.webp", + "images/game/Hot-Nebula.webp", + "images/game/Violet-Dust.webp", + "images/game/Yellow-Dust.webp" + ] + }, + bg_procedural_near_stars : { + probablity : .21, + size : [.5, 1.7], + images : [ + "images/game/Layer.webp", + "images/game/Layer2.webp" + ], + }, + bg_procedural_suns : { + probablity : .32, + timer : [2, 10], + size : [10, 50], + images : [ + "images/game/Sun.fixed.webp", + "images/game/Red-Giant.webp" + ], + }, + bg_procedural_planets : { + probablity : .17, + size : [7, 30], + images : [ + "images/game/Ice-Giant.webp", + "images/game/Gas-Giant.webp", + "images/game/Exoplanet3.webp", + "images/game/Exoplanet2.webp", + "images/game/Exoplanet.webp" + ] + }, + + // Configuración del Dron / Nave + drone_image : "images/game/Louse.webp", + drone_size : 2.5, // Tamaño en celdas (ancho/alto en base al grid de 40 celdas) + drone_gravity : 2.2, // Multiplicador de fuerza gravitatoria hacia el cursor + drone_friction : 0.985, // Fricción / inercia en el vacío (0 = freno total, 1 = inercia infinita) + // drone_max_speed : 25, // Velocidad máxima de seguridad en celdas/segundo + // drone_force : 1.2, // Multiplicador de fuerza f + // drone_max_speed : 28, // Límite máximo en celdas/segundo + // Ajustes de física del dron + drone_force : 3, // Multiplicador fx y fy + drone_max_accel : 35, // Tope del acumulador ax y ay + // Configuración del Escudo Activo + drone_shield_drain : 25, // Consumo por segundo al mantener pulsado (0 a 100) + drone_shield_recovery : 12, // Recuperación por segundo en reposo + drone_shield_radius : 1.7, // Radio del escudo en celdas (alrededor del dron) + // Alerta de Escudo Bajo en el Dron + // drone_shield_low_threshold : 25, // Porcentaje a partir del cual salta la alarma (<= 25%) + // drone_shield_alarm_interval : 0.75, // Intervalo de repetición del sonido de alarma (segundos) + // drone_shield_beacon_speed : 6.0, // Velocidad de giro del girofaro (radianes/segundo) + // Alarma de Escudo Bajo estilo Alien (Klaxon) + drone_shield_low_threshold : 25, // Porcentaje crítico (<= 25%) + // drone_shield_alarm_interval : 0.22, // Cadencia rápida de repetición (segundos entre pulsos) + // drone_shield_beacon_speed : 9.5, // Girofaro acelerado al ritmo de la alarma + drone_shield_alarm_interval : 0.26, // Intervalo ajustado a la longitud del pulso + drone_shield_beacon_speed : 8.5, // Giro acompasado + // Curvas dinámicas de Alerta de Perímetro / Salida de Zona + drone_perimeter_safe_radius : 15.0, // Radio donde empieza la advertencia (celdas) + drone_perimeter_critical_radius : 18.5, // Radio límite antes de perder la nave (celdas) + drone_perimeter_alarm_interval_max : 0.7, // Intervalo al rozar las 15 celdas + drone_perimeter_alarm_interval_min : 0.2, // Intervalo al borde del límite fatal + // Gestión del Escudo del Dron + drone_shield_recharge_time : 20.0, // Segundos que tarda en recargar de 0 a 100% + drone_shield_kill_reward : 25.0, // Puntos de escudo recuperados al destruir un enemigo con el escudo + drone_shield_drain : 25.0, // Consumo de escudo por segundo mientras está activo + + // Configuración de Enemigos + enemy_spawn_rate : 1.6, // Frecuencia de aparición (en segundos) + enemy_speed : 5.0, // Velocidad lineal en celdas por segundo + enemy_size : 2.2, // Tamaño en celdas + enemy_spawn_radius : 22.0, // Distancia radial de generación + enemy_score_value : 100, // Puntos al destruir un enemigo con el escudo + + // Curva de dificultad y desbloqueo progresivo + enemy_spawn_interval : 0.1, // Frecuencia con la que se tira el dado (en segundos) + enemy_time_to_max_difficulty : 120, // Segundos hasta alcanzar el 100% del rango (0.11) + enemy_min_threshold : 0.04, // Umbral inicial (solo estáticos al arrancar) + enemy_max_threshold : 0.11, // Umbral máximo accesible + // Configuración de Entrada Táctica y Sobrevuelo + enemy_overflight_initial_scale : 120.0, // Escala inicial al sobrevolar desde atrás (ajústalo a tu gusto: 4.0, 6.0, 8.0...) + enemy_overflight_scale_speed : 1.8, // Velocidad con la que desciende hacia el plano de juego + // Duración de la retícula de triangulación táctica al aparecer cualquier enemigo (en segundos) + enemy_target_lock_duration : 1.2, + // Configuración de HUD de combate sobre enemigos + enemy_show_health_bars : true, // Activar/desactivar barras de vida + enemy_health_bar_width : 1.8, // Ancho de la barra en celdas + enemy_damage_popup_duration : 0.75, // Segundos que dura el texto flotante de daño + // Ajustes de sobrevuelo + enemy_overflight_initial_scale : 26.5, // Escala inicial + enemy_overflight_duration : 1.4, // Segundos exactos que tarda en alcanzar el plano 2D + // Configuración general de sobrevuelo + // Plantilla de enemigos + enemies_roster : [ + { + id: "static_mine", + // range: [0.00, 0.04], + range: [0.07, 0.09], + image: "images/game/Cricket-Snipper.webp", + size: 2.0, + mass: 5.0, // Pesada, se mueve poco al impactar + health: 40, // Aguanta un toque suave o revienta si entras a toda velocidad + spawn_mode: "deep_space", // Crece de 0 a 1 + behavior: "static", + points: 50 + }, + { + id: "bouncer", + range: [0.04, 0.07], + image: "images/game/Mantis-Hull.webp", + size: 2.2, + mass: 2.5, // Masa media, rebota bien + health: 60, + spawn_mode: "perimeter", // Entra desde el exterior + behavior: "bounce_screen", // Rebota en el área [-23, 23] + accel: 5.5, // Potencia con la que acelera al salir del frenado + turn_speed: 4.8, // Rapidez con la que vira su timón al borde + points: 100 + }, + { + id: "chaser", + // range: [0.07, 0.09], + range: [0.00, 0.04], + image: "images/game/Scarab-Hull.webp", + size: 1.8, + mass: 1.8, // Ligera y ágil + health: 30, // Frágil si la embistes con escudo + spawn_mode: ["overflight", "deep_space", "perimeter"], + behavior: "track_drone", + points: 150 + }, + { + id: "rusher", + range: [0.09, 0.11], + image: "images/game/Strange-Body.webp", + size: 1.5, + mass: 4.0, // Densidad alta: impacto como un misil + health: 20, + spawn_mode: "perimeter", + behavior: "cross_screen", // Cruza a toda mecha + points: 200 + }, + { + id: "asteroid", + range: [0.12, 0.16], + // Array de imágenes aleatorias: + image: [ + "images/game/Grey-Asteroid-1.webp", + "images/game/Grey-Asteroid-2.webp", + "images/game/Grey-Asteroid-3.webp" + ], + size: 2.4, + mass: 7.0, + health: 50, + spawn_mode: "perimeter", + behavior: "cross_screen", + mothership_damage: 25, // Daño directo base al casco + points: 80 + }, + { + id: "asteroid_thrower", + range: [0.16, 0.19], + image: [ + "images/game/Locust-Hull.webp" + ], + size: 2.0, + mass: 2.2, + health: 45, + spawn_mode: ["perimeter", "overflight"], + behavior: "thrower_ai", + turn_speed: 4.5, + max_speed: 4.8, + flee_distance: 7.5, + points: 250 + } + ], + + // Configuración de Efectos de Explosión + explosion_particles_range : [45, 75], // Número aleatorio de partículas [mínimo, máximo] + explosion_particles_spread : 31.0, // Radio/fuerza de alcance de dispersión (celdas/s) + explosion_shockwave_radius : 14.5, // Radio máximo de la onda expansiva en celdas + explosion_volume : 3, // Volumen general de las explosiones (0.0 a 1.0) + + // Balanceo dinámico de la cúpula e inercia de cabina + cockpit_drift_amount : 0.6, // Máximo desplazamiento en celdas por inercia del dron + cockpit_sway_speed : 0.8, // Velocidad del balanceo pasivo de respiración + + // Screen Shake (Temblor de pantalla por explosiones) + screen_shake_shield_intensity : 0.25, // Intensidad al destruir con escudo (en celdas) + screen_shake_shield_duration : 0.15, // Duración sacudida de escudo (segundos) + screen_shake_hit_intensity : 0.85, // Intensidad al recibir impacto directo (en celdas) + screen_shake_hit_duration : 0.42, // Duración sacudida crítica (segundos) + // Factor de desplazamiento parallax del fondo estelar + background_parallax_amount : 0.25, // Intensidad del deslizamiento del fondo + + // ========================================== + // SISTEMA DE DIFICULTAD Y MULTIPLICADORES + // ========================================== + current_difficulty : "average", + + // Valores base del motor + base_enemy_spawn_interval : 0.65, // Segundos base entre chequeos de spawn + base_enemy_min_threshold : 0.04, // Probabilidad inicial de spawn + base_enemy_max_threshold : 0.22, // Límite asintótico de spawn + base_drone_shield_max : 100.0, // Capacidad base de escudo/vida del dron + base_drone_shield_drain_rate : 12.0, // Drenaje base por segundo activo + base_drone_shield_kill_reward : 10.0, // Recarga base al destruir con escudo + base_collision_damage_factor : 12.0, // Factor cinético base de daño + + // Curva de progresión temporal asintótica + difficulty_ramp_half_life : 90.0, // Segundos para el 50% de la curva + difficulty_ramp_power : 1.25, // Exponente de aceleración (1.0 = lineal, >1.0 = agresivo) + + // Catálogo de presets de dificultad (claves en inglés) + difficulty_presets : { + "training" : { + id : "training", + label_i18n : "diff_training", + spawn_interval_mult : 1.4, + min_threshold_mult : 0.5, + max_threshold_mult : 0.6, + ramp_speed_mult : 0.4, + shield_capacity_mult : 1.5, + shield_drain_mult : 0.6, + shield_reward_mult : 2.0, + drone_damage_taken_mult : 0.0, // Daño cero al dron + enemy_damage_taken_mult : 2.0, + mothership_damage_mult : 0.0, // ¡Inmortal! + mothership_repair_mult : 3.0, + drone_lives: 20, + mothership_damage_mult: 0.0, + }, + "newbie" : { + id : "newbie", + label_i18n : "diff_newbie", + spawn_interval_mult : 1.25, + min_threshold_mult : 0.7, + max_threshold_mult : 0.75, + ramp_speed_mult : 0.6, + shield_capacity_mult : 1.3, + shield_drain_mult : 0.8, + shield_reward_mult : 1.5, + drone_damage_taken_mult : 0.4, + enemy_damage_taken_mult : 1.4, + mothership_damage_mult : 0.5, + mothership_repair_mult : 1.8, + drone_lives: 10, + mothership_damage_mult: 0.6, + }, + "lazy" : { + id : "lazy", + label_i18n : "diff_lazy", + spawn_interval_mult : 1.1, + min_threshold_mult : 0.85, + max_threshold_mult : 0.9, + ramp_speed_mult : 0.8, + shield_capacity_mult : 1.15, + shield_drain_mult : 0.9, + shield_reward_mult : 1.2, + drone_damage_taken_mult : 0.7, + enemy_damage_taken_mult : 1.2, + mothership_damage_mult : 0.75, + mothership_repair_mult : 1.3, + drone_lives: 8, + mothership_damage_mult: 0.8, + }, + "average" : { + id : "average", + label_i18n : "diff_average", + spawn_interval_mult : 1.0, + min_threshold_mult : 1.0, + max_threshold_mult : 1.0, + ramp_speed_mult : 1.0, + shield_capacity_mult : 1.0, + shield_drain_mult : 1.0, + shield_reward_mult : 1.0, + drone_damage_taken_mult : 1.0, + enemy_damage_taken_mult : 1.0, + mothership_damage_mult : 1.0, + mothership_repair_mult : 1.0, + drone_lives: 5, + mothership_damage_mult: 1, + }, + "hard" : { + id : "hard", + label_i18n : "diff_hard", + spawn_interval_mult : 0.85, + min_threshold_mult : 1.2, + max_threshold_mult : 1.25, + ramp_speed_mult : 1.35, + shield_capacity_mult : 0.9, + shield_drain_mult : 1.2, + shield_reward_mult : 0.85, + drone_damage_taken_mult : 1.4, + enemy_damage_taken_mult : 0.85, + mothership_damage_mult : 1.35, + mothership_repair_mult : 0.6, + drone_lives: 3, + mothership_damage_mult: 1.5, + }, + "berserk" : { + id : "berserk", + label_i18n : "diff_berserk", + spawn_interval_mult : 0.7, + min_threshold_mult : 1.5, + max_threshold_mult : 1.5, + ramp_speed_mult : 1.8, + shield_capacity_mult : 0.8, + shield_drain_mult : 1.4, + shield_reward_mult : 0.7, + drone_damage_taken_mult : 1.9, + enemy_damage_taken_mult : 0.75, + mothership_damage_mult : 1.8, + mothership_repair_mult : 0.3, + drone_lives: 2, + mothership_damage_mult: 2, + }, + "insane" : { + id : "insane", + label_i18n : "diff_insane", + spawn_interval_mult : 0.5, + min_threshold_mult : 1.8, + max_threshold_mult : 1.8, + ramp_speed_mult : 2.5, + shield_capacity_mult : 0.7, + shield_drain_mult : 1.7, + shield_reward_mult : 0.5, + drone_damage_taken_mult : 2.6, + enemy_damage_taken_mult : 0.6, + mothership_damage_mult : 2.5, // 2 impactos = Game Over + mothership_repair_mult : 0.0, // Sin autoreparación + drone_lives: 1, + mothership_damage_mult: 2.5, + } + }, + + ...(typeof custom_settings == "undefined" ? {} : custom_settings) +}; \ No newline at end of file diff --git a/Public/ecma/Utils/Styles.ecma.js b/Public/ecma/Utils/Styles.ecma.js new file mode 100644 index 0000000..e2771ba --- /dev/null +++ b/Public/ecma/Utils/Styles.ecma.js @@ -0,0 +1,392 @@ +"use strict"; + +/** @type {Dictionary.} */ +const SCSS = { + ...{ + "body,html" : { + height : "100%", + margin : "0em", + "background-color" : "#05070d", + color : "#e2e8f0", + "font-family" : "monospace, 'Courier New', sans-serif", + overflow : "hidden" + } + }, + ...{ + ".xgravity" : { + position : "relative", + top : "0em", + left : "0em", + width : "100%", + height : "100%", + display : "flex", + "justify-content" : "center", + "align-items" : "center" + }, + ".xg-view" : { + position : "absolute", + top : "0em", + left : "0em", + width : "100%", + height : "100%", + display : "flex", + "flex-direction" : "column", + "justify-content" : "center", + "align-items" : "center", + "box-sizing" : "border-box", + padding : "2em", + "text-align" : "center", + "background-color" : "rgba(5, 7, 13, 0.9)" + }, + ".xg-game-container" : { + position : "absolute", + top : "0em", + left : "0em", + width : "100%", + height : "100%", + overflow : "hidden", + "background-color" : "#020408" + }, + ".xg-canvas" : { + position : "absolute", + top : "0em", + left : "0em", + width : "100%", + height : "100%", + display : "block" + }, + ".xg-hud" : { + position : "absolute", + top : "0em", + left : "0em", + width : "100%", + height : "100%", + "pointer-events" : "none", + "box-sizing" : "border-box", + padding : "1em", + display : "flex", + "flex-direction" : "column", + "justify-content" : "space-between" + }, + ".xg-hud-top" : { + display : "flex", + "justify-content" : "space-between", + "align-items" : "flex-end", + "font-size" : "1.1em", + "font-weight" : "bold", + "text-shadow" : "0 0 0.4em #38bdf8" + }, + ".xg-hud-shield-container" : { + display : "flex", + "align-items" : "center", + gap : "0.5em" + }, + ".xg-shield-bar-bg" : { + width : "8em", + height : "0.8em", + background : "rgba(15, 23, 42, 0.8)", + border : "0.1em solid #0284c7", + "border-radius" : "0.2em", + overflow : "hidden" + }, + ".xg-shield-bar-fill" : { + width : "100%", + height : "100%", + background : "linear-gradient(90deg, #0284c7, #38bdf8)", + transition : "width 0.1s linear" + }, + ".xg-hud-bottom" : { + display : "flex", + "justify-content" : "flex-end", + "align-items" : "flex-end", + "pointer-events" : "none" + }, + ".xg-hud-pause-btn" : { + display : "flex", + "pointer-events" : "auto", + "font-size" : "0.85em", + padding : "0.5em 1em", + cursor : "pointer", + "z-index" : "20" + }, + ".xg-pause-modal" : { + position : "absolute", + top : "0em", + left : "0em", + width : "100%", + height : "100%", + background : "rgba(5, 7, 13, 0.75)", + display : "flex", + "flex-direction" : "column", + "justify-content" : "center", + "align-items" : "center", + gap : "1em", + "z-index" : "10" + }, + ".xg-title" : { + "font-size" : "2.5em", + "font-weight" : "bold", + "margin-bottom" : "1.2em", + color : "#38bdf8", + "text-shadow" : "0 0 0.5em #0284c7", + "letter-spacing" : "0.1em" + }, + ".xg-subtitle" : { + "font-size" : "1.5em", + "font-weight" : "bold", + "margin-bottom" : "0.8em", + color : "#7dd3fc" + }, + ".xg-menu-list" : { + display : "flex", + "flex-direction" : "column", + gap : "0.8em", + width : "14em" + }, + ".xg-btn" : { + background : "#0f172a", + color : "#38bdf8", + border : "0.1em solid #0284c7", + padding : "0.6em 1em", + "font-size" : "1em", + "font-family" : "inherit", + cursor : "pointer", + "border-radius" : "0.3em", + transition : "all 0.2s ease-in-out" + }, + ".xg-btn:hover" : { + background : "#0284c7", + color : "#ffffff", + "box-shadow" : "0 0 0.8em #38bdf8" + }, + ".xg-panel" : { + width : "26em", + "max-width" : "90%", + "max-height" : "28em", + "overflow-y" : "auto", + background : "rgba(15, 23, 42, 0.7)", + border : "0.1em solid #1e293b", + "border-radius" : "0.5em", + padding : "1.5em", + "box-sizing" : "border-box", + display : "flex", + "flex-direction" : "column", + gap : "1em" + }, + ".xg-panel::-webkit-scrollbar" : { + width : "0.4em" + }, + ".xg-panel::-webkit-scrollbar-track" : { + background : "rgba(15, 23, 42, 0.5)" + }, + ".xg-panel::-webkit-scrollbar-thumb" : { + background : "#0284c7", + "border-radius" : "0.2em" + }, + ".xg-panel::-webkit-scrollbar-thumb:hover" : { + background : "#38bdf8" + }, + ".xg-field-row" : { + display : "flex", + "justify-content" : "space-between", + "align-items" : "center", + "font-size" : "0.9em" + }, + ".xg-range" : { + cursor : "pointer", + width : "10em" + }, + ".xg-select" : { + background : "#0f172a", + color : "#38bdf8", + border : "0.1em solid #0284c7", + padding : "0.4em 0.6em", + "font-family" : "inherit", + "border-radius" : "0.3em", + cursor : "pointer" + }, + ".xg-credits-text" : { + "font-size" : "0.8em", + "line-height" : "1.5", + color : "#94a3b8", + "text-align" : "left" + }, + ".xg-link" : { + color : "#38bdf8", + "text-decoration" : "none", + "word-break" : "break-all" + }, + ".xg-link:hover" : { + "text-decoration" : "underline" + }, + ".xg-btn-selected" : { + "border-color": "#38bdf8 !important", + "box-shadow": "0 0 12px rgba(56, 189, 248, 0.4)", + color: "#e0f2fe !important" + }, + ".xg-btn-back" : { + "margin-top": "10px", + opacity: 0.85 + }, + ".xg-checkbox" : { + cursor : "pointer", + width : "1.2em", + height : "1.2em", + "accent-color" : "#0284c7" + }, + ".xg-fps-overlay" : { + position : "absolute", + top : "0.5em", + right : "0.5em", // <-- Cambiado a la esquina superior derecha + background : "rgba(15, 23, 42, 0.75)", + border : "0.08em solid #0284c7", + "border-radius" : "0.25em", + padding : "0.2em 0.5em", + "font-size" : "0.85em", + "font-weight" : "bold", + color : "#38bdf8", + "text-shadow" : "0 0 0.3em #0284c7", + "pointer-events" : "none", + "z-index" : "9999", + "user-select" : "none" + }, + }, + // Capa completa superpuesta que cubre el juego y el HUD + ".xg-game-over-view": { + "position": "absolute", + "inset": "0", + "display": "flex", + "flex-direction": "column", + "align-items": "center", + "justify-content": "center", + "background": "rgba(2, 6, 23, 0.78)", + "backdrop-filter": "blur(8px)", + "z-index": "100", + "padding": "20px", + "box-sizing": "border-box", + + "& .xg-title": { + "font-family": "'Courier New', monospace", + "font-size": "2.8rem", + "color": "#38bdf8", + "text-shadow": "0 0 20px rgba(56, 189, 248, 0.6)", + "letter-spacing": "4px", + "margin": "0 0 10px 0" + }, + + // Insignia de motivo de derrota + "& .xg-game-over-reason": { + "margin-bottom": "22px", + + "& .xg-badge-danger": { + "display": "inline-block", + "padding": "6px 14px", + "background": "rgba(239, 68, 68, 0.15)", + "border": "1px solid rgba(239, 68, 68, 0.5)", + "color": "#f87171", + "font-family": "monospace", + "font-size": "0.9rem", + "letter-spacing": "2px", + "border-radius": "4px", + "text-transform": "uppercase" + } + }, + + // Tarjeta central de estadísticas + "& .xg-stats-card": { + "width": "100%", + "max-width": "460px", + "background": "rgba(15, 23, 42, 0.85)", + "border": "1px solid rgba(56, 189, 248, 0.35)", + "box-shadow": "0 0 25px rgba(2, 6, 23, 0.9), inset 0 0 15px rgba(56, 189, 248, 0.08)", + "border-radius": "6px", + "padding": "16px 20px", + "margin-bottom": "24px", + "display": "flex", + "flex-direction": "column", + "gap": "10px", + "box-sizing": "border-box" + }, + + // Filas de estadísticas: aquí se resuelve la separación del texto + "& .xg-stat-row": { + "display": "flex", + "justify-content": "space-between", + "align-items": "center", + "width": "100%", + "padding": "6px 0", + "border-bottom": "1px dashed rgba(56, 189, 248, 0.15)", + "font-family": "monospace", + "font-size": "1rem", + + "&:last-child": { + "border-bottom": "none" + }, + + "& .xg-stat-label": { + "color": "#94a3b8", + "letter-spacing": "1px", + "text-align": "left" + }, + + "& .xg-stat-value": { + "color": "#f1f5f9", + "font-weight": "bold", + "text-align": "right", + "letter-spacing": "1px" + }, + + // Fila destacada de la puntuación + "&.xg-stat-score": { + "border-bottom": "1px solid rgba(56, 189, 248, 0.4)", + "padding-bottom": "10px", + "margin-bottom": "4px", + + "& .xg-val-score": { + "color": "#38bdf8", + "font-size": "1.25rem", + "text-shadow": "0 0 10px rgba(56, 189, 248, 0.5)" + } + } + }, + + // Botonera inferior + "& .xg-menu-list": { + "display": "flex", + "flex-direction": "column", + "gap": "12px", + "width": "100%", + "max-width": "280px", + + "& .xg-btn": { + "width": "100%", + "padding": "12px 0", + "font-family": "monospace", + "font-size": "1.05rem", + "letter-spacing": "2px" + } + } + }, + ".xg-main-menu": { + // ... tus estilos actuales del menú ... + + "& .xg-menu-header": { + "display": "flex", + "flex-direction": "column", + "align-items": "center", + "justify-content": "center", + "margin-bottom": "24px" + }, + + "& .xg-main-logo": { + "width": "120px", + "height": "120px", + "margin-bottom": "14px", + "filter": "drop-shadow(0 0 16px rgba(56, 189, 248, 0.45))", + "transition": "transform 0.3s ease, filter 0.3s ease", + "user-select": "none", + "pointer-events": "none" + } + }, + ...(typeof custom_styles == "undefined" ? {} : custom_styles) +}; \ No newline at end of file diff --git a/Public/ecma/Views/CreditsView.ecma.js b/Public/ecma/Views/CreditsView.ecma.js new file mode 100644 index 0000000..e62d623 --- /dev/null +++ b/Public/ecma/Views/CreditsView.ecma.js @@ -0,0 +1,80 @@ +"use strict"; + +const CreditsView = (function(){ + + const CreditsView = function(xgravity){ + + const self = this; + let view_element = null; + + this.close = () => { + if(view_element && view_element.parentNode){ + view_element.parentNode.removeChild(view_element); + view_element = null; + } + }; + + this.go = () => { + self.close(); + + const repo_url = "https://git.k3y.pw/KyMAN/XGravity"; + const web_url = "https://xgravity.k3y.pw/"; + const photographer_url = "https://unsplash.com/@vincentiu"; + const opengameart_url = "https://opengameart.org/content/space-game-art-pack-extended"; + + const nodes = xgravity.html(xgravity.item_self, [ + "div", { class : "xg-view xg-credits" }, [ + ["h2", { class : "xg-subtitle", "data-i18n" : "cred_title" }], + ["div", { class : "xg-panel xg-credits-text" }, [ + ["p", {}, [ + ["strong", { "data-i18n" : "cred_original_concept" }], + ["br"], + ["span", { "data-i18n" : "cred_author" }] + ]], + ["p", { "data-i18n" : "cred_original_tech" }], + ["p", { "data-i18n" : "cred_old_urls" }], + ["p", {}, [ + ["strong", { "data-i18n" : "cred_remake" }], + ["br"], + ["span", { "data-i18n" : "cred_remake_authors" }] + ]], + ["p", {}, [ + ["strong", { "data-i18n" : "cred_assets" }], + ["br"], + ["span", { "data-i18n" : "cred_bg_photo" }], + ["br"], + ["a", { class : "xg-link", href : photographer_url, target : "_blank" }, photographer_url], + ["br"], + ["br"], + ["span", { "data-i18n" : "cred_tatermand_art" }], + ["br"], + ["a", { class : "xg-link", href : opengameart_url, target : "_blank" }, opengameart_url] + ]], + ["p", {}, [ + ["strong", { "data-i18n" : "cred_links" }], + ["br"], + ["a", { class : "xg-link", href : web_url, target : "_blank" }, web_url], + ["br"], + ["a", { class : "xg-link", href : repo_url, target : "_blank" }, repo_url] + ]], + ["button", { + class : "xg-btn", + "data-i18n" : "btn_back", + onclick : () => { + self.close(); + if(xgravity.views.main_menu) + xgravity.views.main_menu.go(); + } + }] + ]] + ] + ]); + + view_element = nodes[0]; + xgravity.i18n.update(); + }; + + }; + + return CreditsView; +})(); \ No newline at end of file diff --git a/Public/ecma/Views/DifficultySelectView.ecma.js b/Public/ecma/Views/DifficultySelectView.ecma.js new file mode 100644 index 0000000..c81adb3 --- /dev/null +++ b/Public/ecma/Views/DifficultySelectView.ecma.js @@ -0,0 +1,89 @@ +"use strict"; + +const DifficultySelectView = (function(){ + + const DifficultySelectView = function(xgravity){ + + const self = this; + let view_element = null; + + this.close = () => { + if(view_element && view_element.parentNode){ + view_element.parentNode.removeChild(view_element); + view_element = null; + } + }; + + this.go = () => { + self.close(); + + const is_game_active = xgravity.views.game && typeof xgravity.views.game.is_active === "function" + ? xgravity.views.game.is_active() + : false; + + const presets = xgravity.settings.get("difficulty_presets", null, {}); + const current_diff = xgravity.settings.get("current_difficulty", null, "average"); + + // Lista ordenada de niveles + const diff_keys = ["training", "newbie", "lazy", "average", "hard", "berserk", "insane"]; + const diff_buttons = []; + + for(let i = 0; i < diff_keys.length; i++){ + const key = diff_keys[i]; + const preset = presets[key]; + if(!preset) continue; + + const is_selected = (key === current_diff); + + diff_buttons.push([ + "button", { + class : `xg-btn xg-btn-diff ${is_selected ? "xg-btn-selected" : ""}`, + "data-i18n" : preset.label_i18n || `diff_${key}`, + onclick : () => { + // 1. Guardar la dificultad seleccionada + xgravity.settings.add({ current_difficulty : key }); + + // 2. Desmontar esta vista + self.close(); + + // 3. Iniciar la partida limpia + if(xgravity.views.game){ + if(is_game_active){ + xgravity.views.game.stop(); + } + xgravity.views.game.go(); + } + } + } + ]); + } + + // Botón Volver (cierra esta vista y regresa al Menú Principal) + diff_buttons.push([ + "button", { + class : "xg-btn xg-btn-back", + "data-i18n" : "btn_back", + onclick : () => { + self.close(); + if(xgravity.views.main_menu){ + xgravity.views.main_menu.go(); + } + } + } + ]); + + const nodes = xgravity.html(xgravity.item_self, [ + "div", { class : "xg-view xg-difficulty-select" }, [ + ["h2", { class : "xg-subtitle", "data-i18n" : "opt_difficulty" }], + ["div", { class : "xg-menu-list" }, diff_buttons] + ] + ]); + + view_element = nodes[0]; + xgravity.i18n.update(); + }; + + }; + + return DifficultySelectView; +})(); \ No newline at end of file diff --git a/Public/ecma/Views/FpsOverlayView.ecma.js b/Public/ecma/Views/FpsOverlayView.ecma.js new file mode 100644 index 0000000..46a65ce --- /dev/null +++ b/Public/ecma/Views/FpsOverlayView.ecma.js @@ -0,0 +1,78 @@ +"use strict"; + +const FpsOverlayView = (function(){ + + const FpsOverlayView = function(xgravity){ + + const self = this; + let view_element = null; + let label_element = null; + + let frame_count = 0; + let accumulated_time = 0; + let last_fps = 60; + + this.close = () => { + if(view_element && view_element.parentNode){ + view_element.parentNode.removeChild(view_element); + view_element = null; + label_element = null; + } + }; + + this.tick = (delta_time) => { + const enabled = xgravity.settings.get("show_fps", null, false); + const is_game_active = xgravity.views.game + && typeof xgravity.views.game.is_active === "function" + && xgravity.views.game.is_active() + && !xgravity.views.game.is_paused(); + + // Solo se muestra si está activado en ajustes Y hay partida activa no pausada + if(!enabled || !is_game_active){ + if(view_element) self.close(); + return; + } + + if(!view_element){ + self.go(); + } + + frame_count++; + accumulated_time += delta_time; + + if(accumulated_time >= 0.25){ + last_fps = Math.round(frame_count / accumulated_time); + frame_count = 0; + accumulated_time = 0; + + if(label_element){ + label_element.textContent = `${last_fps} FPS`; + + if(last_fps >= 50){ + label_element.style.color = "#38bdf8"; + } else if(last_fps >= 30){ + label_element.style.color = "#facc15"; + } else { + label_element.style.color = "#f43f5e"; + } + } + } + }; + + this.go = () => { + if(view_element && view_element.parentNode) return; + + const nodes = xgravity.html(xgravity.item_self, [ + "div", { class : "xg-fps-overlay" }, [ + ["span", { id : "xg_fps_counter" }, "60 FPS"] + ] + ]); + + view_element = nodes[0]; + label_element = view_element.querySelector("#xg_fps_counter"); + }; + + }; + + return FpsOverlayView; +})(); \ No newline at end of file diff --git a/Public/ecma/Views/GameOverView.ecma.js b/Public/ecma/Views/GameOverView.ecma.js new file mode 100644 index 0000000..919dbcd --- /dev/null +++ b/Public/ecma/Views/GameOverView.ecma.js @@ -0,0 +1,107 @@ +"use strict"; + +const GameOverView = (function(){ + + const GameOverView = function(xgravity){ + + const self = this; + let view_element = null; + + this.close = () => { + if(view_element && view_element.parentNode){ + view_element.parentNode.removeChild(view_element); + view_element = null; + } + }; + + this.go = (stats = {}) => { + self.close(); + + const score = stats.score || 0; + const score_str = score.toString().padStart(8, "0"); + const reason = stats.reason || "mothership_destroyed"; + const diff_id = stats.difficulty || "average"; + + const spawned = stats.total_spawned || 0; + const destroyed = stats.total_destroyed || 0; + const shield_kills = stats.shield_kills || 0; + const clear_rate = spawned > 0 ? ((destroyed / spawned) * 100).toFixed(1) : "0.0"; + + const reason_text = reason === "all_drones_lost" + ? "DEFEAT: ALL DRONES LOST IN ACTION" + : "CRITICAL: MOTHERSHIP HULL BREACHED"; + + const action_buttons = [ + [ + "button", { + class : "xg-btn xg-btn-resume", + "data-i18n" : "btn_retry", + onclick : () => { + self.close(); + if(xgravity.views.game){ + xgravity.views.game.go(); + } + } + } + ], + [ + "button", { + class : "xg-btn", + "data-i18n" : "btn_main_menu", + onclick : () => { + self.close(); + if(xgravity.views.game){ + xgravity.views.game.stop(); + } + if(xgravity.views.main_menu){ + xgravity.views.main_menu.go(); + } + } + } + ] + ]; + + const nodes = xgravity.html(xgravity.item_self, [ + "div", { class : "xg-view xg-game-over-view" }, [ + ["h1", { class : "xg-title", "data-i18n" : "game_over_title" }], + ["div", { class : "xg-game-over-reason" }, [ + ["span", { class : "xg-badge-danger" }, reason_text] + ]], + ["div", { class : "xg-menu-list xg-stats-card" }, [ + ["div", { class : "xg-stat-row xg-stat-score" }, [ + ["span", { class : "xg-stat-label", "data-i18n" : "stat_final_score" }], + ["span", { class : "xg-stat-value xg-val-score" }, `[ ${score_str} ]`] + ]], + ["div", { class : "xg-stat-row" }, [ + ["span", { class : "xg-stat-label", "data-i18n" : "stat_difficulty" }], + ["span", { class : "xg-stat-value" }, diff_id.toUpperCase()] + ]], + ["div", { class : "xg-stat-row" }, [ + ["span", { class : "xg-stat-label", "data-i18n" : "stat_enemies_spawned" }], + ["span", { class : "xg-stat-value" }, `${spawned}`] + ]], + ["div", { class : "xg-stat-row" }, [ + ["span", { class : "xg-stat-label", "data-i18n" : "stat_enemies_destroyed" }], + ["span", { class : "xg-stat-value" }, `${destroyed}`] + ]], + ["div", { class : "xg-stat-row" }, [ + ["span", { class : "xg-stat-label", "data-i18n" : "stat_shield_kills" }], + ["span", { class : "xg-stat-value" }, `${shield_kills}`] + ]], + ["div", { class : "xg-stat-row" }, [ + ["span", { class : "xg-stat-label", "data-i18n" : "stat_clear_rate" }], + ["span", { class : "xg-stat-value" }, `${clear_rate}%`] + ]] + ]], + ["div", { class : "xg-menu-list" }, action_buttons] + ] + ]); + + view_element = nodes[0]; + xgravity.i18n.update(); + }; + + }; + + return GameOverView; +})(); \ No newline at end of file diff --git a/Public/ecma/Views/GameView.ecma.js b/Public/ecma/Views/GameView.ecma.js new file mode 100644 index 0000000..278d053 --- /dev/null +++ b/Public/ecma/Views/GameView.ecma.js @@ -0,0 +1,683 @@ +"use strict"; + +const GameView = (function(){ + + const GameView = function(xgravity){ + + const self = this; + let view_element = null, + canvas_element = null, + ctx = null, + animation_frame_id = null, + last_timestamp = 0, + is_paused = false, + pause_modal = null, + bg_manager = null, + current_bg_mode = null; + + let cockpit_model = null; + let drone_model = null; + let hud_model = null; + let enemies_manager = null; + // let explosions_model = null; + + // Variables de balanceo e inercia de cámara + let camera_drift_x = 0; + let camera_drift_y = 0; + let camera_sway_clock = 0; + let bg_parallax_x = 0; + let bg_parallax_y = 0; + + // Variables de Screen Shake + let shake_trauma = 0.0; + let shake_duration = 0.0; + let shake_max_duration = 0.1; + let shake_intensity = 0.0; + let shake_offset_x = 0; + let shake_offset_y = 0; + let shake_angle = 0; + + // Estado del juego + let current_score = 0; + let screen_shake_intensity = 0; + let screen_shake_decay = 4.0; + + let drone_lives = 3; + let max_drone_lives = 3; + let is_respawning_drone = false; + let is_game_over = false; + + let session_stats = { + total_spawned : 0, + total_destroyed : 0, + shield_kills : 0 + }; + + // const trigger_mega_shake = () => { + // screen_shake_intensity = 28.0; // Temblores violentos en píxeles + // }; + + // Métodos de control de estado expuestos + this.is_active = () => animation_frame_id !== null; + this.is_paused = () => is_paused; + + this.pause = () => { + is_paused = true; + if(xgravity.views.fps){ + xgravity.views.fps.close(); + } + }; + + this.resume = () => { + is_paused = false; + last_timestamp = 0; // Evita saltos de delta_time al volver + }; + + const on_pointer_move = (e) => { + if(!canvas_element || !drone_model) return; + const rect = canvas_element.getBoundingClientRect(); + const client_x = e.touches ? e.touches[0].clientX : e.clientX; + const client_y = e.touches ? e.touches[0].clientY : e.clientY; + + const scale_x = canvas_element.width / rect.width; + const scale_y = canvas_element.height / rect.height; + + const canvas_pixel_x = (client_x - rect.left) * scale_x; + const canvas_pixel_y = (client_y - rect.top) * scale_y; + + drone_model.set_target_pixels(canvas_pixel_x, canvas_pixel_y, canvas_element.width, canvas_element.height); + }; + + // Controladores de activación de escudo + const on_mouse_down = (e) => { + if(e.button === 0 && drone_model){ + drone_model.set_shield_active(true); + } + }; + + const on_mouse_up = (e) => { + if(e.button === 0 && drone_model){ + drone_model.set_shield_active(false); + } + }; + + const on_key_down = (e) => { + // if((e.code === "Space" || e.key === " ") && !e.repeat && drone_model){ + // drone_model.set_shield_active(true); + // } + // Tecla ESC: abre/cierra menú o pausa la partida + if(e.code === "Escape" || e.key === "Escape"){ + e.preventDefault(); + self.toggle_pause(); + return; + } + + const action_key = xgravity.settings.get("action_key", null, "Space"); + const matches_action = (action_key === "Space" && (e.code === "Space" || e.key === " ")) || + (e.code === action_key || e.key.toUpperCase() === action_key.toUpperCase()); + + if(matches_action && !e.repeat && drone_model){ + drone_model.set_shield_active(true); + } + }; + + const on_key_up = (e) => { + // if((e.code === "Space" || e.key === " ") && drone_model){ + // drone_model.set_shield_active(false); + // } + const action_key = xgravity.settings.get("action_key", null, "Space"); + const matches_action = (action_key === "Space" && (e.code === "Space" || e.key === " ")) || + (e.code === action_key || e.key.toUpperCase() === action_key.toUpperCase()); + + if(matches_action && drone_model){ + drone_model.set_shield_active(false); + } + }; + + const on_touch_start = (e) => { + if(drone_model){ + drone_model.set_shield_active(true); + } + }; + + const on_touch_end = (e) => { + if(drone_model){ + drone_model.set_shield_active(false); + } + }; + + // Callback cuando el escudo destruye a un enemigo + const on_enemy_destroyed = (enemy, impact_damage = 0) => { + const bounds = enemy.get_bounds(); + + // 1. Efecto visual de destrucción + if(xgravity.explosions){ + xgravity.explosions.add(bounds.x, bounds.y, "shield"); + } + trigger_screen_shake("shield"); + + // 2. Puntos base configurados en el enemigo + const base_pts = (typeof enemy.get_points === "function") ? enemy.get_points() : 100; + + // 3. MULTIPLICADOR CINÉTICO: + // Si el daño fue 20 -> x1.20 | Si embistes con inercia y fue 65 -> x1.65 | Si fue 120 -> x2.20 + const kinetic_mult = impact_damage > 0 ? (1.0 + (impact_damage * 0.01)) : 1.0; + const final_pts = Math.round(base_pts * kinetic_mult); + + // 4. Sumar puntos y actualizar el HUD inmediatamente + current_score += final_pts; + if(hud_model){ + hud_model.set_score(current_score); + } + + // 5. Estadísticas de la partida + session_stats.total_destroyed++; + session_stats.shield_kills++; + + // 6. Recarga del escudo según dificultad + if(drone_model){ + const base_reward = xgravity.settings.get("base_drone_shield_kill_reward", null, 10.0); + const preset = xgravity.settings.get_difficulty_preset(); + const reward = base_reward * (preset ? preset.shield_reward_mult : 1.0); + drone_model.restore_shield(reward); + } + }; + + const on_mothership_impact = (base_damage = 25) => { + console.log("¡BOOM! Impacto en cabina recibido con daño base:", base_damage); + + const preset = xgravity.settings.get_difficulty_preset(); + const dmg_mult = preset ? preset.mothership_damage_mult : 1.0; + const final_damage = base_damage * dmg_mult; + + if(xgravity.audio){ + xgravity.audio.play("mothership_impact"); + } + + if(cockpit_model && typeof cockpit_model.take_damage === "function"){ + const is_destroyed = cockpit_model.take_damage(final_damage); + if(is_destroyed){ + on_game_over("mothership_destroyed"); + } + } + + // Disparo del temblor brutal de la nodriza + trigger_screen_shake("mothership"); + + if(xgravity.explosions && typeof xgravity.explosions.add_mothership_impact === "function"){ + xgravity.explosions.add_mothership_impact(); + } + }; + + // Callback cuando impacta al dron sin escudo + const on_drone_hit = (enemy, damage_to_drone = 35) => { + const bounds = enemy.get_bounds(); + if(xgravity.explosions){ + xgravity.explosions.add(bounds.x, bounds.y, "hit"); + } + trigger_screen_shake("hit"); + if(cockpit_model) cockpit_model.set_alert(true); + + if(drone_model && damage_to_drone > 0){ + drone_model.damage_shield(damage_to_drone); + // Si el escudo/vida cae a 0, estalla y entra el siguiente + if(drone_model.get_position().shield <= 0){ + destroy_current_drone(); + } + } + }; + + // Callback cuando el enemigo sobrevive al choque (chispas y deflexión) + const on_enemy_deflected = (x, y, nx, ny, intensity, damage, shield_active) => { + if(xgravity.explosions){ + xgravity.explosions.add_sparks(x, y, nx, ny, intensity); + } + + if(!shield_active && drone_model){ + drone_model.damage_shield(damage * 0.5); + trigger_screen_shake("shield"); + } else if(shield_active && drone_model){ + drone_model.damage_shield(damage * 0.2); + } + }; + + const on_game_over = (reason = "mothership_destroyed") => { + if(is_game_over) return; + is_game_over = true; + console.log("=== GAME OVER ===", reason); + + // Ocultamos el botón de pausa para que no estorbe en el Game Over + if(view_element){ + const pause_btn = view_element.querySelector(".xg-hud-pause-btn"); + if(pause_btn) pause_btn.style.display = "none"; + } + + trigger_mega_shake(); + + // Si EnemiesManager lleva la cuenta de spawns, sincronizamos: + if(enemies_manager && typeof enemies_manager.get_total_spawned === "function"){ + session_stats.total_spawned = enemies_manager.get_total_spawned(); + } + + const preset = (xgravity.settings && typeof xgravity.settings.get_difficulty_preset === "function") + ? xgravity.settings.get_difficulty_preset() + : null; + + const final_data = { + score : current_score, + reason : reason, + difficulty : preset ? preset.id : "average", + total_spawned : Math.max(session_stats.total_spawned, session_stats.total_destroyed), + total_destroyed : session_stats.total_destroyed, + shield_kills : session_stats.shield_kills + }; + + // Pausar el juego y abrir la pantalla tras un breve retardo dramático + setTimeout(() => { + self.pause(); + if(xgravity.views.game_over){ + xgravity.views.game_over.go(final_data); + } + }, 600); + }; + + const destroy_current_drone = () => { + if(!drone_model || drone_model.is_deploying() || is_respawning_drone || is_game_over) return; + is_respawning_drone = true; + + const pos = drone_model.get_position(); + + // 1. Explosión de destrucción en el plano de batalla espacial + if(xgravity.explosions){ + for(let k = 0; k < 3; k++){ + xgravity.explosions.add(pos.cell_x + ((Math.random() - 0.5) * 1.5), pos.cell_y + ((Math.random() - 0.5) * 1.5), "hit"); + } + xgravity.explosions.add_sparks(pos.cell_x, pos.cell_y, 0, -1, 3.0); + } + trigger_screen_shake("mothership"); + + // 2. Restar vida + drone_lives = Math.max(0, drone_lives - 1); + if(hud_model){ + hud_model.set_lives(drone_lives, max_drone_lives); + } + + // 3. Evaluar derrota o siguiente dron + if(drone_lives <= 0){ + on_game_over("all_drones_lost"); + } else { + // Pequeña pausa dramática para que se aprecie la explosión (~0.4s) antes de que la nodriza lance el siguiente + setTimeout(() => { + if(drone_model && !is_game_over){ + drone_model.restore_shield(drone_model.get_position().max_shield || 100); + drone_model.start_deployment(); // Emerge a tamaño x10 desde abajo + } + is_respawning_drone = false; + }, 400); + } + }; + + // Disparador de sacudida con perfiles de intensidad + const trigger_screen_shake = (type = "shield") => { + shake_trauma = 1.0; + + if(type === "shield"){ + // Golpe amortiguado en el escudo del dron + shake_intensity = xgravity.settings.get("screen_shake_shield_intensity", null, 0.25); + shake_max_duration = xgravity.settings.get("screen_shake_shield_duration", null, 0.15); + } else if(type === "mothership" || type === "critical"){ + // IMPACTO CRÍTICO EN CABINA: Sacudida masiva y violenta + shake_intensity = xgravity.settings.get("screen_shake_mothership_intensity", null, 4.2); // ~4.2 celdas de amplitud + shake_max_duration = xgravity.settings.get("screen_shake_mothership_duration", null, 1.15); // >1 segundo de temblor + } else { + // Golpe en el dron sin escudo + shake_intensity = xgravity.settings.get("screen_shake_hit_intensity", null, 0.85); + shake_max_duration = xgravity.settings.get("screen_shake_hit_duration", null, 0.42); + } + shake_duration = shake_max_duration; + }; + + const trigger_mega_shake = () => { + trigger_screen_shake("mothership"); + }; + + const sync_background = () => { + const mode = xgravity.settings.get("background_mode", null, "stars"); + if(current_bg_mode !== mode || !bg_manager){ + current_bg_mode = mode; + if(mode === "image") + bg_manager = new ImageBackground(xgravity); + else if(mode === "procedural") + bg_manager = new ProceduralBackground(xgravity); + else + bg_manager = new StarsBackground(xgravity); + + if(canvas_element) + bg_manager.resize(canvas_element.width, canvas_element.height); + } + }; + + const resize_canvas = () => { + if(!canvas_element) return; + const quality_pct = xgravity.settings.get("canvas_quality", null, 100) / 100; + const rect = canvas_element.getBoundingClientRect(); + + const internal_w = Math.max(1, Math.floor(rect.width * quality_pct)); + const internal_h = Math.max(1, Math.floor(rect.height * quality_pct)); + + if(canvas_element.width !== internal_w || canvas_element.height !== internal_h){ + canvas_element.width = internal_w; + canvas_element.height = internal_h; + if(bg_manager) + bg_manager.resize(internal_w, internal_h); + } + }; + + const update_camera = (delta_time, cell_px) => { + camera_sway_clock += delta_time; + + const sway_speed = xgravity.settings.get("cockpit_sway_speed", null, 0.8); + + if(drone_model){ + const drone_pos = drone_model.get_position(); + + // Inercia de cabina + const drift_factor = xgravity.settings.get("cockpit_drift_amount", null, 0.6); + const target_drift_x = (-drone_pos.cell_x / 20.0) * drift_factor; + const target_drift_y = (-drone_pos.cell_y / 20.0) * drift_factor; + + camera_drift_x += (target_drift_x - camera_drift_x) * 4.0 * delta_time; + camera_drift_y += (target_drift_y - camera_drift_y) * 4.0 * delta_time; + + // Parallax del fondo estelar + const bg_factor = xgravity.settings.get("background_parallax_amount", null, 0.25); + const target_bg_x = (drone_pos.cell_x / 20.0) * bg_factor * cell_px; + const target_bg_y = (drone_pos.cell_y / 20.0) * bg_factor * cell_px; + + bg_parallax_x += (target_bg_x - bg_parallax_x) * 3.0 * delta_time; + bg_parallax_y += (target_bg_y - bg_parallax_y) * 3.0 * delta_time; + } + + // Screen Shake + if(shake_duration > 0){ + shake_duration -= delta_time; + shake_trauma = shake_max_duration > 0 ? Math.max(0, shake_duration / shake_max_duration) : 0; + + // Decaimiento no lineal (trauma al cuadrado): arranca fuertísimo y decae suave + const trauma_factor = shake_trauma * shake_trauma; + const shake_offset_cells = trauma_factor * shake_intensity; + + shake_offset_x = (Math.random() * 2 - 1) * shake_offset_cells * cell_px; + shake_offset_y = (Math.random() * 2 - 1) * shake_offset_cells * cell_px; + + // Si es un impacto de nodriza (shake_intensity > 2.0), el latigazo angular se triplica (~0.08 rad ≈ 4.6°) + const max_roll = (shake_intensity > 2.0) ? 0.08 : 0.025; + shake_angle = (Math.random() * 2 - 1) * (trauma_factor * max_roll); + } else { + shake_offset_x = 0; + shake_offset_y = 0; + shake_angle = 0; + } + }; + + const game_loop = (timestamp) => { + if(!last_timestamp) last_timestamp = timestamp; + const delta_time = Math.min((timestamp - last_timestamp) / 1000, 0.1); + last_timestamp = timestamp; + + const cell_size = (canvas_element.width < canvas_element.height ? canvas_element.width : canvas_element.height) / xgravity.settings.get("cells", null, 40); + + if(!is_paused){ + sync_background(); + resize_canvas(); + + if(xgravity.views.fps){ + xgravity.views.fps.tick(delta_time); + } + + // 1. FÍSICAS Y ACTUALIZACIONES (Aquí solo van updates) + if(enemies_manager){ + enemies_manager.update(delta_time, drone_model, on_enemy_destroyed, on_drone_hit, on_enemy_deflected, on_mothership_impact); + } + if(drone_model){ + drone_model.update(delta_time); + } + if(cockpit_model){ + cockpit_model.update(delta_time); + } + if(xgravity.explosions){ + xgravity.explosions.update(delta_time); // <-- ACTIVADO + } + if(hud_model){ + hud_model.render(ctx, canvas_element.width, canvas_element.height, delta_time, drone_model, cockpit_model, current_score, drone_lives, max_drone_lives); + } + + // 2. CÁMARA Y TRAUMA + update_camera(delta_time, cell_size); + + // 3. LIMPIEZA + ctx.clearRect(0, 0, canvas_element.width, canvas_element.height); + + // 4. FONDO ESTELAR CON PARALLAX + if(bg_manager){ + ctx.save(); + ctx.translate(bg_parallax_x + (shake_offset_x * 0.4), bg_parallax_y + (shake_offset_y * 0.4)); + bg_manager.render(ctx, canvas_element.width, canvas_element.height, delta_time); + ctx.restore(); + } + + // 5. SCREEN SHAKE ACTIVO PARA ENTIDADES Y CABINA + ctx.save(); + const cx = canvas_element.width / 2; + const cy = canvas_element.height / 2; + + ctx.translate(cx + shake_offset_x, cy + shake_offset_y); + if(shake_angle !== 0) ctx.rotate(shake_angle); + ctx.translate(-cx, -cy); + + // A) Enemigos en el plano de batalla (debajo del dron) + if(enemies_manager){ + enemies_manager.render_under(ctx, canvas_element.width, canvas_element.height); + } + + // B) Dron y explosiones de combate espacial + if(drone_model){ + drone_model.render(ctx, canvas_element.width, canvas_element.height); + } + if(xgravity.explosions){ + xgravity.explosions.render(ctx, canvas_element.width, canvas_element.height); + } + + // C) Enemigos en sobrevuelo + Retículas y Daño flotante + if(enemies_manager){ + enemies_manager.render_over(ctx, canvas_element.width, canvas_element.height); + } + + // D) Cúpula / Cabina con balanceo inercial + ctx.save(); + const drift_px_x = (camera_drift_x + Math.sin(camera_sway_clock * 0.8) * 0.15) * cell_size; + const drift_px_y = (camera_drift_y + Math.cos(camera_sway_clock * 0.56) * 0.15) * cell_size; + ctx.translate(drift_px_x, drift_px_y); + + if(cockpit_model){ + cockpit_model.render(ctx, canvas_element.width, canvas_element.height, delta_time); + } + ctx.restore(); + + // E) EFECTOS DE CABINA (Flash de pantalla y fragmentos de roca sobre el cristal) + if(xgravity.explosions && typeof xgravity.explosions.render_cockpit === "function"){ + xgravity.explosions.render_cockpit(ctx, canvas_element.width, canvas_element.height); + } + + ctx.restore(); // Cierre de Screen Shake + + // 6. HUD HOLOGRÁFICO (fijo en cabina) + if(hud_model){ + hud_model.render(ctx, canvas_element.width, canvas_element.height, delta_time, drone_model, cockpit_model, current_score); + } + } + + animation_frame_id = requestAnimationFrame(game_loop); + }; + + this.update_hud = () => {}; + + this.toggle_pause = () => { + if(!is_paused){ + self.pause(); + if(xgravity.views.main_menu){ + xgravity.views.main_menu.go(); + } + } else { + // Si ya estaba pausado y pulsa ESC de nuevo, reanudamos + if(xgravity.views.main_menu){ + xgravity.views.main_menu.close(); + } + self.resume(); + } + }; + + this.stop = () => { + if(animation_frame_id){ + cancelAnimationFrame(animation_frame_id); + animation_frame_id = null; + } + if(view_element && view_element.parentNode){ + view_element.parentNode.removeChild(view_element); + view_element = null; + } + + if(xgravity.views.fps){ + xgravity.views.fps.close(); + } + + window.removeEventListener("mousedown", on_mouse_down); + window.removeEventListener("mouseup", on_mouse_up); + window.removeEventListener("keydown", on_key_down); + window.removeEventListener("keyup", on_key_up); + window.removeEventListener("touchstart", on_touch_start); + window.removeEventListener("touchend", on_touch_end); + + window.removeEventListener("mousemove", on_pointer_move); + window.removeEventListener("touchmove", on_pointer_move); + + cockpit_model = null; + drone_model = null; + hud_model = null; + enemies_manager = null; + // explosions_model = null; + + bg_manager = null; + current_bg_mode = null; + }; + + this.go = () => { + self.stop(); + + is_paused = false; + current_score = 0; + last_timestamp = 0; + + cockpit_model = new CockpitModel(xgravity); + drone_model = new DroneModel(xgravity); + hud_model = new TelemetryHudModel(xgravity); + enemies_manager = new EnemiesManager(xgravity); + // explosions_model = new ExplosionsModel(xgravity); + + window.addEventListener("mousedown", on_mouse_down); + window.addEventListener("mouseup", on_mouse_up); + window.addEventListener("keydown", on_key_down); + window.addEventListener("keyup", on_key_up); + window.addEventListener("touchstart", on_touch_start, { passive : true }); + window.addEventListener("touchend", on_touch_end, { passive : true }); + + window.addEventListener("mousemove", on_pointer_move); + window.addEventListener("touchmove", on_pointer_move, { passive : true }); + + session_stats = { + total_spawned : 0, + total_destroyed : 0, + shield_kills : 0 + }; + + if(enemies_manager){ + enemies_manager.set_on_mothership_impact((base_damage, impact_x, impact_y) => { + // 1. Calcular daño mitigado o potenciado por la dificultad + const preset = xgravity.settings.get_difficulty_preset(); + const dmg_mult = preset ? preset.mothership_damage_mult : 1.0; + const final_damage = base_damage * dmg_mult; + + // 2. Aplicar daño a la nave nodriza + let is_destroyed = false; + if(cockpit_model && typeof cockpit_model.take_damage === "function"){ + is_destroyed = cockpit_model.take_damage(final_damage); + } + + // 3. Efectos visuales brutales + trigger_mega_shake(); + + const cx = canvas.width / 2; + const cy = canvas.height / 2; + if(xgravity.explosions && typeof xgravity.explosions.add_mothership_impact === "function"){ + xgravity.explosions.add_mothership_impact(cx, cy); + } + + // 4. Si el casco llega a cero: Game Over + if(is_destroyed){ + // Trigger de fin de partida + console.log("¡NAVE NODRIZA DESTRUIDA!"); + } + }); + } + + const nodes = xgravity.html(xgravity.item_self, [ + "div", { class : "xg-game-container" }, [ + ["canvas", { class : "xg-canvas" }], + ["div", { class : "xg-hud" }, [ + // Arriba (FPS y telemetría) + ["div", { class : "xg-hud-top" }], + + // Abajo a la derecha: Botón de Pausa + ["div", { class : "xg-hud-bottom" }, [ + ["button", { + class : "xg-btn xg-hud-pause-btn", + "data-i18n" : "hud_pause_title", + onclick : () => self.toggle_pause() + }] + ]] + ]] + ] + ]); + + const preset = (xgravity.settings && typeof xgravity.settings.get_difficulty_preset === "function") + ? xgravity.settings.get_difficulty_preset() + : null; + + max_drone_lives = (preset && preset.drone_lives !== undefined) + ? preset.drone_lives + : xgravity.settings.get("drone_lives", null, 3); + + drone_lives = max_drone_lives; + is_game_over = false; + is_respawning_drone = false; + + if(hud_model){ + hud_model.set_lives(drone_lives, max_drone_lives); + } + + view_element = nodes[0]; + canvas_element = view_element.querySelector("canvas"); + ctx = canvas_element.getContext("2d"); + + sync_background(); + resize_canvas(); + self.update_hud(); + xgravity.i18n.update(); + + animation_frame_id = requestAnimationFrame(game_loop); + }; + + }; + + return GameView; +})(); \ No newline at end of file diff --git a/Public/ecma/Views/MainMenuView.ecma.js b/Public/ecma/Views/MainMenuView.ecma.js new file mode 100644 index 0000000..8ee950e --- /dev/null +++ b/Public/ecma/Views/MainMenuView.ecma.js @@ -0,0 +1,101 @@ +"use strict"; + +const MainMenuView = (function(){ + + const MainMenuView = function(xgravity){ + + const self = this; + let view_element = null; + + this.close = () => { + if(view_element && view_element.parentNode){ + view_element.parentNode.removeChild(view_element); + view_element = null; + } + }; + + this.go = () => { + self.close(); + + const is_game_active = xgravity.views.game && typeof xgravity.views.game.is_active === "function" + ? xgravity.views.game.is_active() + : false; + + const menu_buttons = []; + + // 1. Si hay partida en curso: botón Reanudar + if(is_game_active){ + menu_buttons.push([ + "button", { + class : "xg-btn xg-btn-resume", + "data-i18n" : "btn_resume", + onclick : () => { + self.close(); + if(xgravity.views.game){ + xgravity.views.game.resume(); + } + } + } + ]); + } + + // 2. Jugar / Nueva Partida -> Abre DifficultySelectView + menu_buttons.push([ + "button", { + class : "xg-btn", + "data-i18n" : is_game_active ? "btn_new_game" : "btn_play", + onclick : () => { + self.close(); + if(xgravity.views.difficulty_select){ + xgravity.views.difficulty_select.go(); + } + } + } + ]); + + // 3. Vistas secundarias + menu_buttons.push( + ["button", { + class : "xg-btn", + "data-i18n" : "btn_options", + onclick : () => { + self.close(); + if(xgravity.views.options) xgravity.views.options.go(); + } + }], + ["button", { + class : "xg-btn", + "data-i18n" : "btn_ranking", + onclick : () => { + self.close(); + if(xgravity.views.ranking) xgravity.views.ranking.go(); + } + }], + ["button", { + class : "xg-btn", + "data-i18n" : "btn_credits", + onclick : () => { + self.close(); + if(xgravity.views.credits) xgravity.views.credits.go(); + } + }] + ); + + const nodes = xgravity.html(xgravity.item_self, [ + "div", { class : "xg-view xg-main-menu" }, [ + ["div", { class : "xg-menu-header" }, [ + ["img", { class : "xg-main-logo", src : "images/logo.svg", alt : "XGravity Logo" }], + ["h1", { class : "xg-title", "data-i18n" : "xgravity" }] + ]], + ["div", { class : "xg-menu-list" }, menu_buttons] + ] + ]); + + view_element = nodes[0]; + xgravity.i18n.update(); + }; + + }; + + return MainMenuView; +})(); \ No newline at end of file diff --git a/Public/ecma/Views/OptionsView.ecma.js b/Public/ecma/Views/OptionsView.ecma.js new file mode 100644 index 0000000..76ac3b3 --- /dev/null +++ b/Public/ecma/Views/OptionsView.ecma.js @@ -0,0 +1,150 @@ +"use strict"; + +const OptionsView = (function(){ + + const OptionsView = function(xgravity){ + + const self = this; + let view_element = null, + listening_key = false; + + this.close = () => { + if(view_element && view_element.parentNode){ + view_element.parentNode.removeChild(view_element); + view_element = null; + } + }; + + this.go = () => { + self.close(); + + let current_quality = xgravity.settings.get("canvas_quality", null, 100); + let current_action_key = xgravity.settings.get("action_key", null, "Space"); + let current_bg_mode = xgravity.settings.get("background_mode", null, "stars"); + let current_diff = xgravity.settings.get("current_difficulty", null, "average"); + + const nodes = xgravity.html(xgravity.item_self, [ + "div", { class : "xg-view xg-options" }, [ + ["h2", { class : "xg-subtitle", "data-i18n" : "opt_title" }], + ["div", { class : "xg-panel" }, [ + // Mostrar / Ocultar FPS + ["div", { class : "xg-field-row" }, [ + ["span", { "data-i18n" : "opt_show_fps" }], + ["input", { + type : "checkbox", + class : "xg-checkbox", + ...(xgravity.settings.get("show_fps", null, false) ? { checked : "checked" } : {}), + "data-i18n-without" : "true", + onchange : (input) => { + const enabled = input.checked; + xgravity.settings.add({ show_fps : enabled }); + + // Si se desactiva, lo cerramos inmediatamente + if(!enabled && xgravity.views.fps){ + xgravity.views.fps.close(); + } + } + }] + ]], + // Selector de Dificultad + ["div", { class : "xg-field-row" }, [ + ["span", { "data-i18n" : "opt_difficulty" }], + ["select", { + class : "xg-select", + "data-i18n-without" : "true", + onchange : (select) => { + xgravity.settings.add({ current_difficulty : select.value }); + } + }, [ + ["option", { value : "training", ...(current_diff === "training" ? { selected : "selected" } : {}), "data-i18n" : "diff_training" }], + ["option", { value : "newbie", ...(current_diff === "newbie" ? { selected : "selected" } : {}), "data-i18n" : "diff_newbie" }], + ["option", { value : "lazy", ...(current_diff === "lazy" ? { selected : "selected" } : {}), "data-i18n" : "diff_lazy" }], + ["option", { value : "average", ...(current_diff === "average" ? { selected : "selected" } : {}), "data-i18n" : "diff_average" }], + ["option", { value : "hard", ...(current_diff === "hard" ? { selected : "selected" } : {}), "data-i18n" : "diff_hard" }], + ["option", { value : "berserk", ...(current_diff === "berserk" ? { selected : "selected" } : {}), "data-i18n" : "diff_berserk" }], + ["option", { value : "insane", ...(current_diff === "insane" ? { selected : "selected" } : {}), "data-i18n" : "diff_insane" }] + ]] + ]], + // Calidad de Canvas + ["div", { class : "xg-field-row" }, [ + ["label", { + id : "xg_lbl_quality", + "data-i18n" : "opt_canvas_quality" + }, xgravity.i18n.get("opt_canvas_quality", { quality : current_quality })], + ["input", { + type : "range", + class : "xg-range", + min : "3.125", + max : "100", + step : "3.125", + value : current_quality, + "data-i18n-without" : "true", + oninput : (input) => { + const value = parseFloat(input.value); + xgravity.settings.add({ canvas_quality : value }); + const lbl = document.getElementById("xg_lbl_quality"); + if(lbl) + lbl.textContent = xgravity.i18n.get("opt_canvas_quality", { quality : value }); + } + }] + ]], + // Modo de Fondo + ["div", { class : "xg-field-row" }, [ + ["span", { "data-i18n" : "opt_background" }], + ["select", { + class : "xg-select", + "data-i18n-without" : "true", + onchange : (select) => { + xgravity.settings.add({ background_mode : select.value }); + } + }, [ + ["option", { value : "image", ...(current_bg_mode === "image" ? { selected : "selected" } : {}), "data-i18n" : "opt_bg_image" }], + ["option", { value : "stars", ...(current_bg_mode === "stars" ? { selected : "selected" } : {}), "data-i18n" : "opt_bg_stars" }], + ["option", { value : "procedural", ...(current_bg_mode === "procedural" ? { selected : "selected" } : {}), "data-i18n" : "opt_bg_procedural" }] + ]] + ]], + // Tecla de Acción + ["div", { class : "xg-field-row" }, [ + ["span", { "data-i18n" : "opt_action_key" }], + ["button", { + id : "xg_btn_key", + class : "xg-btn", + "data-i18n-without" : "true", + onclick : (btn) => { + if(listening_key) return; + listening_key = true; + btn.textContent = xgravity.i18n.get("btn_press_key"); + + const on_keydown = (e) => { + window.removeEventListener("keydown", on_keydown); + const chosen = e.code === "Space" ? "Space" : (e.key.length === 1 ? e.key.toUpperCase() : e.code); + xgravity.settings.add({ action_key : chosen }); + btn.textContent = chosen; + listening_key = false; + }; + window.addEventListener("keydown", on_keydown); + } + }, current_action_key] + ]], + // Botón Volver con auto-limpieza + ["button", { + class : "xg-btn", + "data-i18n" : "btn_back", + onclick : () => { + self.close(); + if(xgravity.views.main_menu) + xgravity.views.main_menu.go(); + } + }] + ]] + ] + ]); + + view_element = nodes[0]; + xgravity.i18n.update(); + }; + + }; + + return OptionsView; +})(); \ No newline at end of file diff --git a/Public/ecma/Views/RankingView.ecma.js b/Public/ecma/Views/RankingView.ecma.js new file mode 100644 index 0000000..a808cdb --- /dev/null +++ b/Public/ecma/Views/RankingView.ecma.js @@ -0,0 +1,45 @@ +"use strict"; + +const RankingView = (function(){ + + const RankingView = function(xgravity){ + + const self = this; + let view_element = null; + + this.close = () => { + if(view_element && view_element.parentNode){ + view_element.parentNode.removeChild(view_element); + view_element = null; + } + }; + + this.go = () => { + self.close(); + + const nodes = xgravity.html(xgravity.item_self, [ + "div", { class : "xg-view xg-ranking" }, [ + ["h2", { class : "xg-subtitle", "data-i18n" : "rank_title" }], + ["div", { class : "xg-panel" }, [ + ["p", { "data-i18n" : "rank_empty" }], + ["button", { + class : "xg-btn", + "data-i18n" : "btn_back", + onclick : () => { + self.close(); + if(xgravity.views.main_menu) + xgravity.views.main_menu.go(); + } + }] + ]] + ] + ]); + + view_element = nodes[0]; + xgravity.i18n.update(); + }; + + }; + + return RankingView; +})(); \ No newline at end of file diff --git a/Public/images/logo.svg b/Public/images/logo.svg new file mode 100644 index 0000000..d93c292 --- /dev/null +++ b/Public/images/logo.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Public/index.html b/Public/index.html new file mode 100644 index 0000000..3bd29aa --- /dev/null +++ b/Public/index.html @@ -0,0 +1,93 @@ + + + + XGravity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Public/md/ai-dev-rules.md b/Public/md/ai-dev-rules.md new file mode 100644 index 0000000..d5b1d1b --- /dev/null +++ b/Public/md/ai-dev-rules.md @@ -0,0 +1,13 @@ +El proyecto tiene un raíz privado con los archivos `LICENSE`, `README.md` y `.gitignore`; luego tiene un lado público el cual es `/Public`. Dentro de éste un `index.html` y los directorios `data` y `ecma`. En el directorio `data` se meterá todo lo que no se meta en el repositorio de Git, como configuraciones personalizadas, la base de datos, etc. Mientras que los archivos de Script se meten dentro de `ecma` el cual tiene la estructura `Application` para los archivos que funcionen sobre el proyecto de forma diferente o independiente; `Utils` para archivos totalmente autónomos; `Managers` para gestores como `SettingsManager` o `I18NManager`; `Views` para las vistas; y `Models` para los modelos si hicieren falta. + +Las reglas de desarrollo son: + +- La sangría es de 4 espacios. +- Las variables y nombres de funciones, métodos y atributos son Snake Lower Case; el nombre de las clases es en Pascal Case; las constantes de clase o constantes sueltas estarán en Snake Upper Case. +- El GUI HTML se basa en un sistema que simula DPIs a partir de la unidad de medida `em`, el cual divide el lado más estrecho del GUI en 40 celdas por defecto. `1em` es equivalente a una celda. Cada celda es cuadrada. +- Todas las clases que se generen han de ser Funciones Constructoras. Los métodos y atributos estáticos tienen que ir vinculados al nombre sin `prototype`. +- Todo valor susceptible de ser de configuración ha de estar integrado en el archivo `/Public/ecma/Utils/Settings.ecma.js`. +- En HTML, todos los elementos que contengan textos internacionalizados han de llevar el atributo `data-i18n` con la clave del texto, y en caso de ser campos sin contenido como `input`, `textarea`, etc. Han de llevar a mayores el atributo `data-i18n-without="true"` para indicar que no se añada la traducción a su contenido, sólo a los atributos, si los tuviere, como `title`, `placeholder`, etc. +- Las vistas estará en el directorio `/Public/ecma/Views` y tendrán como nombre `NombreDeVistaView.ecma.js` y nombre de clase función constructora `NombreDeVistaView` y se implementarán en el objeto `xgravity.views` con el nombre `nombre_de_vista`. Cada vista ha de tener un método `go` para construir y visualizar dicha vista cada vez que se realiza la acción de ir a dicha vista. Cada texto que se muestre tiene que recogerse del método `xgravity.i18n.get` y sus claves y valores en el archivo `/Public/ecma/Utils/I18N.ecma.js` en cada uno de los idiomas deseados. +- Cualquier HTML que se haga, salvo que no se pueda realizar por dicho método, ha de estar hecho con el método `xgravity.html`. +- Los estilos han de agregarse por bloques que se deseen al archivo `/Public/ecma/Utils/Styles.ecma.js` en formato del método `Common.SCSS`. \ No newline at end of file diff --git a/Public/md/base-dev.md b/Public/md/base-dev.md new file mode 100644 index 0000000..b25ed5d --- /dev/null +++ b/Public/md/base-dev.md @@ -0,0 +1,470 @@ +Tengo la siguiente base estructural hecha sobre la que trabajar. Para empezar, Tenemos el archivo principal de la aplicación, el cual es `/Public/ecma/Application/XGravity.ecma.js`. + +```js +"use strict"; + +const XGravity = (function(){ + + const XGravity = function(){ + + const self = this, + screen = {x : 0, y : 0}; + let cells = 40, + thread_interval = null, + fps = 12; + + this.item_self = document; + + this.settings = new SettingsManager(self); + this.i18n = new I18NManager(self); + this.views = {}; + + const constructor = () => { + + const position = self.settings.get("position", null, "body"); + let target, preload = setInterval(() => { + if(target = document.querySelector(position)){ + clearInterval(preload); + + self.item_self = target.appendChild(document.createElement("div")); + self.item_self.className = self.item_self.id = self.settings.get("class_name", null, "xgravity"); + self.item_self.setAttribute("data-cells", cells = self.settings.get("cells", null, cells)); + + thread_interval = setInterval(thread, 1000 / fps); + + build(); + + }; + }, fps = self.settings.get(["frames_per_second", "fps"], null, fps)); + + Common.SCSS(SCSS); + + }; + + const thread = () => { + if(screen.x != self.item_self.offsetWidth || screen.y != self.item_self.offsetHeight){ + screen.x = self.item_self.offsetWidth; + screen.y = self.item_self.offsetHeight; + + self.item_self.style.fontSize = ((screen.x < screen.y ? screen.x : screen.y) / cells) + "px"; + + }; + }; + + const build = () => {}; + + /** + * @param {...(HTMLElement|string|Array.)} inputs + * @returns {Array.} + * @access public + * @static + */ + this.html = (...inputs) => { + const fragment = new DocumentFragment(), + master = ( + inputs[0] && (inputs[0].tagName || inputs[0].nodeName) ? inputs[0] : + typeof inputs[0] == "string" ? document.querySelector(inputs[0]) : + null), + items = []; + + (master ? inputs.slice(1) : inputs).forEach(subinputs => { + if(subinputs && (subinputs.tagName || subinputs.nodeName)) + fragment.appendChild(subinputs); + else if(typeof subinputs == "string") + fragment.appendChild(document.createTextNode(subinputs)); + else if(subinputs instanceof Array){ + const [tag, attributes, children] = subinputs.concat([null, null]).slice(0, 3), + item = document.createElement(tag); + + attributes && attributes.constructor == Object && Object.entries(attributes).forEach(([key, value]) => { + if(/^on[_\-]?/i.test(key) && typeof value == "function") + item.addEventListener(key.replace(/^on[_\-]?/i, "").replace(/[^a-z0-9]+/gi, "").toLowerCase(), event => { + value(item, event); + }); + else if(value !== null) + item.setAttribute(key.replace(/[^a-z0-9]+/gi, "-"), value); + }); + + if(children instanceof Array) + self.html(...children).forEach(child => item.appendChild(child)); + else if(typeof children == "string") + item.innerHTML += children; + + fragment.appendChild(item); + }; + }); + + items.push(...fragment.childNodes); + master && master.appendChild(fragment); + + return items; + }; + + constructor(); + + }; + + return XGravity; +})(); + +``` + +Dentro de este hay dos gestores, siendo el primero de ellos el de configuración `/Public/ecma/Managers/SettingsManager.ecma.js`: + +```js +"use strict"; + +const SettingsManager = (function(){ + + const SettingsManager = function(xgravity){ + + const self = this, + settings = {}; + + const constructor = () => { + self.add(settings, true); + }; + + this.add = (block, overwrite = true) => { + for(const key in block) + if(overwrite || settings[key] === undefined) + settings[key] = block[key]; + }; + + this.get = (keys, inputs = null, _default = null) => Common.get_value(keys, [inputs, settings], _default); + + constructor(); + + }; + + return SettingsManager; +})(); + +``` + +Y el segundo el de gestión de textos e idiomas `/Public/ecma/Managers/I18NManager.ecma.js`: + +```js +"use strict"; + +const I18NManager = (function(){ + + const I18NManager = function(xgravity){ + + const self = this, + sentences = {}; + let language = "espanol"; + + const constructor = () => { + [i18n, typeof custom_i18n != "undefined" ? custom_i18n : {}].forEach(block => { + self.add(block, true); + }); + self.set_language(xgravity.settings.get("language"), null, Object.keys(sentences)[0] || language); + }; + + this.add = (new_sentences, overwrite = true) => { + for(const language in new_sentences){ + if(!sentences[language]) + sentences[language] = {}; + for(const key in new_sentences[language]) + if(overwrite || sentences[language][key] === undefined) + sentences[language][key] = new_sentences[language][key]; + }; + }; + + const get_sentence = (strings, _default) => { + + const done = []; + + if(typeof strings == "string") + strings = [strings]; + + for(const current of [language].concat(Object.keys(sentences))) + if(!done.includes(current)){ + done.push(current); + for(const key of strings) + if(sentences[current][key] !== undefined) + return sentences[current][key]; + }; + if(_default !== null) + return _default; + for(const text of strings) + return text; + return ""; + }; + + this.get = (strings, inputs = null, _default = null) => Common.string_variables(get_sentence(strings, _default), inputs); + + this.update = () => { + document.querySelectorAll("[data-i18n]").forEach(element => { + + const text = self.get(element.getAttribute("data-i18n")); + + if(element.getAttribute("data-i18n-without") != "true") + element.textContent = text; + ["title", "alt", "placeholder"].forEach(key => { + element.hasAttribute(key) && element.setAttribute(key, text); + }); + + }) + }; + + this.set_language = new_language => { + if(language != new_language && sentences[new_language]){ + language = new_language; + self.update(); + }; + }; + + this.get_languages = () => Object.keys(sentences); + + this.get_current_language = () => language; + + constructor(); + + }; + + return I18NManager; +})(); + +``` + +Por otro lado, tenemos una librería con métodos comunes la cual es `/Public/ecma/Utils/Common.ecma.js`: + +```js +"use strict"; + +const Common = (function(){ + + const Common = function(){}; + + Common.get_keys = (...items) => items.reduce((keys, item) => { + + if(item){ + if(typeof item == "string" && /^[a-z_][a-z0-9_]*$/i.test(item) && !keys.includes(item)) + keys.push(item); + else if(item instanceof Array) + for(const key of Common.get_keys(...item)) + keys.includes(item) || keys.push(key); + }; + + return keys; + }, []); + + Common.get_dictionaries = (...items) => items.reduce((dictionaries, item) => { + + if(item){ + if(item.constructor == Object) + dictionaries.push(item); + else if(item instanceof Array) + dictionaries.push(...Common.get_dictionaries(...item)); + }; + + return dictionaries; + }, []); + + Common.get_value = (keys, inputs, _default = null) => { + if((keys = Common.get_keys(keys)).length) + for(const subinputs of Common.get_dictionaries(inputs)) + for(const key of keys) + if(subinputs[key] !== undefined) + return subinputs[key]; + return _default; + }; + + Common.get_dictionary = (items, overwrite = false) => { + + if(items){ + if(items.constructor == Object) + return items; + else if(items instanceof Array) + return items.reduce((dictionary, item) => { + Object.entries(Common.get_dictionary(item)).forEach(([key, value]) => { + if(overwrite || dictionary[key] === undefined) + dictionary[key] = value; + }); + return dictionary; + }, {}); + }; + + return {}; + }; + + Common.string_variables = (string, variables, _default = null) => { + + variables = Common.get_dictionary(variables); + + return ("" + string).replace(/\{([^\{\}]+)\}/g, (all, key) => ( + variables[key] !== undefined ? variables[key] : + _default !== null ? _default : + all)); + }; + + Common.execute_array = (array, each_callback, end_callback, i = 0) => { + if(i == array.length) + end_callback(); + else + each_callback(array[i], () => { + Common.execute_array(array, each_callback, end_callback, i + 1); + }); + }; + + Common.SCSS = (data, selector = "") => { + + /** @type {Arrya.} */ + const childs = []; + /** @type {string} */ + let css = ``; + + Object.entries(data).forEach(([key, value]) => { + if(value && value.constructor == Object) + key.split(",").forEach(key => { + + /** @type {string} */ + const subselector = `${(selector ? selector + ( + key[0] == "&" ? "" : " " + ) : "") + ( + key[0] == "&" ? key.substring(1) : key + )}`, + /** @type {Array} */ + subcss = Common.SCSS(value, subselector); + + childs.push(`\n\n${subselector}{${subcss[0]}\n}`, ...subcss.slice(1)); + + }); + else + css += `\n ${key.replace(/[^a-z0-9]+/gi, "-")} : ${value === null ? "none" : value};`; + }); + + if(!selector){ + + /** @type {HTMLStyleElement} */ + let style = document.querySelector("head>style"); + + if(!style){ + style = document.querySelector("head").appendChild(document.createElement("style")); + [ + ["data-type", "text/css;charset=utf-8"], + ["data-language", "CSS3"], + ["charset", "utf-8"] + ].forEach(([key, value]) => style.setAttribute(key, value === null ? "none" : value)); + }; + + style.appendChild(document.createTextNode(css + childs.join(""))); + + }; + + return [css, ...childs]; + }; + + return Common; +})(); + +``` + +Otra librería la cual es un diccionario para almacenar la configuración la cual es `/Public/ecma/Utils/Settings.ecma.js`: + +```js +"use strict"; + +/** @type {Dictionary.} */ +const settings = { + cells : 40, + position : "body", + class_name : "xgravity", + frames_per_second : 12, + language : "espanol", + + ...(typeof custom_settings == "undefined" ? {} : custom_settings) +}; + +``` + +Otra librería la cual es un diccionario de diccionarios con los textos donde el primer nivel, la clave indica el idioma, y en el diccionario anidado, la clave identifica al texto internacionalizado `/Public/ecma/Utils/I18N.ecma.js`: + +```js +"use strict"; + +/** @type {Dictionary.>} */ +const i18n = { + espanol : {} +}; + +``` + +Por otro lado, tenemos la librería de estilos, que es un JS que simula una especie de SASS la cual es `/Public/ecma/Utils/Styles.ecma.js`: + +```js +"use strict"; + +/** @type {Dictionary.} */ +const SCSS = { + ...{ + "body,html" : { + height : "100%", + margin : "0em" + } + }, + ...{ + ".xgravity" : { + position : "relative", + top : "0em", + left : "0em", + width : "100%", + height : "100%" + } + }, + ...(typeof custom_styles == "undefined" ? {} : custom_styles) +}; + +``` + +Por otro lado, tenemos un directorio que está excluído del proyecto mediante `/.gitignore` que es `/Public/data` donde metemos cosas personalizadas o que no queramos incluir en el mismo, como pueden ser bases de datos JS, Assets agenos, etc. + +Finalmente tenemos el Index del proyecto el cual es `/Public/index.html`: + +```html + + + + XGravity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` \ No newline at end of file