FalloutTower/td.html

10176 lines
526 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>HTML Tower Defense</title>
<script src="assets/towers.js?v=2"></script>
<script src="assets/intel.js"></script>
<style>
/*
==========================================================================
MASTER AI DEVELOPMENT ROADMAP (UPDATED)
==========================================================================
✅ COMPLETED (Built into engine)
[1] [TOWER ANTI-AIR LIMITS] - Towers 1, 2, and 3 ignore flyers.
[2] [BONUS BOUNTY UPGRADES] - bonus_bounty logic grants extra caps.
[3] [DYNAMIC TACTICAL TOOLTIPS] - Matchups (Strong vs/Weak vs) display.
[4] [FACTION UPGRADE PATHS] - All 40 towers have 4-tier upgrade arrays mapped out.
[11] [VICTORY / POST-GAME SCREEN] - Game over screen calculates MVP, kills, caps, accuracy.
[13] [UI QOL: CANCEL BARRAGE] - Right-click and Escape key cancel targeting modes.
[14] [DYNAMIC TOP-BAR INTEL BRIEF] - Announcer system operational and reading intel.js.
⚠️ PARTIALLY COMPLETED (Needs Finishing)
[9] [ADVANCED AUDIO MANAGEMENT] - Global sounds exist, but volume sliders are missing.
❌ STILL TO DO (Unfinished / Missing)
[5] [CUSTOM MAP EDITOR - FILE I/O] - Missing browser download for .wd files and file input to load.
[6] [WASTELAND ASSAULT MODE] - UI toggle exists, but core engine mechanics to flip roles are missing.
[7 & 8] [PERSISTENT SAVE/LOAD SYSTEM] - Lacks automatic localStorage system to save/load progress.
[10] [CURATED WAVE CONFIGURATIONS] - Missing custom wave scripter; relying on dynamic budget.
[12] [ASSET MANAGEMENT] - Missing sprite rendering; using basic Canvas geometry.
==========================================================================
*/
@font-face {
font-family: 'TTChocolates';
src: url('assets/TT Chocolates ExtraLight.otf') format('opentype');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Overseer';
src: url('assets/OverseerItalic.otf') format('opentype');
font-weight: normal;
font-style: normal;
}
* { box-sizing: border-box; }
body, html { margin: 0; padding: 0; width: 100vw; height: 100vh; overflow: hidden; background: #000; font-family: 'Segoe UI', Tahoma, Verdana, sans-serif; }
#game-container { width: 100vw !important; height: 100vh !important; display: flex; flex-direction: column; }
/* Start Screen */
#start-screen { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 600px; max-width: 95vw; padding: 20px 40px 40px 40px; background-color: #2c3322; background-image: url('assets/images/green-dust-and-scratches.png'), url('assets/images/snow.png'), url('assets/images/white-wall-3-2.png'), url('assets/images/scratch.png'), url('assets/images/mooning.png'); background-repeat: repeat, repeat, repeat, repeat; background-blend-mode: overlay, overlay, overlay, overlay; border-radius: 5px; border: 3px solid #1a1d13; border-top-color: #6b7552; border-left-color: #6b7552; box-shadow: 0 15px 50px rgba(0,0,0,0.9), inset 0 0 20px rgba(0,0,0,0.6); display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 1000; }
/* Title Neon Ignition Sequence (Yellow/White Bloom) */
@keyframes title-neon-flicker {
0%, 4.9% { color: #111111; text-shadow: none; }
5%, 6% { color: #f2d57e; text-shadow: 0 0 10px rgba(242, 213, 126, 0.8), 2px 2px 0px #000; }
6.1%, 12.9% { color: #111111; text-shadow: none; }
13%, 15% { color: #f2d57e; text-shadow: 0 0 15px rgba(242, 213, 126, 0.8), 0 0 5px #fff, 2px 2px 0px #000; }
15.1%, 16.9%{ color: #111111; text-shadow: none; }
17%, 18% { color: #aa8a33; text-shadow: 0 0 5px rgba(242, 213, 126, 0.8), 2px 2px 0px #000; }
18.1%, 24.9%{ color: #111111; text-shadow: none; }
25%, 28% { color: #f2d57e; text-shadow: 0 0 20px rgba(242, 213, 126, 0.8), 0 0 10px #fff, 2px 2px 0px #000; }
28.1%, 40% { color: #111111; text-shadow: none; }
100% { color: #f2d57e; text-shadow: 0 0 30px rgba(242, 213, 126, 0.8), 0 0 15px rgba(242, 213, 126, 0.5), 0 0 5px #fff, 2px 2px 0px #000; }
}
/* Base Animated Title */
#start-screen h1 {
position: relative; /* Needed to anchor the texture overlay */
font-size: clamp(30px, 8vw, 55px); color: #f2d57e; margin-top: 0; margin-bottom: 30px; text-transform: uppercase;
text-align: center; white-space: nowrap; /* Forces text to stay on one line and centered! */
text-shadow: 0 0 30px rgba(242, 213, 126, 0.8), 0 0 15px rgba(242, 213, 126, 0.5), 0 0 5px #fff, 2px 2px 0px #000;
font-family: 'Overseer', sans-serif; font-weight: normal; letter-spacing: 2px;
-webkit-text-stroke: 1px #000;
animation: title-neon-flicker 3.0s ease-in-out 1s both;
}
/* The Texture Layer perfectly overlaid on top of the lit text! */
#start-screen h1::after {
content: 'WASTELAND DEFENSE';
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
/* Using a darker base color to ensure the fibers cast a solid black shadow onto the yellow text */
background-color: #888;
background-image: url('assets/images/light-paper-fibers.png'); background-repeat: repeat;
-webkit-background-clip: text; color: transparent;
/* Multiply forces the dark fibers to permanently stain the bright yellow text below it */
mix-blend-mode: multiply;
pointer-events: none; -webkit-text-stroke: 0px transparent;
}
/* Mechanical Dropdowns */
/* NEW: Power-On and Active Animations */
@keyframes startup-flicker {
0% { opacity: 0; }
10% { opacity: 1; }
20% { opacity: 0; }
30% { opacity: 1; }
40% { opacity: 0; }
60% { opacity: 1; }
70% { opacity: 0.5; }
100% { opacity: 1; }
}
@keyframes active-blink {
0%, 30%, 60%, 100% {
background-color: var(--btn-on, #f2d57e); color: #222222;
box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 10px rgba(242, 213, 126, 0.4);
}
15%, 45% {
background-color: var(--btn-off, #e0dcc8); color: #444444;
box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 3px 3px 6px rgba(0,0,0,0.8);
}
}
/* ========================================= */
/* ISOLATED MAIN MENU STYLES & ANIMATIONS */
/* ========================================= */
/* 1. Main Menu Animations */
@keyframes mm-flicker-in {
0% { opacity: 0; } 20% { opacity: 0.8; } 40% { opacity: 0; } 60% { opacity: 1; } 80% { opacity: 0.5; } 100% { opacity: 1; }
}
@keyframes mm-flicker-in-lit {
/* OFF STATE: Hard cuts with uneven delays between sparks */
0%, 11%, 16%, 39%, 44%, 49%, 54%, 79%, 84%, 87% {
background-color: #666; color: #333; box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 2px 2px 5px rgba(0,0,0,0.9);
}
/* ON STATE: Violent, sharp electrical sputters before catching at 88% */
12%, 15%, 40%, 43%, 50%, 53%, 80%, 83%, 88%, 100% {
background-color: #f2d57e; color: #222; box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 10px rgba(242, 213, 126, 0.6);
}
}
@keyframes mm-fade-in-lit {
0% { background-color: #666; color: #333; box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 2px 2px 5px rgba(0,0,0,0.9); }
100% { background-color: #f2d57e; color: #222; box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 10px rgba(242, 213, 126, 0.6); }
}
@keyframes mm-special-blink {
/* Sharp, non-fading hardware blinks */
0%, 49% { background-color: #f2d57e; color: #222; box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 10px rgba(242, 213, 126, 0.6); }
50%, 99% { background-color: #666; color: #333; box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 2px 2px 5px rgba(0,0,0,0.9); }
100% { background-color: #f2d57e; color: #222; box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 10px rgba(242, 213, 126, 0.6); }
}
/* 2. Menu Headers */
.menu-header {
color: #aaa; font-weight: bold; font-size: 1.4vh; text-transform: uppercase;
margin-bottom: 5px; text-shadow: none; letter-spacing: 1px; line-height: 1;
}
/* 3. Base Physical Styling for all Menu Inputs */
.menu-select, #btn-start, .special-label {
appearance: none; -webkit-appearance: none; -moz-appearance: none;
position: relative; z-index: 1; overflow: visible; /* Changed to visible so glow can bleed out */
border: 2px solid #222; border-top: 2px solid #555; border-left: 2px solid #444; border-bottom: 3px solid #0a0a0a; border-right: 3px solid #111;
box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 2px 2px 5px rgba(0,0,0,0.9);
font-family: Arial, Helvetica, sans-serif; font-weight: bold; text-transform: uppercase; cursor: pointer;
transition: transform 0.05s, box-shadow 0.05s;
}
/* Active Click Physics */
.menu-select:active, #btn-start:active:not(:disabled), .special-label:active, .special-chk:checked + .special-label:active {
border-top: 3px solid #000 !important; border-left: 3px solid #000 !important; border-bottom: 2px solid #444 !important; border-right: 2px solid #444 !important;
box-shadow: inset 2px 2px 10px rgba(0,0,0,0.9), 0px 0px 0px rgba(0,0,0,0) !important; transform: translateY(2px) !important;
}
/* 4. Dropdowns */
.menu-select {
display: block; /* Removes Flexbox to fix text-wrapping bugs */
text-align: center;
margin-bottom: 20px;
font-size: 1.8vh;
padding: 10px 35px; /* Equal left/right padding perfectly centers text between the edges */
width: 350px; max-width: 80%;
background-color: #f2d57e; color: #222222; text-shadow: none;
background-image: url('data:image/svg+xml;utf8,<svg fill="%23222" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M7 10l5 5 5-5z"/></svg>');
background-repeat: no-repeat;
background-position: right 10px center;
line-height: 1.3;
}
.menu-select option {
background: #111111 !important;
color: #f2d57e !important;
text-shadow: none;
font-weight: bold;
}
/* Override browser highlight to keep background black, but change text color to green on hover/focus */
.menu-select option:checked,
.menu-select option:hover,
.menu-select:focus > option:checked {
background: #111111 linear-gradient(0deg, #111111 0%, #111111 100%) !important;
color: #55ff55 !important;
box-shadow: 0 0 10px 100px #111111 inset !important;
}
/* NEW: Custom Div Dropdown System */
.custom-select-wrapper {
position: relative;
width: 350px;
max-width: 80%;
margin: 0 auto 20px auto;
}
.custom-select-options {
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: #111111;
border: 2px solid #555;
z-index: 2000;
display: none;
box-shadow: 0 5px 15px rgba(0,0,0,0.9);
}
.custom-select-options.open {
display: block;
}
.custom-option {
padding: 10px;
color: #f2d57e;
font-family: Arial, Helvetica, sans-serif;
font-size: 1.8vh;
font-weight: bold;
text-transform: uppercase;
text-align: center;
cursor: pointer;
border-bottom: 1px solid #333;
}
.custom-option:last-child {
border-bottom: none;
}
.custom-option:hover {
color: #55ff55;
}
/* Arrow for the currently selected option */
.custom-option.selected::before {
content: '► ';
margin-right: 5px;
/* Color property removed so it dynamically inherits yellow/green from the parent text */
}
/* 5. Play Button */
#btn-start {
margin-top: 0px; padding: 12px 40px; font-size: 2.2vh; border-radius: 3px;
/* Removed Courier New so it correctly matches the Arial UI buttons */
background-color: #666; color: #333; text-shadow: none; /* Dark unlit */
}
/* When JS applies the ready class, it stays lit */
#btn-start.menu-play-ready {
background-color: #f2d57e; color: #222222; text-shadow: none;
box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 10px rgba(242, 213, 126, 0.6);
/* Removed animation so it smoothly transitions without flickering */
}
/* 6. S.P.E.C.I.A.L. Buttons */
.special-chk { position: absolute; opacity: 0; pointer-events: none; }
/* Apply the animation permanently to ALL labels so their internal clocks are forever synced */
.special-label {
display: inline-block; width: 4vh; height: 4vh; line-height: 3.5vh; text-align: center;
margin: 0 4px 0px 4px;
font-size: 2vh; border-radius: 3px;
background-color: #666; color: #333; text-shadow: none;
vertical-align: top;
animation: mm-special-blink 0.5s infinite;
}
/* Checked State: Locks solid lit (Using !important to override the animation without stopping the clock) */
.special-chk:checked + .special-label {
background-color: #f2d57e !important; color: #222 !important; text-shadow: none !important;
box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 10px rgba(242, 213, 126, 0.6) !important;
}
/* Disabled State: Dark unpowered casing (Using !important to override the animation) */
.special-chk:disabled:not(:checked) + .special-label {
background-color: #666 !important; color: #333 !important;
border-top-color: #444 !important; border-left-color: #444 !important; border-bottom-color: #111 !important; border-right-color: #222 !important;
text-shadow: none !important; cursor: not-allowed !important; box-shadow: inset 0 0 10px rgba(0,0,0,0.8) !important;
}
/* Map Section (Now 81% height to make room for the Top Bar) */
#td-board { height: 81vh !important; width: 100vw !important; background: #1a1a1a; position: relative; display: none; overflow: hidden; }
.demo-blur { filter: blur(4px) brightness(0.6); }
#td-canvas { width: 100% !important; height: 100% !important; display: block; z-index: 1; pointer-events: auto; }
/* NEW: Environmental Effect Animations */
@keyframes env-pan-left { from { background-position: 0px 0px; } to { background-position: -2000px 0px; } }
@keyframes env-pan-right { from { background-position: 0px 0px; } to { background-position: 2000px 0px; } }
@keyframes env-pan-up { from { background-position: 0px 0px; } to { background-position: 0px -2000px; } }
@keyframes env-pan-down { from { background-position: 0px 0px; } to { background-position: 0px 2000px; } }
@keyframes env-fade { 0%, 100% { opacity: 0; } 50% { opacity: var(--env-opacity, 1.0); } }
/* NEW: Damage Screen Shake */
@keyframes damage-shake {
0% { transform: translate(1px, 1px) rotate(0deg); }
10% { transform: translate(-1px, -2px) rotate(-1deg); }
20% { transform: translate(-3px, 0px) rotate(1deg); }
30% { transform: translate(3px, 2px) rotate(0deg); }
40% { transform: translate(1px, -1px) rotate(1deg); }
50% { transform: translate(-1px, 2px) rotate(-1deg); }
60% { transform: translate(-3px, 1px) rotate(0deg); }
70% { transform: translate(3px, 1px) rotate(-1deg); }
80% { transform: translate(-1px, -1px) rotate(1deg); }
90% { transform: translate(1px, 2px) rotate(0deg); }
100% { transform: translate(1px, -2px) rotate(-1deg); }
}
.shake-active { animation: damage-shake 0.4s; }
/* Control Panel Section (15% height) */
#control-panel {
width: 100vw !important; height: 15vh !important; min-height: 15vh !important; max-height: 15vh !important;
background-color: #2c3322;
background-image:
url('assets/images/green-dust-and-scratches.png'),
url('assets/images/snow.png'),
url('assets/images/white-wall-3-2.png'),
url('assets/images/scratch.png'),
url('assets/images/mooning.png');
background-repeat: repeat, repeat, repeat, repeat;
background-blend-mode: overlay, overlay, overlay, overlay;
border-top: 3px solid #6b7552; border-bottom: none !important; /* Removed thick bottom border to gain space */
box-shadow: 0 -4px 15px rgba(0,0,0,0.9);
position: relative; z-index: 10;
display: none; padding: 0.5vh 1vw; box-sizing: border-box;
}
/* Authentic Nixie Tube Container */
.nixie-board { display: flex; gap: 4px; align-items: center; justify-content: flex-end; }
/* The Glass Vacuum Tube & Inner Gas Glow */
.nixie-tube {
display: inline-block; position: relative;
/* Soft ambient gas glow inside the glass */
background: radial-gradient(circle at center, rgba(180, 40, 0, 0.2) 0%, #030100 80%);
border-radius: 4px 4px 2px 2px; border: 1px solid #1a1a1a; border-bottom: 2px solid #000;
width: 1.4vw; height: 3.2vh;
box-shadow: inset 1px 1px 3px rgba(255,255,255,0.05), inset -2px -2px 5px rgba(0,0,0,0.9), 1px 1px 2px rgba(0,0,0,0.6);
/* Assigning the newly loaded TT Chocolates font */
font-family: 'TTChocolates', sans-serif; font-size: 2.8vh; font-weight: normal;
text-align: center; line-height: 3.2vh;
overflow: hidden;
}
/* The Hex Mesh & Glass Curve (Sits IN FRONT of everything) */
.nixie-tube::after {
content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='14' viewBox='0 0 8 14'%3E%3Cpath d='M0 3.5L4 1.2l4 2.3v4.6L4 10.4L0 8.1V3.5z M4 10.4l4 2.3v4.6l-4 2.3l-4-2.3v-4.6L4 10.4z' fill='none' stroke='rgba(0,0,0,0.4)' stroke-width='0.8'/%3E%3C/svg%3E");
z-index: 5; pointer-events: none;
/* Removed the white linear-gradient to kill the gray smudge */
}
/* Individual Wire Filaments (Dormant State) */
.nixie-wire {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
z-index: 1;
display: block; /* Removed flexbox for manual centering */
text-align: center;
line-height: 3.6vh; /* Pushes the text down to center it */
color: transparent;
opacity: 0; /* Hides the bulky outline blob completely */
transition: opacity 0.1s;
}
/* The Active Glowing Wire & 5 Random Flicker Profiles */
@keyframes nixie-flicker-1 {
0% { opacity: 0; } 10% { opacity: 0.8; } 15% { opacity: 0.1; } 25% { opacity: 0.9; } 30% { opacity: 0.2; } 45% { opacity: 0.6; } 100% { opacity: 1; }
}
@keyframes nixie-flicker-2 {
0% { opacity: 0; } 5% { opacity: 0.5; } 10% { opacity: 0; } 30% { opacity: 1; } 40% { opacity: 0.3; } 60% { opacity: 0.8; } 100% { opacity: 1; }
}
@keyframes nixie-flicker-3 {
0% { opacity: 0; } 20% { opacity: 1; } 25% { opacity: 0.2; } 35% { opacity: 0.7; } 50% { opacity: 0.1; } 65% { opacity: 0.9; } 100% { opacity: 1; }
}
@keyframes nixie-flicker-4 {
0% { opacity: 0; } 15% { opacity: 0.6; } 20% { opacity: 0.1; } 40% { opacity: 0.8; } 45% { opacity: 0.4; } 70% { opacity: 0.9; } 100% { opacity: 1; }
}
@keyframes nixie-flicker-5 {
0% { opacity: 0; } 8% { opacity: 1; } 18% { opacity: 0.2; } 28% { opacity: 0.9; } 38% { opacity: 0.3; } 58% { opacity: 0.8; } 100% { opacity: 1; }
}
.nixie-wire.warm-up-1 { animation: nixie-flicker-1 2.2s ease-in 0.4s both !important; }
.nixie-wire.warm-up-2 { animation: nixie-flicker-2 2.6s ease-in 0.6s both !important; }
.nixie-wire.warm-up-3 { animation: nixie-flicker-3 2.0s ease-in 0.3s both !important; }
.nixie-wire.warm-up-4 { animation: nixie-flicker-4 2.8s ease-in 0.8s both !important; }
.nixie-wire.warm-up-5 { animation: nixie-flicker-5 2.4s ease-in 0.5s both !important; }
.nixie-wire.active {
z-index: 3;
color: #fff3d4;
opacity: 1;
-webkit-text-stroke: 0px;
text-shadow:
0 0 2px #ffaa00,
0 0 4px #ff5500,
0 0 8px #ff2200,
0 0 15px #ff0000;
/* Animation removed from here, applied via JS classes instead */
}
/* Gas Ignition Flicker Animation */
@keyframes neon-flicker {
0% { opacity: 0.1; text-shadow: none; color: rgba(255,255,255,0.1); }
10% { opacity: 1.0; text-shadow: 0 0 10px #ff0000; }
20% { opacity: 0.2; text-shadow: none; color: rgba(255,255,255,0.2); }
30% { opacity: 0.8; text-shadow: 0 0 5px #ff5500; }
40% { opacity: 0.1; text-shadow: none; }
50% { opacity: 1.0; text-shadow: 0 0 2px #ffaa00, 0 0 5px #ff5500, 0 0 10px #ff2200; }
100% { opacity: 1.0; }
}
.nixie-wire.igniting { animation: neon-flicker 0.3s ease-out forwards; }
/* Raised Metal Plate for Static Symbols (%) */
.nixie-plate {
display: inline-block; background: linear-gradient(to bottom, #333, #222); color: #ffaa00;
font-family: 'TTChocolates', sans-serif; font-weight: bold; font-size: 2vh;
border-radius: 2px; border: 1px solid #1a1a1a; border-top: 2px solid #555; border-left: 2px solid #555;
box-shadow: 1px 1px 3px rgba(0,0,0,0.6); text-shadow: 1px 1px 0px #000;
width: 1.2vw; height: 3.2vh; text-align: center; line-height: 3vh; margin-left: 2px;
}
/* Critical Health Warning Pulse */
@keyframes critical-pulse { 0% { opacity: 0.4; } 100% { opacity: 1; } }
.nixie-tube.critical .nixie-wire.active {
color: rgba(255, 150, 150, 0.9);
text-shadow: 0 0 4px #ff0000, 0 0 10px #cc0000, 0 0 20px #880000;
animation: critical-pulse 0.5s infinite alternate;
}
/* Layout for Panel */
.panel-col { display: flex; flex-direction: column; justify-content: flex-start; height: 100%; overflow: hidden; padding-top: 0; }
.panel-title { color: #aaa; font-weight: bold; font-size: 1.4vh; text-transform: uppercase; border-bottom: 1px solid #555; padding-bottom: 0.2vh; margin-top: 0; margin-bottom: 0.5vh; letter-spacing: 1px; line-height: 1; }
/* CRITICAL FIX: Shrunk font size and line height so it doesn't push off the screen */
.stat-line { color: #ddd; font-size: 1.4vh; margin-bottom: 0.2vh; line-height: 1.1; }
.highlight { color: #4caf50; font-weight: bold; }
/* Buttons - Forced Horizontal */
.build-grid { display: flex; flex-wrap: nowrap; gap: 0.5vw; width: 100%; justify-content: space-between; margin-top: 0.2vh; align-content: flex-start; }
/* Button Flicker Animations */
@keyframes flicker-on {
0% { opacity: 0.2; text-shadow: none; }
10% { opacity: 1; text-shadow: 0 0 8px #ff8800; }
20% { opacity: 0.2; text-shadow: none; }
30% { opacity: 1; text-shadow: 0 0 5px #ff8800; }
50% { opacity: 0.2; text-shadow: none; }
100% { opacity: 1; text-shadow: 0 0 8px #ff8800, 0 0 2px #fff; }
}
/* ---------------------------------------------------- */
/* DIRTY INCANDESCENT PUSH BUTTONS */
/* ---------------------------------------------------- */
/* Global CSS Variables for the Dashboard */
:root {
--btn-off: #666666; /* Dark unpowered hardware */
--btn-on: #f2d57e; /* Yellow backlit */
--power-level: 1; /* Defaults to full power */
}
/* Base Bezel & Structure (Always Off by default) */
.cmd-btn, .sys-btn {
position: relative;
background-color: var(--btn-off) !important;
/* ENGRAVED TEXT GLOBALLY */
color: #333333 !important;
text-shadow: none !important;
/* MODERATE CASING: Reduced from 4px back to 2px/3px */
border: 2px solid #222 !important;
border-top: 2px solid #555 !important;
border-left: 2px solid #444 !important;
border-bottom: 3px solid #0a0a0a !important;
border-right: 3px solid #111 !important;
height: 3.2vh; padding: 0 0.2vw;
display: flex !important; align-items: center !important; justify-content: center !important;
font-size: 1.2vh; font-family: Arial, Helvetica, sans-serif; font-weight: bold;
text-transform: uppercase; flex: 1; text-align: center; cursor: pointer !important; border-radius: 3px;
white-space: nowrap; text-overflow: ellipsis; /* Removed overflow: hidden to allow the glow to bleed out! */
min-width: 0; /* CRITICAL FIX: Stops Flexbox from expanding when text gets longer */
/* MODERATE DROP SHADOW */
box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 2px 2px 5px rgba(0,0,0,0.9) !important;
transition: transform 0.05s, box-shadow 0.05s !important;
opacity: 1 !important;
z-index: 1;
}
/* The Hidden Backlight Layer */
.cmd-btn::before, .sys-btn::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
background-color: var(--btn-on);
/* Added the 10px outer glow here! */
box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 10px rgba(242, 213, 126, 0.6);
opacity: 0; /* Off by default */
z-index: -1;
transition: opacity 0.05s; /* Very fast transition for organic sputtering */
}
/* ENABLED STATE (Backlight ON) - Regulated by --power-level */
.cmd-btn:not(:disabled):not(.unlit)::before,
.sys-btn:not(:disabled):not(.unlit)::before {
opacity: var(--power-level);
}
.cmd-btn:not(:disabled):not(.unlit),
.sys-btn:not(:disabled):not(.unlit) {
color: #222222 !important;
text-shadow: none !important;
}
/* UNAVAILABLE / LOCKED / DISABLED STATE (Dark unpowered hardware) */
.cmd-btn:disabled, .sys-btn:disabled, .cmd-btn.unlit, .sys-btn.unlit {
background-color: #555 !important;
color: #222 !important;
-webkit-text-fill-color: #222 !important; /* Force override any weird inline colors */
border-top: 2px solid #444 !important;
border-left: 2px solid #444 !important;
border-bottom: 3px solid #111 !important;
border-right: 3px solid #222 !important;
text-shadow: none !important;
cursor: not-allowed !important;
box-shadow: inset 0 0 10px rgba(0,0,0,0.8) !important;
}
/* Completely kill the internal lightbulb if disabled */
.cmd-btn:disabled::before, .sys-btn:disabled::before, .cmd-btn.unlit::before, .sys-btn.unlit::before {
display: none !important;
}
/* GAME OVER STATE (Kill all power to buttons and Nixies) */
body.game-over-dead .cmd-btn, body.game-over-dead .sys-btn {
background-color: #333 !important;
color: #111 !important;
-webkit-text-fill-color: #111 !important; /* Force override any weird inline colors */
border-top: 2px solid #222 !important; border-left: 2px solid #222 !important; border-bottom: 3px solid #000 !important; border-right: 3px solid #111 !important;
box-shadow: inset 0 0 10px rgba(0,0,0,0.9) !important;
text-shadow: none !important;
pointer-events: none !important;
transition: all 2s ease-out !important;
}
body.game-over-dead .cmd-btn::before, body.game-over-dead .sys-btn::before {
opacity: 0 !important;
transition: opacity 2s ease-out !important;
}
/* Soft Emergency Breathing Animation */
@keyframes emergency-pulse {
0% { background-color: rgba(204, 68, 68, 0.2); box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 5px rgba(200, 50, 50, 0.2); }
50% { background-color: rgba(204, 68, 68, 0.9); box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 15px rgba(255, 50, 50, 0.8); }
100% { background-color: rgba(204, 68, 68, 0.2); box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 5px rgba(200, 50, 50, 0.2); }
}
/* EMERGENCY RED POWER (Keep Restart and Main Menu buttons alive and pulsing) */
body.game-over-dead #ui-btn-restart, body.game-over-dead #ui-btn-exit {
background-color: #2a1111 !important; /* Dark, muddy reddish-brown */
color: #222 !important;
-webkit-text-fill-color: #222 !important;
pointer-events: auto !important; /* Keep them clickable! */
transition: none !important; /* Prevents fighting with the fade-out */
}
body.game-over-dead #ui-btn-restart::before, body.game-over-dead #ui-btn-exit::before {
display: block !important; /* Ensures the lightbulb isn't hidden if disabled */
opacity: 1 !important; /* Force opacity on, letting the animation control the alpha colors */
animation: emergency-pulse 2s ease-in-out infinite !important;
transition: none !important;
}
@keyframes vats-breathe {
0%, 100% { opacity: 0.3; box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 5px rgba(242, 213, 126, 0.4); }
50% { opacity: 1.0; box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 20px rgba(242, 213, 126, 1.0); }
}
.vats-charged-glow { color: #222 !important; background-color: var(--btn-off) !important; text-shadow: none !important; }
.vats-charged-glow::before {
display: block !important;
animation: vats-breathe 2s ease-in-out infinite !important;
}
/* Fade out Nixie Tubes */
body.game-over-dead .nixie-wire.active {
color: #111 !important; text-shadow: none !important;
transition: color 2s ease-out, text-shadow 2s ease-out !important;
}
/* PRESSED STATE (Straight Inward) */
.cmd-btn:active:not(:disabled):not(.unlit), .sys-btn:active:not(:disabled):not(.unlit) {
border-top: 3px solid #000 !important;
border-left: 3px solid #000 !important;
border-bottom: 2px solid #444 !important;
border-right: 2px solid #444 !important;
/* Heavy inner shadow, completely killed outer drop shadow to mimic pressing flush into panel */
box-shadow: inset 2px 2px 10px rgba(0,0,0,0.9), 0px 0px 0px rgba(0,0,0,0) !important;
transform: scale(0.98) !important; /* Shrinks straight inward slightly */
}
/* ---------------------------------------------------- */
/* FLUORESCENT JAGGED FLICKER ANIMATIONS */
/* ---------------------------------------------------- */
@keyframes btn-flicker-1 { 0% { opacity: 0; } 10% { opacity: 0.8; } 12% { opacity: 0; } 25% { opacity: 1; } 28% { opacity: 0; } 45% { opacity: 0.9; } 48% { opacity: 0; } 60% { opacity: 1; } 100% { opacity: 1; } }
@keyframes btn-flicker-2 { 0% { opacity: 0; } 5% { opacity: 0.4; } 8% { opacity: 0; } 30% { opacity: 1; } 32% { opacity: 0; } 50% { opacity: 0.8; } 55% { opacity: 0; } 65% { opacity: 1; } 100% { opacity: 1; } }
@keyframes btn-flicker-3 { 0% { opacity: 0; } 15% { opacity: 1; } 18% { opacity: 0; } 35% { opacity: 0.9; } 38% { opacity: 0; } 55% { opacity: 0.6; } 58% { opacity: 0; } 70% { opacity: 1; } 100% { opacity: 1; } }
@keyframes btn-flicker-4 { 0% { opacity: 0; } 20% { opacity: 0.6; } 22% { opacity: 0; } 40% { opacity: 1; } 44% { opacity: 0; } 60% { opacity: 0.7; } 63% { opacity: 0; } 80% { opacity: 1; } 100% { opacity: 1; } }
@keyframes btn-flicker-5 { 0% { opacity: 0; } 8% { opacity: 1; } 12% { opacity: 0; } 28% { opacity: 0.9; } 32% { opacity: 0; } 48% { opacity: 0.5; } 52% { opacity: 0; } 68% { opacity: 1; } 100% { opacity: 1; } }
@keyframes btn-flicker-fade { 0% { opacity: 0; } 100% { opacity: 1; } }
.warm-up-1::before { animation: btn-flicker-1 0.6s step-end 0.1s both !important; }
.warm-up-2::before { animation: btn-flicker-2 0.7s step-end 0.15s both !important; }
.warm-up-3::before { animation: btn-flicker-3 0.55s step-end 0.05s both !important; }
.warm-up-4::before { animation: btn-flicker-4 0.75s step-end 0.2s both !important; }
.warm-up-5::before { animation: btn-flicker-5 0.65s step-end 0.1s both !important; }
.warm-up-fade::before { animation: btn-flicker-fade 1s ease-in both !important; }
/* --- NEW: Wasteland Guide Icon & Book Modal --- */
.guide-icon {
position: absolute; cursor: pointer; z-index: 1500;
filter: drop-shadow(3px 5px 5px rgba(0,0,0,0.8));
transition: transform 0.2s ease;
}
.guide-icon:hover { transform: scale(1.15) rotate(-3deg); }
/* Menu Icon: Bottom Right of the Menu Box */
#guide-icon-menu { bottom: 15px; right: 15px; width: 60px; height: auto; }
/* Game Icon: Top Right overlapping the Top Bar */
#guide-icon-game { top: 0.5vh; right: 2vw; width: 45px; height: auto; }
#guide-modal-overlay {
display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.8); z-index: 100000; justify-content: center; align-items: center;
}
#guide-book {
display: flex; width: 80vw; max-width: 900px; height: 75vh;
background-color: #d8ceb9; /* Old parchment color */
background-image: url('assets/images/scratch.png'); background-blend-mode: multiply;
border-radius: 5px; box-shadow: 0 20px 50px rgba(0,0,0,0.9), inset 0 0 40px rgba(100,80,50,0.5);
color: #2b2013; font-family: 'Courier New', Courier, monospace;
position: relative;
}
/* Book Binding (Spine shadow) */
#guide-book::after {
content: ''; position: absolute; left: 50%; top: 0; bottom: 0; width: 40px; transform: translateX(-50%);
background: linear-gradient(to right, rgba(0,0,0,0) 0%, rgba(0,0,0,0.2) 20%, rgba(0,0,0,0.3) 50%, rgba(0,0,0,0.2) 80%, rgba(0,0,0,0) 100%);
pointer-events: none;
}
.guide-page {
flex: 1; padding: 40px; overflow-y: auto; box-sizing: border-box; position: relative;
}
.guide-page h2 { text-transform: uppercase; font-family: 'Overseer', sans-serif; font-size: 3.5vh; border-bottom: 2px solid #2b2013; padding-bottom: 5px; margin-top:0; text-align: center; }
.guide-page h3 { text-transform: uppercase; margin-bottom: 5px; border-bottom: 1px dashed #555; display: inline-block;}
.guide-toc li { margin-bottom: 15px; font-weight: bold; cursor: pointer; text-decoration: underline; }
.guide-toc li:hover { color: #500; }
.close-guide { position: absolute; top: 15px; right: 20px; font-size: 35px; font-weight: bold; cursor: pointer; color: #2b2013; z-index: 10; }
/* NEW: Pause Menu Dimming & Flashing */
/* 1. Unpower all OTHER buttons */
body.is-paused .cmd-btn:not(#ui-btn-pause),
body.is-paused .sys-btn:not(#ui-btn-pause) {
background-color: #555 !important;
color: #222 !important;
-webkit-text-fill-color: #222 !important;
border-top: 2px solid #444 !important;
border-left: 2px solid #444 !important;
border-bottom: 3px solid #111 !important;
border-right: 3px solid #222 !important;
text-shadow: none !important;
box-shadow: inset 0 0 10px rgba(0,0,0,0.8) !important;
pointer-events: none !important;
}
/* 2. Turn off the hidden lightbulb for all OTHER buttons */
body.is-paused .cmd-btn:not(#ui-btn-pause)::before,
body.is-paused .sys-btn:not(#ui-btn-pause)::before {
display: none !important;
}
/* 3. The Flashing Animation for the Lightbulb (0.75s blink -> 1.5s total cycle) */
@keyframes pause-light-blink {
0%, 49.9% { opacity: 1; }
50%, 100% { opacity: 0; }
}
/* 4. Force the CONTINUE button to have the fully dark, shadowless base state */
body.is-paused #ui-btn-pause {
background-color: #555 !important;
color: #222 !important;
-webkit-text-fill-color: #222 !important;
border-top: 2px solid #444 !important;
border-left: 2px solid #444 !important;
border-bottom: 3px solid #111 !important;
border-right: 3px solid #222 !important;
box-shadow: inset 0 0 10px rgba(0,0,0,0.8) !important; /* Strips the outer drop shadow */
}
/* 5. Let the blinking lightbulb push its shadow past the 3px borders (5px offset) */
body.is-paused #ui-btn-pause::before {
display: block !important;
animation: pause-light-blink 1.5s infinite !important;
transition: none !important;
box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 10px rgba(242, 213, 126, 0.6), 5px 5px 5px rgba(0,0,0,0.9) !important;
}
</style>
</head>
<body id="tower-defense">
<div id="audio-preloads" style="display:none;">
<!-- Sound Effects -->
<audio id="snd_gun_light" src="assets/sounds/gun_light.mp3" preload="auto"></audio>
<audio id="snd_gun_heavy" src="assets/sounds/gun_heavy.mp3" preload="auto"></audio>
<audio id="snd_machine_gun" src="assets/sounds/machine_gun.mp3" preload="auto"></audio>
<audio id="snd_shotgun" src="assets/sounds/shotgun.mp3" preload="auto"></audio>
<audio id="snd_missile_launch" src="assets/sounds/missile_launch.mp3" preload="auto"></audio>
<audio id="snd_laser_fire" src="assets/sounds/laser_fire.mp3" preload="auto"></audio>
<audio id="snd_explosion" src="assets/sounds/explosion.mp3" preload="auto"></audio>
<!-- Background Music (Placeholders) -->
<audio id="bgm_menu" src="assets/music/menu_theme_placeholder.mp3" loop preload="auto"></audio>
<audio id="bgm_game" src="assets/music/battle_theme_placeholder.mp3" loop preload="auto"></audio>
</div>
<div id="game-container">
<div id="start-screen">
<h1>Wasteland Defense</h1>
<!-- Game Type Selector -->
<div class="menu-header" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerText='Game Mode\n\nChoose your game mode.\n- Campaign: 4 chapters, 15 waves each, permanent perks.\n- Endless: Infinite waves.\n- Custom Map: Build, save (.wd), and play your own sectors.\n- Assault: Reverse TD. Spawn units to destroy enemy bases.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">Select Game Mode</div>
<select id="gametype-select" class="menu-select" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerText='Game Mode\n\nChoose your game mode.\n- Campaign: 4 chapters, 15 waves each, permanent perks.\n- Endless: Infinite waves.\n- Custom Map: Build, save (.wd), and play your own sectors.\n- Assault: Reverse TD. Spawn units to destroy enemy bases.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">
<option value="campaign" selected>Campaign</option>
<option value="endless">Endless Survival</option>
<option value="custom">Custom Map Builder</option>
<option value="assault">Wasteland Assault</option>
</select>
<!-- Dynamic Map Selector (Hidden in Campaign by default) -->
<div id="map-selection-group" style="display: none; width: 100%; text-align: center;">
<div class="menu-header">SECTOR SELECTION</div>
<select id="level-select" class="menu-select">
<option value="scene_level_1">Holo-Map: Open Field</option>
<option value="scene_level_2">Holo-Map: The Wall</option>
</select>
</div>
<!-- Faction Selector (Always Shown) -->
<div class="menu-header" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerText='Faction\n\nSelect your faction. Each faction offers unique defensive towers and playstyles.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">Select Faction</div>
<select id="faction-select" class="menu-select" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerText='Faction\n\nSelect your faction. Each faction offers unique defensive towers and playstyles.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">
<option value="NCR" selected>New California Republic (NCR)</option>
<option value="BOS">Brotherhood of Steel</option>
<option value="MINUTEMEN">Minutemen</option>
<option value="RAIDERS">Raiders</option>
</select>
<!-- S.P.E.C.I.A.L. Loadout (Shown in Campaign by default) -->
<div id="special-selection-group" style="color: #4caf50; margin-bottom: 25px; text-align: center;">
<div class="menu-header" style="margin-bottom: 10px;">S.P.E.C.I.A.L. LOADOUT (Pick 3)</div>
<label style="position: relative; display: inline-block;" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerHTML='<b style=\'color:#f2d57e\'>STRENGTH</b><br>Passive: Towers deal +15% Damage.<br>Active: Massive 15-second combat boost.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">
<input type="checkbox" class="special-chk" value="S"><div class="special-label">S</div>
</label>
<label style="position: relative; display: inline-block;" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerHTML='<b style=\'color:#f2d57e\'>PERCEPTION</b><br>Passive: Towers gain +2 Range.<br>Active: 15s of infinite map-wide range.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">
<input type="checkbox" class="special-chk" value="P"><div class="special-label">P</div>
</label>
<label style="position: relative; display: inline-block;" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerHTML='<b style=\'color:#f2d57e\'>ENDURANCE</b><br>Passive: +50% Base Health/Lives.<br>Active: 15-second defensive boost.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">
<input type="checkbox" class="special-chk" value="E"><div class="special-label">E</div>
</label>
<label style="position: relative; display: inline-block;" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerHTML='<b style=\'color:#f2d57e\'>CHARISMA</b><br>Passive: Towers & Upgrades cost 15% less.<br>Active: Drops 15 Cap Stashes from the sky.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">
<input type="checkbox" class="special-chk" value="C"><div class="special-label">C</div>
</label>
<label style="position: relative; display: inline-block;" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerHTML='<b style=\'color:#f2d57e\'>INTELLIGENCE</b><br>Passive: Towers need 15% less XP to level.<br>Active: Instantly grants all towers 100 XP.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">
<input type="checkbox" class="special-chk" value="I"><div class="special-label">I</div>
</label>
<label style="position: relative; display: inline-block;" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerHTML='<b style=\'color:#f2d57e\'>AGILITY</b><br>Passive: +15% Fire Rate & Cooldown Speed.<br>Active: Instantly resets all consumable cooldowns.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">
<input type="checkbox" class="special-chk" value="A"><div class="special-label">A</div>
</label>
<label style="position: relative; display: inline-block;" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerHTML='<b style=\'color:#f2d57e\'>LUCK</b><br>Passive: Increased chance of loot drops.<br>Active: Drops 5 Lunchboxes randomly on the map.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">
<input type="checkbox" class="special-chk" value="L"><div class="special-label">L</div>
</label>
</div>
<!-- Difficulty Selector -->
<div class="menu-header" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerText='Difficulty\n\nSelect the threat level. Higher difficulties increase enemy health and numbers.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">Select Difficulty</div>
<select id="difficulty-select" class="menu-select" onmouseover="document.getElementById('tower-tooltip').style.display='block'; document.getElementById('tower-tooltip').innerText='Difficulty\n\nSelect the threat level. Higher difficulties increase enemy health and numbers.';" onmouseout="document.getElementById('tower-tooltip').style.display='none';">
<option value="0.5">Vaultie (Easy)</option>
<option value="1.0" selected>Wastelander (Normal)</option>
<option value="2.0">Survivalist (Hard)</option>
</select>
<div style="display: flex; gap: 10px; width: 350px; max-width: 80%; margin: 0 auto;">
<button id="btn-start" style="flex: 1; margin: 0; padding: 10px 0; font-size: 2.2vh;">Play Game</button>
<button id="btn-load-game" title="Load Game" style="flex: 0 0 60px; margin: 0; padding: 10px 0; display: flex; justify-content: center; align-items: center; border-radius: 3px; border: 2px solid #222; border-top: 2px solid #555; border-left: 2px solid #444; border-bottom: 3px solid #0a0a0a; border-right: 3px solid #111; background-color: #f2d57e; color: #222222; text-shadow: none; box-shadow: inset 0 0 12px rgba(0,0,0,0.6), inset 0 0 4px rgba(0,0,0,0.4), 0 0 10px rgba(242, 213, 126, 0.6); cursor: pointer; transition: transform 0.05s, box-shadow 0.05s;">
<svg width="2.5vh" height="2.5vh" viewBox="0 0 24 24" fill="none" stroke="#222" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
</div>
<input type="file" id="input-load-game" accept=".json" style="display: none;">
<!-- Menu Guide Icon -->
<img id="guide-icon-menu" class="guide-icon" src="assets/images/guide.png" alt="Survival Guide" title="Read the Survival Guide" onclick="document.getElementById('guide-modal-overlay').style.display='flex';" />
</div>
<!-- NEW SOLID TOP INFORMATION BAR -->
<div id="top-bar" style="height: 4vh; background-color: #2c3322; background-image: url('assets/images/green-dust-and-scratches.png'), url('assets/images/snow.png'), url('assets/images/scratch.png'), url('assets/images/white-wall-3-2.png'), url('assets/images/mooning.png'); background-repeat: repeat, repeat, repeat, repeat; background-blend-mode: overlay, overlay, overlay, overlay; border-top: 2px solid #6a7354; border-bottom: 3px solid #1a1d13; box-shadow: 0 5px 15px rgba(0,0,0,0.8); display: none; justify-content: center; align-items: center; padding: 0 2vw; overflow: hidden; position: relative; z-index: 10;">
<div id="ui-intel-brief" style="width: 100%; position: relative; color: #d1d8b6; font-family: 'Courier New', Courier, monospace; font-size: 2.2vh; font-weight: bold; letter-spacing: 1px; white-space: nowrap; text-shadow: 1px 1px 3px #000; display: flex; align-items: center; justify-content: center; height: 100%;">
<!-- UNIFIED INTEL STATE -->
<div id="intel-state-wave" style="position: absolute; width: 100%; height: 100%; left: 0; display: flex; align-items: center; transition: opacity 0.5s;">
<span id="intel-wave-label" style="color: #ffcc00; padding: 0 10px; z-index: 5;">WAVE 0:</span>
<!-- Custom Live Combat Log -->
<div id="intel-ticker-container" style="flex: 1; position: relative; padding-left: 15px; height: 100%; display: flex; align-items: center;">
<div id="intel-ticker-text" style="color: #e0e0e0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%;">[AWAITING COMBAT TELEMETRY...]</div>
</div>
</div>
</div>
</div>
<div id="td-board" style="display: flex; flex-direction: column; flex-grow: 1; position: relative;">
<!-- NEW: Environmental Effect Layer -->
<div id="env-effect-overlay" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 10; pointer-events: none; background-repeat: repeat; display: none;"></div>
<!-- NEW: Screen Crack Overlays (Staggered Placements) -->
<img id="damage-overlay-70" src="assets/images/crushed_6813104.png" style="position: absolute; width: 35%; height: auto; pointer-events: none; z-index: 50; opacity: 0;" />
<img id="damage-overlay-50" src="assets/images/crushed_6813104.png" style="position: absolute; width: 35%; height: auto; pointer-events: none; z-index: 51; opacity: 0;" />
<img id="damage-overlay-30" src="assets/images/crushed_6813104.png" style="position: absolute; width: 35%; height: auto; pointer-events: none; z-index: 52; opacity: 0;" />
<img id="damage-overlay-10" src="assets/images/crushed_6813104.png" style="position: absolute; width: 35%; height: auto; pointer-events: none; z-index: 53; opacity: 0;" />
<!-- NEW: V.A.T.S. Overlay -->
<div id="vats-overlay" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 255, 0, 0.15); pointer-events: none; z-index: 55; display: none; box-shadow: inset 0 0 100px rgba(0, 255, 0, 0.5); mix-blend-mode: overlay;"></div>
<!-- NEW: Static Noise Signal Lost Layer -->
<div id="signal-lost-overlay" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-image: url('assets/images/noise.gif'); background-repeat: repeat; z-index: 54; display: none; pointer-events: none; mix-blend-mode: hard-light; opacity: 0.9;"></div>
<!-- NEW: HTML Game Over Text (Classic Terminal Style) -->
<div id="game-over-text" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 60; display: none; pointer-events: none; text-align: center; background-color: rgba(0, 0, 0, 0.9); padding: 40px 80px; border: 2px solid #4caf50; border-radius: 4px; box-shadow: 0 0 20px rgba(76, 175, 80, 0.4);">
<h1 style="font-family: 'Courier New', monospace; font-size: 6vh; color: #4caf50; text-shadow: 0 0 10px rgba(76,175,80,0.8); margin: 0; letter-spacing: 2px; -webkit-text-stroke: 0px;">CONNECTION LOST</h1>
<!-- POST-GAME STATS -->
<div style="margin-top: 25px; font-family: 'Courier New', monospace; font-size: 2.2vh; color: #aaffaa; text-align: left; display: inline-block; padding: 15px 30px; border: 1px dashed #4caf50; background: rgba(0,20,0,0.5);">
<div style="margin-bottom: 5px;">WAVES SURVIVED:<span id="go-stat-waves" style="float: right; font-weight: bold; color: #fff; margin-left: 30px;">0</span></div>
<div style="margin-bottom: 5px;">TOTAL SCORE:<span id="go-stat-score" style="float: right; font-weight: bold; color: #fff; margin-left: 30px;">0</span></div>
<div style="margin-bottom: 5px;">CAPS ACQUIRED:<span id="go-stat-caps" style="float: right; font-weight: bold; color: #fff; margin-left: 30px;">0</span></div>
<div style="margin-bottom: 5px;">SHOTS FIRED:<span id="go-stat-shots" style="float: right; font-weight: bold; color: #fff; margin-left: 30px;">0</span></div>
<div>MVP TOWER:<span id="go-stat-mvp" style="float: right; font-weight: bold; color: #ffcc00; margin-left: 30px;">NONE</span></div>
</div>
<p style="font-family: 'Courier New', monospace; font-size: 2.5vh; color: #4caf50; text-shadow: none; margin-top: 25px; font-weight: bold;" class="blink-text">> REBOOT SYSTEM CONSOLE_</p>
</div>
<canvas id="td-canvas" width="1920" height="900" style="width: 100%; height: 100%; cursor: default;">Sorry, your browser does not support the HTML 5 Canvas tag.</canvas>
</div>
<div id="control-panel" style="display: flex; flex-direction: column;">
<!-- NEW: OVER-BEZEL HUD ELEMENT -->
<div id="ui-speed-readout" style="display: none; position: absolute; top: -2.5vh; right: 2vw; color: #ffcc00; font-family: 'Courier New', monospace; font-weight: bold; font-size: 2vh; text-shadow: 0 0 5px #ffaa00, 2px 2px 2px #000; z-index: 100;">
TIME DILATION: <span id="ui-speed-val" class="blink-text">>> 1X</span>
</div>
<!-- Bottom 5 Columns -->
<div style="display: flex; width: 100%; height: 100%; gap: 1vw; padding-top: 0.5vh;">
<!-- Col 1: System Status -->
<div class="panel-col" style="flex: 1.4; border-right: 2px solid #444; padding-right: 1vw;">
<div class="panel-title">SYSTEM STATUS</div>
<div class="stat-line" style="display: flex; justify-content: flex-start; align-items: center; margin-bottom: 0.5vh;">
<span style="font-weight: bold; color: #888; width: 3.5vw; display: inline-block;">CAPS</span>
<div id="ui-money-container" class="nixie-board" style="flex: 1; justify-content: flex-end;"></div>
</div>
<div class="stat-line" style="display: flex; justify-content: flex-start; align-items: center; margin-bottom: 0.5vh;">
<span style="font-weight: bold; color: #888; width: 3.5vw; display: inline-block;">SCORE</span>
<div id="ui-score-container" class="nixie-board" style="flex: 1; justify-content: flex-end;"></div>
</div>
<div class="stat-line" style="display: flex; justify-content: flex-start; align-items: center; margin-bottom: 0.5vh;">
<span id="ui-health-label" style="font-weight: bold; color: #888; width: 3.5vw; display: inline-block;">HEALTH</span>
<div id="ui-life-container" class="nixie-board" style="flex: 1; justify-content: flex-end;"></div>
</div>
<div class="stat-line" style="display: flex; justify-content: flex-start; align-items: center; margin-bottom: 0.5vh;">
<span style="font-weight: bold; color: #888; width: 3.5vw; display: inline-block;">WAVE</span>
<div id="ui-wave-container" class="nixie-board" style="flex: 1; justify-content: flex-end;"></div>
</div>
</div>
<!-- Col 2: The Arsenal (Narrowed to flex 4, 4x3 Grid) -->
<div class="panel-col" style="flex: 3.8; border-right: 2px solid #444; padding-right: 1vw;">
<div class="panel-title">THE ARSENAL</div>
<div class="build-grid" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5vh 0.5vw;">
<!-- Row 1 -->
<button id="btn-buy-tower-1" class="cmd-btn" style="flex: 1 1 22%;">TOWER 1</button>
<button id="btn-buy-tower-2" class="cmd-btn" style="flex: 1 1 22%;">TOWER 2</button>
<button id="btn-buy-tower-3" class="cmd-btn" style="flex: 1 1 22%;">TOWER 3</button>
<button id="btn-buy-tower-4" class="cmd-btn" style="flex: 1 1 22%;">TOWER 4</button>
<!-- Row 2 -->
<button id="btn-buy-tower-5" class="cmd-btn" style="flex: 1 1 22%; color: #888;" disabled>\[UNLOCK\]</button>
<button id="btn-buy-tower-6" class="cmd-btn" style="flex: 1 1 22%; color: #888;" disabled>\[UNLOCK\]</button>
<button id="btn-buy-tower-7" class="cmd-btn" style="flex: 1 1 22%; color: #888;" disabled>\[UNLOCK\]</button>
<button id="btn-buy-tower-8" class="cmd-btn" style="flex: 1 1 22%; color: #888;" disabled>\[UNLOCK\]</button>
<!-- Row 3 (Pushed down from rows 1 & 2) -->
<button id="btn-buy-tower-9" class="cmd-btn" style="flex: 1 1 22%; color: #888;" disabled>\[UNLOCK\]</button>
<button id="btn-buy-tower-10" class="cmd-btn" style="flex: 1 1 22%; color: #888;" disabled>\[UNLOCK\]</button>
<button id="btn-buy-tower-11" class="cmd-btn" style="flex: 1 1 22%;" title="Deploys a Mr. Handy to collect loot automatically. (Max 1)">MR. HANDY</button>
<div style="flex: 1 1 22%;"></div><!-- Spacer -->
</div>
</div>
<!-- Col 3: Consumables & S.P.E.C.I.A.L. -->
<div class="panel-col" style="flex: 2.0; border-right: 2px solid #444; padding-right: 1vw;">
<div class="panel-title">CONSUMABLES & S.P.E.C.I.A.L.</div>
<div class="build-grid" style="flex-wrap: wrap; gap: 0.5vh 0.5vw;">
<button id="btn-item-artillery" class="cmd-btn" style="flex: 1 1 30%;" title="Calls down a massive explosive strike." disabled>ARTILLERY</button>
<button id="btn-item-stimpak" class="cmd-btn" style="flex: 1 1 30%;" title="Heals 15 HP instantly (Max 100)." disabled>STIMPAK</button>
<button id="btn-item-psycho" class="cmd-btn" style="flex: 1 1 30%;" title="+25% Damage & Fire Rate for 10s." disabled>PSYCHO</button>
<button id="btn-item-cryomine" class="cmd-btn" style="flex: 1 1 30%;" title="Freezes and slows enemies in a radius." disabled>CRYO MINE</button>
<button id="btn-item-capmine" class="cmd-btn" style="flex: 1 1 30%;" title="Explodes into scattered Bottlecaps!" disabled>CAP MINE</button>
<button id="btn-item-blank" class="cmd-btn" style="flex: 1 1 30%; color: #888;" disabled>???</button>
</div>
<div class="build-grid" style="flex-wrap: nowrap; gap: 0.2vw; margin-top: 0.5vh;">
<button id="btn-special-s" class="cmd-btn sys-btn" style="flex: 1; background: #333; color: #888;" disabled>S</button>
<button id="btn-special-p" class="cmd-btn sys-btn" style="flex: 1; background: #333; color: #888;" disabled>P</button>
<button id="btn-special-e" class="cmd-btn sys-btn" style="flex: 1; background: #333; color: #888;" disabled>E</button>
<button id="btn-special-c" class="cmd-btn sys-btn" style="flex: 1; background: #333; color: #888;" disabled>C</button>
<button id="btn-special-i" class="cmd-btn sys-btn" style="flex: 1; background: #333; color: #888;" disabled>I</button>
<button id="btn-special-a" class="cmd-btn sys-btn" style="flex: 1; background: #333; color: #888;" disabled>A</button>
<button id="btn-special-l" class="cmd-btn sys-btn" style="flex: 1; background: #333; color: #888;" disabled>L</button>
</div>
<!-- Target Actions -->
<div id="target-actions-view" style="display: flex; gap: 0.5vw; margin-top: 0.5vh;">
<button id="ui-btn-upgrade" class="cmd-btn sys-btn" style="display:none; background: #2a7a2a;">UPGRADE</button>
<button id="ui-btn-sell" class="cmd-btn sys-btn" style="display:none; background: #7a2a2a;">SELL</button>
</div>
</div>
<!-- Col 4: NEW V.A.T.S. PANEL -->
<div class="panel-col" style="flex: 0.8; border-right: 2px solid #444; padding-right: 1vw;">
<div class="panel-title">V.A.T.S. MODULE</div>
<div id="vats-container" style="display: flex; flex-direction: column; justify-content: center; height: 100%; padding-bottom: 0.5vh;">
<!-- Removed the 'unlit' override class and background style to let standard disabled CSS take over -->
<button id="btn-vats-activate" class="cmd-btn" style="flex: 1; font-size: 1.8vh;" disabled>V.A.T.S.</button>
</div>
</div>
<!-- Col 5: System Controls -->
<div class="panel-col" style="flex: 2.0;">
<div class="panel-title">SYSTEM CONTROLS</div>
<div class="build-grid" style="flex-wrap: wrap; gap: 0.5vh 0.5vw; align-content: flex-start;">
<!-- Top Row -->
<button id="ui-btn-start-wave" class="cmd-btn sys-btn" style="flex: 1 1 30%; padding: 0.5vh 0;">START WAVE</button>
<button id="ui-btn-restart" class="cmd-btn sys-btn" style="flex: 1 1 30%; background: #555; padding: 0.5vh 0;">RESTART</button>
<button id="ui-btn-exit" class="cmd-btn sys-btn" style="flex: 1 1 30%; background: #555; padding: 0.5vh 0;">MAIN MENU</button>
<!-- Middle Row -->
<button id="ui-btn-slow" class="cmd-btn sys-btn" style="flex: 1 1 30%; padding: 0.5vh 0;">SLOW DOWN</button>
<button id="ui-btn-pause" class="cmd-btn sys-btn" style="flex: 1 1 30%; padding: 0.5vh 0;">PAUSE</button>
<button id="ui-btn-speed" class="cmd-btn sys-btn" style="flex: 1 1 30%; padding: 0.5vh 0;">SPEED UP</button>
<!-- Bottom Row: Audio Toggles & Save -->
<button id="ui-btn-sound" class="cmd-btn sys-btn" style="flex: 1 1 22%; padding: 0.5vh 0;">SOUND</button>
<button id="ui-btn-music" class="cmd-btn sys-btn" style="flex: 1 1 22%; padding: 0.5vh 0;">MUSIC</button>
<button id="ui-btn-save" class="cmd-btn sys-btn" style="flex: 1 1 22%; padding: 0.5vh 0;">SAVE</button>
<!-- NEW: Guide Button -->
<button id="ui-btn-guide" class="cmd-btn sys-btn" style="flex: 1 1 22%; padding: 0.5vh 0;" onclick="document.getElementById('guide-modal-overlay').style.display='flex';">GUIDE</button>
</div>
</div>
</div>
</div>
<!-- ADMIN PANEL -->
<style>
/* Compress Admin Panel to fit all tools inside 15vh */
#admin-panel .cmd-btn, #admin-panel .sys-btn { height: 2.2vh !important; font-size: 1vh !important; line-height: 1 !important; border-bottom: 2px solid #0a0a0a !important; border-right: 2px solid #111 !important; }
#admin-panel input, #admin-panel select { height: 2.2vh !important; font-size: 1vh !important; padding: 0 4px !important; box-sizing: border-box; }
#admin-panel .panel-title { font-size: 1.1vh !important; margin-bottom: 2px !important; padding-bottom: 0 !important; }
</style>
<div id="admin-panel" style="display: none; flex-direction: column; width: 100vw !important; height: 15vh !important; min-height: 15vh !important; max-height: 15vh !important; background: linear-gradient(to bottom, #422, #111); border-top: 4px solid #844; padding: 0.5vh 1vw; box-sizing: border-box; overflow: hidden;">
<div style="display: flex; width: 100%; height: 100%; gap: 1vw; padding-top: 0;">
<!-- Admin Col 1: Data & Image Loader (25%) -->
<div class="panel-col" style="flex: 1; border-right: 2px solid #533; padding-right: 1vw;">
<div class="panel-title" style="color: #faa; border-bottom-color: #844;">1. DATA & IMAGE LOADER</div>
<div style="display: flex; flex-direction: column; gap: 0.5vh; margin-bottom: 0.5vh;">
<div style="display: flex; gap: 0.5vw;">
<select id="admin-level-select" style="flex: 1; padding: 0.5vh; background: #222; color: #fff; border: 1px solid #666; font-size: 1.2vh; font-family: 'Courier New', monospace;"></select>
<button id="admin-btn-loadmap" class="cmd-btn" style="background: #642; flex: 1;">LOAD DATA</button>
</div>
<div style="display: flex; gap: 0.5vw;">
<input type="text" id="admin-img-input" placeholder="e.g. map03.jpg" style="padding: 0.5vh; background: #222; color: #fff; border: 1px solid #666; font-family: 'Courier New', monospace; font-size: 1.2vh; flex: 1;">
<button id="admin-btn-loadimg" class="cmd-btn" style="background: #642; flex: 1;">LOAD IMAGE</button>
</div>
<!-- NEW: Environmental Effects -->
<div style="border-top: 1px dashed #533; margin-top: 2px; padding-top: 4px; display: flex; flex-direction: column; gap: 0.5vh;">
<input type="text" id="admin-env-src" placeholder="Effect Image (e.g. fog.png)" style="padding: 0.5vh; background: #222; color: #fff; border: 1px solid #666; font-size: 1.2vh;">
<div style="display: flex; gap: 0.5vw; align-items: center;">
<select id="admin-env-type" style="flex: 1; padding: 0; background: #222; color: #fff; border: 1px solid #666; font-size: 1vh;">
<option value="static">Static</option>
<option value="fade">Fade</option>
<option value="pan_left">Left</option>
<option value="pan_right">Right</option>
<option value="pan_up">Up</option>
<option value="pan_down">Down</option>
</select>
<input type="number" id="admin-env-opacity" value="0.5" step="0.1" min="0" max="1" style="flex: 0.4; padding: 0; background: #222; color: #fff; border: 1px solid #666; font-size: 1vh; text-align: center;" title="Opacity">
<input type="number" id="admin-env-speed" value="20" min="1" style="flex: 0.4; padding: 0; background: #222; color: #fff; border: 1px solid #666; font-size: 1vh; text-align: center;" title="Speed">
<button id="admin-btn-env-apply" class="cmd-btn sys-btn" style="flex: 1; background: #466; margin: 0 !important;">APPLY</button>
</div>
</div>
</div>
</div>
<!-- Admin Col 2: G.E.C.K Editor (50%) -->
<div class="panel-col" style="flex: 2;">
<div class="panel-title" style="color: #faa; border-bottom-color: #844;">2. G.E.C.K. EDITOR OVERRIDE</div>
<div style="display: flex; gap: 1.5vw; height: 100%; margin-top: 0.5vh;">
<!-- Left Sub-Col: Painting Tools & Spawns -->
<div style="flex: 1; display: flex; flex-direction: column; gap: 0.5vh;">
<div style="display: flex; gap: 0.5vw;">
<button id="admin-brush-path" class="cmd-btn" style="flex: 1; background: #248;">PATH</button>
<button id="admin-brush-block" class="cmd-btn" style="flex: 1; background: #822;">BLOCK</button>
<button id="admin-brush-clear" class="cmd-btn" style="flex: 1; background: #282;">CLEAR</button>
</div>
<div style="display: flex; gap: 0.5vw;">
<button id="admin-brush-ent" class="cmd-btn" style="flex: 1; background: #aaa; color: #000;">ENTRANCE</button>
<button id="admin-brush-exit" class="cmd-btn" style="flex: 1; background: #aaa; color: #000;">EXIT</button>
</div>
<div style="display: flex; gap: 0.5vw; align-items: center; background: #222; padding: 0.2vh 0.5vw; border: 1px solid #444; border-radius: 3px;">
<span style="color: #faa; font-size: 1.2vh; font-weight: bold;">SPAWN MODE:</span>
<select id="admin-spawn-mode" style="flex: 1; padding: 0.5vh; background: transparent; color: #fff; border: none; font-size: 1.2vh; font-family: 'Courier New', monospace;">
<option value="random">Random Entrance</option>
<option value="simultaneous">Simultaneous</option>
</select>
</div>
</div>
<!-- Right Sub-Col: Towers & Global Actions -->
<div style="flex: 1; display: flex; flex-direction: column; gap: 0.5vh;">
<div style="display: flex; gap: 0.5vw; align-items: center;">
<select id="admin-tower-select" style="flex: 2; padding: 0.5vh; background: #222; color: #fff; border: 1px solid #666; font-size: 1.2vh; font-family: 'Courier New', monospace;">
<!-- Populated dynamically by JS -->
</select>
<input type="number" id="admin-tower-level" value="1" min="1" max="10" style="flex: 0.5; padding: 0.5vh; background: #222; color: #fff; border: 1px solid #666; font-size: 1.2vh; text-align: center;" title="Lvl">
<button id="admin-brush-tower" class="cmd-btn" style="flex: 1.5; background: #862;">PLACE</button>
</div>
<div style="display: flex; gap: 0.5vw;">
<button id="admin-btn-fill" class="cmd-btn" style="flex: 1; background: #642;">FILL GRID</button>
<button id="admin-btn-clearall" class="cmd-btn" style="flex: 1; background: #422;">CLEAR ALL</button>
</div>
<div style="display: flex; gap: 0.5vw;">
<button id="admin-btn-toggle" class="cmd-btn" style="flex: 1; background: #444;">OVERLAY</button>
<button id="admin-btn-open-assets" class="cmd-btn sys-btn" style="flex: 1; background: #622;">ASSETS</button>
</div>
</div>
</div>
</div>
<!-- Admin Col 3: Exporter with Text Box (25%) -->
<div class="panel-col" style="flex: 1; border-left: 2px solid #533; padding-left: 1vw;">
<div class="panel-title" style="color: #faa; border-bottom-color: #844;">3. EXPORT DATA</div>
<textarea id="admin-export-out" style="width: 100%; flex-grow: 1; background: #111; color: #0f0; font-family: monospace; font-size: 1vh; resize: none; border: 1px solid #444; margin-bottom: 0.5vh;" readonly placeholder="Code will generate here..."></textarea>
<div style="display: flex; gap: 0.5vw;">
<button id="admin-btn-validate" class="cmd-btn sys-btn" style="background: #288; flex: 1;">VALIDATE</button>
<button id="admin-btn-export" class="cmd-btn" style="background: #642; flex: 1;">GENERATE</button>
<button id="admin-btn-copy" class="cmd-btn" style="background: #268; flex: 1;">COPY</button>
</div>
<button id="admin-btn-wave-editor" class="cmd-btn sys-btn" style="background: #626; width: 100%; margin-top: 0.5vh;" onclick="TD.populateWaveEditor(); document.getElementById('wave-editor-modal-overlay').style.display='flex';">WAVE EDITOR</button>
</div>
</div>
</div>
<!-- NEW: Wave Timeline Editor Modal -->
<div id="wave-editor-modal-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 100000; justify-content: center; align-items: center;">
<div style="background: #1a1a1a; color: #0f0; padding: 20px; border: 2px solid #0f0; border-radius: 8px; width: 900px; max-width: 95vw; height: 85vh; display: flex; flex-direction: column; font-family: 'Courier New', monospace; box-shadow: 0 0 20px rgba(0,255,0,0.3);">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px dashed #0f0; padding-bottom: 10px; margin-bottom: 15px;">
<h2 style="margin: 0; font-size: 1.5em;">WAVE TIMELINE EDITOR</h2>
<button onclick="document.getElementById('wave-editor-modal-overlay').style.display='none';" style="background: transparent; border: none; color: #0f0; font-size: 2em; cursor: pointer; line-height: 0.5;">&times;</button>
</div>
<div style="display: flex; gap: 20px; flex: 1; overflow: hidden;">
<!-- Left: Wave Selector -->
<div style="width: 150px; display: flex; flex-direction: column; border-right: 1px dashed #0f0; padding-right: 15px;">
<label style="font-weight: bold; margin-bottom: 10px;">SELECT WAVE:</label>
<select id="wave-editor-select" size="15" style="background: #222; color: #0f0; border: 1px solid #0f0; padding: 5px; flex: 1; font-family: 'Courier New', monospace;"></select>
<div style="display: flex; gap: 5px; margin-top: 10px;">
<button id="we-btn-add-wave" class="cmd-btn sys-btn" style="flex: 1; background: #2a7a2a; border-color: #0f0;">+ ADD</button>
<button id="we-btn-del-wave" class="cmd-btn sys-btn" style="flex: 1; background: #7a2a2a; border-color: #f00; color: #fcc;">- DEL</button>
</div>
</div>
<!-- Middle/Right: Wave Settings -->
<div style="flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding-right: 10px; gap: 20px;">
<!-- Story Event Config -->
<div style="background: #111; padding: 10px; border: 1px solid #333;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<label style="font-weight: bold; color: #ffaa00; cursor: pointer;">
<input type="checkbox" id="we-story-enable" style="margin-right: 5px;"> SHOW STORY INTRO BEFORE WAVE
</label>
<div style="display: flex; align-items: center; gap: 5px;">
<span style="font-size: 0.9em; color: #aaa;">TARGET FACTION:</span>
<select id="we-story-faction" style="background: #222; color: #0f0; border: 1px solid #0f0; padding: 2px; font-family: 'Courier New', monospace;">
<option value="ALL">ALL (Default)</option>
<option value="NCR">NCR</option>
<option value="BOS">Brotherhood of Steel</option>
<option value="MINUTEMEN">Minutemen</option>
<option value="RAIDERS">Raiders</option>
</select>
</div>
</div>
<input type="text" id="we-story-title" placeholder="Event Title (e.g. INCOMING TRANSMISSION)" style="width: 100%; background: #222; color: #0f0; border: 1px solid #0f0; padding: 5px; font-family: 'Courier New', monospace; margin-bottom: 5px; box-sizing: border-box;">
<textarea id="we-story-desc" placeholder="Story text goes here..." style="width: 100%; height: 60px; background: #222; color: #0f0; border: 1px solid #0f0; padding: 5px; font-family: 'Courier New', monospace; resize: vertical; box-sizing: border-box;"></textarea>
</div>
<!-- Tower Status Config -->
<div style="background: #111; padding: 10px; border: 1px solid #333;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-weight: bold; color: #ffaa00;">TOWER AVAILABILITY</span>
<button id="we-btn-copy-prev" class="cmd-btn sys-btn" style="background: #2a7a2a; padding: 5px 15px;" title="Copies tower locks from the previous wave">USE PREV OPTIONS</button>
</div>
<div id="we-tower-list" style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<!-- Populated by JS -->
</div>
</div>
<!-- Monster Spawns Config -->
<div style="background: #111; padding: 10px; border: 1px solid #333; flex: 1;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-weight: bold; color: #ffaa00;">MONSTER SPAWNS</span>
<button id="we-btn-add-spawn" class="cmd-btn sys-btn" style="background: #2a7a2a; padding: 5px 15px;">+ ADD SPAWN</button>
</div>
<div id="we-spawn-list" style="display: flex; flex-direction: column; gap: 5px;">
<!-- Populated by JS -->
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
var _TD = {
a: [],
retina: window.devicePixelRatio || 1,
init: function (td_board, is_debug) {
delete this.init;
var i, TD = {
version: "0.1.17",
is_debug: !!is_debug,
is_paused: true,
width: 16,
height: 16,
show_monster_life: true,
fps: 0,
exp_fps: 24,
exp_fps_half: 12,
exp_fps_quarter: 6,
exp_fps_eighth: 4,
stage_data: {},
sound_enabled: true, // Default to true
music_enabled: true, // NEW: Default to true
current_bgm: null, // NEW: Track current background music
// --- NEW: TYPEWRITER COMBAT LOG HANDLER ---
typewriter_queue: [],
typewriter_timer: 0,
typewriter_target: "",
typewriter_current: "",
typewriter_pause: 0, // NEW: Reading delay timer
logEvent: function(msg) {
var tickerEl = document.getElementById("intel-ticker-text");
if (!tickerEl) return;
msg = msg.toUpperCase();
this.typewriter_target = msg;
this.typewriter_current = "";
tickerEl.innerText = "";
this.typewriter_timer = 0;
},
// --- NEW: SMART ANNOUNCER BRAIN ---
Announcer: {
queue: [],
add: function(msg, priority) {
priority = priority || 1; // 1 = Flavor, 3 = Action, 5 = Critical
for (var i=0; i<this.queue.length; i++) {
if (this.queue[i].msg === msg) return;
}
this.queue.push({ msg: msg, priority: priority });
this.queue.sort(function(a, b) { return a.priority - b.priority; });
},
update: function() {
// PREVENT overwriting if it's currently typing OR the player is still reading it!
if (TD.typewriter_current.length < TD.typewriter_target.length) return;
if (TD.typewriter_pause > 0) return;
if (this.queue.length > 0) {
var item = this.queue.pop();
TD.logEvent(item.msg);
// Calculate reading time: ~0.5 seconds per word
var wordCount = item.msg.split(" ").length;
TD.typewriter_pause = Math.floor(wordCount * (TD.exp_fps * 0.5));
} else {
// 1% chance per frame to drop flavor text when completely idle
if (window.IntelData && Math.random() > 0.99) {
var flavor = window.IntelData.generateChatter(TD.current_faction);
TD.logEvent(flavor);
var wordCount = flavor.split(" ").length;
TD.typewriter_pause = Math.floor(wordCount * (TD.exp_fps * 0.5));
}
}
}
},
// --- NEW: GLOBAL AUDIO HANDLER ---
playSound: function(sound_id) {
if (!TD.is_paused && TD.sound_enabled) {
var snd = document.getElementById(sound_id);
if (snd) {
// cloneNode allows the same sound to play multiple times concurrently
var clone = snd.cloneNode();
clone.volume = 0.5; // Default to 50% volume so it isn't deafening
clone.play().catch(function(e){ /* Ignore browser autoplay block errors */ });
}
}
},
// --- NEW: GLOBAL MUSIC HANDLER ---
playMusic: function(track_id) {
// Stop current music first
if (TD.current_bgm) {
TD.current_bgm.pause();
TD.current_bgm.currentTime = 0;
}
if (!TD.music_enabled) return;
var bgm = document.getElementById(track_id);
if (bgm) {
bgm.volume = 0.3; // Slightly quieter than SFX
bgm.play().catch(function(e){ console.log("Music blocked by browser autoplay policy."); });
TD.current_bgm = bgm;
}
},
stopMusic: function() {
if (TD.current_bgm) {
TD.current_bgm.pause();
TD.current_bgm.currentTime = 0;
TD.current_bgm = null;
}
},
defaultSettings: function () {
return {
step_time: 36,
grid_size: 60 * _TD.retina,
padding: 0,
global_speed: 0.2,
show_grid: false,
admin_mode: false, // Prevents the render crash
admin_brush: "path" // Default brush type
};
},
init: function (ob_board) {
this.obj_board = TD.lang.$e(ob_board);
this.canvas = this.obj_board.getElementsByTagName("canvas")[0];
if (!this.canvas.getContext) return;
this.ctx = this.canvas.getContext("2d");
this.monster_type_count = TD.getDefaultMonsterAttributes();
this.iframe = 0;
this.last_iframe_time = (new Date()).getTime();
this.fps = 0;
this.start();
},
// --- NEW: V.A.T.S. Core Functions ---
startVats: function() {
// Must be fully charged and not already running
var vatsPercent = Math.min(100, Math.floor((TD.vats_charge / TD.vats_max_charge) * 100));
if (vatsPercent < 100 || TD.vats_active) return;
// FORCE KILL any stuck UI elements before activating
if (TD.stage && TD.stage.current_act && TD.stage.current_act.current_scene && TD.stage.current_act.current_scene.panel) {
TD.stage.current_act.current_scene.panel.balloontip.hide();
TD.stage.current_act.current_scene.panel.balloontip.el = null;
}
TD.vats_active = true;
TD.vats_charge = 0; // Consume the charge
TD.vats_shots = 6;
TD.vats_timer = 10 * TD.exp_fps; // 10 Seconds
// Slow time drastically (grinding crawl)
TD.global_speed_backup = TD.global_speed;
TD.global_speed = 0.025; // CHANGED: 75% slower, ensuring a heavy slow-motion feel
// Visuals
document.getElementById("vats-overlay").style.display = "block";
this.canvas.style.cursor = "crosshair";
var d = document.getElementById("ui-intel-brief");
if (d) d.innerText = "V.A.T.S. DEACTIVATED.";
},
start: function () {
if (TD.vats_charge < 100 || TD.vats_active) return;
TD.vats_active = true;
TD.vats_charge = 0;
TD.vats_shots = 6;
TD.vats_timer = 10 * TD.exp_fps; // 10 Seconds
// Slow time drastically
TD.global_speed_backup = TD.global_speed;
TD.global_speed = 0.001;
// Visuals
document.getElementById("vats-overlay").style.display = "block";
this.canvas.style.cursor = "crosshair";
var d = document.getElementById("ui-intel-brief");
if (d) d.innerText = "V.A.T.S. ENGAGED. SELECT TARGETS.";
},
endVats: function() {
if (!TD.vats_active) return;
TD.vats_active = false;
TD.vats_shots = 0;
TD.vats_timer = 0;
// Restore speed
if (TD.global_speed_backup) TD.global_speed = TD.global_speed_backup;
else TD.global_speed = 0.2; // Safe fallback
// Restore Visuals
document.getElementById("vats-overlay").style.display = "none";
this.canvas.style.cursor = "default";
var vatsBox = document.getElementById("vats-tooltip");
if (vatsBox) vatsBox.style.display = "none"; // FORCE HIDE ON END
var vatsBtn = document.getElementById("btn-vats-activate");
if (vatsBtn) {
vatsBtn.style.backgroundColor = ""; // Clear solid green/yellow
vatsBtn.style.boxShadow = ""; // Clear glow
vatsBtn.style.color = "#4caf50";
}
var d = document.getElementById("ui-intel-brief");
if (d) d.innerText = "V.A.T.S. DEACTIVATED.";
},
start: function () {
clearTimeout(this._st);
var _this = this;
this._exp_fps_0 = this.exp_fps - 0.4;
this._exp_fps_1 = this.exp_fps + 0.4;
this.mode = "normal";
this.eventManager.clear();
this.lang.mix(this, this.defaultSettings());
// --- NEW: S.P.E.C.I.A.L. INITIALIZATION ---
TD.perks = { S: false, P: false, E: false, C: false, I: false, A: false, L: false };
if (window.selectedSPECIALs) {
for (var i = 0; i < window.selectedSPECIALs.length; i++) {
TD.perks[window.selectedSPECIALs[i]] = true;
}
}
TD.overdrive = { S: 0, P: 0, E: 0, C: 0, I: 0, A: 0, L: 0 };
TD.overdrive_cd = { S: 0, P: 0, E: 0, C: 0, I: 0, A: 0, L: 0 };
this.stage = new TD.Stage("stage-main", TD.getDefaultStageData("stage_main"));
this.canvas.setAttribute("width", this.stage.width);
this.canvas.setAttribute("height", this.stage.height);
this.canvas.onmousemove = function (e) {
if (window.is_demo_mode) return;
var xy = _this.getEventXY.call(_this, e);
// Track global mouse position for ghost rendering
TD.mouse_x = xy[0];
TD.mouse_y = xy[1];
// --- NEW: V.A.T.S. Hover Interceptor ---
if (TD.vats_active) {
var map = TD.stage.current_act.current_scene.map;
var hit_enemy = null;
var closest_dist = 999999;
var vatsBox = document.getElementById('vats-tooltip');
// Strict circular hit detection & closest sorting for stacked enemies
for (var i = 0; i < map.monsters.length; i++) {
var m = map.monsters[i];
if (m.is_valid) {
var dist = Math.sqrt(Math.pow(m.cx - xy[0], 2) + Math.pow(m.cy - xy[1], 2));
if (dist <= m.r) { // Must be physically touching the monster's circle
if (dist < closest_dist) {
closest_dist = dist;
hit_enemy = m;
}
}
}
}
if (hit_enemy && vatsBox) {
// Calculate damage and current HP percentage (Avg 300 DMG)
var damagePercent = Math.min(100, Math.floor((300 / Math.max(1, hit_enemy.life)) * 100));
var hpPercent = Math.max(0, Math.min(100, (hit_enemy.life / hit_enemy.life0) * 100));
// Inject into the new HTML elements
document.getElementById('vats-tooltip-hp').style.width = hpPercent + "%";
document.getElementById('vats-tooltip-perc').innerText = damagePercent + "%";
vatsBox.style.display = "block";
// Center the targeting box exactly on the mouse cursor
vatsBox.style.left = (e.clientX - 30) + "px"; // Half of 60px width
vatsBox.style.top = (e.clientY - 22) + "px"; // Half of 45px height
} else if (vatsBox) {
vatsBox.style.display = "none";
}
return; // Stop standard tooltips from rendering
}
// Normal Hover
_this.hover(xy[0], xy[1]);
// Allows for "click and drag" painting in Admin Mode
if (TD.admin_mode && _this.is_mouse_down) {
_this.applyAdminBrush(xy[0], xy[1]);
}
// --- NEW: Mine Placement Cursor & Tooltip Logic ---
if (TD.mode === "deploy_cryomine" || TD.mode === "deploy_capmine") {
var map = TD.stage.current_act.current_scene.map;
if (map) {
var mx = Math.floor((xy[0] - map.x) / TD.grid_size);
var my = Math.floor((xy[1] - map.y) / TD.grid_size);
var grid = map.getGrid(mx, my);
var tooltip = document.getElementById('tower-tooltip');
// Valid path checking: MUST be passable (a path) and MUST NOT have a tower built on it
if (grid && grid.passable_flag == 1 && grid.build_flag == 0) {
TD.mouseHand(true); // Crosshair/Pointer
if (tooltip) tooltip.style.display = "none";
} else {
_this.canvas.style.cursor = "not-allowed"; // Red circle slash
if (tooltip) {
tooltip.innerText = "Must be placed on path.";
tooltip.style.display = "block";
tooltip.style.left = (e.pageX + 15) + "px";
tooltip.style.top = (e.pageY + 15) + "px";
}
}
}
}
};
this.canvas.onmousedown = function (e) { _this.is_mouse_down = true; };
this.canvas.onmouseup = function (e) { _this.is_mouse_down = false; };
this.canvas.onclick = function (e) {
if (window.is_demo_mode) return;
var xy = _this.getEventXY.call(_this, e);
// --- NEW: V.A.T.S. Shooting Logic ---
if (TD.vats_active) {
var map = TD.stage.current_act.current_scene.map;
var hit_enemy = null;
var closest_dist = 999999;
// Check if we clicked an enemy (strict hit detection & sorting)
for (var i = 0; i < map.monsters.length; i++) {
var m = map.monsters[i];
if (m.is_valid) {
var dist = Math.sqrt(Math.pow(m.cx - xy[0], 2) + Math.pow(m.cy - xy[1], 2));
if (dist <= m.r) {
if (dist < closest_dist) {
closest_dist = dist;
hit_enemy = m;
}
}
}
}
if (hit_enemy) {
TD.vats_shots--;
// Visual Blast (Laser impact style)
TD.Explode("vats-hit-" + TD.lang.rndStr(), { cx: hit_enemy.cx, cy: hit_enemy.cy, r: 15 * _TD.retina, step_level: 1, render_level: 9, color: "#00ff00", scene: map.scene, time: 0.3 });
// 200 to 400 Random True Damage (Bypasses armor/shields entirely)
var dmg = Math.floor(Math.random() * 201) + 200;
hit_enemy.life -= dmg;
TD.score += Math.floor(dmg * 0.1); // Score scales with damage dealt
// Pop damage text directly
new TD.FloatingText("dmg-" + TD.lang.rndStr(), {
cx: hit_enemy.cx + (Math.random() * 24 - 12),
cy: hit_enemy.cy - 10 + (Math.random() * 10 - 5),
text: "-" + dmg,
color: "#ffcc00",
map: hit_enemy.map,
size: 14,
life: 24
});
// Handle death directly if it kills them
if (hit_enemy.life <= 0) {
hit_enemy.beKilled({ killed: 0, gainXp: function(){} });
}
if (TD.vats_shots <= 0) TD.endVats();
}
return; // Block normal clicking while VATS is active
}
if (TD.admin_mode) {
_this.applyAdminBrush(xy[0], xy[1]);
} else if (TD.mode === "deploy_handy" && TD.stage && TD.stage.current_act) {
var map = TD.stage.current_act.current_scene.map;
var handyData = TD.factionData[TD.current_faction] ? TD.factionData[TD.current_faction][10] : null;
var cost = (handyData && handyData.free_first_build) ? 0 : 500;
if (map && TD.money >= cost && !map.mr_handy_active) {
TD.money -= cost;
if (handyData && handyData.free_first_build) {
handyData.free_first_build = false;
TD.loadFaction(TD.current_faction);
}
map.mr_handy_active = true;
// Pass the exact mouse coordinates to Mr. Handy's init
new TD.MrHandy("handy-" + TD.lang.rndStr(), { map: map, cx: xy[0], cy: xy[1] });
TD.mode = "normal";
TD.mouseHand(false);
var d = document.getElementById("ui-action-desc");
if (d) d.innerText = "MR. HANDY DEPLOYED.";
}
} else if (TD.mode === "deploy_cryomine" && TD.stage && TD.stage.current_act) {
var map = TD.stage.current_act.current_scene.map;
if (map) {
var mx = Math.floor((xy[0] - map.x) / TD.grid_size);
var my = Math.floor((xy[1] - map.y) / TD.grid_size);
var grid = map.getGrid(mx, my);
if (grid && grid.passable_flag == 1 && grid.build_flag == 0) { // <--- Added restriction
TD.inventory.cryomine--;
TD.cooldowns.cryomine = (TD.inventory.cryomine > 0) ? TD.getCooldownFrames() : 0;
TD.updateItemButton("cryomine");
new TD.CryoMine("cryo-" + TD.lang.rndStr(), { grid: grid, map: map });
TD.mode = "normal";
TD.mouseHand(false);
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "CRYO MINE PLANTED.";
} else {
// --- NEW: Undo selection on invalid click ---
TD.mode = "normal";
TD.mouseHand(false);
var tooltip = document.getElementById('tower-tooltip');
if (tooltip) tooltip.style.display = "none";
var d = document.getElementById("ui-intel-brief");
if(d) d.innerText = "MINE PLACEMENT CANCELLED.";
}
}
} else if (TD.mode === "deploy_capmine" && TD.stage && TD.stage.current_act) {
var map = TD.stage.current_act.current_scene.map;
if (map) {
var mx = Math.floor((xy[0] - map.x) / TD.grid_size);
var my = Math.floor((xy[1] - map.y) / TD.grid_size);
var grid = map.getGrid(mx, my);
if (grid && grid.passable_flag == 1 && grid.build_flag == 0) { // <--- Added restriction
TD.inventory.capmine--;
TD.cooldowns.capmine = (TD.inventory.capmine > 0) ? TD.getCooldownFrames() : 0;
TD.updateItemButton("capmine");
new TD.CapMine("cap-" + TD.lang.rndStr(), { grid: grid, map: map });
TD.mode = "normal";
TD.mouseHand(false);
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "CAP MINE PLANTED.";
} else {
// --- NEW: Undo selection on invalid click ---
TD.mode = "normal";
TD.mouseHand(false);
var tooltip = document.getElementById('tower-tooltip');
if (tooltip) tooltip.style.display = "none";
var d = document.getElementById("ui-intel-brief");
if(d) d.innerText = "MINE PLACEMENT CANCELLED.";
}
}
} else if (TD.mode === "deploy_artillery" && TD.stage && TD.stage.current_act) {
var map = TD.stage.current_act.current_scene.map;
if (map) {
var mx = Math.floor((xy[0] - map.x) / TD.grid_size);
var my = Math.floor((xy[1] - map.y) / TD.grid_size);
var grid = map.getGrid(mx, my);
if (grid && grid.passable_flag == 1 && grid.build_flag == 0) { // <--- Added restriction
TD.inventory.artillery--;
TD.cooldowns.artillery = (TD.inventory.artillery > 0) ? TD.getCooldownFrames() : 0;
TD.updateItemButton("artillery");
var blast_radius = TD.grid_size * 4;
TD.Explode("artillery-" + TD.lang.rndStr(), { cx: xy[0], cy: xy[1], r: blast_radius / _TD.retina, step_level: 1, render_level: 9, color: "#ff5500", scene: map.scene, time: 1.0 });
var dummy_source = { killed: 0, gainXp: function(){} };
TD.lang.each(map.monsters, function (m) {
if (m.is_valid && (Math.pow(m.cx - xy[0], 2) + Math.pow(m.cy - xy[1], 2) <= Math.pow(blast_radius, 2))) {
m.beHit(dummy_source, 300); // 300 Damage
}
});
TD.mode = "normal"; TD.mouseHand(false);
var d = document.getElementById("ui-action-desc"); if(d) d.innerText = "ARTILLERY STRIKE CONFIRMED.";
} else {
var d = document.getElementById("ui-action-desc"); if(d) d.innerText = "ERROR: MUST TARGET MONSTER PATH.";
}
}
} else if (TD.mode === "deploy_ncr_artillery" && TD.stage && TD.stage.current_act) {
var map = TD.stage.current_act.current_scene.map;
if (map) {
var mx = Math.floor((xy[0] - map.x) / TD.grid_size);
var my = Math.floor((xy[1] - map.y) / TD.grid_size);
var startGrid = map.getGrid(mx, my);
if (startGrid && startGrid.passable_flag == 1 && startGrid.build_flag == 0) {
TD.inventory.ncr_artillery--;
TD.cooldowns.ncr_artillery = (TD.inventory.ncr_artillery > 0) ? TD.getCooldownFrames() : 0;
TD.updateItemButton("ncr_artillery");
// Drop targeting flare immediately
TD.Explode("flare-" + TD.lang.rndStr(), { cx: xy[0], cy: xy[1], r: 5 * _TD.retina, step_level: 1, render_level: 9, color: "#ff0000", scene: map.scene, time: 2.0 });
// Find path to exit to plan the walking barrage
var ext = map.exits[0];
var barragePath = [[mx, my]]; // Always bomb the clicked spot first
if (ext) {
var fw = new TD.FindWay(map.grid_x, map.grid_y, mx, my, ext.mx, ext.my, function (x, y) { return map.checkPassable(x, y); });
if (fw.is_arrived && fw.way.length > 0) {
var steps = Math.min(5, fw.way.length);
for (var i = 0; i < steps; i++) barragePath.push(fw.way[i]);
}
}
var dummy_source = { killed: 0, gainXp: function(){} };
var blast_radius = TD.grid_size * 2.5;
barragePath.forEach(function(coords, index) {
setTimeout(function() {
if (TD.is_paused) return;
var tcx = map.x + (coords[0] * TD.grid_size) + (TD.grid_size / 2);
var tcy = map.y + (coords[1] * TD.grid_size) + (TD.grid_size / 2);
TD.Explode("ncr-art-" + index, { cx: tcx, cy: tcy, r: blast_radius / _TD.retina, step_level: 1, render_level: 9, color: "#ff7700", scene: map.scene, time: 0.8 });
TD.lang.each(map.monsters, function (m) {
if (m.is_valid && (Math.pow(m.cx - tcx, 2) + Math.pow(m.cy - tcy, 2) <= Math.pow(blast_radius, 2))) {
m.beHit(dummy_source, 200);
}
});
}, index * 400);
});
TD.mode = "normal"; TD.mouseHand(false);
var d = document.getElementById("ui-action-desc"); if(d) d.innerText = "NCR BARRAGE COORDINATES LOCKED.";
} else {
var d = document.getElementById("ui-action-desc"); if(d) d.innerText = "ERROR: MUST TARGET MONSTER PATH.";
}
}
} else if (TD.mode === "deploy_bos_laser" && TD.stage && TD.stage.current_act) {
var map = TD.stage.current_act.current_scene.map;
if (map) {
var mx = Math.floor((xy[0] - map.x) / TD.grid_size);
var my = Math.floor((xy[1] - map.y) / TD.grid_size);
var grid = map.getGrid(mx, my);
if (grid && grid.passable_flag == 1 && grid.build_flag == 0) {
TD.inventory.bos_laser--;
TD.cooldowns.bos_laser = TD.getCooldownFrames();
TD.updateItemButton("bos_laser");
new TD.OrbitalLaser("laser-" + TD.lang.rndStr(), { map: map, cx: xy[0], cy: xy[1] });
TD.mode = "normal"; TD.mouseHand(false);
var d = document.getElementById("ui-action-desc"); if(d) d.innerText = "ORBITAL STRIKE ACTIVE.";
} else {
var d = document.getElementById("ui-action-desc"); if(d) d.innerText = "ERROR: MUST TARGET MONSTER PATH.";
}
}
} else if (TD.mode === "deploy_mm_flare" && TD.stage && TD.stage.current_act) {
var map = TD.stage.current_act.current_scene.map;
var mx = Math.floor((xy[0] - map.x) / TD.grid_size);
var my = Math.floor((xy[1] - map.y) / TD.grid_size);
var grid = map.getGrid(mx, my);
if (grid && grid.passable_flag == 1 && grid.build_flag == 0) {
TD.inventory.mm_flare--;
TD.cooldowns.mm_flare = TD.getCooldownFrames();
TD.updateItemButton("mm_flare");
new TD.MilitiaBlockade("militia-" + TD.lang.rndStr(), { map: map, cx: grid.cx, cy: grid.cy });
TD.mode = "normal"; TD.mouseHand(false);
var d = document.getElementById("ui-action-desc"); if(d) d.innerText = "MILITIA DEPLOYED.";
} else {
var d = document.getElementById("ui-action-desc"); if(d) d.innerText = "ERROR: MUST TARGET MONSTER PATH.";
}
} else if (TD.mode === "deploy_raider_nuka" && TD.stage && TD.stage.current_act) {
var map = TD.stage.current_act.current_scene.map;
if (map) {
var mx = Math.floor((xy[0] - map.x) / TD.grid_size);
var my = Math.floor((xy[1] - map.y) / TD.grid_size);
var grid = map.getGrid(mx, my);
if (grid && grid.passable_flag == 1 && grid.build_flag == 0) {
TD.inventory.raider_nuka--;
TD.cooldowns.raider_nuka = TD.getCooldownFrames();
TD.updateItemButton("raider_nuka");
new TD.RadZone("nuka-" + TD.lang.rndStr(), { map: map, cx: xy[0], cy: xy[1] });
TD.mode = "normal"; TD.mouseHand(false);
var d = document.getElementById("ui-action-desc"); if(d) d.innerText = "NUKA GRENADE OUT!";
} else {
var d = document.getElementById("ui-action-desc"); if(d) d.innerText = "ERROR: MUST TARGET MONSTER PATH.";
}
}
} else {
_this.click(xy[0], xy[1]);
}
};
// New dedicated function to forcefully paint the grid
this.applyAdminBrush = function(px, py) {
if (!this.stage || !this.stage.current_act || !this.stage.current_act.current_scene || !this.stage.current_act.current_scene.map) return;
var map = this.stage.current_act.current_scene.map;
// Figure out which grid square was clicked based on pixel coordinates
var mx = Math.floor((px - map.x) / TD.grid_size);
var my = Math.floor((py - map.y) / TD.grid_size);
var grid = map.getGrid(mx, my);
if (!grid) return;
if (TD.admin_brush == "path") { grid.passable_flag = 1; grid.build_flag = 0; }
else if (TD.admin_brush == "block") { grid.passable_flag = 0; grid.build_flag = 0; }
else if (TD.admin_brush == "clear") {
// 1. Clear standard grid flags and Towers
grid.passable_flag = 1; grid.build_flag = 1;
if (grid.building) grid.removeBuilding();
// 2. Clear Entrances and Exits
if (grid.is_entrance) { grid.is_entrance = false; map.entrances = map.entrances.filter(function(e) { return e !== grid; }); map.entrance = map.entrances[0] || null; }
if (grid.is_exit) { grid.is_exit = false; map.exits = map.exits.filter(function(e) { return e !== grid; }); map.exit = map.exits[0] || null; }
// 3. Clear Assets (checks if your click pixel was inside any asset's bounding box)
if (map.assets) {
for (var i = map.assets.length - 1; i >= 0; i--) {
var a = map.assets[i];
if (px >= a.x && px <= a.x + a.width && py >= a.y && py <= a.y + a.height) {
// Reset the grid flags underneath the asset
for (var dx = 0; dx < a.grid_x; dx++) {
for (var dy = 0; dy < a.grid_y; dy++) {
var g = map.getGrid(a.mx + dx, a.my + dy);
if (g) { g.passable_flag = 1; g.build_flag = 1; }
}
}
// Delete the asset
map.assets.splice(i, 1);
break; // Only erase one asset per click
}
}
}
}
else if (TD.admin_brush == "entrance") {
if (grid.is_entrance) {
grid.dir = ((grid.dir || 0) + 1) % 4; // Rotate it
} else {
grid.is_entrance = true; grid.passable_flag = 1; grid.build_flag = 0; grid.dir = 0;
if (!map.entrances) map.entrances = [];
map.entrances.push(grid);
map.entrance = map.entrances[0]; // fallback
}
}
else if (TD.admin_brush == "exit") {
if (grid.is_exit) {
grid.dir = ((grid.dir || 0) + 1) % 4; // Rotate it
} else {
grid.is_exit = true; grid.passable_flag = 1; grid.build_flag = 0; grid.dir = 0;
if (!map.exits) map.exits = [];
map.exits.push(grid);
map.exit = map.exits[0]; // fallback
}
}
else if (TD.admin_brush == "tower") {
var towerType = document.getElementById("admin-tower-select").value;
var towerLvl = parseInt(document.getElementById("admin-tower-level").value) || 1;
grid.addBuilding(towerType);
grid.build_flag = 2; // Mark as built
grid.passable_flag = 0; // Towers block the path
// Apply upgrades instantly
if (grid.building) {
for (var i = 1; i < towerLvl; i++) {
grid.building.upgrade();
}
}
}
else if (TD.admin_brush == "asset" && TD.current_editor_asset) {
// NEW: Place the selected asset
if (!map.assets) map.assets = [];
var assetData = TD.current_editor_asset;
// Bottom-Anchoring: The mouse click (mx, my) is treated as the BOTTOM-LEFT tile of the asset.
var base_mx = mx;
var base_my = my - (assetData.grid_y - 1);
// Check if it fits within bounds
if (base_mx + assetData.grid_x > map.grid_x || base_my < 0 || base_mx < 0 || base_my + assetData.grid_y > map.grid_y) {
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "ERROR: Asset bounds off map.";
return;
}
var imgObj = new Image();
imgObj.src = assetData.src;
var x_px = map.x + (base_mx * TD.grid_size);
var y_px = map.y + (base_my * TD.grid_size);
var w_px = assetData.grid_x * TD.grid_size;
var h_px = assetData.grid_y * TD.grid_size;
var newAsset = {
id: assetData.id + "_" + Date.now() + "_" + Math.floor(Math.random() * 1000),
src: assetData.src,
img: imgObj,
mx: base_mx, my: base_my,
grid_x: assetData.grid_x, grid_y: assetData.grid_y,
blocks_path: assetData.blocks_path,
category: assetData.category, // CRITICAL FIX: Save the category so the renderer knows it's a path!
x: x_px, y: y_px, width: w_px, height: h_px,
y2: y_px + h_px,
is_asset: true
};
map.assets.push(newAsset);
for (var i = 0; i < assetData.grid_x; i++) {
for (var j = 0; j < assetData.grid_y; j++) {
var g = map.getGrid(base_mx + i, base_my + j);
if (g) {
// Visual paths don't block building on top of them!
if (assetData.category !== "path") g.build_flag = 0;
if (assetData.blocks_path) g.passable_flag = 0;
}
}
}
var d = document.getElementById("ui-action-desc");
if (assetData.category === "path") {
if (d) d.innerText = "Placed: " + assetData.name + " (Right-click to stop)";
} else {
// Deselect standard props after 1 placement
if (d) d.innerText = "Placed: " + assetData.name;
TD.current_editor_asset = null;
TD.admin_brush = "clear";
}
}
};
this.is_paused = false;
this.stage.start();
// --- FIX: Load the correct map data into the running scene ---
var lvl = window.selectedLevelName;
if (lvl && lvl !== "stage_main") {
var cfg = TD.getDefaultStageData(lvl);
if (cfg && cfg.map && this.stage && this.stage.current_act && this.stage.current_act.current_scene) {
this.stage.cfg = cfg; // Save the config for wave custom data
if (cfg.bg_image) {
var board = document.getElementById("td-board");
if (board) {
board.style.backgroundImage = "url('" + cfg.bg_image + "')";
board.style.backgroundSize = "100% 100%";
board.style.backgroundRepeat = "no-repeat";
}
}
// --- NEW: Load Game Environment Effect ---
var envOverlay = document.getElementById("env-effect-overlay");
if (envOverlay) {
if (cfg.map.env_effect) {
var eCfg = cfg.map.env_effect;
envOverlay.style.backgroundImage = "url('" + eCfg.src + "')";
document.documentElement.style.setProperty("--env-opacity", eCfg.opacity);
envOverlay.style.animation = "none";
void envOverlay.offsetWidth;
if (eCfg.type === "static") { envOverlay.style.opacity = eCfg.opacity; }
else if (eCfg.type === "fade") { envOverlay.style.opacity = 0; envOverlay.style.animation = "env-fade " + eCfg.speed + "s infinite ease-in-out"; }
else { envOverlay.style.opacity = eCfg.opacity; envOverlay.style.animation = "env-" + eCfg.type.replace("_", "-") + " " + eCfg.speed + "s infinite linear"; }
envOverlay.style.display = "block";
} else {
envOverlay.style.display = "none";
}
}
var m = this.stage.current_act.current_scene.map;
m.map_type = cfg.map.map_type || "traditional";
TD.lang.each(m.grids, function(g) { g.passable_flag = 1; g.build_flag = 1; g.is_entrance = false; g.is_exit = false; });
m.entrances = []; m.exits = []; m.entrance = null; m.exit = null;
m.assets = [];
if (cfg.map.grids_cfg) {
TD.lang.each(cfg.map.grids_cfg, function (obj) {
var grid = m.getGrid(obj.pos[0], obj.pos[1]);
if (grid) {
if (!isNaN(obj.passable_flag)) grid.passable_flag = obj.passable_flag;
if (!isNaN(obj.build_flag)) grid.build_flag = obj.build_flag;
if (obj.building) {
grid.addBuilding(obj.building);
if (obj.building_level && grid.building) {
for (var lvl2 = 1; lvl2 < obj.building_level; lvl2++) grid.building.upgrade();
}
}
}
});
}
if (cfg.map.entrance) { var eg = m.getGrid(cfg.map.entrance[0], cfg.map.entrance[1]); if(eg) { eg.is_entrance = true; eg.passable_flag = 1; eg.build_flag = 0; m.entrances.push(eg); } }
if (cfg.map.entrances) {
for (var i = 0; i < cfg.map.entrances.length; i++) {
var eg = m.getGrid(cfg.map.entrances[i][0], cfg.map.entrances[i][1]);
if (eg) { eg.is_entrance = true; eg.passable_flag = 1; eg.build_flag = 0; m.entrances.push(eg); }
}
}
if (cfg.map.exit) { var xg = m.getGrid(cfg.map.exit[0], cfg.map.exit[1]); if(xg) { xg.is_exit = true; xg.passable_flag = 1; xg.build_flag = 0; m.exits.push(xg); } }
if (cfg.map.exits) {
for (var i = 0; i < cfg.map.exits.length; i++) {
var xg = m.getGrid(cfg.map.exits[i][0], cfg.map.exits[i][1]);
if (xg) { xg.is_exit = true; xg.passable_flag = 1; xg.build_flag = 0; m.exits.push(xg); }
}
}
m.entrance = m.entrances[0] || null;
m.exit = m.exits[0] || null;
if (cfg.map.assets) {
var _mapRef = m; // Crucial fix: Store reference to the map
TD.lang.each(cfg.map.assets, function (a) {
var imgObj = new Image();
imgObj.src = a.src;
var x_px = _mapRef.x + (a.mx * TD.grid_size);
var y_px = _mapRef.y + (a.my * TD.grid_size);
var w_px = a.grid_x * TD.grid_size;
var h_px = a.grid_y * TD.grid_size;
_mapRef.assets.push({
id: a.id, src: a.src, img: imgObj,
mx: a.mx, my: a.my, grid_x: a.grid_x, grid_y: a.grid_y,
blocks_path: a.blocks_path, category: a.category,
x: x_px, y: y_px, width: w_px, height: h_px,
y2: y_px + h_px, is_asset: true
});
for (var dx = 0; dx < a.grid_x; dx++) {
for (var dy = 0; dy < a.grid_y; dy++) {
var g = _mapRef.getGrid(a.mx + dx, a.my + dy);
if (g) {
if (a.category !== "path") g.build_flag = 0;
if (a.blocks_path) g.passable_flag = 0;
}
}
}
});
}
}
}
// ----------------------------------------------------------------------
this.step();
return this;
},
checkCheat: function (cheat_code) {
switch (cheat_code) {
case "money+": this.money += 1000000; break;
case "life+": this.life = 100; break;
case "life-": this.life = 1; break;
case "difficulty+": this.difficulty *= 2; break;
case "difficulty-": this.difficulty /= 2; break;
}
},
step: function () {
if (this.is_debug && _TD && _TD.cheat) {
this.checkCheat(_TD.cheat);
_TD.cheat = "";
}
if (this.is_paused) return; // Only stop entirely if the game is strictly paused
this.iframe++;
if (this.iframe % 50 == 0) {
var t = (new Date()).getTime(), step_time = this.step_time;
this.fps = Math.round(500000 / (t - this.last_iframe_time)) / 10;
this.last_iframe_time = t;
if (this.fps < this._exp_fps_0 && step_time > 1) { step_time--; }
else if (this.fps > this._exp_fps_1) { step_time++; }
this.step_time = step_time;
}
if (this.iframe % 2400 == 0) TD.gc();
if (this.admin_mode) {
// EDITOR OVERRIDE: Skip game math, but KEEP RENDERING so brushes update visually!
this.stage.render();
} else {
// NORMAL GAMEPLAY
this.stage.step();
this.stage.render();
}
var _this = this;
this._st = setTimeout(function () { _this.step(); }, this.step_time);
},
getEventXY: function (e) {
var rect = this.canvas.getBoundingClientRect();
// Dynamically calculate the exact scale between physical CSS size and internal Game size
var scaleX = this.canvas.width / rect.width;
var scaleY = this.canvas.height / rect.height;
var x = (e.clientX - rect.left) * scaleX;
var y = (e.clientY - rect.top) * scaleY;
// Prevent coordinates from bleeding off the edge
if (x < 0) x = 0;
if (y < 0) y = 0;
if (x > this.canvas.width) x = this.canvas.width;
if (y > this.canvas.height) y = this.canvas.height;
return [x, y];
},
hover: function (x, y) { this.eventManager.hover(x, y); },
click: function (x, y) { this.eventManager.click(x, y); },
mouseHand: function (v) { this.canvas.style.cursor = v ? "pointer" : "default"; },
log: function (txt) { this.is_debug && window.console && console.log && console.log(txt); },
gc: function () {
if (window.CollectGarbage) { CollectGarbage(); setTimeout(CollectGarbage, 1); }
}
};
for (i = 0; this.a[i]; i++) { this.a[i](TD); }
delete this.a;
TD.init(td_board);
}
};
_TD.a.push(function (TD) {
TD.lang = {
$e: function (el_id) { return document.getElementById(el_id); },
$c: function (tag_name, attributes, parent_node) {
var el = document.createElement(tag_name);
attributes = attributes || {};
for (var k in attributes) { if (attributes.hasOwnProperty(k)) { el.setAttribute(k, attributes[k]); } }
if (parent_node) parent_node.appendChild(el);
return el;
},
strLeft: function (s, n) {
var s2 = s.slice(0, n), i = s2.replace(/[^\x00-\xff]/g, "**").length;
if (i <= n) return s2;
i -= s2.length;
switch (i) {
case 0: return s2;
case n: return s.slice(0, n >> 1);
default:
var k = n - i, s3 = s.slice(k, n), j = s3.replace(/[\x00-\xff]/g, "").length;
return j ? s.slice(0, k) + this.arguments.callee(s3, j) : s.slice(0, k);
}
},
strLen2: function (s) { return s.replace(/[^\x00-\xff]/g, "**").length; },
each: function (list, f) {
if (Array.prototype.forEach) { list.forEach(f); }
else { for (var i = 0, l = list.length; i < l; i++) { f(list[i]); } }
},
any: function (list, f) {
for (var i = 0, l = list.length; i < l; i++) { if (f(list[i])) return list[i]; }
return null;
},
shift: function (list, f) { while (list[0]) { f(list.shift()); } },
rndSort: function (list) {
var a = list.concat();
return a.sort(function () { return Math.random() - 0.5; });
},
_rndRGB2: function (v) { var s = v.toString(16); return s.length == 2 ? s : ("0" + s); },
rndRGB: function () {
var r = Math.floor(Math.random() * 256), g = Math.floor(Math.random() * 256), b = Math.floor(Math.random() * 256);
return "#" + this._rndRGB2(r) + this._rndRGB2(g) + this._rndRGB2(b);
},
rgb2Arr: function (rgb_str) {
if (rgb_str.length != 7) return [0, 0, 0];
var r = rgb_str.substr(1, 2), g = rgb_str.substr(3, 2), b = rgb_str.substr(3, 2);
return [parseInt(r, 16), parseInt(g, 16), parseInt(b, 16)];
},
rndStr: function (n) {
n = n || 16;
var chars = "1234567890abcdefghijklmnopqrstuvwxyz", a = [], i, chars_len = chars.length, r;
for (i = 0; i < n; i++) {
r = Math.floor(Math.random() * chars_len);
a.push(chars.substr(r, 1));
}
return a.join("");
},
nullFunc: function () {},
arrayEqual: function (arr1, arr2) {
var i, l = arr1.length;
if (l != arr2.length) return false;
for (i = 0; i < l; i++) { if (arr1[i] != arr2[i]) return false; }
return true;
},
mix: function (r, s, is_overwrite) {
if (!s || !r) return r;
for (var p in s) {
if (s.hasOwnProperty(p) && (is_overwrite !== false || !(p in r))) { r[p] = s[p]; }
}
return r;
}
};
});
_TD.a.push(function (TD) {
TD.eventManager = {
ex: -1, ey: -1, _registers: {}, ontypes: [ "enter", "hover", "out", "click" ], current_type: "hover",
isOn: function (el) {
return (this.ex != -1 && this.ey != -1 && this.ex > el.x && this.ex < el.x2 && this.ey > el.y && this.ey < el.y2);
},
_mkElEvtName: function (el, evt_type) { return el.id + "::_evt_::" + evt_type; },
on: function (el, evt_type, f) { this._registers[this._mkElEvtName(el, evt_type)] = [el, evt_type, f]; },
removeEventListener: function (el, evt_type) {
var en = this._mkElEvtName(el, evt_type);
delete this._registers[en];
},
clear: function () { delete this._registers; this._registers = {}; },
step: function () {
if (!this.current_type) return;
var k, a, el, et, f, j, _this = this, ontypes_len = this.ontypes.length, is_evt_on, to_del_el = [];
for (k in this._registers) {
if (!this._registers.hasOwnProperty(k)) continue;
a = this._registers[k]; el = a[0]; et = a[1]; f = a[2];
if (!el.is_valid) { to_del_el.push(el); continue; }
if (!el.is_visiable) continue;
is_evt_on = this.isOn(el);
if (this.current_type != "click") {
if (et == "hover" && el.is_hover && is_evt_on) { f(); this.current_type = "hover"; }
else if (et == "enter" && !el.is_hover && is_evt_on) { el.is_hover = true; f(); this.current_type = "enter"; }
else if (et == "out" && el.is_hover && !is_evt_on) { el.is_hover = false; f(); this.current_type = "out"; }
} else {
if (is_evt_on && et == "click") f();
}
}
TD.lang.each(to_del_el, function (obj) {
for (j = 0; j < ontypes_len; j++) _this.removeEventListener(obj, _this.ontypes[j]);
});
this.current_type = "";
},
hover: function (ex, ey) {
if (this.current_type == "click") return;
this.current_type = "hover"; this.ex = ex; this.ey = ey;
},
click: function (ex, ey) { this.current_type = "click"; this.ex = ex; this.ey = ey; }
};
});
_TD.a.push(function (TD) {
TD.Stage = function (id, cfg) {
this.id = id || ("stage-" + TD.lang.rndStr());
this.cfg = cfg || {};
this.width = this.cfg.width || 640;
this.height = this.cfg.height || 540;
this.mode = "normal";
this.state = 0;
this.acts = [];
this.current_act = null;
this._step2 = TD.lang.nullFunc;
this._init();
};
TD.Stage.prototype = {
_init: function () {
if (typeof this.cfg.init == "function") { this.cfg.init.call(this); }
if (typeof this.cfg.step2 == "function") { this._step2 = this.cfg.step2; }
},
start: function () {
this.state = 1;
TD.lang.each(this.acts, function (obj) { obj.start(); });
},
pause: function () { this.state = 2; },
gameover: function () { this.current_act.gameover(); },
clear: function () {
this.state = 3;
TD.lang.each(this.acts, function (obj) { obj.clear(); });
},
step: function () {
if (this.state != 1 || !this.current_act) return;
TD.eventManager.step();
this.current_act.step();
this._step2();
},
render: function () {
if (this.state == 0 || this.state == 3 || !this.current_act) return;
this.current_act.render();
},
addAct: function (act) { this.acts.push(act); },
addElement: function (el, step_level, render_level) {
if (this.current_act) this.current_act.addElement(el, step_level, render_level);
}
};
TD.Act = function (stage, id) {
this.stage = stage;
this.id = id || ("act-" + TD.lang.rndStr());
this.state = 0;
this.scenes = [];
this.end_queue = [];
this.current_scene = null;
this._init();
};
TD.Act.prototype = {
_init: function () { this.stage.addAct(this); },
start: function () {
if (this.stage.current_act && this.stage.current_act.state != 3) {
this.state = 0;
this.stage.current_act.queue(this.start);
return;
}
this.state = 1;
this.stage.current_act = this;
TD.lang.each(this.scenes, function (obj) { obj.start(); });
},
pause: function () { this.state = 2; },
end: function () {
this.state = 3;
var f;
while (f = this.end_queue.shift()) { f(); }
this.stage.current_act = null;
},
queue: function (f) { this.end_queue.push(f); },
clear: function () {
this.state = 3;
TD.lang.each(this.scenes, function (obj) { obj.clear(); });
},
step: function () {
if (this.state != 1 || !this.current_scene) return;
this.current_scene.step();
},
render: function () {
if (this.state == 0 || this.state == 3 || !this.current_scene) return;
this.current_scene.render();
},
addScene: function (scene) { this.scenes.push(scene); },
addElement: function (el, step_level, render_level) {
if (this.current_scene) this.current_scene.addElement(el, step_level, render_level);
},
gameover: function () { this.current_scene.gameover(); }
};
TD.Scene = function (act, id) {
this.act = act;
this.stage = act.stage;
this.is_gameover = false;
this.id = id || ("scene-" + TD.lang.rndStr());
this.state = 0;
this.end_queue = [];
this._step_elements = [ [], [], [] ];
this._render_elements = [ [], [], [], [], [], [], [], [], [], [] ];
this._init();
};
TD.Scene.prototype = {
_init: function () { this.act.addScene(this); this.wave = 0; },
start: function () {
if (this.act.current_scene && this.act.current_scene != this && this.act.current_scene.state != 3) {
this.state = 0;
this.act.current_scene.queue(this.start);
return;
}
this.state = 1;
this.act.current_scene = this;
},
pause: function () { this.state = 2; },
end: function () {
this.state = 3;
var f;
while (f = this.end_queue.shift()) { f(); }
this.clear();
this.act.current_scene = null;
},
clear: function () {
TD.lang.shift(this._step_elements, function (obj) {
TD.lang.shift(obj, function (obj2) { obj2.del(); });
});
TD.lang.shift(this._render_elements, function (obj) {
TD.lang.shift(obj, function (obj2) { obj2.del(); });
});
},
queue: function (f) { this.end_queue.push(f); },
gameover: function () {
if (this.is_gameover) return;
this.pause();
this.is_gameover = true;
},
step: function () {
if (this.state != 1) return;
if (TD.life <= 0) { TD.life = 0; this.gameover(); }
var i, a;
for (i = 0; i < 3; i++) {
a = [];
var level_elements = this._step_elements[i];
TD.lang.shift(level_elements, function (obj) {
if (obj.is_valid) {
if (!obj.is_paused) obj.step();
a.push(obj);
} else { setTimeout(function () { obj = null; }, 500); }
});
this._step_elements[i] = a;
}
},
render: function () {
if (this.state == 0 || this.state == 3) return;
var i, a, ctx = TD.ctx;
ctx.clearRect(0, 0, this.stage.width, this.stage.height);
for (i = 0; i < 10; i++) {
a = [];
var level_elements = this._render_elements[i];
TD.lang.shift(level_elements, function (obj) {
if (obj.is_valid) {
if (obj.is_visiable) obj.render();
a.push(obj);
}
});
this._render_elements[i] = a;
}
if (this.is_gameover) { this.panel.gameover_obj.show(); }
},
addElement: function (el, step_level, render_level) {
step_level = step_level || el.step_level || 1;
render_level = render_level || el.render_level;
this._step_elements[step_level].push(el);
this._render_elements[render_level].push(el);
el.scene = this;
el.step_level = step_level;
el.render_level = render_level;
}
};
});
_TD.a.push(function (TD) {
TD.Element = function (id, cfg) {
this.id = id || ("el-" + TD.lang.rndStr());
this.cfg = cfg || {};
this.is_valid = true;
this.is_visiable = typeof cfg.is_visiable != "undefined" ? cfg.is_visiable : true;
this.is_paused = false;
this.is_hover = false;
this.x = this.cfg.x || -1;
this.y = this.cfg.y || -1;
this.width = this.cfg.width || 0;
this.height = this.cfg.height || 0;
this.step_level = cfg.step_level || 1;
this.render_level = cfg.render_level;
this.on_events = cfg.on_events || [];
this._init();
};
TD.Element.prototype = {
_init: function () {
var _this = this, i, en, len;
for (i = 0, len = this.on_events.length; i < len; i++) {
en = this.on_events[i];
switch (en) {
case "enter": this.on("enter", function () { _this.onEnter(); }); break;
case "out": this.on("out", function () { _this.onOut(); }); break;
case "hover": this.on("hover", function () { _this.onHover(); }); break;
case "click": this.on("click", function () { _this.onClick(); }); break;
}
}
this.caculatePos();
},
caculatePos: function () {
this.cx = this.x + this.width / 2;
this.cy = this.y + this.height / 2;
this.x2 = this.x + this.width;
this.y2 = this.y + this.height;
},
start: function () { this.is_paused = false; },
pause: function () { this.is_paused = true; },
hide: function () { this.is_visiable = false; this.onOut(); },
show: function () { this.is_visiable = true; },
del: function () { this.is_valid = false; },
on: function (evt_type, f) { TD.eventManager.on(this, evt_type, f); },
onEnter: TD.lang.nullFunc, onOut: TD.lang.nullFunc, onHover: TD.lang.nullFunc, onClick: TD.lang.nullFunc, step: TD.lang.nullFunc, render: TD.lang.nullFunc,
addToScene: function (scene, step_level, render_level, pre_add_list) {
this.scene = scene;
if (isNaN(step_level)) return;
this.step_level = step_level || this.step_level;
this.render_level = render_level || this.render_level;
if (pre_add_list) {
TD.lang.each(pre_add_list, function (obj) { scene.addElement(obj, step_level, render_level); });
}
scene.addElement(this, step_level, render_level);
}
};
});
_TD.a.push(function (TD) {
TD._msg_texts = {
"_cant_build": "Can't build here!", "_cant_pass": "Can't pass!", "entrance": "Entrance", "exit": "Exit",
"not_enough_money": "Not enough money, need $${0}.", "wave_info": "Wave ${0}", "panel_money_title": "Money: ",
"panel_score_title": "Score: ", "panel_life_title": "Life: ", "panel_building_title": "Buildings: ", "panel_monster_title": "Monsters: ",
"building_name_wall": "Roadblock", "building_name_cannon": "Cannon", "building_name_LMG": "LMG", "building_name_HMG": "HMG",
"building_name_laser_gun": "Laser gun", "building_info": "${0}: Level ${1}, Damage ${2}, Speed ${3}, Range ${4}, Kill ${5}",
"building_info_wall": "${0}", "building_intro_wall": "Roadblock: monsters could not pass ($${0})",
"building_intro_cannon": "Cannon: balance in range and damage ($${0})", "building_intro_LMG": "Light Machine Gun: longer range, normal damage ($${0})",
"building_intro_HMG": "Heavy Machine Gun: fast shoot, greater damage, normal range ($${0})", "building_intro_laser_gun": "Laser gun: greater damage, 100% hit ($${0})",
"click_to_build": "Left click to build ${0} ($${1})", "upgrade": "Upgrade ${0} to level ${1} , cost $${2}。",
"sell": "Sell ${0} for $${1}", "upgrade_success": "Upgrade success! ${0} upgrades to level ${1}. Next upgrade will take $${2}.",
"monster_info": "Monster: Life ${0}, Shield ${1}, Speed ${2}, Damage ${3}", "button_build_text": "Build", "button_upgrade_text": "Upgrade",
"button_sell_text": "Sell", "button_start_text": "Start", "button_restart_text": "Restart", "button_pause_text": "Pause",
"button_continue_text": "Continue", "button_pause_desc_0": "Pause the game", "button_pause_desc_1": "Resume the game",
"blocked": "Can't build here, it will block the way from entrance to exit!", "monster_be_blocked": "Can't build here, some monster will be blocked!",
"entrance_or_exit_be_blocked": "Can't build on the entrance or the exit!", "_": "ERROR"
};
TD._t = TD.translate = function (k, args) {
args = (typeof args == "object" && args.constructor == Array) ? args : [];
var msg = this._msg_texts[k] || this._msg_texts["_"], i, l = args.length;
for (i = 0; i < l; i++) { msg = msg.replace("${" + i + "}", args[i]); }
return msg;
};
});
_TD.a.push(function (TD) {
TD.default_upgrade_rule = function (old_level, old_value) { return old_value * 1.2; };
// --- NEW: DYNAMIC TACTICAL MATCHUP GENERATOR ---
TD.getTacticalMatchups = function(tower) {
var strong = [], weak = [], cannot = [];
var vType = tower.visual_type || tower.type;
var spd = tower.speed || 1.0;
var isEarlyTower = (tower.type === "tower_1" || tower.type === "tower_2" || tower.type === "tower_3" || (tower.id && (tower.id.endsWith("_1") || tower.id.endsWith("_2") || tower.id.endsWith("_3"))));
if (isEarlyTower) {
cannot.push("Air");
} else if (vType === "laser_gun" || vType === "LMG" || vType === "HMG" || vType === "rifle") {
strong.push("Air");
} else {
weak.push("Air");
}
if (spd >= 3.0) { strong.push("Swarms"); weak.push("Armor"); }
else if (spd <= 1.0) { strong.push("Armor"); weak.push("Swarms"); }
if (vType === "cannon") strong.push("Groups");
if (tower.is_cryo) strong.push("Fast Foes");
if (tower.is_detector) { strong = ["Stealth"]; weak = ["Combat"]; }
if (strong.length === 0) strong.push("General");
if (weak.length === 0) weak.push("None");
return { strong: strong.join(", "), weak: weak.join(", "), cannot: cannot.join(", ") };
};
TD.getDefaultBuildingAttributes = function (building_type) {
var faction = null;
var idx = -1;
if (building_type && building_type.indexOf("tower_") === 0 && TD.current_faction) {
idx = parseInt(building_type.split("_")[1]) - 1;
faction = TD.current_faction;
} else if (building_type) {
// Check specific faction IDs
if (building_type === "mr_handy") { faction = TD.current_faction || "NCR"; idx = 10; }
else if (building_type.indexOf("ncr_") === 0) { faction = "NCR"; idx = parseInt(building_type.split("_")[1]) - 1; }
else if (building_type.indexOf("bos_") === 0) { faction = "BOS"; idx = parseInt(building_type.split("_")[1]) - 1; }
else if (building_type.indexOf("mm_") === 0) { faction = "MINUTEMEN"; idx = parseInt(building_type.split("_")[1]) - 1; }
else if (building_type.indexOf("rk_") === 0) { faction = "RAIDERS"; idx = parseInt(building_type.split("_")[1]) - 1; }
}
if (faction && idx >= 0 && TD.factionData[faction] && TD.factionData[faction][idx]) {
var tData = TD.factionData[faction][idx];
var attrs = {};
// Clone stats directly from Faction Data
for (var k in tData) attrs[k] = tData[k];
// CRITICAL FIX: Ensure the engine uses the internal type name, NOT the visual fallback type
attrs.type = building_type;
// Default upgrade rules
attrs._upgrade_rule_damage = function(old_level, old_value) { return old_value * 1.25; };
attrs._upgrade_rule_range = function(old_level, old_value) { return old_value + 0.1; };
attrs._upgrade_rule_speed = function(old_level, old_value) { return old_value * 1.05; };
return attrs;
}
// If a tower fails to load or is invalid, return a harmless, non-firing block
TD.log("ERROR: Attempted to load invalid tower type: " + building_type);
return { damage: 0, range: 0, speed: 0, bullet_speed: 0, life: 100, shield: 100, cost: 9999, type: "error" };
};
// --- NEW: FACTION TOWER DATA ---
TD.factionData = {
"NCR": typeof ncr_towers !== 'undefined' ? ncr_towers : [],
"BOS": typeof bos_towers !== 'undefined' ? bos_towers : [],
"MINUTEMEN": typeof mm_towers !== 'undefined' ? mm_towers : [],
"RAIDERS": typeof raider_towers !== 'undefined' ? raider_towers : []
};
// Inject Mr. Handy as the 11th "tower" for all factions
for (var f in TD.factionData) {
if (TD.factionData.hasOwnProperty(f)) {
TD.factionData[f].push({
id: "mr_handy",
name: "MR. HANDY",
cost: 500,
unlock_cost: 4000,
is_unlocked: false,
damage: "Melee",
range: "Map",
speed: "Fast"
});
}
}
// --- NEW: ASSET LIBRARY DATA ---
TD.assetLibrary = {
"Paths": [
{ id: "path_dirt", name: "Dirt Tile", src: "assets/images/path1.png", grid_x: 1, grid_y: 1, blocks_path: false, category: "path" },
{ id: "path_stone", name: "Stone Tile", src: "assets/images/path2.png", grid_x: 1, grid_y: 1, blocks_path: false, category: "path" }
],
"Wasteland General": [
{ id: "waste_1x1", name: "Small Box", src: "assets/images/0.png", grid_x: 1, grid_y: 1, blocks_path: true, category: "prop" },
{ id: "waste_1x2", name: "Tall Pillar", src: "assets/images/00.png", grid_x: 1, grid_y: 2, blocks_path: true, category: "prop" },
{ id: "waste_2x2", name: "Large Wreck", src: "assets/images/0000.png", grid_x: 2, grid_y: 2, blocks_path: true, category: "prop" }
],
"Nuka-World": [
// Example of how you can add more themes later
]
};
TD.current_editor_asset = null; // Stores the currently selected asset from the library
TD.loadFaction = function(factionName) {
TD.current_faction = factionName;
var towers = TD.factionData[factionName];
if (!towers) return;
for (let i = 0; i < 11; i++) {
let btn = document.getElementById("btn-buy-tower-" + (i + 1));
if (!btn) continue;
let tData = towers[i];
if (tData) {
// Notice: We removed the old campaign hardcoded limits!
// The Wave Editor handles tower visibility now.
// Dynamically assign unlock cost: Towers 5-11 (index 4-10) cost 1000 + 500 per tier
tData.unlock_cost = (i >= 4) ? (1000 + ((i - 4) * 500)) : 0;
if (tData.is_unlocked) {
var displayCost = tData.free_first_build ? "<span style='color:#0f0;'>(READY)</span>" : "(" + tData.cost + "c)";
btn.innerHTML = tData.name + " " + displayCost;
// If it was previously unlit, it just unlocked! Trigger the flicker.
if (btn.classList.contains('unlit')) {
btn.classList.remove('unlit');
btn.classList.remove('vats-charged-glow'); // Remove the flashing glow
btn.className = btn.className.replace(/warm-up-\d/g, "").trim();
void btn.offsetWidth; // Force CSS reflow
btn.classList.add("warm-up-" + (Math.floor(Math.random() * 5) + 1));
}
// Closure fix: capture the correct reference to tData
(function(td) {
btn.onmouseover = function() {
var tooltip = document.getElementById("tower-tooltip");
if (tooltip) {
var tact = TD.getTacticalMatchups(td);
var cannotHtml = tact.cannot ? "<br><span style='color:#ff5555'>Cannot target: " + tact.cannot + "</span>" : "";
tooltip.innerHTML = td.name + "<br>Dmg: " + td.damage + " | Rng: " + td.range + " | Spd: " + td.speed + "<br><br><span style='color:#0f0'>Strong vs: " + tact.strong + "</span><br><span style='color:#f55'>Weak vs: " + tact.weak + "</span>" + cannotHtml;
tooltip.style.display = "block";
}
};
})(tData);
} else {
var svgLock = "<svg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' style='vertical-align:middle;margin-right:3px;'><rect x='3' y='11' width='18' height='11' rx='2' ry='2'></rect><path d='M7 11V7a5 5 0 0 1 10 0v4'></path></svg>";
// Locked but available to purchase
btn.innerHTML = svgLock + tData.name;
btn.classList.add('unlit');
// Closure fix: capture the correct reference to tData
(function(td) {
btn.onmouseover = function() {
var tooltip = document.getElementById("tower-tooltip");
if (tooltip) {
var tact = TD.getTacticalMatchups(td);
var cannotHtml = tact.cannot ? "<br><span style='color:#ff5555'>Cannot target: " + tact.cannot + "</span>" : "";
tooltip.innerHTML = td.name + " <span style='color:#ff5555'>[LOCKED]</span><br>Dmg: " + td.damage + " | Rng: " + td.range + " | Spd: " + td.speed + "<br><br><span style='color:#0f0'>Strong vs: " + tact.strong + "</span><br><span style='color:#f55'>Weak vs: " + tact.weak + "</span>" + cannotHtml + "<br><br>Unlock Cost: <span style='color:#ffaa00;font-weight:bold;'>" + td.unlock_cost + "c</span>";
tooltip.style.display = "block";
}
};
})(tData);
}
btn.onmouseout = function() {
var tooltip = document.getElementById("tower-tooltip");
if (tooltip) tooltip.style.display = "none";
};
btn.disabled = false;
}
}
// --- NEW: DYNAMIC FACTION CONSUMABLE (6TH SLOT) ---
var btnSpecial = document.getElementById("btn-item-blank") || document.getElementById("btn-item-ncr_artillery") || document.getElementById("btn-item-bos_laser") || document.getElementById("btn-item-mm_flare") || document.getElementById("btn-item-raider_nuka");
if (btnSpecial) {
var specialData = {
"NCR": { id: "ncr_artillery", name: "BARRAGE", desc: "Target path for walking artillery sequence." },
"BOS": { id: "bos_laser", name: "ORBITAL", desc: "Guide a laser from the sky." },
"MINUTEMEN": { id: "mm_flare", name: "MILITIA", desc: "Summon blockaders on the path." },
"RAIDERS": { id: "raider_nuka", name: "NUKA GRENADE", desc: "Huge blast + radiation zone." }
};
var sData = specialData[factionName];
if (sData) {
// Morphs the HTML button into the correct item
btnSpecial.id = "btn-item-" + sData.id;
btnSpecial.title = sData.desc;
// Wire up click event to deploy
btnSpecial.onclick = function() {
if (TD.inventory[sData.id] > 0 && TD.cooldowns[sData.id] <= 0 && TD.stage && TD.stage.current_act) {
var map = TD.stage.current_act.current_scene.map;
if (map) {
map.cancelPreBuild();
TD.mode = "deploy_" + sData.id;
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "DEPLOYING " + sData.name + ". Click map to target.";
TD.mouseHand(true);
}
}
};
TD.updateItemButton(sData.id); // Force visual update
}
}
};
});
_TD.a.push(function (TD) {
function defaultMonsterRender() {
if (!this.is_valid || !this.grid) return;
// NEW: Check if the calling context is the Y-sort queue, otherwise skip to prevent double-drawing
if (arguments[0] !== "ysort") return;
var ctx = TD.ctx;
// V.A.T.S. Highlight
if (TD.vats_active) {
ctx.strokeStyle = "#00ff00";
ctx.lineWidth = 3 * _TD.retina;
} else {
ctx.strokeStyle = "#000";
ctx.lineWidth = 1;
}
// Smooth Fading Render for Stealth
var rgb = TD.lang.rgb2Arr(this.color);
var alpha = typeof this.stealth_alpha !== 'undefined' ? this.stealth_alpha : 1.0;
if (this.is_stealthed || this.is_burrowing) {
// Draw faint hollow outline permanently while flagged as stealth/burrow
ctx.strokeStyle = "rgba(0, 255, 0, 0.3)";
ctx.lineWidth = 2 * _TD.retina;
}
// Draw the solid core fading in and out based on alpha
ctx.fillStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + alpha + ")";
ctx.beginPath(); ctx.arc(this.cx, this.cy, this.r, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
// Only show health if they are visible enough
if (TD.show_monster_life && alpha > 0.3) {
var s = Math.floor(TD.grid_size / 4), l = s * 2 - 2 * _TD.retina;
ctx.fillStyle = "#000"; ctx.beginPath(); ctx.fillRect(this.cx - s, this.cy - this.r - 6, s * 2, 4 * _TD.retina); ctx.closePath();
ctx.fillStyle = "#f00"; ctx.beginPath(); ctx.fillRect(this.cx - s + _TD.retina, this.cy - this.r - (6 - _TD.retina), this.life * l / this.life0, 2 * _TD.retina); ctx.closePath();
}
}
TD.getDefaultMonsterAttributes = function (monster_idx) {
var monster_attributes = [
{ name: "Radroach", desc: "Weak, fast-ish pest", speed: 3, max_speed: 10, life: 50, damage: 1, shield: 0, money: 5 },
{ name: "Feral Ghoul", desc: "Mindless irradiated human", speed: 6, max_speed: 20, life: 50, damage: 2, shield: 1 },
{ name: "Mole Rat", desc: "Fast underground creature", speed: 12, max_speed: 30, life: 50, damage: 3, shield: 1 },
{ name: "Super Mutant", desc: "Large, mutated brute", speed: 5, max_speed: 10, life: 500, damage: 3, shield: 1 },
{ name: "Protectron", desc: "Armored security bot", speed: 5, max_speed: 10, life: 50, damage: 3, shield: 20 },
{ name: "Glowing One", desc: "Highly irradiated, deals massive base damage", speed: 7, max_speed: 14, life: 50, damage: 10, shield: 2 },
{ name: "Deathclaw", desc: "Extremely fast, tough apex predator", speed: 15, max_speed: 30, life: 100, damage: 3, shield: 3 },
{ name: "Sentry Bot", desc: "Heavily armored military robot", speed: 3, max_speed: 10, life: 300, damage: 5, shield: 15 },
// --- NEW: PHASED MONSTERS ---
{ name: "Cazador", is_flying: true, desc: "Lightning fast flying wasp", speed: 15, max_speed: 25, life: 40, damage: 4, shield: 1, color: "#dd9900" },
{ name: "Stingwing", is_flying: true, desc: "Agile flying pest", speed: 12, max_speed: 20, life: 25, damage: 2, shield: 0, color: "#88cc33" },
{ name: "Scorchbeast", is_flying: true, desc: "Massive flying terror", speed: 4, max_speed: 10, life: 2500, damage: 30, shield: 5, color: "#551111" },
{ name: "Nightkin", is_stealthed: true, desc: "Tough, stealthed mutant", speed: 8, max_speed: 15, life: 800, damage: 8, shield: 2, color: "#330066" }
];
if (typeof monster_idx == "undefined") { return monster_attributes.length; }
var attr = monster_attributes[monster_idx] || monster_attributes[0], attr2 = {};
TD.lang.mix(attr2, attr);
if (!attr2.render) { attr2.render = defaultMonsterRender; }
return attr2;
};
TD.WaveDirector = {
consecutive_breather_waves: 0,
survival_waves: [
[], // Index 0 (unused)
[[3, 0]], // Wave 1: 3 Roaches
[[5, 0]], // Wave 2: 5 Roaches
[[4, 0], [1, 1]], // Wave 3: 4 Roaches, 1 Ghoul
[[4, 0], [3, 1]], // Wave 4: 4 Roaches, 3 Ghouls
[[1, 0], [5, 1]], // Wave 5: 1 Roach, 5 Ghouls
[[2, 0], [2, 1], [1, 3]], // Wave 6: 2 Roaches, 2 Ghouls, 1 Mutant
[[3, 0], [3, 1], [2, 3]], // Wave 7: 3 Roaches, 3 Ghouls, 2 Mutants
[[2, 1], [3, 3]], // Wave 8: 2 Ghouls, 3 Mutants
[[4, 3]], // Wave 9: 4 Mutants
[[10, 1]] // Wave 10: 10 Ghouls (The Swarm)
],
generateDynamicWave: function(wave_idx, is_campaign, map) {
var difficulty = TD.difficulty || 1.0;
// 1. CALCULATE PLAYER DPS OUTPUT
var total_dps = 0;
if (map && map.buildings) {
TD.lang.each(map.buildings, function(b) {
if (b.is_weapon) {
var aps = b.speed > 0 ? (1 / b.speed) : 1;
var pierce_mult = b.pierce_count ? b.pierce_count : 1;
var aoe_mult = (b.visual_type === "cannon" || b.fire_pattern === "cluster") ? 3 : 1;
var dps = (b.damage * aps * pierce_mult * aoe_mult);
total_dps += dps;
}
});
}
// 2. DYNAMIC BUDGETING
// Smoothly curve the base budget using wave count, and heavily nerf the DPS tax (from 70% to 15%)
var baseline_threat = (total_dps * 0.15) + (wave_idx * 3) + 15;
var budget = baseline_threat * difficulty;
var minimum_budget = wave_idx * 4;
var maximum_budget = 30 + (wave_idx * 10);
if (budget < minimum_budget) budget = minimum_budget;
if (budget > maximum_budget) budget = maximum_budget; // STOPS INFINITE INFLATION
// 3. THE BREATHER CHECK
var is_siege = map && map.map_type === "siege";
var hp_percent = is_siege ? (TD.base_health / 10) : TD.life;
var force_breather = false;
if (hp_percent < 25) {
if (this.consecutive_breather_waves === 0) {
budget *= 0.6; // 40% reduction for breather
force_breather = true;
this.consecutive_breather_waves = 1;
} else {
this.consecutive_breather_waves = 0; // No mercy
}
} else {
this.consecutive_breather_waves = 0; // Reset if healthy
}
// Bestiary: [Monster ID, Cost, Type, Max Per Wave, Batch Limit]
// Types: 0=Swarm, 1=Tank, 2=Flyer, 3=Stealth, 4=Boss
var bestiary = [
[0, 2, 0, 99, 5], [1, 3, 0, 99, 5], [2, 4, 0, 99, 5], // Swarms (Cheap, unlimited, max 5)
[4, 8, 1, 99, 3], [3, 12, 1, 99, 3], [5, 15, 1, 99, 2], [7, 25, 1, 99, 2], // Tanks (Slightly restricted)
[9, 5, 2, 99, 3], [8, 6, 2, 99, 3], // Flyers (Max 3 per cluster)
[11, 20, 3, 5, 1], // Stealth (Max 5 total, 1 at a time)
[6, 40, 4, 1, 1], [10, 60, 4, 1, 1] // Bosses (Max 1 total, 1 at a time)
];
// 4. TELEGRAPHED SCHEDULE & RESTRICTIONS
var allowed = bestiary.filter(function(m) {
if (m[2] === 4 && (wave_idx < 18 || force_breather)) return false;
if (m[2] === 3 && (wave_idx < 14 || force_breather)) return false;
if (m[2] === 2 && (wave_idx < 11)) return false;
return true;
});
// 5. ASSEMBLE WAVE
var wave_composition = [];
var spawn_counts = { 3: 0, 4: 0 };
// --- NEW: STRICT UNIT CAP TO PREVENT BLOBS ---
// HORDE MODE: Gentle growth until Wave 40, then explosive swarm growth
var max_units_this_wave = 10 + Math.floor(wave_idx * 0.5);
if (wave_idx > 40) {
max_units_this_wave += (wave_idx - 40) * 2; // AI swarm limit unleashes
}
var total_spawned = 0;
while (budget > 0 && allowed.length > 0 && total_spawned < max_units_this_wave) {
var pick = allowed[Math.floor(Math.random() * allowed.length)];
var id = pick[0], cost = pick[1], type = pick[2], max_wave = pick[3], max_batch = pick[4];
if (budget >= cost) {
var qty = Math.min(Math.floor(budget / cost), Math.floor(Math.random() * max_batch) + 1);
// Respect the max units physical limit
if (total_spawned + qty > max_units_this_wave) {
qty = max_units_this_wave - total_spawned;
}
if (type === 3 || type === 4) {
if (spawn_counts[type] + qty > max_wave) qty = max_wave - spawn_counts[type];
spawn_counts[type] += qty;
if (spawn_counts[type] >= max_wave) allowed = allowed.filter(function(m) { return m[2] !== type; });
}
if (qty > 0) {
wave_composition.push([qty, id]);
budget -= (cost * qty);
total_spawned += qty;
} else {
// Failsafe: Remove from pool to prevent infinite loops if qty gets clamped to 0
allowed = allowed.filter(function(m) { return m[2] !== type; });
}
} else {
allowed = allowed.filter(function(m) { return m[1] <= budget; });
}
}
return wave_composition;
}
};
TD.makeMonsters = function (n, range) {
var current_wave = 1;
var is_campaign = window.is_campaign_mode === true;
var map = null;
// Attempt to get real-time state
if (TD.stage && TD.stage.current_act && TD.stage.current_act.current_scene) {
current_wave = TD.stage.current_act.current_scene.wave + 1; // +1 for the incoming wave
map = TD.stage.current_act.current_scene.map;
}
// --- NEW: Custom Spawns Hook ---
if (TD.stage && TD.stage.cfg && TD.stage.cfg.custom_waves) {
var cWave = TD.stage.cfg.custom_waves[current_wave];
if (cWave && cWave.spawns && cWave.spawns.length > 0) {
var customArr = [];
for (var i = 0; i < cWave.spawns.length; i++) {
customArr.push([cWave.spawns[i].qty, cWave.spawns[i].monster_idx]);
}
return customArr;
}
}
// -------------------------------
// 1. Survival Predefined (Waves 1-10)
if (!is_campaign && current_wave <= 10) {
if (TD.WaveDirector.survival_waves[current_wave]) return TD.WaveDirector.survival_waves[current_wave];
}
// 2. Dynamic Curated Director (Survival 11+ OR Campaign 6+)
if ((!is_campaign && current_wave > 10) || (is_campaign && current_wave > 5)) {
if (map) return TD.WaveDirector.generateDynamicWave(current_wave, is_campaign, map);
}
// 3. Fallback for Initialization or Campaign 1-5 (if not hardcoded in maps.js)
n = n || 10;
var a = [], count = 0, i, c, d, r;
var max_allowed_idx = 7;
if (current_wave >= 12) max_allowed_idx = 11;
else if (current_wave >= 8) max_allowed_idx = 11;
else if (current_wave >= 6) max_allowed_idx = 9;
if (!range) { range = []; for (i = 0; i <= max_allowed_idx; i++) { range.push(i); } }
while (count < n) {
d = n - count;
c = Math.min(Math.floor(Math.random() * d) + 1, 3);
r = Math.floor(Math.random() * range.length);
a.push([c, range[r]]);
count += c;
}
return a;
};
});
_TD.a.push(function (TD) {
TD.FindWay = function (w, h, x1, y1, x2, y2, f_passable) {
this.m = []; this.w = w; this.h = h; this.x1 = x1; this.y1 = y1; this.x2 = x2; this.y2 = y2;
this.way = []; this.len = this.w * this.h; this.is_blocked = this.is_arrived = false;
this.fPassable = typeof f_passable == "function" ? f_passable : function () { return true; };
this._init();
};
TD.FindWay.prototype = {
_init: function () {
if (this.x1 == this.x2 && this.y1 == this.y2) { this.is_arrived = true; this.way = [[this.x1, this.y1]]; return; }
for (var i = 0; i < this.len; i++) this.m[i] = -2;
this.x = this.x1; this.y = this.y1; this.distance = 0; this.current = [[this.x, this.y]];
this.setVal(this.x, this.y, 0);
while (this.next()) {}
},
getVal: function (x, y) { var p = y * this.w + x; return p < this.len ? this.m[p] : -1; },
setVal: function (x, y, v) { var p = y * this.w + x; if (p > this.len) return false; this.m[p] = v; },
getNeighborsOf: function (x, y) {
var nbs = [];
if (y > 0) nbs.push([x, y - 1]);
if (x < this.w - 1) nbs.push([x + 1, y]);
if (y < this.h - 1) nbs.push([x, y + 1]);
if (x > 0) nbs.push([x - 1, y]);
return nbs;
},
getAllNeighbors: function () {
var nbs = [], nb1, i, c, l = this.current.length;
for (i = 0; i < l; i++) { c = this.current[i]; nb1 = this.getNeighborsOf(c[0], c[1]); nbs = nbs.concat(nb1); }
return nbs;
},
findWay: function () {
var x = this.x2, y = this.y2, nb, max_len = this.len, nbs_len, nbs, i, l, v, min_v = -1, closest_nbs;
while ((x != this.x1 || y != this.y1) && min_v != 0 && this.way.length < max_len) {
this.way.unshift([x, y]); nbs = this.getNeighborsOf(x, y); nbs_len = nbs.length; closest_nbs = []; min_v = -1;
for (i = 0; i < nbs_len; i++) { v = this.getVal(nbs[i][0], nbs[i][1]); if (v < 0) continue; if (min_v < 0 || min_v > v) min_v = v; }
for (i = 0; i < nbs_len; i++) { nb = nbs[i]; if (min_v == this.getVal(nb[0], nb[1])) { closest_nbs.push(nb); } }
l = closest_nbs.length; i = l > 1 ? Math.floor(Math.random() * l) : 0; nb = closest_nbs[i]; x = nb[0]; y = nb[1];
}
},
arrive: function () { this.current = []; this.is_arrived = true; this.findWay(); },
blocked: function () { this.current = []; this.is_blocked = true; },
next: function () {
var neighbors = this.getAllNeighbors(), nb, l = neighbors.length, valid_neighbors = [], x, y, i, v;
this.distance++;
for (i = 0; i < l; i++) {
nb = neighbors[i]; x = nb[0]; y = nb[1];
if (this.getVal(x, y) != -2) continue;
if (this.fPassable(x, y)) { v = this.distance; valid_neighbors.push(nb); }
else { v = -1; }
this.setVal(x, y, v);
if (x == this.x2 && y == this.y2) { this.arrive(); return false; }
}
if (valid_neighbors.length == 0) { this.blocked(); return false; }
this.current = valid_neighbors; return true;
}
};
});
_TD.a.push(function (TD) {
var grid_obj = {
_init: function (cfg) {
cfg = cfg || {}; this.map = cfg.map; this.scene = this.map.scene;
this.mx = cfg.mx; this.my = cfg.my; this.width = TD.grid_size; this.height = TD.grid_size;
this.is_entrance = this.is_exit = false; this.passable_flag = 1; this.build_flag = 1; this.building = null;
this.caculatePos();
},
caculatePos: function () {
this.x = this.map.x + this.mx * TD.grid_size; this.y = this.map.y + this.my * TD.grid_size;
this.x2 = this.x + TD.grid_size; this.y2 = this.y + TD.grid_size;
this.cx = Math.floor(this.x + TD.grid_size / 2); this.cy = Math.floor(this.y + TD.grid_size / 2);
},
checkBlock: function () {
if (this.is_entrance || this.is_exit) { this._block_msg = TD._t("entrance_or_exit_be_blocked"); return true; }
if (this.map.map_type === "siege") return false;
var is_blocked = false;
var _this = this;
// Every entrance must be able to reach at least one exit
if (this.map.entrances && this.map.exits) {
for (var i = 0; i < this.map.entrances.length; i++) {
var ent = this.map.entrances[i];
var canReach = false;
for (var j = 0; j < this.map.exits.length; j++) {
var ext = this.map.exits[j];
var fw = new TD.FindWay(this.map.grid_x, this.map.grid_y, ent.mx, ent.my, ext.mx, ext.my, function (x, y) { return !(x == _this.mx && y == _this.my) && _this.map.checkPassable(x, y); });
if (!fw.is_blocked) { canReach = true; break; }
}
if (!canReach) { is_blocked = true; break; }
}
}
if (!is_blocked) { is_blocked = !!this.map.anyMonster(function (obj) { return obj.chkIfBlocked(_this.mx, _this.my); }); if (is_blocked) this._block_msg = TD._t("monster_be_blocked"); }
else { this._block_msg = TD._t("blocked"); }
return is_blocked;
},
buyBuilding: function (building_type) {
var cost = TD.getDefaultBuildingAttributes(building_type).cost || 0;
var tData = null;
if (building_type && building_type.indexOf("tower_") === 0 && TD.current_faction) {
var idx = parseInt(building_type.split("_")[1]) - 1;
tData = TD.factionData[TD.current_faction][idx];
if (tData && tData.free_first_build) cost = 0;
}
if (TD.money >= cost) {
TD.money -= cost;
this.addBuilding(building_type);
// Consume the free build flag and update UI
if (tData && tData.free_first_build) {
tData.free_first_build = false;
TD.loadFaction(TD.current_faction);
}
}
else { TD.log(TD._t("not_enough_money", [cost])); this.scene.panel.balloontip.msg(TD._t("not_enough_money", [cost]), this); }
},
addBuilding: function (building_type) {
if (this.building) { this.removeBuilding(); }
var building = new TD.Building("building-" + building_type + "-" + TD.lang.rndStr(), { type: building_type, step_level: this.step_level, render_level: this.render_level });
building.locate(this); this.scene.addElement(building, this.step_level, this.render_level + 1);
this.map.buildings.push(building); this.building = building; this.build_flag = 2;
this.map.checkHasWeapon();
if (this.map.pre_building) this.map.pre_building.hide();
},
removeBuilding: function () { if (this.build_flag == 2) this.build_flag = 1; if (this.building) this.building.remove(); this.building = null; },
addMonster: function (monster) { monster.beAddToGrid(this); this.map.monsters.push(monster); monster.start(); },
hightLight: function (show) { this.map.select_hl[show ? "show" : "hide"](this); },
render: function () {
var ctx = TD.ctx, px = this.x + 0.5, py = this.y + 0.5;
if (this.is_hover && TD.mode === "build") { ctx.fillStyle = "rgba(255, 255, 200, 0.2)"; ctx.beginPath(); ctx.fillRect(px, py, this.width, this.height); ctx.closePath(); ctx.fill(); }
// ADMIN OVERLAY COLORS
if ((TD.admin_mode && TD.admin_overlay !== false) || TD.show_grid) {
// Path Brush: Monsters walk, no towers (Blue)
if (this.passable_flag == 1 && this.build_flag == 0) { ctx.fillStyle = "rgba(0, 100, 255, 0.3)"; ctx.beginPath(); ctx.fillRect(px, py, this.width, this.height); ctx.closePath(); ctx.fill(); }
// Block Brush: No monsters, no towers (Red)
else if (this.passable_flag == 0 && this.build_flag == 0) { ctx.fillStyle = "rgba(255, 0, 0, 0.3)"; ctx.beginPath(); ctx.fillRect(px, py, this.width, this.height); ctx.closePath(); ctx.fill(); }
// Note: The Default "Clear" state is passable=1 and build=1. We draw NOTHING here so you can see the background image.
}
// Only draw arrows if we are alive
var isAlive = (this.map.map_type === "siege") ? (TD.base_health > 0) : (TD.life > 0);
if ((this.is_entrance || this.is_exit) && !window.is_demo_mode && isAlive) {
// Sine wave using the global frame counter for a pulsing effect (0 to 1)
var pulseAlpha = Math.abs(Math.sin(TD.iframe / 12));
ctx.globalAlpha = 0.5 + (pulseAlpha * 0.5); // Minimum alpha adjusted so the outline remains visible
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "bold " + Math.floor(TD.grid_size * 0.8) + "px Arial"; // Slightly larger arrow
ctx.lineWidth = 3 * _TD.retina; // Outline thickness
ctx.strokeStyle = "#000000"; // Crisp black outline
// Save context state to rotate just the arrow
ctx.save();
ctx.translate(this.cx, this.cy);
if (this.is_entrance) {
ctx.fillStyle = "#00ff00"; // Green
ctx.rotate((this.dir || 0) * Math.PI / 2);
ctx.strokeText("➤", 0, 0); // Draw black outline
ctx.fillText("➤", 0, 0); // Draw green arrow
} else {
ctx.fillStyle = "#ff0000"; // Red
ctx.rotate((this.dir || 0) * Math.PI / 2);
ctx.strokeText("➤", 0, 0); // Draw black outline
ctx.fillText("➤", 0, 0); // Draw red arrow
}
// Restore context state
ctx.restore();
ctx.globalAlpha = 1.0; // Reset global alpha so the rest of the game doesn't flash!
}
if (TD.show_grid) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.strokeRect(px, py, this.width, this.height); ctx.closePath(); ctx.stroke();
}
},
onEnter: function () {
if (this.map.is_main_map && TD.mode == "build") {
if (this.build_flag == 1 || this.build_flag == 2) { this.map.pre_building.show(); this.map.pre_building.locate(this); }
else { this.map.pre_building.hide(); }
}
// Removed the annoying default hover balloon tips!
},
onOut: function () { if (this.scene.panel.balloontip.el == this) { this.scene.panel.balloontip.hide(); } },
onClick: function () {
if (TD.admin_mode) return; // Prevent normal clicks from firing while in admin mode
if (this.scene.state != 1) return;
if (TD.mode == "build" && this.map.is_main_map && !this.building) {
if (this.checkBlock()) { this.scene.panel.balloontip.msg(this._block_msg, this); }
else { this.buyBuilding(this.map.pre_building.type); }
} else if (!this.building && this.map.selected_building) {
this.map.selected_building.toggleSelected(); this.map.selected_building = null;
}
}
};
TD.Grid = function (id, cfg) {
cfg.on_events = ["enter", "out", "click"];
var grid = new TD.Element(id, cfg);
TD.lang.mix(grid, grid_obj); grid._init(cfg); return grid;
};
});
_TD.a.push(function (TD) {
var map_obj = {
_init: function (cfg) {
cfg = cfg || {}; this.grid_x = cfg.grid_x || 10; this.grid_y = cfg.grid_y || 10;
this.x = cfg.x || 0; this.y = cfg.y || 0; this.width = this.grid_x * TD.grid_size; this.height = this.grid_y * TD.grid_size;
this.x2 = this.x + this.width; this.y2 = this.y + this.height;
this.grids = []; this.entrances = []; this.exits = []; this.buildings = []; this.monsters = []; this.bullets = [];
this.scene = cfg.scene; this.is_main_map = !!cfg.is_main_map;
this.map_type = cfg.map_type || "traditional"; // NEW: Map Type tracking
this.spawn_mode = cfg.spawn_mode || "random"; // NEW: Spawn mode
this.select_hl = TD.MapSelectHighLight(this.id + "-hl", { map: this }); this.select_hl.addToScene(this.scene, 1, 9);
this.selected_building = null; this._wait_clearInvalidElements = 20; this._wait_add_monsters = 0; this._wait_add_monsters_arr = [];
var i, l = this.grid_x * this.grid_y, grid_data = cfg["grid_data"] || [], d, grid;
for (i = 0; i < l; i++) {
d = grid_data[i] || {}; d.mx = i % this.grid_x; d.my = Math.floor(i / this.grid_x); d.map = this; d.step_level = this.step_level; d.render_level = this.render_level;
grid = new TD.Grid(this.id + "-grid-" + d.mx + "-" + d.my, d); this.grids.push(grid);
}
// Load Entrances
if (cfg.entrance) { var eg = this.getGrid(cfg.entrance[0], cfg.entrance[1]); if (eg) { eg.is_entrance = true; this.entrances.push(eg); } }
if (cfg.entrances) {
for (var i = 0; i < cfg.entrances.length; i++) {
var eg = this.getGrid(cfg.entrances[i][0], cfg.entrances[i][1]);
if (eg) { eg.is_entrance = true; this.entrances.push(eg); }
}
}
// Load Exits
if (cfg.exit) { var xg = this.getGrid(cfg.exit[0], cfg.exit[1]); if (xg) { xg.is_exit = true; this.exits.push(xg); } }
if (cfg.exits) {
for (var i = 0; i < cfg.exits.length; i++) {
var xg = this.getGrid(cfg.exits[i][0], cfg.exits[i][1]);
if (xg) { xg.is_exit = true; this.exits.push(xg); }
}
}
// Fallbacks for legacy single-references in the engine
this.entrance = this.entrances[0] || null;
this.exit = this.exits[0] || null;
// --- DEMO MODE INITIAL BURST ---
if (window.is_demo_mode && this.entrances.length > 0) {
// Queue up 50 quick spawns instantly for absolute chaos
this._wait_add_monsters_arr = [];
for (var j = 0; j < 10; j++) {
this._wait_add_monsters_arr.push([5, Math.floor(Math.random() * TD.monster_type_count)]);
}
}
// --- NEW: DYNAMIC ASSET REBUILDER ---
this.assets = [];
if (cfg.assets) {
var _mapRef = this;
TD.lang.each(cfg.assets, function (a) {
var imgObj = new Image();
imgObj.src = a.src;
var x_px = _mapRef.x + (a.mx * TD.grid_size);
var y_px = _mapRef.y + (a.my * TD.grid_size);
var w_px = a.grid_x * TD.grid_size;
var h_px = a.grid_y * TD.grid_size;
var rebuiltAsset = {
id: a.id, src: a.src, img: imgObj,
mx: a.mx, my: a.my, grid_x: a.grid_x, grid_y: a.grid_y,
blocks_path: a.blocks_path, category: a.category,
x: x_px, y: y_px, width: w_px, height: h_px,
y2: y_px + h_px, is_asset: true
};
_mapRef.assets.push(rebuiltAsset);
// Re-apply pathing/building blockages for this asset
for (var dx = 0; dx < a.grid_x; dx++) {
for (var dy = 0; dy < a.grid_y; dy++) {
var g = _mapRef.getGrid(a.mx + dx, a.my + dy);
if (g) {
if (a.category !== "path") g.build_flag = 0;
if (a.blocks_path) g.passable_flag = 0;
}
}
}
});
}
var _this = this;
if (cfg.grids_cfg) {
TD.lang.each(cfg.grids_cfg, function (obj) {
var grid = _this.getGrid(obj.pos[0], obj.pos[1]); if (!grid) return;
if (!isNaN(obj.passable_flag)) grid.passable_flag = obj.passable_flag;
if (!isNaN(obj.build_flag)) grid.build_flag = obj.build_flag;
if (obj.building) {
grid.addBuilding(obj.building);
if (obj.building_level && grid.building) {
for (var lvl = 1; lvl < obj.building_level; lvl++) grid.building.upgrade();
}
}
});
}
},
checkHasWeapon: function () { this.has_weapon = (this.anyBuilding(function (obj) { return obj.is_weapon; }) != null); },
getGrid: function (mx, my) { var p = my * this.grid_x + mx; return this.grids[p]; },
anyMonster: function (f) { return TD.lang.any(this.monsters, f); },
anyBuilding: function (f) { return TD.lang.any(this.buildings, f); },
anyBullet: function (f) { return TD.lang.any(this.bullets, f); },
eachBuilding: function (f) { TD.lang.each(this.buildings, f); },
eachMonster: function (f) { TD.lang.each(this.monsters, f); },
eachBullet: function (f) { TD.lang.each(this.bullets, f); },
preBuild: function (building_type) {
TD.mode = "build"; if (this.pre_building) { this.pre_building.remove(); }
this.pre_building = new TD.Building(this.id + "-pre-building-" + TD.lang.rndStr(), { type: building_type, map: this, is_pre_building: true });
this.scene.addElement(this.pre_building, 1, this.render_level + 1);
},
cancelPreBuild: function () { TD.mode = "normal"; if (this.pre_building) { this.pre_building.remove(); } },
clearInvalidElements: function () {
if (this._wait_clearInvalidElements > 0) { this._wait_clearInvalidElements--; return; }
this._wait_clearInvalidElements = 20;
var a = []; TD.lang.shift(this.buildings, function (obj) { if (obj.is_valid) a.push(obj); }); this.buildings = a;
a = []; TD.lang.shift(this.monsters, function (obj) { if (obj.is_valid) a.push(obj); }); this.monsters = a;
a = []; TD.lang.shift(this.bullets, function (obj) { if (obj.is_valid) a.push(obj); }); this.bullets = a;
if (this.loots) { a = []; TD.lang.shift(this.loots, function (obj) { if (obj.is_valid) a.push(obj); }); this.loots = a; }
},
addMonster: function (monster) {
if (!this.entrances || this.entrances.length === 0) return;
var m_idx = typeof monster == "number" ? monster : monster.idx;
if (this.spawn_mode === "simultaneous") {
// Spawn a clone at EVERY entrance
for (var i = 0; i < this.entrances.length; i++) {
var new_m = new TD.Monster(null, { idx: m_idx, difficulty: TD.difficulty, step_level: this.step_level, render_level: this.render_level + 2 });
if (window.is_demo_mode) new_m.speed *= 2; // Demo mode 2x speed
this.entrances[i].addMonster(new_m);
}
} else {
// Spawn at a RANDOM entrance
var randEnt = this.entrances[Math.floor(Math.random() * this.entrances.length)];
var new_m = new TD.Monster(null, { idx: m_idx, difficulty: TD.difficulty, step_level: this.step_level, render_level: this.render_level + 2 });
if (window.is_demo_mode) new_m.speed *= 2; // Demo mode 2x speed
randEnt.addMonster(new_m);
}
},
addMonsters: function (n, monster) { this._wait_add_monsters = n; this._wait_add_monsters_objidx = monster; },
addMonsters2: function (arr) { this._wait_add_monsters_arr = arr; },
checkPassable: function (mx, my) { var grid = this.getGrid(mx, my); return (grid != null && grid.passable_flag == 1 && grid.build_flag == 0); },
step: function () {
this.clearInvalidElements();
// --- NEW: UNIVERSAL DEAD ZONE TRACKER ---
if (this.monsters.length > 0 && typeof TD.last_fire_frame !== 'undefined') {
// If 6 seconds (144 frames) pass with monsters on screen but NO shots fired
if (TD.iframe - TD.last_fire_frame >= (TD.exp_fps * 6)) {
if (typeof TD.logEvent === 'function' && window.IntelData) {
TD.logEvent(window.IntelData.getEvent("tactical_dead_zone", "GENERAL"));
}
// Reset the timer so it waits another 6 seconds before warning again
TD.last_fire_frame = TD.iframe;
}
}
// --- ENDLESS MODE DIFFICULTY SCALING ---
// If the map is completely clear of monsters and the queue is empty, the wave is over.
if (!window.is_campaign_mode && !window.is_demo_mode) {
if (this.monsters.length === 0 && this._wait_add_monsters === 0 && this._wait_add_monsters_arr.length === 0) {
// Prevent it from scaling repeatedly while waiting for the next wave to start
if (!this._did_scale_this_wave) {
TD.difficulty += 0.05; // 5% increase in Monster HP/Speed per wave
this._did_scale_this_wave = true;
}
} else {
this._did_scale_this_wave = false; // Reset lock once monsters start spawning again
}
}
if (this._wait_add_monsters > 0) { this.addMonster(this._wait_add_monsters_objidx); this._wait_add_monsters--; }
else if (this._wait_add_monsters_arr.length > 0) { var a = this._wait_add_monsters_arr.shift(); this.addMonsters(a[0], a[1]); }
// --- DEMO MODE TRICKLE SPAWN ---
if (window.is_demo_mode && this.entrance) {
// ~3.0% chance per frame to spawn 1-3 random enemies (Increased by 2x)
if (Math.random() < 0.03 && this._wait_add_monsters === 0 && this._wait_add_monsters_arr.length === 0) {
var randType = Math.floor(Math.random() * TD.monster_type_count);
var randCount = Math.floor(Math.random() * 3) + 1; // 1 to 3 monsters
this.addMonsters(randCount, randType);
}
// ~0.2% chance per frame to drop a massive Artillery Strike on a group of monsters
if (Math.random() < 0.002 && this.monsters.length > 0) {
// Pick a random monster as the target center (naturally biases towards dense groups)
var targetIdx = Math.floor(Math.random() * this.monsters.length);
var targetMonster = this.monsters[targetIdx];
if (targetMonster && targetMonster.is_valid) {
var rx = targetMonster.cx;
var ry = targetMonster.cy;
var blast_radius = TD.grid_size * 5;
TD.Explode("demo-arty-" + TD.lang.rndStr(), { cx: rx, cy: ry, r: blast_radius / _TD.retina, step_level: 1, render_level: 9, color: "#ff5500", scene: this.scene, time: 1.5 });
// Kill monsters caught in the targeted blast
TD.lang.each(this.monsters, function (m) {
if (m.is_valid && (Math.pow(m.cx - rx, 2) + Math.pow(m.cy - ry, 2) <= Math.pow(blast_radius, 2))) {
m.beHit({ killed: 0, gainXp: function(){} }, 500);
}
});
}
}
}
// Trigger consumable timers
if (TD.stepConsumables) TD.stepConsumables();
},
render: function () {
var ctx = TD.ctx; ctx.strokeStyle = "#99a"; ctx.lineWidth = _TD.retina; ctx.beginPath(); ctx.strokeRect(this.x + 0.5, this.y + 0.5, this.width, this.height); ctx.closePath(); ctx.stroke();
var renderQueue = [];
// 1. Gather Assets & Draw Paths Immediately
if (this.assets) {
for (var i = 0; i < this.assets.length; i++) {
var a = this.assets[i];
if (a.category === "path") {
// Draw paths flat on the background immediately
if (a.img && a.img.complete) ctx.drawImage(a.img, a.x, a.y, a.width, a.height);
} else {
renderQueue.push(a); // Props go to Y-Sort queue
}
}
}
// 2. Gather Buildings
for (var i = 0; i < this.buildings.length; i++) {
var b = this.buildings[i];
if (b.is_valid) renderQueue.push(b);
}
// 3. Gather Monsters
for (var i = 0; i < this.monsters.length; i++) {
var m = this.monsters[i];
if (m.is_valid) renderQueue.push(m);
}
// SORT the queue based on the bottom Y edge
renderQueue.sort(function(a, b) {
var a_bottom = a.y2 || (a.cy + (a.r || 0));
var b_bottom = b.y2 || (b.cy + (b.r || 0));
return a_bottom - b_bottom;
});
// DRAW everything in order from back to front
for (var i = 0; i < renderQueue.length; i++) {
var obj = renderQueue[i];
if (obj.is_asset) {
if (obj.img && obj.img.complete) ctx.drawImage(obj.img, obj.x, obj.y, obj.width, obj.height);
} else if (obj.is_monster) {
// Force the monster to render here
if (obj.render) obj.render.call(obj, "ysort"); // FIXED: Pass the required argument
} else {
if (obj.is_visiable && obj.wait_blink >= 0) {
TD.renderBuilding(obj);
if (obj.map.is_main_map && (obj.is_selected || obj.is_hover || obj.map.show_all_ranges) && obj.is_weapon && obj.range > 0 && obj.grid) {
ctx.lineWidth = _TD.retina; ctx.fillStyle = "rgba(187, 141, 32, 0.15)"; ctx.strokeStyle = "#bb8d20"; ctx.beginPath(); ctx.arc(obj.cx, obj.cy, obj.range_px, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
}
}
}
}
// FINALLY: Redraw Entrance and Exit arrows so they are never hidden by assets
if (this.entrance) this.entrance.render();
if (this.exit) this.exit.render();
// DRAW MR. HANDY GHOST (Silhouette Stencil)
if (TD.mode === "deploy_handy" && TD.mouse_x != null && TD.mouse_y != null) {
var r = (TD.grid_size * 0.9) / 2;
if (!this._handy_ghost_img) { this._handy_ghost_img = new Image(); this._handy_ghost_img.src = "assets/images/mrhandy.png"; }
if (this._handy_ghost_img.complete) {
// Generate the stencil once and cache it
if (!this._handy_ghost_canvas) {
this._handy_ghost_canvas = document.createElement('canvas');
this._handy_ghost_canvas.width = r*2 + 4;
this._handy_ghost_canvas.height = r*2 + 4;
var oCtx = this._handy_ghost_canvas.getContext('2d');
var img = this._handy_ghost_img;
// 1. Draw 4 offsets to create a thicker border
oCtx.drawImage(img, 0, 2, r*2, r*2);
oCtx.drawImage(img, 4, 2, r*2, r*2);
oCtx.drawImage(img, 2, 0, r*2, r*2);
oCtx.drawImage(img, 2, 4, r*2, r*2);
// 2. Fill the expanded shape with solid green
oCtx.globalCompositeOperation = 'source-in';
oCtx.fillStyle = '#0f0';
oCtx.fillRect(0, 0, r*2 + 4, r*2 + 4);
// 3. Cut out the original image shape from the center, leaving only the outline
oCtx.globalCompositeOperation = 'destination-out';
oCtx.drawImage(img, 2, 2, r*2, r*2);
// 4. Create a translucent green fill using a temporary canvas
var c2 = document.createElement('canvas');
c2.width = r*2 + 4; c2.height = r*2 + 4;
var x2 = c2.getContext('2d');
x2.drawImage(img, 2, 2, r*2, r*2);
x2.globalCompositeOperation = 'source-in';
x2.fillStyle = 'rgba(0, 255, 0, 0.4)';
x2.fillRect(0, 0, r*2+4, r*2+4);
// 5. Draw the translucent fill back into the cut-out center
oCtx.globalCompositeOperation = 'source-over';
oCtx.drawImage(c2, 0, 0);
}
// Draw the perfectly generated stenciled ghost on the main canvas
ctx.drawImage(this._handy_ghost_canvas, TD.mouse_x - r - 2, TD.mouse_y - r - 2);
}
}
},
onOut: function () { if (this.is_main_map && this.pre_building) this.pre_building.hide(); }
};
TD.Map = function (id, cfg) {
cfg.on_events = ["enter", "out"]; var map = new TD.Element(id, cfg); TD.lang.mix(map, map_obj); map._init(cfg); return map;
};
var map_selecthl_obj = {
_init: function (cfg) { this.map = cfg.map; this.width = TD.grid_size + 2; this.height = TD.grid_size + 2; this.is_visiable = false; },
show: function (grid) { this.x = grid.x; this.y = grid.y; this.is_visiable = true; },
render: function () {
var ctx = TD.ctx; ctx.lineWidth = 2; ctx.strokeStyle = "#f93"; ctx.beginPath(); ctx.strokeRect(this.x, this.y, this.width - 1, this.height - 1); ctx.closePath(); ctx.stroke();
}
};
TD.MapSelectHighLight = function (id, cfg) {
var map_selecthl = new TD.Element(id, cfg); TD.lang.mix(map_selecthl, map_selecthl_obj); map_selecthl._init(cfg); return map_selecthl;
};
});
_TD.a.push(function (TD) {
var monster_obj = {
_init: function (cfg) {
cfg = cfg || {}; this.is_monster = true; this.idx = cfg.idx || 1; this.difficulty = cfg.difficulty || 1.0;
var attr = TD.getDefaultMonsterAttributes(this.idx);
this.name = attr.name || "Unknown"; // NEW: Store the name so VATS tooltip can read it without crashing
this.is_flying = !!attr.is_flying;
this.is_stealthed = !!attr.is_stealthed;
this.speed = Math.floor((attr.speed + this.difficulty / 2) * (Math.random() * 0.5 + 0.75));
if (this.speed < 1) this.speed = 1; if (this.speed > cfg.max_speed) this.speed = cfg.max_speed;
// Universally increased HP modifier (changed from 0.5 to 1.5 for a massive 300% boost)
this.life = this.life0 = Math.floor(attr.life * (this.difficulty + 1) * (Math.random() + 0.5) * 1.5);
if (this.life < 1) this.life = this.life0 = 1;
this.shield = Math.floor(attr.shield + this.difficulty / 2); if (this.shield < 0) this.shield = 0;
this.damage = Math.floor((attr.damage || 1) * (Math.random() * 0.5 + 0.75)); if (this.damage < 1) this.damage = 1;
this.money = attr.money || Math.floor(Math.sqrt((this.speed + this.life) * (this.shield + 1) * this.damage)); if (this.money < 1) this.money = 1;
this.color = attr.color || TD.lang.rndRGB(); this.r = Math.floor(this.damage * 3.6) * _TD.retina;
if (this.r < (12 * _TD.retina)) this.r = 12 * _TD.retina; if (this.r > TD.grid_size - (4 * _TD.retina)) this.r = TD.grid_size - (4 * _TD.retina);
this.render = attr.render; this.grid = null; this.map = null; this.next_grid = null; this.way = []; this.toward = 2; this._dx = 0; this._dy = 0; this.is_blocked = false;
// NEW: Initial random lane assignments & Preferred drift target
this.offset_x = (Math.random() * (TD.grid_size * 0.6)) - (TD.grid_size * 0.3);
this.offset_y = (Math.random() * (TD.grid_size * 0.6)) - (TD.grid_size * 0.3);
this.preferred_offset_x = this.offset_x;
this.preferred_offset_y = this.offset_y;
// MOLE RAT: Initial burrow state
if (this.name === "Mole Rat") {
this.is_burrowing = true;
this.is_stealthed = true;
this.state_timer = TD.exp_fps * (4 + Math.random() * 2); // 4-6 seconds initially
}
// ENDLESS SCALING: +2% HP every 2 waves, +0.6% Speed every 3 waves (Caps at Wave 40)
var current_wave = (TD.stage && TD.stage.current_act && TD.stage.current_act.current_scene) ? TD.stage.current_act.current_scene.wave : 1;
if (current_wave > 10) {
var past_10 = current_wave - 10;
var stat_waves = Math.min(past_10, 30); // Locks scaling at 30 waves past 10 (Wave 40)
var hp_mult = 1.0 + (Math.floor(stat_waves / 2) * 0.02);
var spd_mult = 1.0 + (Math.floor(stat_waves / 3) * 0.006);
this.life0 = Math.floor(this.life0 * hp_mult);
this.life = this.life0;
this.speed = this.speed * spd_mult;
}
},
caculatePos: function () { var r = this.r; this.x = this.cx - r; this.y = this.cy - r; this.x2 = this.cx + r; this.y2 = this.cy + r; },
beHit: function (building, damage) {
if (!this.is_valid) return;
// --- NEW: Vulnerability Aura Check ---
var _this = this;
var vuln_mult = 1.0;
if (this.map && this.map.buildings) {
TD.lang.each(this.map.buildings, function(b) {
if (b.is_valid && b.aura_vulnerability) {
var dist = Math.pow(b.cx - _this.cx, 2) + Math.pow(b.cy - _this.cy, 2);
if (dist <= Math.pow(b.range_px, 2)) vuln_mult = b.aura_vulnerability; // Usually 1.15
}
});
}
damage = Math.floor(damage * vuln_mult);
var min_damage = Math.ceil(damage * 0.1); damage -= this.shield; if (damage <= min_damage) damage = min_damage;
this.life -= damage;
var scoreGained = Math.floor(Math.sqrt(damage));
TD.score += scoreGained;
// Add a very small trickle to V.A.T.S. charge on hit
if (typeof TD.vats_charge !== 'undefined' && !TD.vats_active) {
TD.vats_charge += (scoreGained * 0.1); // Reduced from 0.5 to prevent massive spikes
if (TD.vats_charge > TD.vats_max_charge) TD.vats_charge = TD.vats_max_charge;
}
// --- NEW: Award XP just for hitting the target (2 to 5 XP) ---
if (building && building.gainXp) {
var hitXp = Math.floor(Math.random() * 4) + 2;
building.gainXp(hitXp);
}
// NEW: Scatter and Throttle (Only show text every 3 frames per monster)
if (!this._last_dmg_frame || TD.iframe - this._last_dmg_frame > 3) {
this._last_dmg_frame = TD.iframe;
var offsetX = (Math.random() * 24) - 12; // Scatters horizontally
var offsetY = (Math.random() * 10) - 5; // Scatters slightly vertically
new TD.FloatingText("dmg-" + TD.lang.rndStr(), {
cx: this.cx + offsetX, cy: (this.cy - 10) + offsetY, text: "-" + damage, color: "#ff5555", map: this.map, size: 10, life: 16
});
}
if (this.life <= 0) {
// --- NEW: Tiered XP for landing the killing blow ---
if (building && building.gainXp) {
// Base kill XP is 10. Max index is 8 (Sentry Bot).
// The formula gives roughly 10 to 25 XP based on how tough the monster is.
var killXp = 10 + Math.floor(this.idx * 1.8);
building.gainXp(killXp);
}
// NEW: Scaled Chance to drop Loot (Base 5% + 1.5% per monster index level)
var base_chance = 0.05;
var scaling_bonus = (this.idx * 0.015);
var dropChance = (window.devForcedDrop && window.devForcedDrop !== "none") ? 1.0 : (base_chance + scaling_bonus);
// GUARANTEED WAVE DROPS (First kill of the specific wave)
var forceLoot = null;
if (this.scene && this.scene.wave === 3 && !this.scene._dropW3) {
forceLoot = "money"; this.scene._dropW3 = true;
} else if (this.scene && this.scene.wave === 7 && !this.scene._dropW7) {
forceLoot = "stimpak"; this.scene._dropW7 = true;
} else if (this.scene && this.scene.wave === 15 && !this.scene._dropW15) {
forceLoot = "stimpak"; this.scene._dropW15 = true;
}
if (forceLoot || Math.random() < dropChance) {
var newLoot = new TD.Loot("loot-" + TD.lang.rndStr(), {
cx: this.cx,
cy: this.cy,
map: this.map,
monster_bounty: this.money // scale with monster's actual worth
});
if (forceLoot) {
newLoot.loot_type = forceLoot;
if (forceLoot === "money") newLoot.value = 100; // Extra caps for the guaranteed drop
}
}
this.beKilled(building);
}
var balloontip = this.scene.panel.balloontip;
if (balloontip.el == this) {
var tags = [];
if (this.is_flying) tags.push("[FLYING]");
if (this.is_stealthed) tags.push("[STEALTH]");
if (this.speed >= 15) tags.push("[FAST]");
if (this.life0 >= 500) tags.push("[VERY STRONG]");
if (this.life0 >= 2000) tags.push("[BOSS]");
var tagStr = tags.length > 0 ? "\n" + tags.join(" ") : "";
balloontip.text = this.name.toUpperCase() + "\nHP: " + this.life + " / " + this.life0 + tagStr;
}
},
beKilled: function (building) {
if (!this.is_valid) return;
this.life = 0; this.is_valid = false; building.killed++;
// --- NEW: ANNOUNCE KILL ---
if (window.IntelData && TD.Announcer) {
// Was it a clutch kill right near the exit? (Calculate distance from exit grid)
var clutch = false;
if (this.map && this.map.exit) {
var distToExit = Math.sqrt(Math.pow(this.cx - this.map.exit.cx, 2) + Math.pow(this.cy - this.map.exit.cy, 2));
if (distToExit < (TD.grid_size * 2)) clutch = true; // Within 2 grid squares!
}
if (clutch) {
// Highest priority, absolute clutch save!
TD.Announcer.add("CRITICAL SAVE: " + this.name.toUpperCase() + " ELIMINATED AT THE PERIMETER BY " + (building ? building.name.toUpperCase() : "DEFENSES"), 5);
} else if (this.life0 >= 2000) {
// Boss Kill!
TD.Announcer.add(window.IntelData.getEvent("kill", TD.current_faction, null, this.name), 5);
} else if (Math.random() < 0.15) {
// Normal Kill (Throttled so swarms don't spam)
TD.Announcer.add(window.IntelData.getEvent("kill", TD.current_faction, null, this.name), 2);
}
}
// --- NEW: Scaling Kill Bounty ---
var wave_bonus = 0;
var current_wave = 1;
if (this.scene && this.scene.wave) current_wave = this.scene.wave;
if (current_wave >= 5) wave_bonus = 5;
if (current_wave >= 9) wave_bonus = 8;
if (current_wave >= 13) wave_bonus = 10;
if (current_wave >= 17) wave_bonus = 15;
var final_bounty = this.money + wave_bonus;
TD.money += final_bounty;
// --- NEW: BONUS BOUNTY PAYOUT ---
if (building && building.bonus_bounty) {
var extraCaps = Math.floor(Math.random() * 6) + 15; // Random 15 to 20
TD.money += extraCaps;
new TD.FloatingText("bounty-" + TD.lang.rndStr(), {
cx: this.cx, cy: this.cy - 20, text: "+" + extraCaps + " CAPS", color: "#ffff00", map: this.map, size: 14, life: 36
});
}
// --- NEW: V.A.T.S. Charge Generation ---
if (typeof TD.vats_charge !== 'undefined' && !TD.vats_active) {
// Fixed kill charge: base 3, scales up slightly for stronger enemies
TD.vats_charge += 3 + Math.floor(this.idx * 1.5);
if (TD.vats_charge > TD.vats_max_charge) TD.vats_charge = TD.vats_max_charge;
}
// --- NEW: Shatter AoE ---
if (this.is_frozen && building && building.shatter_aoe) {
var blast_radius = TD.grid_size * 2.0;
TD.Explode("shatter-" + TD.lang.rndStr(), { cx: this.cx, cy: this.cy, r: blast_radius / _TD.retina, step_level: 1, render_level: 9, color: "#aaffff", scene: this.grid.scene, time: 0.3 });
var _this = this;
TD.lang.each(this.map.monsters, function(m) {
if (m.is_valid && m !== _this && Math.pow(m.cx - _this.cx, 2) + Math.pow(m.cy - _this.cy, 2) <= Math.pow(blast_radius, 2)) {
m.beHit(building, 50); // Deal 50 splash damage
}
});
}
TD.Explode(this.id + "-explode", { cx: this.cx, cy: this.cy, color: this.color, r: this.r, step_level: this.step_level, render_level: this.render_level, scene: this.grid.scene });
},
arrive: function () { this.grid = this.next_grid; this.next_grid = null; this.checkFinish(); },
findWay: function () {
var _this = this, bestWay = [], minLen = 999999;
for (var i = 0; i < this.map.exits.length; i++) {
var ext = this.map.exits[i];
var fw = new TD.FindWay(this.map.grid_x, this.map.grid_y, this.grid.mx, this.grid.my, ext.mx, ext.my, function (x, y) { return _this.map.checkPassable(x, y); });
if (fw.is_arrived && fw.way.length < minLen) { minLen = fw.way.length; bestWay = fw.way; }
}
this.way = bestWay;
},
checkFinish: function () {
if (this.grid && this.map && this.grid.is_exit) {
TD.wave_damage += this.damage;
if (window.is_demo_mode) {
this.pause(); this.del();
return;
}
// Trigger Screen Shake visually on the board container
var board = document.getElementById("td-board");
if (board) {
board.classList.remove("shake-active");
void board.offsetWidth; // Reflow to allow rapid sequential shakes
board.classList.add("shake-active");
}
if (this.map.map_type === "siege") {
TD.base_health -= (this.damage * 10);
if (TD.base_health <= 0) { TD.base_health = 0; TD.stage.gameover(); } else { this.pause(); this.del(); }
} else {
TD.life -= this.damage;
if (TD.life <= 0) { TD.life = 0; TD.stage.gameover(); } else { this.pause(); this.del(); }
}
}
},
beAddToGrid: function (grid) { this.grid = grid; this.map = grid.map; this.cx = grid.cx; this.cy = grid.cy; this.grid.scene.addElement(this); },
getNextGrid: function () {
if (this.way.length == 0 || Math.random() < 0.1) { this.findWay(); }
var next_grid = this.way.shift();
if (next_grid && !this.map.checkPassable(next_grid[0], next_grid[1])) { this.findWay(); next_grid = this.way.shift(); }
if (!next_grid) return;
this.next_grid = this.map.getGrid(next_grid[0], next_grid[1]);
},
chkIfBlocked: function (mx, my) {
var _this = this, canReachAnyExit = false;
for (var i = 0; i < this.map.exits.length; i++) {
var ext = this.map.exits[i];
var fw = new TD.FindWay(this.map.grid_x, this.map.grid_y, this.grid.mx, this.grid.my, ext.mx, ext.my, function (x, y) { return !(x == mx && y == my) && _this.map.checkPassable(x, y); });
if (!fw.is_blocked) { canReachAnyExit = true; break; }
}
return !canReachAnyExit;
},
beBlocked: function () { if (this.is_blocked) return; this.is_blocked = true; },
step: function () {
if (!this.is_valid || this.is_paused || !this.grid) return;
// NEW: Handle Freeze Debuff thawing
if (this.is_frozen) {
this.freeze_timer--;
if (this.freeze_timer > 0) {
// --- NEW: True Freeze Bypass ---
// If they have more than 5 seconds (120 frames) left, they are SOLID ICE.
// We simply return out of the function so they don't move or attack at all!
if (this.freeze_timer > 120) {
return;
}
// Dynamic Thaw (Last 5 seconds):
// Timer goes from 120 down to 0.
var freezePercentRemaining = this.freeze_timer / 120; // 1.0 down to 0.0
var thawPercent = 1 - freezePercentRemaining; // 0.0 up to 1.0
// They regain speed linearly as the timer ticks down.
this.speed = Math.max(0.1, this.original_speed * thawPercent);
} else {
// Done freezing
this.is_frozen = false;
this.speed = this.original_speed;
}
}
if (!this.next_grid) {
this.getNextGrid();
if (!this.next_grid) {
if (this.map.map_type === "siege") {
// NEW: Siege Mode Attack Logic
if (!this.attack_cooldown) this.attack_cooldown = 0;
if (this.attack_cooldown > 0) {
this.attack_cooldown--;
} else {
// Look for adjacent blocking towers
var adj = [
this.map.getGrid(this.grid.mx + 1, this.grid.my),
this.map.getGrid(this.grid.mx - 1, this.grid.my),
this.map.getGrid(this.grid.mx, this.grid.my + 1),
this.map.getGrid(this.grid.mx, this.grid.my - 1)
];
for (var i = 0; i < adj.length; i++) {
if (adj[i] && adj[i].building) {
var target = adj[i].building;
var dmg = this.damage || 5;
if (target.shield > 0) {
target.shield -= dmg;
if (target.shield < 0) target.shield = 0;
} else {
target.life -= dmg;
}
// Visual attack feedback
TD.Explode(this.id + "-atk", { cx: target.cx, cy: target.cy, r: 10 * _TD.retina, step_level: this.step_level, render_level: 9, color: "#ff0000", scene: this.map.scene, time: 0.2 });
if (target.life <= 0) target.remove(); // Destroy the building
this.attack_cooldown = 24; // Attack roughly once per second
break;
}
}
}
} else {
this.beBlocked();
}
return;
}
}
// --- NEW: Soft Swarm Repulsion & Lane Shifting ---
var _this = this;
var is_bumping = false; // Track if actively touching someone
TD.lang.each(this.map.monsters, function(m) {
if (m !== _this && m.is_valid && m.grid) {
var dx = _this.cx - m.cx;
var dy = _this.cy - m.cy;
var dist = Math.sqrt(dx*dx + dy*dy);
var minDist = _this.r + m.r;
if (dist > 0 && dist < minDist) {
is_bumping = true;
var push = (minDist - dist) * 0.15; // Tripled magnetic push
_this.offset_x += (dx / dist) * push;
_this.offset_y += (dy / dist) * push;
}
}
});
// --- NEW: Occasional random wandering ---
// ~1% chance per frame to decide to wander to a new part of the path
if (Math.random() < 0.01) {
this.preferred_offset_x = (Math.random() * (TD.grid_size * 0.7)) - (TD.grid_size * 0.35);
this.preferred_offset_y = (Math.random() * (TD.grid_size * 0.7)) - (TD.grid_size * 0.35);
}
// Smoothly pull toward PREFERRED lane over time if not bumping
if (!is_bumping) {
this.offset_x += (this.preferred_offset_x - this.offset_x) * 0.05;
this.offset_y += (this.preferred_offset_y - this.offset_y) * 0.05;
}
// Wider clamping (50%) so they don't clip entirely off the tile
var maxOff = TD.grid_size * 0.5;
if (this.offset_x > maxOff) this.offset_x = maxOff; else if (this.offset_x < -maxOff) this.offset_x = -maxOff;
if (this.offset_y > maxOff) this.offset_y = maxOff; else if (this.offset_y < -maxOff) this.offset_y = -maxOff;
// Add the dynamic lane offset to their target destination
var target_cx = this.next_grid.cx + this.offset_x;
var target_cy = this.next_grid.cy + this.offset_y;
// --- NEW: Smooth Vector Movement ---
var dpx = target_cx - this.cx;
var dpy = target_cy - this.cy;
var distToTarget = Math.sqrt(dpx * dpx + dpy * dpy);
// --- NEW: PATH PROGRESS ANNOUNCER ---
// Calculate how far they are based on their remaining path length
if (this.way && this.map && this.map.entrance && this.map.exit && window.IntelData && TD.Announcer) {
// Estimate total path length (just once)
if (!this._total_path_len) this._total_path_len = this.way.length + 1;
var percentTraveled = 100 - Math.floor((this.way.length / this._total_path_len) * 100);
// Trigger warnings at 50% and 75% marks
if (percentTraveled >= 50 && !this._warned_50) {
this._warned_50 = true;
// Only 10% chance to warn for generic mobs to prevent spam, 100% chance for Bosses
if (this.life0 >= 2000 || Math.random() < 0.1) {
TD.Announcer.add(window.IntelData.getEvent("path_progress", TD.current_faction, null, this.name, 50), 4);
}
}
if (percentTraveled >= 75 && !this._warned_75) {
this._warned_75 = true;
if (this.life0 >= 2000 || Math.random() < 0.2) {
TD.Announcer.add(window.IntelData.getEvent("path_progress", TD.current_faction, null, this.name, 75), 5);
}
}
}
// --- STATUS EFFECT TIMERS ---
if (typeof this.stealth_alpha === 'undefined') this.stealth_alpha = 0.0;
if (this.reveal_timer > 0) {
this.reveal_timer--;
if (this.reveal_timer <= 0) this.is_revealed = false;
}
if (this.is_poisoned) {
this.poison_timer--;
if (this.poison_timer <= 0) this.is_poisoned = false;
// Tick Damage every half second (12 frames)
if (this.poison_timer % 12 === 0) {
var pDmg = this.poison_damage || 5;
this.life -= pDmg;
new TD.FloatingText("pdmg-" + TD.lang.rndStr(), { cx: this.cx + (Math.random()*10-5), cy: this.cy - 10, text: "-" + pDmg, color: "#ff8800", map: this.map, size: 10, life: 12 });
if (this.life <= 0) this.beKilled({ killed: 0, gainXp: function(){} });
}
}
// Smooth Fading Math for Stealth/Burrowing
if (this.is_revealed || (!this.is_stealthed && !this.is_burrowing)) {
this.stealth_alpha += 0.05; // Fade In
if (this.stealth_alpha > 1.0) this.stealth_alpha = 1.0;
} else {
this.stealth_alpha -= 0.05; // Fade Out
if (this.stealth_alpha < 0.0) this.stealth_alpha = 0.0;
}
if (this.slow_timer > 0) {
this.slow_timer--;
if (this.slow_timer <= 0) this.is_slowed = false;
}
// MOLE RAT: State Machine (Burrowing vs Running)
if (typeof this.dig_timer === 'undefined') this.dig_timer = 0;
if (this.dig_timer > 0) this.dig_timer--;
if (this.name === "Mole Rat") {
this.state_timer--;
if (this.state_timer <= 0) {
this.is_burrowing = !this.is_burrowing;
this.dig_timer = TD.exp_fps * 1.0;
if (this.is_burrowing) {
this.is_stealthed = true;
this.is_revealed = false;
this.state_timer = TD.exp_fps * (4 + Math.random() * 2);
} else {
this.is_stealthed = false;
this.state_timer = TD.exp_fps * (6 + Math.random() * 2);
}
}
}
// --- NEW: Process Environmental Auras ---
var aura_slow_mult = 1.0;
if (this.map && this.map.buildings) {
TD.lang.each(this.map.buildings, function(b) {
if (b.is_valid) {
var dist2 = Math.pow(b.cx - _this.cx, 2) + Math.pow(b.cy - _this.cy, 2);
if (dist2 <= Math.pow(b.range_px, 2)) {
if (b.aura_slow) aura_slow_mult = Math.min(aura_slow_mult, b.aura_slow); // Apply the strongest slow (lowest mult)
if (b.aura_dot && TD.iframe % 30 === 0) {
_this.beHit(b, b.aura_dot); // Apply flat damage twice a second
}
}
}
});
}
// Base speed calculation
var current_speed = this.speed * TD.global_speed;
// Speed Modifiers
if (this.dig_timer > 0) {
current_speed = 0; // Frozen completely while digging up/down
} else {
current_speed *= aura_slow_mult; // Apply Radar Jammer Slow
if (this.is_slowed) current_speed *= 0.50; // 50% Penalty from Cryo towers
if (this.name === "Mole Rat" && this.is_burrowing) current_speed *= 0.25; // 75% slower underground
if (is_bumping) current_speed *= 1.30; // 30% speed boost if squeezing past someone
}
if (this.dig_timer > 0) {
current_speed = 0; // Frozen completely while digging up/down
} else {
if (this.is_slowed) current_speed *= 0.50; // 50% Penalty from Cryo towers
if (this.name === "Mole Rat" && this.is_burrowing) current_speed *= 0.25; // 75% slower underground
if (is_bumping) current_speed *= 1.30; // 30% speed boost if squeezing past someone
}
if (distToTarget <= current_speed) {
this.cx = target_cx;
this.cy = target_cy;
this.arrive();
} else {
this.cx += (dpx / distToTarget) * current_speed;
this.cy += (dpy / distToTarget) * current_speed;
}
this.caculatePos();
},
onEnter: function () {
var balloontip = this.scene.panel.balloontip;
if (balloontip.el == this) { balloontip.hide(); balloontip.el = null; }
else {
var tags = [];
if (this.is_flying) tags.push("[FLYING]");
if (this.is_stealthed) tags.push("[STEALTH]");
if (this.speed >= 15) tags.push("[FAST]");
if (this.life0 >= 500) tags.push("[VERY STRONG]");
if (this.life0 >= 2000) tags.push("[BOSS]");
var tagStr = tags.length > 0 ? "\n" + tags.join(" ") : "";
var msg = this.name.toUpperCase() + "\nHP: " + this.life + " / " + this.life0 + tagStr;
balloontip.msg(msg, this);
}
},
onOut: function () {
// FIXED: Tell the global UI to hide the balloon when mouse leaves
if (this.scene.panel.balloontip.el === this) {
this.scene.panel.balloontip.hide();
this.scene.panel.balloontip.el = null;
}
}
};
TD.Monster = function (id, cfg) { cfg.on_events = ["enter", "out"]; var monster = new TD.Element(id, cfg); TD.lang.mix(monster, monster_obj); monster._init(cfg); return monster; };
var explode_obj = {
_init: function (cfg) {
cfg = cfg || {}; var rgb = TD.lang.rgb2Arr(cfg.color);
this.cx = cfg.cx; this.cy = cfg.cy; this.r = cfg.r * _TD.retina; this.step_level = cfg.step_level; this.render_level = cfg.render_level;
this.rgb_r = rgb[0]; this.rgb_g = rgb[1]; this.rgb_b = rgb[2]; this.rgb_a = 1; this.wait = this.wait0 = TD.exp_fps * (cfg.time || 1);
cfg.scene.addElement(this);
},
step: function () { if (!this.is_valid) return; this.wait--; this.r++; this.is_valid = this.wait > 0; this.rgb_a = this.wait / this.wait0; },
render: function () {
var ctx = TD.ctx; ctx.fillStyle = "rgba(" + this.rgb_r + "," + this.rgb_g + "," + this.rgb_b + "," + this.rgb_a + ")";
ctx.beginPath(); ctx.arc(this.cx, this.cy, this.r, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill();
}
};
TD.Explode = function (id, cfg) { var explode = new TD.Element(id, cfg); TD.lang.mix(explode, explode_obj); explode._init(cfg); return explode; };
// --- NEW: Expanding Hollow Radar Pulse Effect ---
var radar_pulse_obj = {
_init: function (cfg) {
cfg = cfg || {}; var rgb = TD.lang.rgb2Arr(cfg.color);
this.cx = cfg.cx; this.cy = cfg.cy; this.map = cfg.map || cfg.scene.map;
this.max_r = cfg.r * _TD.retina;
this.r = 1; // Start tiny at the center of the tower
this.step_level = cfg.step_level; this.render_level = cfg.render_level;
this.rgb_r = rgb[0]; this.rgb_g = rgb[1]; this.rgb_b = rgb[2]; this.rgb_a = 1;
this.wait = this.wait0 = TD.exp_fps * (cfg.time || 1);
this.dr = this.max_r / this.wait0; // Calculate expansion speed per frame
cfg.scene.addElement(this);
},
step: function () {
if (!this.is_valid) return;
this.wait--;
this.r += this.dr; // Expand radius outward
var _this = this;
if (this.map && this.map.monsters) {
TD.lang.each(this.map.monsters, function(m) {
if (m.is_valid && (m.is_stealthed || m.is_burrowing)) {
var dist = Math.sqrt(Math.pow(m.cx - _this.cx, 2) + Math.pow(m.cy - _this.cy, 2));
// BULLETPROOF: If the expanding ring has reached the monster at all
if (dist <= (_this.r + m.r)) {
// Stay revealed for 3.2 seconds (fully fades out right as the next pulse hits them)
m.reveal_timer = Math.floor(TD.exp_fps * 3.2);
m.is_revealed = true;
}
}
});
}
this.is_valid = this.wait > 0;
this.rgb_a = this.wait / this.wait0; // Fade out as it reaches the edge
},
render: function () {
var ctx = TD.ctx;
// Draw ONLY the hollow stroke, NOT a solid fill!
ctx.strokeStyle = "rgba(" + this.rgb_r + "," + this.rgb_g + "," + this.rgb_b + "," + this.rgb_a + ")";
ctx.lineWidth = 4 * _TD.retina;
ctx.beginPath(); ctx.arc(this.cx, this.cy, this.r, 0, Math.PI * 2, true); ctx.closePath(); ctx.stroke();
}
};
TD.RadarPulse = function (id, cfg) { var pulse = new TD.Element(id, cfg); TD.lang.mix(pulse, radar_pulse_obj); pulse._init(cfg); return pulse; };
});
_TD.a.push(function (TD) {
var panel_obj = {
_init: function (cfg) {
cfg = cfg || {}; this.x = cfg.x; this.y = cfg.y; this.scene = cfg.scene; this.map = cfg.main_map;
this.addToScene(this.scene, 1, 7);
// Dummy map so toggleSelected doesn't crash when it searches for the old panel_map
this.scene.panel_map = { eachBuilding: function() {} };
this.gameover_obj = new TD.GameOver("panel-gameover", { panel: this, scene: this.scene, step_level: this.step_level, is_visiable: false, x: 0, y: 0, width: this.scene.stage.width, height: this.scene.stage.height, render_level: 9 });
this.balloontip = new TD.BalloonTip("panel-balloon-tip", { scene: this.scene, step_level: this.step_level, render_level: 9 }); this.balloontip.addToScene(this.scene, 1, 9);
var _this = this;
var makeBtn = function(id) {
var el = document.getElementById(id);
return {
el: el,
show: function() { if(this.el) this.el.style.display = "inline-block"; },
hide: function() { if(this.el) this.el.style.display = "none"; },
set desc(val) { var descEl = document.getElementById("ui-action-desc"); if(descEl) descEl.innerText = val; }
};
};
this.btn_pause = makeBtn("ui-btn-pause");
this.btn_restart = makeBtn("ui-btn-restart");
this.btn_upgrade = makeBtn("ui-btn-upgrade");
this.btn_sell = makeBtn("ui-btn-sell");
this.btn_build = makeBtn("ui-btn-build");
var btnPause = document.getElementById("ui-btn-pause");
if (btnPause) btnPause.onclick = function() {
if (_this.scene.state == 1) {
_this.scene.pause();
this.innerText = "CONTINUE";
_this.btn_upgrade.hide(); _this.btn_sell.hide(); _this.btn_build.hide(); _this.btn_restart.show();
document.body.classList.add("is-paused");
}
else if (_this.scene.state == 2) {
_this.scene.start();
this.innerText = "PAUSE";
_this.btn_restart.hide();
if (_this.scene.map.selected_building) { _this.btn_upgrade.show(); _this.btn_sell.show(); _this.btn_build.show(); }
document.body.classList.remove("is-paused");
}
};
var btnRestart = document.getElementById("ui-btn-restart");
if (btnRestart) btnRestart.onclick = function() {
setTimeout(function () {
TD.stage.clear();
TD.is_paused = true;
// Reset all Consumable inventories, cooldowns, and buffs
if (TD.cooldowns && TD.inventory) {
for (var k in TD.cooldowns) {
TD.cooldowns[k] = 0;
TD.inventory[k] = 0;
var btn = document.getElementById("btn-item-" + k);
if (btn) btn.style.backgroundImage = "none";
if (TD.updateItemButton) TD.updateItemButton(k);
}
}
// --- RESTART BUG FIX: Relock Towers ---
if (TD.factionData) {
for (var faction in TD.factionData) {
if (TD.factionData.hasOwnProperty(faction)) {
var towers = TD.factionData[faction];
for (var i = 0; i < towers.length; i++) {
// Towers 1-4 (index 0-3) are unlocked by default. 5-11 are locked.
towers[i].is_unlocked = (i < 4);
towers[i].free_first_build = false;
}
}
}
}
if (TD.current_faction) TD.loadFaction(TD.current_faction);
// --------------------------------------
if (typeof TD.psycho_active !== 'undefined') TD.psycho_active = 0;
document.getElementById("td-board").style.boxShadow = "none";
// Hide the Game Over text and Static Overlay
var goText = document.getElementById("game-over-text");
if (goText) goText.style.display = "none";
window.triggerPanelIgnition();
TD.start();
// --- FIX: Re-apply Global Starting Caps Doubler ---
if (typeof TD.money !== "undefined") {
TD.money = Math.floor(TD.money * 2.0);
}
TD.mouseHand(false);
}, 0);
};
var btnExit = document.getElementById("ui-btn-exit");
if (btnExit) btnExit.onclick = function() {
// Instantly reloads the page context to the Main Menu.
// This guarantees the engine cache is completely flushed, so when
// you pick a new Faction and hit Play Game, it loads perfectly.
window.location.reload();
};
var btnUpgrade = document.getElementById("ui-btn-upgrade");
if (btnUpgrade) btnUpgrade.onclick = function() { if(_this.scene.map.selected_building) _this.scene.map.selected_building.tryToUpgrade(_this.btn_upgrade); };
var btnSell = document.getElementById("ui-btn-sell");
if (btnSell) btnSell.onclick = function() { if(_this.scene.map.selected_building) _this.scene.map.selected_building.tryToSell(_this.btn_sell); };
var btnBuild = document.getElementById("ui-btn-build");
if (btnBuild) btnBuild.onclick = function() { if(_this.scene.map.selected_building) _this.scene.map.selected_building.tryToUpgrade(_this.btn_build); };
// Wire up the new HTML tower buying buttons and update text feedback safely
var setDesc = function(txt) { var d = document.getElementById("ui-action-desc"); if(d) d.innerText = txt; };
// Wire up the Time Controls
var btnStartWave = document.getElementById("ui-btn-start-wave");
if (btnStartWave) btnStartWave.onclick = function() {
if (TD.stage.wait_new_wave > 0) {
var secondsSkipped = Math.floor(TD.stage.wait_new_wave / TD.exp_fps);
// --- NEW: Guaranteed Minimum Wave Income ---
// Base 25 caps, plus 15 extra caps for every wave you have survived
var currentWaveIdx = (_this.scene && _this.scene.wave) ? _this.scene.wave : 0;
var guaranteedCaps = 25 + (currentWaveIdx * 15);
var bonusCaps = (secondsSkipped * 5) + guaranteedCaps; // Combine them
TD.stage.wait_new_wave = 0;
if (bonusCaps > 0 && _this.map) {
setDesc("WAVE DEPLOYED: +" + bonusCaps + " CAPS SECURED");
var btnRect = btnStartWave.getBoundingClientRect();
var targetEl = document.getElementById("ui-money-container");
var targetRect = targetEl ? targetEl.getBoundingClientRect() : btnRect;
var coin_count = Math.min(secondsSkipped, 5);
var capsPerCoin = Math.floor(bonusCaps / coin_count);
var remainder = bonusCaps % coin_count;
for (var i = 0; i < coin_count; i++) {
(function(delay, i_idx) {
setTimeout(function() {
var animImg = document.createElement("img");
animImg.src = "assets/images/caps.png";
animImg.style.position = "fixed";
var startX = btnRect.left + (btnRect.width / 2) - 20 + (Math.random() * 40 - 20);
var startY = btnRect.top - 20 + (Math.random() * 20 - 10);
animImg.style.left = startX + "px";
animImg.style.top = startY + "px";
animImg.style.width = "40px";
animImg.style.height = "40px";
animImg.style.zIndex = "999999";
animImg.style.pointerEvents = "none";
animImg.style.transition = "all 1.2s ease-in-out";
animImg.style.filter = "drop-shadow(0px 0px 10px rgba(255, 255, 255, 0.7))";
document.body.appendChild(animImg);
void animImg.offsetWidth;
animImg.style.left = (targetRect.left + (targetRect.width / 2) - 20) + "px";
animImg.style.top = (targetRect.top + (targetRect.height / 2) - 20) + "px";
animImg.style.transform = "scale(0.5) rotate(360deg)";
animImg.style.opacity = "0.2";
// Exactly when the 1.2s animation finishes, delete the image AND award the caps!
setTimeout(function() {
if (animImg.parentNode) animImg.parentNode.removeChild(animImg);
TD.money += capsPerCoin + (i_idx === 0 ? remainder : 0);
}, 1200);
}, delay * 100);
})(i, i);
}
} else {
setDesc("INITIATING EARLY WAVE...");
}
}
};
// NEW: Helper to safely update global speed and recalculate tower fire rates
var changeGameSpeed = function(newSpeed) {
TD.global_speed = newSpeed;
if (_this.map && _this.map.buildings) {
TD.lang.each(_this.map.buildings, function(b) {
b._fire_wait2 = Math.floor(Math.max(TD.exp_fps / (b.speed * (TD.global_speed / 0.2)), 1));
});
}
var multiplier = Math.round(TD.global_speed / 0.2);
// Inject the blinking animation globally if it doesn't exist
if (!document.getElementById("blink-style")) {
var style = document.createElement("style");
style.id = "blink-style";
style.innerHTML = "@keyframes blinker { 50% { opacity: 0.2; } } .blink-text { animation: blinker 1.5s linear infinite; color: #ffcc00; }";
document.head.appendChild(style);
}
var speedReadout = document.getElementById("ui-speed-readout");
var speedVal = document.getElementById("ui-speed-val");
var btnSpeed = document.getElementById("ui-btn-speed");
if (btnSpeed) btnSpeed.innerHTML = "SPEED UP"; // Keep button static
if (speedReadout && speedVal) {
if (multiplier > 1) {
speedVal.innerHTML = ">> " + multiplier + "X";
speedReadout.style.display = "block";
} else {
speedReadout.style.display = "none";
}
}
};
var btnSlow = document.getElementById("ui-btn-slow");
if (btnSlow) btnSlow.onclick = function() {
var newSpeed = Math.round((TD.global_speed - 0.2) * 10) / 10;
if (newSpeed >= 0.2) {
changeGameSpeed(newSpeed);
setDesc("TIME DILATION: DECREASED TO " + Math.round(TD.global_speed / 0.2) + "X");
}
};
var btnSpeed = document.getElementById("ui-btn-speed");
if (btnSpeed) btnSpeed.onclick = function() {
var newSpeed = Math.round((TD.global_speed + 0.2) * 10) / 10;
if (newSpeed <= 1.0) { // Caps the maximum speed at 5x
changeGameSpeed(newSpeed);
setDesc("TIME DILATION: ACCELERATED TO " + Math.round(TD.global_speed / 0.2) + "X");
}
};
// Wire up Audio Toggles
var btnSound = document.getElementById("ui-btn-sound");
if (btnSound) {
btnSound.onclick = function() {
TD.sound_enabled = !TD.sound_enabled;
if (TD.sound_enabled) {
this.classList.remove("unlit");
this.style.removeProperty("cursor"); // Restore default hover cursor
this.className = this.className.replace(/warm-up-[\w-]+/g, "").trim(); // Strip animations so it instantly lights up solid
setDesc("AUDIO: SOUND EFFECTS ENABLED");
} else {
this.classList.add("unlit");
this.style.setProperty("cursor", "pointer", "important"); // Keep clickable cursor
setDesc("AUDIO: SOUND EFFECTS MUTED");
}
};
}
var btnMusic = document.getElementById("ui-btn-music");
if (btnMusic) {
btnMusic.onclick = function() {
TD.music_enabled = !TD.music_enabled;
if (TD.music_enabled) {
this.classList.remove("unlit");
this.style.removeProperty("cursor");
this.className = this.className.replace(/warm-up-[\w-]+/g, "").trim(); // Strip animations so it instantly lights up solid
setDesc("AUDIO: MUSIC ENABLED");
TD.playMusic("bgm_game"); // Start music
} else {
this.classList.add("unlit");
this.style.setProperty("cursor", "pointer", "important");
setDesc("AUDIO: MUSIC MUTED");
TD.stopMusic(); // Stop music
}
};
}
var btnSave = document.getElementById("ui-btn-save");
if (btnSave) {
btnSave.onclick = function() {
// Gather critical game state
var saveData = {
money: TD.money,
score: TD.score,
wave: _this.scene ? _this.scene.wave : 0,
faction: TD.current_faction,
difficulty: TD.difficulty,
specials: window.selectedSPECIALs || [],
towers_unlocked: []
};
// Capture which towers the player has bought access to
if (TD.factionData && TD.current_faction) {
var towers = TD.factionData[TD.current_faction];
for (var i = 0; i < towers.length; i++) {
saveData.towers_unlocked.push(towers[i].is_unlocked);
}
}
// Create file and force download
var dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(saveData));
var downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "wasteland_save.json");
document.body.appendChild(downloadAnchorNode); // required for Firefox
downloadAnchorNode.click();
downloadAnchorNode.remove();
setDesc("SYSTEM STATE SAVED TO WASTELAND_SAVE.JSON");
};
}
// Wire up Consumables
// Wire up Consumables
var bStim = document.getElementById("btn-item-stimpak");
if (bStim) bStim.onclick = function() {
// Prevent use if already at 100 HP
if (TD.inventory.stimpak > 0 && TD.cooldowns.stimpak <= 0 && TD.life < 100) {
TD.inventory.stimpak--;
// Only trigger cooldown if they have more left
TD.cooldowns.stimpak = (TD.inventory.stimpak > 0) ? TD.getCooldownFrames() : 0;
var healAmount = 15;
if (TD.life + healAmount > 100) healAmount = 100 - TD.life;
TD.life += healAmount;
setDesc("STIMPAK ADMINISTERED: +" + healAmount + " HP");
if (window.IntelData && TD.Announcer) TD.Announcer.add(window.IntelData.getEvent("consumable_used", TD.current_faction), 4);
TD.updateItemButton("stimpak");
} else if (TD.life >= 100) {
setDesc("HP FULL. STIMPAK NOT NEEDED.");
}
};
var bPsycho = document.getElementById("btn-item-psycho");
if (bPsycho) bPsycho.onclick = function() {
if (TD.inventory.psycho > 0 && TD.cooldowns.psycho <= 0) {
TD.inventory.psycho--;
TD.cooldowns.psycho = (TD.inventory.psycho > 0) ? TD.getCooldownFrames() : 0;
TD.psycho_active = 15 * 24; // Extended to 15 seconds
document.getElementById("td-board").style.boxShadow = "inset 0 0 50px rgba(255,0,0,0.5)";
setDesc("PSYCHO INJECTED! +25% FIRE RATE AND DAMAGE!");
if (window.IntelData && TD.Announcer) TD.Announcer.add(window.IntelData.getEvent("consumable_used", TD.current_faction), 4);
TD.updateItemButton("psycho");
}
};
// Wire up Cryo Mine
var bCryo = document.getElementById("btn-item-cryomine");
if (bCryo) bCryo.onclick = function() {
if (TD.inventory.cryomine > 0 && TD.cooldowns.cryomine <= 0 && _this.map) {
_this.map.cancelPreBuild(); // Clear ghost FIRST
TD.mode = "deploy_cryomine"; // THEN set mode
setDesc("DEPLOYING CRYO MINE. Click map to place.");
TD.mouseHand(true);
}
};
// Wire up Cap Mine
var bCap = document.getElementById("btn-item-capmine");
if (bCap) bCap.onclick = function() {
if (TD.inventory.capmine > 0 && TD.cooldowns.capmine <= 0 && _this.map) {
_this.map.cancelPreBuild(); // Clear ghost FIRST
TD.mode = "deploy_capmine"; // THEN set mode
setDesc("DEPLOYING CAP MINE. Click map to place.");
TD.mouseHand(true);
}
};
// Wire up Artillery
var bArt = document.getElementById("btn-item-artillery");
if (bArt) bArt.onclick = function() {
if (TD.inventory.artillery > 0 && TD.cooldowns.artillery <= 0 && _this.map) {
_this.map.cancelPreBuild(); // Clear ghost FIRST
TD.mode = "deploy_artillery"; // THEN set mode
setDesc("CALLING ARTILLERY STRIKE. Click map to target.");
TD.mouseHand(true);
}
};
// Wire up the Checkbox
var chkGrid = document.getElementById("ui-chk-grid");
if (chkGrid) {
// Ensure the checkbox matches the game's internal variable
chkGrid.checked = !!TD.show_grid;
chkGrid.onchange = function() {
TD.show_grid = this.checked;
setDesc(TD.show_grid ? "VISUAL OVERLAY: GRID ONLINE" : "VISUAL OVERLAY: GRID OFFLINE");
};
}
// Wire up building buttons (11 Slots) - Build or Unlock Logic
for (var i = 1; i <= 11; i++) {
(function(index) {
var btn = document.getElementById("btn-buy-tower-" + index);
if (btn) {
btn.onclick = function() {
if (!TD.current_faction || !TD.factionData[TD.current_faction]) return;
var tData = TD.factionData[TD.current_faction][index - 1]; // Array is 0-indexed
if (!tData) return;
if (tData.is_unlocked) {
// It is unlocked, prepare to build
if (_this.map) {
var buildCost = tData.free_first_build ? 0 : tData.cost;
if (TD.money >= buildCost) {
if (tData.id === "mr_handy") {
_this.map.cancelPreBuild();
TD.mode = "deploy_handy";
setDesc("DEPLOYING MR. HANDY. Click map to place.");
TD.mouseHand(true);
} else {
_this.map.preBuild("tower_" + index);
setDesc("DEPLOYING " + tData.name + ". Click map to place.");
}
} else {
setDesc("NOT ENOUGH CAPS TO BUILD! Need " + buildCost);
}
}
} else {
// It is locked, try to unlock it using the dynamic cost
var unlockCost = tData.unlock_cost || 0;
if (TD.money >= unlockCost) {
TD.money -= unlockCost;
tData.is_unlocked = true;
tData.free_first_build = true; // NEW: Set the free build flag
TD.loadFaction(TD.current_faction); // Refresh buttons
setDesc(tData.name + " AUTHORIZED.");
if (typeof TD.logEvent === 'function') TD.logEvent("NEW TECH AUTHORIZED: " + tData.name + " DEPLOYMENT READY");
} else {
setDesc("NOT ENOUGH CAPS TO UNLOCK! Need " + unlockCost);
}
}
};
}
})(i);
}
// SHIFT + A Listener for Admin Mode
document.onkeydown = function(e) {
if (e.shiftKey && (e.key === "A" || e.key === "a")) {
TD.admin_mode = !TD.admin_mode;
var d = document.getElementById("ui-action-desc");
var adminPanel = document.getElementById("admin-panel");
var controlPanel = document.getElementById("control-panel");
if (TD.admin_mode) {
// Safely pause and wipe action without destroying the rendering engine
if (_this.scene) {
_this.scene.pause();
var activeMap = _this.scene.map;
if (activeMap) {
// Kill all monsters and bullets
TD.lang.each(activeMap.monsters, function(m) { m.is_valid = false; });
activeMap.monsters = [];
TD.lang.each(activeMap.bullets, function(b) { b.is_valid = false; });
activeMap.bullets = [];
// Remove all towers
TD.lang.each(activeMap.buildings, function(b) { b.grid.removeBuilding(); });
activeMap.buildings = [];
activeMap.has_weapon = false;
}
_this.scene.wave = 0; // Reset wave counter
}
// Hide pre-building ghost tower
if (_this.scene && _this.scene.map && _this.scene.map.pre_building) _this.scene.map.pre_building.hide();
if (controlPanel) controlPanel.style.display = "none";
if (adminPanel) adminPanel.style.display = "flex";
if(d) d.innerText = "G.E.C.K. EDITOR OVERRIDE ENGAGED";
} else {
// RESUME GAMEPLAY
if (_this.scene) {
_this.scene.start(); // Restart the internal scene clock
TD.stage.wait_new_wave = TD.exp_fps * 3; // Reset the wave countdown timer
}
if (adminPanel) adminPanel.style.display = "none";
if (controlPanel) controlPanel.style.display = "flex";
if(d) d.innerText = "AWAITING PIP-BOY INPUT...";
}
}
};
// Wire up the Admin Brushes
var setBrush = function(brushName) { TD.admin_brush = brushName; var d = document.getElementById("ui-action-desc"); if(d) d.innerText = "BRUSH SELECTED: " + brushName.toUpperCase(); };
var bPath = document.getElementById("admin-brush-path"); if(bPath) bPath.onclick = function() { setBrush("path"); };
var bBlock = document.getElementById("admin-brush-block"); if(bBlock) bBlock.onclick = function() { setBrush("block"); };
var bClear = document.getElementById("admin-brush-clear"); if(bClear) bClear.onclick = function() { setBrush("clear"); };
var bEnt = document.getElementById("admin-brush-ent"); if(bEnt) bEnt.onclick = function() { setBrush("entrance"); };
var bExit = document.getElementById("admin-brush-exit"); if(bExit) bExit.onclick = function() { setBrush("exit"); };
// NEW: Tower Brush Binding
var bTower = document.getElementById("admin-brush-tower");
if(bTower) bTower.onclick = function() { setBrush("tower"); };
// New Tools: Fill, Clear All, and Overlay Toggle
var bFill = document.getElementById("admin-btn-fill");
if(bFill) bFill.onclick = function() {
TD.lang.each(_this.map.grids, function(g) {
if (TD.admin_brush == "path") { g.passable_flag = 1; g.build_flag = 0; }
else if (TD.admin_brush == "block") { g.passable_flag = 0; g.build_flag = 0; }
else if (TD.admin_brush == "clear") { g.passable_flag = 1; g.build_flag = 1; }
});
};
var bClearAll = document.getElementById("admin-btn-clearall");
if(bClearAll) bClearAll.onclick = function() {
// Reset everything to default Clear state
TD.lang.each(_this.map.grids, function(g) { g.passable_flag = 1; g.build_flag = 1; g.is_entrance = false; g.is_exit = false; });
// CRITICAL FIX: Completely wipe the memory arrays and visual assets
_this.map.entrances = [];
_this.map.exits = [];
_this.map.entrance = null;
_this.map.exit = null;
_this.map.assets = [];
// Force an immediate redraw so the screen actually clears!
_this.scene.render();
};
TD.admin_overlay = true; // Overlay is on by default
var bToggle = document.getElementById("admin-btn-toggle");
if(bToggle) bToggle.onclick = function() { TD.admin_overlay = !TD.admin_overlay; };
// NEW: Open Asset Library Modal
var btnOpenAssets = document.getElementById("admin-btn-open-assets");
if (btnOpenAssets) {
btnOpenAssets.onclick = function() {
if (TD.populateAssetModal) TD.populateAssetModal();
document.getElementById("asset-modal-overlay").style.display = "flex";
};
}
// Wire up the Map Loader
var btnLoadMap = document.getElementById("admin-btn-loadmap");
if (btnLoadMap) btnLoadMap.onclick = function() {
var lvl = document.getElementById("admin-level-select").value;
var cfg = TD.getDefaultStageData(lvl);
if (cfg && cfg.map && _this.scene && _this.scene.map) {
// --- NEW: Load Background Image and Update Text Box ---
if (cfg.bg_image) {
var board = document.getElementById("td-board");
if (board) {
board.style.backgroundImage = "url('" + cfg.bg_image + "')";
board.style.backgroundSize = "100% 100%";
board.style.backgroundRepeat = "no-repeat";
}
var imgInput = document.getElementById("admin-img-input");
if (imgInput) imgInput.value = cfg.bg_image;
}
// --- NEW: Load Environment Effect ---
var envOverlay = document.getElementById("env-effect-overlay");
if (envOverlay) {
if (cfg.map && cfg.map.env_effect) {
var eCfg = cfg.map.env_effect;
document.getElementById("admin-env-src").value = eCfg.src;
document.getElementById("admin-env-type").value = eCfg.type;
document.getElementById("admin-env-opacity").value = eCfg.opacity;
document.getElementById("admin-env-speed").value = eCfg.speed;
envOverlay.style.backgroundImage = "url('" + eCfg.src + "')";
document.documentElement.style.setProperty("--env-opacity", eCfg.opacity);
envOverlay.style.animation = "none";
void envOverlay.offsetWidth;
if (eCfg.type === "static") { envOverlay.style.opacity = eCfg.opacity; }
else if (eCfg.type === "fade") { envOverlay.style.opacity = 0; envOverlay.style.animation = "env-fade " + eCfg.speed + "s infinite ease-in-out"; }
else { envOverlay.style.opacity = eCfg.opacity; envOverlay.style.animation = "env-" + eCfg.type.replace("_", "-") + " " + eCfg.speed + "s infinite linear"; }
envOverlay.style.display = "block";
} else {
// Clear inputs and hide overlay if no effect is saved
document.getElementById("admin-env-src").value = "";
envOverlay.style.display = "none";
envOverlay.style.animation = "none";
}
}
// ----------------------------------------------------
var m = _this.scene.map;
// Reset grid to default blank slate
TD.lang.each(m.grids, function(g) { g.passable_flag = 1; g.build_flag = 1; g.is_entrance = false; g.is_exit = false; });
// CRITICAL FIX: Completely wipe the memory arrays before loading new data
m.entrances = []; m.exits = []; m.entrance = null; m.exit = null;
m.assets = []; // --- FIX: Wipe old visual paths and assets! ---
// Reapply config
if (cfg.map.grids_cfg) {
TD.lang.each(cfg.map.grids_cfg, function (obj) {
var grid = m.getGrid(obj.pos[0], obj.pos[1]);
if (grid) {
if (!isNaN(obj.passable_flag)) grid.passable_flag = obj.passable_flag;
if (!isNaN(obj.build_flag)) grid.build_flag = obj.build_flag;
if (obj.building) {
grid.addBuilding(obj.building);
if (obj.building_level && grid.building) {
for (var lvl = 1; lvl < obj.building_level; lvl++) grid.building.upgrade();
}
}
}
});
}
// Properly rebuild the Entrance arrays
if (cfg.map.entrance) { var eg = m.getGrid(cfg.map.entrance[0], cfg.map.entrance[1]); if(eg) { eg.is_entrance = true; eg.passable_flag = 1; eg.build_flag = 0; m.entrances.push(eg); } }
if (cfg.map.entrances) {
for (var i = 0; i < cfg.map.entrances.length; i++) {
var eg = m.getGrid(cfg.map.entrances[i][0], cfg.map.entrances[i][1]);
if (eg) { eg.is_entrance = true; eg.passable_flag = 1; eg.build_flag = 0; m.entrances.push(eg); }
}
}
// Properly rebuild the Exit arrays
if (cfg.map.exit) { var xg = m.getGrid(cfg.map.exit[0], cfg.map.exit[1]); if(xg) { xg.is_exit = true; xg.passable_flag = 1; xg.build_flag = 0; m.exits.push(xg); } }
if (cfg.map.exits) {
for (var i = 0; i < cfg.map.exits.length; i++) {
var xg = m.getGrid(cfg.map.exits[i][0], cfg.map.exits[i][1]);
if (xg) { xg.is_exit = true; xg.passable_flag = 1; xg.build_flag = 0; m.exits.push(xg); }
}
}
m.entrance = m.entrances[0] || null;
m.exit = m.exits[0] || null;
// --- NEW: Load Custom Wave Data if it exists ---
if (cfg.custom_waves) {
TD.custom_wave_data = JSON.parse(JSON.stringify(cfg.custom_waves));
} else {
// Reset to blank slate if no custom waves exist on this map
TD.custom_wave_data = {};
for (var w = 1; w <= 15; w++) {
TD.custom_wave_data[w] = {
story_enable: false,
story_data: { "ALL": {title:"", desc:""}, "NCR": {title:"", desc:""}, "BOS": {title:"", desc:""}, "MINUTEMEN": {title:"", desc:""}, "RAIDERS": {title:"", desc:""} },
towers: {}, spawns: []
};
}
}
if (typeof TD.we_loadCurrentWave === 'function' && document.getElementById('wave-editor-modal-overlay').style.display !== 'none') {
TD.we_loadCurrentWave();
}
// --- FIX: Rebuild Visual Assets for the Loaded Map ---
if (cfg.map.assets) {
TD.lang.each(cfg.map.assets, function (a) {
var imgObj = new Image();
imgObj.src = a.src;
var x_px = m.x + (a.mx * TD.grid_size);
var y_px = m.y + (a.my * TD.grid_size);
var w_px = a.grid_x * TD.grid_size;
var h_px = a.grid_y * TD.grid_size;
m.assets.push({
id: a.id, src: a.src, img: imgObj,
mx: a.mx, my: a.my, grid_x: a.grid_x, grid_y: a.grid_y,
blocks_path: a.blocks_path, category: a.category,
x: x_px, y: y_px, width: w_px, height: h_px,
y2: y_px + h_px, is_asset: true
});
for (var dx = 0; dx < a.grid_x; dx++) {
for (var dy = 0; dy < a.grid_y; dy++) {
var g = m.getGrid(a.mx + dx, a.my + dy);
if (g) {
if (a.category !== "path") g.build_flag = 0;
if (a.blocks_path) g.passable_flag = 0;
}
}
}
});
}
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "GRID DATA LOADED: " + lvl;
}
};
// Wire up the Image Loader
var btnLoadImg = document.getElementById("admin-btn-loadimg");
if (btnLoadImg) btnLoadImg.onclick = function() {
var url = document.getElementById("admin-img-input").value;
if(url) {
var board = document.getElementById("td-board");
if (board) {
board.style.backgroundImage = "url('" + url + "')";
board.style.backgroundSize = "100% 100%";
board.style.backgroundRepeat = "no-repeat";
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "BACKGROUND LOADED: " + url;
}
}
};
// --- NEW: Wire up Environmental Effects ---
var btnEnvApply = document.getElementById("admin-btn-env-apply");
if (btnEnvApply) btnEnvApply.onclick = function() {
var envOverlay = document.getElementById("env-effect-overlay");
var src = document.getElementById("admin-env-src").value;
if (!envOverlay) return;
if (!src) {
envOverlay.style.display = "none";
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "ENVIRONMENT CLEARED.";
return;
}
var type = document.getElementById("admin-env-type").value;
var opacity = parseFloat(document.getElementById("admin-env-opacity").value) || 0.5;
var speed = parseInt(document.getElementById("admin-env-speed").value) || 20;
envOverlay.style.backgroundImage = "url('" + src + "')";
document.documentElement.style.setProperty("--env-opacity", opacity);
// Clear old animations first
envOverlay.style.animation = "none";
void envOverlay.offsetWidth; // Trigger reflow
if (type === "static") {
envOverlay.style.opacity = opacity;
} else if (type === "fade") {
envOverlay.style.opacity = 0; // The animation handles visibility
envOverlay.style.animation = "env-fade " + speed + "s infinite ease-in-out";
} else {
envOverlay.style.opacity = opacity;
var animName = "env-" + type.replace("_", "-");
envOverlay.style.animation = animName + " " + speed + "s infinite linear";
}
envOverlay.style.display = "block";
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "ENVIRONMENT EFFECT APPLIED.";
};
// --- NEW: BULLETPROOF PATH VALIDATOR ---
var btnValidate = document.getElementById("admin-btn-validate");
if (btnValidate) {
btnValidate.onclick = function() {
var m = _this.scene.map;
if (!m || !m.entrance || !m.exit) {
alert("ERROR: Missing Entrance or Exit!");
return;
}
// Native Engine Pathfinder
var fw = new TD.FindWay(
m.grid_x, m.grid_y,
m.entrance.mx, m.entrance.my,
m.exit.mx, m.exit.my,
function (x, y) { return m.checkPassable(x, y); }
);
// fw.is_arrived is true if it reached the exit.
if (fw.is_arrived) {
var validPath = [m.entrance];
if (fw.way && fw.way.length > 0) {
for (var i = 0; i < fw.way.length; i++) {
var gx = fw.way[i][0];
var gy = fw.way[i][1];
var g = m.getGrid(gx, gy);
if (g) validPath.push(g);
}
} else {
validPath.push(m.exit);
}
// 1. ALERT FIRST so you always get the message!
alert("SUCCESS! Path is connected (" + validPath.length + " tiles long).");
// 2. Safe Hologram Render (No crashing, no flashing)
if (!m._holo_render_active) {
m._holo_render_active = true;
var originalRender = m.render;
var life = 60; // Lasts ~2 seconds
m.render = function() {
originalRender.apply(this, arguments); // Draw map normally
if (life > 0) {
var ctx = TD.ctx;
ctx.fillStyle = "rgba(0, 255, 255, " + (life / 60) * 0.5 + ")";
for (var i = 0; i < validPath.length; i++) {
var g = validPath[i];
if (g) ctx.fillRect(g.x, g.y, g.width, g.height);
}
life--;
} else {
// Clean up and restore original render
m.render = originalRender;
m._holo_render_active = false;
}
};
}
} else {
alert("ERROR: The path is broken! Make sure you used the Blue Path brush to connect the Entrance to the Exit.");
}
};
}
// -------------------------------------
// --- NEW: MAP EXPORTER ---
var btnExport = document.getElementById("admin-btn-export");
if (btnExport) {
btnExport.onclick = function() {
var m = _this.scene.map;
var spawnMode = document.getElementById("admin-spawn-mode") ? document.getElementById("admin-spawn-mode").value : "random";
// 1. Get Entrances and Exits as Arrays
var entArr = [];
if (m.entrances && m.entrances.length > 0) {
for (var i = 0; i < m.entrances.length; i++) entArr.push("[" + m.entrances[i].mx + ", " + m.entrances[i].my + "]");
}
var extArr = [];
if (m.exits && m.exits.length > 0) {
for (var i = 0; i < m.exits.length; i++) extArr.push("[" + m.exits[i].mx + ", " + m.exits[i].my + "]");
}
var entStr = entArr.length > 0 ? "[" + entArr.join(", ") + "]" : "[]";
var extStr = extArr.length > 0 ? "[" + extArr.join(", ") + "]" : "[]";
// 2. Build Grids Config
var grids = [];
TD.lang.each(m.grids, function(g) {
if (g.passable_flag !== 1 || g.build_flag !== 1 || g.building) {
var gStr = "{ pos: [" + g.mx + ", " + g.my + "]";
if (g.passable_flag !== 1) gStr += ", passable_flag: " + g.passable_flag;
if (g.build_flag !== 1) gStr += ", build_flag: " + g.build_flag;
if (g.building) gStr += ", building: '" + g.building.type + "', building_level: " + g.building.level;
gStr += " }";
grids.push(gStr);
}
});
// 2.5 Build Assets Config (Fully dynamic for future assets)
var assetArr = [];
if (m.assets && m.assets.length > 0) {
TD.lang.each(m.assets, function(a) {
// Rebuild the core properties needed to recreate this asset on load
var aStr = "{ id: '" + a.id + "', src: '" + a.src + "', mx: " + a.mx + ", my: " + a.my + ", grid_x: " + a.grid_x + ", grid_y: " + a.grid_y + ", blocks_path: " + a.blocks_path + ", category: '" + a.category + "' }";
assetArr.push(aStr);
});
}
var assetStr = assetArr.length > 0 ? "assets: [" + assetArr.join(", ") + "], " : "";
// --- NEW: Build Environment Config ---
var envStr = "";
var eSrc = document.getElementById("admin-env-src").value;
if (eSrc) {
var eType = document.getElementById("admin-env-type").value;
var eOp = document.getElementById("admin-env-opacity").value;
var eSpd = document.getElementById("admin-env-speed").value;
envStr = "env_effect: { src: '" + eSrc + "', type: '" + eType + "', opacity: " + eOp + ", speed: " + eSpd + " }, ";
}
// 3. Form the final string (SINGLE LINE, DYNAMIC NAME)
if (typeof TD.we_saveCurrentWave === 'function') TD.we_saveCurrentWave(); // Ensure latest edits are saved
var waveDataStr = "";
if (typeof TD.custom_wave_data !== 'undefined') {
waveDataStr = "custom_waves: " + JSON.stringify(TD.custom_wave_data) + ", ";
}
var bgImg = document.getElementById("admin-img-input").value || "map01.jpg";
var loadedMapName = document.getElementById("admin-level-select") ? document.getElementById("admin-level-select").value : "custom_map";
var finalCode = loadedMapName + ": { " + waveDataStr + "bg_image: '" + bgImg + "', map: { " + envStr + "map_type: '" + m.map_type + "', spawn_mode: '" + spawnMode + "', grid_x: " + m.grid_x + ", grid_y: " + m.grid_y + ", x: " + m.x + ", y: " + m.y + ", entrances: " + entStr + ", exits: " + extStr + ", " + assetStr + "grids_cfg: [" + grids.join(", ") + "] }, panel: { x: 1920, y: TD.padding, map: { grid_x: 3, grid_y: 3, x: 0, y: 110 * _TD.retina, grids_cfg: [] } }, config: config_base, newWave: newWave_func },";
var outBox = document.getElementById("admin-export-out");
if (outBox) {
outBox.value = finalCode;
outBox.select();
}
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "CODE GENERATED! Click COPY CODE.";
};
}
// COPY BUTTON LOGIC
var btnCopy = document.getElementById("admin-btn-copy");
if (btnCopy) {
btnCopy.onclick = function() {
var outBox = document.getElementById("admin-export-out");
if (outBox && outBox.value) {
outBox.select();
document.execCommand("copy"); // Copies to clipboard
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "CODE COPIED TO CLIPBOARD!";
}
};
}
},
step: function () {
if (TD.life_recover) { this._life_recover = this._life_recover2 = TD.life_recover; this._life_recover_wait = this._life_recover_wait2 = TD.exp_fps * 3; TD.life_recover = 0; }
if (this._life_recover && (TD.iframe % TD.exp_fps_eighth == 0)) { TD.life ++; this._life_recover --; }
// --- NEW: TYPEWRITER RENDER LOOP ---
var tickerEl = document.getElementById("intel-ticker-text");
var tickerContainer = document.getElementById("intel-ticker-container");
// Assign hover pause listeners if they don't exist yet
if (tickerContainer && typeof TD.ticker_hover === 'undefined') {
TD.ticker_hover = false;
tickerContainer.onmouseover = function() { TD.ticker_hover = true; };
tickerContainer.onmouseout = function() { TD.ticker_hover = false; };
}
if (tickerEl && tickerContainer && this.scene && this.scene.state === 1) {
// Call the announcer brain every frame, it manages its own reading/typing delays now!
if (TD.Announcer) TD.Announcer.update();
// Only type if we haven't finished the string, AND the mouse isn't hovering
if (TD.typewriter_current.length < TD.typewriter_target.length && !TD.ticker_hover) {
// Slower, classic typing speed (1 character per frame at normal speed)
var charsToAdd = Math.max(1, Math.floor(1.5 * TD.global_speed * (60 / TD.exp_fps)));
TD.typewriter_current = TD.typewriter_target.substring(0, TD.typewriter_current.length + charsToAdd);
// FIX: Standard pipe cursor
tickerEl.innerHTML = TD.typewriter_current + "<span class='blink-text' style='color:#0f0; font-weight:bold;'>|</span>";
}
// Once finished, remove the cursor and tick down the reading pause
else if (TD.typewriter_current.length >= TD.typewriter_target.length && TD.typewriter_target.length > 0) {
tickerEl.innerHTML = TD.typewriter_current;
// Count down the reading pause
if (!TD.ticker_hover && TD.typewriter_pause > 0) {
TD.typewriter_pause--;
}
}
}
// --- NEW: V.A.T.S. UI & Timer Logic ---
var vatsBtn = document.getElementById("btn-vats-activate");
if (vatsBtn && typeof TD.vats_charge !== 'undefined') {
// --- CRITICAL FIX: Calculate the visual percentage ---
var vatsPercent = Math.min(100, Math.floor((TD.vats_charge / TD.vats_max_charge) * 100));
if (TD.vats_active) {
TD.vats_timer--;
var secsLeft = Math.ceil(TD.vats_timer / TD.exp_fps);
vatsBtn.innerHTML = "SHOTS: " + TD.vats_shots + "<br>" + secsLeft + "s";
vatsBtn.style.lineHeight = "1.2";
vatsBtn.style.backgroundImage = "none";
vatsBtn.classList.add("vats-charged-glow");
var hasValidMonsters = false;
var incomingMonsters = false;
if (this.map) {
if (this.map.monsters) {
for (var i = 0; i < this.map.monsters.length; i++) {
if (this.map.monsters[i].is_valid) { hasValidMonsters = true; break; }
}
}
incomingMonsters = (this.map._wait_add_monsters > 0 || this.map._wait_add_monsters_arr.length > 0);
}
if (TD.vats_timer <= 0 || TD.vats_shots <= 0 || (!hasValidMonsters && !incomingMonsters)) {
TD.endVats();
}
} else if (vatsPercent >= 100) {
vatsBtn.disabled = TD.is_paused; // Block activation if paused!
vatsBtn.innerHTML = "ACTIVATE<br>V.A.T.S.";
vatsBtn.style.lineHeight = "1.2";
vatsBtn.style.cursor = TD.is_paused ? "not-allowed" : "pointer";
vatsBtn.style.backgroundImage = "none";
vatsBtn.classList.add("vats-charged-glow");
} else {
vatsBtn.disabled = true;
vatsBtn.innerText = "V.A.T.S. " + vatsPercent + "%";
vatsBtn.style.cursor = "not-allowed";
vatsBtn.style.lineHeight = "normal";
vatsBtn.style.backgroundImage = "none";
// Reset everything completely so inline styles stop interfering
vatsBtn.style.removeProperty("color");
vatsBtn.style.removeProperty("box-shadow");
vatsBtn.style.removeProperty("background-color");
vatsBtn.classList.remove("vats-charged-glow");
}
}
},
render: function () {
// True Physical Nixie Tube Simulator
var updateNixie = function(containerId, targetValue, length, suffix) {
var container = document.getElementById(containerId);
if (!container) return;
var targetStr = targetValue.toString();
// Pad with spaces instead of zeros so no wire receives power
while (targetStr.length < length) targetStr = " " + targetStr;
if (suffix) targetStr += suffix;
// 1. Initialization: Build physical 0-9 wire stack
if (container.children.length === 0) {
container.innerHTML = "";
for (var i = 0; i < targetStr.length; i++) {
// Generate a random flicker profile (1 through 5)
var randomFlicker = "warm-up-" + (Math.floor(Math.random() * 5) + 1);
if (targetStr[i] === "%") {
var tube = document.createElement("div");
tube.className = "nixie-tube";
tube.setAttribute("data-current", "%");
var wire = document.createElement("div");
wire.className = "nixie-wire active " + randomFlicker;
wire.setAttribute("data-digit", "%");
wire.innerText = "%";
tube.appendChild(wire);
container.appendChild(tube);
} else {
var tube = document.createElement("div");
tube.className = "nixie-tube";
tube.setAttribute("data-current", targetStr[i]);
// Create the 10 physical wires in depth order
var wireOrder = ['0','1','2','3','4','5','6','7','8','9'];
for (var w = 0; w < wireOrder.length; w++) {
var wire = document.createElement("div");
wire.className = "nixie-wire";
wire.setAttribute("data-digit", wireOrder[w]);
wire.innerText = wireOrder[w];
if (wireOrder[w] === targetStr[i]) {
wire.classList.add("active");
wire.classList.add(randomFlicker); // Apply random flicker to the active digit
}
tube.appendChild(wire);
}
container.appendChild(tube);
}
}
return;
}
// 2. Trigger Ignition on changed tubes
for (var i = 0; i < targetStr.length; i++) {
if (targetStr[i] === "%") continue;
var tube = container.children[i];
if (!tube) continue;
var currentVal = tube.getAttribute("data-current");
var newTarget = targetStr[i];
if (currentVal !== newTarget) {
tube.setAttribute("data-current", newTarget);
// Find wires to turn off and on
var wires = tube.children;
for (var w = 0; w < wires.length; w++) {
var wire = wires[w];
if (wire.getAttribute("data-digit") === newTarget) {
wire.classList.add("active");
// Ignite stagger
(function(d) {
d.classList.remove("igniting");
void d.offsetWidth;
setTimeout(function() {
d.classList.add("igniting");
}, Math.random() * 80);
})(wire);
} else {
wire.classList.remove("active");
wire.classList.remove("igniting");
}
}
}
}
};
// Caps and Score get 6 digits
updateNixie("ui-money-container", TD.money, 6, "");
updateNixie("ui-score-container", TD.score, 6, "");
// Wave gets 3 digits
updateNixie("ui-wave-container", this.scene.wave, 3, "");
// Health gets 3 or 4 digits, plus a dynamic suffix
var elLabel = document.getElementById("ui-health-label");
if (elLabel) {
if (this.map && this.map.map_type === "siege") {
elLabel.innerText = "BASE HP";
updateNixie("ui-life-container", TD.base_health, 4, "");
} else {
elLabel.innerText = "HEALTH";
updateNixie("ui-life-container", TD.life, 3, "%");
}
// Color critical health safely (causes a deep red alarm pulse)
var lifeContainer = document.getElementById("ui-life-container");
var isCritical = (this.map && this.map.map_type === "siege") ? (TD.base_health <= 300) : (TD.life <= 25);
if (lifeContainer) {
for (var i=0; i<lifeContainer.children.length; i++) {
var child = lifeContainer.children[i];
if (child && child.classList) { // SAFETY CHECK
if (isCritical) child.classList.add("critical");
else child.classList.remove("critical");
}
}
}
}
// --- NEW: HEALTH-BASED POWER FAILURE ---
var hpPercent = 100;
if (this.map && this.map.map_type === "siege") hpPercent = (TD.base_health / 1000) * 100;
else hpPercent = TD.life;
// --- NEW: SCREEN CRACK OVERLAYS ---
var d70 = document.getElementById("damage-overlay-70");
var d50 = document.getElementById("damage-overlay-50");
var d30 = document.getElementById("damage-overlay-30");
var d10 = document.getElementById("damage-overlay-10");
var placeCrack = function(el, topMin, topMax, leftMin, leftMax, isActive) {
if (isActive) {
// Turn ON: Instant snap
el.style.transition = "none";
if (el.style.opacity === "0" || !el.style.opacity) {
el.style.top = Math.floor(Math.random() * (topMax - topMin + 1) + topMin) + "%";
el.style.left = Math.floor(Math.random() * (leftMax - leftMin + 1) + leftMin) + "%";
el.style.transform = "rotate(" + Math.floor(Math.random() * 360) + "deg)";
}
el.style.opacity = "0.9";
} else {
// Turn OFF: Smooth fade
el.style.transition = "opacity 2s ease-out";
el.style.opacity = "0";
}
};
if (d70 && d50 && d30 && d10) {
placeCrack(d70, 5, 25, 5, 25, (hpPercent <= 70 && hpPercent > 0));
placeCrack(d50, 45, 65, 55, 75, (hpPercent <= 50 && hpPercent > 0));
placeCrack(d30, 45, 65, 5, 25, (hpPercent <= 30 && hpPercent > 0));
placeCrack(d10, 5, 25, 55, 75, (hpPercent <= 10 && hpPercent > 0));
}
// 0% Threshold (Fade out buttons, kill signal feed)
var staticOverlay = document.getElementById("signal-lost-overlay");
if (hpPercent <= 0) {
document.body.classList.add("game-over-dead");
if (staticOverlay) staticOverlay.style.display = "block";
} else {
document.body.classList.remove("game-over-dead");
if (staticOverlay) staticOverlay.style.display = "none";
}
var powerLevel = 1.0;
var sputterChance = 0; // Out of 100 per frame
if (hpPercent <= 25) { powerLevel = 0.4; sputterChance = 10; }
else if (hpPercent <= 45) { powerLevel = 0.65; sputterChance = 5; }
else if (hpPercent <= 65) { powerLevel = 0.85; sputterChance = 2; }
// Apply global dimming to all lit buttons
document.documentElement.style.setProperty('--power-level', powerLevel);
// Randomly force a lit button to restart its ignition cycle (sputter)
if (sputterChance > 0 && Math.random() * 100 < sputterChance) {
// Find all buttons that are currently enabled and not blank
var litBtns = document.querySelectorAll(".cmd-btn:not(:disabled):not(.unlit), .sys-btn:not(:disabled):not(.unlit)");
if (litBtns.length > 0) {
var randBtn = litBtns[Math.floor(Math.random() * litBtns.length)];
randBtn.className = randBtn.className.replace(/warm-up-\d/g, "").trim();
void randBtn.offsetWidth;
randBtn.classList.add("warm-up-" + (Math.floor(Math.random() * 5) + 1));
}
}
// ----------------------------------------
var elFps = document.getElementById("ui-fps"); if(elFps) elFps.innerText = TD.fps;
// --- NEW: DYNAMIC MODAL UPGRADE AFFORDABILITY CHECK ---
var modalOverlay = document.getElementById("tower-modal-overlay");
if (modalOverlay && modalOverlay.style.display === "flex") {
[1, 2].forEach(function(pathNum) {
var btn = document.getElementById("modal-btn-path" + pathNum);
if (btn && btn.hasAttribute("data-cost")) {
var cost = parseInt(btn.getAttribute("data-cost"));
var nameEl = btn.querySelector("#path" + pathNum + "-name");
var descEl = btn.querySelector("#path" + pathNum + "-desc");
if (TD.money >= cost && btn.disabled) {
btn.disabled = false;
btn.style.backgroundColor = "#2a7a2a";
btn.style.borderColor = "#0f0";
if (nameEl) nameEl.style.color = "#000";
if (descEl) descEl.style.color = "#111";
} else if (TD.money < cost && !btn.disabled) {
btn.disabled = true;
btn.style.backgroundColor = "#444";
btn.style.borderColor = "#555";
if (nameEl) nameEl.style.color = "#888";
if (descEl) descEl.style.color = "#888";
}
}
});
}
// --- NEW: DYNAMIC TOWER AFFORDABILITY CHECK ---
if (TD.current_faction && TD.factionData[TD.current_faction]) {
var towers = TD.factionData[TD.current_faction];
for (var t = 0; t < 11; t++) {
var btn = document.getElementById("btn-buy-tower-" + (t + 1));
if (!btn) continue;
if (towers[t].is_unlocked) {
// Clean up flashing if any
btn.classList.remove('vats-charged-glow');
btn.style.removeProperty("box-shadow");
btn.style.removeProperty("background-color");
btn.style.removeProperty("color");
var cost = towers[t].free_first_build ? 0 : towers[t].cost; // FIX: Respect the free flag
var wasDisabled = btn.disabled;
// FIX: Mr. Handy Max Limit Check
if (towers[t].id === "mr_handy" && this.map && this.map.mr_handy_active) {
btn.disabled = true;
btn.innerHTML = towers[t].name + " <span style='color:#f55;'>(MAX)</span>";
btn.className = btn.className.replace(/warm-up-\d/g, "").trim();
}
else if (TD.money >= cost) {
btn.disabled = false;
// If it just became affordable, flicker it on!
if (wasDisabled) {
btn.className = btn.className.replace(/warm-up-\d/g, "").trim();
void btn.offsetWidth; // Force CSS reflow
btn.classList.add("warm-up-" + (Math.floor(Math.random() * 5) + 1));
}
} else {
btn.disabled = true;
// Strip glow immediately if broke
btn.className = btn.className.replace(/warm-up-\d/g, "").trim();
}
} else {
// It is locked: Make it flash YELLOW if affordable!
var unlockCost = towers[t].unlock_cost || 0;
if (TD.money >= unlockCost) {
var newlyLit = !btn.classList.contains("vats-charged-glow");
btn.classList.remove("unlit");
btn.classList.add("vats-charged-glow");
btn.style.removeProperty("box-shadow");
btn.style.removeProperty("background-color");
btn.style.removeProperty("color");
if (newlyLit && document.getAnimations) {
var anims = document.getAnimations();
var vatsAnims = [];
for (var a = 0; a < anims.length; a++) {
if (anims[a].animationName === "vats-breathe") vatsAnims.push(anims[a]);
}
if (vatsAnims.length > 0) {
var targetTime = vatsAnims[0].currentTime;
for (var a = 1; a < vatsAnims.length; a++) {
vatsAnims[a].currentTime = targetTime;
}
}
}
} else {
btn.classList.add("unlit");
btn.classList.remove("vats-charged-glow");
btn.style.removeProperty("box-shadow");
btn.style.removeProperty("background-color");
btn.style.removeProperty("color");
}
}
}
}
var stg = this.scene.stage || TD.stage;
var elIntel = document.getElementById("ui-intel-brief");
var stateWave = document.getElementById("intel-state-wave");
if (elIntel && stg && stateWave) {
// ACCURATE STATE DETECTION: Checks active monsters AND the spawn queues
var waveInMotion = (this.map.monsters.length > 0 || this.map._wait_add_monsters > 0 || this.map._wait_add_monsters_arr.length > 0);
var isWaiting = !waveInMotion;
var sec = Math.max(0, Math.ceil(stg.wait_new_wave / TD.exp_fps));
var nextWaveIdx = this.scene.wave + 1;
var currentWaveIdx = this.scene.wave;
if (isWaiting) {
// --- STATE 1: WAITING FOR WAVE ---
document.getElementById("intel-wave-label").innerText = "WAVE " + nextWaveIdx + ":";
// --- NEW: Wave End Hook (Story Popup & Tower Limits) ---
if (stg.cfg && stg.cfg.custom_waves) {
var customData = stg.cfg.custom_waves[nextWaveIdx];
if (customData && (!this.scene._prep_done_for_wave || this.scene._prep_done_for_wave !== nextWaveIdx)) {
this.scene._prep_done_for_wave = nextWaveIdx;
// 1. Tower Availability Override
if (TD.factionData && TD.current_faction) {
var towers = TD.factionData[TD.current_faction];
for (var t = 1; t <= 11; t++) {
var tStatus = customData.towers["tower_" + t] || "unlocked";
var btn = document.getElementById("btn-buy-tower-" + t);
if (tStatus === "hidden") {
if (btn) btn.style.setProperty("display", "none", "important");
} else {
if (btn) btn.style.removeProperty("display");
if (towers[t-1]) {
// Only lock it if the player hasn't already paid to unlock it!
if (tStatus === "unlocked") {
towers[t-1].is_unlocked = true;
} else if (tStatus === "locked" && towers[t-1].is_unlocked !== true) {
towers[t-1].is_unlocked = false;
}
}
}
}
TD.loadFaction(TD.current_faction); // Refresh UI costs and locks
}
// 2. Story Popup Override
if (customData.story_enable && customData.story_data) {
var sData = customData.story_data[TD.current_faction];
// Fallback to "ALL" if faction-specific text is blank
if (!sData || (!sData.title && !sData.desc)) {
sData = customData.story_data["ALL"];
}
if (sData && (sData.title || sData.desc)) {
TD.is_paused = true; // Hard freeze engine
document.getElementById("campaign-intro-title").innerText = sData.title || "INCOMING TRANSMISSION";
// Convert both Enter keys and manually typed \n into proper HTML line breaks
document.getElementById("campaign-intro-desc").innerHTML = (sData.desc || "").replace(/\\n/g, "<br>").replace(/\n/g, "<br>");
document.getElementById("campaign-intro-modal-overlay").style.display = "flex";
}
}
}
}
// ---------------------------------------------------------
var wavesCfg = stg.config ? stg.config.waves : [];
// Dynamically update the configuration array in real-time before the wave starts
if (wavesCfg && (!this.scene._wave_generated_for || this.scene._wave_generated_for !== nextWaveIdx)) {
wavesCfg[nextWaveIdx] = TD.makeMonsters();
this.scene._wave_generated_for = nextWaveIdx;
this.scene._intel_chatter_generated = false; // Reset chatter flag for the next wave
}
var nextWaveData = wavesCfg[nextWaveIdx];
var monsterNames = [];
if (nextWaveData && nextWaveData.length > 0) {
for (var i = 0; i < nextWaveData.length; i++) {
var mQty = nextWaveData[i][0];
var mIdx = nextWaveData[i][1];
var mName = TD.getDefaultMonsterAttributes(mIdx).name;
monsterNames.push(mQty + "X " + mName.toUpperCase());
}
}
var monsterString = monsterNames.length > 0 ? monsterNames.join(", ") : "UNKNOWN THREATS";
// Bypass the queue and force the countdown directly into the ticker text instantly every frame
var tickerEl = document.getElementById("intel-ticker-text");
if (tickerEl) {
TD.typewriter_target = "<span style='color:#fff;'>T-MINUS:</span> " + sec + "s <span style='color:#6a7354;'>|</span> <span style='color:#fff;'>THREAT:</span> " + monsterString;
TD.typewriter_current = TD.typewriter_target; // Bypass typing animation for live timer
tickerEl.innerHTML = TD.typewriter_target;
}
} else {
// --- STATE 2: WAVE IN MOTION ---
document.getElementById("intel-wave-label").innerText = "WAVE " + currentWaveIdx + ":";
// Generate initial chatter when the wave starts
if (!this.scene._intel_chatter_generated && window.IntelData) {
this.scene._intel_chatter_generated = true;
if (typeof TD !== 'undefined' && TD.logEvent) {
// --- EARLY WARNING & COUNTER-MEASURE CHECK ---
var has_aa = false;
var has_detect = false;
var incoming_stealth = false;
var incoming_flyer = false;
// 1. Check what the player has built
if (this.map && this.map.buildings) {
TD.lang.each(this.map.buildings, function(b) {
if (b.is_valid) {
var vType = b.visual_type || b.type;
var isEarly = (b.type === "tower_1" || b.type === "tower_2" || b.type === "tower_3" || (b.id && (b.id.endsWith("_1") || b.id.endsWith("_2") || b.id.endsWith("_3"))));
if (!isEarly && (vType === "laser_gun" || vType === "LMG" || vType === "HMG" || vType === "rifle")) has_aa = true;
if (b.is_detector) has_detect = true;
}
});
}
// 2. Generate SMART Organic Wave Report
var currentWaveData = (stg.config && stg.config.waves) ? stg.config.waves[currentWaveIdx] : null;
var threatPhrases = [];
// A. Tally and Consolidate identical enemies
var consolidatedWave = {};
if (currentWaveData) {
for (var k = 0; k < currentWaveData.length; k++) {
var mIdx = currentWaveData[k][1];
var mQty = currentWaveData[k][0];
if (!consolidatedWave[mIdx]) consolidatedWave[mIdx] = { qty: 0, attr: TD.getDefaultMonsterAttributes(mIdx) };
consolidatedWave[mIdx].qty += mQty;
// Track critical threats
if (consolidatedWave[mIdx].attr.is_flying || mIdx === 8 || mIdx === 9 || mIdx === 10) incoming_flyer = true;
if (consolidatedWave[mIdx].attr.is_stealthed || mIdx === 11) incoming_stealth = true;
}
}
// B. Convert to text based on the number of unique types
var uniqueTypes = Object.keys(consolidatedWave).length;
var monsterStr = "UNKNOWN HOSTILES";
if (uniqueTypes >= 3) {
// Too many types = Truncate to a Horde description
monsterStr = "A MASSIVE MIXED HORDE";
if (incoming_flyer && incoming_stealth) monsterStr += " (CLOAKED AND AERIAL THREATS EMBEDDED)";
else if (incoming_flyer) monsterStr += " (AERIAL THREATS EMBEDDED)";
else if (incoming_stealth) monsterStr += " (CLOAKED THREATS EMBEDDED)";
} else if (uniqueTypes > 0) {
// 1 or 2 types = List them specifically with human quantities
for (var key in consolidatedWave) {
var group = consolidatedWave[key];
var qtyStr = "A FEW";
if (group.qty === 1) qtyStr = "A LONE";
else if (group.qty >= 2 && group.qty <= 4) qtyStr = "A SMALL GROUP OF";
else if (group.qty >= 5 && group.qty <= 9) qtyStr = "A HEAVY PRESENCE OF";
else if (group.qty >= 10) qtyStr = "A MASSIVE SWARM OF";
threatPhrases.push(qtyStr + " " + group.attr.name.toUpperCase() + (group.qty > 1 && !group.attr.name.endsWith("S") ? "S" : ""));
}
monsterStr = threatPhrases.join(" AND ");
}
// C. Pull the faction-specific Mad-Libs sentence and inject the Smart string!
var baseSentence = window.IntelData.getEvent("wave_start", TD.current_faction);
var finalReport = baseSentence.replace("{M}", monsterStr);
TD.logEvent(finalReport);
// 3. Log Missing Counter-Measures (We skipped the "Now" alerts because the new summary includes them naturally)
if (incoming_flyer && !has_aa) TD.logEvent(window.IntelData.getEvent("tactical_no_antiair", "GENERAL"));
else if (incoming_flyer) TD.logEvent(window.IntelData.getEvent("threat_flying_now", "GENERAL"));
if (incoming_stealth && !has_detect) TD.logEvent(window.IntelData.getEvent("tactical_no_detector", "GENERAL"));
else if (incoming_stealth) TD.logEvent(window.IntelData.getEvent("threat_stealth_now", "GENERAL"));
// 4. Look Ahead to the NEXT wave for early prep warnings
var nextWaveData = (stg.config && stg.config.waves) ? stg.config.waves[currentWaveIdx + 1] : null;
if (nextWaveData) {
var next_stealth = false;
var next_flyer = false;
for (var n = 0; n < nextWaveData.length; n++) {
var nAttr = TD.getDefaultMonsterAttributes(nextWaveData[n][1]);
if (nAttr.is_flying || nextWaveData[n][1] === 8 || nextWaveData[n][1] === 9 || nextWaveData[n][1] === 10) next_flyer = true;
if (nAttr.is_stealthed || nextWaveData[n][1] === 11) next_stealth = true;
}
if (next_flyer && !incoming_flyer) TD.logEvent(window.IntelData.getEvent("threat_flying_prep", "GENERAL"));
if (next_stealth && !incoming_stealth) TD.logEvent(window.IntelData.getEvent("threat_stealth_prep", "GENERAL"));
}
}
}
}
// 3. UI BUTTON STATES (Logic Locks)
var btnStartWave = document.getElementById("ui-btn-start-wave");
if (btnStartWave) {
// Disabled during combat
btnStartWave.disabled = waveInMotion;
btnStartWave.style.opacity = waveInMotion ? "0.3" : "1";
btnStartWave.style.cursor = waveInMotion ? "not-allowed" : "pointer";
}
var btnSlow = document.getElementById("ui-btn-slow");
var btnSpeed = document.getElementById("ui-btn-speed");
// Speed controls are DISABLED during "The Calm" (waiting phase)
var timeControlsActive = waveInMotion;
if (btnSlow) {
var canSlow = timeControlsActive && (Math.round(TD.global_speed * 10) > 2);
btnSlow.disabled = !canSlow;
btnSlow.style.opacity = !canSlow ? "0.3" : "1";
btnSlow.style.cursor = !canSlow ? "not-allowed" : "pointer";
}
if (btnSpeed) {
var canSpeed = timeControlsActive && (Math.round(TD.global_speed * 10) < 10);
btnSpeed.disabled = !canSpeed;
btnSpeed.style.opacity = !canSpeed ? "0.3" : "1";
btnSpeed.style.cursor = !canSpeed ? "not-allowed" : "pointer";
}
}
}
};
TD.Panel = function (id, cfg) { var panel = new TD.Element(id, cfg); TD.lang.mix(panel, panel_obj); panel._init(cfg); return panel; };
var balloontip_obj = {
_init: function (cfg) { cfg = cfg || {}; this.scene = cfg.scene; },
caculatePos: function () {
var el = this.el; this.x = el.cx + 0.5; this.y = el.cy + 0.5;
if (this.x + this.width > this.scene.stage.width - TD.padding) { this.x = this.x - this.width; }
this.px = this.x + 5 * _TD.retina; this.py = this.y + 4 * _TD.retina;
},
msg: function (txt, el) {
this.text = txt; var ctx = TD.ctx; ctx.font = "bold " + (12 * _TD.retina) + "px 'Courier New'";
// Support multi-line width calculation properly
var lines = txt.split('\n');
var maxW = 0;
for (var i=0; i<lines.length; i++) {
var w = ctx.measureText(lines[i]).width;
if (w > maxW) maxW = w;
}
this.width = maxW + 20 * _TD.retina;
this.height = (lines.length * 15 + 8) * _TD.retina;
if (el && el.cx && el.cy) { this.el = el; this.caculatePos(); this.show(); }
},
step: function () { if (!this.el || !this.el.is_valid) { this.hide(); return; } if (this.el.is_monster) { this.caculatePos(); } },
render: function () {
if (!this.el) return; var ctx = TD.ctx;
if (this.el.is_monster) {
// --- V.A.T.S. STYLE RENDER FOR MONSTERS ---
var w = this.width;
var h = this.height + 15 * _TD.retina; // extra space for HP bar
// Main Box
ctx.fillStyle = "rgba(0, 40, 0, 0.7)";
ctx.fillRect(this.x, this.y, w, h);
ctx.strokeStyle = "rgba(0, 255, 0, 0.2)";
ctx.lineWidth = 1;
ctx.strokeRect(this.x, this.y, w, h);
// Corner Brackets
ctx.strokeStyle = "#0f0";
ctx.lineWidth = 2 * _TD.retina;
var bS = 8 * _TD.retina;
ctx.beginPath();
ctx.moveTo(this.x, this.y + bS); ctx.lineTo(this.x, this.y); ctx.lineTo(this.x + bS, this.y); // Top Left
ctx.moveTo(this.x + w - bS, this.y); ctx.lineTo(this.x + w, this.y); ctx.lineTo(this.x + w, this.y + bS); // Top Right
ctx.moveTo(this.x, this.y + h - bS); ctx.lineTo(this.x, this.y + h); ctx.lineTo(this.x + bS, this.y + h); // Bottom Left
ctx.moveTo(this.x + w - bS, this.y + h); ctx.lineTo(this.x + w, this.y + h); ctx.lineTo(this.x + w, this.y + h - bS); // Bottom Right
ctx.stroke();
// Health Bar (Top)
var barW = w - 16 * _TD.retina;
var barH = 5 * _TD.retina;
var barX = this.x + 8 * _TD.retina;
var barY = this.y + 6 * _TD.retina;
var hpPercent = Math.max(0, this.el.life / this.el.life0);
ctx.fillStyle = "rgba(0,0,0,0.8)"; ctx.fillRect(barX, barY, barW, barH);
ctx.fillStyle = "#0f0"; ctx.fillRect(barX, barY, barW * hpPercent, barH);
ctx.lineWidth = 1; ctx.strokeStyle = "#0f0"; ctx.strokeRect(barX, barY, barW, barH);
// Text Lines
ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillStyle = "#0f0";
ctx.font = "bold " + (12 * _TD.retina) + "px 'Courier New'";
var lines = this.text.split('\n');
for (var i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], this.x + w/2, this.y + 16 * _TD.retina + (i * 15 * _TD.retina));
}
} else {
// --- STANDARD YELLOW TOOLTIP FOR EVERYTHING ELSE ---
ctx.lineWidth = _TD.retina; ctx.fillStyle = "rgba(255, 255, 0, 0.5)"; ctx.strokeStyle = "rgba(222, 222, 0, 0.9)";
ctx.beginPath(); ctx.rect(this.x, this.y, this.width, this.height); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillStyle = "#000"; ctx.font = "normal " + (12 * _TD.retina) + "px 'Courier New'";
var lines = this.text.split('\n');
for (var i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], this.px, this.py + (i * 15 * _TD.retina));
}
}
}
};
TD.BalloonTip = function (id, cfg) { var balloontip = new TD.Element(id, cfg); TD.lang.mix(balloontip, balloontip_obj); balloontip._init(cfg); return balloontip; };
var button_obj = {
_init: function (cfg) {
cfg = cfg || {}; this.text = cfg.text; this.onClick = cfg.onClick || TD.lang.nullFunc; this.x = cfg.x; this.y = cfg.y; this.width = cfg.width || 80 * _TD.retina; this.height = cfg.height || 30 * _TD.retina; this.font_x = this.x + 8 * _TD.retina; this.font_y = this.y + 9 * _TD.retina; this.scene = cfg.scene; this.desc = cfg.desc || "";
this.addToScene(this.scene, this.step_level, this.render_level); this.caculatePos();
},
onEnter: function () { TD.mouseHand(true); if (this.desc) { this.scene.panel.balloontip.msg(this.desc, this); } },
onOut: function () { TD.mouseHand(false); if (this.scene.panel.balloontip.el == this) { this.scene.panel.balloontip.hide(); } },
render: function () {
var ctx = TD.ctx; ctx.lineWidth = 2 * _TD.retina; ctx.fillStyle = this.is_hover ? "#eee" : "#ccc"; ctx.strokeStyle = "#999"; ctx.beginPath(); ctx.rect(this.x, this.y, this.width, this.height); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillStyle = "#000"; ctx.font = "normal " + (12 * _TD.retina) + "px 'Courier New'"; ctx.beginPath(); ctx.fillText(this.text, this.font_x, this.font_y); ctx.closePath(); ctx.fill();
}
};
TD.Button = function (id, cfg) { cfg.on_events = ["enter", "out", "click"]; var button = new TD.Element(id, cfg); TD.lang.mix(button, button_obj); button._init(cfg); return button; };
var gameover_obj = {
_init: function (cfg) { this.panel = cfg.panel; this.scene = cfg.scene; this.addToScene(this.scene, 1, 9); },
render: function () {
this.panel.btn_pause.hide(); this.panel.btn_upgrade.hide(); this.panel.btn_sell.hide(); this.panel.btn_build.hide(); this.panel.btn_restart.show();
// Inject current stats into the Game Over overlay
var elWaves = document.getElementById("go-stat-waves");
if (elWaves && this.scene) elWaves.innerText = this.scene.wave || 0;
var elScore = document.getElementById("go-stat-score");
if (elScore && typeof TD !== 'undefined') elScore.innerText = TD.score || 0;
var elCaps = document.getElementById("go-stat-caps");
if (elCaps && typeof TD !== 'undefined') elCaps.innerText = TD.money || 0;
var elShots = document.getElementById("go-stat-shots");
if (elShots && typeof TD !== 'undefined') elShots.innerText = TD.total_shots || 0;
var elMvp = document.getElementById("go-stat-mvp");
if (elMvp && this.scene && this.scene.map) {
var mvp = null;
var maxKills = -1;
TD.lang.each(this.scene.map.buildings, function(b) {
if (b.is_weapon && b.killed > maxKills) {
maxKills = b.killed;
mvp = b;
}
});
if (mvp && maxKills > 0) {
elMvp.innerText = mvp.name.toUpperCase() + " [" + maxKills + " KILLS]";
} else {
elMvp.innerText = "NONE";
}
}
// Replaced Canvas drawing with the HTML overlay trigger
var goText = document.getElementById("game-over-text");
if (goText) goText.style.display = "block";
}
};
TD.GameOver = function (id, cfg) { var obj = new TD.Element(id, cfg); TD.lang.mix(obj, gameover_obj); obj._init(cfg); return obj; };
TD.recover = function (n) { TD.life_recover = n; TD.log("life recover: " + n); };
});
_TD.a.push(function (TD) {
function lineTo2(ctx, x0, y0, x1, y1, len) {
var x2, y2, a, b, p, xt, a2, b2, c2;
if (x0 == x1) { x2 = x0; y2 = y1 > y0 ? y0 + len : y0 - len; }
else if (y0 == y1) { y2 = y0; x2 = x1 > x0 ? x0 + len : x0 - len; }
else {
a = (y0 - y1) / (x0 - x1); b = y0 - x0 * a; a2 = a * a + 1; b2 = 2 * (a * (b - y0) - x0); c2 = Math.pow(b - y0, 2) + x0 * x0 - Math.pow(len, 2); p = Math.pow(b2, 2) - 4 * a2 * c2;
if (p < 0) { return [0, 0]; } p = Math.sqrt(p); xt = (-b2 + p) / (2 * a2);
if ((x1 - x0 > 0 && xt - x0 > 0) || (x1 - x0 < 0 && xt - x0 < 0)) { x2 = xt; y2 = a * x2 + b; }
else { x2 = (-b2 - p) / (2 * a2); y2 = a * x2 + b; }
}
ctx.lineCap = "round"; ctx.moveTo(x0, y0); ctx.lineTo(x2, y2); return [x2, y2];
}
var renderFunctions = {
"rifle": function (b, ctx, map, gs, gs2) {
var target_position = b.getTargetPosition();
ctx.fillStyle = "#886644"; ctx.strokeStyle = "#000"; ctx.beginPath(); ctx.lineWidth = _TD.retina; ctx.arc(b.cx, b.cy, 6 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = 2 * _TD.retina; ctx.beginPath(); ctx.moveTo(b.cx, b.cy); b.muzzle = lineTo2(ctx, b.cx, b.cy, target_position[0], target_position[1], gs2); ctx.closePath(); ctx.stroke();
ctx.lineWidth = _TD.retina; ctx.fillStyle = "#333"; ctx.beginPath(); ctx.arc(b.cx, b.cy, 4 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
},
"cannon": function (b, ctx, map, gs, gs2) {
var target_position = b.getTargetPosition();
ctx.fillStyle = "#393"; ctx.strokeStyle = "#000"; ctx.beginPath(); ctx.lineWidth = _TD.retina; ctx.arc(b.cx, b.cy, gs2 - 5, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = 3 * _TD.retina; ctx.beginPath(); ctx.moveTo(b.cx, b.cy); b.muzzle = lineTo2(ctx, b.cx, b.cy, target_position[0], target_position[1], gs2); ctx.closePath(); ctx.stroke();
ctx.lineWidth = _TD.retina; ctx.fillStyle = "#060"; ctx.beginPath(); ctx.arc(b.cx, b.cy, 7 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#cec"; ctx.beginPath(); ctx.arc(b.cx + 2, b.cy - 2, 3 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill();
},
"LMG": function (b, ctx, map, gs, gs2) {
var target_position = b.getTargetPosition();
ctx.fillStyle = "#36f"; ctx.strokeStyle = "#000"; ctx.beginPath(); ctx.lineWidth = _TD.retina; ctx.arc(b.cx, b.cy, 7 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = 2 * _TD.retina; ctx.beginPath(); ctx.moveTo(b.cx, b.cy); b.muzzle = lineTo2(ctx, b.cx, b.cy, target_position[0], target_position[1], gs2); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = _TD.retina; ctx.fillStyle = "#66c"; ctx.beginPath(); ctx.arc(b.cx, b.cy, 5 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#ccf"; ctx.beginPath(); ctx.arc(b.cx + 1, b.cy - 1, 2 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill();
},
"HMG": function (b, ctx, map, gs, gs2) {
var target_position = b.getTargetPosition();
ctx.fillStyle = "#933"; ctx.strokeStyle = "#000"; ctx.beginPath(); ctx.lineWidth = _TD.retina; ctx.arc(b.cx, b.cy, gs2 - 2, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = 5 * _TD.retina; ctx.beginPath(); ctx.moveTo(b.cx, b.cy); b.muzzle = lineTo2(ctx, b.cx, b.cy, target_position[0], target_position[1], gs2); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.lineWidth = _TD.retina; ctx.fillStyle = "#630"; ctx.beginPath(); ctx.arc(b.cx, b.cy, gs2 - 5 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#960"; ctx.beginPath(); ctx.arc(b.cx + 1, b.cy - 1, 8 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill();
ctx.fillStyle = "#fcc"; ctx.beginPath(); ctx.arc(b.cx + 3, b.cy - 3, 4 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill();
},
"wall": function (b, ctx, map, gs, gs2) {
ctx.lineWidth = _TD.retina; ctx.fillStyle = "#666"; ctx.strokeStyle = "#000"; ctx.fillRect(b.cx - gs2 + 1, b.cy - gs2 + 1, gs - 1, gs - 1);
ctx.beginPath(); ctx.moveTo(b.cx - gs2 + 0.5, b.cy - gs2 + 0.5); ctx.lineTo(b.cx - gs2 + 0.5, b.cy + gs2 + 0.5); ctx.lineTo(b.cx + gs2 + 0.5, b.cy + gs2 + 0.5); ctx.lineTo(b.cx + gs2 + 0.5, b.cy - gs2 + 0.5); ctx.lineTo(b.cx - gs2 + 0.5, b.cy - gs2 + 0.5); ctx.moveTo(b.cx - gs2 + 0.5, b.cy + gs2 + 0.5); ctx.lineTo(b.cx + gs2 + 0.5, b.cy - gs2 + 0.5); ctx.moveTo(b.cx - gs2 + 0.5, b.cy - gs2 + 0.5); ctx.lineTo(b.cx + gs2 + 0.5, b.cy + gs2 + 0.5); ctx.closePath(); ctx.stroke();
},
"laser_gun": function (b, ctx) {
ctx.fillStyle = "#f00"; ctx.strokeStyle = "#000"; ctx.beginPath(); ctx.lineWidth = _TD.retina;
ctx.moveTo(b.cx, b.cy - 10 * _TD.retina); ctx.lineTo(b.cx - 8.66 * _TD.retina, b.cy + 5 * _TD.retina); ctx.lineTo(b.cx + 8.66 * _TD.retina, b.cy + 5 * _TD.retina); ctx.lineTo(b.cx, b.cy - 10 * _TD.retina); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#60f"; ctx.beginPath(); ctx.arc(b.cx, b.cy, 7 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#000"; ctx.beginPath(); ctx.arc(b.cx, b.cy, 3 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill();
ctx.fillStyle = "#666"; ctx.beginPath(); ctx.arc(b.cx + 1, b.cy - 1, _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill();
ctx.lineWidth = 3 * _TD.retina; ctx.beginPath(); ctx.moveTo(b.cx, b.cy); ctx.closePath(); ctx.fill(); ctx.stroke();
}
};
TD.renderBuilding = function (building) {
var ctx = TD.ctx, map = building.map, gs = TD.grid_size, gs2 = TD.grid_size / 2;
// --- NEW: PSYCHO RAGE GLOW (Smooth Breathing) ---
if (TD.psycho_active > 0 && building.is_weapon && !building.is_pre_building) {
ctx.save();
ctx.beginPath();
// Increased radius (gs2 * 1.1) so it spills out past the largest towers
ctx.arc(building.cx, building.cy, gs2 * 1.1, 0, Math.PI * 2);
// Smooth sine wave: (sin(x) + 1) / 2 creates a perfect 0.0 to 1.0 oscillation
var pulse = (Math.sin(TD.iframe / 10) + 1) / 2;
ctx.shadowBlur = 15 + (pulse * 15); // Blurs smoothly between 15px and 30px
ctx.shadowColor = "#ff0000";
// Opacity pulses smoothly between 0.15 (dim) and 0.45 (bright)
ctx.fillStyle = "rgba(255, 0, 0, " + (0.15 + (pulse * 0.3)) + ")";
ctx.fill();
ctx.restore();
}
// NEW: Draw aerial drop shadow AND ground anchor pad
if (building.is_flying && !building.is_pre_building) {
// 1. Draw the stationary physical anchor pad on the ground
ctx.fillStyle = "#222222";
ctx.strokeStyle = "#111111";
ctx.lineWidth = 2 * _TD.retina;
ctx.fillRect(building.x + 2, building.y + 2, gs - 4, gs - 4);
ctx.strokeRect(building.x + 2, building.y + 2, gs - 4, gs - 4);
// 2. Draw the floating drop shadow
ctx.save();
ctx.translate(building.cx, building.cy - building.hover_y);
ctx.scale(1, 0.5); // Flatten the circle into a perspective ellipse
ctx.beginPath();
ctx.arc(0, 0, 15 * _TD.retina, 0, Math.PI * 2, true);
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fill();
ctx.restore();
}
// Use visual_type if defined, otherwise fallback to the type, otherwise wall
var renderType = building.visual_type || building.type;
(renderFunctions[renderType] || renderFunctions["wall"])(building, ctx, map, gs, gs2);
}
});
_TD.a.push(function (TD) {
var building_obj = {
_init: function (cfg) {
this.is_selected = false; this.level = 1; this.xp = 0;
this.xp_needed = 50; // Starts strictly at 50 XP
this.killed = 0; this.target = null;
cfg = cfg || {}; this.map = cfg.map || null; this.grid = cfg.grid || null; this.bullet_type = cfg.bullet_type || 1;
this.type = cfg.type; this.speed = cfg.speed; this.bullet_speed = cfg.bullet_speed; this.is_pre_building = !!cfg.is_pre_building; this.blink = this.is_pre_building; this.wait_blink = this._default_wait_blink = 20; this.is_weapon = (this.type != "wall");
// 1. Fetch the exact stats from the Faction Data
var o = TD.getDefaultBuildingAttributes(this.type);
// 2. Overwrite the default config with the faction specific stats
TD.lang.mix(this, o, true); // The 'true' forces the mix function to overwrite properties!
// --- NEW: APPLY S.P.E.C.I.A.L. PASSIVES ---
if (TD.perks) {
if (TD.perks.C) this.cost = Math.floor(this.cost * 0.85); // Charisma: 15% cheaper
if (TD.perks.S) this.damage = Math.floor(this.damage * 1.15); // Strength: 15% more damage
if (TD.perks.P) this.range += 2; // Perception: +2 Range
if (TD.perks.I) this.xp_needed = Math.floor(this.xp_needed * 0.85); // Intelligence: 15% less XP to level
if (TD.perks.A) this.speed *= 1.15; // Agility: 15% faster fire rate
}
this.range_px = this.range * TD.grid_size; this.money = this.cost;
this.caculatePos();
},
getUpgradeCost: function () { return Math.floor(this.money * 0.75); },
getSellMoney: function () { return Math.floor(this.money * 0.5) || 1; },
getBuyCost: function () { return Math.floor(this.money * 0.5) || 1; },
toggleSelected: function () {
this.is_selected = !this.is_selected; this.grid.hightLight(this.is_selected); var _this = this;
if (this.is_selected) {
this.map.eachBuilding(function (obj) { obj.is_selected = obj == _this; });
(this.map.is_main_map ? this.scene.panel_map : this.scene.map).eachBuilding(function (obj) { obj.is_selected = false; obj.grid.hightLight(false); });
this.map.selected_building = this;
if (!this.map.is_main_map) { this.scene.map.preBuild(this.type); } else { this.scene.map.cancelPreBuild(); }
} else {
if (this.map.selected_building == this) this.map.selected_building = null;
if (!this.map.is_main_map) { this.scene.map.cancelPreBuild(); }
}
if (this.map.is_main_map) {
if (this.map.selected_building) { this.scene.panel.btn_upgrade.show(); this.scene.panel.btn_sell.show(); this.scene.panel.btn_build.show(); this.updateBtnDesc(); }
else { this.scene.panel.btn_upgrade.hide(); this.scene.panel.btn_sell.hide(); this.scene.panel.btn_build.hide(); }
}
},
updateBtnDesc: function () {
this.scene.panel.btn_upgrade.desc = "Upgrade " + this.name + " to Level " + (this.level + 1) + " (Cost: " + this.getUpgradeCost() + "c)";
this.scene.panel.btn_sell.desc = "Sell " + this.name + " for " + this.getSellMoney() + "c";
this.scene.panel.btn_build.desc = "Build " + this.name + " for " + this.getBuyCost() + "c";
},
locate: function (grid) {
this.grid = grid; this.map = grid.map; this.cx = this.grid.cx; this.cy = this.grid.cy; this.x = this.grid.x; this.y = this.grid.y; this.x2 = this.grid.x2; this.y2 = this.grid.y2; this.width = this.grid.width; this.height = this.grid.height; this.px = this.x + 0.5; this.py = this.y + 0.5;
this.wait_blink = this._default_wait_blink; this._fire_wait = Math.floor(Math.max(TD.exp_fps / (this.speed * (TD.global_speed / 0.2)), 1)); this._fire_wait2 = this._fire_wait;
// NEW: Flight Path Initialization
if (this.is_flying) {
this.flight_angle = Math.random() * Math.PI * 2; // Random start point on path
this.anchor_cx = this.cx;
this.anchor_cy = this.cy;
this.flight_radius = TD.grid_size * 1.2; // roughly 3x3 area
this.flight_speed = 0.03 + Math.random() * 0.02; // Orbit speed
this.hover_y = -40 * _TD.retina; // Altitude offset
}
},
remove: function () { if (this.grid && this.grid.building && this.grid.building == this) this.grid.building = null; this.hide(); this.del(); },
findTarget: function () {
if (!this.is_weapon || this.is_pre_building || !this.grid) return;
var current_range = this.eff_range || this.range;
var cx = this.cx, cy = this.cy, range2 = Math.pow(current_range * TD.grid_size, 2);
var vType = this.visual_type || this.type;
var canHitAir = (vType === "laser_gun" || vType === "LMG" || vType === "HMG" || vType === "rifle");
// --- NEW: TOWER ANTI-AIR LIMITS ---
// Prevent early game towers (1, 2, 3) from shooting flyers, regardless of weapon upgrades
if (this.type === "tower_1" || this.type === "tower_2" || this.type === "tower_3") {
canHitAir = false;
}
var valid_targets = [];
TD.lang.each(this.map.monsters, function (obj) {
if (obj.is_valid) {
var in_range = Math.pow(obj.cx - cx, 2) + Math.pow(obj.cy - cy, 2) <= range2;
var isFlying = !!obj.is_flying || obj.idx === 8 || obj.idx === 9 || obj.idx === 10;
var isStealthed = !!obj.is_stealthed || obj.idx === 11 || obj.is_burrowing; // Mole rats hide too!
if (in_range) {
if (isFlying && !canHitAir) return; // Anti-Air restriction
if (isStealthed && !obj.is_revealed) return; // Cannot shoot invisible/burrowed targets unless revealed
valid_targets.push(obj);
}
}
});
if (valid_targets.length === 0) {
this.target = null;
return;
}
// 2. Sort by player-selected targeting mode (default: closest)
var mode = this.target_mode || "closest";
valid_targets.sort(function(a, b) {
if (mode === "strongest") return b.life - a.life;
if (mode === "weakest") return a.life - b.life;
if (mode === "leader") return a.way.length - b.way.length; // Fewer steps to exit = further ahead
// Fallback / "closest"
var distA = Math.pow(a.cx - cx, 2) + Math.pow(a.cy - cy, 2);
var distB = Math.pow(b.cx - cx, 2) + Math.pow(b.cy - cy, 2);
return distA - distB;
});
var new_target = valid_targets[0];
// --- NEW: ANNOUNCE TARGET ACQUISITION ---
if (this.target !== new_target && new_target && window.IntelData && TD.Announcer) {
// If it's a Boss (2000+ HP), it's a Critical "Hero Duel" announcement
if (new_target.life0 >= 2000) {
TD.Announcer.add(window.IntelData.getEvent("intercept", TD.current_faction, this.name, new_target.name), 5);
}
// Otherwise, normal priority, and we throttle it so cheap towers don't spam the log
else if (Math.random() < 0.1) {
TD.Announcer.add(window.IntelData.getEvent("intercept", TD.current_faction, this.name, new_target.name), 3);
}
}
this.target = new_target;
},
getTargetPosition: function () {
if (!this.target) { var grid = this.map.is_main_map ? this.map.entrance : this.grid; return [grid.cx, grid.cy]; }
return [this.target.cx, this.target.cy];
},
fire: function () {
var _this = this;
// --- DETECTOR PULSE ---
if (this.is_detector) {
new TD.RadarPulse("radar-" + TD.lang.rndStr(), { cx: this.cx, cy: this.cy, r: this.range_px / _TD.retina, step_level: 1, render_level: 9, color: "#00ffff", scene: this.map.scene, map: this.map, time: 1.0 });
return;
}
if (!this.target || !this.target.is_valid) return;
if (typeof TD !== 'undefined') {
TD.total_shots = (TD.total_shots || 0) + 1; // Tracks actual attacks
TD.last_fire_frame = TD.iframe; // NEW: Universal fire tracker for the dead-zone logic
}
// Use dynamic burst count if the upgrade provided one, otherwise fallback to name-based logic
var burst_count = this.burst_count || 1;
if (!this.burst_count && this.name && (this.name.indexOf("Trooper") > -1 || this.name.indexOf("Initiate") > -1 || this.name.indexOf("Settler") > -1 || this.name.indexOf("Scum") > -1)) burst_count = 3;
this._is_bursting = true;
for (var i = 0; i < burst_count; i++) {
setTimeout(function() {
if (!_this.is_valid || !_this.target || !_this.target.is_valid) return;
// --- NEW: SPECIAL EFFECTS FIRE HOOK ---
// Let the isolated handler take over if this tower has advanced firing logic
if (_this.has_special && typeof TD.SpecialEffects !== 'undefined' && TD.SpecialEffects.onFire(_this)) {
return; // If the handler returns true, it took care of shooting, so we skip standard logic
}
if (_this.visual_type == "laser_gun") { _this.target.beHit(_this, _this.damage); return; }
var muzzle = _this.muzzle || [_this.cx, _this.cy], cx = muzzle[0], cy = muzzle[1];
var current_damage = _this.eff_damage || _this.damage;
new TD.Bullet(null, { building: _this, damage: current_damage, target: _this.target, speed: _this.bullet_speed, x: cx, y: cy, pierce_count: _this.pierce_count });
// --- PLAY SOUND EFFECT ---
if (_this.sound_type) {
TD.playSound(_this.sound_type);
}
}, i * 150);
}
setTimeout(function() { _this._is_bursting = false; }, burst_count * 150);
},
tryToFire: function () {
// Detectors fire automatically without a target
if (this.is_detector) {
this._fire_wait--;
if (this._fire_wait <= 0) { this._fire_wait = this._fire_wait2; this.fire(); }
return;
}
if (!this.is_weapon || !this.target || this._is_bursting) return;
if (TD.psycho_active > 0) this._fire_wait -= 1.50;
else this._fire_wait--;
if (this._fire_wait <= 0) {
this._fire_wait = this._fire_wait2;
this.fire();
}
},
_upgrade2: function (k) {
if (!this._upgrade_records[k]) this._upgrade_records[k] = this[k];
var v = this._upgrade_records[k], mk = "max_" + k, uk = "_upgrade_rule_" + k, uf = this[uk] || TD.default_upgrade_rule;
if (!v || isNaN(v)) return;
v = uf(this.level, v); if (this[mk] && !isNaN(this[mk]) && this[mk] < v) v = this[mk];
this._upgrade_records[k] = v; this[k] = Math.floor(v);
},
upgrade: function () {
if (!this._upgrade_records) this._upgrade_records = {};
var attrs = ["damage", "range", "speed", "life", "shield"], i, l = attrs.length;
for (i = 0; i < l; i++) this._upgrade2(attrs[i]);
this.level++; this.range_px = this.range * TD.grid_size;
},
tryToUpgrade: function (btn) {
var cost = this.getUpgradeCost(), msg = "";
if (cost > TD.money) { msg = TD._t("not_enough_money", [cost]); }
else { TD.money -= cost; this.money += cost; this.upgrade(); msg = TD._t("upgrade_success", [TD._t("building_name_" + this.type), this.level, this.getUpgradeCost()]); }
this.updateBtnDesc(); this.scene.panel.balloontip.msg(msg, btn);
},
tryToSell: function () {
if (!this.is_valid) return;
TD.money += this.getSellMoney(); this.grid.removeBuilding(); this.is_valid = false; this.map.selected_building = null; this.map.select_hl.hide(); this.map.checkHasWeapon(); this.scene.panel.btn_upgrade.hide(); this.scene.panel.btn_sell.hide(); this.scene.panel.btn_build.hide(); this.scene.panel.balloontip.hide();
},
gainXp: function (amount) {
if (!this.is_weapon) return;
// --- NEW: Level 100 Cap ---
if (this.level >= 100) return;
this.xp += amount;
if (this.xp >= this.xp_needed) {
this.xp -= this.xp_needed;
this.level++;
// Scales by exactly +25 XP per level (Level 1->2 is 50, 2->3 is 75, etc.)
this.xp_needed = 50 + (this.level - 1) * 25;
// --- NEW: Level Up Floating Text ---
if (this.map && this.map.scene) {
new TD.FloatingText("lvlup-" + TD.lang.rndStr(), {
cx: this.cx,
cy: this.cy - (TD.grid_size / 2), // Start slightly above the tower
text: "LVL UP ⬆",
color: "#00ff00", // Bright Green
size: 14,
life: 48, // Lasts a full 2 seconds
map: this.map
});
if (typeof TD.logEvent === 'function') {
if (this.level === 2) TD.logEvent(this.name + " UNIT PROMOTED TO VETERAN STATUS");
else if (this.level === 4) TD.logEvent(this.name + " UNIT ACHIEVED ELITE COMBAT RATING");
}
}
// Auto-level base stats slightly
this.damage = Math.floor(this.damage * 1.1) + 1;
this.range = this.range + 0.1;
this.range_px = this.range * TD.grid_size;
this.speed = this.speed * 1.05;
this._fire_wait2 = Math.floor(Math.max(TD.exp_fps / (this.speed * (TD.global_speed / 0.2)), 1)); // Recalculate fire rate
}
},
step: function () {
if (this.blink) { this.wait_blink--; if (this.wait_blink < -this._default_wait_blink) this.wait_blink = this._default_wait_blink; }
// NEW: Flight Mechanics (Lissajous Figure-8 curve)
if (this.is_flying && !this.is_pre_building && this.anchor_cx) {
this.flight_angle += this.flight_speed * TD.global_speed;
this.cx = this.anchor_cx + Math.cos(this.flight_angle) * this.flight_radius;
// Double the sine frequency to make it a Figure-8 pattern, and add the hover altitude
this.cy = this.anchor_cy + Math.sin(this.flight_angle * 2) * (this.flight_radius * 0.5) + this.hover_y;
}
// --- DYNAMIC AURA CALCULATION ---
this.eff_range = this.range;
this.eff_damage = this.damage;
this.eff_speed = this.speed;
if (this.grid && !this.is_pre_building) {
var adj = [
this.map.getGrid(this.grid.mx + 1, this.grid.my),
this.map.getGrid(this.grid.mx - 1, this.grid.my),
this.map.getGrid(this.grid.mx, this.grid.my + 1),
this.map.getGrid(this.grid.mx, this.grid.my - 1)
];
for (var i = 0; i < adj.length; i++) {
if (adj[i] && adj[i].building && adj[i].building.aura_type) {
if (adj[i].building.aura_type === "range") this.eff_range += 1;
if (adj[i].building.aura_type === "speed") this.eff_speed *= 1.15;
if (adj[i].building.aura_type === "damage") this.eff_damage = Math.floor(this.eff_damage * 1.30);
}
}
}
// Sync visual range if hovered
this.range_px = this.eff_range * TD.grid_size;
// Update fire rate math dynamically
this._fire_wait2 = Math.floor(Math.max(TD.exp_fps / (this.eff_speed * (TD.global_speed / 0.2)), 1));
this.findTarget(); this.tryToFire();
// --- UPGRADE EFFECTS TICK ---
if (typeof TD.SpecialEffects !== 'undefined') {
TD.SpecialEffects.onStep(this);
}
},
render: function () {
if (!this.is_visiable || this.wait_blink < 0) return;
// NEW: If on the main map, building rendering is handled by the Y-Sort queue in Map.render
if (this.map.is_main_map && !this.is_pre_building) return;
var ctx = TD.ctx;
TD.renderBuilding(this);
// NEW: Added "this.is_hover" to the condition so it draws the range circle when moused over
var ctx = TD.ctx; TD.renderBuilding(this);
// NEW: Added "this.is_hover" to the condition so it draws the range circle when moused over
if (this.map.is_main_map && (this.is_selected || this.is_hover || (this.is_pre_building) || this.map.show_all_ranges) && this.is_weapon && this.range > 0 && this.grid) {
ctx.lineWidth = _TD.retina; ctx.fillStyle = "rgba(187, 141, 32, 0.15)"; ctx.strokeStyle = "#bb8d20"; ctx.beginPath(); ctx.arc(this.cx, this.cy, this.range_px, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.stroke();
}
if (this.type == "laser_gun" && this.target && this.target.is_valid) {
ctx.lineWidth = 3 * _TD.retina; ctx.strokeStyle = "rgba(50, 50, 200, 0.5)"; ctx.beginPath(); ctx.moveTo(this.cx, this.cy); ctx.lineTo(this.target.cx, this.target.cy); ctx.closePath(); ctx.stroke();
ctx.lineWidth = _TD.retina; ctx.strokeStyle = "rgba(150, 150, 255, 0.5)"; ctx.beginPath(); ctx.lineTo(this.cx, this.cy); ctx.closePath(); ctx.stroke();
}
},
onEnter: function () {
if (this.is_pre_building || !this.map.is_main_map) return;
var tooltip = document.getElementById("tower-tooltip");
if (tooltip) {
// Determine Current Upgrades
var p1Name = "None"; var p2Name = "None";
if (this.upgrades && this.path1_tier > 0) p1Name = this.upgrades.path1[this.path1_tier - 1].name;
if (this.upgrades && this.path2_tier > 0) p2Name = this.upgrades.path2[this.path2_tier - 1].name;
var tact = TD.getTacticalMatchups(this);
var cannotStr = tact.cannot ? "\nCANNOT: " + tact.cannot : "";
var stats = "UNIT : " + this.name.toUpperCase() + "\n" +
"LEVEL : " + this.level + "\n" +
"XP : " + Math.floor(this.xp) + " / " + this.xp_needed + "\n" +
"DMG : " + this.damage + "\n" +
"RANGE : " + this.range.toFixed(1) + "\n" +
"KILLS : " + this.killed + "\n" +
"PATH 1: " + p1Name + "\n" +
"PATH 2: " + p2Name + "\n" +
"STRONG: " + tact.strong + "\n" +
"WEAK : " + tact.weak + cannotStr;
tooltip.innerText = stats;
tooltip.style.display = "block";
}
},
onOut: function () {
var tooltip = document.getElementById("tower-tooltip");
if (tooltip) tooltip.style.display = "none";
},
onClick: function () {
if (this.is_pre_building || this.scene.state != 1 || !this.map.is_main_map) return;
var tooltip = document.getElementById("tower-tooltip");
if (tooltip) tooltip.style.display = "none";
var overlay = document.getElementById("tower-modal-overlay");
if (!overlay) return;
// Initialize tracking arrays if they don't exist
if (typeof this.path1_tier === 'undefined') this.path1_tier = 0;
if (typeof this.path2_tier === 'undefined') this.path2_tier = 0;
document.getElementById("modal-tower-name").innerText = this.name.toUpperCase();
// NEW: Reset the Image to the default "T" text for normal towers
var imgContainer = document.getElementById("modal-tower-img");
if (imgContainer) {
imgContainer.innerHTML = "T";
}
// NEW: Reset the Targeting Dropdown back to standard Tower modes
var selTarget = document.getElementById("modal-target-mode");
if (selTarget) {
selTarget.innerHTML = '<option value="closest">Closest</option><option value="leader">Leader</option><option value="strongest">Strongest</option><option value="weakest">Weakest</option>';
selTarget.value = this.target_mode || "closest";
var _building = this;
selTarget.onchange = function() { _building.target_mode = this.value; };
}
// Determine Current Upgrades
var p1Name = "None"; var p2Name = "None";
if (this.upgrades && this.path1_tier > 0) p1Name = this.upgrades.path1[this.path1_tier - 1].name;
if (this.upgrades && this.path2_tier > 0) p2Name = this.upgrades.path2[this.path2_tier - 1].name;
var tact = TD.getTacticalMatchups(this);
var cannotHtml = tact.cannot ? "<b style='color:#ff5555'>Cannot target:</b> " + tact.cannot + "<br>" : "";
var statsHtml = "<b>Level:</b> " + this.level + "<br>" +
"<b>XP:</b> " + Math.floor(this.xp) + " / " + this.xp_needed + "<br><br>" +
"<b>Damage:</b> " + this.damage + "<br>" +
"<b>Range:</b> " + this.range.toFixed(1) + "<br>" +
"<b>Kills:</b> " + this.killed + "<br><br>" +
"<b style='color:#0f0'>Strong vs:</b> " + tact.strong + "<br>" +
"<b style='color:#f55'>Weak vs:</b> " + tact.weak + "<br>" +
cannotHtml + "<br>" +
"<b style='color:#ffcc00;'>Path 1:</b> " + p1Name + "<br>" +
"<b style='color:#ffcc00;'>Path 2:</b> " + p2Name;
document.getElementById("modal-tower-stats").innerHTML = statsHtml;
var _building = this;
var btnClose = document.getElementById("modal-close-btn");
var btnSell = document.getElementById("modal-btn-sell");
var newClose = btnClose.cloneNode(true); btnClose.parentNode.replaceChild(newClose, btnClose);
var newSell = btnSell.cloneNode(true); btnSell.parentNode.replaceChild(newSell, btnSell);
newClose.onclick = function() { overlay.style.display = "none"; };
// Update Sell Button text to show value
newSell.innerText = "SELL TOWER (" + _building.getSellMoney() + "c)";
newSell.onclick = function() {
_building.tryToSell();
overlay.style.display = "none";
};
overlay.onclick = function(e) { if(e.target === overlay) overlay.style.display = "none"; };
var selTarget = document.getElementById("modal-target-mode");
if (selTarget) {
selTarget.value = _building.target_mode || "closest";
selTarget.onchange = function() { _building.target_mode = this.value; };
}
// --- POPULATE UPGRADES ---
var btnPath1 = document.getElementById("modal-btn-path1");
var btnPath2 = document.getElementById("modal-btn-path2");
var newP1 = btnPath1.cloneNode(true); btnPath1.parentNode.replaceChild(newP1, btnPath1);
var newP2 = btnPath2.cloneNode(true); btnPath2.parentNode.replaceChild(newP2, btnPath2);
var drawIndicators = function(containerId, currentTier) {
var container = document.getElementById(containerId);
container.innerHTML = "";
for (var i = 0; i < 4; i++) {
var box = document.createElement("div");
box.style.width = "12px";
box.style.height = "12px";
box.style.border = "1px solid #0f0";
// Fill green if unlocked, empty if locked
box.style.background = (i < currentTier) ? "#0f0" : "transparent";
container.appendChild(box);
}
};
var updateUpgradeUI = function(pathNum, btnElement, currentTier, otherTier, pathData) {
var nameEl = btnElement.querySelector("#path" + pathNum + "-name");
var costEl = btnElement.querySelector("#path" + pathNum + "-cost");
var descEl = btnElement.querySelector("#path" + pathNum + "-desc");
drawIndicators("path" + pathNum + "-indicators", currentTier);
// Check Max Tier restriction (BTD5 logic: if one path is > 2, the other is capped at 2)
var isRestricted = (currentTier === 2 && otherTier > 2);
if (currentTier >= 4) {
nameEl.innerText = "MAX TIER REACHED";
costEl.innerText = "";
descEl.innerText = "No further upgrades available on this path.";
btnElement.disabled = true;
btnElement.style.backgroundColor = "#444";
btnElement.style.borderColor = "#555";
nameEl.style.color = "#888";
descEl.style.color = "#888";
btnElement.removeAttribute("data-cost");
} else if (isRestricted) {
nameEl.innerText = "PATH LOCKED";
costEl.innerText = "";
descEl.innerText = "Cannot upgrade past Tier 2 because the other path is Tier 3+.";
btnElement.disabled = true;
btnElement.style.backgroundColor = "#400";
btnElement.style.borderColor = "#800";
nameEl.style.color = "#888";
descEl.style.color = "#888";
btnElement.removeAttribute("data-cost");
} else {
var nextUpgrade = pathData[currentTier];
if (!nextUpgrade) return; // Failsafe
nameEl.innerText = nextUpgrade.name;
costEl.innerText = nextUpgrade.cost + "c";
descEl.innerText = nextUpgrade.desc;
// Charisma Perk Check
var actualCost = nextUpgrade.cost;
if (TD.perks && TD.perks.C) actualCost = Math.floor(actualCost * 0.85);
if (TD.perks && TD.perks.C) costEl.innerText = actualCost + "c"; // Show discounted cost
btnElement.setAttribute("data-cost", actualCost);
if (TD.money >= actualCost) {
btnElement.disabled = false;
btnElement.style.backgroundColor = "#2a7a2a";
btnElement.style.borderColor = "#0f0";
nameEl.style.color = "#000";
descEl.style.color = "#111";
} else {
btnElement.disabled = true;
btnElement.style.backgroundColor = "#444";
btnElement.style.borderColor = "#555";
nameEl.style.color = "#888";
descEl.style.color = "#888";
}
// ALWAYS assign the click handler! (It won't fire if disabled by the browser)
btnElement.onclick = function() {
if (TD.money >= actualCost) {
TD.money -= actualCost;
if (pathNum === 1) _building.path1_tier++;
else _building.path2_tier++;
// --- NEW: CALL THE CENTRALIZED UPGRADE HANDLER ---
if (nextUpgrade.effects && typeof TD.SpecialEffects !== 'undefined') {
TD.SpecialEffects.onApply(_building, nextUpgrade.effects);
}
// NEW: ANNOUNCE UPGRADE
if (window.IntelData && TD.Announcer) {
// If they just bought Tier 4 (which pushes the tracker to 4)
if (pathNum === 1 && _building.path1_tier >= 4 || pathNum === 2 && _building.path2_tier >= 4) {
TD.Announcer.add(window.IntelData.getEvent("tower_max", TD.current_faction, _building.name), 4);
} else {
TD.Announcer.add(window.IntelData.getEvent("tower_upgrade", TD.current_faction, _building.name), 3);
}
}
// Visual feedback
TD.Explode("upg-" + TD.lang.rndStr(), { cx: _building.cx, cy: _building.cy, r: 20 * _TD.retina, step_level: 1, render_level: 9, color: "#00ff00", scene: _building.map.scene, time: 0.3 });
// Re-render UI instantly
_building.onClick();
}
};
}
};
// Grab the upgrade data from the faction mapping
if (_building.upgrades) {
updateUpgradeUI(1, newP1, _building.path1_tier, _building.path2_tier, _building.upgrades.path1);
updateUpgradeUI(2, newP2, _building.path2_tier, _building.path1_tier, _building.upgrades.path2);
}
overlay.style.display = "flex";
}
};
TD.Building = function (id, cfg) { cfg.on_events = ["enter", "out", "click"]; var building = new TD.Element(id, cfg); TD.lang.mix(building, building_obj); building._init(cfg); return building; };
var bullet_obj = {
_init: function (cfg) {
cfg = cfg || {}; this.speed = cfg.speed; this.target = cfg.target; this.cx = cfg.x; this.cy = cfg.y;
if (typeof TD !== 'undefined') TD.total_shots = (TD.total_shots || 0) + 1; // Track total shots fired
// PSYCHO BUFF: 25% More Damage
var finalDmg = cfg.damage;
if (typeof TD !== 'undefined' && TD.psycho_active > 0) {
finalDmg = Math.floor(finalDmg * 1.50); // 50% More Damage!
}
this.damage = finalDmg;
this.r = cfg.r || Math.max(Math.log(this.damage), 2); if (this.r < 1) this.r = 1; if (this.r > 6) this.r = 6;
this.building = cfg.building || null; this.map = cfg.map || this.building.map; this.type = cfg.type || 1; this.color = cfg.color || "#000";
// NEW: Lifespan limit so bullets don't chase forever
// Range is in grid spaces (e.g. 5). Multiply by grid size to get max pixel travel distance,
// then divide by base speed to get the exact number of frames it should live.
var max_travel_px = (this.building.range * TD.grid_size) * 3; // INCREASED: Allow 300% travel distance to ensure hits
this.max_life = Math.floor(max_travel_px / (10 * this.speed)); // INCREASED base life floor
this.map.bullets.push(this); this.addToScene(this.map.scene, 1, 6);
if (this.type == 1) { this.caculate(); }
},
caculate: function () {
var sx, sy, c, tx = this.target.cx, ty = this.target.cy, speed;
sx = tx - this.cx; sy = ty - this.cy; c = Math.sqrt(Math.pow(sx, 2) + Math.pow(sy, 2)); speed = 20 * this.speed * TD.global_speed;
this.vx = sx * speed / c; this.vy = sy * speed / c;
// ANTI-AIR: 50% Damage Penalty if an LMG is shooting a Flyer
if (this.target.is_flying && this.building && (this.building.visual_type === "LMG" || this.building.type === "LMG")) {
this.damage = Math.floor(this.damage * 0.5);
if (this.damage < 1) this.damage = 1;
}
},
checkOutOfMap: function () {
this.is_valid = !(this.cx < this.map.x || this.cx > this.map.x2 || this.cy < this.map.y || this.cy > this.map.y2); return !this.is_valid;
},
checkHit: function () {
var cx = this.cx, cy = this.cy, r = this.r * _TD.retina, monster = this.map.anyMonster(function (obj) { return Math.pow(obj.cx - cx, 2) + Math.pow(obj.cy - cy, 2) <= Math.pow(obj.r + r, 2) * 2; });
if (monster) {
// NEW: Radius Damage (AoE) Logic
var bName = this.building.name.toUpperCase();
// Identify explosive ordnance (Slow cannons, explicitly named weapons, or upgraded Scrap Turret)
var is_aoe = (this.building.visual_type === "cannon" && (this.building.speed <= 1.5 || bName.indexOf("SCRAP TURRET") > -1)) ||
bName.indexOf("PLASMA") > -1 || bName.indexOf("MISSILE") > -1 || bName.indexOf("ARTILLERY") > -1;
if (is_aoe) {
var blast_radius = TD.grid_size * 1.5; // Default medium blast (1.5 tiles)
if (bName.indexOf("SCRAP TURRET") > -1) blast_radius = TD.grid_size * 0.8; // True Micro-explosions
if (this.building.speed <= 0.2) blast_radius = TD.grid_size * 3.0; // Massive Nuke/Artillery blast
// Spawn a massive fiery visual explosion
TD.Explode(this.id + "-explode", { cx: this.cx, cy: this.cy, r: blast_radius / _TD.retina, step_level: this.step_level, render_level: this.render_level, color: "#ff5500", scene: this.map.scene, time: 0.4 });
var _this = this;
TD.lang.each(this.map.monsters, function (m) {
if (m.is_valid) {
var dist = Math.sqrt(Math.pow(m.cx - cx, 2) + Math.pow(m.cy - cy, 2));
if (dist <= blast_radius) {
// Damage lowers outward from impact (minimum 25% damage at the absolute edge)
var dmg_percent = 1 - (dist / blast_radius);
if (dmg_percent < 0.25) dmg_percent = 0.25;
var splash_dmg = Math.floor(_this.damage * dmg_percent);
if (splash_dmg < 1) splash_dmg = 1; // Always deal at least 1 damage
m.beHit(_this.building, splash_dmg);
}
}
});
} else {
// CRYO / SLOW EFFECT (0 Damage, AoE Splash)
if (this.building && this.building.is_cryo) {
var blast_radius = TD.grid_size * 1.5; // 1.5 tile splash radius
TD.Explode("slow-splash-" + TD.lang.rndStr(), { cx: this.cx, cy: this.cy, r: blast_radius / _TD.retina, step_level: 1, render_level: 9, color: "#aaffff", scene: this.map.scene, time: 0.4 });
var _this = this;
TD.lang.each(this.map.monsters, function (m) {
if (m.is_valid) {
var dist = Math.sqrt(Math.pow(m.cx - _this.cx, 2) + Math.pow(m.cy - _this.cy, 2));
if (dist <= blast_radius) {
m.is_slowed = true;
m.slow_timer = TD.exp_fps * 4; // Slowed for 4 seconds
}
}
});
} else if (this.building && this.building.is_poison) {
// POISON EFFECT (0 Base Damage, Heavy DoT)
monster.is_poisoned = true;
monster.poison_timer = TD.exp_fps * 5; // 5 seconds of poison
TD.Explode("poison-" + TD.lang.rndStr(), { cx: this.cx, cy: this.cy, r: this.r * 2, step_level: this.step_level, render_level: 9, color: "#00ff00", scene: this.map.scene, time: 0.3 });
} else {
// Standard Single-Target Hit (Bullets, Lasers)
monster.beHit(this.building, this.damage);
TD.Explode(this.id + "-explode", { cx: this.cx, cy: this.cy, r: this.r, step_level: this.step_level, render_level: this.render_level, color: this.color, scene: this.map.scene, time: 0.2 });
// --- UPGRADE EFFECTS ---
var should_destroy = true;
if (typeof TD.SpecialEffects !== 'undefined') {
should_destroy = TD.SpecialEffects.onHit(this, monster);
}
if (!should_destroy) {
return false; // Pierce active, keep flying
}
}
}
this.is_valid = false;
return true;
}
return false;
},
step: function () {
// NEW: Dynamic Range Limit. The bullet fizzles out if it flies too far from its parent tower.
if (this.building && this.building.is_valid) {
// Calculate distance from bullet to the center of the tower that fired it
var dist_from_tower = Math.sqrt(Math.pow(this.cx - this.building.cx, 2) + Math.pow(this.cy - this.building.cy, 2));
// Allow a 10% buffer so bullets hitting enemies perfectly on the edge of the range ring don't despawn prematurely
var max_allowed_distance = (this.building.range * TD.grid_size) * 1.1;
if (dist_from_tower > max_allowed_distance) {
this.is_valid = false;
return;
}
}
// NEW: Home in on target every frame to prevent missing at high speeds!
if (this.target && this.target.is_valid && !this.target.is_fake) {
var dist_to_target = Math.sqrt(Math.pow(this.target.cx - this.cx, 2) + Math.pow(this.target.cy - this.cy, 2));
var speed_this_frame = 20 * this.speed * TD.global_speed; // Maximum distance bullet travels per frame
if (dist_to_target <= speed_this_frame) {
// Prevent overshoot by snapping directly to the target
this.cx = this.target.cx;
this.cy = this.target.cy;
this.vx = 0;
this.vy = 0;
} else {
this.caculate();
}
} else if (this.vx === 0 && this.vy === 0) {
// Failsafe: Target died exactly as bullet arrived
this.is_valid = false;
return;
}
if (this.checkOutOfMap() || this.checkHit()) return;
this.cx += this.vx; this.cy += this.vy;
},
render: function () {
var ctx = TD.ctx;
// FLAMER RENDER: Glowing, expanding circles without tails
if (this.cfg && this.cfg.is_flame) {
// Calculate distance from the tower so the flame can physically expand as it travels
var dist = Math.sqrt(Math.pow(this.cx - this.building.cx, 2) + Math.pow(this.cy - this.building.cy, 2));
var size = this.r + (dist * 0.08); // Expands outward
ctx.globalCompositeOperation = "lighter"; // Makes overlapping fire particles glow intensely
ctx.fillStyle = this.color;
ctx.shadowBlur = 15;
ctx.shadowColor = this.color;
ctx.beginPath();
ctx.arc(this.cx, this.cy, size, 0, Math.PI * 2, true);
ctx.fill();
// Cleanup context so other things don't accidentally glow
ctx.shadowBlur = 0;
ctx.globalCompositeOperation = "source-over";
return;
}
// STANDARD BULLET RENDER
// Normalize velocity to get a consistent direction
var norm = Math.sqrt(this.vx * this.vx + this.vy * this.vy) || 1;
var tailLen = 12 * _TD.retina; // Fixed, short tail length (12 pixels)
ctx.fillStyle = this.color;
ctx.strokeStyle = this.color;
ctx.lineWidth = Math.min(this.r, 2 * _TD.retina); // Keep the tail thin
ctx.lineCap = "round";
// Draw the head of the projectile
ctx.beginPath();
ctx.arc(this.cx, this.cy, this.r, 0, Math.PI * 2, true);
ctx.fill();
// Draw the fixed-length tail extending backwards
ctx.beginPath();
ctx.moveTo(this.cx, this.cy);
ctx.lineTo(this.cx - (this.vx / norm) * tailLen, this.cy - (this.vy / norm) * tailLen);
ctx.stroke();
}
};
TD.Bullet = function (id, cfg) { var bullet = new TD.Element(id, cfg); TD.lang.mix(bullet, bullet_obj); bullet._init(cfg); return bullet; };
});
_TD.a.push(function (TD) {
// --- NEW: V.A.T.S. Initialization ---
TD.vats_charge = 0; // Starts at 0, max 100
TD.vats_active = false; // Is VATS currently running?
TD.vats_shots = 0; // Shots remaining
TD.vats_timer = 0; // 10 second countdown
TD.vats_max_charge = 5000; // Vastly increased to handle score integration
// NEW: Consumable Inventory & Cooldown Tracker
// Added faction-specific items
TD.inventory = { stimpak: 0, psycho: 0, cryomine: 0, artillery: 0, capmine: 0, ncr_artillery: 0, bos_laser: 0, mm_flare: 0, raider_nuka: 0 };
TD.cooldowns = { stimpak: 0, psycho: 0, cryomine: 0, artillery: 0, capmine: 0, ncr_artillery: 0, bos_laser: 0, mm_flare: 0, raider_nuka: 0 };
// Calculates the required cooldown frames based on difficulty
TD.getCooldownFrames = function() {
if (TD.difficulty < 1) return 1 * 24; // Halved
if (TD.difficulty > 1) return 12 * 24; // Halved
return 5 * 24; // Halved
};
// NEW: The core Cooldown loop (Runs every frame)
TD.stepConsumables = function() {
for (var key in TD.cooldowns) {
if (TD.cooldowns[key] > 0) {
TD.cooldowns[key]--;
var btn = document.getElementById("btn-item-" + key);
if (btn) {
// Calculate how much has RECHARGED (100% means fully ready)
var percentRecharged = 100 - ((TD.cooldowns[key] / TD.getCooldownFrames()) * 100);
// The button stays physically disabled
btn.disabled = true;
btn.style.cursor = "not-allowed";
// Draw the bright yellow light sliding in from the left
// #f2d57e is the var(--btn-on) color, #555 is the standard disabled background
btn.style.backgroundImage = "linear-gradient(to right, #f2d57e " + percentRecharged + "%, #555 " + percentRecharged + "%)";
// Keep text readable against the dark background while it fills
btn.style.color = "#222";
btn.style.textShadow = "none";
}
// When cooldown finishes, reset button if we have inventory
if (TD.cooldowns[key] <= 0) {
var btnReset = document.getElementById("btn-item-" + key);
if (btnReset) btnReset.style.backgroundImage = "none";
TD.updateItemButton(key);
}
}
}
// SMART CIRCUIT: Auto-disable Stimpak if HP is 100
if (TD.inventory.stimpak > 0 && TD.cooldowns.stimpak <= 0) {
var stimBtn = document.getElementById("btn-item-stimpak");
if (stimBtn) {
// If Siege mode, check base_health, otherwise check life
var isSiege = TD.stage && TD.stage.current_act && TD.stage.current_act.current_scene && TD.stage.current_act.current_scene.map && TD.stage.current_act.current_scene.map.map_type === "siege";
var isFullHealth = isSiege ? (TD.base_health >= 1000) : (TD.life >= 100);
if (isFullHealth && !stimBtn.disabled) {
stimBtn.disabled = true; // Cut power
stimBtn.removeAttribute("data-warmed-up"); // Erase memory so it sparks when damaged
} else if (!isFullHealth && stimBtn.disabled) {
// We took damage! Restore power natively and let the Observer catch it for the RNG spark!
stimBtn.disabled = false;
}
}
}
// Psycho Active Buff Timer
if (TD.psycho_active > 0) {
TD.psycho_active--;
if (TD.psycho_active <= 0) {
TD.log("PSYCHO BUFF WORE OFF");
document.getElementById("td-board").style.boxShadow = "none"; // Remove red screen glow
}
}
// --- S.P.E.C.I.A.L. Overdrive Timers ---
if (TD.overdrive && TD.overdrive_cd && TD.updateSpecialButtons) {
for (var key in TD.overdrive) {
if (TD.overdrive[key] > 0) {
TD.overdrive[key]--;
if (TD.overdrive[key] <= 0 && key === 'P' && TD.stage && TD.stage.current_act) {
// Remove infinite range snipers when Perception ends
var map = TD.stage.current_act.current_scene.map;
if (map) { TD.lang.each(map.buildings, function(b) { b.target = null; }); }
}
} else if (TD.overdrive_cd[key] > 0) {
TD.overdrive_cd[key]--;
}
}
if (TD.iframe % 6 === 0) TD.updateSpecialButtons(); // Update UI every 1/4 second
}
};
// NEW: Function to update button text & toggle disabled state
TD.updateItemButton = function(type) {
var counts = TD.inventory[type];
var btn = document.getElementById("btn-item-" + type);
if (btn) {
var name = type.toUpperCase();
if (type === "cryomine") name = "CRYO MINE";
if (type === "capmine") name = "CAP MINE";
if (type === "ncr_artillery") name = "BARRAGE";
if (type === "bos_laser") name = "ORBITAL";
if (type === "mm_flare") name = "MILITIA";
if (type === "raider_nuka") name = "NUKA GRENADE";
var wasDisabled = btn.disabled;
// CLEAR the visual cooldown styles explicitly so CSS takes over
btn.style.background = "";
btn.style.opacity = "";
btn.style.cursor = "";
if (counts > 0) {
btn.innerText = name + " (" + counts + ")";
btn.style.color = ""; // Let CSS handle color
btn.disabled = false; // Enable button!
// If it just became ready (off cooldown/gained inventory), flicker it on!
if (wasDisabled) {
btn.className = btn.className.replace(/warm-up-\d/g, "").trim();
void btn.offsetWidth; // Force CSS reflow
btn.classList.add("warm-up-" + (Math.floor(Math.random() * 5) + 1));
}
} else {
btn.innerText = name;
btn.style.color = "";
btn.disabled = true; // Disable if empty
btn.style.opacity = "";
btn.style.cursor = "";
// Strip glow immediately if empty
btn.className = btn.className.replace(/warm-up-\d/g, "").trim();
}
}
};
// --- NEW: S.P.E.C.I.A.L. BUTTON UI LOGIC ---
TD.updateSpecialButtons = function() {
var letters = ['S', 'P', 'E', 'C', 'I', 'A', 'L'];
for (var i = 0; i < letters.length; i++) {
var letter = letters[i];
var btn = document.getElementById("btn-special-" + letter.toLowerCase());
if (!btn) continue;
if (TD.perks && TD.perks[letter]) {
// Button is owned! Check cooldowns.
if (TD.overdrive_cd[letter] > 0) {
btn.disabled = true;
var percent = 100 - ((TD.overdrive_cd[letter] / (TD.exp_fps * 120)) * 100); // 2 min cooldown
btn.style.backgroundImage = "linear-gradient(to top, #f2d57e " + percent + "%, #333 " + percent + "%)";
btn.style.color = "#888";
} else if (TD.overdrive[letter] > 0) {
btn.disabled = true; // Actively running!
btn.style.backgroundImage = "none";
btn.style.backgroundColor = "#ffcc00"; // Solid yellow
btn.style.color = "#000";
btn.style.boxShadow = "0 0 15px #ffcc00";
} else {
btn.disabled = false; // Ready to use!
btn.style.backgroundImage = "none";
btn.style.backgroundColor = "#2a7a2a"; // Ready Green
btn.style.color = "#fff";
btn.style.boxShadow = "none";
}
} else {
btn.disabled = true; // Perk not owned
btn.style.background = "#222";
btn.style.color = "#444";
btn.style.borderColor = "#111";
}
}
};
// Wire up the click events for the 7 buttons
var letters = ['S', 'P', 'E', 'C', 'I', 'A', 'L'];
letters.forEach(function(letter) {
var btn = document.getElementById("btn-special-" + letter.toLowerCase());
if (btn) {
btn.onclick = function() {
if (TD.perks && TD.perks[letter] && TD.overdrive_cd[letter] <= 0 && TD.overdrive[letter] <= 0) {
TD.overdrive[letter] = TD.exp_fps * 15; // 15 Seconds of Overdrive
TD.overdrive_cd[letter] = TD.exp_fps * 135; // 2 minute cooldown starts after
var desc = document.getElementById("ui-action-desc");
if (desc) desc.innerText = letter + " OVERDRIVE ENGAGED!";
// Instant Trigger Effects (Charisma Drops, Luck Explosions, Intel XP)
if (letter === 'C') {
// CARAVAN DROP (Coins from sky)
for (var i = 0; i < 15; i++) {
(function(delay) {
setTimeout(function() {
var loot = new TD.Loot("loot-" + TD.lang.rndStr(), { cx: TD.stage.width/2 + (Math.random()*400-200), cy: TD.padding, map: _this.map });
loot.loot_type = "money"; loot.value = 50; loot.altitude = 400; // Drops from 400px high
loot.vx = (Math.random()*4)-2; loot.vy = 2; // Falls downward
}, delay * 150);
})(i);
}
} else if (letter === 'I') {
// INSTANT XP BURST
if (_this.map) {
TD.lang.each(_this.map.buildings, function(b) { if (b.is_weapon) b.gainXp(100); }); // Flat 100 XP to all towers
}
} else if (letter === 'A') {
// INSTANT COOLDOWN RESET
for (var k in TD.cooldowns) TD.cooldowns[k] = 0;
} else if (letter === 'L') {
// LUCK EXPLOSION
for (var i = 0; i < 5; i++) {
var loot = new TD.Loot("loot-" + TD.lang.rndStr(), { cx: TD.stage.width/2 + (Math.random()*600-300), cy: TD.stage.height/2 + (Math.random()*400-200), map: _this.map });
loot.loot_type = "lunchbox";
}
}
}
};
}
});
// NEW: Loot Drop Object
TD.Loot = function (id, cfg) {
cfg.on_events = ["click", "enter", "out"]; // Built-in engine click listener!
var loot = new TD.Element(id, cfg);
loot._init = function(cfg) {
this.cx = cfg.cx; this.cy = cfg.cy; this.map = cfg.map; this.is_valid = true;
// NEW: Smart Off-Path Targeting (5x5 Grid Radius)
var currentGridX = Math.floor((this.cx - this.map.x) / TD.grid_size);
var currentGridY = Math.floor((this.cy - this.map.y) / TD.grid_size);
var validTargets = [];
var maxRadius = Math.max(this.map.grid_x, this.map.grid_y); // Search up to full map size
// Expanding ring search to guarantee finding the closest off-path tile
for (var r = 1; r <= maxRadius; r++) {
for (var dx = -r; dx <= r; dx++) {
for (var dy = -r; dy <= r; dy++) {
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue; // Only check perimeter of the current ring
var targetX = currentGridX + dx;
var targetY = currentGridY + dy;
// Strict map boundaries to prevent wrapping
if (targetX < 0 || targetX >= this.map.grid_x || targetY < 0 || targetY >= this.map.grid_y) continue;
var g = this.map.getGrid(targetX, targetY);
// Add to targets if there is NO tower (build_flag !== 2) AND it's not a dedicated path
if (g && g.build_flag !== 2 && (g.passable_flag === 0 || g.build_flag === 1)) {
validTargets.push(g);
}
}
}
if (validTargets.length > 0) break; // Stop expanding as soon as we find the closest valid tile!
}
var tcx = this.cx;
var tcy = this.cy;
if (validTargets.length > 0) {
// Throw to a random valid off-path tile in the closest available ring
var targetGrid = validTargets[Math.floor(Math.random() * validTargets.length)];
tcx = targetGrid.cx + (Math.random() * 20 - 10);
tcy = targetGrid.cy + (Math.random() * 20 - 10);
} else {
// Absolute last resort fallback (e.g. entire map is completely filled)
tcx = this.cx + (Math.random() * 30 - 15);
tcy = this.cy + (Math.random() * 30 - 15);
}
this.target_cx = tcx;
this.target_cy = tcy;
this.fling_timer = 20; // Reaches target in 20 frames
this.vx = (this.target_cx - this.cx) / this.fling_timer;
this.vy = (this.target_cy - this.cy) / this.fling_timer;
this.altitude = 0; // Starts on the ground
var r = TD.grid_size * 0.4;
this.x = this.cx - r; this.y = this.cy - r; this.width = r * 2; this.height = r * 2;
this.x2 = this.x + this.width; this.y2 = this.y + this.height;
if (!this.map.loots) this.map.loots = [];
this.map.loots.push(this);
// Announce that Loot has dropped!
if (window.IntelData && TD.Announcer) TD.Announcer.add(window.IntelData.getEvent("loot_drop", TD.current_faction), 3);
var duration = 20;
if (TD.difficulty < 1) duration = 35;
else if (TD.difficulty > 1) duration = 12;
this.life = duration * 24;
this.max_life = this.life;
var base_bounty = cfg.monster_bounty || 10;
this.value = Math.floor(base_bounty * (0.5 + Math.random()));
// --- NEW: Handle Pre-defined Loot (from Lunchboxes) vs RNG ---
if (cfg.forced_type) {
this.loot_type = cfg.forced_type;
this.value = cfg.forced_value || this.value;
} else if (window.devForcedDrop && window.devForcedDrop !== "none") {
// --- Dev Cheat Override ---
if (window.devForcedDrop === "faction") {
var factionLoot = { "NCR": "ncr_artillery", "BOS": "bos_laser", "MINUTEMEN": "mm_flare", "RAIDERS": "raider_nuka" };
this.loot_type = factionLoot[TD.current_faction] || "artillery";
} else {
this.loot_type = window.devForcedDrop;
}
window.devForcedDrop = "none";
var d = document.getElementById("ui-intel-brief");
if (d) d.innerText = "FORCED DROP ACQUIRED.";
} else {
// Standard RNG
var roll = Math.random();
if (roll < 0.70) this.loot_type = "money";
else if (roll < 0.75) this.loot_type = "lunchbox";
else if (roll < 0.80) this.loot_type = "stimpak";
else if (roll < 0.85) this.loot_type = "psycho";
else if (roll < 0.90) this.loot_type = "cryomine";
else if (roll < 0.95) this.loot_type = "capmine";
else {
var factionLoot = { "NCR": "ncr_artillery", "BOS": "bos_laser", "MINUTEMEN": "mm_flare", "RAIDERS": "raider_nuka" };
this.loot_type = factionLoot[TD.current_faction] || "artillery";
}
}
this.addToScene(this.map.scene, 1, 9);
};
loot.step = function () {
if (!this.is_valid) return;
// --- NEW: Magnet Flight to UI ---
if (this.is_collecting) {
this.cx += this.vx;
this.cy += this.vy;
this.altitude *= 0.8; // Drop altitude fast so it centers on UI
// Keep hitbox updated
var r = TD.grid_size * 0.4;
this.x = this.cx - r; this.y = this.cy - r;
this.x2 = this.x + (r*2); this.y2 = this.y + (r*2);
this.collect_timer--;
if (this.collect_timer <= 0) {
this.giveReward();
this.is_valid = false;
}
return;
}
// Apply physics throw if still flinging
if (this.fling_timer > 0) {
this.cx += this.vx;
this.cy += this.vy;
// Sine wave formula for jumping: Starts at 0, peaks at middle of timer, ends at 0
// Math.sin(0 to PI) creates a perfect arc.
var progress = 1 - (this.fling_timer / 20); // 0.0 to 1.0
this.altitude = Math.sin(progress * Math.PI) * 40; // Jump up to 40 pixels high
// Keep hitbox updated as it flies
var r = TD.grid_size * 0.4;
this.x = this.cx - r; this.y = this.cy - r;
this.x2 = this.x + (r*2); this.y2 = this.y + (r*2);
this.fling_timer--;
} else {
this.altitude = 0; // Rest flat on the ground
}
this.life--;
if (this.life <= 0) {
this.is_valid = false;
// Announce that we lost the loot!
if (window.IntelData && TD.Announcer) TD.Announcer.add(window.IntelData.getEvent("loot_lost", TD.current_faction), 4);
}
};
loot.render = function () {
if (!this.is_valid) return;
var ctx = TD.ctx;
var size = (this.width / 2) * (this.is_collecting ? 1 : (this.life / this.max_life));
if (size < 2) size = 2;
// --- NEW: IMAGE RENDER (Lunchbox, Caps, Stimpak, Psycho) ---
if (this.loot_type === "lunchbox" || this.loot_type === "money" || this.loot_type === "stimpak" || this.loot_type === "psycho") {
if (!this.img) {
this.img = new Image();
if (this.loot_type === "lunchbox") this.img.src = "assets/images/lunchbox.png";
else if (this.loot_type === "stimpak") this.img.src = "assets/images/stimpack.png";
else if (this.loot_type === "psycho") this.img.src = "assets/images/psycho.png";
else this.img.src = "assets/images/caps.png";
}
if (this.img.complete) {
var imgSize = size * 2.5;
ctx.shadowBlur = 15;
ctx.shadowColor = "rgba(255, 255, 255, 0.7)";
ctx.drawImage(this.img, this.cx - (imgSize/2), this.cy - (imgSize/2) - this.altitude, imgSize, imgSize);
ctx.shadowBlur = 0;
}
return;
}
// --- STANDARD CIRCLE RENDER (For other items) ---
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(this.cx, this.cy - this.altitude, size, 0, Math.PI * 2, true);
ctx.shadowBlur = 10;
ctx.shadowColor = "rgba(255, 255, 255, 0.7)";
if (this.loot_type === "psycho") {
ctx.fillStyle = "#55ff55"; ctx.strokeStyle = "#00aa00";
} else if (this.loot_type === "cryomine") {
ctx.fillStyle = "#aaffff"; ctx.strokeStyle = "#00aaaa";
} else if (this.loot_type === "artillery") {
ctx.fillStyle = "#cccccc"; ctx.strokeStyle = "#555555";
} else {
// Faction Ultimate Drops
ctx.fillStyle = "#ffffaa"; ctx.strokeStyle = "#ffffff";
}
ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.shadowBlur = 0;
ctx.fillStyle = "#000"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.font = "bold " + Math.floor(size) + "px Arial";
var isUltimate = ["ncr_artillery", "bos_laser", "mm_flare", "raider_nuka"].indexOf(this.loot_type) > -1;
var letter = isUltimate ? "⭐" : this.loot_type.charAt(0).toUpperCase();
ctx.fillText(letter, this.cx, this.cy - this.altitude);
};
loot.onClick = function () {
if (!this.is_valid || this.is_collecting) return;
// Force hide the tooltip immediately, because this object is about to be deleted or moved!
var tooltip = document.getElementById("tower-tooltip");
if (tooltip) tooltip.style.display = "none";
// --- NEW: Lunchbox Physical Spitting Logic ---
if (this.loot_type === "lunchbox") {
var itemsToSpit = Math.floor(Math.random() * 3) + 1;
var possibleLoot = ["stimpak", "psycho", "cryomine", "capmine"];
var _map = this.map;
var _cx = this.cx;
var _cy = this.cy;
for (var i = 0; i < itemsToSpit; i++) {
(function(delay) {
setTimeout(function() {
var roll = Math.random();
var spawnType = "money";
var spawnVal = 10;
if (roll < 0.4) {
// Caps
spawnVal = Math.floor(Math.random() * 90) + 10;
} else {
// Consumable
spawnType = possibleLoot[Math.floor(Math.random() * possibleLoot.length)];
}
// Spawn a brand new physical loot object directly on the lunchbox coordinates
// It will naturally run its own `_init` and physics jump outward!
new TD.Loot("loot-" + TD.lang.rndStr(), {
cx: _cx,
cy: _cy,
map: _map,
forced_type: spawnType,
forced_value: spawnVal
});
}, delay * 200); // Stagger the jumps by 200ms
})(i);
}
// Explode effect to hide the lunchbox disappearing
TD.Explode("lb-pop-" + TD.lang.rndStr(), { cx: this.cx, cy: this.cy, r: 20, step_level: 1, render_level: 9, color: "#ffffff", scene: this.map.scene, time: 0.3 });
this.is_valid = false;
return;
}
// --- ALL OTHER LOOT: HTML DOM Flight Animation ---
var targetId = (this.loot_type === "money") ? "ui-money-container" : "btn-item-" + this.loot_type;
var targetEl = document.getElementById(targetId);
if (!targetEl) targetEl = document.getElementById("ui-money-container"); // Safe fallback
// 1. Get the physical screen coordinates of the loot right now
var crect = TD.canvas.getBoundingClientRect();
var scaleX = crect.width / TD.canvas.width;
var scaleY = crect.height / TD.canvas.height;
var startX = crect.left + (this.cx * scaleX);
var startY = crect.top + (this.cy * scaleY);
// 2. Create the temporary HTML image
var animImg = document.createElement("img");
// --- NEW: Correctly assign images based on loot type ---
if (this.loot_type === "stimpak") {
animImg.src = "assets/images/stimpack.png";
} else if (this.loot_type === "psycho") {
animImg.src = "assets/images/psycho.png";
} else if (this.loot_type === "money") {
animImg.src = "assets/images/caps.png";
} else {
// For Mines and Artillery, fallback to caps shape but keep their specific colored glow
animImg.src = "assets/images/caps.png";
}
animImg.style.position = "fixed";
animImg.style.left = startX + "px";
animImg.style.top = startY + "px";
animImg.style.width = "40px";
animImg.style.height = "40px";
animImg.style.zIndex = "999999";
animImg.style.pointerEvents = "none";
animImg.style.transition = "all 0.5s ease-in-out";
// Apply consistent off-white glow
animImg.style.filter = "drop-shadow(0px 0px 10px rgba(255, 255, 255, 0.7))";
document.body.appendChild(animImg);
// 3. Force reflow, then apply target coordinates to trigger CSS transition
void animImg.offsetWidth;
var targetRect = targetEl.getBoundingClientRect();
animImg.style.left = (targetRect.left + (targetRect.width / 2) - 20) + "px";
animImg.style.top = (targetRect.top + (targetRect.height / 2) - 20) + "px";
animImg.style.transform = "scale(0.5)"; // Shrink as it flies
animImg.style.opacity = "0.2";
// 4. Grant the reward and pop the text instantly!
this.giveReward();
// 5. Wait for CSS transition to finish, then delete the flying image
setTimeout(function() {
if (animImg.parentNode) animImg.parentNode.removeChild(animImg);
}, 500); // 500ms matches the CSS transition time
// Kill the canvas object immediately so the player can't double-click it
this.is_valid = false;
};
loot.giveReward = function () {
var msg = "";
var color = "#fff";
var _val = this.value;
var _type = this.loot_type;
if (_type === "money") {
// Do not award immediately! Wait 500ms for the animation to hit the UI.
setTimeout(function() {
TD.money += _val;
}, 500);
msg = "+$" + _val;
color = "#ffdd33";
} else {
if (TD.inventory[_type] < 5) {
// Delay inventory addition
setTimeout(function() {
TD.inventory[_type]++;
TD.updateItemButton(_type);
}, 500);
var name = _type.toUpperCase();
if (_type === "cryomine") name = "CRYO MINE";
if (_type === "capmine") name = "CAP MINE";
msg = "+1 " + name;
if (_type === "stimpak") color = "#ff5555";
else if (_type === "psycho") color = "#55ff55";
else if (_type === "cryomine") color = "#aaffff";
else if (_type === "capmine") color = "#ffaa00";
else if (_type === "artillery") color = "#cccccc";
else color = "#ffffff";
} else {
// Inventory full, refund caps (delayed)
setTimeout(function() {
TD.money += _val;
}, 500);
msg = "FULL! +$" + _val;
color = "#ffdd33";
}
}
// Spawn floating text IMMEDIATELY at the pickup location
new TD.FloatingText("ft-" + TD.lang.rndStr(), {
cx: this.x + (this.width / 2),
cy: this.y + (this.height / 2) - 20,
text: msg, color: color, map: this.map
});
// Announce we secured it! (Priority 2, it's good but not critical)
if (window.IntelData && TD.Announcer) TD.Announcer.add(window.IntelData.getEvent("consumable_used", TD.current_faction), 2);
};
// --- NEW: LORE TOOLTIPS FOR LOOT ---
loot.onEnter = function() {
var tooltip = document.getElementById("tower-tooltip");
if (!tooltip) return;
var title = "";
var desc = "";
if (this.loot_type === "money") {
title = "BOTTLECAPS";
desc = "Effect: +Caps.\nDon't ask why mutated bugs are carrying pre-war soda caps. Just take 'em.";
} else if (this.loot_type === "lunchbox") {
title = "SCAVENGED LUNCHBOX";
desc = "Effect: Drops random items.\nOnce held a Vault Boy thermos. Now holds whatever the last poor guy stuffed in it.";
} else if (this.loot_type === "stimpak") {
title = "STANDARD STIMPAK";
desc = "Effect: Restores 15 HP.\nIt's mostly just Med-X and Brahmin blood, but it keeps the defenses from collapsing.";
} else if (this.loot_type === "psycho") {
title = "DIRTY PSYCHO";
desc = "Effect: Boosts DMG/Speed by 25% for 10s.\nInject this into the targeting relays and watch the lead fly. Go get 'em!";
} else if (this.loot_type === "cryomine") {
title = "CRYO-GRENADE";
desc = "Effect: Freezes Enemies in Radius.\nLiquid nitrogen payload. Stops charging Deathclaws dead in their tracks.";
} else if (this.loot_type === "capmine") {
title = "SHRAPNEL CAP MINE";
desc = "Effect: Massive AoE Damage + Drops Caps.\nTurns pocket change into high-velocity projectiles. Pick up the caps after the mist clears.";
} else {
title = "FACTION SUPPLY DROP";
desc = "Effect: Grants Faction Ultimate.\nPre-war tech salvaged for the current war.";
}
tooltip.innerHTML = "<strong style='color:#f2d57e; font-size:14px;'>" + title + "</strong><br><br><span style='color:#ccc; font-size:12px;'>" + desc.replace(/\n/g, "<br>") + "</span>";
tooltip.style.display = "block";
};
loot.onOut = function() {
var tooltip = document.getElementById("tower-tooltip");
if (tooltip) tooltip.style.display = "none";
};
loot._init(cfg);
return loot;
};
// NEW: Mr. Handy Loot Collector Object
TD.MrHandy = function (id, cfg) {
cfg.on_events = ["click", "enter", "out"]; // NEW: Make him clickable!
var handy = new TD.Element(id, cfg);
handy._init = function(cfg) {
this.map = cfg.map;
// NEW: Upgrade and Behavior Tracking
this.name = "Mr. Handy";
this.level = 1;
this.path1_tier = 0;
this.path2_tier = 0;
this.behavior = "normal"; // normal, defensive, aggressive
this.is_weapon = false; // Starts as non-combat
this.upgrades = {
path1: [
{ name: "Circular Saw", cost: 250, desc: "Deals melee damage. Unlocks 'Aggressive' behavior." },
{ name: "Laser Emitter", cost: 600, desc: "Fires a basic laser at nearby enemies while moving." },
{ name: "Flamer Jet", cost: 1200, desc: "Sprays short-range AoE fire that leaves a burn effect." },
{ name: "Military Grade", cost: 3000, desc: "Converts to a heavily armored combat bot. High damage boost." }
],
path2: [
{ name: "Reinforced Plating", cost: 300, desc: "Triples his maximum HP." },
{ name: "Thruster Overclock", cost: 700, desc: "+50% movement speed to cross the map faster." },
{ name: "Magnetic Coil", cost: 1500, desc: "Automatically sucks in loot from 1.5 squares away." },
{ name: "Scavenger Protocol", cost: 2500, desc: "Randomly 'finds' and generates loot while exploring." }
]
};
// Start at provided click location, otherwise fallback to exit/center
if (cfg.cx != null && cfg.cy != null) {
this.cx = cfg.cx; this.cy = cfg.cy;
} else {
var startGrid = this.map.exit || this.map.getGrid(Math.floor(this.map.grid_x/2), Math.floor(this.map.grid_y/2));
this.cx = startGrid.cx; this.cy = startGrid.cy;
}
this.r = (TD.grid_size * 0.9) / 2; // 90% of grid size
this.width = this.r * 2; this.height = this.r * 2; // FIXED: Canvas needs exact dimensions to render the image
this.caculatePos();
this.is_valid = true;
this.max_life = 500; // Fairly tanky
this.life = this.max_life;
this.base_speed = 10;
this.loot_speed = this.base_speed * 2.0; // 100% faster sprint
this.target_loot = null;
this.wander_target = null;
this.wander_timer = 0;
this.img = new Image();
this.img.src = "assets/images/mrhandy.png";
this.addToScene(this.map.scene, 1, 9); // Render level 9 so he floats above towers
};
handy.caculatePos = function() {
this.x = this.cx - this.r; this.y = this.cy - this.r;
this.x2 = this.cx + this.r; this.y2 = this.cy + this.r;
};
handy.step = function() {
if (!this.is_valid) return;
var _this = this;
var current_speed = this.base_speed * TD.global_speed;
// 1. MAGNETIC COIL (Tier 3 Utility)
if (this.magnetic_coil) {
if (this.map.loots && this.map.loots.length > 0) {
for (var i = 0; i < this.map.loots.length; i++) {
var l = this.map.loots[i];
if (l.is_valid && !l.is_collecting && l.fling_timer <= 0) {
var d = Math.sqrt(Math.pow(l.cx - this.cx, 2) + Math.pow(l.cy - this.cy, 2));
if (d <= TD.grid_size * 1.5) { // 1.5 square pull radius
l.onClick(); // Instantly loot it!
}
}
}
}
}
// 2. SCAVENGER PROTOCOL (Tier 4 Utility)
if (this.path2_tier >= 4) {
if (typeof this._scav_timer === 'undefined') this._scav_timer = TD.exp_fps * 10; // Every 10 seconds
this._scav_timer--;
if (this._scav_timer <= 0) {
this._scav_timer = TD.exp_fps * (8 + Math.random() * 4); // Random 8-12 seconds
new TD.Loot("scav-" + TD.lang.rndStr(), { cx: this.cx, cy: this.cy, map: this.map, monster_bounty: 25 });
new TD.FloatingText("ft-" + TD.lang.rndStr(), { cx: this.cx, cy: this.cy - 20, text: "Found something!", color: "#ffcc00", map: this.map });
}
}
// 3. COMBAT PROTOCOLS (Path 1)
var target_monster = null;
var closestDistM = 999999;
var is_bumping = false;
// Scan for enemies
TD.lang.each(this.map.monsters, function(m) {
if (m.is_valid) {
var dist = Math.sqrt(Math.pow(m.cx - _this.cx, 2) + Math.pow(m.cy - _this.cy, 2));
// Track closest for Aggressive tracking
if (dist < closestDistM) { closestDistM = dist; target_monster = m; }
// Physical collision damage (Enemy hits Handy)
if (dist < m.r + _this.r) {
is_bumping = true;
_this.life -= m.damage;
if (TD.iframe % 5 === 0) {
new TD.FloatingText("hdmg-" + TD.lang.rndStr(), { cx: _this.cx, cy: _this.cy - 10, text: "-" + m.damage, color: "#ff0000", map: _this.map, size: 10, life: 16 });
}
// Tier 1 Combat: Circular Saw
if (_this.path1_tier >= 1 && TD.iframe % 3 === 0) {
var sawDmg = _this.path1_tier >= 4 ? 25 : 5; // Much stronger if Military Grade
m.beHit({ killed: 0, gainXp: function(){} }, sawDmg);
TD.Explode("saw-" + TD.lang.rndStr(), { cx: m.cx, cy: m.cy, r: 8, step_level: 1, render_level: 9, color: "#ff5500", scene: _this.map.scene, time: 0.1 });
}
}
// Tier 2 Combat: Laser Emitter
if (_this.path1_tier >= 2 && dist < TD.grid_size * 3) {
if (typeof _this._laser_timer === 'undefined') _this._laser_timer = 0;
_this._laser_timer--;
var fireRate = _this.path1_tier >= 4 ? 6 : 15; // Faster if Military Grade
if (_this._laser_timer <= 0) {
_this._laser_timer = fireRate;
var laserDmg = _this.path1_tier >= 4 ? 50 : 15;
m.beHit({ killed: 0, gainXp: function(){} }, laserDmg);
// NEW: Laser Sound
TD.playSound("snd_laser_fire");
// Draw the laser beam
var ctx = TD.ctx;
ctx.lineWidth = 2 * _TD.retina; ctx.strokeStyle = "rgba(255, 0, 0, 0.8)";
ctx.beginPath(); ctx.moveTo(_this.cx, _this.cy); ctx.lineTo(m.cx, m.cy); ctx.stroke();
}
}
// Tier 3 Combat: Flamer
if (_this.path1_tier >= 3 && dist < TD.grid_size * 1.5) {
if (TD.iframe % 5 === 0) {
m.is_poisoned = true;
m.poison_timer = TD.exp_fps * 2;
m.poison_damage = _this.path1_tier >= 4 ? 10 : 3;
TD.Explode("flame-" + TD.lang.rndStr(), { cx: m.cx, cy: m.cy, r: 12, step_level: 1, render_level: 9, color: "#ff8800", scene: _this.map.scene, time: 0.2 });
// NEW: Flamer Sound (reusing the shotgun blast for a heavy spray noise)
if (TD.iframe % 15 === 0) TD.playSound("snd_shotgun");
}
}
}
});
if (this.life <= 0) {
TD.Explode("handy-explode", { cx: this.cx, cy: this.cy, r: this.r*2, step_level: 1, render_level: 9, color: "#ffffaa", scene: this.map.scene, time: 0.5 });
this.is_valid = false;
this.map.mr_handy_active = false;
return;
}
// 4. BEHAVIOR & MOVEMENT LOGIC
this.target_loot = null;
var closestDistL = 999999;
if (this.map.loots && this.map.loots.length > 0) {
for (var i = 0; i < this.map.loots.length; i++) {
var l = this.map.loots[i];
if (l.is_valid && !l.is_collecting && l.fling_timer <= 0) {
var d = Math.sqrt(Math.pow(l.cx - this.cx, 2) + Math.pow(l.cy - this.cy, 2));
if (d < closestDistL) { closestDistL = d; this.target_loot = l; }
}
}
}
var tcx = this.cx, tcy = this.cy;
var evade_multiplier = 0; // Default: No evasion
// BEHAVIOR ROUTING
if (this.behavior === "aggressive" && target_monster) {
// Aggressive: Chase the closest monster. Ignore loot unless it's extremely close.
if (this.target_loot && closestDistL <= TD.grid_size * 2.5) {
tcx = this.target_loot.cx; tcy = this.target_loot.cy;
current_speed = this.loot_speed * TD.global_speed;
if (closestDistL <= this.r) this.target_loot.onClick();
} else {
tcx = target_monster.cx; tcy = target_monster.cy;
}
} else if (this.behavior === "defensive") {
// Defensive: Highly evasive. Gets loot, but runs away very fast if a monster gets close.
evade_multiplier = 2.5; // Huge push away from monsters
if (this.target_loot) {
tcx = this.target_loot.cx; tcy = this.target_loot.cy;
current_speed = this.loot_speed * TD.global_speed;
if (closestDistL <= this.r) this.target_loot.onClick();
} else {
// Safe to wander
if (this.wander_timer <= 0 || !this.wander_target) {
this.wander_target = { cx: this.map.x + (Math.random() * this.map.width), cy: this.map.y + (Math.random() * this.map.height) };
this.wander_timer = TD.exp_fps * 3;
} else {
tcx = this.wander_target.cx; tcy = this.wander_target.cy;
this.wander_timer--;
}
}
} else {
// Normal: Balances loot and evasion.
evade_multiplier = 1.0; // Moderate push away from monsters
if (this.target_loot) {
tcx = this.target_loot.cx; tcy = this.target_loot.cy;
current_speed = this.loot_speed * TD.global_speed;
if (closestDistL <= this.r) this.target_loot.onClick();
} else {
if (this.wander_timer <= 0 || !this.wander_target) {
this.wander_target = { cx: this.map.x + (Math.random() * this.map.width), cy: this.map.y + (Math.random() * this.map.height) };
this.wander_timer = TD.exp_fps * 3;
} else {
tcx = this.wander_target.cx; tcy = this.wander_target.cy;
this.wander_timer--;
}
}
}
// --- Vector Flight & Obstacle Evasion ---
var dx = tcx - this.cx, dy = tcy - this.cy;
var c = Math.sqrt(dx*dx + dy*dy);
var move_x = 0, move_y = 0;
if (c > 0) {
move_x = (dx / c) * current_speed;
move_y = (dy / c) * current_speed;
}
// Apply Monster Avoidance if active
if (evade_multiplier > 0 && this.map.monsters) {
var evade_x = 0, evade_y = 0;
var avoid_radius = TD.grid_size * 1.5;
TD.lang.each(this.map.monsters, function(m) {
if (m.is_valid && m.life > 0) {
var mdx = _this.cx - m.cx;
var mdy = _this.cy - m.cy;
var dist = Math.sqrt(mdx*mdx + mdy*mdy);
if (dist > 0 && dist < avoid_radius) {
var force = Math.pow((avoid_radius - dist) / avoid_radius, 2);
evade_x += (mdx / dist) * force * current_speed * evade_multiplier;
evade_y += (mdy / dist) * force * current_speed * evade_multiplier;
}
}
});
move_x += evade_x; move_y += evade_y;
}
// Normalize final movement vector
var final_c = Math.sqrt(move_x*move_x + move_y*move_y);
if (final_c > 0 && final_c > current_speed) {
move_x = (move_x / final_c) * current_speed;
move_y = (move_y / final_c) * current_speed;
}
// Apply movement (snap perfectly to target if looting to prevent jitter)
if (c <= current_speed && this.target_loot && evade_multiplier === 0) {
this.cx = tcx; this.cy = tcy;
} else {
this.cx += move_x;
this.cy += move_y;
// Keep him on screen bounds
if (this.cx < this.map.x) this.cx = this.map.x;
if (this.cx > this.map.x2) this.cx = this.map.x2;
if (this.cy < this.map.y) this.cy = this.map.y;
if (this.cy > this.map.y2) this.cy = this.map.y2;
}
// Oscillating hover effect
this.hover_y = Math.sin(TD.iframe / 6) * 4 * _TD.retina;
this.caculatePos();
};
handy.render = function() {
if (!this.is_valid) return;
var ctx = TD.ctx;
// Draw the floating drop shadow on the ground
ctx.save();
ctx.translate(this.cx, this.cy + this.r); // Anchor shadow at his base
ctx.scale(1, 0.4); // Flatten into a perspective ellipse
ctx.beginPath();
ctx.arc(0, 0, this.r * 0.7, 0, Math.PI * 2, true);
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fill();
ctx.restore();
// Draw Image
if (this.img && this.img.complete) {
ctx.drawImage(this.img, this.x, this.y + this.hover_y, this.width, this.height);
} else {
// Fallback circle if image fails
ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(this.cx, this.cy + this.hover_y, this.r, 0, Math.PI*2); ctx.fill();
}
// Health Bar
var hbw = 20 * _TD.retina, hbh = 3 * _TD.retina;
var hbx = this.cx - (hbw/2);
var hby = this.y + this.hover_y - (6 * _TD.retina);
ctx.fillStyle = "#000"; ctx.fillRect(hbx, hby, hbw, hbh);
ctx.fillStyle = "#0f0"; ctx.fillRect(hbx, hby, hbw * (this.life/this.max_life), hbh);
};
// --- NEW: MR HANDY HOVER & CLICK LOGIC ---
handy.onEnter = function () {
var tooltip = document.getElementById("tower-tooltip");
if (tooltip) {
var p1Name = this.path1_tier > 0 ? this.upgrades.path1[this.path1_tier - 1].name : "None";
var p2Name = this.path2_tier > 0 ? this.upgrades.path2[this.path2_tier - 1].name : "None";
var stats = "UNIT : " + this.name.toUpperCase() + "\n" +
"HP : " + Math.floor(this.life) + " / " + this.max_life + "\n" +
"MODE : " + this.behavior.toUpperCase() + "\n" +
"PATH 1: " + p1Name + "\n" +
"PATH 2: " + p2Name;
tooltip.innerText = stats;
tooltip.style.display = "block";
}
};
handy.onOut = function () {
var tooltip = document.getElementById("tower-tooltip");
if (tooltip) tooltip.style.display = "none";
};
handy.onClick = function () {
if (TD.is_paused || TD.admin_mode) return;
var tooltip = document.getElementById("tower-tooltip");
if (tooltip) tooltip.style.display = "none";
var overlay = document.getElementById("tower-modal-overlay");
if (!overlay) return;
document.getElementById("modal-tower-name").innerText = this.name.toUpperCase();
// NEW: Inject Mr. Handy's Image into the modal
var imgContainer = document.getElementById("modal-tower-img");
if (imgContainer) {
imgContainer.innerHTML = "<img src='assets/images/mrhandy.png' style='width: 100%; height: 100%; object-fit: contain; filter: drop-shadow(0 0 5px #0f0);'>";
}
var p1Name = this.path1_tier > 0 ? this.upgrades.path1[this.path1_tier - 1].name : "None";
var p2Name = this.path2_tier > 0 ? this.upgrades.path2[this.path2_tier - 1].name : "None";
var statsHtml = "<b>Level:</b> " + this.level + "<br>" +
"<b>HP:</b> " + Math.floor(this.life) + " / " + this.max_life + "<br><br>" +
"<b>Behavior:</b> " + this.behavior.toUpperCase() + "<br><br>" +
"<b style='color:#ffcc00;'>Path 1:</b> " + p1Name + "<br>" +
"<b style='color:#ffcc00;'>Path 2:</b> " + p2Name;
document.getElementById("modal-tower-stats").innerHTML = statsHtml;
var _handy = this;
var btnClose = document.getElementById("modal-close-btn");
var btnSell = document.getElementById("modal-btn-sell");
var newClose = btnClose.cloneNode(true); btnClose.parentNode.replaceChild(newClose, btnClose);
var newSell = btnSell.cloneNode(true); btnSell.parentNode.replaceChild(newSell, btnSell);
newClose.onclick = function() { overlay.style.display = "none"; };
newSell.innerText = "SCRAP UNIT (250c)";
newSell.onclick = function() {
TD.money += 250;
_handy.is_valid = false;
_handy.map.mr_handy_active = false;
TD.Explode("scrap-" + TD.lang.rndStr(), { cx: _handy.cx, cy: _handy.cy, r: 20, step_level: 1, render_level: 9, color: "#ffffaa", scene: _handy.map.scene, time: 0.3 });
overlay.style.display = "none";
};
overlay.onclick = function(e) { if(e.target === overlay) overlay.style.display = "none"; };
// Override the Targeting Dropdown for Behaviors
var selTarget = document.getElementById("modal-target-mode");
if (selTarget) {
selTarget.innerHTML = '<option value="normal">Normal (Balanced)</option><option value="defensive">Defensive (Evade)</option>';
// Only show Aggressive if he has a weapon
if (_handy.is_weapon) selTarget.innerHTML += '<option value="aggressive">Aggressive (Combat)</option>';
selTarget.value = _handy.behavior;
selTarget.onchange = function() { _handy.behavior = this.value; };
}
// Upgrades UI Logic
var drawIndicators = function(containerId, currentTier) {
var container = document.getElementById(containerId);
container.innerHTML = "";
for (var i = 0; i < 4; i++) {
var box = document.createElement("div");
box.style.width = "12px"; box.style.height = "12px"; box.style.border = "1px solid #0f0";
box.style.background = (i < currentTier) ? "#0f0" : "transparent";
container.appendChild(box);
}
};
var updateUpgradeUI = function(pathNum, btnElement, currentTier, otherTier, pathData) {
var nameEl = btnElement.querySelector("#path" + pathNum + "-name");
var costEl = btnElement.querySelector("#path" + pathNum + "-cost");
var descEl = btnElement.querySelector("#path" + pathNum + "-desc");
drawIndicators("path" + pathNum + "-indicators", currentTier);
var isRestricted = (currentTier === 2 && otherTier > 2);
if (currentTier >= 4) {
nameEl.innerText = "MAX TIER REACHED"; costEl.innerText = ""; descEl.innerText = "";
btnElement.disabled = true; btnElement.style.backgroundColor = "#444"; btnElement.style.borderColor = "#555";
nameEl.style.color = "#888"; descEl.style.color = "#888";
btnElement.removeAttribute("data-cost");
} else if (isRestricted) {
nameEl.innerText = "PATH LOCKED"; costEl.innerText = ""; descEl.innerText = "Cannot upgrade past Tier 2 because the other path is Tier 3+.";
btnElement.disabled = true; btnElement.style.backgroundColor = "#400"; btnElement.style.borderColor = "#800";
nameEl.style.color = "#888"; descEl.style.color = "#888";
btnElement.removeAttribute("data-cost");
} else {
var nextUpgrade = pathData[currentTier];
nameEl.innerText = nextUpgrade.name; costEl.innerText = nextUpgrade.cost + "c"; descEl.innerText = nextUpgrade.desc;
var actualCost = nextUpgrade.cost;
if (TD.perks && TD.perks.C) actualCost = Math.floor(actualCost * 0.85);
if (TD.perks && TD.perks.C) costEl.innerText = actualCost + "c";
btnElement.setAttribute("data-cost", actualCost);
if (TD.money >= actualCost) {
btnElement.disabled = false; btnElement.style.backgroundColor = "#2a7a2a"; btnElement.style.borderColor = "#0f0";
nameEl.style.color = "#000"; descEl.style.color = "#111";
} else {
btnElement.disabled = true; btnElement.style.backgroundColor = "#444"; btnElement.style.borderColor = "#555";
nameEl.style.color = "#888"; descEl.style.color = "#888";
}
// ALWAYS assign the click handler!
btnElement.onclick = function() {
if (TD.money >= actualCost) {
TD.money -= actualCost;
if (pathNum === 1) _handy.path1_tier++; else _handy.path2_tier++;
// Immediately apply logic flags based on tier
if (pathNum === 1 && _handy.path1_tier === 1) _handy.is_weapon = true; // Unlocks Aggressive
if (pathNum === 2 && _handy.path2_tier === 1) { _handy.max_life *= 3; _handy.life = _handy.max_life; } // HP First
if (pathNum === 2 && _handy.path2_tier === 2) _handy.base_speed *= 1.5; // Speed Second
if (pathNum === 2 && _handy.path2_tier === 3) _handy.magnetic_coil = true;
TD.Explode("upg-" + TD.lang.rndStr(), { cx: _handy.cx, cy: _handy.cy, r: 20 * _TD.retina, step_level: 1, render_level: 9, color: "#00ff00", scene: _handy.map.scene, time: 0.3 });
_handy.onClick(); // Re-render UI
}
};
}
};
var btnPath1 = document.getElementById("modal-btn-path1");
var btnPath2 = document.getElementById("modal-btn-path2");
var newP1 = btnPath1.cloneNode(true); btnPath1.parentNode.replaceChild(newP1, btnPath1);
var newP2 = btnPath2.cloneNode(true); btnPath2.parentNode.replaceChild(newP2, btnPath2);
updateUpgradeUI(1, newP1, _handy.path1_tier, _handy.path2_tier, _handy.upgrades.path1);
updateUpgradeUI(2, newP2, _handy.path2_tier, _handy.path1_tier, _handy.upgrades.path2);
overlay.style.display = "flex";
};
handy._init(cfg);
return handy;
};
// NEW: Cryo Mine Engine Object
TD.CryoMine = function (id, cfg) {
cfg.on_events = ["click", "enter", "out"];
var mine = new TD.Element(id, cfg);
mine._init = function(cfg) {
this.grid = cfg.grid;
this.map = cfg.map;
this.cx = this.grid.cx;
this.cy = this.grid.cy;
var r = TD.grid_size * 0.3;
this.x = this.cx - r; this.y = this.cy - r;
this.width = r * 2; this.height = r * 2;
this.x2 = this.x + this.width; this.y2 = this.y + this.height;
this.is_valid = true;
this.pulse = 0;
this.blast_radius = TD.grid_size * 3.5;
this.addToScene(this.map.scene, 1, 4);
};
mine.onEnter = function() { this.map.scene.panel.balloontip.msg("CRYO MINE", this); };
mine.onOut = function() { this.map.scene.panel.balloontip.hide(); };
mine.detonate = function() {
if (!this.is_valid) return;
this.is_valid = false;
var ctx = TD.ctx;
var cx = this.cx;
var cy = this.cy;
var r2 = Math.pow(this.blast_radius, 2);
TD.Explode("explode-" + TD.lang.rndStr(), {
cx: cx, cy: cy, r: this.blast_radius / _TD.retina,
step_level: this.step_level, render_level: 9,
color: "#00ffff", scene: this.map.scene, time: 0.5
});
TD.lang.each(this.map.monsters, function (m) {
if (m.is_valid) {
var dist2 = Math.pow(m.cx - cx, 2) + Math.pow(m.cy - cy, 2);
if (dist2 <= r2) {
if (!m.is_frozen) {
m.is_frozen = true;
m.original_speed = m.speed;
}
m.speed = 0.1; // Almost 0 to stop movement without breaking math
m.freeze_timer = 10 * 24; // Doubled to 10 seconds (240 frames)
}
}
});
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "CRYO MINE DETONATED!";
};
mine.step = function () {
if (!this.is_valid) return;
this.pulse += 0.1;
var cx = this.cx, cy = this.cy;
var trigger_dist = Math.pow(TD.grid_size * 1.0, 2);
var triggered = TD.lang.any(this.map.monsters, function (m) {
return (Math.pow(m.cx - cx, 2) + Math.pow(m.cy - cy, 2) <= trigger_dist);
});
if (triggered) this.detonate();
};
mine.render = function () {
if (!this.is_valid) return;
var ctx = TD.ctx;
var glow = Math.abs(Math.sin(this.pulse));
ctx.shadowBlur = 10 * glow;
ctx.shadowColor = "#00ffff";
ctx.fillStyle = "#aaffff";
ctx.beginPath();
ctx.arc(this.cx, this.cy, 4 * _TD.retina, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = "#555";
ctx.lineWidth = 2 * _TD.retina;
ctx.beginPath();
ctx.arc(this.cx, this.cy, 6 * _TD.retina, 0, Math.PI * 2, true);
ctx.closePath();
ctx.stroke();
};
mine.onClick = function () {
if (!this.is_valid) return;
this.detonate();
};
mine._init(cfg);
return mine;
};
// NEW: Cap Mine Engine Object
TD.CapMine = function (id, cfg) {
cfg.on_events = ["click", "enter", "out"];
var mine = new TD.Element(id, cfg);
mine._init = function(cfg) {
this.grid = cfg.grid; this.map = cfg.map;
this.cx = this.grid.cx; this.cy = this.grid.cy;
var r = TD.grid_size * 0.3;
this.x = this.cx - r; this.y = this.cy - r;
this.width = r * 2; this.height = r * 2;
this.x2 = this.x + this.width; this.y2 = this.y + this.height;
this.is_valid = true; this.pulse = 0;
this.blast_radius = TD.grid_size * 3.0;
this.addToScene(this.map.scene, 1, 4);
};
mine.onEnter = function() { this.map.scene.panel.balloontip.msg("CAP MINE", this); };
mine.onOut = function() { this.map.scene.panel.balloontip.hide(); };
mine.detonate = function() {
if (!this.is_valid) return;
this.is_valid = false;
var cx = this.cx, cy = this.cy, r2 = Math.pow(this.blast_radius, 2);
TD.Explode("explode-" + TD.lang.rndStr(), { cx: cx, cy: cy, r: this.blast_radius / _TD.retina, step_level: this.step_level, render_level: 9, color: "#ffaa00", scene: this.map.scene, time: 0.5 });
var dummy_source = { killed: 0, gainXp: function(){} };
TD.lang.each(this.map.monsters, function (m) {
if (m.is_valid && (Math.pow(m.cx - cx, 2) + Math.pow(m.cy - cy, 2) <= r2)) {
m.beHit(dummy_source, 500);
}
});
// Spit out 3-5 coins worth 5-15 each
var coin_count = Math.floor(Math.random() * 3) + 3;
for(var i=0; i<coin_count; i++) {
var coin_val = Math.floor(Math.random() * 11) + 5;
var loot = new TD.Loot("loot-" + TD.lang.rndStr(), { cx: cx, cy: cy, map: this.map, monster_bounty: coin_val });
loot.loot_type = "money"; // Force it to be money
loot.value = coin_val;
}
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "CAP MINE DETONATED!";
};
mine.step = function () {
if (!this.is_valid) return;
this.pulse += 0.1;
var cx = this.cx, cy = this.cy, trigger_dist = Math.pow(TD.grid_size * 1.0, 2);
if (TD.lang.any(this.map.monsters, function (m) { return (Math.pow(m.cx - cx, 2) + Math.pow(m.cy - cy, 2) <= trigger_dist); })) this.detonate();
};
mine.render = function () {
if (!this.is_valid) return;
var ctx = TD.ctx, glow = Math.abs(Math.sin(this.pulse));
ctx.shadowBlur = 10 * glow; ctx.shadowColor = "#ffaa00";
ctx.fillStyle = "#ffcc00";
ctx.beginPath(); ctx.arc(this.cx, this.cy, 4 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); ctx.shadowBlur = 0;
ctx.strokeStyle = "#555"; ctx.lineWidth = 2 * _TD.retina;
ctx.beginPath(); ctx.arc(this.cx, this.cy, 6 * _TD.retina, 0, Math.PI * 2, true); ctx.closePath(); ctx.stroke();
};
mine.onClick = function () { if (this.is_valid) this.detonate(); };
mine._init(cfg); return mine;
};
// NEW: Floating Text Engine Object
TD.FloatingText = function (id, cfg) {
var ft = new TD.Element(id, cfg);
ft._init = function(cfg) {
this.cx = cfg.cx; this.cy = cfg.cy; this.text = cfg.text;
this.color = cfg.color || "#fff"; this.map = cfg.map;
this.size = cfg.size || 16;
this.is_valid = true; this.life = cfg.life || 36; this.max_life = this.life;
this.addToScene(this.map.scene, 1, 9);
};
ft.step = function () {
if (!this.is_valid) return;
this.cy -= 1.0; this.life--;
if (this.life <= 0) this.is_valid = false;
};
ft.render = function () {
if (!this.is_valid) return;
var ctx = TD.ctx;
ctx.globalAlpha = Math.sin((this.life / this.max_life) * Math.PI);
ctx.fillStyle = this.color; ctx.font = "bold " + Math.floor(this.size * _TD.retina) + "px Arial";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.shadowBlur = 4; ctx.shadowColor = "#000";
ctx.fillText(this.text, this.cx, this.cy);
ctx.globalAlpha = 1.0; ctx.shadowBlur = 0;
};
ft._init(cfg);
return ft;
};
// --- NEW: ULTIMATE ABILITY ELEMENTS ---
// 1. BOS Orbital Laser (Mouse Tracking)
TD.OrbitalLaser = function (id, cfg) {
var el = new TD.Element(id, cfg);
el._init = function(cfg) {
this.map = cfg.map; this.cx = cfg.cx; this.cy = cfg.cy;
this.life = 8 * 24; this.r = TD.grid_size * 1.5;
this.is_valid = true; this.addToScene(this.map.scene, 1, 9);
var _this = this;
this._mm = function(e) {
var rect = TD.canvas.getBoundingClientRect();
_this.cx = (e.clientX - rect.left) * (TD.canvas.width / rect.width);
_this.cy = (e.clientY - rect.top) * (TD.canvas.height / rect.height);
};
window.addEventListener("mousemove", this._mm);
};
el.step = function() {
if (!this.is_valid) return;
this.life--;
if (this.life <= 0) { this.is_valid = false; window.removeEventListener("mousemove", this._mm); return; }
var _this = this; var dummy = { killed: 0, gainXp: function(){} };
TD.lang.each(this.map.monsters, function(m) {
if (m.is_valid && Math.pow(m.cx - _this.cx, 2) + Math.pow(m.cy - _this.cy, 2) <= Math.pow(_this.r, 2)) m.beHit(dummy, 15); // 15 dmg per frame
});
if (Math.random() < 0.4) TD.Explode("orb-p-"+TD.lang.rndStr(), {cx: this.cx + (Math.random()*this.r - this.r/2), cy: this.cy + (Math.random()*this.r - this.r/2), r: 8, step_level:1, render_level:9, color: "#00aaff", scene: this.map.scene, time: 0.2});
};
el.render = function() {
if (!this.is_valid) return;
var ctx = TD.ctx; ctx.shadowBlur = 30; ctx.shadowColor = "#00aaff";
ctx.fillStyle = "rgba(100, 200, 255, 0.6)"; ctx.beginPath(); ctx.arc(this.cx, this.cy, this.r, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.arc(this.cx, this.cy, this.r * 0.4, 0, Math.PI*2); ctx.fill(); ctx.shadowBlur = 0;
};
el._init(cfg); return el;
};
// 2. Minutemen Militia Blockade
TD.MilitiaBlockade = function(id, cfg) {
var el = new TD.Element(id, cfg);
el._init = function(cfg) {
this.map = cfg.map; this.cx = cfg.cx; this.cy = cfg.cy;
this.max_life = 1000; this.life = this.max_life;
this.r = TD.grid_size * 1.0; this.is_valid = true;
this.addToScene(this.map.scene, 1, 4);
};
el.step = function() {
if (!this.is_valid) return;
var _this = this; var dummy = { killed: 0, gainXp: function(){} }; var isAttacked = false;
TD.lang.each(this.map.monsters, function(m) {
if (m.is_valid && Math.pow(m.cx - _this.cx, 2) + Math.pow(m.cy - _this.cy, 2) <= Math.pow(_this.r * 1.5, 2)) {
m.speed = 0.1; m.is_frozen = true; m.freeze_timer = 5; // Halt monster
_this.life -= m.damage; isAttacked = true;
if (TD.iframe % 12 === 0) m.beHit(dummy, 30); // Shoot back
}
});
if (isAttacked) TD.Explode("m-hit-"+TD.lang.rndStr(), {cx: this.cx, cy: this.cy, r: 10, step_level:1, render_level:9, color:"#ff0000", scene:this.map.scene, time:0.1});
if (this.life <= 0) this.is_valid = false;
};
el.render = function() {
if (!this.is_valid) return;
var ctx = TD.ctx; ctx.fillStyle = "#4a6887";
ctx.beginPath(); ctx.arc(this.cx - 10, this.cy + 5, 8, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(this.cx + 10, this.cy + 5, 8, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(this.cx, this.cy - 12, 8, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "#000"; ctx.fillRect(this.cx - 15, this.cy - 25, 30, 4);
ctx.fillStyle = "#0f0"; ctx.fillRect(this.cx - 15, this.cy - 25, 30 * (this.life/this.max_life), 4);
};
el._init(cfg); return el;
};
// 3. Raider Nuka-Grenade (Radiation Zone)
TD.RadZone = function(id, cfg) {
var el = new TD.Element(id, cfg);
el._init = function(cfg) {
this.map = cfg.map; this.cx = cfg.cx; this.cy = cfg.cy;
this.life = 10 * 24; this.r = TD.grid_size * 3.5; this.is_valid = true;
this.addToScene(this.map.scene, 1, 3);
TD.Explode("nuka-blast", {cx: this.cx, cy: this.cy, r: this.r, step_level:1, render_level:9, color:"#ffcc00", scene:this.map.scene, time:1.0});
var dummy = { killed: 0, gainXp: function(){} }; var _this = this;
TD.lang.each(this.map.monsters, function(m) {
if (m.is_valid && Math.pow(m.cx - _this.cx, 2) + Math.pow(m.cy - _this.cy, 2) <= Math.pow(_this.r, 2)) m.beHit(dummy, 600);
});
};
el.step = function() {
if (!this.is_valid) return;
this.life--; if (this.life <= 0) { this.is_valid = false; return; }
var dummy = { killed: 0, gainXp: function(){} }; var _this = this;
TD.lang.each(this.map.monsters, function(m) {
if (m.is_valid && Math.pow(m.cx - _this.cx, 2) + Math.pow(m.cy - _this.cy, 2) <= Math.pow(_this.r, 2)) m.beHit(dummy, 6); // DoT
});
};
el.render = function() {
if (!this.is_valid) return;
var ctx = TD.ctx; ctx.shadowBlur = 15; ctx.shadowColor = "#00ff00";
ctx.fillStyle = "rgba(50, 255, 50, 0.25)"; ctx.beginPath(); ctx.arc(this.cx, this.cy, this.r, 0, Math.PI*2); ctx.fill(); ctx.shadowBlur = 0;
};
el._init(cfg); return el;
};
// NEW: Hard Reset / Cancel Build Mode function
var forceCancelBuild = function(e) {
if (typeof TD === 'undefined' || !TD.stage) return;
// Only prevent right-click menu if we are actually cancelling something
var isDeploying = TD.mode && TD.mode.indexOf("deploy_") === 0;
if (e && e.type === "contextmenu" && (TD.mode === "build" || isDeploying || (TD.stage.current_act && TD.stage.current_act.current_scene.map.selected_building) || TD.current_editor_asset)) {
e.preventDefault();
}
try {
var map = TD.stage.current_act.current_scene.map;
if (TD.mode === "build") map.cancelPreBuild();
if (isDeploying) {
TD.mode = "normal";
TD.mouseHand(false);
var d = document.getElementById("ui-intel-brief");
if (d) d.innerText = "DEPLOYMENT CANCELLED.";
}
if (map.selected_building) {
map.selected_building.is_selected = false;
map.selected_building = null;
}
if (map.select_hl) map.select_hl.hide();
if (map.scene && map.scene.panel && map.scene.panel.balloontip) {
map.scene.panel.balloontip.hide();
}
// NEW: Clear the asset brush safely
if (TD.current_editor_asset) {
TD.current_editor_asset = null;
TD.admin_brush = "clear";
var d = document.getElementById("ui-action-desc");
if (d) d.innerText = "Brush cleared.";
}
} catch(err) {}
};
// Listeners
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") forceCancelBuild(e);
// --- NEW: DEV CHEAT WINDOW TOGGLE ---
if (e.key === "`" || e.key === "~") {
var w = document.getElementById("dev-cheat-window");
if (w) w.style.display = (w.style.display === "none") ? "block" : "none";
}
});
// Listeners
var btnVats = document.getElementById("btn-vats-activate");
if (btnVats) {
btnVats.onclick = function() {
if (typeof TD !== 'undefined') TD.startVats();
};
}
document.addEventListener("contextmenu", forceCancelBuild);
// --- NEW: DEV WINDOW LOGIC & DRAGGING ---
// Dragging Logic
var cheatWindow = document.getElementById("dev-cheat-window");
var cheatHeader = document.getElementById("dev-cheat-header");
if (cheatWindow && cheatHeader) {
var isDragging = false, offsetX, offsetY;
cheatHeader.addEventListener("mousedown", function(e) {
isDragging = true;
var rect = cheatWindow.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
});
document.addEventListener("mousemove", function(e) {
if (!isDragging) return;
cheatWindow.style.left = (e.clientX - offsetX) + "px";
cheatWindow.style.top = (e.clientY - offsetY) + "px";
cheatWindow.style.right = "auto";
cheatWindow.style.bottom = "auto";
});
document.addEventListener("mouseup", function() { isDragging = false; });
}
// Button Logic
document.getElementById("dev-btn-caps").onclick = function() {
if (typeof TD === 'undefined') return;
var val = parseInt(document.getElementById("dev-input-caps").value) || 0;
TD.money += val;
};
document.getElementById("dev-btn-score").onclick = function() {
if (typeof TD === 'undefined') return;
var val = parseInt(document.getElementById("dev-input-score").value) || 0;
TD.score += val;
};
document.getElementById("dev-btn-health").onclick = function() {
if (typeof TD === 'undefined') return;
var val = parseInt(document.getElementById("dev-input-health").value) || 100;
if (TD.stage && TD.stage.current_act && TD.stage.current_act.current_scene.map.map_type === "siege") {
TD.base_health = val;
} else {
TD.life = val;
}
};
document.getElementById("dev-btn-vats").onclick = function() {
if (typeof TD === 'undefined' || typeof TD.vats_charge === 'undefined') return;
var val = parseInt(document.getElementById("dev-input-vats").value) || 0;
if (val < 0) val = 0;
if (val > 100) val = 100;
TD.vats_charge = (val / 100) * TD.vats_max_charge;
};
document.getElementById("dev-btn-wave").onclick = function() {
if (typeof TD === 'undefined' || !TD.stage || !TD.stage.current_act || !TD.stage.current_act.current_scene) return;
var val = parseInt(document.getElementById("dev-input-wave").value) || 1;
if (val < 1) val = 1;
var scene = TD.stage.current_act.current_scene;
var activeMap = scene.map;
// Wipe existing monsters so waves don't mix
if (activeMap) {
TD.lang.each(activeMap.monsters, function(m) { m.is_valid = false; });
activeMap.monsters = [];
activeMap._wait_add_monsters = 0;
activeMap._wait_add_monsters_arr = [];
}
// Set internal wave to 1 less than target, so it spawns perfectly on next wave start
scene.wave = val - 1;
// FIX: Clear the wave generation lock so the Intel script and spawner recognize the jump!
scene._wave_generated_for = -1;
scene._intel_chatter_generated = false;
TD.stage.wait_new_wave = TD.exp_fps * 5; // Reset 5 second spawn timer
// Push the update directly to the Intel text to overwrite whatever was there
if (typeof TD.logEvent === 'function') TD.logEvent("OVERSEER OVERRIDE: INITIATING JUMP TO WAVE " + val);
};
document.getElementById("dev-btn-spawn").onclick = function() {
if (typeof TD === 'undefined' || !TD.stage || !TD.stage.current_act || !TD.stage.current_act.current_scene) return;
var val = parseInt(document.getElementById("dev-sel-spawn").value) || 0;
var map = TD.stage.current_act.current_scene.map;
if (map) {
map.addMonster(val);
}
};
document.getElementById("dev-btn-tower").onclick = function() {
if (typeof TD === 'undefined' || !TD.factionData || !TD.current_faction) return;
var val = document.getElementById("dev-sel-tower").value;
var towers = TD.factionData[TD.current_faction];
if (val === "all") {
for (var i = 0; i < towers.length; i++) towers[i].is_unlocked = true;
} else {
var idx = parseInt(val.replace("t", "")) - 1;
if (towers[idx]) towers[idx].is_unlocked = true;
}
TD.loadFaction(TD.current_faction);
};
document.getElementById("dev-btn-item").onclick = function() {
if (typeof TD === 'undefined' || !TD.inventory) return;
var item = document.getElementById("dev-sel-item").value;
var val = parseInt(document.getElementById("dev-input-item").value) || 1;
TD.inventory[item] += val;
if (TD.inventory[item] > 10) TD.inventory[item] = 10;
TD.updateItemButton(item);
};
document.getElementById("dev-btn-drop").onclick = function() {
window.devForcedDrop = document.getElementById("dev-sel-drop").value;
var d = document.getElementById("ui-intel-brief");
if (d) d.innerText = "NEXT DROP FORCED: " + window.devForcedDrop.toUpperCase();
};
// --- NEW: ASSET MODAL LOGIC ---
TD.populateAssetModal = function() {
var themeSelect = document.getElementById("asset-theme-select");
var gridContainer = document.getElementById("asset-grid-container");
if (!themeSelect || !gridContainer) return;
// Populate Theme Dropdown if empty
if (themeSelect.options.length === 0) {
for (var theme in TD.assetLibrary) {
var opt = document.createElement("option");
opt.value = theme;
opt.innerHTML = theme;
themeSelect.appendChild(opt);
}
// Add listener to change grid when theme changes
themeSelect.onchange = function() { TD.populateAssetModal(); };
}
var currentTheme = themeSelect.value;
var assets = TD.assetLibrary[currentTheme] || [];
// Clear existing grid
gridContainer.innerHTML = "";
// Build thumbnail cards
TD.lang.each(assets, function(asset) {
var card = document.createElement("div");
card.style.background = "#222";
card.style.border = "1px solid #0f0";
card.style.padding = "5px";
card.style.cursor = "pointer";
card.style.textAlign = "center";
card.style.display = "flex";
card.style.flexDirection = "column";
card.style.justifyContent = "space-between";
// Hover effect
card.onmouseover = function() { card.style.background = "#050"; };
card.onmouseout = function() { card.style.background = "#222"; };
// Image Thumbnail
var img = document.createElement("img");
img.src = asset.src;
img.style.maxWidth = "100%";
img.style.maxHeight = "80px";
img.style.objectFit = "contain";
img.style.marginBottom = "5px";
// Error fallback if image doesn't exist
img.onerror = function() { this.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"><rect width="50" height="50" fill="%23f00"/><text x="10" y="30" fill="white" font-family="sans-serif" font-size="20">?</text></svg>'; };
var label = document.createElement("div");
label.style.fontSize = "0.9em";
label.innerHTML = "<b>" + asset.name + "</b><br><span style='color:#aaa; font-size:0.8em;'>" + asset.grid_x + "x" + asset.grid_y + (asset.blocks_path ? " (Solid)" : "") + "</span>";
card.appendChild(img);
card.appendChild(label);
// Click to select
card.onclick = function() {
TD.current_editor_asset = asset;
TD.admin_brush = "asset"; // Set the engine brush mode to asset placing
// Close modal and set description
document.getElementById("asset-modal-overlay").style.display = "none";
var uiDesc = document.getElementById("ui-action-desc");
if (uiDesc) uiDesc.innerHTML = "ASSET BRUSH: Placing " + asset.name + " (" + asset.grid_x + "x" + asset.grid_y + "). Click map to drop.";
};
gridContainer.appendChild(card);
});
};
});
_TD.a.push(function (TD) {
// --- NEW: WAVE EDITOR LOGIC ---
TD.custom_wave_data = {};
for (var i = 1; i <= 15; i++) {
TD.custom_wave_data[i] = {
story_enable: false,
story_data: {
"ALL": { title: "", desc: "" },
"NCR": { title: "", desc: "" },
"BOS": { title: "", desc: "" },
"MINUTEMEN": { title: "", desc: "" },
"RAIDERS": { title: "", desc: "" }
},
towers: {}, // e.g. { "tower_1": "unlocked", "tower_2": "locked", "tower_3": "hidden" }
spawns: [] // e.g. [ { qty: 5, monster_idx: 0 } ]
};
}
TD.we_current_wave = 1;
TD.we_current_faction_tab = "ALL";
TD.we_refreshWaveList = function() {
var waveSelect = document.getElementById("wave-editor-select");
waveSelect.innerHTML = ""; // Clear existing options
var numWaves = Object.keys(TD.custom_wave_data).length;
if (numWaves === 0) {
// Failsafe
TD.custom_wave_data[1] = { story_enable: false, story_data: { "ALL": {title:"", desc:""}, "NCR": {title:"", desc:""}, "BOS": {title:"", desc:""}, "MINUTEMEN": {title:"", desc:""}, "RAIDERS": {title:"", desc:""} }, towers: {}, spawns: [] };
numWaves = 1;
}
for (var i = 1; i <= numWaves; i++) {
var opt = document.createElement("option");
opt.value = i;
opt.innerText = "WAVE " + i;
waveSelect.appendChild(opt);
}
if (TD.we_current_wave > numWaves) TD.we_current_wave = numWaves;
if (TD.we_current_wave < 1) TD.we_current_wave = 1;
waveSelect.value = TD.we_current_wave;
};
TD.populateWaveEditor = function() {
var waveSelect = document.getElementById("wave-editor-select");
if (!waveSelect.onchange) { // Only wire up events once
waveSelect.onchange = function() {
TD.we_saveCurrentWave();
TD.we_current_wave = parseInt(this.value);
TD.we_loadCurrentWave();
};
document.getElementById("we-btn-add-spawn").onclick = function() {
TD.custom_wave_data[TD.we_current_wave].spawns.push({ qty: 1, monster_idx: 0 });
TD.we_renderSpawns();
};
var facSelect = document.getElementById("we-story-faction");
if (facSelect) {
facSelect.onchange = function() {
TD.we_saveCurrentWave();
TD.we_current_faction_tab = this.value;
TD.we_loadCurrentWave();
};
}
// --- NEW: Add / Delete Wave Buttons ---
var btnAddWave = document.getElementById("we-btn-add-wave");
if (btnAddWave) {
btnAddWave.onclick = function() {
TD.we_saveCurrentWave();
var newWaveNum = Object.keys(TD.custom_wave_data).length + 1;
TD.custom_wave_data[newWaveNum] = {
story_enable: false,
story_data: { "ALL": {title:"", desc:""}, "NCR": {title:"", desc:""}, "BOS": {title:"", desc:""}, "MINUTEMEN": {title:"", desc:""}, "RAIDERS": {title:"", desc:""} },
towers: {}, spawns: []
};
TD.we_current_wave = newWaveNum;
TD.we_refreshWaveList();
TD.we_loadCurrentWave();
};
}
var btnDelWave = document.getElementById("we-btn-del-wave");
if (btnDelWave) {
btnDelWave.onclick = function() {
var numWaves = Object.keys(TD.custom_wave_data).length;
if (numWaves <= 1) {
alert("You cannot delete the last wave!");
return;
}
if (confirm("Are you sure you want to delete Wave " + TD.we_current_wave + "?")) {
// Shift all subsequent waves down by 1
for (var i = TD.we_current_wave; i < numWaves; i++) {
TD.custom_wave_data[i] = TD.custom_wave_data[i + 1];
}
// Delete the now-duplicate last element
delete TD.custom_wave_data[numWaves];
TD.we_refreshWaveList();
TD.we_loadCurrentWave();
}
};
}
// --- NEW: Copy Previous Wave Tower Settings ---
var btnCopyPrev = document.getElementById("we-btn-copy-prev");
if (btnCopyPrev) {
btnCopyPrev.onclick = function() {
if (TD.we_current_wave > 1) {
TD.we_saveCurrentWave(); // Save any story text first so we don't lose it!
var prevTowers = TD.custom_wave_data[TD.we_current_wave - 1].towers;
// Copy previous tower settings directly into the current wave's data
for (var t = 1; t <= 11; t++) {
TD.custom_wave_data[TD.we_current_wave].towers["tower_" + t] = prevTowers["tower_" + t] || "unlocked";
}
TD.we_loadCurrentWave(); // Instantly refresh UI to show the copied dropdowns
var d = document.getElementById("ui-action-desc");
if(d) d.innerText = "Tower settings copied from Wave " + (TD.we_current_wave - 1);
} else {
alert("There is no previous wave to copy from! (You are on Wave 1)");
}
};
}
}
TD.we_refreshWaveList();
// Build Tower List Framework (Only need to do this once)
var towerList = document.getElementById("we-tower-list");
if (towerList.children.length === 0) {
for (var t = 1; t <= 11; t++) {
var tName = "Tower " + t;
if (t === 11) tName = "Mr. Handy";
var row = document.createElement("div");
row.style.display = "flex"; row.style.justifyContent = "space-between"; row.style.alignItems = "center";
var lbl = document.createElement("label");
lbl.innerText = tName;
var sel = document.createElement("select");
sel.id = "we-tower-sel-" + t;
sel.style.background = "#222"; sel.style.color = "#0f0"; sel.style.border = "1px solid #0f0"; sel.style.padding = "2px";
sel.innerHTML = '<option value="unlocked">Unlocked</option><option value="locked">Locked</option><option value="hidden">Hidden</option>';
row.appendChild(lbl);
row.appendChild(sel);
towerList.appendChild(row);
}
}
TD.we_loadCurrentWave();
};
TD.we_loadCurrentWave = function() {
// Bulletproof safety fallback to prevent crashes if a map loaded incomplete data
if (!TD.custom_wave_data[TD.we_current_wave]) {
TD.custom_wave_data[TD.we_current_wave] = {
story_enable: false,
story_data: { "ALL": {title:"", desc:""}, "NCR": {title:"", desc:""}, "BOS": {title:"", desc:""}, "MINUTEMEN": {title:"", desc:""}, "RAIDERS": {title:"", desc:""} },
towers: {}, spawns: []
};
}
var data = TD.custom_wave_data[TD.we_current_wave];
// Backwards compatibility for data generated prior to the faction update
if (data.story_title !== undefined) {
data.story_data = {
"ALL": { title: data.story_title || "", desc: data.story_desc || "" },
"NCR": { title: "", desc: "" }, "BOS": { title: "", desc: "" }, "MINUTEMEN": { title: "", desc: "" }, "RAIDERS": { title: "", desc: "" }
};
delete data.story_title;
delete data.story_desc;
}
document.getElementById("we-story-enable").checked = data.story_enable;
var fData = data.story_data[TD.we_current_faction_tab] || { title: "", desc: "" };
document.getElementById("we-story-title").value = fData.title;
document.getElementById("we-story-desc").value = fData.desc;
for (var t = 1; t <= 11; t++) {
var sel = document.getElementById("we-tower-sel-" + t);
if (sel) sel.value = data.towers["tower_" + t] || "unlocked";
}
TD.we_renderSpawns();
};
TD.we_saveCurrentWave = function() {
var data = TD.custom_wave_data[TD.we_current_wave];
data.story_enable = document.getElementById("we-story-enable").checked;
// Save current faction tab text
if (!data.story_data) data.story_data = { "ALL": {title:"", desc:""}, "NCR": {title:"", desc:""}, "BOS": {title:"", desc:""}, "MINUTEMEN": {title:"", desc:""}, "RAIDERS": {title:"", desc:""} };
data.story_data[TD.we_current_faction_tab].title = document.getElementById("we-story-title").value;
data.story_data[TD.we_current_faction_tab].desc = document.getElementById("we-story-desc").value;
for (var t = 1; t <= 11; t++) {
var sel = document.getElementById("we-tower-sel-" + t);
if (sel) data.towers["tower_" + t] = sel.value;
}
};
TD.we_renderSpawns = function() {
var spawnList = document.getElementById("we-spawn-list");
spawnList.innerHTML = "";
var data = TD.custom_wave_data[TD.we_current_wave];
TD.lang.each(data.spawns, function(spawn, idx) {
var row = document.createElement("div");
row.style.display = "flex"; row.style.gap = "5px"; row.style.alignItems = "center"; row.style.background = "#222"; row.style.padding = "5px"; row.style.border = "1px solid #444";
var inpQty = document.createElement("input");
inpQty.type = "number"; inpQty.min = 1; inpQty.value = spawn.qty;
inpQty.style.width = "50px"; inpQty.style.background = "#111"; inpQty.style.color = "#0f0"; inpQty.style.border = "1px solid #0f0";
inpQty.onchange = function() { spawn.qty = parseInt(this.value); };
var selType = document.createElement("select");
selType.style.flex = 1; selType.style.background = "#111"; selType.style.color = "#0f0"; selType.style.border = "1px solid #0f0";
var totalMonsters = TD.getDefaultMonsterAttributes();
for (var m = 0; m < totalMonsters; m++) {
var mData = TD.getDefaultMonsterAttributes(m);
var opt = document.createElement("option");
opt.value = m; opt.innerText = mData.name;
selType.appendChild(opt);
}
selType.value = spawn.monster_idx;
selType.onchange = function() { spawn.monster_idx = parseInt(this.value); };
var btnDel = document.createElement("button");
btnDel.innerText = "X"; btnDel.style.background = "#500"; btnDel.style.color = "#f00"; btnDel.style.border = "1px solid #f00"; btnDel.style.cursor = "pointer";
btnDel.onclick = function() { data.spawns.splice(idx, 1); TD.we_renderSpawns(); };
row.appendChild(inpQty);
row.appendChild(document.createTextNode("x"));
row.appendChild(selType);
row.appendChild(btnDel);
spawnList.appendChild(row);
});
};
// --- END NEW LOGIC ---
// NEW: Floating Admin Cheat Window
// ==========================================
// NEW: ISOLATED SPECIAL EFFECTS MODULE
// ==========================================
TD.SpecialEffects = {
// Called once when an upgrade is purchased
onApply: function(building, effects) {
if (!effects) return;
building.has_special = true;
// Direct Stats
if (effects.damage_add) building.damage += effects.damage_add;
if (effects.range_add) {
building.range += effects.range_add;
building.range_px = building.range * TD.grid_size;
}
if (effects.damage_mult) building.damage = Math.floor(building.damage * effects.damage_mult);
if (effects.speed_mult) {
building.speed *= effects.speed_mult;
building._fire_wait2 = Math.floor(Math.max(TD.exp_fps / (building.speed * (TD.global_speed / 0.2)), 1));
}
// --- NEW: Flight Modifiers ---
if (effects.flight_speed_mult) building.flight_speed *= effects.flight_speed_mult;
if (effects.patrol_radius_add) building.flight_radius += (effects.patrol_radius_add * TD.grid_size);
if (effects.visual_type) {
// GAUSS RIFLE FIX: Keep the physical bullet logic but use a custom blue visual shot
if (effects.visual_type === "laser_gun" && effects.pierce) {
building.special_fire_pattern = "gauss";
} else {
building.visual_type = effects.visual_type;
}
}
// Combat Flags
if (effects.knockback) building.has_knockback = true;
if (effects.stun_chance) building.stun_chance = effects.stun_chance;
if (effects.pierce) building.pierce_count = effects.pierce;
if (effects.burst_count) building.burst_count = effects.burst_count;
if (effects.is_detector) building.is_detector = true;
// Firing Patterns
if (effects.fire_pattern) building.special_fire_pattern = effects.fire_pattern;
if (effects.pellet_count) building.special_pellet_count = effects.pellet_count;
// Special Active Abilities (Secondary weapons, Auras, etc.)
if (effects.secondary) { building.special_secondary = effects.secondary; building.special_secondary_cooldown = effects.secondary_cd || 5; }
if (effects.crit_req) { building.special_crit = true; building.special_crit_req = effects.crit_req; }
if (effects.dot_duration) { building.special_dot = true; building.special_dot_duration = effects.dot_duration; }
if (effects.chain_max) { building.special_chain = true; building.special_chain_max = effects.chain_max; }
// --- NEW: BONUS BOUNTY UPGRADE ---
if (effects.bonus_bounty) { building.bonus_bounty = true; }
// Dynamic Auras are now handled in TD.Building.prototype.step
if (effects.aura_type) {
building.aura_type = effects.aura_type;
}
// --- FIX: MISSING STAT MAPPINGS ---
if (effects.aura_vulnerability) building.aura_vulnerability = effects.aura_vulnerability;
if (effects.aura_slow) building.aura_slow = effects.aura_slow;
if (effects.aura_dot) building.aura_dot = effects.aura_dot;
if (effects.shatter_aoe) building.shatter_aoe = true;
if (effects.crit_chance) building.crit_chance = effects.crit_chance;
if (effects.crit_mult) building.crit_mult = effects.crit_mult;
if (effects.boss_damage_mult) building.boss_damage_mult = effects.boss_damage_mult;
},
// Called right before a bullet spawns
onFire: function(building) {
if (!building.special_fire_pattern) return false; // Use normal firing
var muzzle = building.muzzle || [building.cx, building.cy];
var cx = muzzle[0], cy = muzzle[1];
if (building.sound_type) TD.playSound(building.sound_type);
// GAUSS RIFLE: Fast physical blue bullet that tracks to target
if (building.special_fire_pattern === "gauss") {
new TD.Bullet(null, { building: building, damage: building.damage, target: building.target, speed: building.bullet_speed * 4.0, x: cx, y: cy, color: "#00aaff" });
return true;
}
if (building.special_fire_pattern === "flamer") {
var pellets = building.special_pellet_count || 5;
var spread = 45; // Wider cone spread
for (var p = 0; p < pellets; p++) {
var offset_x = (Math.random() * spread) - (spread / 2);
var offset_y = (Math.random() * spread) - (spread / 2);
var fake_target = {
cx: building.target.cx + offset_x,
cy: building.target.cy + offset_y,
is_valid: true,
is_fake: true
};
// Randomize speed and color for depth and texture
var rSpeed = building.bullet_speed * (0.5 + Math.random() * 0.7);
var colors = ["#ff5500", "#ffaa00", "#ff3300", "#ffff33"];
var rColor = colors[Math.floor(Math.random() * colors.length)];
new TD.Bullet(null, { building: building, damage: building.damage, target: fake_target, speed: rSpeed, x: cx, y: cy, color: rColor, is_flame: true, r: 4 });
}
return true;
}
if (building.special_fire_pattern === "shotgun") {
var pellets = building.special_pellet_count || 3;
// We create "fake" targets slightly offset from the real target to create a cone
var spread = 30; // Pixel spread
for (var p = 0; p < pellets; p++) {
var offset_x = (Math.random() * spread) - (spread / 2);
var offset_y = (Math.random() * spread) - (spread / 2);
var fake_target = {
cx: building.target.cx + offset_x,
cy: building.target.cy + offset_y,
is_valid: true,
is_fake: true
};
new TD.Bullet(null, { building: building, damage: building.damage, target: fake_target, speed: building.bullet_speed, x: cx, y: cy });
}
return true; // We handled the firing
}
if (building.special_fire_pattern === "twin") {
// Fire two bullets parallel to each other
var fake_target_1 = { cx: building.target.cx - 10, cy: building.target.cy - 10, is_valid: true, is_fake: true };
var fake_target_2 = { cx: building.target.cx + 10, cy: building.target.cy + 10, is_valid: true, is_fake: true };
new TD.Bullet(null, { building: building, damage: building.damage, target: fake_target_1, speed: building.bullet_speed, x: cx, y: cy });
new TD.Bullet(null, { building: building, damage: building.damage, target: fake_target_2, speed: building.bullet_speed, x: cx, y: cy });
return true;
}
if (building.special_fire_pattern === "mirv") {
// MIRV fires two mini-nukes
var fake_target_1 = { cx: building.target.cx - 30, cy: building.target.cy, is_valid: true, is_fake: true };
var fake_target_2 = { cx: building.target.cx + 30, cy: building.target.cy, is_valid: true, is_fake: true };
new TD.Bullet(null, { building: building, damage: building.damage, target: fake_target_1, speed: building.bullet_speed * 0.5, x: cx, y: cy });
new TD.Bullet(null, { building: building, damage: building.damage, target: fake_target_2, speed: building.bullet_speed * 0.5, x: cx, y: cy });
return true;
}
return false;
},
// Called every frame by the tower
onStep: function(building) {
if (!building || !building.is_valid || building.is_pre_building) return;
// Secondary Weapons (Grenades, Nukes, etc)
if (building.special_secondary) {
if (typeof building._sec_timer === 'undefined') building._sec_timer = 0;
building._sec_timer++;
var triggerTime = TD.exp_fps * (building.special_secondary_cooldown || 5);
// Some abilities trigger even without a target (like dropping napalm while flying)
var needsTarget = ["grenade", "nuke", "gas_grenade", "barricade_trap", "heavy_bomb"].includes(building.special_secondary);
var canFire = needsTarget ? (building.target && building.target.is_valid) : true;
if (building._sec_timer >= triggerTime && canFire) {
building._sec_timer = 0;
if (building.special_secondary === "grenade") {
var blast = TD.grid_size * 1.5;
TD.Explode("grenade-" + TD.lang.rndStr(), { cx: building.target.cx, cy: building.target.cy, r: blast / _TD.retina, step_level: 1, render_level: 9, color: "#ff8800", scene: building.map.scene, time: 0.3 });
var _b = building;
TD.lang.each(building.map.monsters, function(m) {
if (m.is_valid && Math.pow(m.cx - _b.target.cx, 2) + Math.pow(m.cy - _b.target.cy, 2) <= Math.pow(blast, 2)) m.beHit(_b, Math.floor(_b.damage * 0.5));
});
}
else if (building.special_secondary === "nuke") {
var blast = TD.grid_size * 5.0;
TD.Explode("mini-nuke-" + TD.lang.rndStr(), { cx: building.target.cx, cy: building.target.cy, r: blast / _TD.retina, step_level: 1, render_level: 9, color: "#ffff00", scene: building.map.scene, time: 1.0 });
var _b = building;
TD.lang.each(building.map.monsters, function(m) {
if (m.is_valid && Math.pow(m.cx - _b.target.cx, 2) + Math.pow(m.cy - _b.target.cy, 2) <= Math.pow(blast, 2)) m.beHit(_b, 1000);
});
}
else if (building.special_secondary === "liberty_laser") {
var _b = building;
TD.lang.each(building.map.monsters, function(m) {
if (m.is_valid && Math.pow(m.cx - _b.cx, 2) + Math.pow(m.cy - _b.cy, 2) <= Math.pow(_b.range_px, 2)) {
m.beHit(_b, 800);
TD.Explode("lib-hit-" + TD.lang.rndStr(), { cx: m.cx, cy: m.cy, r: 15 * _TD.retina, step_level: 1, render_level: 9, color: "#00aaff", scene: _b.map.scene, time: 0.2 });
}
});
}
else if (building.special_secondary === "emp") {
var _b = building;
TD.Explode("emp-pulse-" + TD.lang.rndStr(), { cx: _b.cx, cy: _b.cy, r: _b.range_px / _TD.retina, step_level: 1, render_level: 9, color: "#88ccff", scene: _b.map.scene, time: 0.5 });
TD.lang.each(building.map.monsters, function(m) {
if (m.is_valid && Math.pow(m.cx - _b.cx, 2) + Math.pow(m.cy - _b.cy, 2) <= Math.pow(_b.range_px, 2)) {
if (!m.is_frozen) m.original_speed = m.speed;
m.is_frozen = true;
m.speed = 0.1;
m.freeze_timer = TD.exp_fps * 1.0; // 1 second stun
}
});
}
else if (building.special_secondary === "scramble") {
var _b = building;
TD.Explode("scramble-pulse-" + TD.lang.rndStr(), { cx: _b.cx, cy: _b.cy, r: _b.range_px / _TD.retina, step_level: 1, render_level: 9, color: "#ff55ff", scene: _b.map.scene, time: 0.5 });
TD.lang.each(building.map.monsters, function(m) {
if (m.is_valid && Math.pow(m.cx - _b.cx, 2) + Math.pow(m.cy - _b.cy, 2) <= Math.pow(_b.range_px, 2)) {
// Invert their target grid logic so they walk backwards
var temp = m.grid;
m.grid = m.next_grid;
m.next_grid = temp;
m.freeze_timer = TD.exp_fps * 1.5; // Acts as a confusion timer before they recalculate
}
});
}
else if (building.special_secondary === "gas_grenade") {
var blast = TD.grid_size * 2.0;
TD.Explode("gas-" + TD.lang.rndStr(), { cx: building.target.cx, cy: building.target.cy, r: blast / _TD.retina, step_level: 1, render_level: 9, color: "#55ff55", scene: building.map.scene, time: 3.0 });
var _b = building;
TD.lang.each(building.map.monsters, function(m) {
if (m.is_valid && Math.pow(m.cx - _b.target.cx, 2) + Math.pow(m.cy - _b.target.cy, 2) <= Math.pow(blast, 2)) {
m.is_slowed = true;
m.slow_timer = TD.exp_fps * 3; // Slowed for 3 seconds
}
});
}
else if (building.special_secondary === "napalm_drop") {
// Drops fire directly beneath the flying Vertibird
new TD.RadZone("napalm-" + TD.lang.rndStr(), { map: building.map, cx: building.cx, cy: building.cy });
}
else if (building.special_secondary === "heavy_bomb") {
// Finds the leader and drops a nuke-sized explosion that only deals 300 damage
var leader = null;
var shortest_way = 999999;
TD.lang.each(building.map.monsters, function(m) {
if (m.is_valid && m.way.length < shortest_way) {
shortest_way = m.way.length;
leader = m;
}
});
if (leader) {
var blast = TD.grid_size * 3.0;
TD.playSound("snd_explosion");
TD.Explode("bomb-" + TD.lang.rndStr(), { cx: leader.cx, cy: leader.cy, r: blast / _TD.retina, step_level: 1, render_level: 9, color: "#ff5500", scene: building.map.scene, time: 0.6 });
var _b = building;
TD.lang.each(building.map.monsters, function(m) {
if (m.is_valid && Math.pow(m.cx - leader.cx, 2) + Math.pow(m.cy - leader.cy, 2) <= Math.pow(blast, 2)) m.beHit(_b, 300);
});
}
}
}
}
},
// Called when a bullet hits a monster. Returns 'false' if the bullet should keep flying (Pierce).
onHit: function(bullet, monster) {
var bld = bullet.building;
if (!bld || !bld.is_valid) return true;
// --- NEW: RNG Crits ---
if (bld.crit_chance && Math.random() <= bld.crit_chance) {
var mult = bld.crit_mult || 2.0;
bullet.damage = Math.floor(bullet.damage * mult);
new TD.FloatingText("crit-" + TD.lang.rndStr(), { cx: monster.cx, cy: monster.cy - 15, text: "CRIT!", color: "#ffaa00", map: bld.map, size: 12, life: 24 });
}
// --- NEW: Boss Damage Multiplier ---
if (bld.boss_damage_mult && monster.life0 >= 2000) {
bullet.damage = Math.floor(bullet.damage * bld.boss_damage_mult);
}
// 1. Stun Chance
if (bld.stun_chance) {
if (Math.random() <= bld.stun_chance) {
if (!monster.is_frozen) {
monster.is_frozen = true;
monster.original_speed = monster.speed;
monster.speed = 0.1;
}
monster.freeze_timer = TD.exp_fps * 1.5;
TD.Explode("stun-" + TD.lang.rndStr(), { cx: monster.cx, cy: monster.cy, r: 15 * _TD.retina, step_level: 1, render_level: 9, color: "#ffff00", scene: bld.map.scene, time: 0.2 });
}
}
// 2. Knockback (MULTIPLIER INCREASED TO 5.0)
if (bld.has_knockback && monster.life0 < 2000) {
monster.cx -= (bullet.vx * 5.0);
monster.cy -= (bullet.vy * 5.0);
monster.caculatePos();
}
// 3. Guaranteed Crits
if (bld.special_crit) {
if (typeof bld._shot_tracker === 'undefined') bld._shot_tracker = 0;
bld._shot_tracker++;
if (bld._shot_tracker >= bld.special_crit_req) {
bld._shot_tracker = 0;
bullet.damage *= 5;
new TD.FloatingText("crit-" + TD.lang.rndStr(), { cx: monster.cx, cy: monster.cy - 15, text: "CRIT!", color: "#ff0000", map: bld.map, size: 14, life: 24 });
TD.Explode("crit-blast-" + TD.lang.rndStr(), { cx: monster.cx, cy: monster.cy, r: 15 * _TD.retina, step_level: 1, render_level: 9, color: "#ff0000", scene: bld.map.scene, time: 0.2 });
}
}
// 4. DoTs
if (bld.special_dot) {
monster.is_poisoned = true;
monster.poison_timer = TD.exp_fps * (bld.special_dot_duration || 2);
monster.poison_damage = Math.floor(bullet.damage * 0.2); // 20% of bullet damage per tick
if (monster.poison_damage < 1) monster.poison_damage = 1;
TD.Explode("fire-" + TD.lang.rndStr(), { cx: monster.cx, cy: monster.cy, r: monster.r * 1.5, step_level: 1, render_level: 9, color: "#ff8800", scene: bld.map.scene, time: 0.2 });
}
// 5. Chain Lightning
if (bld.special_chain) {
var hit_count = 0;
TD.lang.each(bld.map.monsters, function(m) {
if (m.is_valid && m !== monster && hit_count < bld.special_chain_max) {
if (Math.pow(m.cx - monster.cx, 2) + Math.pow(m.cy - monster.cy, 2) <= Math.pow(TD.grid_size * 2.5, 2)) {
m.beHit(bld, Math.floor(bullet.damage * 0.5));
var ctx = TD.ctx;
ctx.lineWidth = 2 * _TD.retina; ctx.strokeStyle = "rgba(0, 255, 255, 0.8)";
ctx.beginPath(); ctx.moveTo(monster.cx, monster.cy); ctx.lineTo(m.cx, m.cy); ctx.stroke();
hit_count++;
}
}
});
}
// 6. Pierce Logic (Returns false so bullet doesn't die)
if (bullet.pierce_count && bullet.pierce_count > 1) {
bullet.pierce_count--;
return false;
}
return true;
}
};
});
// NEW: Global Tooltip Mouse Tracker
document.addEventListener("mousemove", function(e) {
var tooltip = document.getElementById("tower-tooltip");
if (tooltip && tooltip.style.display === "block") {
var x = e.pageX + 15;
var y = e.pageY + 15;
// Bounding Box Logic: Stop it from bleeding off the right or bottom edges
var tw = tooltip.offsetWidth;
var th = tooltip.offsetHeight;
if (x + tw > window.innerWidth) { x = e.pageX - tw - 15; }
if (y + th > window.innerHeight) { y = window.innerHeight - th - 5; }
tooltip.style.left = x + "px";
tooltip.style.top = y + "px";
}
});
// NEW: Reusable Panel Ignition Function
window.triggerPanelIgnition = function() {
document.getElementById("ui-money-container").innerHTML = "";
document.getElementById("ui-score-container").innerHTML = "";
document.getElementById("ui-life-container").innerHTML = "";
document.getElementById("ui-wave-container").innerHTML = "";
var btns = document.querySelectorAll(".cmd-btn, .sys-btn");
for (var i = 0; i < btns.length; i++) {
var btn = btns[i];
// Only ignite if the button is NOT locked or manually turned off
if (!btn.disabled && !btn.classList.contains("unlit")) {
btn.classList.remove("warm-up-1", "warm-up-2", "warm-up-3", "warm-up-4", "warm-up-5");
void btn.offsetWidth; // Force CSS reflow to restart the animation
btn.classList.add("warm-up-" + (Math.floor(Math.random() * 5) + 1));
}
}
};
window.onload = function () { // --- DEMO MODE INIT ---
var board = document.getElementById("td-board");
board.style.display = "block";
board.classList.add("demo-blur");
// Hide UI elements during demo
var controlPanel = document.getElementById("control-panel");
if (controlPanel) controlPanel.style.display = "none";
var topBar = document.getElementById("top-bar");
if (topBar) topBar.style.display = "none";
window.selectedLevelName = "demo_map_1";
window.is_demo_mode = true;
// Expose TD to the window so the start button can restart the engine
_TD.a.push(function(TD) { window.TD = TD; });
_TD.init("td-board", true);
// --- NEW MAIN MENU LOGIC ---
var gameTypeSelect = document.getElementById("gametype-select");
var mapGroup = document.getElementById("map-selection-group");
var specialGroup = document.getElementById("special-selection-group");
var updatePlayButtonLighting = function() {
var btnStart = document.getElementById("btn-start");
var isEndless = gameTypeSelect && gameTypeSelect.value === "endless";
var checkedCount = document.querySelectorAll(".special-chk:checked").length;
if (btnStart) {
if (isEndless || checkedCount === 3) {
btnStart.classList.add("btn-lit");
} else {
btnStart.classList.remove("btn-lit");
}
}
};
// 1. Define the isolated dynamic lighting logic
var updatePlayButtonLighting = function() {
var btnStart = document.getElementById("btn-start");
var isCampaign = gameTypeSelect && gameTypeSelect.value === "campaign";
var checkedCount = document.querySelectorAll(".special-chk:checked").length;
if (btnStart) {
// Unlock if NOT campaign, OR if campaign has 3 perks selected
if (!isCampaign || checkedCount === 3) {
if (!btnStart.classList.contains("menu-play-ready")) {
btnStart.classList.add("menu-play-ready");
btnStart.disabled = false;
btnStart.style.cursor = "pointer";
}
} else {
btnStart.classList.remove("menu-play-ready");
btnStart.disabled = true;
btnStart.style.cursor = "not-allowed";
}
}
};
// 2. Bind the dropdown change listener
if (gameTypeSelect) {
gameTypeSelect.addEventListener("change", function() {
if (this.value === "campaign") {
mapGroup.style.display = "none"; specialGroup.style.display = "block";
} else if (this.value === "custom") {
mapGroup.style.display = "none"; specialGroup.style.display = "none"; // Blank Slate
} else {
mapGroup.style.display = "block"; specialGroup.style.display = "none";
}
updatePlayButtonLighting();
});
}
// 3. Bind the S.P.E.C.I.A.L. checkbox listeners
var specialCheckboxes = document.querySelectorAll(".special-chk");
for (var i = 0; i < specialCheckboxes.length; i++) {
specialCheckboxes[i].addEventListener("change", function() {
var checkedCount = document.querySelectorAll(".special-chk:checked").length;
for (var j = 0; j < specialCheckboxes.length; j++) {
var box = specialCheckboxes[j];
if (checkedCount >= 3) {
if (!box.checked) box.disabled = true;
} else {
box.disabled = false;
}
}
updatePlayButtonLighting();
});
}
// 4. Set initial lighting state on load
updatePlayButtonLighting();
// --- NEW: Wasteland Guide Logic ---
var guideIcon = document.getElementById("guide-icon");
if (guideIcon) {
guideIcon.onclick = function() {
document.getElementById("guide-modal-overlay").style.display = "flex";
// Auto-populate the monster list on the right page
var listContainer = document.getElementById("guide-monster-list");
if (listContainer && TD.getDefaultMonsterAttributes) {
listContainer.innerHTML = ""; // clear it
var totalMonsters = TD.getDefaultMonsterAttributes(); // gets the count
for (var i = 0; i < totalMonsters; i++) {
var mData = TD.getDefaultMonsterAttributes(i);
var html = "<div style='margin-bottom: 20px; padding-bottom: 10px; border-bottom: 1px dashed rgba(0,0,0,0.2);'>";
var tags = [];
if (mData.is_flying) tags.push("<strong>[FLYING]</strong>");
if (mData.is_stealthed) tags.push("<strong>[STEALTH]</strong>");
if (mData.speed >= 15) tags.push("[FAST]");
if (mData.life >= 500) tags.push("<strong>[VERY STRONG]</strong>");
if (mData.life >= 2000) tags.push("<strong style='color:#800;'>[BOSS]</strong>");
html += "<h3>" + mData.name.toUpperCase() + "</h3> " + tags.join(" ") + "<br>";
html += "<strong>HP:</strong> " + mData.life + " | <strong>Speed:</strong> " + mData.speed + " | <strong>Dmg:</strong> " + mData.damage + "<br>";
html += "<em style='color: #444;'>" + (mData.desc || "A dangerous creature of the wastes.") + "</em>";
html += "</div>";
listContainer.innerHTML += html;
}
}
};
}
// --- END NEW MAIN MENU LOGIC ---
// 2. Bind the dropdown change listener
if (gameTypeSelect) {
gameTypeSelect.addEventListener("change", function() {
if (this.value === "campaign") {
mapGroup.style.display = "none";
specialGroup.style.display = "block";
} else {
mapGroup.style.display = "block";
specialGroup.style.display = "none";
}
updatePlayButtonLighting();
});
}
// 3. Bind the S.P.E.C.I.A.L. checkbox listeners
var specialCheckboxes = document.querySelectorAll(".special-chk");
for (var i = 0; i < specialCheckboxes.length; i++) {
specialCheckboxes[i].addEventListener("change", function() {
var checkedCount = document.querySelectorAll(".special-chk:checked").length;
for (var j = 0; j < specialCheckboxes.length; j++) {
var box = specialCheckboxes[j];
if (checkedCount >= 3) {
if (!box.checked) box.disabled = true;
} else {
box.disabled = false;
}
}
updatePlayButtonLighting();
});
}
// Dynamically generate the Admin Dropdown menu by reading the internal StageData
var populateAdminDropdown = function() {
var adminSelect = document.getElementById("admin-level-select");
if (!adminSelect) return;
// Scan the entire document's source code for the map definitions
var dummyData = document.documentElement.innerHTML;
// Look for scene_level, campaign_map, and demo_map strings
var sceneNames = dummyData.match(/scene_level_\d+/g) || [];
var campaignNames = dummyData.match(/campaign_map_\d+/g) || [];
var demoNames = dummyData.match(/demo_map_\d+/g) || [];
var levelNames = sceneNames.concat(campaignNames, demoNames);
// Force all 16 campaign maps to always exist in the dropdown so you can build and export them
for (var c = 1; c <= 16; c++) {
levelNames.push("campaign_map_" + c);
}
if (levelNames.length === 0) levelNames = ["scene_level_1", "scene_level_2"];
// Remove duplicates
levelNames = levelNames.filter(function(item, pos) { return levelNames.indexOf(item) == pos; });
adminSelect.innerHTML = "";
for(var i=0; i<levelNames.length; i++) {
var opt = document.createElement("option");
opt.value = levelNames[i];
opt.innerText = levelNames[i].toUpperCase();
adminSelect.appendChild(opt);
}
};
populateAdminDropdown(); // Run the scan when the page loads
var populateAdminTowers = function() {
var sel = document.getElementById("admin-tower-select");
if (!sel || !TD.factionData) return;
sel.innerHTML = "";
var grpAdap = document.createElement("optgroup");
grpAdap.label = "ADAPTIVE (Matches Player Faction)";
for (var i = 1; i <= 11; i++) {
var opt = document.createElement("option");
opt.value = "tower_" + i;
opt.innerText = "Slot " + i + (i === 11 ? " (Mr. Handy)" : "");
grpAdap.appendChild(opt);
}
sel.appendChild(grpAdap);
for (var f in TD.factionData) {
if (TD.factionData.hasOwnProperty(f)) {
var grp = document.createElement("optgroup");
grp.label = f + " SPECIFIC";
var list = TD.factionData[f];
for (var j = 0; j < list.length; j++) {
var t = list[j];
var opt = document.createElement("option");
opt.value = t.id;
opt.innerText = t.name;
grp.appendChild(opt);
}
sel.appendChild(grp);
}
}
};
populateAdminTowers();
// NEW: Reusable panel Ignition Function
window.triggerPanelIgnition = function() {
// Clear Nixie containers to force a rebuild
var containers = ["ui-money-container", "ui-score-container", "ui-life-container", "ui-wave-container"];
for (var i = 0; i < containers.length; i++) {
var c = document.getElementById(containers[i]);
if (c) c.innerHTML = "";
}
// Apply random flicker profiles to ENABLED buttons only
var buttons = document.querySelectorAll(".cmd-btn, .sys-btn");
for (var j = 0; j < buttons.length; j++) {
var btn = buttons[j];
// Clear memory on full system reboot
btn.removeAttribute("data-warmed-up");
// Strip out any old warm-up classes first
btn.className = btn.className.replace(/warm-up-[\w-]+/g, "").trim();
// Only ignite if the button is physically enabled and NOT locked
if (!btn.disabled && !btn.classList.contains("unlit")) {
void btn.offsetWidth; // Force CSS reflow
var roll = Math.random() * 100;
if (roll > 20) {
// 80%: Instant on
} else if (roll > 5) {
// 15%: Random jagged flicker
btn.classList.add("warm-up-" + (Math.floor(Math.random() * 5) + 1));
} else {
// 5%: Smooth fade
btn.classList.add("warm-up-fade");
}
btn.setAttribute("data-warmed-up", "true"); // Tag it
}
}
};
var btnDeploy = document.getElementById("campaign-btn-deploy");
if (btnDeploy) {
btnDeploy.addEventListener("click", function() {
document.getElementById("campaign-intro-modal-overlay").style.display = "none";
// Release the deep pause and JUMPSTART the engine loop
TD.is_paused = false;
if (typeof TD.step === 'function') TD.step(); // <--- This wakes the clock back up!
if (TD.stage && TD.stage.current_act && TD.stage.current_act.current_scene) {
TD.stage.current_act.current_scene.start();
}
});
}
// --- NEW: Watcher for buttons gaining/losing power ---
var origDisabled = Object.getOwnPropertyDescriptor(HTMLButtonElement.prototype, 'disabled');
if (origDisabled) {
Object.defineProperty(HTMLButtonElement.prototype, 'disabled', {
get: function() { return origDisabled.get.call(this); },
set: function(val) {
origDisabled.set.call(this, val);
if (val) this.setAttribute('disabled', 'disabled');
else this.removeAttribute('disabled');
}
});
}
var panelObserver = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.attributeName === "disabled") {
var btn = mutation.target;
if (btn.classList.contains("cmd-btn") || btn.classList.contains("sys-btn")) {
btn.className = btn.className.replace(/warm-up-[\w-]+/g, "").trim(); // Always clear old flickers
// If it just gained power, ignite it
if (!btn.disabled && !btn.classList.contains("unlit")) {
void btn.offsetWidth;
// Only run RNG if it hasn't warmed up yet!
if (!btn.getAttribute("data-warmed-up")) {
var roll = Math.random() * 100;
if (roll > 20) {
// 80%: Instant on
} else if (roll > 5) {
// 15%: Flicker
btn.classList.add("warm-up-" + (Math.floor(Math.random() * 5) + 1));
} else {
// 5%: Fade
btn.classList.add("warm-up-fade");
}
btn.setAttribute("data-warmed-up", "true");
}
}
}
}
});
});
panelObserver.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['disabled'] });
// ------------------------------------------------
document.getElementById("btn-start").addEventListener("click", function() {
// Capture Game Type
var gameType = document.getElementById("gametype-select");
window.is_campaign_mode = (gameType && gameType.value === "campaign");
window.is_custom_mode = (gameType && gameType.value === "custom");
window.is_assault_mode = (gameType && gameType.value === "assault");
// Capture S.P.E.C.I.A.L. choices
window.selectedSPECIALs = [];
var checkedSpecials = document.querySelectorAll(".special-chk:checked");
for (var i = 0; i < checkedSpecials.length; i++) {
window.selectedSPECIALs.push(checkedSpecials[i].value);
}
// Map Selection Logic
if (window.is_campaign_mode) {
window.selectedLevelName = "campaign_map_1"; // Default start for campaign
window.campaign_map_index = 1; // Explicitly set the index!
} else {
window.selectedLevelName = document.getElementById("level-select").value;
}
window.selectedFaction = document.getElementById("faction-select").value;
window.customDifficulty = parseFloat(document.getElementById("difficulty-select").value);
document.getElementById("start-screen").style.display = "none";
document.getElementById("top-bar").style.display = "flex";
var board = document.getElementById("td-board");
board.style.display = "block";
board.style.transition = "filter 0.5s ease"; // Add fade transition only for un-blurring
board.classList.remove("demo-blur");
document.getElementById("control-panel").style.display = "flex";
// Stop demo and start real game
window.is_demo_mode = false;
TD.stage.clear();
window.triggerPanelIgnition();
TD.total_shots = 0; // Reset shots for new game
TD.start();
// Start the battle music!
if (typeof TD.playMusic === 'function') {
TD.playMusic("bgm_game");
}
// --- NEW: Global Starting Caps Doubler ---
if (typeof TD.money !== "undefined") {
TD.money = Math.floor(TD.money * 2.0);
}
// Apply Global Passives (Endurance, Agility Items)
if (TD.perks) {
if (TD.perks.E) { TD.life = Math.floor(TD.life * 1.5); TD.base_health = Math.floor(TD.base_health * 1.5); }
if (TD.perks.A) TD.getCooldownFrames = function() { var base = (TD.difficulty < 1) ? 24 : (TD.difficulty > 1) ? 288 : 120; return Math.floor(base * 0.85); };
}
});
// --- NEW: LOAD GAME LOGIC ---
var btnLoadGame = document.getElementById("btn-load-game");
var inputLoadGame = document.getElementById("input-load-game");
if (btnLoadGame && inputLoadGame) {
btnLoadGame.addEventListener("click", function() {
inputLoadGame.click(); // Trigger the hidden file input
});
inputLoadGame.addEventListener("change", function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(evt) {
try {
var saveData = JSON.parse(evt.target.result);
// 1. Transition UI
document.getElementById("start-screen").style.display = "none";
document.getElementById("top-bar").style.display = "flex";
var board = document.getElementById("td-board");
board.style.display = "block";
board.classList.remove("demo-blur");
document.getElementById("control-panel").style.display = "flex";
// 2. Set Global Configurations
window.is_demo_mode = false;
window.selectedSPECIALs = saveData.specials || [];
window.selectedFaction = saveData.faction || "NCR";
TD.current_faction = window.selectedFaction;
TD.difficulty = saveData.difficulty || 1.0;
// 3. Start Engine
TD.stage.clear();
window.triggerPanelIgnition();
TD.start();
// 4. Inject Saved Stats
TD.money = saveData.money || 0;
TD.score = saveData.score || 0;
if (TD.stage && TD.stage.current_act && TD.stage.current_act.current_scene) {
TD.stage.current_act.current_scene.wave = saveData.wave || 0;
}
// 5. Restore Unlocked Towers
if (TD.factionData && TD.factionData[TD.current_faction] && saveData.towers_unlocked) {
for (var i = 0; i < saveData.towers_unlocked.length; i++) {
if (TD.factionData[TD.current_faction][i]) {
TD.factionData[TD.current_faction][i].is_unlocked = saveData.towers_unlocked[i];
}
}
}
TD.loadFaction(TD.current_faction);
// Start the battle music!
if (typeof TD.playMusic === 'function') {
TD.playMusic("bgm_game");
}
} catch(err) {
alert("ERROR: Save file corrupted or invalid JSON.");
console.error(err);
}
};
reader.readAsText(file);
});
}
// --- NEW: CAMPAIGN PROCEED BUTTON LOGIC ---
var btnProceed = document.getElementById("campaign-btn-proceed");
if (btnProceed) {
btnProceed.addEventListener("click", function() {
if (window.campaign_map_index >= 16) {
window.location.reload(); // Game won, back to main menu
return;
}
// Chapter Finale Check
if (window.campaign_map_index % 4 === 0) {
// Save S.P.E.C.I.A.L. Perk
var chosenPerk = document.getElementById("campaign-special-select").value;
if (window.selectedSPECIALs.indexOf(chosenPerk) === -1) {
window.selectedSPECIALs.push(chosenPerk);
}
// Relock Towers (Keep first 4 base towers unlocked)
if (TD.factionData && TD.current_faction) {
var towers = TD.factionData[TD.current_faction];
for (var i = 0; i < towers.length; i++) {
towers[i].is_unlocked = (i < 4);
}
TD.loadFaction(TD.current_faction);
}
}
var m = TD.stage.current_act.current_scene.map;
// 1. Wipe the board
TD.lang.each(m.buildings, function(b) { b.grid.removeBuilding(); }); m.buildings = [];
TD.lang.each(m.monsters, function(mob) { mob.is_valid = false; }); m.monsters = [];
TD.lang.each(m.bullets, function(bul) { bul.is_valid = false; }); m.bullets = [];
m.has_weapon = false;
// 2. Load next map
window.campaign_map_index++;
var nextLevel = "campaign_map_" + window.campaign_map_index;
var cfg = TD.getDefaultStageData(nextLevel);
if (cfg && cfg.map) {
if (cfg.bg_image) document.getElementById("td-board").style.backgroundImage = "url('" + cfg.bg_image + "')";
m.map_type = cfg.map.map_type || "traditional";
// Reset grids
TD.lang.each(m.grids, function(g) { g.passable_flag = 1; g.build_flag = 1; g.is_entrance = false; g.is_exit = false; });
// Apply new grid config
if (cfg.map.grids_cfg) {
TD.lang.each(cfg.map.grids_cfg, function (obj) {
var grid = m.getGrid(obj.pos[0], obj.pos[1]);
if (grid) {
if (!isNaN(obj.passable_flag)) grid.passable_flag = obj.passable_flag;
if (!isNaN(obj.build_flag)) grid.build_flag = obj.build_flag;
if (obj.building) grid.addBuilding(obj.building);
}
});
}
if (cfg.map.entrance) { var eg = m.getGrid(cfg.map.entrance[0], cfg.map.entrance[1]); if(eg) { eg.is_entrance = true; eg.passable_flag = 1; eg.build_flag = 0; m.entrance = eg; } }
if (cfg.map.exit) { var xg = m.getGrid(cfg.map.exit[0], cfg.map.exit[1]); if(xg) { xg.is_exit = true; xg.passable_flag = 1; xg.build_flag = 0; m.exit = xg; } }
}
// 3. Reset Engine States
TD.stage.current_act.current_scene.wave = 0;
TD.stage.wait_new_wave = TD.exp_fps * 5; // 5-second start timer
document.getElementById("campaign-modal-overlay").style.display = "none";
TD.stage.current_act.current_scene.start(); // Unpause engine
});
}
};
</script>
<!-- ========================================== -->
<!-- NEW: TOWER TOOLTIP & MODAL SYSTEM -->
<!-- ========================================== -->
<!-- The Hover Tooltip -->
<div id="tower-tooltip" style="display: none; position: absolute; z-index: 9999; background: rgba(0,20,0,0.9); color: #0f0; padding: 10px; border: 1px solid #0f0; border-radius: 4px; pointer-events: none; font-family: 'Courier New', monospace; box-shadow: 0 0 10px rgba(0,255,0,0.2); font-size: 12px; white-space: pre-line;">
<!-- Data injected via JS -->
</div>
<!-- NEW: V.A.T.S. Targeting Box -->
<div id="vats-tooltip" style="display: none; position: absolute; z-index: 10000; width: 60px; height: 45px; background: rgba(0, 40, 0, 0.4); pointer-events: none; border: 1px solid rgba(0, 255, 0, 0.1);">
<!-- Full Brackets -->
<div style="position: absolute; top: -2px; bottom: -2px; left: -2px; width: 10px; border-top: 3px solid #0f0; border-bottom: 3px solid #0f0; border-left: 3px solid #0f0;"></div>
<div style="position: absolute; top: -2px; bottom: -2px; right: -2px; width: 10px; border-top: 3px solid #0f0; border-bottom: 3px solid #0f0; border-right: 3px solid #0f0;"></div>
<!-- Health Bar (Top) -->
<div style="position: absolute; top: 4px; left: 50%; transform: translateX(-50%); width: 80%; height: 10px; border: 1px solid #0f0; background: rgba(0,0,0,0.8);">
<div id="vats-tooltip-hp" style="width: 100%; height: 100%; background: #0f0; transition: width 0.1s;"></div>
</div>
<!-- Percentage (Bottom) -->
<div id="vats-tooltip-perc" style="position: absolute; bottom: 4px; width: 100%; text-align: center; color: #0f0; font-family: 'Courier New', monospace; font-weight: bold; font-size: 16px; text-shadow: 1px 1px 2px #000;">
100%
</div>
</div>
<!-- The Click Modal Overlay -->
<div id="tower-modal-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: transparent; z-index: 99999; justify-content: center; align-items: center;">
<div id="tower-modal" style="background: #111; color: #0f0; padding: 20px; border: 2px solid #0f0; border-radius: 8px; width: 450px; font-family: 'Courier New', monospace; box-shadow: 0 0 20px rgba(0,255,0,0.4);">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px dashed #0f0; padding-bottom: 10px; margin-bottom: 15px;">
<h2 id="modal-tower-name" style="margin: 0; font-size: 1.4em; text-transform: uppercase;">Tower Name</h2>
<button id="modal-close-btn" style="background: transparent; border: none; color: #0f0; font-size: 1.5em; cursor: pointer;">&times;</button>
</div>
<!-- Stats & Image -->
<div style="display: flex; gap: 15px; margin-bottom: 20px;">
<div style="width: 64px; height: 64px; border: 1px solid #333; background: #000; display: flex; justify-content: center; align-items: center; font-size: 24px;">
<div id="modal-tower-img" style="color: #fff;">T</div>
</div>
<div id="modal-tower-stats" style="font-size: 0.9em; line-height: 1.6; flex: 1;">
<!-- Stats injected via JS -->
</div>
</div>
<!-- Targeting Selection -->
<div style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between;">
<label for="modal-target-mode" style="font-size: 0.9em; font-weight: bold;">TARGETING:</label>
<select id="modal-target-mode" style="background: #222; color: #0f0; border: 1px solid #0f0; padding: 5px; font-family: 'Courier New', monospace; flex: 0.8;">
<option value="closest">Closest</option>
<option value="leader">Leader</option>
<option value="strongest">Strongest</option>
<option value="weakest">Weakest</option>
</select>
</div>
<!-- NEW: UPGRADE PATHS (BTD5 Style) -->
<div style="border-top: 1px dashed #0f0; padding-top: 15px; margin-bottom: 15px;">
<!-- Path 1 -->
<div style="display: flex; flex-direction: column; margin-bottom: 10px;">
<div style="display: flex; justify-content: flex-end; gap: 4px; margin-bottom: 4px;" id="path1-indicators">
<!-- 4 boxes injected via JS -->
</div>
<button id="modal-btn-path1" class="cmd-btn" style="width: 100%; display: flex; flex-direction: column; align-items: flex-start; background-color: #2a7a2a !important; border: 2px solid #0f0 !important; cursor: pointer; padding: 10px; height: auto; text-align: left;">
<div style="display: flex; justify-content: space-between; width: 100%; font-size: 1.1em; font-weight: bold; margin-bottom: 5px; color: #000 !important; text-shadow: none !important;">
<span id="path1-name">Upgrade Name</span>
<span id="path1-cost" style="color: #000 !important; text-shadow: none !important;">0c</span>
</div>
<div id="path1-desc" style="font-size: 0.85em; font-weight: bold; white-space: normal; color: #111 !important; text-shadow: none !important;">
Upgrade description goes here.
</div>
</button>
</div>
<!-- Path 2 -->
<div style="display: flex; flex-direction: column;">
<div style="display: flex; justify-content: flex-end; gap: 4px; margin-bottom: 4px;" id="path2-indicators">
<!-- 4 boxes injected via JS -->
</div>
<button id="modal-btn-path2" class="cmd-btn" style="width: 100%; display: flex; flex-direction: column; align-items: flex-start; background-color: #2a7a2a !important; border: 2px solid #0f0 !important; cursor: pointer; padding: 10px; height: auto; text-align: left;">
<div style="display: flex; justify-content: space-between; width: 100%; font-size: 1.1em; font-weight: bold; margin-bottom: 5px; color: #000 !important; text-shadow: none !important;">
<span id="path2-name">Upgrade Name</span>
<span id="path2-cost" style="color: #000 !important; text-shadow: none !important;">0c</span>
</div>
<div id="path2-desc" style="font-size: 0.85em; font-weight: bold; white-space: normal; color: #111 !important; text-shadow: none !important;">
Upgrade description goes here.
</div>
</button>
</div>
</div>
<!-- Sell Button (Pushed to bottom) -->
<button id="modal-btn-sell" class="cmd-btn" style="width: 100%; background: #330000; color: #ff3333; border: 1px solid #ff3333; cursor: pointer; padding: 10px;">SELL TOWER (0c)</button>
</div>
</div>
<!-- Campaign Transition Modal -->
<div id="campaign-modal-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,20,0,0.9); z-index: 99999; justify-content: center; align-items: center; backdrop-filter: blur(4px);">
<div style="background: #111; color: #0f0; padding: 40px; border: 2px solid #0f0; border-radius: 8px; width: 450px; text-align: center; font-family: 'Courier New', monospace; box-shadow: 0 0 30px rgba(0,255,0,0.5);">
<h1 id="campaign-modal-title" style="margin-top: 0; font-size: 2.5em; text-transform: uppercase;">AREA CLEARED</h1>
<p id="campaign-modal-desc" style="font-size: 1.2em; margin-bottom: 20px;">Threat neutralized. Moving to the next sector.</p>
<!-- Hidden by default, shown at the end of a Chapter -->
<div id="campaign-special-reward" style="display: none; background: #0a220a; padding: 15px; border: 1px dashed #0f0; margin-bottom: 20px;">
<h3 style="margin-top: 0; color: #fff;">CHAPTER COMPLETE</h3>
<p style="font-size: 0.9em; margin-bottom: 10px;">Select a permanent S.P.E.C.I.A.L. perk:</p>
<select id="campaign-special-select" style="background: #222; color: #0f0; border: 1px solid #0f0; padding: 10px; font-size: 1.2em; font-family: 'Courier New', monospace; width: 100%;">
<option value="S">Strength</option>
<option value="P">Perception</option>
<option value="E">Endurance</option>
<option value="C">Charisma</option>
<option value="I">Intelligence</option>
<option value="A">Agility</option>
<option value="L">Luck</option>
</select>
</div>
<button id="campaign-btn-proceed" class="cmd-btn sys-btn" style="padding: 15px 30px; background: #2a7a2a; color: #fff; font-size: 1.5em; width: 100%; cursor: pointer;">PROCEED</button>
</div>
</div>
<!-- NEW: Campaign Story Intro Modal -->
<div id="campaign-intro-modal-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.95); z-index: 100000; justify-content: center; align-items: center; backdrop-filter: blur(5px);">
<div style="background: #1a1a1a; color: #0f0; padding: 40px; border: 2px solid #0f0; border-radius: 8px; width: 600px; max-width: 90%; text-align: center; font-family: 'Courier New', monospace; box-shadow: 0 0 30px rgba(0,255,0,0.4);">
<h1 id="campaign-intro-title" style="margin-top: 0; font-size: 2.2em; text-transform: uppercase; border-bottom: 2px dashed #0f0; padding-bottom: 10px;">INCOMING TRANSMISSION</h1>
<p id="campaign-intro-desc" style="font-size: 1.1em; line-height: 1.6; margin-bottom: 30px; text-align: left; color: #ccc;">
\[Story Text Here\]
</p>
<button id="campaign-btn-deploy" class="cmd-btn sys-btn" style="padding: 15px 30px; background: #2a7a2a; color: #fff; font-size: 1.5em; width: 100%; cursor: pointer; border: 2px solid #0f0 !important;">ACKNOWLEDGE</button>
</div>
</div>
<!-- --- NEW: Wasteland Guide Icon & Modal --- -->
<div id="guide-modal-overlay">
<div id="guide-book">
<span class="close-guide" onclick="document.getElementById('guide-modal-overlay').style.display='none';">&times;</span>
<!-- Left Page: Table of Contents -->
<div class="guide-page" style="border-right: 1px solid rgba(0,0,0,0.1);">
<h2>Table of Contents</h2>
<ul class="guide-toc" style="list-style-type: none; padding-left: 10px;">
<li>Chapter 1: Not Dying (Probably)</li>
<li>Chapter 2: Water & Rads</li>
<li>Chapter 3: Weapon Maintenance</li>
<li>Chapter 4: The 5-Year Rule</li>
<li>Chapter 5: Bartering Basics</li>
<li style="color: #500;">Chapter 6: Mutant Beast Field Guide</li>
</ul>
<div style="margin-top: 50px; text-align: center; opacity: 0.6;">
<img src="assets/images/guide.png" style="width: 120px; filter: grayscale(100%) contrast(200%);" onerror="this.style.display='none'" />
</div>
</div>
<!-- Right Page: Monster Guide -->
<div class="guide-page">
<h2>Mutant Field Guide</h2>
<div id="guide-monster-list">
<!-- Populated via JS -->
</div>
</div>
</div>
</div>
<!-- NEW: Asset Library Modal Overlay -->
<div id="asset-modal-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 100000; justify-content: center; align-items: center;">
<div style="background: #1a1a1a; color: #0f0; padding: 20px; border: 2px solid #0f0; border-radius: 8px; width: 800px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column; font-family: 'Courier New', monospace; box-shadow: 0 0 20px rgba(0,255,0,0.3);">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 2px dashed #0f0; padding-bottom: 10px; margin-bottom: 15px;">
<h2 style="margin: 0; font-size: 1.5em;">G.E.C.K. ASSET LIBRARY</h2>
<button onclick="document.getElementById('asset-modal-overlay').style.display='none';" style="background: transparent; border: none; color: #0f0; font-size: 2em; cursor: pointer; line-height: 0.5;">&times;</button>
</div>
<div style="display: flex; gap: 15px; margin-bottom: 15px;">
<label style="font-weight: bold; align-self: center;">THEME:</label>
<select id="asset-theme-select" style="background: #222; color: #0f0; border: 1px solid #0f0; padding: 8px; font-family: 'Courier New', monospace; flex: 1;">
<!-- Populated by JS -->
</select>
</div>
<!-- Asset Grid Container -->
<div id="asset-grid-container" style="flex: 1; overflow-y: auto; background: #111; border: 1px solid #333; padding: 10px; display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 15px; align-content: start;">
<!-- Populated by JS -->
</div>
</div>
</div>
<!-- NEW: Floating Admin Cheat Window -->
<div id="dev-cheat-window" style="display: none; position: absolute; top: 10%; left: 10%; width: 300px; background: rgba(0, 20, 0, 0.85); border: 2px solid #0f0; border-radius: 6px; font-family: 'Courier New', monospace; color: #0f0; z-index: 1000000; box-shadow: 0 0 15px rgba(0,255,0,0.3); backdrop-filter: blur(4px);">
<!-- Draggable Header -->
<div id="dev-cheat-header" style="background: rgba(0, 50, 0, 0.9); padding: 8px; cursor: move; border-bottom: 1px solid #0f0; display: flex; justify-content: space-between; align-items: center; border-radius: 4px 4px 0 0;">
<b style="font-size: 1.1em; letter-spacing: 1px;">OVERSEER CONSOLE</b>
<button onclick="document.getElementById('dev-cheat-window').style.display='none';" style="background: transparent; border: none; color: #0f0; font-size: 1.5em; cursor: pointer; line-height: 0.5;">&times;</button>
</div>
<!-- Tool Container -->
<div style="padding: 15px; display: flex; flex-direction: column; gap: 10px; font-size: 0.9em;">
<!-- Raw Stats -->
<div style="display: flex; gap: 5px; align-items: center;">
<label style="width: 60px;">CAPS:</label>
<input type="number" id="dev-input-caps" value="1000" style="flex: 1; background: #111; color: #0f0; border: 1px solid #0f0; padding: 3px;">
<button id="dev-btn-caps" class="cmd-btn" style="background: #050; border-color: #0f0; padding: 3px 10px;">GIVE</button>
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="width: 60px;">SCORE:</label>
<input type="number" id="dev-input-score" value="5000" style="flex: 1; background: #111; color: #0f0; border: 1px solid #0f0; padding: 3px;">
<button id="dev-btn-score" class="cmd-btn" style="background: #050; border-color: #0f0; padding: 3px 10px;">GIVE</button>
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="width: 60px;">HEALTH:</label>
<input type="number" id="dev-input-health" value="100" style="flex: 1; background: #111; color: #0f0; border: 1px solid #0f0; padding: 3px;">
<button id="dev-btn-health" class="cmd-btn" style="background: #050; border-color: #0f0; padding: 3px 10px;">SET</button>
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="width: 60px;">VATS %:</label>
<input type="number" id="dev-input-vats" value="100" min="0" max="100" style="width: 90px; flex-shrink: 0; background: #111; color: #0f0; border: 1px solid #0f0; padding: 3px;">
<button id="dev-btn-vats" class="cmd-btn" style="flex: 1; background: #050; border-color: #0f0; padding: 3px 10px;">SET</button>
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="width: 60px;">WAVE:</label>
<input type="number" id="dev-input-wave" value="15" min="1" style="width: 90px; flex-shrink: 0; background: #111; color: #0f0; border: 1px solid #0f0; padding: 3px;">
<button id="dev-btn-wave" class="cmd-btn" style="flex: 1; background: #050; border-color: #0f0; padding: 3px 10px;">JUMP</button>
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="width: 60px;">SPAWN:</label>
<select id="dev-sel-spawn" style="width: 90px; flex-shrink: 0; background: #111; color: #0f0; border: 1px solid #0f0; padding: 3px;">
<option value="0">Radroach</option>
<option value="1">Feral Ghoul</option>
<option value="2">Mole Rat</option>
<option value="3">Super Mutant</option>
<option value="4">Protectron</option>
<option value="5">Glowing One</option>
<option value="6">Deathclaw</option>
<option value="7">Sentry Bot</option>
<option value="8">Cazador (Fly)</option>
<option value="9">Stingwing (Fly)</option>
<option value="10">Scorchbeast (Fly)</option>
<option value="11">Nightkin (Stealth)</option>
</select>
<button id="dev-btn-spawn" class="cmd-btn" style="flex: 1; background: #050; border-color: #0f0; padding: 3px 10px;">DROP</button>
</div>
<hr style="border-color: #050; width: 100%; margin: 5px 0;">
<!-- Unlock Towers -->
<div style="display: flex; gap: 5px; align-items: center;">
<label style="width: 60px;">UNLOCK:</label>
<select id="dev-sel-tower" style="width: 90px; flex-shrink: 0; background: #111; color: #0f0; border: 1px solid #0f0; padding: 3px;">
<option value="all">ALL TOWERS</option>
<option value="t5">Tower 5</option>
<option value="t6">Tower 6</option>
<option value="t7">Tower 7</option>
<option value="t8">Tower 8</option>
<option value="t9">Tower 9</option>
<option value="t10">Tower 10</option>
</select>
<button id="dev-btn-tower" class="cmd-btn" style="flex: 1; background: #050; border-color: #0f0; padding: 3px 10px;">DO IT</button>
</div>
<!-- Items -->
<div style="display: flex; gap: 5px; align-items: center;">
<label style="width: 60px;">ITEM:</label>
<select id="dev-sel-item" style="width: 90px; flex-shrink: 0; background: #111; color: #0f0; border: 1px solid #0f0; padding: 3px;">
<option value="stimpak">Stimpak</option>
<option value="psycho">Psycho</option>
<option value="cryomine">Cryo Mine</option>
<option value="capmine">Cap Mine</option>
<option value="artillery">Artillery</option>
</select>
<input type="number" id="dev-input-item" value="1" min="1" max="10" style="width: 30px; flex-shrink: 0; background: #111; color: #0f0; border: 1px solid #0f0; padding: 3px; text-align: center;">
<button id="dev-btn-item" class="cmd-btn" style="flex: 1; background: #050; border-color: #0f0; padding: 3px 10px;">ADD</button>
</div>
<!-- Forced Drop -->
<div style="display: flex; gap: 5px; align-items: center;">
<label style="width: 60px; font-size: 0.8em;">FORCE DROP:</label>
<select id="dev-sel-drop" style="width: 90px; flex-shrink: 0; background: #111; color: #0f0; border: 1px solid #0f0; padding: 3px;">
<option value="none">Normal RNG</option>
<option value="money">Caps</option>
<option value="lunchbox">Lunchbox</option>
<option value="stimpak">Stimpak</option>
<option value="psycho">Psycho</option>
<option value="cryomine">Cryo Mine</option>
<option value="capmine">Cap Mine</option>
<option value="faction">Faction Ult</option>
</select>
<button id="dev-btn-drop" class="cmd-btn" style="flex: 1; background: #050; border-color: #0f0; padding: 3px 10px;">SET</button>
</div>
</div>
</div>
<!-- NEW: Custom Dropdown Logic -->
<script>
document.addEventListener("DOMContentLoaded", function() {
var selects = document.querySelectorAll("#start-screen select.menu-select");
selects.forEach(function(select) {
// Hide original select
select.style.display = "none";
// Create wrapper
var wrapper = document.createElement("div");
wrapper.className = "custom-select-wrapper";
select.parentNode.insertBefore(wrapper, select);
wrapper.appendChild(select);
// Create the visible 'closed' display
var display = document.createElement("div");
display.className = "menu-select";
display.style.marginBottom = "0";
display.style.width = "100%";
display.style.maxWidth = "100%";
// Override CSS to force a perfect flexbox center
display.style.display = "flex";
display.style.alignItems = "center";
display.style.justifyContent = "center";
display.style.padding = "10px 35px";
// Wrap the text in a span to protect it from line-break rendering bugs
var textSpan = document.createElement("span");
textSpan.style.textAlign = "center";
textSpan.style.lineHeight = "1.2";
textSpan.innerHTML = select.options[select.selectedIndex].innerHTML;
display.appendChild(textSpan);
// Transfer tooltip hover effects from original select
display.onmouseover = select.onmouseover;
display.onmouseout = select.onmouseout;
wrapper.appendChild(display);
// Create the dropdown options container
var optionsContainer = document.createElement("div");
optionsContainer.className = "custom-select-options";
wrapper.appendChild(optionsContainer);
// Populate options
Array.from(select.options).forEach(function(option) {
var optDiv = document.createElement("div");
optDiv.className = "custom-option";
optDiv.innerHTML = option.innerHTML;
// NEW: Mark the initially selected option so the arrow appears on load
if (option.selected) {
optDiv.classList.add("selected");
}
// Handle selecting an option
optDiv.addEventListener("click", function(e) {
e.stopPropagation();
textSpan.innerHTML = this.innerHTML; // Update the span, not the div
select.value = option.value;
// NEW: Remove the arrow from all options, then add it to the clicked one
Array.from(optionsContainer.children).forEach(function(sibling) {
sibling.classList.remove("selected");
});
this.classList.add("selected");
optionsContainer.classList.remove("open");
// Trigger the change event on the original select so game logic updates
var event = new Event('change');
select.dispatchEvent(event);
});
optionsContainer.appendChild(optDiv);
});
// Toggle dropdown open/close
display.addEventListener("click", function(e) {
e.stopPropagation();
// Close other open dropdowns
document.querySelectorAll(".custom-select-options").forEach(function(opt) {
if (opt !== optionsContainer) opt.classList.remove("open");
});
optionsContainer.classList.toggle("open");
});
});
// Close dropdowns when clicking outside
document.addEventListener("click", function() {
document.querySelectorAll(".custom-select-options").forEach(function(opt) {
opt.classList.remove("open");
});
});
});
</script>
<!-- Load External Map Data -->
<script src="assets/maps.js"></script>
</body>
</html>