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.
This commit is contained in:
sha
2026-02-21 16:27:34 +02:00
parent 899d9d7c13
commit e3ec163ad7
19 changed files with 1428 additions and 112 deletions
+9
View File
@@ -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:*)"
]
}
}
+176 -39
View File
@@ -11,11 +11,12 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <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/main.css" />
<link rel="stylesheet" href="src/styles/gradient.css" /> <link rel="stylesheet" href="src/styles/gradient.css" />
<link rel="stylesheet" href="src/styles/modal.css" /> <link rel="stylesheet" href="src/styles/modal.css" />
<link rel="stylesheet" href="src/styles/icons.css" /> <link rel="stylesheet" href="src/styles/icons.css" />
<link rel="stylesheet" href="src/styles/guide.css" />
</head> </head>
<body> <body>
@@ -23,13 +24,107 @@
<div class="evolution-container" id="evolutionContainer" aria-hidden="true"></div> <div class="evolution-container" id="evolutionContainer" aria-hidden="true"></div>
<!-- ── Settings trigger ─────────────────────────────────── --> <!-- ── 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> </button>
<!-- ── Main content area (grows with future features) ───── --> <!-- ── Main content area (grows with future features) ───── -->
<main class="page-main" id="pageMain"></main> <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 &nbsp;<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 ──────────────────────────────────── --> <!-- ── Settings modal ──────────────────────────────────── -->
<div <div
class="modal-overlay" class="modal-overlay"
@@ -48,51 +143,93 @@
<div class="modal-body"> <div class="modal-body">
<!-- Background color --> <!-- ── background ──────────────────────────── -->
<div class="setting-group"> <div class="settings-section">
<label class="setting-label" for="colorPicker">background color</label> <div class="setting-group">
<div class="color-input-wrapper"> <label class="setting-label" for="colorPicker">background colour</label>
<input type="color" id="colorPicker" value="#4d22b3" class="color-picker" /> <div class="color-input-wrapper">
<span class="color-value" id="colorValue">#4d22b3</span> <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>
</div> </div>
<!-- Gradient animation speed --> <!-- ── elements ────────────────────────────── -->
<div class="setting-group"> <div class="settings-section">
<label class="setting-label" for="speedSlider">animation speed</label> <p class="settings-section__label">elements</p>
<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>
<!-- Icon movement speed --> <div class="setting-group">
<div class="setting-group"> <label class="setting-label" for="moveSpeedSlider">movement speed</label>
<label class="setting-label" for="moveSpeedSlider">icon movement speed</label> <div class="slider-wrapper">
<div class="slider-wrapper"> <span class="slider-label">slow</span>
<span class="slider-label">slow</span> <input type="range" id="moveSpeedSlider" min="1" max="50" value="5" class="slider" />
<input type="range" id="moveSpeedSlider" min="1" max="10" value="5" class="slider" /> <span class="slider-label">fast</span>
<span class="slider-label">fast</span> </div>
</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> </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> </div>
<div class="modal-footer">
<button class="reset-btn" id="resetBtn">reset to defaults</button>
</div>
</div> </div>
</div> </div>
+14 -6
View File
@@ -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": [ "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": "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": "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": { "icons": {
@@ -17,16 +18,23 @@
"chat-round-unread-bold-duotone": { "label": "Unread Chat", "type": "neutral", "group": "social" }, "chat-round-unread-bold-duotone": { "label": "Unread Chat", "type": "neutral", "group": "social" },
"database-bold-duotone": { "label": "Database", "type": "good", "group": "tech" }, "database-bold-duotone": { "label": "Database", "type": "good", "group": "tech" },
"delivery-bold-duotone": { "label": "Delivery", "type": "good", "group": "economy" }, "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": { "types": {
"good": { "label": "Beneficial", "color": "#a8ffb8" }, "good": { "label": "Beneficial", "color": "#a8ffb8" },
"bad": { "label": "Harmful", "color": "#ffb0a8" }, "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": { "spawn": {
"initial": "dna-bold-duotone" "initial": "dna-bold-duotone",
"rare": "bug",
"rareChance": 0.08
} }
} }
+15
View File
@@ -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
View File
@@ -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

+23
View File
@@ -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
View File
@@ -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

+18
View File
@@ -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

+26
View File
@@ -40,4 +40,30 @@ export const DEFAULTS = {
/** Max |Δv| applied on each drift kick */ /** Max |Δv| applied on each drift kick */
DRIFT_MAGNITUDE: 0.25, 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,
}; };
+140 -12
View File
@@ -7,9 +7,15 @@
* organic, chaotic-feeling motion (not perfectly straight lines). * organic, chaotic-feeling motion (not perfectly straight lines).
* - Edge detection: velocity component is reflected (±abs) on contact, * - Edge detection: velocity component is reflected (±abs) on contact,
* ensuring the bounce always sends the icon back into the viewport. * ensuring the bounce always sends the icon back into the viewport.
* - Rotation: each entity spawns with a fixed random orientation (0360°).
*
* 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: * 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 * <div class="icon-entity__body"> ← scale animation via CSS transition
* <svg>…</svg> ← injected SVG, sized by CSS * <svg>…</svg> ← injected SVG, sized by CSS
* </div> * </div>
@@ -28,27 +34,42 @@ export class Entity {
/** @type {number} */ y; /** @type {number} */ y;
/** @type {number} */ vx; /** @type {number} */ vx;
/** @type {number} */ vy; /** @type {number} */ vy;
/** Fixed spawn orientation in degrees (0360) */
/** @type {number} */ rotation;
// ── Identity ───────────────────────────────────────────── // ── Identity ─────────────────────────────────────────────
/** The current icon name — changes on infection. */
/** @type {string} */ entityKey;
/** @type {string} */ name; /** @type {string} */ name;
/** @type {string} */ type; /** @type {string} */ type;
/** @type {string} */ color; /** @type {string} */ color;
/** @type {boolean} */ alive = true; /** @type {boolean} */ alive = true;
// ── Visual state ──────────────────────────────────────────
/** Accumulated hue-rotate offset (degrees). Increases on each hit. */
/** @type {number} */ hueShift = 0;
// ── DOM ─────────────────────────────────────────────────── // ── DOM ───────────────────────────────────────────────────
/** @type {HTMLElement|null} */ el = null; /** @type {HTMLElement|null} */ el = null;
/** @type {HTMLElement|null} */ bodyEl = null; /** @type {HTMLElement|null} */ bodyEl = null;
// ── Private ───────────────────────────────────────────────
/** @type {boolean} */ #infected = false;
/** @type {boolean} */ #dying = false;
/** /**
* @param {{ name: string, type: string, color: string, * @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 }) { constructor({ name, type, color, x, y, vx, vy, rotation }) {
this.name = name; this.name = name;
this.type = type; this.entityKey = name;
this.color = color; this.type = type;
this.x = x; this.y = y; this.color = color;
this.vx = vx; this.vy = vy; this.x = x; this.y = y;
this.vx = vx; this.vy = vy;
this.rotation = rotation !== undefined ? rotation : Math.random() * 360;
} }
// ── Public API ───────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────
@@ -101,7 +122,7 @@ export class Entity {
* @param {number} speedMultiplier scales BASE_SPEED (e.g. slider / 5) * @param {number} speedMultiplier scales BASE_SPEED (e.g. slider / 5)
*/ */
update(speedMultiplier) { update(speedMultiplier) {
if (!this.el || !this.alive) return; if (!this.el || !this.alive || this.#dying) return;
// Move // Move
this.x += this.vx * speedMultiplier; this.x += this.vx * speedMultiplier;
@@ -134,6 +155,110 @@ export class Entity {
this._applyTransform(); 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. */ /** Remove this entity from the DOM and mark it as dead. */
destroy() { destroy() {
this.alive = false; this.alive = false;
@@ -145,13 +270,16 @@ export class Entity {
// ── Private ──────────────────────────────────────────────── // ── 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). * 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() { _applyTransform() {
if (!this.el) return; if (!this.el) return;
const { ICON_HALF: h } = DEFAULTS; 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)`;
} }
} }
+335 -14
View File
@@ -3,12 +3,19 @@
* Orchestrates the icon evolution system: * Orchestrates the icon evolution system:
* *
* 1. Spawning — after a random interval (SPAWN_DELAY_MIN … SPAWN_DELAY_MAX), * 1. Spawning — after a random interval (SPAWN_DELAY_MIN … SPAWN_DELAY_MAX),
* a dna-bold-duotone icon appears at a random viewport position. * an icon appears at a random viewport position. Most spawns produce the
* After each spawn the timer resets for the next one. * "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. * 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(110) 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(110) scales entity velocity in real time.
* *
* The icon to spawn first is declared in icons.json under spawn.initial. * The icon to spawn first is declared in icons.json under spawn.initial.
* All icon metadata (type, colour) is also read from icons.json. * 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 { preloadIcons } from './iconLoader.js';
import { DEFAULTS } from './constants.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 { export class EvolutionController {
/** @type {Entity[]} */ #entities = []; /** @type {Entity[]} */ #entities = [];
/** @type {HTMLElement|null} */ #container = null; /** @type {HTMLElement|null} */ #container = null;
/** @type {number} */ #moveSpeed = DEFAULTS.MOVE_SPEED; /** @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 setTimeout>|null} */ #spawnTimer = null;
/** @type {ReturnType<typeof setInterval>|null} */ #saveTimer = null;
/** @type {number|null} */ #animFrame = null; /** @type {number|null} */ #animFrame = null;
/** @type {boolean} */ #running = false; /** @type {boolean} */ #running = false;
/** @type {object|null} */ #iconsData = null; /** @type {object|null} */ #iconsData = null;
@@ -41,12 +55,25 @@ export class EvolutionController {
this.#iconsData = await this.#loadIconsData(); this.#iconsData = await this.#loadIconsData();
// Warm the SVG cache for the initial icon so first spawn is instant // Warm the SVG cache for both the normal and rare icons so first spawns are instant
const initialName = this.#iconsData.spawn.initial; const { initial, rare } = this.#iconsData.spawn;
preloadIcons([initialName]); 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.#scheduleNextSpawn();
this.#startLoop(); 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; this.#moveSpeed = speed;
} }
/** Current move speed setting (150). 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 (01) that a virus contact kills its target. */
setVirusKillChance(v) { this.#virusKillChance = v; }
get virusKillChance() { return this.#virusKillChance; }
/** Probability (01) 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 all timers and the rAF loop; remove all entities from the DOM. */
stop() { stop() {
this.#running = false; this.#running = false;
if (this.#spawnTimer !== null) clearTimeout(this.#spawnTimer); if (this.#spawnTimer !== null) clearTimeout(this.#spawnTimer);
if (this.#animFrame !== null) cancelAnimationFrame(this.#animFrame); if (this.#animFrame !== null) cancelAnimationFrame(this.#animFrame);
if (this.#saveTimer !== null) clearInterval(this.#saveTimer);
this.#entities.forEach(e => e.destroy()); this.#entities.forEach(e => e.destroy());
this.#entities = []; this.#entities = [];
} }
@@ -78,9 +177,11 @@ export class EvolutionController {
* Self-resets after each spawn so the process continues indefinitely. * Self-resets after each spawn so the process continues indefinitely.
*/ */
#scheduleNextSpawn() { #scheduleNextSpawn() {
const min = DEFAULTS.SPAWN_DELAY_MIN; // Exponential scale: rate 5 → default delays; higher rate → shorter delays.
const max = DEFAULTS.SPAWN_DELAY_MAX; const factor = Math.pow(this.#spawnRate / 5, 1.5);
const delay = min + Math.random() * (max - min); 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 () => { this.#spawnTimer = setTimeout(async () => {
await this.#spawnEntity(); await this.#spawnEntity();
@@ -92,7 +193,15 @@ export class EvolutionController {
async #spawnEntity() { async #spawnEntity() {
if (!this.#container || !this.#iconsData) return; 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 iconMeta = this.#iconsData.icons[name];
const typeMeta = this.#iconsData.types[iconMeta.type]; const typeMeta = this.#iconsData.types[iconMeta.type];
@@ -135,12 +244,224 @@ export class EvolutionController {
entity.update(multiplier); 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);
}; };
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 ──────────────────────────────────────────── // ── Data loading ────────────────────────────────────────────
/** Fetch and return the icons.json configuration. */ /** Fetch and return the icons.json configuration. */
+5 -3
View File
@@ -5,6 +5,7 @@
*/ */
import { deriveGradientPair } from '../utils/colorUtils.js'; import { deriveGradientPair } from '../utils/colorUtils.js';
import { DEFAULTS } from './constants.js';
/** Root element — CSS custom properties live here */ /** Root element — CSS custom properties live here */
const ROOT = document.documentElement; const ROOT = document.documentElement;
@@ -23,9 +24,9 @@ function speedToDuration(speed) {
} }
export class GradientController { export class GradientController {
/** @type {string} */ #color = '#4d22b3'; /** @type {string} */ #color = DEFAULTS.GRADIENT_COLOR;
/** @type {number} */ #speed = 2; /** @type {number} */ #speed = DEFAULTS.GRADIENT_SPEED;
/** @type {boolean} */ #rotating = false; /** @type {boolean} */ #rotating = DEFAULTS.GRADIENT_ROTATION;
/** /**
* Initialise gradient with a base colour. * Initialise gradient with a base colour.
@@ -36,6 +37,7 @@ export class GradientController {
init(hex) { init(hex) {
this.setColor(hex); this.setColor(hex);
this.setSpeed(this.#speed); this.setSpeed(this.#speed);
this.toggleRotation(this.#rotating);
} }
/** /**
+150
View File
@@ -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
}
}));
}
}
+8
View File
@@ -14,12 +14,14 @@ import { GradientController } from './gradient.js';
import { ModalController } from './modal.js'; import { ModalController } from './modal.js';
import { SettingsController } from './settings.js'; import { SettingsController } from './settings.js';
import { EvolutionController } from './evolution.js'; import { EvolutionController } from './evolution.js';
import { GuideController } from './guide.js';
import { DEFAULTS } from './constants.js'; import { DEFAULTS } from './constants.js';
// ── Initialise controllers ───────────────────────────────── // ── Initialise controllers ─────────────────────────────────
const gradient = new GradientController(); const gradient = new GradientController();
const modal = new ModalController(); const modal = new ModalController();
const evolution = new EvolutionController(); const evolution = new EvolutionController();
const guide = new GuideController();
// SettingsController bridges UI → gradient + evolution // SettingsController bridges UI → gradient + evolution
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
@@ -30,8 +32,14 @@ document.getElementById('settingsBtn').addEventListener('click', (e) => {
modal.open(/** @type {HTMLElement} */ (e.currentTarget)); modal.open(/** @type {HTMLElement} */ (e.currentTarget));
}); });
// ── Wire up evolution guide ────────────────────────────────
guide.init(evolution);
// ── Boot gradient ────────────────────────────────────────── // ── Boot gradient ──────────────────────────────────────────
gradient.init(DEFAULTS.GRADIENT_COLOR); 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) ── // ── Boot evolution (async — loads icons.json + warms SVG cache) ──
evolution.init(document.getElementById('evolutionContainer')); evolution.init(document.getElementById('evolutionContainer'));
+138 -18
View File
@@ -1,10 +1,20 @@
/** /**
* settings.js * settings.js
* Connects the settings UI controls to GradientController and EvolutionController. * 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 { export class SettingsController {
/** @type {import('./gradient.js').GradientController} */ /** @type {import('./gradient.js').GradientController} */
#gradient; #gradient;
@@ -20,41 +30,151 @@ export class SettingsController {
this.#gradient = gradient; this.#gradient = gradient;
this.#evolution = evolution; this.#evolution = evolution;
this.#bindEvents(); 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 ──────────────────────────────────────────────── // ── Private ────────────────────────────────────────────────
#bindEvents() { #bindEvents() {
const colorPicker = /** @type {HTMLInputElement} */ (document.getElementById('colorPicker')); const colorPicker = /** @type {HTMLInputElement} */ (document.getElementById('colorPicker'));
const colorValue = /** @type {HTMLElement} */ (document.getElementById('colorValue')); const colorValue = /** @type {HTMLElement} */ (document.getElementById('colorValue'));
const speedSlider = /** @type {HTMLInputElement} */ (document.getElementById('speedSlider')); const speedSlider = /** @type {HTMLInputElement} */ (document.getElementById('speedSlider'));
const moveSpeedSlider = /** @type {HTMLInputElement} */ (document.getElementById('moveSpeedSlider')); const rotToggle = /** @type {HTMLInputElement} */ (document.getElementById('rotationToggle'));
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 ────────────────────────────────────── // ── Colour picker ──────────────────────────────────────
// 'input' fires on every pointer move — real-time background preview
colorPicker.addEventListener('input', (e) => { colorPicker.addEventListener('input', (e) => {
const hex = /** @type {HTMLInputElement} */ (e.target).value; const hex = /** @type {HTMLInputElement} */ (e.target).value;
colorValue.textContent = hex; colorValue.textContent = hex;
this.#gradient.setColor(hex); this.#gradient.setColor(hex);
this.#saveState();
}); });
// ── Gradient animation speed ─────────────────────────── // ── Gradient animation speed ───────────────────────────
speedSlider.addEventListener('input', (e) => { speedSlider.addEventListener('input', (e) => {
const speed = Number(/** @type {HTMLInputElement} */ (e.target).value); this.#gradient.setSpeed(Number(/** @type {HTMLInputElement} */ (e.target).value));
this.#gradient.setSpeed(speed); this.#saveState();
});
// ── Icon movement speed ────────────────────────────────
moveSpeedSlider.addEventListener('input', (e) => {
const speed = Number(/** @type {HTMLInputElement} */ (e.target).value);
this.#evolution.setMoveSpeed(speed);
}); });
// ── Rotation toggle ──────────────────────────────────── // ── Rotation toggle ────────────────────────────────────
rotToggle.addEventListener('change', (e) => { rotToggle.addEventListener('change', (e) => {
const enabled = /** @type {HTMLInputElement} */ (e.target).checked; this.#gradient.toggleRotation(/** @type {HTMLInputElement} */ (e.target).checked);
this.#gradient.toggleRotation(enabled); 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);
}
} }
+237
View File
@@ -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;
}
+35
View File
@@ -70,3 +70,38 @@
display: block; display: block;
/* SVGs use currentColor — colour is inherited from .icon-entity */ /* 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;
}
+22 -17
View File
@@ -20,7 +20,7 @@
--rotation-duration: 22s; --rotation-duration: 22s;
/* Modal surface */ /* 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-border: rgba(255, 255, 255, 0.08);
--modal-divider: rgba(255, 255, 255, 0.06); --modal-divider: rgba(255, 255, 255, 0.06);
@@ -52,14 +52,9 @@ body {
justify-content: center; justify-content: center;
} }
/* ── Settings Button ─────────────────────────────────────── */ /* ── Icon Button (shared base — settings + guide) ────────── */
.settings-btn { .icon-btn {
position: fixed; /* Strip browser defaults */
top: 22px;
right: 26px;
z-index: 100;
/* Strip all button decoration */
appearance: none; appearance: none;
background: transparent; background: transparent;
border: none; border: none;
@@ -67,21 +62,22 @@ body {
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
user-select: none;
/* Shape & layout */ /* Shape */
width: 38px; width: 38px;
height: 38px; height: 38px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0;
/* Typography */ /* Typography */
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 500; font-weight: 500;
line-height: 1; line-height: 1;
letter-spacing: -0.02em;
color: rgba(255, 255, 255, 0.65); color: rgba(255, 255, 255, 0.65);
transition: transition:
@@ -89,18 +85,27 @@ body {
background 0.18s ease; background 0.18s ease;
} }
.settings-btn:hover { .icon-btn:hover,
color: rgba(255, 255, 255, 0.95); .icon-btn[aria-expanded="true"] {
background: rgba(255, 255, 255, 0.14); color: var(--text-hi);
background: var(--surface-tint);
} }
.settings-btn:active { .icon-btn:active {
color: #fff; color: #fff;
background: rgba(255, 255, 255, 0.22); background: rgba(255, 255, 255, 0.22);
} }
/* Focus-visible ring for keyboard nav */ .icon-btn:focus-visible {
.settings-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.5); outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px; outline-offset: 2px;
} }
/* ── Settings Button (position only) ────────────────────── */
.settings-btn {
position: fixed;
top: 22px;
right: 26px;
z-index: 100;
letter-spacing: -0.02em;
}
+75 -1
View File
@@ -112,7 +112,35 @@
padding: 20px; padding: 20px;
display: flex; display: flex;
flex-direction: column; 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) ─────────────────────── */ /* ── Setting group (label + control) ─────────────────────── */
@@ -333,3 +361,49 @@
outline: 2px solid rgba(255, 255, 255, 0.45); outline: 2px solid rgba(255, 255, 255, 0.45);
outline-offset: 2px; 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;
}