Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4615f70
added sparkles to corner
phillipnguyenn Feb 7, 2026
91a3fb0
added second sparkle to second section of index
phillipnguyenn Feb 7, 2026
418667e
reduced the size of two sparkles
phillipnguyenn Feb 7, 2026
c52ed9d
moved double sparkles from index to event highlight card
phillipnguyenn Feb 10, 2026
9ef4639
added 3 randomised images which loads new combination for sparkles
phillipnguyenn Feb 18, 2026
a211ff9
changed logic of random sparkles to just choose random number instead…
phillipnguyenn Feb 18, 2026
12dc4cc
fixed up to be cleaner logic for random sparkle
phillipnguyenn Feb 18, 2026
af5dadb
changed opacity of sparkles to be full
phillipnguyenn Feb 18, 2026
d2ed15b
changed order of sparkle overlay to account for images, sparkle shoul…
phillipnguyenn Feb 18, 2026
a4a7556
corrected placement of sparkle again..
phillipnguyenn Feb 18, 2026
0c1574d
added explosion component with framer motion, bomb can be clicked
phillipnguyenn Feb 21, 2026
fe204e8
added random offset for game effect
phillipnguyenn Feb 21, 2026
a403047
modified handling of explosion to ensure that animation fully plays b…
phillipnguyenn Feb 21, 2026
ca72580
added ability to pass x & y offsets to adjust exact positioning of ex…
phillipnguyenn Feb 21, 2026
bcf315c
added offset to better center explosion for bomb
phillipnguyenn Feb 21, 2026
43750c1
changed look of explosion to be more pixelated
phillipnguyenn Feb 21, 2026
cd1debb
added better randomness for the each individual particle
phillipnguyenn Feb 21, 2026
bf62e22
changed number of particles (odd numbers work better)
phillipnguyenn Feb 21, 2026
198e3d8
added better comments for sparkle selection logic
phillipnguyenn Feb 21, 2026
e2ab4a1
removed magic numbers for sparkle overlay
phillipnguyenn Feb 21, 2026
adbedec
merge main into issue-69 to prepare for sparkle PR
phillipnguyenn Feb 21, 2026
41b38a7
refactor: enhance sparkle overlay logic with unique index handling, i…
phillipnguyenn Feb 21, 2026
22698c9
refactor: change Explosion component to export function syntax
phillipnguyenn Feb 21, 2026
582f32e
explosions now work without re-rendering sparkles
phillipnguyenn Feb 21, 2026
609e183
Revert "merge main into issue-69 to prepare for sparkle PR"
SafetyInObscurity Mar 18, 2026
223bbe3
Merge branch 'main' into issue-69-Add_landing_sparkles_and_explosions
SafetyInObscurity Mar 19, 2026
a325b33
Fix linting
SafetyInObscurity Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added client/public/sparkle_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client/public/sparkle_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client/public/sparkle_3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
138 changes: 50 additions & 88 deletions client/src/components/ui/Explosion.tsx
Original file line number Diff line number Diff line change
@@ -1,95 +1,57 @@
import Image from "next/image";
import React, { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";

import { ExplosionPosition } from "../../hooks/useExplosions";
import { Crater } from "./Crater";
import { DebrisBurst } from "./DebrisBurst";
import { Smoke } from "./Smoke";
type ExplosionProps = {
colour1: string;
colour2: string;
// looks better with odd numbers
count: number;
// can optionally take offsets to adjust exact position
yOffset?: number;
xOffset?: number;
};

interface ExplosionProps {
explosion: ExplosionPosition;
}

/**
* Renders a single explosion at a specific position.
* Position is defined as a percentage of the parent container.
*/
export const Explosion = React.memo(function Explosion({
explosion,
export function Explosion({
colour1,
colour2,
count,
yOffset = 0,
xOffset = 0,
}: ExplosionProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [debrisPosition, setDebrisPosition] = useState<{
x: number;
y: number;
} | null>(null);

// Convert percentage position to pixel coordinates for DebrisBurst
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current.closest(
'[class*="relative"]',
) as HTMLElement;
if (!container) {
// Fallback: use window if no relative container found
const x = (explosion.x / 100) * window.innerWidth;
const y = (explosion.y / 100) * window.innerHeight;
setDebrisPosition({ x, y });
return;
}

const rect = container.getBoundingClientRect();
const x = rect.left + (explosion.x / 100) * rect.width;
const y = rect.top + (explosion.y / 100) * rect.height;
setDebrisPosition({ x, y });
}, [explosion.x, explosion.y]);
const particles = Array.from({ length: count });

return (
<div ref={containerRef}>
{/* SVG Crater with depth shading */}
<div
className="pointer-events-none absolute z-40"
style={{
left: `${explosion.x}%`,
top: `${explosion.y}%`,
transform: "translate(-50%, -50%)",
animation: "crater-fade 2.5s ease-out forwards",
willChange: "opacity",
}}
>
<Crater size={110} intensity={0.95} />
</div>
{/* Physics-based debris burst */}
{debrisPosition && (
<DebrisBurst
x={debrisPosition.x}
y={debrisPosition.y}
count={6}
power={400}
spreadDeg={360}
gravity={1000}
bounce={0.25}
/>
)}
{/* The actual explosion GIF */}
<div
className="pointer-events-none absolute z-50"
style={{
left: `${explosion.x}%`,
top: `${explosion.y}%`,
transform: "translate(-50%, -50%)",
}}
>
<Image
src="/explosions/samj_cartoon_explosion.gif"
alt="Explosion"
width={150}
height={150}
className="animate-fade-in"
unoptimized // GIFs need unoptimized to animate
/>
</div>
{/* Rising smoke effect */}
<Smoke x={explosion.x} y={explosion.y} duration={1800} />
<div className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center">
{particles.map((_, i) => {
const individualSize = Math.random() * 0.1 + 4;
const randomDisplacement = Math.random() * 10;

// for every particle return a particle with a different size
return (
<motion.div
key={i}
initial={{
x: xOffset,
y: yOffset,
opacity: 1,
scale: individualSize,
}}
animate={{
x: Math.cos(i + randomDisplacement) * 150 + xOffset,
y: Math.sin(i + randomDisplacement) * 150 + yOffset,
opacity: 0,
scale: 0,
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="absolute h-4 w-4 rounded-none"
style={{
backgroundColor: i % 2 === 0 ? colour1 : colour2,
boxShadow: `0 0 10px ${colour1}`,
}}
/>
);
})}
</div>
);
});
}

export default Explosion;
55 changes: 53 additions & 2 deletions client/src/components/ui/eventHighlightCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Play } from "lucide-react";
import Image from "next/image";
import { useState } from "react";

export type eventHighlightCardImage = {
url: string;
Expand All @@ -17,6 +18,13 @@ export type eventHighlightCardType = {
row: number;
};

export type sparkleIndexOverlay = {
card: eventHighlightCardType;
indexes: number[];
};

const sparkleImages = ["/sparkle_1.png", "/sparkle_2.png", "/sparkle_3.png"];

// Purple card header section.
const renderCardHeader = (card: eventHighlightCardType) => {
// Renders differently if we want the techno border.
Expand All @@ -41,14 +49,41 @@ const renderCardHeader = (card: eventHighlightCardType) => {
</div>
);
}

return (
<div className="rounded-md border border-accent bg-dark_alt px-4 py-2 font-jersey10 text-2xl font-semibold">
{card.title}
</div>
);
};

// only render sparkles on specific cards
const renderSparkleOverlay = (sparkle: sparkleIndexOverlay) => {
switch (sparkle.card.id) {
case 2:
return (
<Image
src={sparkleImages[sparkle.indexes[0]]}
width={15}
height={17}
alt="sparkle"
className="absolute bottom-0 right-0 h-10 w-10 [image-rendering:pixelated]"
/>
);
case 3:
return (
<Image
src={sparkleImages[sparkle.indexes[0]]}
width={15}
height={17}
alt="sparkle"
className="absolute bottom-0 left-0 h-10 w-10 [image-rendering:pixelated]"
/>
);
default:
return null;
}
};

export function EventHighlightCard({
id,
title,
Expand All @@ -57,11 +92,21 @@ export function EventHighlightCard({
image,
row,
}: eventHighlightCardType) {
const [indexes] = useState(() => {
const first = Math.floor(Math.random() * sparkleImages.length);
let second = Math.floor(Math.random() * sparkleImages.length);

if (second === first) {
second = (first + 1) % sparkleImages.length;
}
return [first, second];
});

return (
<div key={id} className="flex flex-col">
{renderCardHeader({ id, title, description, type, image, row })}

<div className="mt-4 rounded-md border border-muted bg-landingCard p-4 text-gray-200">
<div className="relative mt-4 rounded-md border border-muted bg-landingCard p-4 text-gray-200">
<div className="flex gap-2">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center">
<Play
Expand All @@ -81,7 +126,13 @@ export function EventHighlightCard({
/>
)}
</div>
{renderSparkleOverlay({
card: { id, title, description, type, image, row },
indexes,
})}
</div>
</div>
);
}

// render sparkle overlay function takes a card and index to then return a sparkle on the specified indexes
107 changes: 57 additions & 50 deletions client/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,24 @@ import {
EventHighlightCard,
eventHighlightCardType,
} from "@/components/ui/eventHighlightCard";
import Explosion from "@/components/ui/Explosion";
import LandingGames from "@/components/ui/landingGames";
import { useExplosionContext } from "@/contexts/ExplosionContext";
import { UiEvent, useEvents } from "@/hooks/useEvents";

export default function Landing() {
const [showExplosion, setShowExplosion] = useState(false);
const [isShaking, setIsShaking] = useState(false);
const { triggerExplosionAt } = useExplosionContext();

const handleBombClick = (e: React.MouseEvent) => {
// Trigger multiple explosions across the page
for (let i = 0; i < 10; i++) {
setTimeout(() => {
// Random position with 10% margin from edges
const x = window.innerWidth * (0.1 + Math.random() * 0.8);
const y = window.innerHeight * (0.1 + Math.random() * 0.8);
triggerExplosionAt(x, y);
}, i * 50); // Stagger by 50ms
}

const handleExplode = () => {
if (showExplosion) return;

setShowExplosion(true);
setTimeout(() => setShowExplosion(false), 700);
// Trigger screen shake
setIsShaking(true);
setTimeout(() => setIsShaking(false), 400);

// Prevent event bubbling
e.stopPropagation();
};

const { data, isPending, isError, isFetching } = useEvents({
type: "upcoming",
pageSize: 100,
Expand Down Expand Up @@ -132,48 +124,63 @@ export default function Landing() {
alt="placeholder"
className="retroBorder min-w-80"
/>
<Image
src="/bomb.png"
width={96}
height={156}
alt="Bomb - click to explode!"
className="absolute bottom-0 left-0 h-auto w-[20%] -translate-x-1/4 -translate-y-4 cursor-pointer transition-transform [image-rendering:pixelated] hover:scale-110"
onClick={handleBombClick}
/>
<div
className="absolute bottom-0 left-0 h-auto w-[20%] -translate-x-1/4 -translate-y-4 cursor-pointer"
onClick={handleExplode}
>
{showExplosion && (
<Explosion
colour1="#ef4444"
colour2="#f59e0b"
count={11}
yOffset={40}
/>
)}
<Image
src="/bomb.png"
width={96}
height={156}
alt="bomb"
className="h-auto w-full transition-transform [image-rendering:pixelated] active:scale-90"
/>
</div>
</div>
</div>
</section>

<section className="-mt-8 bg-dark_3 py-16 [clip-path:polygon(0%_0%,20%_0%,calc(20%+32px)_32px,100%_32px,100%_100%,0%_100%)] [overflow:clip]">
<div className="container mx-auto px-6 lg:px-12">
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
{eventCards
.filter((card) => card.row === 1)
.map((card) => (
<EventHighlightCard key={card.id} {...card} />
))}
<div className="relative">
<div className="grid grid-cols-1 gap-10 md:grid-cols-2">
{eventCards
.filter((card) => card.row === 1)
.map((card) => (
<EventHighlightCard key={card.id} {...card} />
))}
</div>
</div>

<div className="mt-10 grid grid-cols-1 gap-10 md:grid-cols-[23fr_27fr_11fr]">
{eventCards
.filter((card) => card.row === 2)
.map((card) => (
<EventHighlightCard key={card.id} {...card} />
))}

<div className="flex flex-row items-center justify-center gap-4 md:hidden lg:flex lg:flex-col lg:items-start">
{gameLogoImages.map((logo, index) => (
<Image
key={index}
src={logo.url}
width={135}
height={46}
alt={logo.alt}
className={`${index < gameLogoImages.length - 1 ? "lg:mb-5" : ""} ${
logo.position === "end" ? "lg:self-end" : ""
}`}
/>
))}
<div className="relative">
<div className="mt-10 grid grid-cols-1 gap-10 md:grid-cols-[23fr_27fr_11fr]">
{eventCards
.filter((card) => card.row === 2)
.map((card) => (
<EventHighlightCard key={card.id} {...card} />
))}
<div className="flex flex-row items-center justify-center gap-4 md:hidden lg:flex lg:flex-col lg:items-start">
{gameLogoImages.map((logo, index) => (
<Image
key={index}
src={logo.url}
width={135}
height={46}
alt={logo.alt}
className={`${index < gameLogoImages.length - 1 ? "lg:mb-5" : ""} ${
logo.position === "end" ? "lg:self-end" : ""
}`}
/>
))}
</div>
</div>
</div>
</div>
Expand Down
Loading