Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8b3e8447 |
@@ -3,9 +3,12 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(dotnet test:*)",
|
"Bash(dotnet test:*)",
|
||||||
"Bash(dotnet build)",
|
"Bash(dotnet build)",
|
||||||
"Bash(dir:*)"
|
"Bash(dir:*)",
|
||||||
|
"Bash(node --check:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(node build.js:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using JoshHeaps.Net.Hubs;
|
using JoshHeaps.Net.Hubs;
|
||||||
|
using JoshHeaps.Net.Services;
|
||||||
using JoshHeaps.Net.Services.Implementations;
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
|
||||||
@@ -50,4 +51,7 @@ app.MapControllers();
|
|||||||
|
|
||||||
app.MapHub<ChessHub>("/chessHub");
|
app.MapHub<ChessHub>("/chessHub");
|
||||||
|
|
||||||
|
// Build particle simulator bundle from ES6 modules
|
||||||
|
ParticleBundler.BuildBundle(app.Environment);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundles particle simulator ES6 modules into a single file
|
||||||
|
/// </summary>
|
||||||
|
public static class ParticleBundler
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Run the particle simulator build script
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="webHostEnvironment">Web host environment for path resolution</param>
|
||||||
|
/// <returns>True if build succeeded, false otherwise</returns>
|
||||||
|
public static bool BuildBundle(IWebHostEnvironment webHostEnvironment)
|
||||||
|
{
|
||||||
|
var particlesPath = Path.Combine(webHostEnvironment.WebRootPath, "js", "particles");
|
||||||
|
var buildScriptPath = Path.Combine(particlesPath, "build.js");
|
||||||
|
|
||||||
|
if (!File.Exists(buildScriptPath))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"⚠️ Particle bundler script not found at: {buildScriptPath}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.WriteLine("🔨 Building particle simulator bundle...");
|
||||||
|
|
||||||
|
var processStartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "node",
|
||||||
|
Arguments = $"\"{buildScriptPath}\"",
|
||||||
|
WorkingDirectory = particlesPath,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = Process.Start(processStartInfo);
|
||||||
|
if (process == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("❌ Failed to start Node.js process");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
var error = process.StandardError.ReadToEnd();
|
||||||
|
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine(output);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("❌ Particle bundle build failed:");
|
||||||
|
Console.WriteLine(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"❌ Error building particle bundle: {ex.Message}");
|
||||||
|
Console.WriteLine(" Make sure Node.js is installed and available in PATH");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +1,111 @@
|
|||||||
/* ------- page background + overlays ------- */
|
/* ========================================
|
||||||
|
CSS CUSTOM PROPERTIES
|
||||||
|
======================================== */
|
||||||
|
:root {
|
||||||
|
/* Spacing system */
|
||||||
|
--spacing-xs: 0.35rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 0.6rem;
|
||||||
|
--spacing-lg: 0.75rem;
|
||||||
|
--spacing-xl: 1rem;
|
||||||
|
|
||||||
|
/* Layout constants */
|
||||||
|
--fab-size: 44px;
|
||||||
|
--panel-gap: 12px;
|
||||||
|
--border-radius: 10px;
|
||||||
|
--border-radius-sm: 0.4rem;
|
||||||
|
--border-radius-fab: 8px;
|
||||||
|
|
||||||
|
/* Colors */
|
||||||
|
--bg-primary: #000;
|
||||||
|
--bg-panel: rgba(15, 15, 15, 0.65);
|
||||||
|
--bg-panel-dark: rgba(11, 11, 11, 0.65);
|
||||||
|
--bg-button: #1f1f1f;
|
||||||
|
--bg-button-hover: #2a2a2a;
|
||||||
|
--text-primary: #e6e6e6;
|
||||||
|
--border-color: #2a2a2a;
|
||||||
|
--border-color-dark: #1a1a1a;
|
||||||
|
|
||||||
|
/* Slider colors */
|
||||||
|
--slider-track: #444;
|
||||||
|
--slider-fill: #4cafef;
|
||||||
|
--slider-thumb: #ff5722;
|
||||||
|
|
||||||
|
/* Z-index layers */
|
||||||
|
--z-canvas: 0;
|
||||||
|
--z-ui: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BASE STYLES
|
||||||
|
======================================== */
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #000;
|
background: var(--bg-primary);
|
||||||
color: #e6e6e6;
|
color: var(--text-primary);
|
||||||
font-family: ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* canvas as full-screen background */
|
/* ========================================
|
||||||
|
CANVAS
|
||||||
|
======================================== */
|
||||||
.sim {
|
.sim {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0; /* top:0 right:0 bottom:0 left:0 */
|
inset: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: block;
|
display: block;
|
||||||
z-index: 0;
|
z-index: var(--z-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* floating toolbar */
|
.container {
|
||||||
|
position: relative;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
TOOLBAR
|
||||||
|
======================================== */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
top: var(--panel-gap);
|
||||||
left: 12px;
|
left: var(--panel-gap);
|
||||||
right: 12px;
|
right: var(--panel-gap);
|
||||||
z-index: 20;
|
z-index: var(--z-ui);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: var(--spacing-xl);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: .6rem .75rem;
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
background: rgba(15,15,15,.65);
|
background: var(--bg-panel);
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 10px;
|
border-radius: var(--border-radius);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* collapse/expand toolbar */
|
|
||||||
.toolbar .spacer {
|
.toolbar .spacer {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#toggleToolbar {
|
#toggleToolbar {
|
||||||
padding: .35rem .5rem;
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
border-radius: .4rem;
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsed toolbar state (desktop) */
|
||||||
.toolbar.collapsed {
|
.toolbar.collapsed {
|
||||||
padding: .35rem .5rem;
|
left: var(--panel-gap);
|
||||||
gap: .5rem;
|
right: auto;
|
||||||
|
width: var(--fab-size);
|
||||||
|
height: var(--fab-size);
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar.collapsed .group,
|
.toolbar.collapsed .group,
|
||||||
@@ -60,93 +115,150 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* floating rules panel (details) */
|
.toolbar.collapsed #toggleToolbar {
|
||||||
.rulepanel {
|
width: 100%;
|
||||||
position: fixed;
|
height: 100%;
|
||||||
left: 12px;
|
padding: 0;
|
||||||
right: 12px;
|
border-radius: var(--border-radius-fab);
|
||||||
bottom: 12px;
|
font-size: 0;
|
||||||
z-index: 20;
|
|
||||||
background: rgba(11,11,11,.65);
|
|
||||||
border: 1px solid #1a1a1a;
|
|
||||||
border-radius: 10px;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
overflow: hidden; /* collapsed state shows only summary */
|
|
||||||
max-height: 42vh; /* when open, content scrolls inside */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rulepanel[open] {
|
.toolbar.collapsed #toggleToolbar::before {
|
||||||
|
content: "☰";
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
RULE EDITOR PANEL
|
||||||
|
======================================== */
|
||||||
|
.rulepanel {
|
||||||
|
position: fixed;
|
||||||
|
left: var(--panel-gap);
|
||||||
|
right: var(--panel-gap);
|
||||||
|
bottom: var(--panel-gap);
|
||||||
|
z-index: var(--z-ui);
|
||||||
|
background: var(--bg-panel-dark);
|
||||||
|
border: 1px solid var(--border-color-dark);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
max-height: 42vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rulepanel[open] {
|
||||||
|
max-height: calc(100dvh - 24px - 60px);
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable both-edges;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulepanel[open]::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.rulepanel > summary {
|
.rulepanel > summary {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
margin: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
padding: .6rem .75rem;
|
border-bottom: 1px solid var(--border-color-dark);
|
||||||
margin: 0;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: transparent;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .5rem;
|
gap: var(--spacing-sm);
|
||||||
border-bottom: 1px solid #1a1a1a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rulepanel > summary::-webkit-details-marker {
|
.rulepanel > summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rulepanel > summary::after {
|
.rulepanel[open] > summary::after {
|
||||||
content: "▾";
|
content: "▾";
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
opacity: .8;
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed rules panel (FAB style) */
|
||||||
|
.rulepanel:not([open]) {
|
||||||
|
left: var(--panel-gap);
|
||||||
|
right: auto;
|
||||||
|
width: var(--fab-size);
|
||||||
|
height: var(--fab-size);
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulepanel:not([open]) > summary {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rulepanel:not([open]) > summary::after {
|
.rulepanel:not([open]) > summary::after {
|
||||||
content: "▸";
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulepanel:not([open]) > summary::before {
|
||||||
|
content: "Rules";
|
||||||
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rules-body {
|
.rules-body {
|
||||||
padding: .75rem 1rem 1rem;
|
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-xl);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .75rem;
|
gap: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-grid {
|
.rule-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px,1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
gap: .5rem 1rem;
|
gap: var(--spacing-sm) var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-item {
|
.rule-item {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .5rem;
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* buttons + inputs (keep your existing styles, just a few tweaks) */
|
.rule-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-import {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
FORM CONTROLS
|
||||||
|
======================================== */
|
||||||
button {
|
button {
|
||||||
background: #1f1f1f;
|
background: var(--bg-button);
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid var(--border-color);
|
||||||
color: #e6e6e6;
|
color: var(--text-primary);
|
||||||
padding: .4rem .7rem;
|
padding: 0.4rem 0.7rem;
|
||||||
border-radius: .5rem;
|
border-radius: var(--spacing-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
background: #2a2a2a;
|
background: var(--bg-button-hover);
|
||||||
}
|
|
||||||
|
|
||||||
/* range styling (from your sheet; leave as-is) */
|
|
||||||
:root {
|
|
||||||
--slider-track: #444;
|
|
||||||
--slider-fill: #4cafef;
|
|
||||||
--slider-thumb: #ff5722;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Range slider styling */
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -193,187 +305,51 @@ input[type="range"]::-moz-range-progress {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* container no longer controls layout; keep for semantics */
|
/* ========================================
|
||||||
.container {
|
MOBILE STYLES
|
||||||
position: relative;
|
======================================== */
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Canvas already full screen; nothing to change there */
|
|
||||||
|
|
||||||
/* --- Toolbar square when collapsed --- */
|
|
||||||
.toolbar {
|
|
||||||
position: fixed;
|
|
||||||
top: 12px;
|
|
||||||
left: 12px;
|
|
||||||
right: 12px;
|
|
||||||
z-index: 20;
|
|
||||||
background: rgba(15,15,15,.65);
|
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
border-radius: 10px;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar.collapsed {
|
|
||||||
left: 12px;
|
|
||||||
right: auto; /* stop spanning full width */
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
gap: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar.collapsed .group,
|
|
||||||
.toolbar.collapsed #restart,
|
|
||||||
.toolbar.collapsed #permalink,
|
|
||||||
.toolbar.collapsed .spacer {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar.collapsed #toggleToolbar {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0; /* hide the word "Toolbar" */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar.collapsed #toggleToolbar::before {
|
|
||||||
content: "☰"; /* icon only */
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Rules panel as a floating card; tiny square when closed --- */
|
|
||||||
.rulepanel {
|
|
||||||
position: fixed;
|
|
||||||
left: 12px;
|
|
||||||
right: 12px;
|
|
||||||
bottom: 12px;
|
|
||||||
z-index: 20;
|
|
||||||
background: rgba(11,11,11,.65);
|
|
||||||
border: 1px solid #1a1a1a;
|
|
||||||
border-radius: 10px;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
max-height: 42vh;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel[open] {
|
|
||||||
max-height: calc(100dvh - 24px - 60px);
|
|
||||||
overflow: auto;
|
|
||||||
scrollbar-gutter: stable both-edges; /* avoids layout jump */
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel[open] {
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
/* Firefox */
|
|
||||||
.rulepanel[open]::-webkit-scrollbar {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
/* WebKit */
|
|
||||||
|
|
||||||
.rulepanel > summary {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
padding: .6rem .75rem;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
border-bottom: 1px solid #1a1a1a;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel > summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel[open] > summary::after {
|
|
||||||
content: "▾";
|
|
||||||
margin-left: auto;
|
|
||||||
opacity: .8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* closed => tiny square pinned bottom-right */
|
|
||||||
.rulepanel:not([open]) {
|
|
||||||
left: 12px;
|
|
||||||
right: auto; /* stop spanning full width */
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
gap: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel:not([open]) > summary {
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 0; /* hide label text */
|
|
||||||
}
|
|
||||||
|
|
||||||
.rulepanel:not([open]) > summary::after {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
/* no caret */
|
|
||||||
.rulepanel:not([open]) > summary::before {
|
|
||||||
content: "Rules"; /* icon only */
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep sliders/layout rules you already have */
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
/* Default to FAB */
|
/* Toolbar defaults to FAB on mobile */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
top: 12px;
|
top: var(--panel-gap);
|
||||||
left: 12px;
|
left: var(--panel-gap);
|
||||||
width: 44px;
|
width: var(--fab-size);
|
||||||
height: 44px;
|
height: var(--fab-size);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar .group, .toolbar #restart, .toolbar #permalink, .toolbar .spacer {
|
.toolbar .group,
|
||||||
|
.toolbar #restart,
|
||||||
|
.toolbar #permalink,
|
||||||
|
.toolbar .spacer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FAB button icon */
|
|
||||||
#toggleToolbar {
|
#toggleToolbar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 44px;
|
height: var(--fab-size);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-fab);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#toggleToolbar::before {
|
#toggleToolbar::before {
|
||||||
width: 44px;
|
width: var(--fab-size);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Expanded drawer */
|
/* Expanded drawer state on mobile */
|
||||||
.toolbar.mobile.open {
|
.toolbar.mobile.open {
|
||||||
left: 12px;
|
left: var(--panel-gap);
|
||||||
width: min(85vw, 420px);
|
width: min(85vw, 420px);
|
||||||
height: 70vh; /* scroll within drawer if needed */
|
height: 70vh;
|
||||||
padding: .6rem .75rem;
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .65rem;
|
gap: 0.65rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +361,7 @@ input[type="range"]::-moz-range-progress {
|
|||||||
|
|
||||||
.toolbar.mobile.open .group {
|
.toolbar.mobile.open .group {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: .5rem;
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar.mobile.open input[type="number"],
|
.toolbar.mobile.open input[type="number"],
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Particle Simulator - Modular Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The particle simulator has been refactored from a single 780-line monolithic file into a clean, modular architecture with separate ES6 modules for each concern.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
particles/
|
||||||
|
├── src/ Source modules (ES6)
|
||||||
|
│ ├── constants.js Physics & UI constants (40 lines)
|
||||||
|
│ ├── utils.js Utility functions (47 lines)
|
||||||
|
│ ├── random.js PRNG implementation (18 lines)
|
||||||
|
│ ├── colors.js Color palette configuration (16 lines)
|
||||||
|
│ ├── canvas.js Canvas utilities (37 lines)
|
||||||
|
│ ├── particles.js Particle state management (96 lines)
|
||||||
|
│ ├── spatial-grid.js Spatial grid optimization (76 lines)
|
||||||
|
│ ├── rules.js Interaction rules (71 lines)
|
||||||
|
│ ├── physics.js Physics simulation (193 lines)
|
||||||
|
│ ├── renderer.js Rendering logic (35 lines)
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── toolbar.js Toolbar responsiveness (34 lines)
|
||||||
|
│ │ └── rule-editor.js Rule editor controls (72 lines)
|
||||||
|
│ └── main.js Application entry point (213 lines)
|
||||||
|
├── build.js Simple bundler script
|
||||||
|
├── page.bundle.js Built output (auto-generated)
|
||||||
|
└── README.md This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Responsibilities
|
||||||
|
|
||||||
|
### Core Modules
|
||||||
|
|
||||||
|
- **constants.js** - All configurable physics parameters and constants
|
||||||
|
- **utils.js** - General-purpose utility functions (formatNumber, wrapPosition, etc.)
|
||||||
|
- **random.js** - Deterministic pseudo-random number generator (mulberry32)
|
||||||
|
- **colors.js** - Particle color palette and labels
|
||||||
|
|
||||||
|
### Simulation Modules
|
||||||
|
|
||||||
|
- **particles.js** - Particle state using Structure of Arrays (SoA) pattern
|
||||||
|
- **spatial-grid.js** - Spatial partitioning for O(1) neighbor queries
|
||||||
|
- **physics.js** - Force calculations, integration, and collision resolution
|
||||||
|
- **renderer.js** - Canvas rendering with motion trails
|
||||||
|
- **rules.js** - Interaction matrix generation and validation
|
||||||
|
|
||||||
|
### UI Modules
|
||||||
|
|
||||||
|
- **canvas.js** - Canvas initialization and DPI scaling
|
||||||
|
- **ui/toolbar.js** - Responsive toolbar (mobile FAB / desktop bar)
|
||||||
|
- **ui/rule-editor.js** - Interactive rule editing controls
|
||||||
|
- **main.js** - Application orchestration and event handling
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
The project uses a simple custom bundler (`build.js`) that combines all ES6 modules into a single IIFE for browser compatibility.
|
||||||
|
|
||||||
|
### Automatic Build on Startup (Recommended)
|
||||||
|
|
||||||
|
The bundle is **automatically built when the application starts**. The `ParticleBundler` service in `Program.cs` runs the Node.js build script before `app.Run()`.
|
||||||
|
|
||||||
|
When you start the application with `dotnet run`, you'll see:
|
||||||
|
```
|
||||||
|
🔨 Building particle simulator bundle...
|
||||||
|
✓ Processing constants.js
|
||||||
|
✓ Processing utils.js
|
||||||
|
...
|
||||||
|
✨ Bundle created successfully!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Build (Optional)
|
||||||
|
|
||||||
|
You can also manually rebuild the bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd JoshHeaps.Net/wwwroot/js/particles
|
||||||
|
node build.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build output:
|
||||||
|
- All modules are concatenated in dependency order
|
||||||
|
- Import/export statements are removed
|
||||||
|
- Code is wrapped in an IIFE (Immediately Invoked Function Expression)
|
||||||
|
- Result is ~38 KB of clean, minification-ready code
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Edit source modules** in `src/` directory
|
||||||
|
2. **Start the application**: `dotnet run` (bundle is built automatically)
|
||||||
|
3. **Test in browser** - The bundle is automatically used by the page
|
||||||
|
4. **Commit** source modules (bundle is auto-generated, but should be committed too)
|
||||||
|
|
||||||
|
## Benefits of This Architecture
|
||||||
|
|
||||||
|
### 🎯 Single Responsibility
|
||||||
|
Each module has one clear, focused purpose. No file exceeds 200 lines.
|
||||||
|
|
||||||
|
### 🧪 Testable
|
||||||
|
Individual modules can be tested in isolation.
|
||||||
|
|
||||||
|
### 📖 Readable
|
||||||
|
Easy to navigate and understand. New developers can quickly find what they need.
|
||||||
|
|
||||||
|
### ♻️ Reusable
|
||||||
|
Modules like `spatial-grid.js` or `random.js` can be reused in other projects.
|
||||||
|
|
||||||
|
### 🔍 Maintainable
|
||||||
|
Bug fixes and feature additions are localized to specific modules.
|
||||||
|
|
||||||
|
### 📦 Dependency Clarity
|
||||||
|
Import statements clearly show what each module depends on.
|
||||||
|
|
||||||
|
## Key Design Patterns
|
||||||
|
|
||||||
|
### Structure of Arrays (SoA)
|
||||||
|
The `particles.js` module uses separate typed arrays for each property (position, velocity, color) instead of an array of particle objects. This improves cache locality and performance.
|
||||||
|
|
||||||
|
### Encapsulation
|
||||||
|
Internal state is hidden. Modules expose only necessary functions through exports.
|
||||||
|
|
||||||
|
### Separation of Concerns
|
||||||
|
- Physics logic is completely separate from rendering
|
||||||
|
- UI controls are separate from simulation logic
|
||||||
|
- State management is centralized in `particles.js`
|
||||||
|
|
||||||
|
### Dependency Injection
|
||||||
|
The `main.js` module wires everything together, passing dependencies explicitly.
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
This modular architecture maintains 100% compatibility with the original implementation:
|
||||||
|
- Same visual output
|
||||||
|
- Same physics behavior
|
||||||
|
- Same UI/UX
|
||||||
|
- Same performance characteristics
|
||||||
|
|
||||||
|
The ONLY difference is the code organization, which is dramatically improved.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple ES6 Module Bundler
|
||||||
|
* Bundles ES6 modules into a single IIFE for browser compatibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const srcDir = path.join(__dirname, 'src');
|
||||||
|
const outputFile = path.join(__dirname, 'page.bundle.js');
|
||||||
|
|
||||||
|
// Module dependency order (topological sort)
|
||||||
|
const moduleOrder = [
|
||||||
|
'constants.js',
|
||||||
|
'utils.js',
|
||||||
|
'random.js',
|
||||||
|
'colors.js',
|
||||||
|
'canvas.js',
|
||||||
|
'particles.js',
|
||||||
|
'spatial-grid.js',
|
||||||
|
'rules.js',
|
||||||
|
'physics.js',
|
||||||
|
'renderer.js',
|
||||||
|
'ui/toolbar.js',
|
||||||
|
'ui/rule-editor.js',
|
||||||
|
'main.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and process a module file
|
||||||
|
* @param {string} modulePath - Path to module file
|
||||||
|
* @returns {string} Processed module content
|
||||||
|
*/
|
||||||
|
function processModule(modulePath) {
|
||||||
|
const fullPath = path.join(srcDir, modulePath);
|
||||||
|
let content = fs.readFileSync(fullPath, 'utf8');
|
||||||
|
|
||||||
|
// Remove export statements (we'll use direct assignment in IIFE)
|
||||||
|
content = content.replace(/export\s+(const|let|var|function|class)\s+/g, '$1 ');
|
||||||
|
content = content.replace(/export\s+\{[^}]+\};?/g, '');
|
||||||
|
content = content.replace(/export\s+default\s+/g, '');
|
||||||
|
|
||||||
|
// Remove import statements (modules are already in order)
|
||||||
|
content = content.replace(/import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"];?\s*/g, '');
|
||||||
|
content = content.replace(/import\s+\*\s+as\s+\w+\s+from\s+['"][^'"]+['"];?\s*/g, '');
|
||||||
|
content = content.replace(/import\s+\w+\s+from\s+['"][^'"]+['"];?\s*/g, '');
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundle all modules into a single file
|
||||||
|
*/
|
||||||
|
function bundle() {
|
||||||
|
console.log('🔨 Building particle simulator bundle...');
|
||||||
|
|
||||||
|
let bundledContent = '(function () {\n "use strict";\n\n';
|
||||||
|
|
||||||
|
// Process each module in dependency order
|
||||||
|
for (const modulePath of moduleOrder) {
|
||||||
|
console.log(` ✓ Processing ${modulePath}`);
|
||||||
|
const moduleContent = processModule(modulePath);
|
||||||
|
bundledContent += ` // ========================================\n`;
|
||||||
|
bundledContent += ` // MODULE: ${modulePath}\n`;
|
||||||
|
bundledContent += ` // ========================================\n\n`;
|
||||||
|
|
||||||
|
// Indent module content
|
||||||
|
const indentedContent = moduleContent
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.length > 0 ? ' ' + line : line)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
bundledContent += indentedContent + '\n\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
bundledContent += '})();\n';
|
||||||
|
|
||||||
|
// Write bundle to output file
|
||||||
|
fs.writeFileSync(outputFile, bundledContent, 'utf8');
|
||||||
|
|
||||||
|
const stats = fs.statSync(outputFile);
|
||||||
|
const sizeKB = (stats.size / 1024).toFixed(2);
|
||||||
|
|
||||||
|
console.log(`✨ Bundle created successfully!`);
|
||||||
|
console.log(` Output: ${outputFile}`);
|
||||||
|
console.log(` Size: ${sizeKB} KB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run bundler
|
||||||
|
try {
|
||||||
|
bundle();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Build failed:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Canvas Utilities
|
||||||
|
* Canvas setup and resizing functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare canvas context with proper DPI scaling
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
|
* @returns {CanvasRenderingContext2D}
|
||||||
|
*/
|
||||||
|
export function prepareCanvasContext(canvas) {
|
||||||
|
const context = canvas.getContext("2d", { alpha: false });
|
||||||
|
resizeCanvasToDisplaySize(canvas, context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize canvas to match display size with device pixel ratio scaling
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
|
* @param {CanvasRenderingContext2D} context
|
||||||
|
*/
|
||||||
|
export function resizeCanvasToDisplaySize(canvas, context) {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||||||
|
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||||||
|
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale context so we can draw in CSS pixels
|
||||||
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Color Configuration
|
||||||
|
* Particle color palette and naming
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const COLOR_NAMES = ["R", "G", "B", "Y", "C", "M"];
|
||||||
|
|
||||||
|
export const PALETTE = [
|
||||||
|
"#ff0000", // Red
|
||||||
|
"#00ff00", // Green
|
||||||
|
"#0066ff", // Blue
|
||||||
|
"#ffff00", // Yellow
|
||||||
|
"#00ffff", // Cyan
|
||||||
|
"#ff00ff" // Magenta
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COLOR_LABELS = ["Red", "Green", "Blue", "Yellow", "Cyan", "Magenta"];
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Physics and UI Constants
|
||||||
|
* All configurable parameters for the particle simulation
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// PHYSICS CONSTANTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Force calculation parameters
|
||||||
|
export const FORCE_SOFTEN = 25; // Softening factor (px^2) to prevent division by zero
|
||||||
|
export const MAX_ACCELERATION = 1.5; // Maximum acceleration magnitude (px/s^2)
|
||||||
|
export const VELOCITY_DAMPING = 0.985; // Velocity damping coefficient per step
|
||||||
|
export const INTERACTION_RANGE = 240; // Max distance for particle interactions (px)
|
||||||
|
export const INTERACTION_RANGE_SQUARED = INTERACTION_RANGE * INTERACTION_RANGE;
|
||||||
|
|
||||||
|
// Rendering parameters
|
||||||
|
export const PARTICLE_RADIUS = 2; // Visual radius of each particle (px)
|
||||||
|
export const TRAIL_ALPHA = 0.1; // Fade alpha for motion trails (lower = longer trails)
|
||||||
|
|
||||||
|
// Interaction rule constraints
|
||||||
|
export const RULE_MIN = -1; // Minimum interaction strength
|
||||||
|
export const RULE_MAX = 1; // Maximum interaction strength
|
||||||
|
export const RULE_SELF_LIMIT = 0.6; // Self-interaction strength limit
|
||||||
|
export const RULE_STABILITY_THRESHOLD = 3; // Max sum of absolute values per row
|
||||||
|
|
||||||
|
// Collision parameters (simple barrier)
|
||||||
|
export const BARRIER_RADIUS = PARTICLE_RADIUS * 2.0; // Collision radius (4px - particles stay separated)
|
||||||
|
export const BARRIER_DIAMETER = BARRIER_RADIUS * 2; // Full barrier diameter (8px)
|
||||||
|
export const BARRIER_RESTITUTION = 0.8; // Bounciness coefficient (0.8 = bouncy)
|
||||||
|
export const BARRIER_FRICTION = 0.001; // Very low friction (smooth motion)
|
||||||
|
|
||||||
|
// Collision solver
|
||||||
|
export const COLLISION_ITERATIONS = 1; // Solver iterations per substep
|
||||||
|
|
||||||
|
// Simulation timing
|
||||||
|
export const FIXED_TIMESTEP = 1 / 60; // 60 FPS physics update
|
||||||
|
export const MAX_FRAME_DELTA = 0.1; // Prevent spiral of death (seconds)
|
||||||
|
|
||||||
|
// Spatial grid optimization
|
||||||
|
export const CELL_SIZE = Math.max(INTERACTION_RANGE, BARRIER_DIAMETER * 2);
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Particle Simulator - Main Application Entry Point
|
||||||
|
* Initializes and orchestrates the particle simulation system
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
FIXED_TIMESTEP,
|
||||||
|
MAX_FRAME_DELTA,
|
||||||
|
COLLISION_ITERATIONS,
|
||||||
|
RULE_MIN,
|
||||||
|
RULE_MAX
|
||||||
|
} from './constants.js';
|
||||||
|
import { getElementById, formatNumber } from './utils.js';
|
||||||
|
import { createRandomGenerator } from './random.js';
|
||||||
|
import { prepareCanvasContext, resizeCanvasToDisplaySize } from './canvas.js';
|
||||||
|
import { createParticles } from './particles.js';
|
||||||
|
import { rebuildSpatialGrid } from './spatial-grid.js';
|
||||||
|
import { simulatePhysicsStep, resolveCollisions } from './physics.js';
|
||||||
|
import { renderParticles } from './renderer.js';
|
||||||
|
import { buildInteractionRules } from './rules.js';
|
||||||
|
import { setupToolbarResponsiveness } from './ui/toolbar.js';
|
||||||
|
import { setupRuleEditor } from './ui/rule-editor.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main application initialization
|
||||||
|
* Initializes simulation, UI, and event handlers
|
||||||
|
*/
|
||||||
|
function initializeApplication() {
|
||||||
|
const bootConfig = window.__PARTICLE_BOOT__ || { seed: 123, n: 600, speed: 1 };
|
||||||
|
|
||||||
|
// Canvas setup
|
||||||
|
const canvas = getElementById("sim");
|
||||||
|
const context = prepareCanvasContext(canvas);
|
||||||
|
|
||||||
|
// Input controls
|
||||||
|
const seedInput = getElementById("seed");
|
||||||
|
const countInput = getElementById("count");
|
||||||
|
const speedRange = getElementById("speed");
|
||||||
|
const speedOutput = getElementById("speedOut");
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
const restartButton = getElementById("restart");
|
||||||
|
const permalinkButton = getElementById("permalink");
|
||||||
|
const randomSeedButton = getElementById("randomSeed");
|
||||||
|
|
||||||
|
// Rule editor elements
|
||||||
|
const activeColorSelect = getElementById("ruleActiveColor");
|
||||||
|
const sliderIds = ["R", "G", "B", "Y", "C", "M"];
|
||||||
|
const sliders = sliderIds.map(id => ({
|
||||||
|
input: getElementById("rule_" + id),
|
||||||
|
output: getElementById("rule_" + id + "_out")
|
||||||
|
}));
|
||||||
|
const copyRulesButton = getElementById("copyRules");
|
||||||
|
const toggleImportButton = getElementById("toggleImport");
|
||||||
|
const importArea = getElementById("importArea");
|
||||||
|
const rulesJsonTextarea = getElementById("rulesJson");
|
||||||
|
const applyRulesButton = getElementById("applyRules");
|
||||||
|
const cancelImportButton = getElementById("cancelImport");
|
||||||
|
|
||||||
|
// Toolbar elements
|
||||||
|
const toolbarElement = document.querySelector(".toolbar");
|
||||||
|
const toggleToolbarButton = getElementById("toggleToolbar");
|
||||||
|
|
||||||
|
// Initialize input values from boot config
|
||||||
|
seedInput.value = String(bootConfig.seed >>> 0);
|
||||||
|
countInput.value = String(bootConfig.n);
|
||||||
|
speedRange.value = String(bootConfig.speed);
|
||||||
|
speedOutput.textContent = speedRange.value;
|
||||||
|
|
||||||
|
// Simulation state
|
||||||
|
let randomGenerator;
|
||||||
|
let interactionRules;
|
||||||
|
let worldWidth = canvas.clientWidth;
|
||||||
|
let worldHeight = canvas.clientHeight;
|
||||||
|
|
||||||
|
// Animation timing (fixed timestep with accumulator)
|
||||||
|
let timeAccumulator = 0;
|
||||||
|
let lastFrameTime = performance.now();
|
||||||
|
let isRunning = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize/restart simulation with new parameters
|
||||||
|
*/
|
||||||
|
function initializeSimulation(seed, particleCount) {
|
||||||
|
randomGenerator = createRandomGenerator(seed >>> 0);
|
||||||
|
interactionRules = buildInteractionRules(randomGenerator);
|
||||||
|
worldWidth = canvas.clientWidth;
|
||||||
|
worldHeight = canvas.clientHeight;
|
||||||
|
createParticles(randomGenerator, particleCount, worldWidth, worldHeight);
|
||||||
|
syncRuleUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync rule editor UI with current interaction rules
|
||||||
|
*/
|
||||||
|
function syncRuleUI() {
|
||||||
|
if (!interactionRules) return;
|
||||||
|
|
||||||
|
const sourceColor = parseInt(activeColorSelect.value, 10) || 0;
|
||||||
|
for (let targetColor = 0; targetColor < sliders.length; targetColor++) {
|
||||||
|
const value = Math.max(RULE_MIN, Math.min(RULE_MAX,
|
||||||
|
interactionRules[sourceColor][targetColor] ?? 0));
|
||||||
|
sliders[targetColor].input.value = String(value);
|
||||||
|
sliders[targetColor].output.textContent = formatNumber(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main animation frame update loop
|
||||||
|
*/
|
||||||
|
function animationFrame(currentTime) {
|
||||||
|
if (!isRunning) return;
|
||||||
|
|
||||||
|
// Update canvas size
|
||||||
|
resizeCanvasToDisplaySize(canvas, context);
|
||||||
|
worldWidth = canvas.clientWidth;
|
||||||
|
worldHeight = canvas.clientHeight;
|
||||||
|
|
||||||
|
const speedMultiplier = +speedRange.value || 1;
|
||||||
|
|
||||||
|
// Fixed timestep accumulator (prevents spiral of death)
|
||||||
|
timeAccumulator += Math.min(MAX_FRAME_DELTA, (currentTime - lastFrameTime) / 1000);
|
||||||
|
lastFrameTime = currentTime;
|
||||||
|
|
||||||
|
// Run physics updates at fixed timestep
|
||||||
|
while (timeAccumulator >= FIXED_TIMESTEP) {
|
||||||
|
rebuildSpatialGrid(worldWidth, worldHeight);
|
||||||
|
simulatePhysicsStep(FIXED_TIMESTEP, speedMultiplier, worldWidth, worldHeight, interactionRules);
|
||||||
|
|
||||||
|
// Rebuild grid after positions changed
|
||||||
|
rebuildSpatialGrid(worldWidth, worldHeight);
|
||||||
|
|
||||||
|
// Resolve collisions
|
||||||
|
for (let iteration = 0; iteration < COLLISION_ITERATIONS; iteration++) {
|
||||||
|
resolveCollisions(worldWidth, worldHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeAccumulator -= FIXED_TIMESTEP;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderParticles(context, worldWidth, worldHeight);
|
||||||
|
requestAnimationFrame(animationFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// UI EVENT BINDINGS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Speed slider updates output display
|
||||||
|
speedRange.addEventListener("input", () => {
|
||||||
|
speedOutput.textContent = speedRange.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup toolbar responsiveness
|
||||||
|
setupToolbarResponsiveness(toolbarElement, toggleToolbarButton);
|
||||||
|
|
||||||
|
// Setup rule editor
|
||||||
|
const rulesReference = {
|
||||||
|
get rules() { return interactionRules; },
|
||||||
|
set rules(v) { interactionRules = v; }
|
||||||
|
};
|
||||||
|
setupRuleEditor({
|
||||||
|
activeColorSelect,
|
||||||
|
sliders,
|
||||||
|
copyButton: copyRulesButton,
|
||||||
|
toggleImportButton,
|
||||||
|
importArea,
|
||||||
|
rulesJsonTextarea,
|
||||||
|
applyButton: applyRulesButton,
|
||||||
|
cancelButton: cancelImportButton
|
||||||
|
}, syncRuleUI, rulesReference);
|
||||||
|
|
||||||
|
// Restart simulation button
|
||||||
|
restartButton.addEventListener("click", () => {
|
||||||
|
const seed = (seedInput.value === "") ? (bootConfig.seed >>> 0) :
|
||||||
|
(parseInt(seedInput.value, 10) >>> 0);
|
||||||
|
const count = Math.max(50, Math.min(5000, parseInt(countInput.value, 10) || bootConfig.n));
|
||||||
|
initializeSimulation(seed, count);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Permalink button - copy shareable URL
|
||||||
|
permalinkButton.addEventListener("click", async () => {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
url.searchParams.set("seed", String(seedInput.value || bootConfig.seed));
|
||||||
|
url.searchParams.set("n", String(countInput.value || bootConfig.n));
|
||||||
|
url.searchParams.set("speed", String(speedRange.value || bootConfig.speed));
|
||||||
|
history.replaceState({}, "", url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url.toString());
|
||||||
|
permalinkButton.textContent = "Copied!";
|
||||||
|
setTimeout(() => permalinkButton.textContent = "Permalink", 800);
|
||||||
|
} catch (error) {
|
||||||
|
// Clipboard access denied - ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Random seed button
|
||||||
|
randomSeedButton.addEventListener("click", () => {
|
||||||
|
const buffer = new Uint32Array(1);
|
||||||
|
(window.crypto || window.msCrypto).getRandomValues(buffer);
|
||||||
|
seedInput.value = String(buffer[0] >>> 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pause simulation when tab is hidden (battery saving)
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
isRunning = document.visibilityState !== "hidden";
|
||||||
|
if (isRunning) {
|
||||||
|
lastFrameTime = performance.now();
|
||||||
|
requestAnimationFrame(animationFrame);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle window resizes
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
resizeCanvasToDisplaySize(canvas, context);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start simulation
|
||||||
|
initializeSimulation(bootConfig.seed >>> 0, bootConfig.n);
|
||||||
|
requestAnimationFrame(animationFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// APPLICATION BOOTSTRAP
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Start application when DOM is ready
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", initializeApplication, { once: true });
|
||||||
|
} else {
|
||||||
|
initializeApplication();
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Particle State Management
|
||||||
|
* Structure of Arrays (SoA) for efficient particle storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { COLOR_NAMES } from './colors.js';
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// PARTICLE STATE (Structure of Arrays)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
let particlePositionX; // Float32Array - X positions
|
||||||
|
let particlePositionY; // Float32Array - Y positions
|
||||||
|
let particleVelocityX; // Float32Array - X velocities
|
||||||
|
let particleVelocityY; // Float32Array - Y velocities
|
||||||
|
let particleColor; // Uint8Array - Color indices
|
||||||
|
let particleCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize particle system with random positions and velocities
|
||||||
|
* @param {function} random - Random number generator
|
||||||
|
* @param {number} count - Number of particles to create
|
||||||
|
* @param {number} width - Simulation width
|
||||||
|
* @param {number} height - Simulation height
|
||||||
|
*/
|
||||||
|
export function createParticles(random, count, width, height) {
|
||||||
|
particlePositionX = new Float32Array(count);
|
||||||
|
particlePositionY = new Float32Array(count);
|
||||||
|
particleVelocityX = new Float32Array(count);
|
||||||
|
particleVelocityY = new Float32Array(count);
|
||||||
|
particleColor = new Uint8Array(count);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
particlePositionX[i] = random() * width;
|
||||||
|
particlePositionY[i] = random() * height;
|
||||||
|
particleVelocityX[i] = (random() - 0.5) * 0.1;
|
||||||
|
particleVelocityY[i] = (random() - 0.5) * 0.1;
|
||||||
|
// Round-robin color assignment for even distribution
|
||||||
|
particleColor[i] = i % COLOR_NAMES.length;
|
||||||
|
}
|
||||||
|
particleCount = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current particle count
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function getParticleCount() {
|
||||||
|
return particleCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get particle position arrays (read-only access)
|
||||||
|
* @returns {{x: Float32Array, y: Float32Array}}
|
||||||
|
*/
|
||||||
|
export function getParticlePositions() {
|
||||||
|
return {
|
||||||
|
x: particlePositionX,
|
||||||
|
y: particlePositionY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get particle velocity arrays (read-only access)
|
||||||
|
* @returns {{x: Float32Array, y: Float32Array}}
|
||||||
|
*/
|
||||||
|
export function getParticleVelocities() {
|
||||||
|
return {
|
||||||
|
x: particleVelocityX,
|
||||||
|
y: particleVelocityY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get particle color array (read-only access)
|
||||||
|
* @returns {Uint8Array}
|
||||||
|
*/
|
||||||
|
export function getParticleColors() {
|
||||||
|
return particleColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a particle's position
|
||||||
|
* @param {number} index - Particle index
|
||||||
|
* @param {number} x - New X position
|
||||||
|
* @param {number} y - New Y position
|
||||||
|
*/
|
||||||
|
export function setParticlePosition(index, x, y) {
|
||||||
|
particlePositionX[index] = x;
|
||||||
|
particlePositionY[index] = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a particle's velocity
|
||||||
|
* @param {number} index - Particle index
|
||||||
|
* @param {number} vx - New X velocity
|
||||||
|
* @param {number} vy - New Y velocity
|
||||||
|
*/
|
||||||
|
export function setParticleVelocity(index, vx, vy) {
|
||||||
|
particleVelocityX[index] = vx;
|
||||||
|
particleVelocityY[index] = vy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* Physics Simulation
|
||||||
|
* Force calculations, integration, and collision resolution
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
FORCE_SOFTEN,
|
||||||
|
MAX_ACCELERATION,
|
||||||
|
VELOCITY_DAMPING,
|
||||||
|
INTERACTION_RANGE,
|
||||||
|
INTERACTION_RANGE_SQUARED,
|
||||||
|
BARRIER_RADIUS,
|
||||||
|
BARRIER_DIAMETER,
|
||||||
|
BARRIER_RESTITUTION,
|
||||||
|
BARRIER_FRICTION
|
||||||
|
} from './constants.js';
|
||||||
|
import { wrapDistance, wrapPosition } from './utils.js';
|
||||||
|
import {
|
||||||
|
getParticleCount,
|
||||||
|
getParticlePositions,
|
||||||
|
getParticleVelocities,
|
||||||
|
getParticleColors,
|
||||||
|
setParticlePosition,
|
||||||
|
setParticleVelocity
|
||||||
|
} from './particles.js';
|
||||||
|
import { getGridCell, forEachNeighborParticle } from './spatial-grid.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update particle physics for one timestep
|
||||||
|
* Calculates forces based on interaction rules, integrates motion, and wraps positions
|
||||||
|
* @param {number} deltaTime - Time step duration (seconds)
|
||||||
|
* @param {number} speedMultiplier - Speed multiplier from user input
|
||||||
|
* @param {number} worldWidth - Width of simulation space
|
||||||
|
* @param {number} worldHeight - Height of simulation space
|
||||||
|
* @param {number[][]} interactionRules - 6x6 matrix of color interaction strengths
|
||||||
|
*/
|
||||||
|
export function simulatePhysicsStep(deltaTime, speedMultiplier, worldWidth, worldHeight, interactionRules) {
|
||||||
|
const halfWidth = worldWidth * 0.5;
|
||||||
|
const halfHeight = worldHeight * 0.5;
|
||||||
|
|
||||||
|
const particleCount = getParticleCount();
|
||||||
|
const positions = getParticlePositions();
|
||||||
|
const velocities = getParticleVelocities();
|
||||||
|
const colors = getParticleColors();
|
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
let accelerationX = 0;
|
||||||
|
let accelerationY = 0;
|
||||||
|
|
||||||
|
// Find grid cell for this particle
|
||||||
|
const { cellX, cellY } = getGridCell(positions.x[i], positions.y[i]);
|
||||||
|
|
||||||
|
// Compute forces from nearby particles
|
||||||
|
forEachNeighborParticle(cellX, cellY, (j) => {
|
||||||
|
if (j === i) return; // Skip self
|
||||||
|
|
||||||
|
// Calculate toroidal (wrapping) distance
|
||||||
|
let deltaX = positions.x[j] - positions.x[i];
|
||||||
|
let deltaY = positions.y[j] - positions.y[i];
|
||||||
|
|
||||||
|
deltaX = wrapDistance(deltaX, halfWidth, worldWidth);
|
||||||
|
deltaY = wrapDistance(deltaY, halfHeight, worldHeight);
|
||||||
|
|
||||||
|
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||||
|
|
||||||
|
// Skip if beyond interaction range
|
||||||
|
if (INTERACTION_RANGE && distanceSquared > INTERACTION_RANGE_SQUARED) return;
|
||||||
|
|
||||||
|
// Apply interaction force: F = k / (r^2 + soften)
|
||||||
|
const ruleStrength = interactionRules[colors[i]][colors[j]];
|
||||||
|
const forceFactor = 1 / (distanceSquared + FORCE_SOFTEN);
|
||||||
|
accelerationX += ruleStrength * deltaX * forceFactor;
|
||||||
|
accelerationY += ruleStrength * deltaY * forceFactor;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clamp acceleration magnitude
|
||||||
|
const accelerationMagnitudeSquared = accelerationX * accelerationX + accelerationY * accelerationY;
|
||||||
|
if (accelerationMagnitudeSquared > MAX_ACCELERATION * MAX_ACCELERATION) {
|
||||||
|
const scale = MAX_ACCELERATION / Math.sqrt(accelerationMagnitudeSquared);
|
||||||
|
accelerationX *= scale;
|
||||||
|
accelerationY *= scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integrate velocity (with damping)
|
||||||
|
const newVx = (velocities.x[i] + accelerationX * deltaTime * speedMultiplier) * VELOCITY_DAMPING;
|
||||||
|
const newVy = (velocities.y[i] + accelerationY * deltaTime * speedMultiplier) * VELOCITY_DAMPING;
|
||||||
|
setParticleVelocity(i, newVx, newVy);
|
||||||
|
|
||||||
|
// Integrate position
|
||||||
|
const newPx = positions.x[i] + newVx * deltaTime * speedMultiplier;
|
||||||
|
const newPy = positions.y[i] + newVy * deltaTime * speedMultiplier;
|
||||||
|
|
||||||
|
// Wrap positions (toroidal world)
|
||||||
|
setParticlePosition(
|
||||||
|
i,
|
||||||
|
wrapPosition(newPx, worldWidth),
|
||||||
|
wrapPosition(newPy, worldHeight)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve particle-particle collisions using simple barrier physics
|
||||||
|
* Particles have a barrier radius that prevents overlap with elastic bouncing
|
||||||
|
* @param {number} worldWidth - Width of simulation space
|
||||||
|
* @param {number} worldHeight - Height of simulation space
|
||||||
|
*/
|
||||||
|
export function resolveCollisions(worldWidth, worldHeight) {
|
||||||
|
const halfWidth = worldWidth * 0.5;
|
||||||
|
const halfHeight = worldHeight * 0.5;
|
||||||
|
const barrierDistanceSquared = BARRIER_DIAMETER * BARRIER_DIAMETER;
|
||||||
|
|
||||||
|
const particleCount = getParticleCount();
|
||||||
|
const positions = getParticlePositions();
|
||||||
|
const velocities = getParticleVelocities();
|
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
const { cellX, cellY } = getGridCell(positions.x[i], positions.y[i]);
|
||||||
|
|
||||||
|
forEachNeighborParticle(cellX, cellY, (j) => {
|
||||||
|
// Only process each pair once (i < j)
|
||||||
|
if (j <= i) return;
|
||||||
|
|
||||||
|
// Calculate toroidal distance
|
||||||
|
let deltaX = positions.x[j] - positions.x[i];
|
||||||
|
let deltaY = positions.y[j] - positions.y[i];
|
||||||
|
|
||||||
|
deltaX = wrapDistance(deltaX, halfWidth, worldWidth);
|
||||||
|
deltaY = wrapDistance(deltaY, halfHeight, worldHeight);
|
||||||
|
|
||||||
|
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||||
|
|
||||||
|
// Check if particles are colliding (within barrier distance)
|
||||||
|
if (distanceSquared >= barrierDistanceSquared || distanceSquared === 0) return;
|
||||||
|
|
||||||
|
const distance = Math.sqrt(distanceSquared);
|
||||||
|
const normalX = deltaX / distance;
|
||||||
|
const normalY = deltaY / distance;
|
||||||
|
|
||||||
|
// Position correction: push particles apart to barrier distance
|
||||||
|
const overlap = BARRIER_DIAMETER - distance;
|
||||||
|
const correction = overlap * 0.5;
|
||||||
|
const newPx_i = positions.x[i] - normalX * correction;
|
||||||
|
const newPy_i = positions.y[i] - normalY * correction;
|
||||||
|
const newPx_j = positions.x[j] + normalX * correction;
|
||||||
|
const newPy_j = positions.y[j] + normalY * correction;
|
||||||
|
|
||||||
|
setParticlePosition(i, wrapPosition(newPx_i, worldWidth), wrapPosition(newPy_i, worldHeight));
|
||||||
|
setParticlePosition(j, wrapPosition(newPx_j, worldWidth), wrapPosition(newPy_j, worldHeight));
|
||||||
|
|
||||||
|
// Elastic collision response with restitution
|
||||||
|
const relativeVelocityX = velocities.x[j] - velocities.x[i];
|
||||||
|
const relativeVelocityY = velocities.y[j] - velocities.y[i];
|
||||||
|
const normalVelocity = relativeVelocityX * normalX + relativeVelocityY * normalY;
|
||||||
|
|
||||||
|
// Only resolve if particles are approaching
|
||||||
|
if (normalVelocity < 0) {
|
||||||
|
const impulse = (1 + BARRIER_RESTITUTION) * normalVelocity * 0.5;
|
||||||
|
const impulseX = impulse * normalX;
|
||||||
|
const impulseY = impulse * normalY;
|
||||||
|
|
||||||
|
setParticleVelocity(
|
||||||
|
i,
|
||||||
|
velocities.x[i] + impulseX,
|
||||||
|
velocities.y[i] + impulseY
|
||||||
|
);
|
||||||
|
setParticleVelocity(
|
||||||
|
j,
|
||||||
|
velocities.x[j] - impulseX,
|
||||||
|
velocities.y[j] - impulseY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply minimal friction to tangential velocity
|
||||||
|
const tangentVelocityX = relativeVelocityX - normalVelocity * normalX;
|
||||||
|
const tangentVelocityY = relativeVelocityY - normalVelocity * normalY;
|
||||||
|
const tangentMagnitude = Math.hypot(tangentVelocityX, tangentVelocityY);
|
||||||
|
|
||||||
|
if (tangentMagnitude > 1e-8) {
|
||||||
|
const tangentX = tangentVelocityX / tangentMagnitude;
|
||||||
|
const tangentY = tangentVelocityY / tangentMagnitude;
|
||||||
|
const frictionImpulse = BARRIER_FRICTION * tangentMagnitude * 0.5;
|
||||||
|
|
||||||
|
setParticleVelocity(
|
||||||
|
i,
|
||||||
|
velocities.x[i] + tangentX * frictionImpulse,
|
||||||
|
velocities.y[i] + tangentY * frictionImpulse
|
||||||
|
);
|
||||||
|
setParticleVelocity(
|
||||||
|
j,
|
||||||
|
velocities.x[j] - tangentX * frictionImpulse,
|
||||||
|
velocities.y[j] - tangentY * frictionImpulse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Pseudo-Random Number Generator
|
||||||
|
* Deterministic PRNG for reproducible simulations
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a deterministic PRNG using mulberry32 algorithm
|
||||||
|
* @param {number} seed - Initial seed value
|
||||||
|
* @returns {function(): number} Random number generator function returning [0, 1)
|
||||||
|
*/
|
||||||
|
export function createRandomGenerator(seed) {
|
||||||
|
let state = (seed >>> 0) || 1;
|
||||||
|
return function () {
|
||||||
|
state += 0x6D2B79F5;
|
||||||
|
let random = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||||
|
random ^= random + Math.imul(random ^ (random >>> 7), 61 | random);
|
||||||
|
return ((random ^ (random >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Rendering
|
||||||
|
* Canvas rendering with motion trail effects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PARTICLE_RADIUS, TRAIL_ALPHA } from './constants.js';
|
||||||
|
import { PALETTE } from './colors.js';
|
||||||
|
import { getParticleCount, getParticlePositions, getParticleColors } from './particles.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render all particles to canvas with motion trail effect
|
||||||
|
* @param {CanvasRenderingContext2D} context - Canvas rendering context
|
||||||
|
* @param {number} width - Canvas width
|
||||||
|
* @param {number} height - Canvas height
|
||||||
|
*/
|
||||||
|
export function renderParticles(context, width, height) {
|
||||||
|
// Fade previous frame to create motion trails
|
||||||
|
context.globalAlpha = TRAIL_ALPHA; // Lower alpha = longer trails
|
||||||
|
context.fillStyle = "#000";
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
|
||||||
|
// Draw all particles
|
||||||
|
const particleCount = getParticleCount();
|
||||||
|
const positions = getParticlePositions();
|
||||||
|
const colors = getParticleColors();
|
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
context.fillStyle = PALETTE[colors[i]];
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(positions.x[i], positions.y[i], PARTICLE_RADIUS, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Interaction Rules
|
||||||
|
* Generate and manage particle interaction matrices
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { COLOR_NAMES } from './colors.js';
|
||||||
|
import { RULE_MIN, RULE_MAX, RULE_SELF_LIMIT, RULE_STABILITY_THRESHOLD } from './constants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a randomized 6x6 interaction matrix defining how each color affects others
|
||||||
|
* Self-interactions are limited to [-0.6, 0.6], cross-interactions to [-1, 1]
|
||||||
|
* Includes stability normalization to prevent explosive behavior
|
||||||
|
* @param {function} random - Random number generator
|
||||||
|
* @returns {number[][]} 6x6 matrix of interaction strengths
|
||||||
|
*/
|
||||||
|
export function buildInteractionRules(random) {
|
||||||
|
const colorCount = COLOR_NAMES.length;
|
||||||
|
const rules = Array.from({ length: colorCount }, () => Array(colorCount).fill(0));
|
||||||
|
|
||||||
|
for (let i = 0; i < colorCount; i++) {
|
||||||
|
for (let j = 0; j < colorCount; j++) {
|
||||||
|
if (i === j) {
|
||||||
|
// Self interaction: mild cohesion/dispersion
|
||||||
|
rules[i][j] = (random() * 2 - 1) * RULE_SELF_LIMIT;
|
||||||
|
} else {
|
||||||
|
// Cross interaction: wider range
|
||||||
|
rules[i][j] = (random() * 2 - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stability pass: normalize each row to prevent excessive total force
|
||||||
|
for (let i = 0; i < colorCount; i++) {
|
||||||
|
const rowSum = rules[i].reduce((sum, value) => sum + Math.abs(value), 0);
|
||||||
|
if (rowSum > RULE_STABILITY_THRESHOLD) {
|
||||||
|
const scale = RULE_STABILITY_THRESHOLD / rowSum;
|
||||||
|
for (let j = 0; j < colorCount; j++) {
|
||||||
|
rules[i][j] *= scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and clamp imported rules to valid range
|
||||||
|
* @param {any} importedRules - Rules to validate
|
||||||
|
* @returns {{valid: boolean, rules?: number[][], error?: string}}
|
||||||
|
*/
|
||||||
|
export function validateRules(importedRules) {
|
||||||
|
// Validate shape
|
||||||
|
if (!Array.isArray(importedRules) || importedRules.length !== 6 ||
|
||||||
|
!importedRules.every(row => Array.isArray(row) && row.length === 6)) {
|
||||||
|
return { valid: false, error: "Shape 6x6 required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp values and validate
|
||||||
|
const clampedRules = Array.from({ length: 6 }, () => Array(6).fill(0));
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
for (let j = 0; j < 6; j++) {
|
||||||
|
const value = +importedRules[i][j];
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return { valid: false, error: "Non-numeric rule" };
|
||||||
|
}
|
||||||
|
clampedRules[i][j] = Math.max(RULE_MIN, Math.min(RULE_MAX, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, rules: clampedRules };
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Spatial Grid Optimization
|
||||||
|
* Uniform spatial grid for efficient O(1) neighbor queries
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CELL_SIZE } from './constants.js';
|
||||||
|
import { getParticleCount, getParticlePositions } from './particles.js';
|
||||||
|
|
||||||
|
let gridWidth = 0;
|
||||||
|
let gridHeight = 0;
|
||||||
|
let spatialGrid = []; // Array of arrays containing particle indices
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the spatial grid for the current particle positions
|
||||||
|
* Enables O(1) neighbor queries instead of O(n^2)
|
||||||
|
* @param {number} worldWidth - Width of simulation space
|
||||||
|
* @param {number} worldHeight - Height of simulation space
|
||||||
|
*/
|
||||||
|
export function rebuildSpatialGrid(worldWidth, worldHeight) {
|
||||||
|
gridWidth = Math.ceil(worldWidth / CELL_SIZE) | 0;
|
||||||
|
gridHeight = Math.ceil(worldHeight / CELL_SIZE) | 0;
|
||||||
|
const totalCells = gridWidth * gridHeight;
|
||||||
|
|
||||||
|
// Initialize or clear grid cells
|
||||||
|
if (spatialGrid.length !== totalCells) {
|
||||||
|
spatialGrid = Array.from({ length: totalCells }, () => []);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < totalCells; i++) {
|
||||||
|
spatialGrid[i].length = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign each particle to its grid cell
|
||||||
|
const particleCount = getParticleCount();
|
||||||
|
const positions = getParticlePositions();
|
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
let cellX = (Math.floor(positions.x[i] / CELL_SIZE) % gridWidth + gridWidth) % gridWidth;
|
||||||
|
let cellY = (Math.floor(positions.y[i] / CELL_SIZE) % gridHeight + gridHeight) % gridHeight;
|
||||||
|
spatialGrid[cellY * gridWidth + cellX].push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute callback for all particles in neighboring cells (including current cell)
|
||||||
|
* @param {number} cellX - X coordinate of center cell
|
||||||
|
* @param {number} cellY - Y coordinate of center cell
|
||||||
|
* @param {function(number): void} callback - Function to call with each neighbor particle index
|
||||||
|
*/
|
||||||
|
export function forEachNeighborParticle(cellX, cellY, callback) {
|
||||||
|
for (let deltaY = -1; deltaY <= 1; deltaY++) {
|
||||||
|
for (let deltaX = -1; deltaX <= 1; deltaX++) {
|
||||||
|
const neighborX = (cellX + deltaX + gridWidth) % gridWidth;
|
||||||
|
const neighborY = (cellY + deltaY + gridHeight) % gridHeight;
|
||||||
|
const cell = spatialGrid[neighborY * gridWidth + neighborX];
|
||||||
|
for (let k = 0; k < cell.length; k++) {
|
||||||
|
callback(cell[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the grid cell coordinates for a given position
|
||||||
|
* @param {number} x - World X coordinate
|
||||||
|
* @param {number} y - World Y coordinate
|
||||||
|
* @returns {{cellX: number, cellY: number}}
|
||||||
|
*/
|
||||||
|
export function getGridCell(x, y) {
|
||||||
|
return {
|
||||||
|
cellX: (Math.floor(x / CELL_SIZE) % gridWidth + gridWidth) % gridWidth,
|
||||||
|
cellY: (Math.floor(y / CELL_SIZE) % gridHeight + gridHeight) % gridHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Rule Editor UI
|
||||||
|
* Interactive controls for editing particle interaction rules
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PALETTE } from '../colors.js';
|
||||||
|
import { RULE_MIN, RULE_MAX } from '../constants.js';
|
||||||
|
import { formatNumber } from '../utils.js';
|
||||||
|
import { validateRules } from '../rules.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup rule editor UI interactions
|
||||||
|
* @param {Object} elements - UI element references
|
||||||
|
* @param {function} syncCallback - Callback to sync UI with current rules
|
||||||
|
* @param {Object} rulesRef - Reference object containing the rules matrix
|
||||||
|
*/
|
||||||
|
export function setupRuleEditor(elements, syncCallback, rulesRef) {
|
||||||
|
const { activeColorSelect, sliders, copyButton, toggleImportButton,
|
||||||
|
importArea, rulesJsonTextarea, applyButton, cancelButton } = elements;
|
||||||
|
|
||||||
|
// Color sliders with live preview
|
||||||
|
sliders.forEach((slider, targetIndex) => {
|
||||||
|
const color = PALETTE[targetIndex];
|
||||||
|
slider.input.style.setProperty("--slider-thumb", color);
|
||||||
|
|
||||||
|
slider.input.addEventListener("input", () => {
|
||||||
|
const sourceColor = parseInt(activeColorSelect.value, 10) || 0;
|
||||||
|
const value = Math.max(RULE_MIN, Math.min(RULE_MAX, parseFloat(slider.input.value)));
|
||||||
|
rulesRef.rules[sourceColor][targetIndex] = value;
|
||||||
|
slider.output.textContent = formatNumber(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Active color selection
|
||||||
|
activeColorSelect.addEventListener("change", syncCallback);
|
||||||
|
|
||||||
|
// Copy rules to clipboard
|
||||||
|
copyButton.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(JSON.stringify(rulesRef.rules));
|
||||||
|
copyButton.textContent = "Copied!";
|
||||||
|
setTimeout(() => copyButton.textContent = "Copy Rules", 800);
|
||||||
|
} catch (error) {
|
||||||
|
// Clipboard access denied - ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import/export UI toggle
|
||||||
|
toggleImportButton.addEventListener("click", () => {
|
||||||
|
importArea.hidden = !importArea.hidden;
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelButton.addEventListener("click", () => {
|
||||||
|
importArea.hidden = true;
|
||||||
|
rulesJsonTextarea.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply imported rules
|
||||||
|
applyButton.addEventListener("click", () => {
|
||||||
|
try {
|
||||||
|
const importedRules = JSON.parse(rulesJsonTextarea.value);
|
||||||
|
const validation = validateRules(importedRules);
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(validation.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
rulesRef.rules = validation.rules;
|
||||||
|
syncCallback();
|
||||||
|
importArea.hidden = true;
|
||||||
|
} catch (error) {
|
||||||
|
alert("Invalid rules JSON: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Toolbar UI
|
||||||
|
* Responsive toolbar behavior (mobile FAB vs desktop bar)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup responsive toolbar behavior (mobile FAB vs desktop bar)
|
||||||
|
* @param {HTMLElement} toolbar
|
||||||
|
* @param {HTMLElement} toggleButton
|
||||||
|
*/
|
||||||
|
export function setupToolbarResponsiveness(toolbar, toggleButton) {
|
||||||
|
const mediaQuery = window.matchMedia("(max-width: 640px)");
|
||||||
|
|
||||||
|
function applyToolbarMode() {
|
||||||
|
if (mediaQuery.matches) {
|
||||||
|
toolbar.classList.add("mobile");
|
||||||
|
toolbar.classList.remove("open", "collapsed");
|
||||||
|
} else {
|
||||||
|
toolbar.classList.remove("mobile", "open");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaQuery.addEventListener("change", applyToolbarMode);
|
||||||
|
applyToolbarMode();
|
||||||
|
|
||||||
|
toggleButton.addEventListener("click", () => {
|
||||||
|
if (toolbar.classList.contains("mobile")) {
|
||||||
|
toolbar.classList.toggle("open"); // Mobile: toggle drawer
|
||||||
|
} else {
|
||||||
|
toolbar.classList.toggle("collapsed"); // Desktop: toggle bar
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Utility Functions
|
||||||
|
* General-purpose helper functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand for document.getElementById
|
||||||
|
* @param {string} id - Element ID to retrieve
|
||||||
|
* @returns {HTMLElement|null}
|
||||||
|
*/
|
||||||
|
export function getElementById(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a number with sign prefix and 2 decimal places
|
||||||
|
* @param {number} value - Number to format
|
||||||
|
* @returns {string} Formatted string like "+0.45" or "-1.23"
|
||||||
|
*/
|
||||||
|
export function formatNumber(value) {
|
||||||
|
return (value >= 0 ? "+" : "") + value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute toroidal (wrapping) shortest distance between two points
|
||||||
|
* @param {number} delta - Difference between coordinates
|
||||||
|
* @param {number} halfSize - Half of the world size in that dimension
|
||||||
|
* @param {number} worldSize - Full world size in that dimension
|
||||||
|
* @returns {number} Shortest wrapped distance
|
||||||
|
*/
|
||||||
|
export function wrapDistance(delta, halfSize, worldSize) {
|
||||||
|
if (delta > halfSize) return delta - worldSize;
|
||||||
|
if (delta < -halfSize) return delta + worldSize;
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a coordinate to stay within world bounds (toroidal topology)
|
||||||
|
* @param {number} position - Position to wrap
|
||||||
|
* @param {number} worldSize - Size of world dimension
|
||||||
|
* @returns {number} Wrapped position in [0, worldSize)
|
||||||
|
*/
|
||||||
|
export function wrapPosition(position, worldSize) {
|
||||||
|
if (position < 0) return position + worldSize;
|
||||||
|
if (position >= worldSize) return position - worldSize;
|
||||||
|
return position;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user