Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8b3e8447 | ||
|
|
9969c6e3cf | ||
|
|
0788015cb0 | ||
|
|
34de3c1abc | ||
|
|
311d62a719 | ||
|
|
4217bf3703 | ||
|
|
fd68ada82e | ||
|
|
7c77cd0e52 | ||
|
|
93bdb3518a | ||
|
|
69e1e8450d | ||
|
|
09b408a75b | ||
|
|
e74de40d2e | ||
|
|
03dd308449 | ||
|
|
54e4f25be7 | ||
|
|
c2622132bc | ||
|
|
9bd7eb283e | ||
|
|
9b86bfafe0 | ||
|
|
95b4ae63ea | ||
|
|
edab13f8e4 | ||
|
|
ea78302041 | ||
|
|
3b91055666 | ||
|
|
b4acdee6e3 | ||
|
|
10bf9b3d30 | ||
|
|
a3b384b221 | ||
|
|
be0a753945 | ||
|
|
eb3d15527a | ||
|
|
bb0d611b21 | ||
|
|
636f133288 | ||
|
|
847f5cc970 | ||
|
|
75d4ee8f9a | ||
|
|
d0272adb51 |
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(dotnet test:*)"
|
"Bash(dotnet test:*)",
|
||||||
|
"Bash(dotnet build)",
|
||||||
|
"Bash(dir:*)",
|
||||||
|
"Bash(node --check:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(node build.js:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
name: .NET
|
name: .NET
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [ "master" ]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "master" ]
|
branches: [ "master" ]
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ public class ApiTests : PageTest
|
|||||||
private IAPIRequestContext? _apiContext;
|
private IAPIRequestContext? _apiContext;
|
||||||
private TestConfiguration Config => TestConfiguration.Instance;
|
private TestConfiguration Config => TestConfiguration.Instance;
|
||||||
|
|
||||||
|
[OneTimeSetUp]
|
||||||
|
public async Task OneTimeSetUp()
|
||||||
|
{
|
||||||
|
if (!Config.Playwright.Headless)
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("HEADED", "1");
|
||||||
|
}
|
||||||
|
if (Config.Playwright.SlowMotion > 0)
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
public async Task Setup()
|
public async Task Setup()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -81,18 +81,4 @@ public class MultiplayerTests : PageTest
|
|||||||
// Should see SignalR connected message in console
|
// Should see SignalR connected message in console
|
||||||
Assert.That(consoleLogs, Does.Contain("✅ SignalR connected").Or.Contain("SignalR connected"));
|
Assert.That(consoleLogs, Does.Contain("✅ SignalR connected").Or.Contain("SignalR connected"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task Move_Updates_Are_Sent_Via_SignalR()
|
|
||||||
{
|
|
||||||
// This would require more complex setup with two players
|
|
||||||
// For now, just verify the SignalR methods exist in the JavaScript
|
|
||||||
var jsContent = await Page.EvaluateAsync<string>(@"
|
|
||||||
() => {
|
|
||||||
return document.querySelector('script[src*=""chessLogic.js""]') ? 'chessLogic.js loaded' : 'not found';
|
|
||||||
}
|
|
||||||
");
|
|
||||||
|
|
||||||
Assert.That(jsContent, Is.EqualTo("chessLogic.js loaded"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -9,14 +9,21 @@ namespace JoshHeaps.Net.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class ChessController(IChessService chessService, IHubContext<ChessHub> chessHub, IBackgroundTaskQueue queue) : ControllerBase
|
public class ChessController(
|
||||||
|
IChessService chessService,
|
||||||
|
IHubContext<ChessHub> chessHub,
|
||||||
|
IBackgroundTaskQueue queue) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Store of ongoing games.
|
/// Store of ongoing games.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||||
|
private static readonly ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
|
||||||
|
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
|
||||||
|
|
||||||
private static ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
|
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||||
|
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
||||||
|
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new chess game and store it in-memory.
|
/// Create a new chess game and store it in-memory.
|
||||||
@@ -54,7 +61,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
|
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -96,7 +103,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
isWhite = false;
|
isWhite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
|
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -158,7 +165,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
var expectedPlayerId = isWhiteMove ? gameState.WhitePlayerId : gameState.BlackPlayerId;
|
var expectedPlayerId = isWhiteMove ? gameState.WhitePlayerId : gameState.BlackPlayerId;
|
||||||
|
|
||||||
if (moveDto.PlayerId != expectedPlayerId)
|
if (moveDto.PlayerId != expectedPlayerId)
|
||||||
return Forbid("You are not the current player.");
|
return StatusCode(403, "You are not the current player.");
|
||||||
|
|
||||||
// Make sure player owns the piece
|
// Make sure player owns the piece
|
||||||
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId);
|
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId);
|
||||||
@@ -167,7 +174,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
return NotFound("Chess piece Id does not exist");
|
return NotFound("Chess piece Id does not exist");
|
||||||
|
|
||||||
if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black))
|
if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black))
|
||||||
return Forbid("You cannot move this piece.");
|
return StatusCode(403, "You cannot move this piece.");
|
||||||
|
|
||||||
var result = chessService.MakeMove(gameState, moveDto);
|
var result = chessService.MakeMove(gameState, moveDto);
|
||||||
|
|
||||||
@@ -175,18 +182,11 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
return BadRequest(result);
|
return BadRequest(result);
|
||||||
|
|
||||||
if (result.IsCheckmate || result.IsStalemate)
|
if (result.IsCheckmate || result.IsStalemate)
|
||||||
{
|
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||||
// queue game removal
|
else if (gameState.IsVsComputer)
|
||||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(1));
|
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||||
// increase timeout if play continues.
|
|
||||||
if (gameState.IsVsComputer)
|
|
||||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
|
|
||||||
else
|
|
||||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gameState.IsVsComputer && gameState.Computer is not null)
|
if (gameState.IsVsComputer && gameState.Computer is not null)
|
||||||
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
|
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
|
||||||
@@ -229,31 +229,36 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
|
|
||||||
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
||||||
{
|
{
|
||||||
if (_gameRemovalTasks.ContainsKey(id))
|
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
|
||||||
{
|
{
|
||||||
|
oldCts.Cancel();
|
||||||
|
oldCts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_gameRemovalCancellationTokens[id] = cts;
|
||||||
|
|
||||||
_gameRemovalTasks[id] = Task.Run(async () =>
|
_gameRemovalTasks[id] = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await Task.Delay(delay);
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(delay, cts.Token);
|
||||||
|
|
||||||
if (_games[id].Computer is not null)
|
if (_games.TryGetValue(id, out var game) && game.Computer is not null)
|
||||||
await _games[id].Computer!.DisposeAsync();
|
await game.Computer.DisposeAsync();
|
||||||
|
|
||||||
_games.Remove(id, out _);
|
_games.Remove(id, out _);
|
||||||
_gameRemovalTasks.Remove(id, out _);
|
}
|
||||||
});
|
catch (OperationCanceledException) { }
|
||||||
|
finally
|
||||||
return;
|
{
|
||||||
|
if (_gameRemovalCancellationTokens.TryGetValue(id, out var currentCts) && currentCts == cts)
|
||||||
|
{
|
||||||
|
_gameRemovalCancellationTokens.TryRemove(id, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
_gameRemovalTasks.TryAdd(id, Task.Run(async () =>
|
cts.Dispose();
|
||||||
{
|
}
|
||||||
await Task.Delay(delay);
|
});
|
||||||
|
|
||||||
if (_games[id].Computer is not null)
|
|
||||||
await _games[id].Computer!.DisposeAsync();
|
|
||||||
|
|
||||||
_games.Remove(id, out _);
|
|
||||||
_gameRemovalTasks.Remove(id, out _);
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -59,7 +59,14 @@
|
|||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="~/js/signalr/signalr.min.js"></script>
|
<script src="~/js/signalr/signalr.min.js"></script>
|
||||||
<script src="~/js/ChessScripts/chessLogic.js"></script>
|
<script src="~/js/ChessScripts/GameState.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessUtils.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessAPI.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessSignalR.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessInteractions.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessBoard.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessModals.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/chessMain.js"></script>
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
<div id="ProjectsBox">
|
<div id="ProjectsBox">
|
||||||
<div id="ChessProject" class="projectContainer">
|
<div id="ChessProject" class="projectContainer">
|
||||||
<div class="displayBox diagonal-section-left">
|
<div class="displayBox diagonal-section-left">
|
||||||
<a target="_blank" href="https://github.com/JoshHeaps/OnlineChess">
|
<a target="_blank" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml">
|
||||||
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/>
|
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div id="ChessText" class="projectTextRight">
|
<div id="ChessText" class="projectTextRight">
|
||||||
<h2 id="ChessTitle" class="projectHeaderRight projectHeader">Chess</h2>
|
<h2 id="ChessTitle" class="projectHeaderRight projectHeader">Chess</h2>
|
||||||
<p id="ChessDescription" class="projectDescriptionRight projectDescription">
|
<p id="ChessDescription" class="projectDescriptionRight projectDescription">
|
||||||
As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. This project is a work in progress, as currently it can only be played locally. It was built in C# on .NET 8.0, using winforms, because I like a challenge. I plan to add online multiplayer functionality in the future.
|
As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,6 +41,20 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="CloudStorageProject" class="projectContainer">
|
||||||
|
<div class="displayBox diagonal-section-left">
|
||||||
|
<a target="_blank" href="https://media.joshheaps.net">
|
||||||
|
<img id="CloudStorageImage" class="projectImage projectImageLeft" src="/images/CloudStorageDemo.png" title="CloudStorageScreenshot" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div id="CloudStorageText" class="projectTextRight">
|
||||||
|
<h2 id="CloudStorageTitle" class="projectHeaderRight projectHeader">Image Cloud Storage</h2>
|
||||||
|
<p id="CloudStorageDescription" class="projectDescriptionRight projectDescription">
|
||||||
|
My wife and I LOVE to take pictures and videos of our kids, but we do not like paying for extra cloud storage. I made this for my family so we can upload pictures to my server, share them with each other, and now we don't need to worry about how much space we're using.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="DemosBox" class="displayBox">
|
<div id="DemosBox" class="displayBox">
|
||||||
@@ -49,8 +63,14 @@
|
|||||||
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/chess'">
|
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/chess'">
|
||||||
Play Chess
|
Play Chess
|
||||||
</button>
|
</button>
|
||||||
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/particles'">
|
<button id="ParticleDemo" class="demoButton" onclick="window.location.href='/particles'">
|
||||||
Life
|
Particle Simulator
|
||||||
|
</button>
|
||||||
|
<button id="MemoryLane" class="demoButton" onclick="window.location.href='/memorylane'">
|
||||||
|
Memory Lane
|
||||||
|
</button>
|
||||||
|
<button id="CloadStorageDemo" class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">
|
||||||
|
Cloud Image Storage
|
||||||
</button>
|
</button>
|
||||||
<button id="MoreFiller" class="demoButton">
|
<button id="MoreFiller" class="demoButton">
|
||||||
More coming soon...
|
More coming soon...
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@page
|
||||||
|
@model JoshHeaps.Net.Pages.MemoryLaneModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Memory Lane";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="memory-lane-container">
|
||||||
|
<h1 class="memory-lane-header">Memory Lane</h1>
|
||||||
|
<p class="memory-lane-subtitle">Special moments and celebrations from our life together</p>
|
||||||
|
|
||||||
|
<div class="events-grid">
|
||||||
|
<a href="/memoryLane/fifthTempleAnniversary" class="event-card">
|
||||||
|
<div class="event-icon">💝</div>
|
||||||
|
<div class="event-title">Our 5th Sealing Anniversary</div>
|
||||||
|
<div class="event-description">
|
||||||
|
Celebrating the fifth year of our eternal journey together
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="event-card disabled">
|
||||||
|
<div class="event-icon">🎂</div>
|
||||||
|
<div class="event-title">Birthdays & Births</div>
|
||||||
|
<div class="event-description">
|
||||||
|
Welcoming new life and celebrating another year
|
||||||
|
</div>
|
||||||
|
<div class="coming-soon">Coming soon...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="event-card disabled">
|
||||||
|
<div class="event-icon">🌟</div>
|
||||||
|
<div class="event-title">Milestones</div>
|
||||||
|
<div class="event-description">
|
||||||
|
Big achievements and unforgettable moments
|
||||||
|
</div>
|
||||||
|
<div class="coming-soon">Coming soon...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="event-card disabled">
|
||||||
|
<div class="event-icon">✨</div>
|
||||||
|
<div class="event-title">More Memories</div>
|
||||||
|
<div class="event-description">
|
||||||
|
Even more special occasions and celebrations
|
||||||
|
</div>
|
||||||
|
<div class="coming-soon">Coming soon...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/" class="home-button">← Back to Home</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link href="/css/memoryLane/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Pages
|
||||||
|
{
|
||||||
|
public class MemoryLaneModel() : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@page
|
||||||
|
@model JoshHeaps.Net.Pages.MemoryLane.FifthTempleAnniversary
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Happy 5th Anniversary, Morgan!";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="anniversary-container">
|
||||||
|
<h1 class="anniversary-header">Happy 5th Anniversary, Morgan!</h1>
|
||||||
|
|
||||||
|
<div id="WelcomeMessage"></div>
|
||||||
|
|
||||||
|
<div class="hearts">💖 💕 💗</div>
|
||||||
|
|
||||||
|
<div class="photo-gallery">
|
||||||
|
<div class="photo-frame">
|
||||||
|
<div class="photo-wrapper">
|
||||||
|
<img src="/images/Wife Images/45053.jpeg" alt="Morgan with our beautiful baby">
|
||||||
|
</div>
|
||||||
|
<div class="photo-caption">The most amazing mother to our precious child</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="photo-frame">
|
||||||
|
<div class="photo-wrapper">
|
||||||
|
<img src="/images/Wife Images/45054.jpeg" alt="Josh and Morgan together">
|
||||||
|
</div>
|
||||||
|
<div class="photo-caption">My best friend and eternal companion</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="love-message">
|
||||||
|
<h2>Five Years of Forever</h2>
|
||||||
|
<p>
|
||||||
|
Five years ago today, on October 2nd, 2020, I knelt across the altar from you in the temple
|
||||||
|
and we were sealed for time and all eternity. It was the best decision I ever made.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Morgan, you are everything I could have ever hoped for and so much more. You're not just
|
||||||
|
my wife—you're my best friend, my partner in every adventure, and the love of my life.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You make me laugh every single day. Your kindness touches everyone around you. Your
|
||||||
|
strength amazes me. Your beauty, inside and out, takes my breath away. Watching you
|
||||||
|
become a mother has shown me depths of love and dedication I never knew existed.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Thank you for five incredible years of love, laughter, growth, and joy. Thank you for
|
||||||
|
choosing me, for believing in me, and for building this beautiful life with me.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Here's to five years down and an eternity to go. I love you more today than yesterday,
|
||||||
|
and I'll love you even more tomorrow.
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 1.5rem; margin-top: 2rem; color: #764ba2;">
|
||||||
|
<strong>I love you, Morgan. Forever and always. 💖</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hearts">💝 💞 💓</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link href="/css/anniversary/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="/js/anniversary/page.js"></script>
|
||||||
|
<script src="/js/utils.js"></script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Pages.MemoryLane
|
||||||
|
{
|
||||||
|
public class FifthTempleAnniversary() : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -1,35 +1,30 @@
|
|||||||
namespace JoshHeaps.Net.Services.Implementations;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Services.Implementations;
|
||||||
|
|
||||||
public class AutoIpUpdateService(
|
public class AutoIpUpdateService(
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
ILogger<AutoIpUpdateService> log)
|
ILogger<AutoIpUpdateService> log)
|
||||||
: BackgroundService
|
: BackgroundService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(5);
|
|
||||||
private static readonly HttpClient httpClient = new();
|
|
||||||
public static bool IsEnabled { get; private set; } = false;
|
public static bool IsEnabled { get; private set; } = false;
|
||||||
|
|
||||||
|
private static readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1);
|
||||||
|
private static readonly HttpClient _httpClient = new();
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stop)
|
protected override async Task ExecuteAsync(CancellationToken stop)
|
||||||
{
|
{
|
||||||
IsEnabled = true;
|
IsEnabled = true;
|
||||||
var timer = new PeriodicTimer(CheckInterval);
|
var timer = new PeriodicTimer(_checkInterval);
|
||||||
AAAARecord dnsRecord = await GetDnsRecordAsync();
|
|
||||||
string lastKnownIp = dnsRecord.Content;
|
|
||||||
|
|
||||||
while (await timer.WaitForNextTickAsync(stop))
|
while (await timer.WaitForNextTickAsync(stop))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string currentIp = await GetPublicIpAsync() ?? "";
|
await UpdateIpAddressIfChanged();
|
||||||
|
|
||||||
if (lastKnownIp != currentIp)
|
|
||||||
{
|
|
||||||
await UpdateDnsIpAsync(config, dnsRecord, currentIp);
|
|
||||||
|
|
||||||
lastKnownIp = currentIp;
|
|
||||||
}
|
}
|
||||||
}
|
catch (OperationCanceledException) { break; }
|
||||||
catch (OperationCanceledException) { /* shutting down */ }
|
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
log.LogError(ex, "Error while attempting ip update");
|
log.LogError(ex, "Error while attempting ip update");
|
||||||
@@ -37,11 +32,33 @@ public class AutoIpUpdateService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task UpdateIpAddressIfChanged()
|
||||||
|
{
|
||||||
|
var dnsRecords = await GetDnsRecordAsync();
|
||||||
|
string dnsRecordIp = dnsRecords[0].Content;
|
||||||
|
|
||||||
|
if (!dnsRecords.Records.All(x => x.Content == dnsRecords[0].Content))
|
||||||
|
dnsRecordIp = string.Empty;
|
||||||
|
|
||||||
|
string publicIp = await GetPublicIpAsync() ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(publicIp) || dnsRecordIp == publicIp)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var dnsRecord in dnsRecords.Records)
|
||||||
|
{
|
||||||
|
if (dnsRecord.Content == publicIp)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await UpdateDnsIpAsync(config, dnsRecord, publicIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<string> GetPublicIpAsync()
|
private static async Task<string> GetPublicIpAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await httpClient.GetStringAsync(@"https://api.ipify.org/");
|
return await _httpClient.GetStringAsync(@"https://api.ipify.org/");
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -52,7 +69,7 @@ public class AutoIpUpdateService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<AAAARecord> GetDnsRecordAsync()
|
private async Task<RecordList> GetDnsRecordAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -61,8 +78,8 @@ public class AutoIpUpdateService(
|
|||||||
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
|
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
|
||||||
var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records");
|
var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records");
|
||||||
Console.WriteLine(await result.Content.ReadAsStringAsync());
|
Console.WriteLine(await result.Content.ReadAsStringAsync());
|
||||||
var records = System.Text.Json.JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
|
var records = JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
|
||||||
return records!.Result[0];
|
return records!;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -73,22 +90,23 @@ public class AutoIpUpdateService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task UpdateDnsIpAsync(IConfiguration config, AAAARecord record, string ip)
|
private static async Task UpdateDnsIpAsync(IConfiguration config, DnsRecord record, string ip)
|
||||||
{
|
{
|
||||||
HttpClient cfClient = new();
|
HttpClient cfClient = new();
|
||||||
cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]);
|
cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]);
|
||||||
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
|
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
|
||||||
object content = new
|
|
||||||
|
object updateRequestBody = new
|
||||||
{
|
{
|
||||||
comment = "Update as needed",
|
name = record.Name,
|
||||||
|
ttl = record.Ttl,
|
||||||
|
type = record.Type,
|
||||||
|
comment = record.Comment,
|
||||||
content = ip,
|
content = ip,
|
||||||
name = "@",
|
proxied = record.Proxied,
|
||||||
proxied = true,
|
|
||||||
ttl = 3600,
|
|
||||||
type = "AAAA"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await cfClient.PutAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records{record.Id}", content);
|
var result = await cfClient.PatchAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records/{record.Id}", updateRequestBody);
|
||||||
|
|
||||||
if (!result.IsSuccessStatusCode)
|
if (!result.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -99,7 +117,23 @@ public class AutoIpUpdateService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
record AAAARecord(string Comment, string Content, string Name, string Id);
|
record DnsRecord(
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("ttl")] int Ttl,
|
||||||
|
[property: JsonPropertyName("type")] string Type,
|
||||||
|
[property: JsonPropertyName("comment")] string Comment,
|
||||||
|
[property: JsonPropertyName("content")] string Content,
|
||||||
|
[property: JsonPropertyName("proxied")] bool Proxied,
|
||||||
|
[property: JsonPropertyName("id")] string Id);
|
||||||
|
|
||||||
record RecordList(List<AAAARecord> Result);
|
record RecordList([property: JsonPropertyName("result")] List<DnsRecord> Records)
|
||||||
|
{
|
||||||
|
public DnsRecord this[int index]
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return Records[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ public class ChessService : IChessService
|
|||||||
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == pieceId);
|
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == pieceId);
|
||||||
|
|
||||||
if (piece == null) return [];
|
if (piece == null) return [];
|
||||||
if (piece.Color != gameState.CurrentPlayer) return [];
|
|
||||||
|
|
||||||
var candidateMoves = GenerateCandidateMoves(gameState, piece);
|
var candidateMoves = GenerateCandidateMoves(gameState, piece);
|
||||||
var legalMoves = new List<Position>();
|
var legalMoves = new List<Position>();
|
||||||
@@ -529,8 +528,15 @@ public class ChessService : IChessService
|
|||||||
var ep = gs.EnPassantTarget.Value;
|
var ep = gs.EnPassantTarget.Value;
|
||||||
|
|
||||||
if (ep.Row == forward1 && Math.Abs(ep.Col - startCol) == 1)
|
if (ep.Row == forward1 && Math.Abs(ep.Col - startCol) == 1)
|
||||||
|
{
|
||||||
|
// Verify there's an enemy pawn to capture
|
||||||
|
int enemyPawnRow = piece.Color == PieceColor.White ? ep.Row + 1 : ep.Row - 1;
|
||||||
|
var enemyPawn = gs.Board[enemyPawnRow, ep.Col];
|
||||||
|
|
||||||
|
if (enemyPawn != null && enemyPawn.Type == PieceType.Pawn && enemyPawn.Color != piece.Color)
|
||||||
moves.Add(ep);
|
moves.Add(ep);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return moves;
|
return moves;
|
||||||
}
|
}
|
||||||
@@ -542,11 +548,7 @@ public class ChessService : IChessService
|
|||||||
=> GenerateSlidingMoves(gs, piece, [(1, 1), (1, -1), (-1, 1), (-1, -1)]);
|
=> GenerateSlidingMoves(gs, piece, [(1, 1), (1, -1), (-1, 1), (-1, -1)]);
|
||||||
|
|
||||||
private static List<Position> GenerateQueenMoves(GameState gs, ChessPiece piece)
|
private static List<Position> GenerateQueenMoves(GameState gs, ChessPiece piece)
|
||||||
=> GenerateSlidingMoves(gs, piece,
|
=> [..GenerateRookMoves(gs, piece), ..GenerateBishopMoves(gs, piece)];
|
||||||
[
|
|
||||||
(1, 0), (-1, 0), (0, 1), (0, -1),
|
|
||||||
(1, 1), (1, -1), (-1, 1), (-1, -1)
|
|
||||||
]);
|
|
||||||
|
|
||||||
private static List<Position> GenerateSlidingMoves(GameState gs, ChessPiece piece, (int dr, int dc)[] directions)
|
private static List<Position> GenerateSlidingMoves(GameState gs, ChessPiece piece, (int dr, int dc)[] directions)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-header {
|
||||||
|
color: white;
|
||||||
|
font-size: 3.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||||
|
animation: fadeInDown 1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#WelcomeMessage {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem 2.5rem;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
max-width: 900px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
animation: fadeIn 2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hearts {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
animation: heartbeat 1.5s ease-in-out infinite;
|
||||||
|
width: fit-content;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
max-width: 1200px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame {
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 15px 40px rgba(0,0,0,0.3);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
animation: fadeInUp 1.5s ease-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame:hover {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 500px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame:nth-child(2) img {
|
||||||
|
object-position: center 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-caption {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2.5rem 3rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
max-width: 1000px;
|
||||||
|
color: #444;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
animation: fadeIn 2.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message h2 {
|
||||||
|
color: #764ba2;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.9;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message p:last-of-type {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heartbeat {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.photo-gallery {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-wrapper {
|
||||||
|
height: 450px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.anniversary-container {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-header {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#WelcomeMessage {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-wrapper {
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message p {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hearts {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
#chessBoard {
|
#boardContainer {
|
||||||
|
position: relative;
|
||||||
|
width: fit-content;
|
||||||
margin: 2vw auto;
|
margin: 2vw auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chessBoard {
|
||||||
width: 60vw;
|
width: 60vw;
|
||||||
height: 60vw;
|
height: 60vw;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -159,3 +164,29 @@
|
|||||||
height: 50px;
|
height: 50px;
|
||||||
pointer-events: none; /* ensures img doesn't steal the click */
|
pointer-events: none; /* ensures img doesn't steal the click */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chessSquare .coordinate-label {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 1.2vw;
|
||||||
|
font-weight: bold;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chessSquare.light .coordinate-label {
|
||||||
|
color: #656770;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chessSquare.dark .coordinate-label {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chessSquare .row-label {
|
||||||
|
top: 2px;
|
||||||
|
left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chessSquare .col-label {
|
||||||
|
bottom: 2px;
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
@@ -55,8 +55,8 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5vw;
|
border-radius: 5vw;
|
||||||
width: 25vw;
|
width: 20vw;
|
||||||
height: 10vw;
|
height: 8vw;
|
||||||
font-size: 2vw;
|
font-size: 2vw;
|
||||||
background-color: rgb(104, 255, 0, 0.39);
|
background-color: rgb(104, 255, 0, 0.39);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-lane-container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-lane-header {
|
||||||
|
color: #f5f5f5;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-lane-subtitle {
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 3rem auto;
|
||||||
|
max-width: 900px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:hover {
|
||||||
|
background: #2f2f2f;
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card.disabled:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #f5f5f5;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-description {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-button {
|
||||||
|
margin-top: 4rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #f5f5f5;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-button:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.memory-lane-header {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-lane-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,113 @@
|
|||||||
|
const ChessAPI = {
|
||||||
|
async joinGame() {
|
||||||
|
const response = await fetch('/api/chess/JoinGame');
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCPUGame(difficulty) {
|
||||||
|
const response = await fetch(`/api/chess/new/${difficulty}`);
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGameState(gameId) {
|
||||||
|
const response = await fetch(`/api/chess/${gameId}`);
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLegalMoves(pieceId) {
|
||||||
|
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
|
||||||
|
if (!response.ok) throw new Error("API failed");
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async makeMove(moveDto) {
|
||||||
|
const response = await fetch("/api/chess/move", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(moveDto)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = await response.text();
|
||||||
|
throw new Error(message || "Invalid move or not your turn.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || "Invalid move or not your turn.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleMove(targetRow, targetCol) {
|
||||||
|
if (!GameState.selectedPiece) return;
|
||||||
|
|
||||||
|
const isPawn = GameState.selectedPiece.type === 0;
|
||||||
|
const reachedEnd = (Number(GameState.selectedPiece.color) === 0 && Number(targetRow) === 0) ||
|
||||||
|
(Number(GameState.selectedPiece.color) === 1 && Number(targetRow) === 0);
|
||||||
|
|
||||||
|
let choice = null;
|
||||||
|
|
||||||
|
if (isPawn && reachedEnd) {
|
||||||
|
choice = await ChessModals.promptPromotion();
|
||||||
|
if (!choice) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[targetRow, targetCol] = ChessUtils.flipCoordinates(targetRow, targetCol);
|
||||||
|
|
||||||
|
const moveDto = {
|
||||||
|
GameId: GameState.currentGameId,
|
||||||
|
PlayerId: GameState.currentPlayerId,
|
||||||
|
PieceId: GameState.selectedPiece.id,
|
||||||
|
SourceRow: GameState.selectedPiece.row,
|
||||||
|
SourceCol: GameState.selectedPiece.col,
|
||||||
|
TargetRow: targetRow,
|
||||||
|
TargetCol: targetCol,
|
||||||
|
PromotionChoice: choice
|
||||||
|
};
|
||||||
|
|
||||||
|
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moveResult = await this.makeMove(moveDto);
|
||||||
|
|
||||||
|
const updatedGame = await this.getGameState(GameState.currentGameId);
|
||||||
|
ChessBoard.renderPieces(updatedGame.pieces);
|
||||||
|
|
||||||
|
GameState.clearSelection();
|
||||||
|
ChessInteractions.clearHighlights();
|
||||||
|
|
||||||
|
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
|
||||||
|
this.alertGameStatusChange(moveResult);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert("❌ " + error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
alertGameStatusChange(moveResult) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
let gameOver = false;
|
||||||
|
|
||||||
|
if (moveResult.isCheck) {
|
||||||
|
console.log("🛑 Check!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moveResult.isCheckmate) {
|
||||||
|
alert("♟️ Checkmate!");
|
||||||
|
gameOver = true;
|
||||||
|
} else if (moveResult.isStalemate) {
|
||||||
|
alert("🤝 Stalemate!");
|
||||||
|
gameOver = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameOver) {
|
||||||
|
await ChessSignalR.leaveGame();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessAPI.js loaded");
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
const ChessBoard = {
|
||||||
|
renderPieces(pieces) {
|
||||||
|
this.clearAllSquares();
|
||||||
|
this.renderCoordinateLabels();
|
||||||
|
this.placePieces(pieces);
|
||||||
|
this.setupSquareEventHandlers();
|
||||||
|
this.highlightPreviousMove();
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAllSquares() {
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
square.innerHTML = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
placePieces(pieces) {
|
||||||
|
pieces.forEach(piece => {
|
||||||
|
const [r, c] = ChessUtils.flipCoordinates(piece.row, piece.col);
|
||||||
|
const index = r * 8 + c;
|
||||||
|
const square = document.getElementById(`square-${index}`);
|
||||||
|
if (!square) return;
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = ChessUtils.getPieceImageUrl(piece);
|
||||||
|
img.alt = piece.type;
|
||||||
|
img.classList.add("chessPiece");
|
||||||
|
|
||||||
|
this.setupPieceDragEvents(img, piece);
|
||||||
|
this.setupPieceClickEvent(img, piece);
|
||||||
|
|
||||||
|
square.appendChild(img);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setupPieceDragEvents(img, piece) {
|
||||||
|
img.draggable = true;
|
||||||
|
img.ondragstart = async (e) => {
|
||||||
|
GameState.setSelectedPiece(piece);
|
||||||
|
try {
|
||||||
|
const moves = await ChessAPI.getLegalMoves(piece.id);
|
||||||
|
GameState.setLegalMoves(moves);
|
||||||
|
ChessInteractions.highlightSelected(piece.row, piece.col);
|
||||||
|
ChessInteractions.highlightLegalMoves(moves);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.dataTransfer.setData("text/plain", JSON.stringify({
|
||||||
|
srcRow: piece.Row,
|
||||||
|
srcCol: piece.Col
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.ondragend = () => ChessInteractions.clearHighlights();
|
||||||
|
},
|
||||||
|
|
||||||
|
setupPieceClickEvent(img, piece) {
|
||||||
|
img.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
ChessInteractions.handlePieceClick(piece);
|
||||||
|
console.log("Clicked on:", piece);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
setupSquareEventHandlers() {
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
square.classList.remove("previous-start", "previous-end");
|
||||||
|
|
||||||
|
if (!square.classList.contains("legal")) {
|
||||||
|
square.onclick = () => ChessInteractions.clearHighlights();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightPreviousMove() {
|
||||||
|
if (GameState.previousMoveStart && GameState.previousMoveEnd) {
|
||||||
|
const [startRow, startCol] = ChessUtils.flipCoordinates(...GameState.previousMoveStart);
|
||||||
|
const [endRow, endCol] = ChessUtils.flipCoordinates(...GameState.previousMoveEnd);
|
||||||
|
|
||||||
|
const startIndex = startRow * 8 + startCol;
|
||||||
|
const endIndex = endRow * 8 + endCol;
|
||||||
|
|
||||||
|
document.getElementById(`square-${startIndex}`)?.classList.add("previous-start");
|
||||||
|
document.getElementById(`square-${endIndex}`)?.classList.add("previous-end");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setupDragAndDrop() {
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
|
||||||
|
square.ondragover = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
square.ondrop = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!GameState.selectedPiece) return;
|
||||||
|
|
||||||
|
const index = parseInt(square.id.split('-')[1], 10);
|
||||||
|
const targetRow = Math.floor(index / 8);
|
||||||
|
const targetCol = index % 8;
|
||||||
|
|
||||||
|
ChessAPI.handleMove(targetRow, targetCol);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderCoordinateLabels() {
|
||||||
|
const files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
|
||||||
|
const ranks = ['8', '7', '6', '5', '4', '3', '2', '1'];
|
||||||
|
|
||||||
|
// If player is black, reverse the coordinates
|
||||||
|
if (!GameState.currentPlayerIsWhite) {
|
||||||
|
files.reverse();
|
||||||
|
ranks.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
const row = Math.floor(i / 8);
|
||||||
|
const col = i % 8;
|
||||||
|
|
||||||
|
// Add rank label (1-8) on the leftmost column
|
||||||
|
if (col === 0) {
|
||||||
|
const rankLabel = document.createElement('span');
|
||||||
|
rankLabel.className = 'coordinate-label row-label';
|
||||||
|
rankLabel.textContent = ranks[row];
|
||||||
|
square.appendChild(rankLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add file label (a-h) on the bottom row
|
||||||
|
if (row === 7) {
|
||||||
|
const fileLabel = document.createElement('span');
|
||||||
|
fileLabel.className = 'coordinate-label col-label';
|
||||||
|
fileLabel.textContent = files[col];
|
||||||
|
square.appendChild(fileLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessBoard.js loaded");
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const ChessInteractions = {
|
||||||
|
async handlePieceClick(piece) {
|
||||||
|
this.clearHighlights();
|
||||||
|
GameState.setSelectedPiece(piece);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moves = await ChessAPI.getLegalMoves(piece.id);
|
||||||
|
GameState.setLegalMoves(moves);
|
||||||
|
this.highlightSelected(piece.row, piece.col);
|
||||||
|
this.highlightLegalMoves(moves);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error in handlePieceClick:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightSelected(row, col) {
|
||||||
|
const [r, c] = ChessUtils.flipCoordinates(row, col);
|
||||||
|
const index = r * 8 + c;
|
||||||
|
document.getElementById(`square-${index}`).classList.add("selected");
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightLegalMoves(moves) {
|
||||||
|
moves.forEach(move => {
|
||||||
|
const [r, c] = ChessUtils.flipCoordinates(move.row, move.col);
|
||||||
|
const index = r * 8 + c;
|
||||||
|
const square = document.getElementById(`square-${index}`);
|
||||||
|
square.classList.add("legal");
|
||||||
|
|
||||||
|
const img = square.querySelector("img");
|
||||||
|
if (img) img.onclick = null;
|
||||||
|
|
||||||
|
let overlay = document.createElement("div");
|
||||||
|
overlay.className = "legalOverlay";
|
||||||
|
square.appendChild(overlay);
|
||||||
|
|
||||||
|
square.onclick = () => ChessAPI.handleMove(r, c);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearHighlights() {
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
|
||||||
|
const overlay = square.querySelector(".legalOverlay");
|
||||||
|
if (overlay) square.removeChild(overlay);
|
||||||
|
|
||||||
|
if (square.classList.contains("legal")) {
|
||||||
|
square.classList.remove("legal");
|
||||||
|
square.onclick = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
square.classList.remove("selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
GameState.clearSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessInteractions.js loaded");
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const ChessModals = {
|
||||||
|
promptPromotion() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let color = 1;
|
||||||
|
if (GameState.currentPlayerIsWhite)
|
||||||
|
color = 0;
|
||||||
|
|
||||||
|
ChessUtils.updatePromotionModalImages(color);
|
||||||
|
|
||||||
|
document.getElementById("promotionModal").style.display = "block";
|
||||||
|
window.selectPromotion = (piece) => {
|
||||||
|
document.getElementById("promotionModal").style.display = "none";
|
||||||
|
resolve(piece);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
promptDifficulty() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
document.getElementById("difficultyModal").style.display = "block";
|
||||||
|
window.selectDifficulty = (difficulty) => {
|
||||||
|
document.getElementById("difficultyModal").style.display = "none";
|
||||||
|
resolve(difficulty);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessModals.js loaded");
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const ChessSignalR = {
|
||||||
|
connection: null,
|
||||||
|
|
||||||
|
async setupConnection() {
|
||||||
|
this.connection = new signalR.HubConnectionBuilder()
|
||||||
|
.withUrl("/chessHub")
|
||||||
|
.configureLogging(signalR.LogLevel.Information)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.connection.onclose(err => {
|
||||||
|
console.error("❌ SignalR connection closed:", err?.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => {
|
||||||
|
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connection.start();
|
||||||
|
console.log("✅ SignalR connected");
|
||||||
|
await this.connection.invoke("JoinWebsocketGroup", GameState.currentGameId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ SignalR failed to start or join:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleMoveUpdate(gameId, moveDto, moveResultDto) {
|
||||||
|
if (gameId !== GameState.currentGameId) return;
|
||||||
|
|
||||||
|
const gameState = await ChessAPI.getGameState(gameId);
|
||||||
|
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
|
||||||
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
|
ChessAPI.alertGameStatusChange(moveResultDto);
|
||||||
|
},
|
||||||
|
|
||||||
|
async notifyMoveMade(moveDto, moveResult) {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async leaveGame() {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId);
|
||||||
|
await this.connection.stop();
|
||||||
|
this.connection = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopConnection() {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.stop();
|
||||||
|
this.connection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessSignalR.js loaded");
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const ChessUtils = {
|
||||||
|
flipCoordinates(row, col) {
|
||||||
|
if (!GameState.currentPlayerIsWhite) {
|
||||||
|
return [7 - row, 7 - col];
|
||||||
|
}
|
||||||
|
return [row, col];
|
||||||
|
},
|
||||||
|
|
||||||
|
getCookie(name) {
|
||||||
|
const value = document.cookie.split('; ')
|
||||||
|
.find(row => row.startsWith(name + '='));
|
||||||
|
return value ? value.split('=')[1] : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
setCookie(name, value, maxAge = 86400) {
|
||||||
|
document.cookie = `${name}=${value}; path=/; max-age=${maxAge}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPieceImageUrl(piece) {
|
||||||
|
const basePath = "/images/Chess Images/";
|
||||||
|
const color = piece.color === 0 ? "White" : "Black";
|
||||||
|
const typeMap = {
|
||||||
|
0: "Pawn",
|
||||||
|
1: "Rook",
|
||||||
|
2: "Knight",
|
||||||
|
3: "Bishop",
|
||||||
|
4: "Queen",
|
||||||
|
5: "King"
|
||||||
|
};
|
||||||
|
|
||||||
|
return basePath + color + typeMap[piece.type] + ".svg";
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePromotionModalImages(color) {
|
||||||
|
const pieceNames = ["Queen", "Rook", "Bishop", "Knight"];
|
||||||
|
const buttons = document.querySelectorAll("#promotionModal button img");
|
||||||
|
|
||||||
|
buttons.forEach((img, index) => {
|
||||||
|
img.src = `/images/Chess Images/${color === 0 ? "White" : "Black"}${pieceNames[index]}.svg`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessUtils.js loaded");
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
const GameState = {
|
||||||
|
currentGameId: null,
|
||||||
|
currentPlayerId: null,
|
||||||
|
currentPlayerIsWhite: null,
|
||||||
|
selectedPiece: null,
|
||||||
|
legalMoves: [],
|
||||||
|
previousMoveStart: null,
|
||||||
|
previousMoveEnd: null,
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.currentGameId = null;
|
||||||
|
this.currentPlayerId = null;
|
||||||
|
this.currentPlayerIsWhite = null;
|
||||||
|
this.selectedPiece = null;
|
||||||
|
this.legalMoves = [];
|
||||||
|
this.previousMoveStart = null;
|
||||||
|
this.previousMoveEnd = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
setGameInfo(gameId, playerId, isWhite) {
|
||||||
|
this.currentGameId = gameId;
|
||||||
|
this.currentPlayerId = playerId;
|
||||||
|
this.currentPlayerIsWhite = isWhite;
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedPiece(piece) {
|
||||||
|
this.selectedPiece = piece;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSelection() {
|
||||||
|
this.selectedPiece = null;
|
||||||
|
this.legalMoves = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
setLegalMoves(moves) {
|
||||||
|
this.legalMoves = moves;
|
||||||
|
},
|
||||||
|
|
||||||
|
setPreviousMove(startPos, endPos) {
|
||||||
|
this.previousMoveStart = startPos;
|
||||||
|
this.previousMoveEnd = endPos;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearPreviousMove() {
|
||||||
|
this.previousMoveStart = null;
|
||||||
|
this.previousMoveEnd = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("GameState.js loaded");
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
async function startNewGame() {
|
||||||
|
await ChessSignalR.stopConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gameData = await ChessAPI.joinGame();
|
||||||
|
|
||||||
|
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
||||||
|
GameState.clearPreviousMove();
|
||||||
|
|
||||||
|
ChessUtils.setCookie('chessGameId', gameData.gameId);
|
||||||
|
ChessUtils.setCookie('chessPlayerId', gameData.id);
|
||||||
|
ChessUtils.setCookie('chessPlayerIsWhite', gameData.isWhite);
|
||||||
|
|
||||||
|
console.log("🆕 Game started:", gameData.gameId);
|
||||||
|
|
||||||
|
await ChessSignalR.setupConnection();
|
||||||
|
|
||||||
|
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||||
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start new game:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCPUGame() {
|
||||||
|
await ChessSignalR.stopConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const difficulty = await ChessModals.promptDifficulty();
|
||||||
|
const gameData = await ChessAPI.createCPUGame(difficulty);
|
||||||
|
|
||||||
|
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
||||||
|
GameState.clearPreviousMove();
|
||||||
|
|
||||||
|
ChessUtils.setCookie('chessGameId', gameData.gameId);
|
||||||
|
ChessUtils.setCookie('chessPlayerId', gameData.id);
|
||||||
|
ChessUtils.setCookie('chessPlayerIsWhite', gameData.isWhite);
|
||||||
|
|
||||||
|
console.log("🆕 CPU Game started:", gameData.gameId);
|
||||||
|
|
||||||
|
await ChessSignalR.setupConnection();
|
||||||
|
|
||||||
|
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||||
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start CPU game:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeSavedGame() {
|
||||||
|
const savedGameId = ChessUtils.getCookie("chessGameId");
|
||||||
|
const savedPlayerId = ChessUtils.getCookie("chessPlayerId");
|
||||||
|
const savedPlayerIsWhite = ChessUtils.getCookie("chessPlayerIsWhite");
|
||||||
|
|
||||||
|
if (savedGameId && savedPlayerId) {
|
||||||
|
try {
|
||||||
|
const gameState = await ChessAPI.getGameState(savedGameId);
|
||||||
|
|
||||||
|
console.log("🧠 Rejoining saved game...");
|
||||||
|
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
||||||
|
|
||||||
|
await ChessSignalR.setupConnection();
|
||||||
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Saved game not found or expired.", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.startNewGame = startNewGame;
|
||||||
|
window.startCPUGame = startCPUGame;
|
||||||
|
|
||||||
|
window.addEventListener('load', resumeSavedGame);
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
ChessBoard.setupDragAndDrop();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("chessMain.js loaded");
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
getIpStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getIpStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/debug/IpCheck`);
|
||||||
|
if (!res.ok) throw new Error("API failed");
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
console.log(text);
|
||||||
|
document.querySelector('#IsIpChecking').textContent = text;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error in api:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
window.onload = function () {
|
||||||
|
const element = document.getElementById("WelcomeMessage");
|
||||||
|
element.style.display = "none"; // Trigger reflow
|
||||||
|
element.offsetHeight; // Force reflow
|
||||||
|
element.style.display = ""; // Restore the original display
|
||||||
|
typeText("#WelcomeMessage", "Today, October 2nd, marks the 5th anniversary of the day I was sealed for time and all eternity to my wonderful wife. She's the kindest, sweetest, funniest, most beautiful woman I've ever met. I love you Morgan 💖");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
element.style.display = "none"; // Trigger reflow
|
element.style.display = "none"; // Trigger reflow
|
||||||
element.offsetHeight; // Force reflow
|
element.offsetHeight; // Force reflow
|
||||||
element.style.display = ""; // Restore the original display
|
element.style.display = ""; // Restore the original display
|
||||||
typeText("#WelcomeMessage", "Hey! You found me! Since I have your attention, why don't I tell you about myself?");
|
typeText("#WelcomeMessage", "Hey, you found me...!?``````````````````````No that's stupid...```````````````````Just...```````uhhhh...````````Hello! My name is Josh. I'm a software engineer, and I try to have a lot of fun with what I do. While I have your attention, why don't I tell you more about myself?");
|
||||||
}
|
}
|
||||||
@@ -12,23 +12,27 @@
|
|||||||
let intervalId;
|
let intervalId;
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const punctuation = ['.', '!', '?'];
|
const punctuation = ['.', '!', '?'];
|
||||||
|
const punctuationDict = {
|
||||||
|
'.': 150,
|
||||||
|
'!': 200,
|
||||||
|
'?': 200,
|
||||||
|
',': 100,
|
||||||
|
'`': 25,
|
||||||
|
};
|
||||||
|
|
||||||
let currentText = "";
|
let currentText = "";
|
||||||
|
|
||||||
function changeIntervalTime(element, text) {
|
function changeIntervalTime(element, text) {
|
||||||
if (punctuation.includes(text.charAt(index))) {
|
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
simulateTyping(element, text, 200);
|
simulateTyping(element, text, punctuationDict[text.charAt(index)] ?? 50);
|
||||||
}
|
|
||||||
else {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
simulateTyping(element, text);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function simulateTyping(element, text, speed = 50) {
|
function simulateTyping(element, text, speed = 50) {
|
||||||
intervalId = setInterval(() => {
|
intervalId = setInterval(() => {
|
||||||
if (index < text.length) {
|
if (index < text.length) {
|
||||||
|
if (text.charAt(index) === '`')
|
||||||
|
currentText = currentText.slice(0, -1);
|
||||||
|
else
|
||||||
currentText += text.charAt(index);
|
currentText += text.charAt(index);
|
||||||
changeIntervalTime(element, text);
|
changeIntervalTime(element, text);
|
||||||
index++;
|
index++;
|
||||||
|
|||||||