feat: implement GuideController for evolution guide panel
- Added GuideController to manage the evolution guide panel, including event listeners for opening/closing the panel and spawning entities. - Introduced a new guide.js file with the GuideController implementation. - Created guide.css for styling the guide panel and its elements. - Updated main.js to initialize the GuideController and link it with the EvolutionController. - Enhanced settings.js to include functionality for resetting settings to defaults and saving state to localStorage. - Modified modal.css to improve the layout and styling of the modal footer and reset button. - Adjusted main.css to unify button styles for settings and guide buttons. - Updated icons.css to add animations for entity interactions. - Refactored gradient.js to utilize constants for default values.
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(/Users/sha/Developer/sha.dev/devpage/src/icons/bacteria.svg:*)",
|
||||
"Bash(/Users/sha/Developer/sha.dev/devpage/src/styles/guide.css:*)",
|
||||
"Bash(/Users/sha/Developer/sha.dev/devpage/src/js/guide.js:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,12 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Styles (order matters: base → gradient → modal → icons) -->
|
||||
<!-- Styles (order matters: base → gradient → modal → icons → guide) -->
|
||||
<link rel="stylesheet" href="src/styles/main.css" />
|
||||
<link rel="stylesheet" href="src/styles/gradient.css" />
|
||||
<link rel="stylesheet" href="src/styles/modal.css" />
|
||||
<link rel="stylesheet" href="src/styles/icons.css" />
|
||||
<link rel="stylesheet" href="src/styles/guide.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -23,13 +24,107 @@
|
||||
<div class="evolution-container" id="evolutionContainer" aria-hidden="true"></div>
|
||||
|
||||
<!-- ── Settings trigger ─────────────────────────────────── -->
|
||||
<button class="settings-btn" id="settingsBtn" aria-label="Open settings" aria-haspopup="dialog">
|
||||
<button class="settings-btn icon-btn" id="settingsBtn" aria-label="Open settings" aria-haspopup="dialog">
|
||||
#
|
||||
</button>
|
||||
|
||||
<!-- ── Main content area (grows with future features) ───── -->
|
||||
<main class="page-main" id="pageMain"></main>
|
||||
|
||||
<!-- ── Evolution guide button (bottom-right) ─────────────── -->
|
||||
<button
|
||||
class="guide-btn icon-btn"
|
||||
id="guideBtn"
|
||||
aria-label="Open evolution guide"
|
||||
aria-expanded="false"
|
||||
aria-controls="guidePanel"
|
||||
>ℹ</button>
|
||||
|
||||
<!-- ── Evolution guide panel ─────────────────────────────── -->
|
||||
<aside
|
||||
class="guide-panel"
|
||||
id="guidePanel"
|
||||
aria-hidden="true"
|
||||
aria-label="Evolution guide"
|
||||
>
|
||||
<div class="guide-panel__header">
|
||||
<p class="guide-panel__title">evolution info</p>
|
||||
<span class="guide-panel__uptime">0:00</span>
|
||||
</div>
|
||||
|
||||
<!-- Entities -->
|
||||
<div class="guide-section">
|
||||
<p class="guide-section__label">entities</p>
|
||||
<ul class="guide-entities">
|
||||
<li class="guide-entity" style="--c:#a8ffb8" data-count-key="dna-bold-duotone">
|
||||
<span class="guide-entity__icon" data-guide-icon="dna-bold-duotone"></span>
|
||||
<span class="guide-entity__name">dna</span>
|
||||
<span class="guide-entity__role">base lifeform</span>
|
||||
<span class="guide-entity__count">0</span>
|
||||
</li>
|
||||
<li class="guide-entity" style="--c:#ffb0a8" data-count-key="bug">
|
||||
<span class="guide-entity__icon" data-guide-icon="bug"></span>
|
||||
<span class="guide-entity__name">bug</span>
|
||||
<span class="guide-entity__role">rare · infector</span>
|
||||
<span class="guide-entity__count">0</span>
|
||||
</li>
|
||||
<li class="guide-entity" style="--c:#ff4d6d" data-count-key="virus-filled">
|
||||
<span class="guide-entity__icon" data-guide-icon="virus-filled"></span>
|
||||
<span class="guide-entity__name">virus</span>
|
||||
<span class="guide-entity__role">lethal threat</span>
|
||||
<span class="guide-entity__count">0</span>
|
||||
</li>
|
||||
<li class="guide-entity" style="--c:#80ffee" data-count-key="bacteria">
|
||||
<span class="guide-entity__icon" data-guide-icon="bacteria"></span>
|
||||
<span class="guide-entity__name">bacteria</span>
|
||||
<span class="guide-entity__role">mutant · immune</span>
|
||||
<span class="guide-entity__count">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Interactions -->
|
||||
<div class="guide-section">
|
||||
<p class="guide-section__label">interactions</p>
|
||||
<ul class="guide-rules">
|
||||
<li class="guide-rule">
|
||||
<span class="guide-rule__dot" style="--c:#ffb0a8"></span>
|
||||
<span class="guide-rule__op">+</span>
|
||||
<span class="guide-rule__dot" style="--c:#a8ffb8"></span>
|
||||
<span class="guide-rule__desc">bug infects → <span style="color:#ff4d6d">virus</span></span>
|
||||
</li>
|
||||
<li class="guide-rule">
|
||||
<span class="guide-rule__dot" style="--c:#ffb0a8"></span>
|
||||
<span class="guide-rule__op">+</span>
|
||||
<span class="guide-rule__dot" style="--c:#ff4d6d"></span>
|
||||
<span class="guide-rule__desc">bug cures → <span style="color:#a8ffb8">dna</span></span>
|
||||
</li>
|
||||
<li class="guide-rule">
|
||||
<span class="guide-rule__dot" style="--c:#ff4d6d"></span>
|
||||
<span class="guide-rule__op">+</span>
|
||||
<span class="guide-rule__dot" style="--c:#a8ffb8"></span>
|
||||
<span class="guide-rule__desc">virus kills <span class="guide-rule__pct">90%</span></span>
|
||||
</li>
|
||||
<li class="guide-rule">
|
||||
<span class="guide-rule__dot" style="--c:#ff4d6d"></span>
|
||||
<span class="guide-rule__op">+</span>
|
||||
<span class="guide-rule__dot" style="--c:#a8ffb8"></span>
|
||||
<span class="guide-rule__desc">mutates → <span style="color:#80ffee">bacteria</span> <span class="guide-rule__pct">10%</span></span>
|
||||
</li>
|
||||
<li class="guide-rule">
|
||||
<span class="guide-rule__dot" style="--c:#80ffee"></span>
|
||||
<span class="guide-rule__op"></span>
|
||||
<span class="guide-rule__dot" style="--c:transparent;box-shadow:none;border:1px solid rgba(255,255,255,0.18)"></span>
|
||||
<span class="guide-rule__desc">bacteria immune to virus</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="guide-footer">
|
||||
<button class="guide-reset-btn" id="guideResetBtn">restart evolution</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── Settings modal ──────────────────────────────────── -->
|
||||
<div
|
||||
class="modal-overlay"
|
||||
@@ -48,51 +143,93 @@
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<!-- Background color -->
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="colorPicker">background color</label>
|
||||
<div class="color-input-wrapper">
|
||||
<input type="color" id="colorPicker" value="#4d22b3" class="color-picker" />
|
||||
<span class="color-value" id="colorValue">#4d22b3</span>
|
||||
<!-- ── background ──────────────────────────── -->
|
||||
<div class="settings-section">
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="colorPicker">background colour</label>
|
||||
<div class="color-input-wrapper">
|
||||
<input type="color" id="colorPicker" value="#4d22b3" class="color-picker" />
|
||||
<span class="color-value" id="colorValue">#4d22b3</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="speedSlider">gradient speed</label>
|
||||
<div class="slider-wrapper">
|
||||
<span class="slider-label">slow</span>
|
||||
<input type="range" id="speedSlider" min="1" max="10" value="2" class="slider" />
|
||||
<span class="slider-label">fast</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">gradient rotation</span>
|
||||
<div class="toggle-wrapper">
|
||||
<span class="toggle-label">off</span>
|
||||
<label class="toggle" for="rotationToggle" aria-label="Toggle gradient rotation">
|
||||
<input type="checkbox" id="rotationToggle" />
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
<span class="toggle-label">on</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gradient animation speed -->
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="speedSlider">animation speed</label>
|
||||
<div class="slider-wrapper">
|
||||
<span class="slider-label">slow</span>
|
||||
<input type="range" id="speedSlider" min="1" max="10" value="2" class="slider" />
|
||||
<span class="slider-label">fast</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── elements ────────────────────────────── -->
|
||||
<div class="settings-section">
|
||||
<p class="settings-section__label">elements</p>
|
||||
|
||||
<!-- Icon movement speed -->
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="moveSpeedSlider">icon movement speed</label>
|
||||
<div class="slider-wrapper">
|
||||
<span class="slider-label">slow</span>
|
||||
<input type="range" id="moveSpeedSlider" min="1" max="10" value="5" class="slider" />
|
||||
<span class="slider-label">fast</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gradient rotation -->
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">gradient rotation</span>
|
||||
<div class="toggle-wrapper">
|
||||
<span class="toggle-label">off</span>
|
||||
<label class="toggle" for="rotationToggle" aria-label="Toggle gradient rotation">
|
||||
<input type="checkbox" id="rotationToggle" />
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
<span class="toggle-label">on</span>
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="moveSpeedSlider">movement speed</label>
|
||||
<div class="slider-wrapper">
|
||||
<span class="slider-label">slow</span>
|
||||
<input type="range" id="moveSpeedSlider" min="1" max="50" value="5" class="slider" />
|
||||
<span class="slider-label">fast</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="spawnRateSlider">spawn rate</label>
|
||||
<div class="slider-wrapper">
|
||||
<span class="slider-label">rare</span>
|
||||
<input type="range" id="spawnRateSlider" min="1" max="10" value="5" class="slider" />
|
||||
<span class="slider-label">frequent</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="virusKillSlider">virus lethality</label>
|
||||
<div class="slider-wrapper">
|
||||
<span class="slider-label">0%</span>
|
||||
<input type="range" id="virusKillSlider" min="0" max="100" value="90" class="slider" />
|
||||
<span class="slider-label">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="bugChanceSlider">bug rarity</label>
|
||||
<div class="slider-wrapper">
|
||||
<span class="slider-label">0%</span>
|
||||
<input type="range" id="bugChanceSlider" min="0" max="100" value="8" class="slider" />
|
||||
<span class="slider-label">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="bugCountSlider">bug number</label>
|
||||
<div class="slider-wrapper">
|
||||
<span class="slider-label">0</span>
|
||||
<input type="range" id="bugCountSlider" min="0" max="10" value="2" class="slider" />
|
||||
<span class="slider-label">10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="reset-btn" id="resetBtn">reset to defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"_comment": "Icon registry for the evolution system. Each icon has a type (good/bad/neutral) and belongs to a group. Types define the visual colour tint. Spawn config controls initial entity creation.",
|
||||
"_comment": "Icon registry for the evolution system. Each icon has a type (good/bad/neutral/viral) and belongs to a group. Types define the visual colour tint. Spawn config controls initial entity creation and rare spawns.",
|
||||
|
||||
"groups": [
|
||||
{ "id": "biology", "label": "Biology", "icons": ["dna-bold-duotone"] },
|
||||
{ "id": "biology", "label": "Biology", "icons": ["dna-bold-duotone", "bacteria"] },
|
||||
{ "id": "tech", "label": "Technology", "icons": ["database-bold-duotone", "display-line-duotone", "bug-bold-duotone"] },
|
||||
{ "id": "social", "label": "Social", "icons": ["chat-round-dots-bold-duotone", "chat-round-unread-bold-duotone"] },
|
||||
{ "id": "economy", "label": "Economy", "icons": ["chat-round-money-bold-duotone", "delivery-bold-duotone", "buildings-3-bold-duotone"] }
|
||||
{ "id": "economy", "label": "Economy", "icons": ["chat-round-money-bold-duotone", "delivery-bold-duotone", "buildings-3-bold-duotone"] },
|
||||
{ "id": "threat", "label": "Threat", "icons": ["bug", "virus-filled"] }
|
||||
],
|
||||
|
||||
"icons": {
|
||||
@@ -17,16 +18,23 @@
|
||||
"chat-round-unread-bold-duotone": { "label": "Unread Chat", "type": "neutral", "group": "social" },
|
||||
"database-bold-duotone": { "label": "Database", "type": "good", "group": "tech" },
|
||||
"delivery-bold-duotone": { "label": "Delivery", "type": "good", "group": "economy" },
|
||||
"display-line-duotone": { "label": "Display", "type": "neutral", "group": "tech" }
|
||||
"display-line-duotone": { "label": "Display", "type": "neutral", "group": "tech" },
|
||||
"bug": { "label": "Bug (rare)", "type": "bad", "group": "threat", "isRare": true, "behavior": "infect", "infectWith": "virus-filled" },
|
||||
"virus-filled": { "label": "Virus", "type": "viral", "group": "threat" },
|
||||
"bacteria": { "label": "Bacteria", "type": "mutant", "group": "biology" }
|
||||
},
|
||||
|
||||
"types": {
|
||||
"good": { "label": "Beneficial", "color": "#a8ffb8" },
|
||||
"bad": { "label": "Harmful", "color": "#ffb0a8" },
|
||||
"neutral": { "label": "Neutral", "color": "#c4d8ff" }
|
||||
"neutral": { "label": "Neutral", "color": "#c4d8ff" },
|
||||
"viral": { "label": "Viral", "color": "#ff4d6d" },
|
||||
"mutant": { "label": "Mutant", "color": "#80ffee" }
|
||||
},
|
||||
|
||||
"spawn": {
|
||||
"initial": "dna-bold-duotone"
|
||||
"initial": "dna-bold-duotone",
|
||||
"rare": "bug",
|
||||
"rareChance": 0.08
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<!-- elongated cell body -->
|
||||
<ellipse cx="10.5" cy="12" rx="5" ry="8"/>
|
||||
<!-- internal nucleus region (lighter) -->
|
||||
<ellipse cx="10" cy="10.5" rx="2.2" ry="3.2" fill="white" opacity="0.22"/>
|
||||
<!-- membrane inclusion dots -->
|
||||
<circle cx="8.5" cy="8.5" r="0.9" fill="white" opacity="0.42"/>
|
||||
<circle cx="7.8" cy="12.5" r="0.8" fill="white" opacity="0.42"/>
|
||||
<circle cx="8.5" cy="15.5" r="0.85" fill="white" opacity="0.42"/>
|
||||
<!-- flagellum (wavy tail exiting body) -->
|
||||
<path d="M15.5 13 C17.5 12 17 15 19.5 14.5 C21.5 14 21 17 23 16.5"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/>
|
||||
<!-- tip dot -->
|
||||
<circle cx="23" cy="16.5" r="0.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 782 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M19 11.938V15a7 7 0 0 1-6.25 6.96V15a.75.75 0 0 0-1.5 0v6.96A7 7 0 0 1 5 15v-3.062A3.94 3.94 0 0 1 8.938 8h6.124A3.94 3.94 0 0 1 19 11.938" clip-rule="evenodd" opacity="0.5"/><path fill="currentColor" d="M19 14.75v-1.5h3a.75.75 0 0 1 0 1.5zm-1.504 4.586c.31-.393.58-.82.801-1.276l2.538 1.27a.75.75 0 1 1-.67 1.34zM5.703 18.06q.333.684.801 1.276l-2.669 1.335a.75.75 0 0 1-.67-1.342zM5 13.25H2a.75.75 0 0 0 0 1.5h3zm12.354-4.515l2.81-1.406a.75.75 0 1 1 .671 1.341L18.42 9.88a4 4 0 0 0-1.065-1.144M6.647 8.735c-.427.306-.79.695-1.067 1.144L3.165 8.67a.75.75 0 0 1 .67-1.341zM16.5 8.27V7.5a4.5 4.5 0 1 0-9 0v.77A3.9 3.9 0 0 1 8.938 8h6.124c.508 0 .993.096 1.438.27"/><path fill="currentColor" d="M6.376 1.584a.75.75 0 0 0 .208 1.04l2.36 1.573a4.5 4.5 0 0 1 1.387-.877L7.416 1.376a.75.75 0 0 0-1.04.208m8.68 2.613a4.5 4.5 0 0 0-1.387-.877l2.915-1.944a.75.75 0 1 1 .832 1.248z" opacity="0.5"/><path fill="currentColor" fill-rule="evenodd" d="M12 14.25a.75.75 0 0 1 .75.75v7h-1.5v-7a.75.75 0 0 1 .75-.75" clip-rule="evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M19 11.938V15a7 7 0 0 1-6.25 6.96V15a.75.75 0 0 0-1.5 0v6.96A7 7 0 0 1 5 15v-3.062A3.94 3.94 0 0 1 8.938 8h6.124A3.94 3.94 0 0 1 19 11.938" clip-rule="evenodd" opacity="0.5"/><path fill="currentColor" d="M19 14.75v-1.5h3a.75.75 0 0 1 0 1.5zm-1.504 4.586c.31-.393.58-.82.801-1.276l2.538 1.27a.75.75 0 1 1-.67 1.34zM5.703 18.06q.333.684.801 1.276l-2.669 1.335a.75.75 0 0 1-.67-1.342zM5 13.25H2a.75.75 0 0 0 0 1.5h3zm12.354-4.515l2.81-1.406a.75.75 0 1 1 .671 1.341L18.42 9.88a4 4 0 0 0-1.065-1.144M6.647 8.735c-.427.306-.79.695-1.067 1.144L3.165 8.67a.75.75 0 0 1 .67-1.341zM16.5 8.27V7.5a4.5 4.5 0 1 0-9 0v.77A3.9 3.9 0 0 1 8.938 8h6.124c.508 0 .993.096 1.438.27"/><path fill="currentColor" d="M6.376 1.584a.75.75 0 0 0 .208 1.04l2.36 1.573a4.5 4.5 0 0 1 1.387-.877L7.416 1.376a.75.75 0 0 0-1.04.208m8.68 2.613a4.5 4.5 0 0 0-1.387-.877l2.915-1.944a.75.75 0 1 1 .832 1.248z" opacity="0.5"/><path fill="currentColor" fill-rule="evenodd" d="M12 14.25a.75.75 0 0 1 .75.75v7h-1.5v-7a.75.75 0 0 1 .75-.75" clip-rule="evenodd"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<!-- antennae -->
|
||||
<path d="M10 5.2 C9.2 3.8 7.5 2.8 6 3.2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" fill="none"/>
|
||||
<path d="M14 5.2 C14.8 3.8 16.5 2.8 18 3.2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" fill="none"/>
|
||||
<!-- head -->
|
||||
<circle cx="12" cy="7" r="2.3" fill="currentColor"/>
|
||||
<!-- thorax -->
|
||||
<ellipse cx="12" cy="11.5" rx="3.6" ry="2.8" fill="currentColor"/>
|
||||
<!-- abdomen left wing -->
|
||||
<ellipse cx="9.2" cy="16.5" rx="2.8" ry="3.8" fill="currentColor"/>
|
||||
<!-- abdomen right wing -->
|
||||
<ellipse cx="14.8" cy="16.5" rx="2.8" ry="3.8" fill="currentColor"/>
|
||||
<!-- center line -->
|
||||
<line x1="12" y1="12" x2="12" y2="21" stroke="white" stroke-width="0.7" stroke-linecap="round" opacity="0.35"/>
|
||||
<!-- legs left -->
|
||||
<path d="M8.5 10.5 L5 8.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M8.5 12.5 L4.5 12.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M8.5 14.5 L5.5 16.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" fill="none"/>
|
||||
<!-- legs right -->
|
||||
<path d="M15.5 10.5 L19 8.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M15.5 12.5 L19.5 12.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M15.5 14.5 L18.5 16.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M22 11h-.17c-1.05 0-1.96-.67-2.36-1.64l-.02-.05c-.41-.98-.25-2.1.5-2.85l.12-.12a.996.996 0 1 0-1.41-1.41l-.12.12c-.75.75-1.87.91-2.85.5l-.05-.02C14.67 5.13 14 4.22 14 3.17v-.08c0-.64-.45-1.08-1-1.08s-1 .45-1 1v.04c0 1.1-.66 2.08-1.67 2.5s-2.18.19-2.95-.59l-.02-.03a.996.996 0 1 0-1.41 1.41l.04.04c.77.77 1 1.92.58 2.93l-.03.06C6.13 10.36 5.17 11 4.1 11c-.64 0-1.08.45-1.08 1s.45 1 1 1h.07c1.08 0 2.05.65 2.46 1.64l.02.05c.42 1 .19 2.16-.58 2.93l-.04.04a.996.996 0 1 0 1.41 1.41l.04-.04c.77-.77 1.92-1 2.93-.58l.14.06c.94.39 1.55 1.3 1.55 2.32v.09c0 .64.45 1.09 1 1.09s1-.45 1-1v-.17c0-1.01.61-1.93 1.55-2.32l.25-.1c.94-.39 2.02-.17 2.74.55l.12.12a.996.996 0 1 0 1.41-1.41l-.12-.12c-.75-.75-.91-1.87-.5-2.85l.02-.05c.4-.97 1.31-1.64 2.36-1.64h.08c.64 0 1.09-.45 1.09-1s-.45-1-1-1ZM9 13c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1m5 3.5c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1m1-4.5c-1.1 0-2-.9-2-2s.9-2 2-2s2 .9 2 2s-.9 2-2 2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M22 11h-.17c-1.05 0-1.96-.67-2.36-1.64l-.02-.05c-.41-.98-.25-2.1.5-2.85l.12-.12a.996.996 0 1 0-1.41-1.41l-.12.12c-.75.75-1.87.91-2.85.5l-.05-.02C14.67 5.13 14 4.22 14 3.17v-.08c0-.64-.45-1.08-1-1.08s-1 .45-1 1v.04c0 1.1-.66 2.08-1.67 2.5s-2.18.19-2.95-.59l-.02-.03a.996.996 0 1 0-1.41 1.41l.04.04c.77.77 1 1.92.58 2.93l-.03.06C6.13 10.36 5.17 11 4.1 11c-.64 0-1.08.45-1.08 1s.45 1 1 1h.07c1.08 0 2.05.65 2.46 1.64l.02.05c.42 1 .19 2.16-.58 2.93l-.04.04a.996.996 0 1 0 1.41 1.41l.04-.04c.77-.77 1.92-1 2.93-.58l.14.06c.94.39 1.55 1.3 1.55 2.32v.09c0 .64.45 1.09 1 1.09s1-.45 1-1v-.17c0-1.01.61-1.93 1.55-2.32l.25-.1c.94-.39 2.02-.17 2.74.55l.12.12a.996.996 0 1 0 1.41-1.41l-.12-.12c-.75-.75-.91-1.87-.5-2.85l.02-.05c.4-.97 1.31-1.64 2.36-1.64h.08c.64 0 1.09-.45 1.09-1s-.45-1-1-1ZM9 13c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1m5 3.5c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1m1-4.5c-1.1 0-2-.9-2-2s.9-2 2-2s2 .9 2 2s-.9 2-2 2"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<!-- spikes (cardinal) -->
|
||||
<rect x="11.25" y="1.5" width="1.5" height="3.5" rx="0.75"/>
|
||||
<rect x="11.25" y="19" width="1.5" height="3.5" rx="0.75"/>
|
||||
<rect x="1.5" y="11.25" width="3.5" height="1.5" rx="0.75"/>
|
||||
<rect x="19" y="11.25" width="3.5" height="1.5" rx="0.75"/>
|
||||
<!-- spikes (diagonal) -->
|
||||
<rect x="11.25" y="1.5" width="1.5" height="3.5" rx="0.75" transform="rotate(45 12 12)"/>
|
||||
<rect x="11.25" y="1.5" width="1.5" height="3.5" rx="0.75" transform="rotate(135 12 12)"/>
|
||||
<rect x="11.25" y="1.5" width="1.5" height="3.5" rx="0.75" transform="rotate(225 12 12)"/>
|
||||
<rect x="11.25" y="1.5" width="1.5" height="3.5" rx="0.75" transform="rotate(315 12 12)"/>
|
||||
<!-- central body -->
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<!-- receptor dots -->
|
||||
<circle cx="12" cy="9.2" r="1.2" fill="white" opacity="0.45"/>
|
||||
<circle cx="10" cy="13.2" r="0.95" fill="white" opacity="0.45"/>
|
||||
<circle cx="14" cy="13.2" r="0.95" fill="white" opacity="0.45"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -40,4 +40,30 @@ export const DEFAULTS = {
|
||||
/** Max |Δv| applied on each drift kick */
|
||||
DRIFT_MAGNITUDE: 0.25,
|
||||
|
||||
// ── Collisions ─────────────────────────────────────────────
|
||||
/** Distance between centres (px) at which two entities collide (= ICON_SIZE) */
|
||||
COLLISION_DIAMETER: 24,
|
||||
|
||||
// ── Hit colour shift ───────────────────────────────────────
|
||||
/** Minimum hue-rotate degrees added to an entity's filter on each collision */
|
||||
HIT_HUE_MIN: 60,
|
||||
/** Additional random range on top of HIT_HUE_MIN */
|
||||
HIT_HUE_RANGE: 60,
|
||||
|
||||
// ── Bug / infection ────────────────────────────────────────
|
||||
/** Fallback probability for a rare-bug spawn when icons.json rareChance is absent */
|
||||
BUG_SPAWN_CHANCE: 0.08,
|
||||
/** Max number of bug entities alive simultaneously */
|
||||
BUG_MAX_COUNT: 2,
|
||||
|
||||
// ── Virus kill ─────────────────────────────────────────────
|
||||
/** Probability that a virus contact kills the target (else it survives unaffected) */
|
||||
VIRUS_KILL_CHANCE: 0.9,
|
||||
/** CSS colour applied to a dying entity immediately on contact */
|
||||
KILL_FADE_COLOR: '#ffaaaa',
|
||||
/** Delay (ms) before the grayscale-dead visual phase begins */
|
||||
KILL_FADE_MS: 400,
|
||||
/** Total ms from kill trigger to entity removal */
|
||||
KILL_DEATH_DURATION: 3_000,
|
||||
|
||||
};
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
* organic, chaotic-feeling motion (not perfectly straight lines).
|
||||
* - Edge detection: velocity component is reflected (±abs) on contact,
|
||||
* ensuring the bounce always sends the icon back into the viewport.
|
||||
* - Rotation: each entity spawns with a fixed random orientation (0–360°).
|
||||
*
|
||||
* Collision response (handled by EvolutionController):
|
||||
* - onHit() — colour-shifts the icon via hue-rotate filter + flash anim.
|
||||
* - infectWith() — async; collapses the icon, swaps SVG, re-expands as new form.
|
||||
* - die() — slow death: light-red flash → dark-grayscale fade → destroy.
|
||||
*
|
||||
* DOM structure:
|
||||
* <div class="icon-entity"> ← position via JS transform
|
||||
* <div class="icon-entity"> ← position + rotation via JS transform
|
||||
* <div class="icon-entity__body"> ← scale animation via CSS transition
|
||||
* <svg>…</svg> ← injected SVG, sized by CSS
|
||||
* </div>
|
||||
@@ -28,27 +34,42 @@ export class Entity {
|
||||
/** @type {number} */ y;
|
||||
/** @type {number} */ vx;
|
||||
/** @type {number} */ vy;
|
||||
/** Fixed spawn orientation in degrees (0–360) */
|
||||
/** @type {number} */ rotation;
|
||||
|
||||
// ── Identity ─────────────────────────────────────────────
|
||||
/** The current icon name — changes on infection. */
|
||||
/** @type {string} */ entityKey;
|
||||
/** @type {string} */ name;
|
||||
/** @type {string} */ type;
|
||||
/** @type {string} */ color;
|
||||
/** @type {boolean} */ alive = true;
|
||||
|
||||
// ── Visual state ──────────────────────────────────────────
|
||||
/** Accumulated hue-rotate offset (degrees). Increases on each hit. */
|
||||
/** @type {number} */ hueShift = 0;
|
||||
|
||||
// ── DOM ───────────────────────────────────────────────────
|
||||
/** @type {HTMLElement|null} */ el = null;
|
||||
/** @type {HTMLElement|null} */ bodyEl = null;
|
||||
|
||||
// ── Private ───────────────────────────────────────────────
|
||||
/** @type {boolean} */ #infected = false;
|
||||
/** @type {boolean} */ #dying = false;
|
||||
|
||||
/**
|
||||
* @param {{ name: string, type: string, color: string,
|
||||
* x: number, y: number, vx: number, vy: number }} config
|
||||
* x: number, y: number, vx: number, vy: number,
|
||||
* rotation?: number }} config
|
||||
*/
|
||||
constructor({ name, type, color, x, y, vx, vy }) {
|
||||
this.name = name;
|
||||
this.type = type;
|
||||
this.color = color;
|
||||
this.x = x; this.y = y;
|
||||
this.vx = vx; this.vy = vy;
|
||||
constructor({ name, type, color, x, y, vx, vy, rotation }) {
|
||||
this.name = name;
|
||||
this.entityKey = name;
|
||||
this.type = type;
|
||||
this.color = color;
|
||||
this.x = x; this.y = y;
|
||||
this.vx = vx; this.vy = vy;
|
||||
this.rotation = rotation !== undefined ? rotation : Math.random() * 360;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────
|
||||
@@ -101,7 +122,7 @@ export class Entity {
|
||||
* @param {number} speedMultiplier scales BASE_SPEED (e.g. slider / 5)
|
||||
*/
|
||||
update(speedMultiplier) {
|
||||
if (!this.el || !this.alive) return;
|
||||
if (!this.el || !this.alive || this.#dying) return;
|
||||
|
||||
// Move
|
||||
this.x += this.vx * speedMultiplier;
|
||||
@@ -134,6 +155,110 @@ export class Entity {
|
||||
this._applyTransform();
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a physical collision with another entity.
|
||||
* Shifts the hue-rotate filter by a random step and plays a brief
|
||||
* scale-punch animation to signal the impact.
|
||||
*/
|
||||
onHit() {
|
||||
if (!this.bodyEl || !this.alive || this.#dying) return;
|
||||
const shift = DEFAULTS.HIT_HUE_MIN + Math.floor(Math.random() * DEFAULTS.HIT_HUE_RANGE);
|
||||
this.hueShift = (this.hueShift + shift) % 360;
|
||||
this.bodyEl.style.filter =
|
||||
`hue-rotate(${this.hueShift}deg) drop-shadow(0 1px 6px rgba(0,0,0,0.35))`;
|
||||
// Flash animation — class removed after its duration
|
||||
this.bodyEl.classList.remove('icon-entity__body--hit'); // reset if mid-anim
|
||||
void this.bodyEl.offsetWidth; // force reflow
|
||||
this.bodyEl.classList.add('icon-entity__body--hit');
|
||||
setTimeout(() => this.bodyEl?.classList.remove('icon-entity__body--hit'), 350);
|
||||
}
|
||||
|
||||
/** True while the entity is in its slow-death sequence. */
|
||||
get dying() { return this.#dying; }
|
||||
|
||||
/**
|
||||
* Begin a slow death sequence triggered by a virus contact.
|
||||
*
|
||||
* Phase 1 (immediate): entity stops moving, colour changes to light red.
|
||||
* Phase 2 (+KILL_FADE_MS): grayscale-dark CSS class fades the icon out.
|
||||
* Phase 3 (+KILL_DEATH_DURATION): entity is removed from the DOM.
|
||||
*/
|
||||
die() {
|
||||
if (this.#dying || !this.bodyEl || !this.alive) return;
|
||||
this.#dying = true;
|
||||
|
||||
// Freeze movement
|
||||
this.vx = 0;
|
||||
this.vy = 0;
|
||||
|
||||
// Phase 1: light-red flash — clear any hit filter so CSS class takes over
|
||||
this.el.style.color = DEFAULTS.KILL_FADE_COLOR;
|
||||
this.hueShift = 0;
|
||||
this.bodyEl.style.filter = '';
|
||||
this.bodyEl.classList.remove('icon-entity__body--hit');
|
||||
|
||||
// Phase 2: grayscale dark fade
|
||||
setTimeout(() => {
|
||||
if (this.bodyEl) this.bodyEl.classList.add('icon-entity__body--dying');
|
||||
}, DEFAULTS.KILL_FADE_MS);
|
||||
|
||||
// Phase 3: remove from DOM — EvolutionController cleans up entity array each tick
|
||||
setTimeout(() => this.destroy(), DEFAULTS.KILL_DEATH_DURATION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform this entity into another icon (infection mechanic).
|
||||
* Pre-fetches the new SVG, collapses the current icon, swaps content,
|
||||
* then expands the new icon using the standard appear animation.
|
||||
*
|
||||
* @param {string} iconName Key of the SVG to transform into (e.g. 'virus-filled')
|
||||
* @param {string} color CSS colour string for the new icon tint
|
||||
*/
|
||||
/**
|
||||
* @param {string} iconName
|
||||
* @param {string} color
|
||||
* @param {{ force?: boolean }} [opts] force:true bypasses the #infected guard (used for bug cure)
|
||||
*/
|
||||
async infectWith(iconName, color, { force = false } = {}) {
|
||||
if (this.#dying || !this.bodyEl || !this.alive) return;
|
||||
if (!force && this.#infected) return;
|
||||
this.#infected = true;
|
||||
this.entityKey = iconName;
|
||||
|
||||
try {
|
||||
// Pre-fetch the new SVG (likely cached) before touching the DOM
|
||||
const svg = await loadIcon(iconName);
|
||||
if (!this.bodyEl || !this.alive) return;
|
||||
|
||||
// Collapse current icon — CSS transition: scale 0.6s spring
|
||||
this.bodyEl.dataset.state = 'spawning';
|
||||
|
||||
// Wait ~300 ms — at this point the entity is visibly shrinking.
|
||||
// Swap the SVG content while it's small and hard to see.
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
if (!this.bodyEl || !this.alive) return;
|
||||
|
||||
this.bodyEl.innerHTML = svg;
|
||||
this.color = color;
|
||||
this.el.style.color = color;
|
||||
this.hueShift = 0;
|
||||
this.bodyEl.style.filter = ''; // clear any hit hue-rotate
|
||||
|
||||
// Two rAF ticks guarantee the 'spawning' state was painted
|
||||
// before switching to 'alive', so the expand transition fires.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (this.bodyEl) this.bodyEl.dataset.state = 'alive';
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[Entity] infectWith failed:', err);
|
||||
this.#infected = false;
|
||||
this.entityKey = this.name; // revert
|
||||
if (this.bodyEl) this.bodyEl.dataset.state = 'alive';
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove this entity from the DOM and mark it as dead. */
|
||||
destroy() {
|
||||
this.alive = false;
|
||||
@@ -145,13 +270,16 @@ export class Entity {
|
||||
// ── Private ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write the current (x, y) position to the DOM via transform.
|
||||
* Write the current (x, y) position and fixed rotation to the DOM.
|
||||
* Using transform keeps this on the compositor thread (no layout).
|
||||
* We offset by ICON_HALF so (x, y) represents the icon's centre point.
|
||||
* Offset by ICON_HALF so (x, y) is the icon's centre point.
|
||||
* rotate() is applied after translate so it spins the icon around
|
||||
* its own centre, independent of its screen position.
|
||||
*/
|
||||
_applyTransform() {
|
||||
if (!this.el) return;
|
||||
const { ICON_HALF: h } = DEFAULTS;
|
||||
this.el.style.transform = `translate(${this.x - h}px, ${this.y - h}px)`;
|
||||
this.el.style.transform =
|
||||
`translate(${this.x - h}px, ${this.y - h}px) rotate(${this.rotation}deg)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
* Orchestrates the icon evolution system:
|
||||
*
|
||||
* 1. Spawning — after a random interval (SPAWN_DELAY_MIN … SPAWN_DELAY_MAX),
|
||||
* a dna-bold-duotone icon appears at a random viewport position.
|
||||
* After each spawn the timer resets for the next one.
|
||||
* an icon appears at a random viewport position. Most spawns produce the
|
||||
* "initial" icon (dna-bold-duotone); with rareChance probability the rare
|
||||
* "bug" icon spawns instead.
|
||||
*
|
||||
* 2. Physics loop — a single rAF loop drives all live entities each frame.
|
||||
* After every position update, entity pairs are checked for collisions.
|
||||
*
|
||||
* 3. Speed control — setMoveSpeed(1–10) scales entity velocity in real time.
|
||||
* 3. Collision response — overlapping entities receive an elastic velocity
|
||||
* impulse (equal-mass reflection), are pushed apart, and both call onHit()
|
||||
* to shift their hue colour. If a "bug" entity collides with a non-immune
|
||||
* entity, the target is infected and transforms into "virus-filled".
|
||||
*
|
||||
* 4. Speed control — setMoveSpeed(1–10) scales entity velocity in real time.
|
||||
*
|
||||
* The icon to spawn first is declared in icons.json under spawn.initial.
|
||||
* All icon metadata (type, colour) is also read from icons.json.
|
||||
@@ -18,13 +25,20 @@ import { Entity } from './entity.js';
|
||||
import { preloadIcons } from './iconLoader.js';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
|
||||
const ICONS_DATA_URL = 'src/data/icons.json';
|
||||
const ICONS_DATA_URL = 'src/data/icons.json';
|
||||
const EVOLUTION_STORAGE_KEY = 'devpage:evolution';
|
||||
|
||||
export class EvolutionController {
|
||||
/** @type {Entity[]} */ #entities = [];
|
||||
/** @type {HTMLElement|null} */ #container = null;
|
||||
/** @type {number} */ #moveSpeed = DEFAULTS.MOVE_SPEED;
|
||||
/** @type {Entity[]} */ #entities = [];
|
||||
/** @type {HTMLElement|null} */ #container = null;
|
||||
/** @type {number} */ #moveSpeed = DEFAULTS.MOVE_SPEED;
|
||||
/** @type {number} */ #spawnRate = 5;
|
||||
/** @type {number} */ #virusKillChance = DEFAULTS.VIRUS_KILL_CHANCE;
|
||||
/** @type {number} */ #bugSpawnChance = DEFAULTS.BUG_SPAWN_CHANCE;
|
||||
/** @type {number} */ #bugMaxCount = DEFAULTS.BUG_MAX_COUNT;
|
||||
/** @type {number} */ #startTime = 0;
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */ #spawnTimer = null;
|
||||
/** @type {ReturnType<typeof setInterval>|null} */ #saveTimer = null;
|
||||
/** @type {number|null} */ #animFrame = null;
|
||||
/** @type {boolean} */ #running = false;
|
||||
/** @type {object|null} */ #iconsData = null;
|
||||
@@ -41,12 +55,25 @@ export class EvolutionController {
|
||||
|
||||
this.#iconsData = await this.#loadIconsData();
|
||||
|
||||
// Warm the SVG cache for the initial icon so first spawn is instant
|
||||
const initialName = this.#iconsData.spawn.initial;
|
||||
preloadIcons([initialName]);
|
||||
// Warm the SVG cache for both the normal and rare icons so first spawns are instant
|
||||
const { initial, rare } = this.#iconsData.spawn;
|
||||
const toPreload = [initial];
|
||||
if (rare) toPreload.push(rare);
|
||||
// Also preload virus-filled and bacteria so transforms are instant
|
||||
if (this.#iconsData.icons['virus-filled']) toPreload.push('virus-filled');
|
||||
if (this.#iconsData.icons['bacteria']) toPreload.push('bacteria');
|
||||
preloadIcons(toPreload);
|
||||
|
||||
this.#startTime = Date.now();
|
||||
|
||||
// Restore any previously saved entity state before the spawner kicks off
|
||||
await this.#restoreState();
|
||||
|
||||
this.#scheduleNextSpawn();
|
||||
this.#startLoop();
|
||||
|
||||
// Persist entity state to localStorage every 2 s
|
||||
this.#saveTimer = setInterval(() => this.#saveState(), 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,11 +86,83 @@ export class EvolutionController {
|
||||
this.#moveSpeed = speed;
|
||||
}
|
||||
|
||||
/** Current move speed setting (1–50). Used by SettingsController to sync UI on boot. */
|
||||
get moveSpeed() { return this.#moveSpeed; }
|
||||
|
||||
/** Spawn frequency (1 = rare / slow, 10 = frequent / fast). */
|
||||
setSpawnRate(rate) { this.#spawnRate = rate; }
|
||||
get spawnRate() { return this.#spawnRate; }
|
||||
|
||||
/** Probability (0–1) that a virus contact kills its target. */
|
||||
setVirusKillChance(v) { this.#virusKillChance = v; }
|
||||
get virusKillChance() { return this.#virusKillChance; }
|
||||
|
||||
/** Probability (0–1) that a rare-bug icon spawns instead of the normal icon. */
|
||||
setBugSpawnChance(v) { this.#bugSpawnChance = v; }
|
||||
get bugSpawnChance() { return this.#bugSpawnChance; }
|
||||
|
||||
/** Max number of bug entities allowed alive at the same time (0 = no bugs). */
|
||||
setBugMaxCount(n) { this.#bugMaxCount = n; }
|
||||
get bugMaxCount() { return this.#bugMaxCount; }
|
||||
|
||||
/** Milliseconds elapsed since init() was called. */
|
||||
get lifetime() { return this.#startTime ? Date.now() - this.#startTime : 0; }
|
||||
|
||||
/** Count of live entities grouped by entityKey. */
|
||||
getCounts() {
|
||||
/** @type {Record<string,number>} */
|
||||
const counts = {};
|
||||
for (const e of this.#entities) {
|
||||
counts[e.entityKey] = (counts[e.entityKey] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually spawn a specific icon by name at a random viewport position.
|
||||
* Used by the guide panel when the user clicks an entity row.
|
||||
*
|
||||
* @param {string} iconName Key from icons.json (e.g. 'dna-bold-duotone', 'bug')
|
||||
*/
|
||||
async spawnNamed(iconName) {
|
||||
if (!this.#container || !this.#iconsData) return;
|
||||
const iconMeta = this.#iconsData.icons[iconName];
|
||||
if (!iconMeta) return;
|
||||
const typeMeta = this.#iconsData.types[iconMeta.type];
|
||||
|
||||
const margin = DEFAULTS.ICON_SIZE * 2;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const x = margin + Math.random() * (vw - margin * 2);
|
||||
const y = margin + Math.random() * (vh - margin * 2);
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
|
||||
const entity = new Entity({
|
||||
name: iconName, type: iconMeta.type, color: typeMeta.color,
|
||||
x, y,
|
||||
vx: Math.cos(angle) * DEFAULTS.BASE_SPEED,
|
||||
vy: Math.sin(angle) * DEFAULTS.BASE_SPEED,
|
||||
});
|
||||
|
||||
await entity.mount(this.#container);
|
||||
if (entity.alive) this.#entities.push(entity);
|
||||
}
|
||||
|
||||
/** Remove all entities from the screen without stopping the physics loop. */
|
||||
clear() {
|
||||
this.#entities.forEach(e => e.destroy());
|
||||
this.#entities = [];
|
||||
this.#startTime = Date.now(); // reset lifetime counter
|
||||
// Wipe saved state so a page reload starts fresh
|
||||
try { localStorage.removeItem(EVOLUTION_STORAGE_KEY); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Stop all timers and the rAF loop; remove all entities from the DOM. */
|
||||
stop() {
|
||||
this.#running = false;
|
||||
if (this.#spawnTimer !== null) clearTimeout(this.#spawnTimer);
|
||||
if (this.#animFrame !== null) cancelAnimationFrame(this.#animFrame);
|
||||
if (this.#saveTimer !== null) clearInterval(this.#saveTimer);
|
||||
this.#entities.forEach(e => e.destroy());
|
||||
this.#entities = [];
|
||||
}
|
||||
@@ -78,9 +177,11 @@ export class EvolutionController {
|
||||
* Self-resets after each spawn so the process continues indefinitely.
|
||||
*/
|
||||
#scheduleNextSpawn() {
|
||||
const min = DEFAULTS.SPAWN_DELAY_MIN;
|
||||
const max = DEFAULTS.SPAWN_DELAY_MAX;
|
||||
const delay = min + Math.random() * (max - min);
|
||||
// Exponential scale: rate 5 → default delays; higher rate → shorter delays.
|
||||
const factor = Math.pow(this.#spawnRate / 5, 1.5);
|
||||
const min = Math.max(300, Math.round(DEFAULTS.SPAWN_DELAY_MIN / factor));
|
||||
const max = Math.max(1500, Math.round(DEFAULTS.SPAWN_DELAY_MAX / factor));
|
||||
const delay = min + Math.random() * (max - min);
|
||||
|
||||
this.#spawnTimer = setTimeout(async () => {
|
||||
await this.#spawnEntity();
|
||||
@@ -92,7 +193,15 @@ export class EvolutionController {
|
||||
async #spawnEntity() {
|
||||
if (!this.#container || !this.#iconsData) return;
|
||||
|
||||
const name = this.#iconsData.spawn.initial;
|
||||
// Decide whether this spawn is a bug or the normal icon.
|
||||
// A bug only spawns if the rarity roll passes AND the live bug cap isn't reached.
|
||||
const { initial, rare } = this.#iconsData.spawn;
|
||||
let name = initial;
|
||||
if (rare && Math.random() < this.#bugSpawnChance) {
|
||||
const liveBugs = this.#entities.filter(e => e.entityKey === 'bug').length;
|
||||
if (liveBugs < this.#bugMaxCount) name = rare;
|
||||
}
|
||||
|
||||
const iconMeta = this.#iconsData.icons[name];
|
||||
const typeMeta = this.#iconsData.types[iconMeta.type];
|
||||
|
||||
@@ -135,12 +244,224 @@ export class EvolutionController {
|
||||
entity.update(multiplier);
|
||||
}
|
||||
|
||||
// Check and resolve entity-to-entity collisions after all positions updated
|
||||
this.#checkCollisions();
|
||||
|
||||
// Prune entities that died this frame (virus killed by mutation, slow-death complete)
|
||||
if (this.#entities.some(e => !e.alive)) {
|
||||
this.#entities = this.#entities.filter(e => e.alive);
|
||||
}
|
||||
|
||||
this.#animFrame = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
this.#animFrame = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// ── Collision detection ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* O(n²) broad + narrow-phase collision check for all entity pairs.
|
||||
*
|
||||
* On overlap:
|
||||
* 1. Elastic velocity impulse along the collision normal (equal-mass reflection).
|
||||
* 2. Positional correction — push both entities apart so they no longer overlap.
|
||||
* 3. Both entities call onHit() to shift their hue colour.
|
||||
* 4. If one entity is the rare "bug", the other is infected and transforms
|
||||
* into "virus-filled" (unless it's already a bug or virus).
|
||||
*/
|
||||
#checkCollisions() {
|
||||
const entities = this.#entities;
|
||||
const diameter = DEFAULTS.COLLISION_DIAMETER;
|
||||
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
for (let j = i + 1; j < entities.length; j++) {
|
||||
const a = entities[i];
|
||||
const b = entities[j];
|
||||
if (!a.alive || !b.alive || a.dying || b.dying) continue;
|
||||
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
|
||||
if (dist >= diameter || dist === 0) continue; // no collision
|
||||
|
||||
// ── Collision normal (unit vector from a → b) ──────────
|
||||
const nx = dx / dist;
|
||||
const ny = dy / dist;
|
||||
|
||||
// ── Elastic impulse along normal ───────────────────────
|
||||
// Bug collisions: only the bug's velocity reflects — the entity it hits
|
||||
// keeps its direction (bug is the "ghost" infector, not a physics partner).
|
||||
// Normal collisions: equal-mass exchange.
|
||||
const aIsBug = a.entityKey === 'bug';
|
||||
const bIsBug = b.entityKey === 'bug';
|
||||
const dot = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
|
||||
if (dot > 0) {
|
||||
if (aIsBug || bIsBug) {
|
||||
if (aIsBug) { a.vx -= dot * nx; a.vy -= dot * ny; }
|
||||
else { b.vx += dot * nx; b.vy += dot * ny; }
|
||||
} else {
|
||||
a.vx -= dot * nx; a.vy -= dot * ny;
|
||||
b.vx += dot * nx; b.vy += dot * ny;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Positional correction — push apart equally ─────────
|
||||
const half = (diameter - dist) / 2;
|
||||
a.x -= half * nx; a.y -= half * ny;
|
||||
b.x += half * nx; b.y += half * ny;
|
||||
|
||||
// ── Visual feedback — hue shift for non-bug collisions only ───
|
||||
// Bug passes through entities silently (no hue flash on target).
|
||||
if (!aIsBug && !bIsBug) {
|
||||
a.onHit();
|
||||
b.onHit();
|
||||
} else if (aIsBug) {
|
||||
a.onHit();
|
||||
} else {
|
||||
b.onHit();
|
||||
}
|
||||
|
||||
// ── Bug infection ──────────────────────────────────────
|
||||
// The rare "bug" transforms any non-immune entity into "virus-filled".
|
||||
if (!this.#iconsData) continue;
|
||||
const virusMeta = this.#iconsData.icons['virus-filled'];
|
||||
const virusColor = virusMeta
|
||||
? (this.#iconsData.types[virusMeta.type]?.color ?? '#ff4d6d')
|
||||
: '#ff4d6d';
|
||||
|
||||
// bug + normal entity → infects with virus
|
||||
// bug + virus-filled → backwards-transforms virus back to DNA (cure)
|
||||
const dnaMeta = this.#iconsData.icons['dna-bold-duotone'];
|
||||
const dnaColor = dnaMeta
|
||||
? (this.#iconsData.types[dnaMeta.type]?.color ?? '#a8ffb8')
|
||||
: '#a8ffb8';
|
||||
const bugImmune = new Set(['bug', 'bacteria']);
|
||||
|
||||
if (a.entityKey === 'bug') {
|
||||
if (b.entityKey === 'virus-filled') {
|
||||
b.infectWith('dna-bold-duotone', dnaColor, { force: true });
|
||||
} else if (!bugImmune.has(b.entityKey)) {
|
||||
b.infectWith('virus-filled', virusColor);
|
||||
}
|
||||
} else if (b.entityKey === 'bug') {
|
||||
if (a.entityKey === 'virus-filled') {
|
||||
a.infectWith('dna-bold-duotone', dnaColor, { force: true });
|
||||
} else if (!bugImmune.has(a.entityKey)) {
|
||||
a.infectWith('virus-filled', virusColor);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Virus kill / mutation ──────────────────────────────
|
||||
// virus-filled hitting a non-immune entity: 90% kills it, 10% triggers mutation.
|
||||
const virusImmune = new Set(['bug', 'virus-filled', 'bacteria']);
|
||||
const aIsVirus = a.entityKey === 'virus-filled';
|
||||
const bIsVirus = b.entityKey === 'virus-filled';
|
||||
|
||||
if (aIsVirus && !virusImmune.has(b.entityKey)) {
|
||||
this.#resolveVirusContact(a, b);
|
||||
} else if (bIsVirus && !virusImmune.has(a.entityKey)) {
|
||||
this.#resolveVirusContact(b, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Virus contact resolution ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle a collision between a virus-filled entity and a vulnerable target.
|
||||
* Bug is the sole mutation/transformation trigger — virus only kills.
|
||||
*
|
||||
* - virusKillChance: target begins slow-death sequence.
|
||||
* - else: target survives unaffected (virus bounces away normally).
|
||||
*
|
||||
* @param {import('./entity.js').Entity} _virus the virus-filled entity (unused)
|
||||
* @param {import('./entity.js').Entity} target the entity being contacted
|
||||
*/
|
||||
#resolveVirusContact(_virus, target) {
|
||||
if (Math.random() < this.#virusKillChance) {
|
||||
target.die();
|
||||
}
|
||||
// else: target survives — virus bounces, no transformation
|
||||
}
|
||||
|
||||
// ── State persistence ────────────────────────────────────────
|
||||
|
||||
/** Serialise all live (non-dying) entities + startTime to localStorage. */
|
||||
#saveState() {
|
||||
try {
|
||||
const snapshot = {
|
||||
startTime: this.#startTime,
|
||||
entities: this.#entities
|
||||
.filter(e => e.alive && !e.dying)
|
||||
.map(e => ({
|
||||
name: e.entityKey,
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
vx: e.vx,
|
||||
vy: e.vy,
|
||||
rotation: e.rotation,
|
||||
hueShift: e.hueShift,
|
||||
})),
|
||||
};
|
||||
localStorage.setItem(EVOLUTION_STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch { /* quota exceeded — ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate entities from a previously saved localStorage snapshot.
|
||||
* Called once during init(), before the spawner starts.
|
||||
*/
|
||||
async #restoreState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(EVOLUTION_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const saved = JSON.parse(raw);
|
||||
if (!saved || typeof saved !== 'object') return;
|
||||
|
||||
// Restore elapsed time so lifetime continues across reloads
|
||||
if (typeof saved.startTime === 'number') {
|
||||
this.#startTime = saved.startTime;
|
||||
}
|
||||
|
||||
const entities = saved.entities;
|
||||
if (!Array.isArray(entities) || entities.length === 0) return;
|
||||
|
||||
await Promise.all(entities.map(async (s) => {
|
||||
if (!this.#iconsData || !this.#container) return;
|
||||
const iconMeta = this.#iconsData.icons[s.name];
|
||||
if (!iconMeta) return;
|
||||
const typeMeta = this.#iconsData.types[iconMeta.type];
|
||||
if (!typeMeta) return;
|
||||
|
||||
const entity = new Entity({
|
||||
name: s.name,
|
||||
type: iconMeta.type,
|
||||
color: typeMeta.color,
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
vx: s.vx,
|
||||
vy: s.vy,
|
||||
rotation: s.rotation,
|
||||
});
|
||||
|
||||
await entity.mount(this.#container);
|
||||
|
||||
if (entity.alive) {
|
||||
// Restore accumulated hue-rotate from collisions
|
||||
if (s.hueShift && entity.bodyEl) {
|
||||
entity.hueShift = s.hueShift;
|
||||
entity.bodyEl.style.filter =
|
||||
`hue-rotate(${s.hueShift}deg) drop-shadow(0 1px 6px rgba(0,0,0,0.35))`;
|
||||
}
|
||||
this.#entities.push(entity);
|
||||
}
|
||||
}));
|
||||
} catch { /* corrupt save — start fresh */ }
|
||||
}
|
||||
|
||||
// ── Data loading ────────────────────────────────────────────
|
||||
|
||||
/** Fetch and return the icons.json configuration. */
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { deriveGradientPair } from '../utils/colorUtils.js';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
|
||||
/** Root element — CSS custom properties live here */
|
||||
const ROOT = document.documentElement;
|
||||
@@ -23,9 +24,9 @@ function speedToDuration(speed) {
|
||||
}
|
||||
|
||||
export class GradientController {
|
||||
/** @type {string} */ #color = '#4d22b3';
|
||||
/** @type {number} */ #speed = 2;
|
||||
/** @type {boolean} */ #rotating = false;
|
||||
/** @type {string} */ #color = DEFAULTS.GRADIENT_COLOR;
|
||||
/** @type {number} */ #speed = DEFAULTS.GRADIENT_SPEED;
|
||||
/** @type {boolean} */ #rotating = DEFAULTS.GRADIENT_ROTATION;
|
||||
|
||||
/**
|
||||
* Initialise gradient with a base colour.
|
||||
@@ -36,6 +37,7 @@ export class GradientController {
|
||||
init(hex) {
|
||||
this.setColor(hex);
|
||||
this.setSpeed(this.#speed);
|
||||
this.toggleRotation(this.#rotating);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* guide.js
|
||||
* GuideController — manages the evolution guide panel.
|
||||
*
|
||||
* The panel lives in the DOM (HTML), always hidden.
|
||||
* On first open, guide.js fetches and injects SVG icons into
|
||||
* [data-guide-icon] slots so the panel shows actual game icons.
|
||||
*
|
||||
* While the panel is open, a 200 ms interval keeps entity population
|
||||
* counts and the system uptime display live.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Guide button (bottom-right): click to open/close.
|
||||
* - Click outside the panel: closes it.
|
||||
* - Escape key: closes it.
|
||||
* - Clicking an entity row spawns that entity at a random position.
|
||||
*/
|
||||
|
||||
import { loadIcon } from './iconLoader.js';
|
||||
|
||||
/** Format ms → "m:ss" or "h:mm:ss". */
|
||||
function formatDuration(ms) {
|
||||
const s = Math.floor(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
if (h > 0) {
|
||||
return `${h}:${String(m % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export class GuideController {
|
||||
/** @type {HTMLElement|null} */ #panel = null;
|
||||
/** @type {HTMLElement|null} */ #btn = null;
|
||||
/** @type {boolean} */ #open = false;
|
||||
/** @type {boolean} */ #iconsLoaded = false;
|
||||
/** @type {import('./evolution.js').EvolutionController|null} */ #evolution = null;
|
||||
/** @type {ReturnType<typeof setInterval>|null} */ #statsTimer = null;
|
||||
|
||||
/**
|
||||
* Attach event listeners. Must be called after DOM is ready.
|
||||
*
|
||||
* @param {import('./evolution.js').EvolutionController} [evolution]
|
||||
* Optional — if provided, clicking an entity row will spawn that entity
|
||||
* and the panel will show live population counts + system uptime.
|
||||
*/
|
||||
init(evolution = null) {
|
||||
this.#evolution = evolution;
|
||||
this.#btn = document.getElementById('guideBtn');
|
||||
this.#panel = document.getElementById('guidePanel');
|
||||
if (!this.#btn || !this.#panel) return;
|
||||
|
||||
this.#btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.#open ? this.close() : this.open();
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.#open && !this.#panel.contains(e.target)) this.close();
|
||||
});
|
||||
|
||||
// Close on Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.#open) this.close();
|
||||
});
|
||||
|
||||
// Entity row clicks → spawn that entity
|
||||
if (this.#evolution) this.#bindEntityClicks();
|
||||
|
||||
// Restart button → clear all entities + saved state
|
||||
const resetBtn = document.getElementById('guideResetBtn');
|
||||
if (resetBtn && this.#evolution) {
|
||||
resetBtn.addEventListener('click', () => this.#evolution.clear());
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.#open = true;
|
||||
this.#panel.classList.add('guide-panel--visible');
|
||||
this.#panel.setAttribute('aria-hidden', 'false');
|
||||
this.#btn.setAttribute('aria-expanded', 'true');
|
||||
if (!this.#iconsLoaded) this.#populateIcons();
|
||||
if (this.#evolution) this.#startLiveUpdate();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#open = false;
|
||||
this.#panel.classList.remove('guide-panel--visible');
|
||||
this.#panel.setAttribute('aria-hidden', 'true');
|
||||
this.#btn.setAttribute('aria-expanded', 'false');
|
||||
this.#stopLiveUpdate();
|
||||
}
|
||||
|
||||
// ── Private ─────────────────────────────────────────────────
|
||||
|
||||
/** Wire each .guide-entity row to spawn that entity on click. */
|
||||
#bindEntityClicks() {
|
||||
this.#panel.querySelectorAll('.guide-entity').forEach(item => {
|
||||
const slot = item.querySelector('[data-guide-icon]');
|
||||
if (!slot) return;
|
||||
const iconName = slot.dataset.guideIcon;
|
||||
item.addEventListener('click', () => this.#evolution.spawnNamed(iconName));
|
||||
});
|
||||
}
|
||||
|
||||
/** Start a 200 ms interval that refreshes counts + uptime while the panel is open. */
|
||||
#startLiveUpdate() {
|
||||
this.#refreshStats();
|
||||
this.#statsTimer = setInterval(() => this.#refreshStats(), 200);
|
||||
}
|
||||
|
||||
#stopLiveUpdate() {
|
||||
if (this.#statsTimer !== null) {
|
||||
clearInterval(this.#statsTimer);
|
||||
this.#statsTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Push current entity counts and system lifetime into the panel DOM. */
|
||||
#refreshStats() {
|
||||
if (!this.#evolution || !this.#panel) return;
|
||||
|
||||
const counts = this.#evolution.getCounts();
|
||||
const lifetime = this.#evolution.lifetime;
|
||||
|
||||
// Update per-entity count badges
|
||||
this.#panel.querySelectorAll('[data-count-key]').forEach(item => {
|
||||
const el = item.querySelector('.guide-entity__count');
|
||||
if (el) el.textContent = String(counts[item.dataset.countKey] ?? 0);
|
||||
});
|
||||
|
||||
// Update uptime display
|
||||
const uptimeEl = this.#panel.querySelector('.guide-panel__uptime');
|
||||
if (uptimeEl) uptimeEl.textContent = formatDuration(lifetime);
|
||||
}
|
||||
|
||||
/** Inject SVGs into every [data-guide-icon] slot in the panel. */
|
||||
async #populateIcons() {
|
||||
this.#iconsLoaded = true;
|
||||
const slots = this.#panel.querySelectorAll('[data-guide-icon]');
|
||||
await Promise.all(Array.from(slots).map(async (slot) => {
|
||||
try {
|
||||
slot.innerHTML = await loadIcon(slot.dataset.guideIcon);
|
||||
} catch {
|
||||
// Silent fail — slot stays empty
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,14 @@ import { GradientController } from './gradient.js';
|
||||
import { ModalController } from './modal.js';
|
||||
import { SettingsController } from './settings.js';
|
||||
import { EvolutionController } from './evolution.js';
|
||||
import { GuideController } from './guide.js';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
|
||||
// ── Initialise controllers ─────────────────────────────────
|
||||
const gradient = new GradientController();
|
||||
const modal = new ModalController();
|
||||
const evolution = new EvolutionController();
|
||||
const guide = new GuideController();
|
||||
|
||||
// SettingsController bridges UI → gradient + evolution
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
@@ -30,8 +32,14 @@ document.getElementById('settingsBtn').addEventListener('click', (e) => {
|
||||
modal.open(/** @type {HTMLElement} */ (e.currentTarget));
|
||||
});
|
||||
|
||||
// ── Wire up evolution guide ────────────────────────────────
|
||||
guide.init(evolution);
|
||||
|
||||
// ── Boot gradient ──────────────────────────────────────────
|
||||
gradient.init(DEFAULTS.GRADIENT_COLOR);
|
||||
|
||||
// Apply saved settings after gradient has set its defaults
|
||||
settings.loadSaved();
|
||||
|
||||
// ── Boot evolution (async — loads icons.json + warms SVG cache) ──
|
||||
evolution.init(document.getElementById('evolutionContainer'));
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
/**
|
||||
* settings.js
|
||||
* Connects the settings UI controls to GradientController and EvolutionController.
|
||||
* Owns no state of its own — it only reads from the DOM and
|
||||
* forwards values to the relevant controller.
|
||||
*
|
||||
* Persistence:
|
||||
* - Every setting change is immediately written to localStorage.
|
||||
* - Call loadSaved() from main.js (after gradient.init()) to restore the
|
||||
* saved state on the next page load so the system continues as configured.
|
||||
*
|
||||
* Reset:
|
||||
* - resetBtn clears localStorage, restores DEFAULTS, and wipes live entities.
|
||||
*/
|
||||
|
||||
import { DEFAULTS } from './constants.js';
|
||||
|
||||
const STORAGE_KEY = 'devpage:settings';
|
||||
|
||||
export class SettingsController {
|
||||
/** @type {import('./gradient.js').GradientController} */
|
||||
#gradient;
|
||||
@@ -20,41 +30,151 @@ export class SettingsController {
|
||||
this.#gradient = gradient;
|
||||
this.#evolution = evolution;
|
||||
this.#bindEvents();
|
||||
this.#syncControls();
|
||||
}
|
||||
|
||||
// ── Public ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply saved localStorage settings to the controllers and sync the UI.
|
||||
* Must be called AFTER gradient.init() so saved values override DEFAULTS.
|
||||
*/
|
||||
loadSaved() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const s = JSON.parse(raw);
|
||||
if (typeof s.gradientColor === 'string') this.#gradient.setColor(s.gradientColor);
|
||||
if (typeof s.gradientSpeed === 'number') this.#gradient.setSpeed(s.gradientSpeed);
|
||||
if (typeof s.gradientRotation === 'boolean') this.#gradient.toggleRotation(s.gradientRotation);
|
||||
if (typeof s.moveSpeed === 'number') this.#evolution.setMoveSpeed(s.moveSpeed);
|
||||
if (typeof s.spawnRate === 'number') this.#evolution.setSpawnRate(s.spawnRate);
|
||||
if (typeof s.virusKillChance === 'number') this.#evolution.setVirusKillChance(s.virusKillChance);
|
||||
if (typeof s.bugSpawnChance === 'number') this.#evolution.setBugSpawnChance(s.bugSpawnChance);
|
||||
if (typeof s.bugMaxCount === 'number') this.#evolution.setBugMaxCount(s.bugMaxCount);
|
||||
} catch { /* corrupt storage — ignore */ }
|
||||
this.#syncControls();
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────
|
||||
|
||||
#bindEvents() {
|
||||
const colorPicker = /** @type {HTMLInputElement} */ (document.getElementById('colorPicker'));
|
||||
const colorValue = /** @type {HTMLElement} */ (document.getElementById('colorValue'));
|
||||
const speedSlider = /** @type {HTMLInputElement} */ (document.getElementById('speedSlider'));
|
||||
const moveSpeedSlider = /** @type {HTMLInputElement} */ (document.getElementById('moveSpeedSlider'));
|
||||
const rotToggle = /** @type {HTMLInputElement} */ (document.getElementById('rotationToggle'));
|
||||
const colorPicker = /** @type {HTMLInputElement} */ (document.getElementById('colorPicker'));
|
||||
const colorValue = /** @type {HTMLElement} */ (document.getElementById('colorValue'));
|
||||
const speedSlider = /** @type {HTMLInputElement} */ (document.getElementById('speedSlider'));
|
||||
const rotToggle = /** @type {HTMLInputElement} */ (document.getElementById('rotationToggle'));
|
||||
const moveSpeedSlider = /** @type {HTMLInputElement} */ (document.getElementById('moveSpeedSlider'));
|
||||
const spawnRateSlider = /** @type {HTMLInputElement} */ (document.getElementById('spawnRateSlider'));
|
||||
const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider'));
|
||||
const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider'));
|
||||
const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
|
||||
const resetBtn = /** @type {HTMLButtonElement} */ (document.getElementById('resetBtn'));
|
||||
|
||||
// ── Colour picker ──────────────────────────────────────
|
||||
// 'input' fires on every pointer move — real-time background preview
|
||||
colorPicker.addEventListener('input', (e) => {
|
||||
const hex = /** @type {HTMLInputElement} */ (e.target).value;
|
||||
colorValue.textContent = hex;
|
||||
this.#gradient.setColor(hex);
|
||||
this.#saveState();
|
||||
});
|
||||
|
||||
// ── Gradient animation speed ───────────────────────────
|
||||
speedSlider.addEventListener('input', (e) => {
|
||||
const speed = Number(/** @type {HTMLInputElement} */ (e.target).value);
|
||||
this.#gradient.setSpeed(speed);
|
||||
});
|
||||
|
||||
// ── Icon movement speed ────────────────────────────────
|
||||
moveSpeedSlider.addEventListener('input', (e) => {
|
||||
const speed = Number(/** @type {HTMLInputElement} */ (e.target).value);
|
||||
this.#evolution.setMoveSpeed(speed);
|
||||
this.#gradient.setSpeed(Number(/** @type {HTMLInputElement} */ (e.target).value));
|
||||
this.#saveState();
|
||||
});
|
||||
|
||||
// ── Rotation toggle ────────────────────────────────────
|
||||
rotToggle.addEventListener('change', (e) => {
|
||||
const enabled = /** @type {HTMLInputElement} */ (e.target).checked;
|
||||
this.#gradient.toggleRotation(enabled);
|
||||
this.#gradient.toggleRotation(/** @type {HTMLInputElement} */ (e.target).checked);
|
||||
this.#saveState();
|
||||
});
|
||||
|
||||
// ── Icon movement speed ────────────────────────────────
|
||||
moveSpeedSlider.addEventListener('input', (e) => {
|
||||
this.#evolution.setMoveSpeed(Number(/** @type {HTMLInputElement} */ (e.target).value));
|
||||
this.#saveState();
|
||||
});
|
||||
|
||||
// ── Spawn rate ─────────────────────────────────────────
|
||||
spawnRateSlider.addEventListener('input', (e) => {
|
||||
this.#evolution.setSpawnRate(Number(/** @type {HTMLInputElement} */ (e.target).value));
|
||||
this.#saveState();
|
||||
});
|
||||
|
||||
// ── Virus lethality ────────────────────────────────────
|
||||
virusKillSlider.addEventListener('input', (e) => {
|
||||
this.#evolution.setVirusKillChance(Number(/** @type {HTMLInputElement} */ (e.target).value) / 100);
|
||||
this.#saveState();
|
||||
});
|
||||
|
||||
// ── Bug spawn chance ───────────────────────────────────
|
||||
bugChanceSlider.addEventListener('input', (e) => {
|
||||
this.#evolution.setBugSpawnChance(Number(/** @type {HTMLInputElement} */ (e.target).value) / 100);
|
||||
this.#saveState();
|
||||
});
|
||||
|
||||
// ── Bug max count ──────────────────────────────────────
|
||||
bugCountSlider.addEventListener('input', (e) => {
|
||||
this.#evolution.setBugMaxCount(Number(/** @type {HTMLInputElement} */ (e.target).value));
|
||||
this.#saveState();
|
||||
});
|
||||
|
||||
// ── Reset to defaults ──────────────────────────────────
|
||||
resetBtn.addEventListener('click', () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
this.#gradient.setColor(DEFAULTS.GRADIENT_COLOR);
|
||||
this.#gradient.setSpeed(DEFAULTS.GRADIENT_SPEED);
|
||||
this.#gradient.toggleRotation(DEFAULTS.GRADIENT_ROTATION);
|
||||
this.#evolution.setMoveSpeed(DEFAULTS.MOVE_SPEED);
|
||||
this.#evolution.setSpawnRate(5);
|
||||
this.#evolution.setVirusKillChance(DEFAULTS.VIRUS_KILL_CHANCE);
|
||||
this.#evolution.setBugSpawnChance(DEFAULTS.BUG_SPAWN_CHANCE);
|
||||
this.#evolution.setBugMaxCount(DEFAULTS.BUG_MAX_COUNT);
|
||||
this.#evolution.clear();
|
||||
this.#syncControls();
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist current controller state to localStorage. */
|
||||
#saveState() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
gradientColor: this.#gradient.color,
|
||||
gradientSpeed: this.#gradient.speed,
|
||||
gradientRotation: this.#gradient.rotating,
|
||||
moveSpeed: this.#evolution.moveSpeed,
|
||||
spawnRate: this.#evolution.spawnRate,
|
||||
virusKillChance: this.#evolution.virusKillChance,
|
||||
bugSpawnChance: this.#evolution.bugSpawnChance,
|
||||
bugMaxCount: this.#evolution.bugMaxCount,
|
||||
}));
|
||||
} catch { /* quota exceeded or private browsing — ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Push controller state → DOM controls so the UI always matches.
|
||||
* Called on construction, after loadSaved(), and after reset.
|
||||
*/
|
||||
#syncControls() {
|
||||
const colorPicker = /** @type {HTMLInputElement} */ (document.getElementById('colorPicker'));
|
||||
const colorValue = /** @type {HTMLElement} */ (document.getElementById('colorValue'));
|
||||
const speedSlider = /** @type {HTMLInputElement} */ (document.getElementById('speedSlider'));
|
||||
const rotToggle = /** @type {HTMLInputElement} */ (document.getElementById('rotationToggle'));
|
||||
const moveSpeedSlider = /** @type {HTMLInputElement} */ (document.getElementById('moveSpeedSlider'));
|
||||
const spawnRateSlider = /** @type {HTMLInputElement} */ (document.getElementById('spawnRateSlider'));
|
||||
const virusKillSlider = /** @type {HTMLInputElement} */ (document.getElementById('virusKillSlider'));
|
||||
const bugChanceSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugChanceSlider'));
|
||||
const bugCountSlider = /** @type {HTMLInputElement} */ (document.getElementById('bugCountSlider'));
|
||||
|
||||
colorPicker.value = this.#gradient.color;
|
||||
colorValue.textContent = this.#gradient.color;
|
||||
speedSlider.value = String(this.#gradient.speed);
|
||||
rotToggle.checked = this.#gradient.rotating;
|
||||
moveSpeedSlider.value = String(this.#evolution.moveSpeed);
|
||||
spawnRateSlider.value = String(this.#evolution.spawnRate);
|
||||
virusKillSlider.value = String(Math.round(this.#evolution.virusKillChance * 100));
|
||||
bugChanceSlider.value = String(Math.round(this.#evolution.bugSpawnChance * 100));
|
||||
bugCountSlider.value = String(this.#evolution.bugMaxCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
guide.css — evolution guide button and panel
|
||||
════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Guide trigger button (position only — appearance from .icon-btn) ── */
|
||||
.guide-btn {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* ── Guide panel ─────────────────────────────────────────── */
|
||||
.guide-panel {
|
||||
position: fixed;
|
||||
bottom: 4.2rem;
|
||||
right: 1.5rem;
|
||||
z-index: 200;
|
||||
|
||||
width: 272px;
|
||||
padding: 1.1rem 1.2rem 1.2rem;
|
||||
|
||||
background: var(--modal-surface);
|
||||
border: 1px solid var(--modal-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
|
||||
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.guide-panel--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── Panel header (title + uptime) ───────────────────────── */
|
||||
.guide-panel__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.guide-panel__title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-lo);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.guide-panel__uptime {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.58rem;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
letter-spacing: 0.05em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────── */
|
||||
.guide-section + .guide-section {
|
||||
margin-top: 0.9rem;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--modal-divider);
|
||||
}
|
||||
|
||||
.guide-section__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.58rem;
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
margin: 0 0 0.55rem;
|
||||
}
|
||||
|
||||
/* ── Entities list ───────────────────────────────────────── */
|
||||
.guide-entities {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.48rem;
|
||||
}
|
||||
|
||||
.guide-entity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 0.2rem 0.35rem;
|
||||
margin: 0 -0.35rem;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.guide-entity:hover {
|
||||
background: var(--surface-tint);
|
||||
}
|
||||
|
||||
/* Icon container — SVG injected here by guide.js */
|
||||
.guide-entity__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--c, rgba(255, 255, 255, 0.6));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--c, white) 60%, transparent));
|
||||
}
|
||||
.guide-entity__icon svg { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.guide-entity__name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--c, rgba(255, 255, 255, 0.8));
|
||||
width: 54px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.guide-entity__role {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.guide-entity__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
color: rgba(255, 255, 255, 0.22);
|
||||
min-width: 20px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Interactions list ───────────────────────────────────── */
|
||||
.guide-rules {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.guide-rule {
|
||||
display: grid;
|
||||
grid-template-columns: 9px 9px 9px 1fr;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
/* Coloured circle representing an entity type */
|
||||
.guide-rule__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--c, rgba(255, 255, 255, 0.25));
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 5px color-mix(in srgb, var(--c, white) 55%, transparent);
|
||||
}
|
||||
|
||||
.guide-rule__op {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.55rem;
|
||||
color: rgba(255, 255, 255, 0.22);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guide-rule__desc {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.guide-rule__pct {
|
||||
color: rgba(255, 255, 255, 0.22);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
/* ── Guide footer + restart button ───────────────────────── */
|
||||
.guide-footer {
|
||||
margin-top: 0.9rem;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--modal-divider);
|
||||
}
|
||||
|
||||
.guide-reset-btn {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
padding: 5px 0;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: lowercase;
|
||||
color: rgba(255, 255, 255, 0.28);
|
||||
|
||||
transition:
|
||||
color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.guide-reset-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.guide-reset-btn:active {
|
||||
color: var(--text-hi);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.guide-reset-btn:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -70,3 +70,38 @@
|
||||
display: block;
|
||||
/* SVGs use currentColor — colour is inherited from .icon-entity */
|
||||
}
|
||||
|
||||
/* ── Collision hit flash ─────────────────────────────────── */
|
||||
/*
|
||||
* Brief scale-punch animation applied by Entity.onHit().
|
||||
* Runs independently of the scale transition on .icon-entity__body —
|
||||
* CSS animations take priority over transitions, and both settle on scale:1.
|
||||
*
|
||||
* The class is added and removed by JS; the `both` fill-mode holds the
|
||||
* final scale:1 state until the class is removed, avoiding any jump.
|
||||
*/
|
||||
@keyframes entity-hit {
|
||||
0% { scale: 1; }
|
||||
35% { scale: 1.5; }
|
||||
100% { scale: 1; }
|
||||
}
|
||||
|
||||
.icon-entity__body--hit {
|
||||
animation: entity-hit 0.35s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
}
|
||||
|
||||
/* ── Entity slow-death state ─────────────────────────────── */
|
||||
/*
|
||||
* Added by Entity.die() after KILL_FADE_MS delay.
|
||||
* The entity is already light-red (set via inline color) when this kicks in.
|
||||
* Transitions to dark + grayscale over ~2.6s before JS destroys the DOM node.
|
||||
* scale:1 prevents the spawning transition from interfering.
|
||||
*/
|
||||
.icon-entity__body--dying {
|
||||
scale: 1 !important;
|
||||
opacity: 0.12;
|
||||
filter: brightness(0.2) grayscale(1) drop-shadow(0 0 0 transparent) !important;
|
||||
transition:
|
||||
opacity 2.6s ease-in,
|
||||
filter 2.6s ease-in !important;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
--rotation-duration: 22s;
|
||||
|
||||
/* Modal surface */
|
||||
--modal-surface: rgba(10, 10, 14, 0.82);
|
||||
--modal-surface: rgba(10, 10, 14, 0.6);
|
||||
--modal-border: rgba(255, 255, 255, 0.08);
|
||||
--modal-divider: rgba(255, 255, 255, 0.06);
|
||||
|
||||
@@ -52,14 +52,9 @@ body {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Settings Button ─────────────────────────────────────── */
|
||||
.settings-btn {
|
||||
position: fixed;
|
||||
top: 22px;
|
||||
right: 26px;
|
||||
z-index: 100;
|
||||
|
||||
/* Strip all button decoration */
|
||||
/* ── Icon Button (shared base — settings + guide) ────────── */
|
||||
.icon-btn {
|
||||
/* Strip browser defaults */
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -67,21 +62,22 @@ body {
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
|
||||
/* Shape & layout */
|
||||
/* Shape */
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
/* Typography */
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
|
||||
transition:
|
||||
@@ -89,18 +85,27 @@ body {
|
||||
background 0.18s ease;
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
.icon-btn:hover,
|
||||
.icon-btn[aria-expanded="true"] {
|
||||
color: var(--text-hi);
|
||||
background: var(--surface-tint);
|
||||
}
|
||||
|
||||
.settings-btn:active {
|
||||
.icon-btn:active {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
/* Focus-visible ring for keyboard nav */
|
||||
.settings-btn:focus-visible {
|
||||
.icon-btn:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Settings Button (position only) ────────────────────── */
|
||||
.settings-btn {
|
||||
position: fixed;
|
||||
top: 22px;
|
||||
right: 26px;
|
||||
z-index: 100;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,35 @@
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
gap: 0;
|
||||
max-height: calc(90vh - 60px);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.modal-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ── Settings section (visual group) ─────────────────────── */
|
||||
.settings-section {
|
||||
padding: 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-section + .settings-section {
|
||||
border-top: 1px solid var(--modal-divider);
|
||||
}
|
||||
|
||||
.settings-section__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-lo);
|
||||
opacity: 0.5;
|
||||
user-select: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Setting group (label + control) ─────────────────────── */
|
||||
@@ -333,3 +361,49 @@
|
||||
outline: 2px solid rgba(255, 255, 255, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Modal footer + reset button ─────────────────────────── */
|
||||
.modal-footer {
|
||||
padding: 14px 20px 18px;
|
||||
border-top: 1px solid var(--modal-divider);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
padding: 7px 0;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 80, 80, 0.22);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.60rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: lowercase;
|
||||
color: rgba(255, 120, 100, 0.55);
|
||||
|
||||
transition:
|
||||
color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
color: rgba(255, 140, 110, 0.85);
|
||||
background: rgba(255, 80, 60, 0.09);
|
||||
border-color: rgba(255, 80, 80, 0.40);
|
||||
}
|
||||
|
||||
.reset-btn:active {
|
||||
color: rgba(255, 160, 130, 1);
|
||||
background: rgba(255, 80, 60, 0.16);
|
||||
}
|
||||
|
||||
.reset-btn:focus-visible {
|
||||
outline: 2px solid rgba(255, 100, 80, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||