From b3b0c645e9bb860e60e9f4d2ae4daef017101de9 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Thu, 25 Jun 2026 05:58:51 +0500 Subject: [PATCH] refactor: separate js code --- .gitignore | 1 + game.html | 1434 +--------------------------------------------- index.html | 20 +- js/config.js | 204 +++++++ js/enemy.js | 148 +++++ js/game.js | 272 +++++++++ js/index.js | 17 + js/input.js | 62 ++ js/intro.js | 153 +++++ js/main.js | 29 + js/particle.js | 28 + js/projectile.js | 116 ++++ js/utils.js | 142 +++++ 13 files changed, 1182 insertions(+), 1444 deletions(-) create mode 100644 .gitignore create mode 100644 js/config.js create mode 100644 js/enemy.js create mode 100644 js/game.js create mode 100644 js/index.js create mode 100644 js/input.js create mode 100644 js/intro.js create mode 100644 js/main.js create mode 100644 js/particle.js create mode 100644 js/projectile.js create mode 100644 js/utils.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0273118 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.mimocode/ diff --git a/game.html b/game.html index d39275a..cee5f1e 100644 --- a/game.html +++ b/game.html @@ -33,1430 +33,14 @@ - + + + + + + + + + diff --git a/index.html b/index.html index b13d110..43f179d 100644 --- a/index.html +++ b/index.html @@ -20,24 +20,6 @@ - + diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..040b6a0 --- /dev/null +++ b/js/config.js @@ -0,0 +1,204 @@ +const CONFIG = { + commands: { play: "!play" }, + texts: { + startTitle: "SHOOTING WORD", + startSubtitle: "Введи !play чтобы играть", + gameOverTitle: "ЧАТ, ВЫ ПРОИГРАЛИ!", + gameOverSubtitle: "Введи !play чтобы играть", + scoreLabel: "SCORE:", + waveLabel: "WAVE:", + livesLabel: "LIVES:", + finalScoreLabel: "Final Score:", + armorLabel: "ARMOR", + }, + game: { + startLives: 10, + startWave: 1, + startScore: 0, + spawnIntervalBase: 240, + spawnIntervalMin: 60, + spawnIntervalDecayPerWave: 12, + waveInterval: 900, + heavyEnemyChanceBase: 0.25, + heavyEnemyChancePerWave: 0.02, + scoreSimple: 10, + scoreHeavy: 50, + }, + speed: { + simpleEnemyCrossTime: 60, + simpleEnemyCrossTimeRand: 120, + simpleEnemyCrossTimeDecay: 4000, + heavyEnemyCrossTime: 120, + heavyEnemyCrossTimeRand: 240, + projectileCrossTime: 1.3, + gridSpeed: 0.5, + }, + visual: { + gridSize: 80, + enemyPaddingX: 60, + enemyAlienOffset: 16, + armorBarPad: 20, + armorBarFont: 20, + missTargetPadX: 60, + missTargetPadY: 100, + inputAreaHeight: 105, + removeZone: 60, + playerY: 32, + playerRadius: 16, + playerGlow: 30, + enemySimpleHeight: 30, + enemyHeavyHeight: 42, + enemySimpleFont: 28, + enemyHeavyFont: 30, + enemyArmorBarHeight: 6, + enemyArmorBarOffset: 11, + enemyArmorLabelOffset: 15, + enemyGlow: 10, + enemyFlashGlow: 50, + enemyLineWidth: 3, + projectileGlow: 30, + projectileHitRadius: 30, + projectileMissRadius: 30, + projectileFont: 28, + projectileCircleRadius: 30, + shakeHit: 8, + shakeMiss: 6, + shakeDamage: 16, + particleVel: 12, + particleSizeMin: 4, + particleSizeMax: 10, + particleGlow: 16, + trailSizeBase: 4, + trailSizeInc: 1, + trailGlow: 20, + shooterOffset: 50, + }, + visualRatio: { + missTargetYRange: 0.5, + projectileTrailLength: 8, + projectileTrailDecay: 0.15, + projectileTrailAlpha: 0.6, + projectileCircleAlpha: 0.5, + enemyPulseRate: 0.05, + enemyAlphaBase: 0.7, + enemyAlphaRange: 0.3, + enemyGlowPulse: 5, + particleDecayBase: 0.02, + particleDecayRange: 0.03, + particleDamping: 0.98, + }, + counts: { + projectileTrailLength: 8, + particleHit: 15, + particleMiss: 20, + particleDestroySimple: 15, + particleDestroyHeavy: 30, + particleArmorBreak: 15, + flashDuration: 10, + shakeHitDuration: 6, + shakeMissDuration: 5, + shakeDamageDuration: 10, + trailMaxLength: 10, + trailDecay: 0.12, + trailAlpha: 0.5, + }, + timing: { + inputShootDelay: 150, + glitchDuration: 300, + }, + colors: { + primary: "#00ff41", + primaryDim: "#00cc33", + primaryGlow: "rgba(0,255,65,0.2)", + primaryGrid: "rgba(0,255,65,0.08)", + heavy: "#ffaa00", + heavyDim: "#cc8800", + miss: "#ff0044", + white: "#ffffff", + red: "#ff0000", + armorBarBg: "rgba(255,0,0,0.3)", + armorBarFill: "#ffaa00", + playerBase: "rgba(0,255,65,0.15)", + scanline: "rgba(0,255,65,0.03)", + }, + intro: { + fontSize: 44, + textYR: 0.35, + subtitleYOffset: 60, + fadeInFrames: 40, + shot1Frame: 45, + shot2Frame: 70, + shot3Frame: 95, + shotSpeed: 0.045, + particleCountPerHit: 18, + slideStartFrame: 122, + slideDuration: 20, + totalFrames: 180, + startDelay: 300, + glow: 50, + trailGlow: 20, + projectileGlow: 30, + projectileRadius: 8, + trailRect: 8, + trailMax: 10, + trailDecay: 0.12, + trailAlpha: 0.5, + subtitleFont: 32, + subtitleGlow: 20, + }, +}; + +const WordGenerator = { + simpleWords: [ + "hack", "code", "data", "node", "link", "byte", "bit", "net", "web", "sys", + "run", "log", "bug", "fix", "api", "cpu", "ram", "ssd", "gpu", "dns", + "ip", "url", "tag", "css", "js", "sql", "git", "cli", "env", "dev", + "app", "bot", "ai", "io", "os", "ui", "ux", "vm", "vpn", "lan", + "wan", "ftp", "ssh", "http", "json", "xml", "yaml", "md", "py", "go", + "rust", "java", "php", "cpp", "ts", "rb", "kt", "swift", "dart", "lua", + "perl", "bash", "zsh", "fish", "vim", "nano", "emacs", "sed", "awk", "grep", + "find", "cat", "ls", "cd", "mv", "cp", "rm", "mkdir", "chmod", "chown", + "ping", "curl", "wget", "nc", "nmap", "tcp", "udp", "ip4", "ip6", "mac", + "hex", "bin", "dec", "oct", "xor", "and", "or", "not", "shl", "shr", + "key", "lock", "door", "gate", "wall", "fire", "ice", "wind", "storm", "rain", + "sun", "moon", "star", "void", "null", "zero", "one", "two", "ten", "max", + ], + heavyWords: [ + ["root", "access"], ["data", "breach"], ["fire", "wall"], ["deep", "web"], + ["dark", "net"], ["cloud", "base"], ["neural", "net"], ["quantum", "bit"], + ["block", "chain"], ["smart", "contract"], ["machine", "learn"], ["deep", "fake"], + ["zero", "day"], ["back", "door"], ["side", "channel"], ["man", "middle"], + ["denial", "service"], ["brute", "force"], ["social", "engineer"], ["phishing", "attack"], + ["sql", "inject"], ["cross", "site"], ["buffer", "over"], ["heap", "spray"], + ["stack", "pivot"], ["return", "orient"], ["format", "string"], ["race", "condition"], + ["time", "check"], ["use", "after"], ["double", "free"], ["null", "pointer"], + ["integer", "over"], ["type", "confus"], ["memory", "leak"], ["dead", "lock"], + ["live", "lock"], ["starvation", "mode"], ["priority", "invert"], ["cache", "miss"], + ["branch", "mispred"], ["speculative", "exec"], ["meltdown", "flaw"], ["spectre", "bug"], + ["row", "hammer"], ["cold", "boot"], ["evil", "maid"], ["supply", "chain"], + ["hardware", "troj"], ["firmware", "root"], ["bios", "implant"], ["uefi", "shell"], + ["smm", "exploit"], ["ring", "zero"], ["kernel", "panic"], ["blue", "screen"], + ["kernel", "oops"], ["seg", "fault"], ["bus", "error"], ["illegal", "op"], + ["trap", "divide"], ["trap", "debug"], ["trap", "nmi"], ["trap", "break"], + ["trap", "overflow"], ["trap", "bound"], ["trap", "invalid"], ["trap", "device"], + ["trap", "double"], ["trap", "copro"], ["trap", "tss"], ["trap", "segment"], + ["trap", "stack"], ["trap", "general"], ["trap", "page"], ["trap", "x87"], + ["trap", "align"], ["trap", "machine"], ["trap", "simd"], ["trap", "virtual"], + ["security", "check"], ["stack", "cookie"], ["aslr", "bypass"], ["dep", "bypass"], + ["cfg", "bypass"], ["cet", "bypass"], ["shadow", "stack"], ["control", "flow"], + ["indirect", "call"], ["jump", "oriented"], ["call", "oriented"], ["data", "oriented"], + ["counterfeit", "obj"], ["use", "after"], ["heap", "feng"], ["house", "spirit"], + ["house", "lore"], ["house", "force"], ["fast", "bin"], ["tcache", "poison"], + ["unsorted", "bin"], ["large", "bin"], ["small", "bin"], ["mmap", "chunk"], + ["top", "chunk"], ["wilderness", "area"], ["arena", "corrupt"], ["thread", "cache"], + ["per", "thread"], ["main", "arena"], + ], + + generateWord(isHeavy) { + if (isHeavy) { + const pair = this.heavyWords[Math.floor(Math.random() * this.heavyWords.length)]; + return pair; + } + return this.simpleWords[Math.floor(Math.random() * this.simpleWords.length)]; + }, +}; diff --git a/js/enemy.js b/js/enemy.js new file mode 100644 index 0000000..fee5b95 --- /dev/null +++ b/js/enemy.js @@ -0,0 +1,148 @@ +class Enemy { + constructor() { + const isHeavy = Math.random() < CONFIG.game.heavyEnemyChanceBase + wave * CONFIG.game.heavyEnemyChancePerWave; + this.isHeavy = isHeavy; + const wordData = WordGenerator.generateWord(isHeavy); + + if (isHeavy) { + this.words = wordData; + this.currentWordIndex = 0; + this.text = this.words[0]; + } else { + this.words = [wordData]; + this.currentWordIndex = 0; + this.text = wordData; + } + + this.y = 0; + this.speed = isHeavy + ? S.heavyEnemySpeedBase + Math.random() * S.heavyEnemySpeedRandom + : Math.min( + S.simpleEnemySpeedBase + + Math.random() * S.simpleEnemySpeedRandom + + wave * S.simpleEnemySpeedPerWave, + S.simpleEnemyMaxSpeed, + ); + this.height = isHeavy ? S.enemyHeavyHeight : S.enemySimpleHeight; + this.pulse = 0; + this.flashTimer = 0; + this.width = 0; + this.x = this._calculateSpawnX(); + } + + _calculateSpawnX() { + const padding = S.enemyPaddingX; + + ctx.save(); + ctx.font = `${this.isHeavy ? "bold " : ""}${this.isHeavy ? S.enemyHeavyFont : S.enemySimpleFont}px 'Courier New', monospace`; + + let maxTextWidth = 0; + for (let word of this.words) { + const w = ctx.measureText(word).width; + if (w > maxTextWidth) maxTextWidth = w; + } + ctx.restore(); + + this.width = maxTextWidth + 24; + const halfWidth = this.width / 2; + + const minX = padding + halfWidth; + const maxX = W - padding - halfWidth; + + return minX + Math.random() * (maxX - minX); + } + + nextWord() { + this.currentWordIndex++; + this.flashTimer = S.flashDuration; + if (this.currentWordIndex >= this.words.length) { + return true; + } + this.text = this.words[this.currentWordIndex]; + return false; + } + + update() { + this.y += this.speed; + this.pulse += 0.05; + if (this.flashTimer > 0) this.flashTimer--; + } + + draw() { + const alpha = S.enemyAlphaBase + Math.sin(this.pulse) * S.enemyAlphaRange; + const flash = this.flashTimer > 0; + + ctx.save(); + ctx.font = `${this.isHeavy ? "bold " : ""}${this.isHeavy ? S.enemyHeavyFont : S.enemySimpleFont}px 'Courier New', monospace`; + + const boxW = this.width; + const boxH = this.height; + + ctx.shadowColor = flash ? CONFIG.colors.white : this.isHeavy ? CONFIG.colors.heavy : CONFIG.colors.primary; + ctx.shadowBlur = flash ? S.enemyFlashGlow : S.enemyGlow + Math.sin(this.pulse * 2) * S.enemyGlowPulse; + + ctx.strokeStyle = flash + ? CONFIG.colors.white + : this.isHeavy + ? `rgba(255,170,0,${alpha})` + : `rgba(0,255,65,${alpha})`; + ctx.lineWidth = flash ? S.enemyLineWidth * 2 : S.enemyLineWidth; + + if (this.isHeavy) { + const off = S.enemyAlienOffset; + ctx.beginPath(); + ctx.moveTo(this.x - boxW / 2 + off, this.y - boxH / 2); + ctx.lineTo(this.x + boxW / 2 - off, this.y - boxH / 2); + ctx.lineTo(this.x + boxW / 2, this.y); + ctx.lineTo(this.x + boxW / 2 - off, this.y + boxH / 2); + ctx.lineTo(this.x - boxW / 2 + off, this.y + boxH / 2); + ctx.lineTo(this.x - boxW / 2, this.y); + ctx.closePath(); + ctx.stroke(); + + const barW = boxW - S.armorBarPad; + const barH = S.enemyArmorBarHeight; + const totalLayers = this.words.length; + const remainingLayers = totalLayers - this.currentWordIndex; + ctx.fillStyle = CONFIG.colors.armorBarBg; + ctx.fillRect(this.x - barW / 2, this.y - boxH / 2 - S.enemyArmorBarOffset, barW, barH); + ctx.fillStyle = flash ? CONFIG.colors.white : CONFIG.colors.armorBarFill; + ctx.fillRect( + this.x - barW / 2, + this.y - boxH / 2 - S.enemyArmorBarOffset, + barW * (remainingLayers / totalLayers), + barH, + ); + + ctx.font = `${S.armorBarFont}px "Courier New", monospace`; + ctx.fillStyle = flash ? CONFIG.colors.white : CONFIG.colors.heavy; + ctx.textAlign = "center"; + ctx.fillText( + `${CONFIG.texts.armorLabel} ${remainingLayers}/${totalLayers}`, + this.x, + this.y - boxH / 2 - S.enemyArmorLabelOffset, + ); + } else { + ctx.strokeRect(this.x - boxW / 2, this.y - boxH / 2, boxW, boxH); + } + + ctx.shadowBlur = 0; + ctx.font = `${this.isHeavy ? "bold " : ""}${this.isHeavy ? S.enemyHeavyFont : S.enemySimpleFont}px 'Courier New', monospace`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + ctx.fillStyle = flash ? CONFIG.colors.white : this.isHeavy ? CONFIG.colors.heavy : CONFIG.colors.primary; + ctx.fillText(this.text, this.x, this.y); + + ctx.restore(); + } + + getBounds() { + return { + left: this.x - this.width / 2, + right: this.x + this.width / 2, + top: this.y - this.height / 2, + bottom: this.y + this.height / 2, + }; + } +} diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..718c4d7 --- /dev/null +++ b/js/game.js @@ -0,0 +1,272 @@ +const canvas = document.getElementById("game-canvas"); +const ctx = canvas.getContext("2d"); +let W, H; + +let gameState = "start"; +let score = 0, + wave = 1, + lives = 3; +let enemies = []; +let particles = []; +let projectiles = []; +let spawnTimer = 0; +let spawnInterval = CONFIG.game.spawnIntervalBase; +let waveTimer = 0; +let shakeTimer = 0; +let shakeAmount = 0; +let bgOffset = 0; +let gameLoopStarted = false; +let introPlaying = false; + +let playerStats = {}; + +const game = { + launchProjectile(word, targetEnemy, isHit, username) { + projectiles.push(new Projectile(word, targetEnemy, isHit, username)); + }, + + onProjectileHit(projectile, enemy) { + const dead = enemy.nextWord(); + + if (dead) { + score += enemy.isHeavy ? CONFIG.game.scoreHeavy : CONFIG.game.scoreSimple; + document.getElementById("score").textContent = score; + if (projectile.username) { + if (!playerStats[projectile.username]) playerStats[projectile.username] = { kills: 0, misses: 0 }; + playerStats[projectile.username].kills++; + } + this.destroyEnemy(enemy); + } else { + spawnParticles(enemy.x, enemy.y, CONFIG.colors.heavy, S.particleArmorBreak); + triggerShake(S.shakeHit, S.shakeHitDuration); + } + }, + + onProjectileMiss(projectile) { + spawnParticles(projectile.targetX, projectile.targetY, CONFIG.colors.miss, S.particleMiss); + triggerShake(S.shakeMiss, S.shakeMissDuration); + if (projectile.username) { + if (!playerStats[projectile.username]) playerStats[projectile.username] = { kills: 0, misses: 0 }; + playerStats[projectile.username].misses++; + } + }, + + destroyEnemy(enemy) { + const idx = enemies.indexOf(enemy); + if (idx > -1) { + const color = enemy.isHeavy ? CONFIG.colors.heavy : CONFIG.colors.primary; + const count = enemy.isHeavy + ? S.particleDestroyHeavy + : S.particleDestroySimple; + spawnParticles(enemy.x, enemy.y, color, count); + enemies.splice(idx, 1); + } + }, +}; + +function spawnEnemy() { + enemies.push(new Enemy()); +} + +function takeDamage() { + lives--; + document.getElementById("lives").textContent = lives; + triggerShake(S.shakeDamage, S.shakeDamageDuration); + document.getElementById("game-container").classList.add("glitch"); + setTimeout( + () => document.getElementById("game-container").classList.remove("glitch"), + S.glitchDuration, + ); + + if (lives <= 0) { + gameOver(); + } +} + +let gameOverTimeout = null; + +function renderPlayerStats() { + const container = document.getElementById("player-stats"); + container.innerHTML = ""; + + const names = Object.keys(playerStats); + if (names.length === 0) return; + + const byKills = names.slice().sort((a, b) => playerStats[b].kills - playerStats[a].kills); + const top3 = byKills.slice(0, 3); + + const sectionTitle = document.createElement("div"); + sectionTitle.className = "stats-section-title"; + sectionTitle.textContent = "УБИЙЦЫ"; + container.appendChild(sectionTitle); + + top3.forEach((name, i) => { + const row = document.createElement("div"); + row.className = "stats-row" + (i === 0 ? " stats-top1" : i === 1 ? " stats-top2" : " stats-top3"); + const place = i === 0 ? "1" : i === 1 ? "2" : "3"; + row.innerHTML = `#${place} ${name} ${playerStats[name].kills}`; + container.appendChild(row); + }); + + let maxMisses = 0; + let mazilaName = ""; + for (const name of names) { + if (playerStats[name].misses > maxMisses) { + maxMisses = playerStats[name].misses; + mazilaName = name; + } + } + + if (mazilaName && maxMisses > 0) { + const mazila = document.createElement("div"); + mazila.className = "stats-mazila"; + mazila.innerHTML = `Мазила: ${mazilaName} (${maxMisses})`; + container.appendChild(mazila); + } +} + +function gameOver() { + gameState = "gameover"; + document.getElementById("final-score").textContent = score; + renderPlayerStats(); + document.getElementById("gameover-screen").classList.remove("hidden"); + document.getElementById("gameover-subtitle").classList.add("hidden"); + + if (gameOverTimeout) clearTimeout(gameOverTimeout); + gameOverTimeout = setTimeout(() => { + gameOverTimeout = null; + document.getElementById("gameover-screen").classList.add("hidden"); + document.getElementById("ui-overlay").classList.add("hidden"); + document.getElementById("input-display").classList.add("hidden"); + gameLoopStarted = false; + recalc(); + runIntro(); + }, 10000); +} + +function startGame() { + if (gameOverTimeout) { + clearTimeout(gameOverTimeout); + gameOverTimeout = null; + } + gameState = "playing"; + score = CONFIG.game.startScore; + wave = CONFIG.game.startWave; + lives = CONFIG.game.startLives; + enemies = []; + particles = []; + projectiles = []; + playerStats = {}; + spawnTimer = CONFIG.game.spawnIntervalBase; + spawnInterval = CONFIG.game.spawnIntervalBase; + waveTimer = 0; + InputModule.clear(); + + document.getElementById("score").textContent = score; + document.getElementById("wave").textContent = wave; + document.getElementById("lives").textContent = lives; + document.getElementById("start-screen").classList.add("hidden"); + document.getElementById("gameover-screen").classList.add("hidden"); + document.getElementById("ui-overlay").classList.remove("hidden"); + document.getElementById("input-display").classList.remove("hidden"); + + if (!gameLoopStarted) { + gameLoopStarted = true; + loop(); + } +} + +function update() { + if (gameState !== "playing") return; + + spawnTimer++; + if (spawnTimer >= spawnInterval) { + spawnEnemy(); + spawnTimer = 0; + spawnInterval = Math.max( + CONFIG.game.spawnIntervalMin, + CONFIG.game.spawnIntervalBase - wave * CONFIG.game.spawnIntervalDecayPerWave, + ); + } + + waveTimer++; + if (waveTimer >= CONFIG.game.waveInterval) { + wave++; + waveTimer = 0; + document.getElementById("wave").textContent = wave; + spawnInterval = Math.max( + CONFIG.game.spawnIntervalMin, + CONFIG.game.spawnIntervalBase - wave * CONFIG.game.spawnIntervalDecayPerWave, + ); + } + + for (let i = enemies.length - 1; i >= 0; i--) { + const e = enemies[i]; + e.update(); + + if (e.y > H - S.removeZone) { + takeDamage(); + enemies.splice(i, 1); + } + } + + for (let i = particles.length - 1; i >= 0; i--) { + particles[i].update(); + if (particles[i].life <= 0) particles.splice(i, 1); + } + + for (let i = projectiles.length - 1; i >= 0; i--) { + const alive = projectiles[i].update(); + if (!alive) { + projectiles.splice(i, 1); + } + } + + if (shakeTimer > 0) { + shakeTimer--; + shakeAmount *= 0.9; + } +} + +function draw() { + ctx.clearRect(0, 0, W, H); + + ctx.save(); + if (shakeTimer > 0) { + ctx.translate((Math.random() - 0.5) * shakeAmount, (Math.random() - 0.5) * shakeAmount); + } + + drawGrid(); + + for (let p of projectiles) p.draw(); + for (let p of particles) p.draw(); + for (let e of enemies) e.draw(); + + ctx.save(); + ctx.fillStyle = CONFIG.colors.playerBase; + ctx.fillRect(0, H - S.inputAreaHeight, W, S.inputAreaHeight); + ctx.strokeStyle = CONFIG.colors.primary; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, H - S.inputAreaHeight); + ctx.lineTo(W, H - S.inputAreaHeight); + ctx.stroke(); + + ctx.fillStyle = CONFIG.colors.primary; + ctx.shadowColor = CONFIG.colors.primary; + ctx.shadowBlur = S.playerGlow; + ctx.beginPath(); + ctx.arc(W / 2, H - S.playerY, S.playerRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + ctx.restore(); +} + +function loop() { + update(); + draw(); + if (gameLoopStarted) { + requestAnimationFrame(loop); + } +} diff --git a/js/index.js b/js/index.js new file mode 100644 index 0000000..4843bd6 --- /dev/null +++ b/js/index.js @@ -0,0 +1,17 @@ +const input = document.getElementById("channel-input"); +const copyBtn = document.getElementById("copy-btn"); +const linkEl = document.getElementById("game-link"); + +function copyLink() { + const name = input.value.trim().toLowerCase() || "Ku6ep_XBOCTuK"; + const url = `${location.origin}${location.pathname.replace(/\/[^/]*$/, "/")}game.html?channel=${encodeURIComponent(name)}`; + navigator.clipboard.writeText(url).then(() => { + linkEl.textContent = "СКОПИРОВАНО!"; + linkEl.className = "copied"; + }); +} + +copyBtn.addEventListener("click", copyLink); +input.addEventListener("keydown", (e) => { + if (e.key === "Enter") copyLink(); +}); diff --git a/js/input.js b/js/input.js new file mode 100644 index 0000000..ed260d5 --- /dev/null +++ b/js/input.js @@ -0,0 +1,62 @@ +const InputModule = { + currentLine: "", + + submitLine(text, username) { + const line = text.trim().toLowerCase(); + if (!line) return; + if (introPlaying) return; + + if (line === CONFIG.commands.play) { + if (gameState === "start" || gameState === "gameover") { + startGame(); + } + return; + } + + if (gameState !== "playing") return; + + this.currentLine = line; + this.updateDisplay(line); + + const enemy = this.findEnemy(line); + + this.animateShoot(line, enemy, username); + }, + + animateShoot(line, enemy, username) { + const el = document.getElementById("typed-text"); + + el.classList.add("shooting"); + + setTimeout(() => { + el.classList.remove("shooting"); + this.currentLine = ""; + this.updateDisplay(""); + + if (enemy) { + game.launchProjectile(line, enemy, true, username); + } else { + game.launchProjectile(line, null, false, username); + } + }, S.inputShootDelay); + }, + + findEnemy(word) { + for (let e of enemies) { + if (e.text === word) { + return e; + } + } + return null; + }, + + updateDisplay(text) { + const el = document.getElementById("typed-text"); + el.innerHTML = text; + }, + + clear() { + this.currentLine = ""; + this.updateDisplay(""); + }, +}; diff --git a/js/intro.js b/js/intro.js new file mode 100644 index 0000000..2ca8057 --- /dev/null +++ b/js/intro.js @@ -0,0 +1,153 @@ +function runIntro() { + introPlaying = true; + const I = CONFIG.intro; + const ih = H / 1000; + const IS = { + fontSize: Math.round(I.fontSize * ih), + glow: Math.round(I.glow * ih), + trailGlow: Math.round(I.trailGlow * ih), + projectileGlow: Math.round(I.projectileGlow * ih), + projectileRadius: Math.round(I.projectileRadius * ih), + trailRect: Math.round(I.trailRect * ih), + subtitleFont: Math.round(I.subtitleFont * ih), + subtitleGlow: Math.round(I.subtitleGlow * ih), + subtitleYOffset: Math.round(I.subtitleYOffset * ih), + }; + const TEXT_Y = Math.round(H * I.textYR); + ctx.save(); + ctx.font = `bold ${IS.fontSize}px "Courier New", monospace`; + const totalW = ctx.measureText("SHOOTING WORLD").width; + const worW = ctx.measureText("SHOOTING WOR").width; + const lW = ctx.measureText("L").width; + const dW = ctx.measureText("D").width; + ctx.restore(); + + const cx = W / 2; + const lTargetX = cx - totalW / 2 + worW + lW / 2; + const lTargetY = TEXT_Y; + const shooterX = cx; + const shooterY = H - S.shooterOffset; + + let frame = 0; + let textAlpha = 0; + let p1 = []; + let shots = []; + + function drawPartialWorld(alpha, dSlide) { + ctx.save(); + ctx.font = `bold ${IS.fontSize}px "Courier New", monospace`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + ctx.globalAlpha = alpha; + ctx.fillStyle = CONFIG.colors.primary; + ctx.shadowColor = CONFIG.colors.primaryGlow; + ctx.shadowBlur = IS.glow; + ctx.fillText("SHOOTING WOR", cx - totalW / 2 + worW / 2, TEXT_Y); + + const lFade = Math.max(0, 1 - dSlide); + if (lFade > 0) { + ctx.globalAlpha = alpha * lFade; + ctx.fillText("L", cx - totalW / 2 + worW + lW / 2, TEXT_Y); + } + + const dOffset = lW * dSlide; + ctx.globalAlpha = alpha; + ctx.fillText("D", cx - totalW / 2 + worW + lW + dW / 2 - dOffset, TEXT_Y); + + ctx.restore(); + } + + function drawShotProjectile(s) { + const x = shooterX + (lTargetX - shooterX) * s.progress; + const y = shooterY + (lTargetY - shooterY) * s.progress; + ctx.save(); + + for (let t of s.trail) { + ctx.globalAlpha = t.life * I.trailAlpha; + ctx.fillStyle = CONFIG.colors.primary; + ctx.shadowColor = CONFIG.colors.primary; + ctx.shadowBlur = IS.trailGlow; + ctx.fillRect(t.x - IS.trailRect / 2, t.y - IS.trailRect / 2, IS.trailRect, IS.trailRect); + } + + if (!s.done) { + ctx.globalAlpha = 1; + ctx.fillStyle = CONFIG.colors.primary; + ctx.shadowColor = CONFIG.colors.primary; + ctx.shadowBlur = IS.projectileGlow; + ctx.beginPath(); + ctx.arc(x, y, IS.projectileRadius, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + function introLoop() { + frame++; + + if (frame <= I.fadeInFrames) { + textAlpha = Math.min(1, frame / I.fadeInFrames); + } + + if (frame === I.shot1Frame) shots.push({ progress: 0, trail: [], done: false }); + if (frame === I.shot2Frame) shots.push({ progress: 0, trail: [], done: false }); + if (frame === I.shot3Frame) shots.push({ progress: 0, trail: [], done: false }); + + for (let s of shots) { + if (s.done) continue; + s.progress = Math.min(1, s.progress + I.shotSpeed); + + const x = shooterX + (lTargetX - shooterX) * s.progress; + const y = shooterY + (lTargetY - shooterY) * s.progress; + + s.trail.push({ x, y, life: 1 }); + if (s.trail.length > I.trailMax) s.trail.shift(); + for (let t of s.trail) t.life -= I.trailDecay; + s.trail = s.trail.filter((t) => t.life > 0); + + if (s.progress >= 1) { + s.done = true; + for (let i = 0; i < I.particleCountPerHit; i++) { + p1.push(new Particle(lTargetX, lTargetY, CONFIG.colors.primary)); + } + } + } + + let dSlide = 0; + if (frame >= I.slideStartFrame) { + dSlide = Math.min(1, (frame - I.slideStartFrame) / I.slideDuration); + } + + for (let p of p1) p.update(); + p1 = p1.filter((p) => p.life > 0); + + ctx.clearRect(0, 0, W, H); + drawGrid(); + + drawPartialWorld(textAlpha, dSlide); + for (let s of shots) drawShotProjectile(s); + for (let p of p1) p.draw(); + + if (frame < I.totalFrames) { + requestAnimationFrame(introLoop); + } else { + p1 = []; + introPlaying = false; + ctx.clearRect(0, 0, W, H); + drawGrid(); + drawPartialWorld(1, 1); + ctx.save(); + ctx.font = `${IS.subtitleFont}px "Courier New", monospace`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = CONFIG.colors.primaryDim; + ctx.shadowColor = CONFIG.colors.primary; + ctx.shadowBlur = IS.subtitleGlow; + ctx.fillText(CONFIG.texts.startSubtitle, cx, TEXT_Y + IS.subtitleYOffset); + ctx.restore(); + } + } + + setTimeout(introLoop, I.startDelay); +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..2d53245 --- /dev/null +++ b/js/main.js @@ -0,0 +1,29 @@ +(function initUI() { + document.getElementById("start-subtitle").textContent = CONFIG.texts.startSubtitle; + document.getElementById("gameover-title").textContent = CONFIG.texts.gameOverTitle; + document.getElementById("gameover-subtitle").textContent = CONFIG.texts.gameOverSubtitle; + + document.querySelector("#ui-overlay .ui-text:nth-child(1)").innerHTML = + `${CONFIG.texts.scoreLabel} 0`; + document.querySelector("#ui-overlay .ui-text:nth-child(2)").innerHTML = + `${CONFIG.texts.waveLabel} 1`; + document.querySelector("#ui-overlay .ui-text:nth-child(3)").innerHTML = + `${CONFIG.texts.livesLabel} 3`; + + document.querySelector("#gameover-screen .screen-subtitle").innerHTML = + `${CONFIG.texts.finalScoreLabel} 0`; +})(); + +const params = new URLSearchParams(location.search); +const channel = params.get("channel") || "ku6ep_xboctuk"; +const client = new tmi.Client({ channels: [channel] }); +client.connect(); + +client.on("message", (channel, tags, message, self) => { + const username = tags["display-name"] || tags.username || "anonymous"; + InputModule.submitLine(message, username); +}); + +window.addEventListener("resize", recalc); +recalc(); +runIntro(); diff --git a/js/particle.js b/js/particle.js new file mode 100644 index 0000000..b698269 --- /dev/null +++ b/js/particle.js @@ -0,0 +1,28 @@ +class Particle { + constructor(x, y, color) { + this.x = x; + this.y = y; + this.vx = (Math.random() - 0.5) * S.particleVel; + this.vy = (Math.random() - 0.5) * S.particleVel; + this.life = 1; + this.decay = S.particleDecayBase + Math.random() * S.particleDecayRange; + this.color = color; + this.size = S.particleSizeMin + Math.random() * (S.particleSizeMax - S.particleSizeMin); + } + update() { + this.x += this.vx; + this.y += this.vy; + this.life -= this.decay; + this.vx *= S.particleDamping; + this.vy *= S.particleDamping; + } + draw() { + ctx.save(); + ctx.globalAlpha = this.life; + ctx.fillStyle = this.color; + ctx.shadowColor = this.color; + ctx.shadowBlur = S.particleGlow; + ctx.fillRect(this.x - this.size / 2, this.y - this.size / 2, this.size, this.size); + ctx.restore(); + } +} diff --git a/js/projectile.js b/js/projectile.js new file mode 100644 index 0000000..b50ad19 --- /dev/null +++ b/js/projectile.js @@ -0,0 +1,116 @@ +class Projectile { + constructor(word, targetEnemy, isHit, username) { + this.word = word; + this.isHit = isHit; + this.username = username; + this.x = W / 2; + this.y = H - S.shooterOffset; + this.speed = S.projectileSpeed; + this.life = 1; + this.trail = []; + this.hit = false; + + if (isHit && targetEnemy) { + this.target = targetEnemy; + this.targetX = targetEnemy.x; + this.targetY = targetEnemy.y; + const dx = targetEnemy.x - this.x; + const dy = targetEnemy.y - this.y; + const dist = Math.sqrt(dx * dx + dy * dy); + this.vx = (dx / dist) * this.speed; + this.vy = (dy / dist) * this.speed; + } else { + this.target = null; + this.targetX = S.missTargetPadX + Math.random() * (W - 2 * S.missTargetPadX); + this.targetY = S.missTargetPadY + Math.random() * (H * S.missTargetYRange); + const dx = this.targetX - this.x; + const dy = this.targetY - this.y; + const dist = Math.sqrt(dx * dx + dy * dy); + this.vx = (dx / dist) * this.speed; + this.vy = (dy / dist) * this.speed; + } + } + + update() { + this.trail.push({ x: this.x, y: this.y, life: 1 }); + if (this.trail.length > S.projectileTrailLength) this.trail.shift(); + + for (let t of this.trail) { + t.life -= S.projectileTrailDecay; + } + this.trail = this.trail.filter((t) => t.life > 0); + + this.x += this.vx; + this.y += this.vy; + + if (this.isHit && this.target && !this.hit) { + const dx = this.target.x - this.x; + const dy = this.target.y - this.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < S.projectileHitRadius) { + this.hit = true; + game.onProjectileHit(this, this.target); + return false; + } + + if (!enemies.includes(this.target)) { + return false; + } + } + + if (!this.isHit && !this.hit) { + const dx = this.targetX - this.x; + const dy = this.targetY - this.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < S.projectileMissRadius) { + this.hit = true; + game.onProjectileMiss(this); + return false; + } + } + + const off = S.shooterOffset; + if (this.x < -off || this.x > W + off || this.y < -off || this.y > H + off) { + return false; + } + + return true; + } + + draw() { + ctx.save(); + + const color = this.isHit ? CONFIG.colors.primary : CONFIG.colors.miss; + const glowColor = this.isHit ? CONFIG.colors.primary : CONFIG.colors.miss; + + for (let i = 0; i < this.trail.length; i++) { + const t = this.trail[i]; + ctx.globalAlpha = t.life * S.projectileTrailAlpha; + ctx.fillStyle = color; + ctx.shadowColor = glowColor; + ctx.shadowBlur = S.trailGlow; + const size = S.trailSizeBase + i * S.trailSizeInc; + ctx.fillRect(t.x - size / 2, t.y - size / 2, size, size); + } + + ctx.globalAlpha = 1; + ctx.font = `bold ${S.projectileFont}px "Courier New", monospace`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = color; + ctx.shadowColor = glowColor; + ctx.shadowBlur = S.projectileGlow; + ctx.fillText(this.word, this.x, this.y); + + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.globalAlpha = S.projectileCircleAlpha; + ctx.beginPath(); + ctx.arc(this.x, this.y, S.projectileCircleRadius, 0, Math.PI * 2); + ctx.stroke(); + + ctx.restore(); + } +} diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..5f85417 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,142 @@ +let S = {}; + +function recalc() { + const dpr = window.devicePixelRatio || 1; + W = window.innerWidth; + H = window.innerHeight; + canvas.width = W * dpr; + canvas.height = H * dpr; + canvas.style.width = W + "px"; + canvas.style.height = H + "px"; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + S = {}; + + const V = CONFIG.visual; + const C = CONFIG.counts; + const R = CONFIG.visualRatio; + const T = CONFIG.timing; + + S.gridSize = Math.round(V.gridSize * W / 1000); + S.enemyPaddingX = Math.round(V.enemyPaddingX * W / 1000); + S.enemyAlienOffset = Math.round(V.enemyAlienOffset * W / 1000); + S.armorBarPad = Math.round(V.armorBarPad * W / 1000); + S.armorBarFont = Math.round(V.armorBarFont * W / 1000); + S.missTargetPadX = Math.round(V.missTargetPadX * W / 1000); + S.missTargetPadY = Math.round(V.missTargetPadY * H / 1000); + S.inputAreaHeight = Math.round(V.inputAreaHeight * H / 1000); + S.removeZone = Math.round(V.removeZone * H / 1000); + + for (const k of ["playerY","playerRadius","playerGlow","enemySimpleHeight","enemyHeavyHeight", + "enemySimpleFont","enemyHeavyFont","enemyArmorBarHeight","enemyArmorBarOffset", + "enemyArmorLabelOffset","enemyGlow","enemyFlashGlow","enemyLineWidth", + "projectileGlow","projectileHitRadius","projectileMissRadius","projectileFont", + "projectileCircleRadius","shakeHit","shakeMiss","shakeDamage","particleVel", + "particleSizeMin","particleSizeMax","particleGlow","trailSizeBase","trailSizeInc", + "trailGlow","shooterOffset"]) { + S[k] = Math.round(V[k] * H / 1000); + } + + const SP = CONFIG.speed; + const playH = H - S.removeZone; + S.simpleEnemySpeedBase = playH / (SP.simpleEnemyCrossTime * 60); + S.simpleEnemySpeedRandom = playH / (SP.simpleEnemyCrossTimeRand * 60); + S.simpleEnemySpeedPerWave = playH / (SP.simpleEnemyCrossTimeDecay * 60); + S.simpleEnemyMaxSpeed = playH / (15 * 60); + S.heavyEnemySpeedBase = playH / (SP.heavyEnemyCrossTime * 60); + S.heavyEnemySpeedRandom = playH / (SP.heavyEnemyCrossTimeRand * 60); + S.projectileSpeed = H / (SP.projectileCrossTime * 60); + S.gridSpeed = SP.gridSpeed * H / 1000; + + for (const k of ["projectileTrailLength","particleHit","particleMiss", + "particleDestroySimple","particleDestroyHeavy","particleArmorBreak", + "flashDuration","shakeHitDuration","shakeMissDuration","shakeDamageDuration", + "trailMaxLength","trailDecay","trailAlpha"]) { + S[k] = C[k]; + } + + for (const k of ["missTargetYRange","projectileTrailLength","projectileTrailDecay", + "projectileTrailAlpha","projectileCircleAlpha","enemyPulseRate","enemyAlphaBase", + "enemyAlphaRange","enemyGlowPulse","particleDecayBase","particleDecayRange", + "particleDamping"]) { + S[k] = R[k]; + } + + S.inputShootDelay = T.inputShootDelay; + S.glitchDuration = T.glitchDuration; + + applyOverlayScale(W, H); +} + +function applyOverlayScale(W, H) { + const title = document.querySelector(".screen-title"); + if (title) title.style.fontSize = Math.round(40 * H / 1000) + "px"; + const subs = document.querySelectorAll(".screen-subtitle"); + for (let el of subs) el.style.fontSize = Math.round(18 * H / 1000) + "px"; + + const overlay = document.getElementById("ui-overlay"); + if (overlay) { + overlay.style.padding = Math.round(12 * H / 1000) + "px " + Math.round(16 * W / 1000) + "px"; + overlay.style.fontSize = Math.round(14 * H / 1000) + "px"; + } + const inputDisplay = document.getElementById("input-display"); + if (inputDisplay) { + inputDisplay.style.padding = Math.round(14 * H / 1000) + "px " + Math.round(16 * W / 1000) + "px"; + } + const typed = document.getElementById("typed-text"); + if (typed) { + typed.style.fontSize = Math.round(20 * H / 1000) + "px"; + typed.style.minHeight = Math.round(28 * H / 1000) + "px"; + } + const chForm = document.getElementById("channel-form"); + if (chForm) { + chForm.querySelector(".screen-title").style.fontSize = Math.round(80 * H / 1000) + "px"; + const inp = chForm.querySelector("input"); + if (inp) { + inp.style.fontSize = Math.round(22 * H / 1000) + "px"; + inp.style.padding = Math.round(12 * H / 1000) + "px " + Math.round(20 * W / 1000) + "px"; + inp.style.width = Math.round(300 * W / 1000) + "px"; + } + const btn = chForm.querySelector("button"); + if (btn) { + btn.style.fontSize = Math.round(18 * H / 1000) + "px"; + btn.style.padding = Math.round(10 * H / 1000) + "px " + Math.round(40 * W / 1000) + "px"; + btn.style.minWidth = Math.round(260 * W / 1000) + "px"; + } + const link = document.getElementById("game-link"); + if (link) link.style.fontSize = Math.round(16 * H / 1000) + "px"; + const note = chForm.querySelector(".obs-note"); + if (note) note.style.fontSize = Math.round(16 * H / 1000) + "px"; + } + const goTitle = document.getElementById("gameover-title"); + if (goTitle) goTitle.style.fontSize = Math.round(32 * H / 1000) + "px"; +} + +function drawGrid() { + ctx.strokeStyle = CONFIG.colors.primaryGrid; + ctx.lineWidth = 1; + const gridSize = S.gridSize; + bgOffset = (bgOffset + S.gridSpeed) % gridSize; + for (let x = 0; x <= W; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, H); + ctx.stroke(); + } + for (let y = bgOffset; y <= H; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(W, y); + ctx.stroke(); + } +} + +function spawnParticles(x, y, color, count) { + for (let i = 0; i < count; i++) { + particles.push(new Particle(x, y, color)); + } +} + +function triggerShake(amount, duration) { + shakeAmount = amount; + shakeTimer = duration; +}