Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
23
README.md
23
README.md
@ -242,26 +242,3 @@ The **docker folder** contains a couple of helper scripts:
|
||||
Afterwards, continue to start your project in the backend directory by running:
|
||||
|
||||
`yarn start`
|
||||
admin commands tools for admin@flatlogic.com
|
||||
admin_pass for admin@flatlogic.com
|
||||
password for admin@flatlogic.com PASSWORD fac6a658
|
||||
Google
|
||||
import { useReportWebVitals } from 'next/web-vitals'
|
||||
|
||||
const handleWebVitals = (metric) => {
|
||||
switch (metric.name) {
|
||||
case 'FCP': {
|
||||
// handle FCP results
|
||||
}
|
||||
case 'LCP': {
|
||||
// handle LCP results
|
||||
}
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
useReportWebVitals(handleWebVitals)
|
||||
|
||||
return <Component {...pageProps} />
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB |
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -7,13 +7,6 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
href: '/play',
|
||||
label: 'Play Game',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiGamepadVariant' in icon ? icon['mdiGamepadVariant' as keyof typeof icon] : icon.mdiTable,
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
|
||||
@ -1,166 +1,166 @@
|
||||
import React from 'react';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
|
||||
const colorBursts = [
|
||||
'bg-[#35F2C1]',
|
||||
'bg-[#FF4FD8]',
|
||||
'bg-[#FFD166]',
|
||||
'bg-[#7C5CFF]',
|
||||
'bg-[#38BDF8]',
|
||||
'bg-[#FF7A59]',
|
||||
];
|
||||
|
||||
const arenaStats = [
|
||||
['49', 'color blocks'],
|
||||
['4', 'rivals'],
|
||||
['45s', 'rounds'],
|
||||
];
|
||||
|
||||
const floatingBlocks = [
|
||||
'left-[6%] top-[18%] h-14 w-14 rotate-12 bg-[#FF4FD8] shadow-[#B8228D]',
|
||||
'right-[8%] top-[17%] h-16 w-16 -rotate-12 bg-[#FFD166] shadow-[#B88711]',
|
||||
'left-[12%] bottom-[14%] h-12 w-12 -rotate-6 bg-[#38BDF8] shadow-[#117DAA]',
|
||||
'right-[18%] bottom-[18%] h-10 w-10 rotate-6 bg-[#7C5CFF] shadow-[#4931B8]',
|
||||
];
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('left');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'Steal a Color Game'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Steal a Color')}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A fast, colorful browser territory game with a blocky color-stealing arena."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<main className="min-h-screen overflow-hidden bg-[#8DE7FF] text-[#172033]">
|
||||
<section className="relative isolate flex min-h-screen flex-col px-5 py-6 sm:px-8 lg:px-12">
|
||||
<div className="absolute inset-0 -z-20 bg-[linear-gradient(180deg,_#75D9FF_0%,_#C9F6FF_50%,_#9BE36F_50%,_#43B02A_100%)]" />
|
||||
<div className="absolute inset-x-0 bottom-0 -z-10 h-[42%] bg-[linear-gradient(90deg,_rgba(255,255,255,0.16)_1px,_transparent_1px),linear-gradient(0deg,_rgba(255,255,255,0.16)_1px,_transparent_1px)] bg-[size:54px_54px] opacity-70" />
|
||||
<div className="absolute left-[8%] top-[12%] -z-10 h-16 w-48 rounded-full bg-white/70 blur-sm" />
|
||||
<div className="absolute right-[14%] top-[9%] -z-10 h-20 w-64 rounded-full bg-white/65 blur-sm" />
|
||||
<div className="absolute left-[42%] top-[18%] -z-10 h-12 w-36 rounded-full bg-white/55 blur-sm" />
|
||||
|
||||
{floatingBlocks.map((block) => (
|
||||
<span
|
||||
key={block}
|
||||
className={`absolute -z-10 hidden rounded-xl border-4 border-white/45 shadow-[0_10px_0_rgba(0,0,0,0.22),0_18px_30px_rgba(0,0,0,0.2)] md:block ${block}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<header className="mx-auto flex w-full max-w-7xl items-center justify-between rounded-3xl border-4 border-white bg-white/90 px-4 py-3 shadow-[0_10px_0_#B6D7EF,0_18px_35px_rgba(25,60,100,0.22)] backdrop-blur">
|
||||
<Link href="/" className="flex items-center gap-3 rounded-2xl focus:outline-none focus:ring-4 focus:ring-[#FFD166]">
|
||||
<span className="grid h-12 w-12 grid-cols-2 overflow-hidden rounded-xl border-4 border-[#172033] shadow-[0_6px_0_#7E8795]">
|
||||
{colorBursts.slice(0, 4).map((color) => (
|
||||
<span key={color} className={color} />
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-xs font-black uppercase tracking-[0.28em] text-[#627084]">Roblox-like</span>
|
||||
<span className="block text-xl font-black leading-none text-[#172033]">Steal a Color</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-2 text-sm font-black">
|
||||
<Link href="/play" className="hidden rounded-2xl border-2 border-[#172033] bg-[#FFD166] px-4 py-2 text-[#172033] shadow-[0_5px_0_#B88711] transition hover:-translate-y-0.5 hover:shadow-[0_7px_0_#B88711] sm:inline-flex">
|
||||
Play
|
||||
</Link>
|
||||
<Link href="/login" className="rounded-2xl border-2 border-[#172033] bg-white px-4 py-2 text-[#172033] shadow-[0_5px_0_#9AA7B8] transition hover:-translate-y-0.5 hover:shadow-[0_7px_0_#9AA7B8]">
|
||||
Admin Login
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto grid w-full max-w-7xl flex-1 items-center gap-12 py-12 lg:grid-cols-[1fr_1fr] lg:py-16">
|
||||
<div className="max-w-3xl">
|
||||
<div className="mb-6 inline-flex items-center gap-3 rounded-2xl border-4 border-[#172033] bg-white px-4 py-3 text-sm font-black uppercase tracking-[0.18em] text-[#172033] shadow-[0_8px_0_#9AA7B8]">
|
||||
<span className="h-4 w-4 rounded bg-[#35F2C1] shadow-[0_4px_0_#159875]" />
|
||||
Color stealing block arena
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
|
||||
<h1 className="max-w-3xl text-5xl font-black leading-[0.95] tracking-tight text-[#172033] drop-shadow-[0_5px_0_rgba(255,255,255,0.9)] sm:text-6xl lg:text-7xl">
|
||||
Jump in, slap a color block, and steal the whole map.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl rounded-3xl border-4 border-white bg-white/85 p-5 text-lg font-bold leading-8 text-[#42526B] shadow-[0_10px_0_#B6D7EF] sm:text-xl">
|
||||
A chunkier, Roblox-inspired look for quick territory rounds: toy-bright tiles, simple block avatars, chunky buttons, and an arena that feels like a colorful playground.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-4 sm:flex-row">
|
||||
<Link href="/play" className="inline-flex items-center justify-center rounded-2xl border-4 border-[#172033] bg-[#35F2C1] px-7 py-4 text-base font-black uppercase tracking-[0.08em] text-[#07111F] shadow-[0_9px_0_#159875,0_18px_26px_rgba(21,152,117,0.26)] transition hover:-translate-y-1 hover:shadow-[0_12px_0_#159875,0_22px_32px_rgba(21,152,117,0.26)] focus:outline-none focus:ring-4 focus:ring-[#FFD166]">
|
||||
Start a Match
|
||||
</Link>
|
||||
<Link href="/login" className="inline-flex items-center justify-center rounded-2xl border-4 border-[#172033] bg-[#FF4FD8] px-7 py-4 text-base font-black uppercase tracking-[0.08em] text-white shadow-[0_9px_0_#B8228D,0_18px_26px_rgba(184,34,141,0.24)] transition hover:-translate-y-1 hover:shadow-[0_12px_0_#B8228D,0_22px_32px_rgba(184,34,141,0.24)] focus:outline-none focus:ring-4 focus:ring-[#FFD166]">
|
||||
Open Admin
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<dl className="mt-10 grid max-w-2xl grid-cols-3 gap-3">
|
||||
{arenaStats.map(([value, label]) => (
|
||||
<div key={label} className="rounded-2xl border-4 border-[#172033] bg-white p-4 text-center shadow-[0_8px_0_#9AA7B8]">
|
||||
<dt className="text-3xl font-black text-[#172033] sm:text-4xl">{value}</dt>
|
||||
<dd className="mt-1 text-[0.65rem] font-black uppercase tracking-[0.2em] text-[#627084] sm:text-xs">{label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-xl">
|
||||
<div className="absolute -bottom-8 left-1/2 h-20 w-[92%] -translate-x-1/2 rounded-[50%] bg-[#1E7C22]/35 blur-xl" />
|
||||
<div className="relative rounded-[2rem] border-4 border-[#172033] bg-[#7BD957] p-5 shadow-[0_16px_0_#2F8A24,0_30px_40px_rgba(20,76,32,0.28)]">
|
||||
<div className="mb-4 flex items-center justify-between rounded-2xl border-4 border-white bg-white/90 px-4 py-3 shadow-[0_7px_0_#B6D7EF]">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.28em] text-[#627084]">Live board</p>
|
||||
<p className="text-2xl font-black text-[#172033]">Preview Arena</p>
|
||||
</div>
|
||||
<span className="rounded-xl border-2 border-[#172033] bg-[#FF4FD8] px-3 py-2 text-sm font-black text-white shadow-[0_5px_0_#B8228D]">00:45</span>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-[1.5rem] border-4 border-[#172033] bg-[#244A2B] p-3 shadow-[inset_0_8px_0_rgba(255,255,255,0.16)]">
|
||||
<div className="pointer-events-none absolute -top-8 left-8 flex items-end gap-2">
|
||||
<span className="block h-7 w-7 rounded-lg border-2 border-[#172033] bg-[#FFD1A3] shadow-[0_4px_0_#B6783C]" />
|
||||
<span className="block h-10 w-9 rounded-lg border-2 border-[#172033] bg-[#35F2C1] shadow-[0_6px_0_#159875]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{Array.from({ length: 49 }).map((_, index) => {
|
||||
const color = colorBursts[(index * 5 + Math.floor(index / 7)) % colorBursts.length];
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`${color} aspect-square rounded-xl border-2 border-white/45 shadow-[inset_0_5px_0_rgba(255,255,255,0.45),inset_0_-6px_0_rgba(0,0,0,0.18),0_7px_0_rgba(0,0,0,0.22)] transition duration-300 hover:-translate-y-1`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Steal a Color Game app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center '>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{[
|
||||
['You', '31%', '#35F2C1', '#159875'],
|
||||
['Pixel', '26%', '#FF4FD8', '#B8228D'],
|
||||
['Volt', '23%', '#FFD166', '#B88711'],
|
||||
['Nova', '20%', '#7C5CFF', '#4931B8'],
|
||||
].map(([name, score, color, shade]) => (
|
||||
<div key={name} className="rounded-2xl border-4 border-[#172033] bg-white p-3 shadow-[0_7px_0_#9AA7B8]">
|
||||
<div className="mb-2 h-5 rounded-lg border-2 border-[#172033]" style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade}` }} />
|
||||
<p className="text-sm font-black text-[#172033]">{name}</p>
|
||||
<p className="text-xs font-bold text-[#627084]">{score} control</p>
|
||||
</div>
|
||||
))}
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
@ -1,471 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
type Owner = 0 | 1 | 2 | 3;
|
||||
type Phase = 'setup' | 'playing' | 'finished';
|
||||
|
||||
type Player = {
|
||||
id: Owner;
|
||||
name: string;
|
||||
color: string;
|
||||
textColor: string;
|
||||
glow: string;
|
||||
shade: string;
|
||||
};
|
||||
|
||||
type MatchRecord = {
|
||||
id: string;
|
||||
playerName: string;
|
||||
score: number;
|
||||
winner: string;
|
||||
won: boolean;
|
||||
captured: number;
|
||||
createdAt: string;
|
||||
board: Owner[];
|
||||
};
|
||||
|
||||
const boardSize = 49;
|
||||
const matchSeconds = 45;
|
||||
const historyKey = 'steal-a-color-history';
|
||||
|
||||
const players: Player[] = [
|
||||
{ id: 0, name: 'You', color: '#35F2C1', textColor: 'text-[#087560]', glow: 'shadow-[#159875]/35', shade: '#159875' },
|
||||
{ id: 1, name: 'Pixel', color: '#FF4FD8', textColor: 'text-[#B8228D]', glow: 'shadow-[#B8228D]/30', shade: '#B8228D' },
|
||||
{ id: 2, name: 'Volt', color: '#FFD166', textColor: 'text-[#9B6A00]', glow: 'shadow-[#B88711]/30', shade: '#B88711' },
|
||||
{ id: 3, name: 'Nova', color: '#7C5CFF', textColor: 'text-[#4931B8]', glow: 'shadow-[#4931B8]/30', shade: '#4931B8' },
|
||||
];
|
||||
|
||||
const createBoard = () =>
|
||||
Array.from({ length: boardSize }, (_, index) => {
|
||||
if (index === 0 || index === 1 || index === 7) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (index === 5 || index === 6 || index === 13) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (index === 35 || index === 42 || index === 43) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (index === 41 || index === 47 || index === 48) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return ((index + Math.floor(index / 7)) % 4) as Owner;
|
||||
});
|
||||
|
||||
const getCounts = (board: Owner[]) =>
|
||||
players.map((player) => ({
|
||||
...player,
|
||||
tiles: board.filter((owner) => owner === player.id).length,
|
||||
}));
|
||||
|
||||
const loadHistory = (): MatchRecord[] => {
|
||||
if (typeof window === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(historyKey);
|
||||
if (!stored) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load Steal a Color match history', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const saveHistory = (records: MatchRecord[]) => {
|
||||
try {
|
||||
window.localStorage.setItem(historyKey, JSON.stringify(records));
|
||||
} catch (error) {
|
||||
console.error('Failed to save Steal a Color match history', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (value: string) =>
|
||||
new Intl.DateTimeFormat('en', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
|
||||
export default function PlayStealAColor() {
|
||||
const [phase, setPhase] = useState<Phase>('setup');
|
||||
const [playerName, setPlayerName] = useState('Player One');
|
||||
const [board, setBoard] = useState<Owner[]>(createBoard);
|
||||
const [secondsLeft, setSecondsLeft] = useState(matchSeconds);
|
||||
const [captured, setCaptured] = useState(0);
|
||||
const [history, setHistory] = useState<MatchRecord[]>([]);
|
||||
const [selectedMatchId, setSelectedMatchId] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState('Name your blocky avatar, start a match, then smack rival color blocks to steal them.');
|
||||
|
||||
useEffect(() => {
|
||||
const records = loadHistory();
|
||||
setHistory(records);
|
||||
setSelectedMatchId(records[0]?.id ?? null);
|
||||
}, []);
|
||||
|
||||
const counts = useMemo(() => getCounts(board), [board]);
|
||||
const leader = useMemo(() => [...counts].sort((a, b) => b.tiles - a.tiles)[0], [counts]);
|
||||
const playerScore = counts[0]?.tiles ?? 0;
|
||||
const selectedMatch = history.find((record) => record.id === selectedMatchId) ?? history[0];
|
||||
|
||||
const finishMatch = useCallback(
|
||||
(finalBoard: Owner[]) => {
|
||||
const finalCounts = getCounts(finalBoard);
|
||||
const winner = [...finalCounts].sort((a, b) => b.tiles - a.tiles)[0];
|
||||
const record: MatchRecord = {
|
||||
id: `${Date.now()}`,
|
||||
playerName: playerName.trim() || 'Player One',
|
||||
score: finalCounts[0].tiles,
|
||||
winner: winner.name,
|
||||
won: winner.id === 0,
|
||||
captured,
|
||||
createdAt: new Date().toISOString(),
|
||||
board: finalBoard,
|
||||
};
|
||||
const nextHistory = [record, ...history].slice(0, 6);
|
||||
setPhase('finished');
|
||||
setMessage(winner.id === 0 ? 'Victory! Your color owns the arena.' : `${winner.name} stole the crown. Hit rematch and take it back.`);
|
||||
setHistory(nextHistory);
|
||||
setSelectedMatchId(record.id);
|
||||
saveHistory(nextHistory);
|
||||
},
|
||||
[captured, history, playerName]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'playing') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (secondsLeft <= 0) {
|
||||
finishMatch(board);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => setSecondsLeft((current) => current - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [board, finishMatch, phase, secondsLeft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'playing') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const aiTimer = window.setInterval(() => {
|
||||
setBoard((currentBoard) => {
|
||||
const nextBoard = [...currentBoard];
|
||||
const ai = (1 + Math.floor(Math.random() * 3)) as Owner;
|
||||
const candidates = nextBoard
|
||||
.map((owner, index) => ({ owner, index }))
|
||||
.filter((tile) => tile.owner !== ai);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return currentBoard;
|
||||
}
|
||||
|
||||
const target = candidates[Math.floor(Math.random() * candidates.length)];
|
||||
nextBoard[target.index] = ai;
|
||||
return nextBoard;
|
||||
});
|
||||
}, 1050);
|
||||
|
||||
return () => window.clearInterval(aiTimer);
|
||||
}, [phase]);
|
||||
|
||||
const startMatch = () => {
|
||||
const safeName = playerName.trim();
|
||||
if (!safeName) {
|
||||
setMessage('Please add a player name before starting.');
|
||||
return;
|
||||
}
|
||||
|
||||
setBoard(createBoard());
|
||||
setSecondsLeft(matchSeconds);
|
||||
setCaptured(0);
|
||||
setPhase('playing');
|
||||
setMessage(`Go ${safeName}! Tap any rival block to flip it mint.`);
|
||||
};
|
||||
|
||||
const stealTile = (index: number) => {
|
||||
if (phase !== 'playing') {
|
||||
setMessage('Press Start first to unlock the arena.');
|
||||
return;
|
||||
}
|
||||
|
||||
setBoard((currentBoard) => {
|
||||
if (currentBoard[index] === 0) {
|
||||
setMessage('That block already belongs to you. Find a rival color.');
|
||||
return currentBoard;
|
||||
}
|
||||
|
||||
const nextBoard = [...currentBoard];
|
||||
nextBoard[index] = 0;
|
||||
setCaptured((current) => current + 1);
|
||||
setMessage('Steal confirmed! Keep the mint wave rolling.');
|
||||
return nextBoard;
|
||||
});
|
||||
};
|
||||
|
||||
const clearHistory = () => {
|
||||
setHistory([]);
|
||||
setSelectedMatchId(null);
|
||||
saveHistory([]);
|
||||
setMessage('Local match history cleared. The arena is reset.');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Play Steal a Color')}</title>
|
||||
</Head>
|
||||
|
||||
<main className="min-h-screen overflow-hidden bg-[#8DE7FF] text-[#172033]">
|
||||
<section className="relative px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div className="absolute inset-0 -z-20 bg-[linear-gradient(180deg,_#73D9FF_0%,_#D8FAFF_48%,_#95DE69_48%,_#42AF2A_100%)]" />
|
||||
<div className="absolute inset-x-0 bottom-0 -z-10 h-[50%] bg-[linear-gradient(90deg,_rgba(255,255,255,0.18)_1px,_transparent_1px),linear-gradient(0deg,_rgba(255,255,255,0.18)_1px,_transparent_1px)] bg-[size:50px_50px] opacity-75" />
|
||||
<div className="absolute left-[5%] top-16 -z-10 h-14 w-48 rounded-full bg-white/70 blur-sm" />
|
||||
<div className="absolute right-[9%] top-24 -z-10 h-16 w-60 rounded-full bg-white/65 blur-sm" />
|
||||
<div className="relative mx-auto max-w-7xl">
|
||||
<header className="mb-6 flex flex-col gap-3 rounded-3xl border-4 border-white bg-white/90 p-3 shadow-[0_10px_0_#B6D7EF,0_18px_35px_rgba(25,60,100,0.22)] backdrop-blur md:flex-row md:items-center md:justify-between">
|
||||
<Link href="/" className="flex items-center gap-3 rounded-2xl px-2 py-1 focus:outline-none focus:ring-4 focus:ring-[#FFD166]">
|
||||
<span className="grid h-12 w-12 grid-cols-2 overflow-hidden rounded-xl border-4 border-[#172033] shadow-[0_6px_0_#7E8795]">
|
||||
{players.map((player) => (
|
||||
<span key={player.id} style={{ backgroundColor: player.color }} />
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-xs font-black uppercase tracking-[0.25em] text-[#627084]">Steal a Color</span>
|
||||
<span className="text-2xl font-black text-[#172033]">Block Arena</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex flex-wrap gap-2 text-sm font-black">
|
||||
<Link href="/" className="rounded-2xl border-2 border-[#172033] bg-[#FFD166] px-4 py-2 text-[#172033] shadow-[0_5px_0_#B88711] transition hover:-translate-y-0.5">
|
||||
Landing
|
||||
</Link>
|
||||
<Link href="/login" className="rounded-2xl border-2 border-[#172033] bg-white px-4 py-2 text-[#172033] shadow-[0_5px_0_#9AA7B8] transition hover:-translate-y-0.5">
|
||||
Admin Login
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[21rem_1fr_22rem]">
|
||||
<aside className="space-y-4">
|
||||
<section className="rounded-3xl border-4 border-[#172033] bg-white p-5 shadow-[0_12px_0_#9AA7B8,0_22px_30px_rgba(23,32,51,0.18)]">
|
||||
<p className="inline-flex rounded-xl border-2 border-[#172033] bg-[#FF4FD8] px-3 py-2 text-xs font-black uppercase tracking-[0.2em] text-white shadow-[0_5px_0_#B8228D]">Match setup</p>
|
||||
<h1 className="mt-5 text-3xl font-black leading-tight tracking-tight text-[#172033]">Claim the bright blocks before the rivals steal them.</h1>
|
||||
|
||||
<label className="mt-5 block text-sm font-black uppercase tracking-[0.18em] text-[#627084]" htmlFor="playerName">
|
||||
Player name
|
||||
</label>
|
||||
<input
|
||||
id="playerName"
|
||||
value={playerName}
|
||||
maxLength={24}
|
||||
disabled={phase === 'playing'}
|
||||
onChange={(event) => setPlayerName(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border-4 border-[#172033] bg-[#F7FBFF] px-4 py-3 font-black text-[#172033] outline-none shadow-[inset_0_5px_0_rgba(255,255,255,0.85),0_6px_0_#9AA7B8] transition placeholder:text-[#8B9AB0] focus:ring-4 focus:ring-[#FFD166] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="Your nickname"
|
||||
/>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startMatch}
|
||||
className="rounded-2xl border-4 border-[#172033] bg-[#35F2C1] px-4 py-3 text-sm font-black uppercase tracking-[0.08em] text-[#07111F] shadow-[0_8px_0_#159875] transition hover:-translate-y-0.5 hover:shadow-[0_10px_0_#159875] focus:outline-none focus:ring-4 focus:ring-[#FFD166]"
|
||||
>
|
||||
{phase === 'finished' ? 'Rematch' : 'Start'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => finishMatch(board)}
|
||||
disabled={phase !== 'playing'}
|
||||
className="rounded-2xl border-4 border-[#172033] bg-[#FF4FD8] px-4 py-3 text-sm font-black uppercase tracking-[0.08em] text-white shadow-[0_8px_0_#B8228D] transition hover:-translate-y-0.5 hover:shadow-[0_10px_0_#B8228D] focus:outline-none focus:ring-4 focus:ring-[#FFD166] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Finish
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-2xl border-4 border-[#159875] bg-[#D9FFF6] p-4 text-sm font-bold leading-6 text-[#087560] shadow-[0_7px_0_#A5DCD0]">
|
||||
{message}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border-4 border-[#172033] bg-[#FFD166] p-5 shadow-[0_10px_0_#B88711]">
|
||||
<p className="text-xs font-black uppercase tracking-[0.25em] text-[#775000]">How to steal</p>
|
||||
<ol className="mt-3 space-y-3 text-sm font-black leading-6 text-[#172033]">
|
||||
<li><strong>1.</strong> You are mint. Tap a rival block to flip it.</li>
|
||||
<li><strong>2.</strong> AI rivals steal back every second.</li>
|
||||
<li><strong>3.</strong> Biggest color count after 45 seconds wins.</li>
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section className="rounded-[2rem] border-4 border-[#172033] bg-[#7BD957] p-3 shadow-[0_16px_0_#2F8A24,0_28px_40px_rgba(20,76,32,0.28)] sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 rounded-3xl border-4 border-white bg-white/90 p-4 shadow-[0_8px_0_#B6D7EF] sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.28em] text-[#627084]">Arena</p>
|
||||
<h2 className="text-3xl font-black text-[#172033]">Tap-to-steal blocks</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl border-4 border-[#172033] bg-[#FF4FD8] px-4 py-2 text-center font-black text-white shadow-[0_7px_0_#B8228D]">
|
||||
<span className="block text-xs uppercase tracking-[0.2em] text-white/80">Time</span>
|
||||
<span className="text-3xl">0:{String(secondsLeft).padStart(2, '0')}</span>
|
||||
</div>
|
||||
<div className="rounded-2xl border-4 border-[#172033] bg-white px-4 py-2 text-center font-black text-[#172033] shadow-[0_7px_0_#9AA7B8]">
|
||||
<span className="block text-xs uppercase tracking-[0.2em] text-[#627084]">Your blocks</span>
|
||||
<span className="text-3xl">{playerScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{counts.map((player) => (
|
||||
<div key={player.id} className="rounded-2xl border-4 border-[#172033] bg-white p-2 text-center shadow-[0_7px_0_#9AA7B8]">
|
||||
<span className="mx-auto block h-8 w-8 rounded-lg border-2 border-[#172033]" style={{ backgroundColor: player.color, boxShadow: `0 5px 0 ${player.shade}` }} />
|
||||
<p className="mt-2 truncate text-xs font-black text-[#172033]">{player.name === 'You' ? playerName || 'You' : player.name}</p>
|
||||
<p className="text-[0.65rem] font-black uppercase tracking-[0.16em] text-[#627084]">{player.tiles}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-2 rounded-[1.5rem] border-4 border-[#172033] bg-[#244A2B] p-2 shadow-[inset_0_8px_0_rgba(255,255,255,0.16)] sm:gap-3 sm:p-4">
|
||||
{board.map((owner, index) => {
|
||||
const tileOwner = players[owner];
|
||||
return (
|
||||
<button
|
||||
key={`${index}-${owner}`}
|
||||
type="button"
|
||||
aria-label={`Tile ${index + 1}, owned by ${tileOwner.name}`}
|
||||
onClick={() => stealTile(index)}
|
||||
className={`relative aspect-square overflow-hidden rounded-xl border-2 border-white/50 shadow-lg ${tileOwner.glow} transition duration-150 hover:-translate-y-1 hover:scale-[1.02] focus:outline-none focus:ring-4 focus:ring-white ${owner === 0 ? 'ring-2 ring-[#087560]' : ''} ${phase === 'playing' ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
style={{
|
||||
backgroundColor: tileOwner.color,
|
||||
boxShadow: `inset 0 6px 0 rgba(255,255,255,0.42), inset 0 -8px 0 rgba(0,0,0,0.2), 0 8px 0 ${tileOwner.shade}, 0 14px 18px rgba(0,0,0,0.22)`,
|
||||
}}
|
||||
>
|
||||
<span className="absolute left-2 top-2 h-2 w-1/2 rounded-full bg-white/45" />
|
||||
<span className="sr-only">{tileOwner.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{phase === 'finished' ? (
|
||||
<div className="mt-5 rounded-3xl border-4 border-[#172033] bg-white p-5 text-[#172033] shadow-[0_10px_0_#9AA7B8]">
|
||||
<p className="text-xs font-black uppercase tracking-[0.25em] text-[#627084]">Results</p>
|
||||
<h3 className="mt-2 text-3xl font-black">
|
||||
{leader.id === 0 ? 'You won the color crown!' : `${leader.name} wins this round.`}
|
||||
</h3>
|
||||
<p className="mt-2 font-bold text-[#42526B]">
|
||||
{playerName.trim() || 'Player One'} finished with <strong>{playerScore} blocks</strong> and made <strong>{captured} manual steals</strong>.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<section className="rounded-3xl border-4 border-[#172033] bg-white p-5 shadow-[0_12px_0_#9AA7B8,0_22px_30px_rgba(23,32,51,0.16)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.25em] text-[#627084]">Live leaderboard</p>
|
||||
<h2 className="text-2xl font-black text-[#172033]">{leader.name} leads</h2>
|
||||
</div>
|
||||
<span className="rounded-xl border-2 border-[#172033] px-3 py-2 text-xs font-black text-[#07111F] shadow-[0_5px_0_rgba(0,0,0,0.22)]" style={{ backgroundColor: leader.color }}>
|
||||
{Math.round((leader.tiles / boardSize) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
{counts.map((player) => {
|
||||
const width = `${Math.max(8, Math.round((player.tiles / boardSize) * 100))}%`;
|
||||
return (
|
||||
<div key={player.id}>
|
||||
<div className="mb-1 flex items-center justify-between text-sm font-black">
|
||||
<span className={player.textColor}>{player.name === 'You' ? playerName || 'You' : player.name}</span>
|
||||
<span className="text-[#172033]">{player.tiles} blocks</span>
|
||||
</div>
|
||||
<div className="h-5 overflow-hidden rounded-xl border-2 border-[#172033] bg-[#E8F2FF] shadow-[inset_0_3px_0_rgba(255,255,255,0.9)]">
|
||||
<div className="h-full rounded-lg border-r-2 border-[#172033]/30" style={{ width, backgroundColor: player.color, boxShadow: `inset 0 4px 0 rgba(255,255,255,0.35), inset 0 -4px 0 ${player.shade}` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border-4 border-[#172033] bg-[#DDFBFF] p-5 shadow-[0_10px_0_#77BFD0]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.25em] text-[#3E7582]">Recent rounds</p>
|
||||
<h2 className="text-2xl font-black text-[#172033]">Local history</h2>
|
||||
</div>
|
||||
<button type="button" onClick={clearHistory} className="rounded-xl border-2 border-[#172033] bg-white px-3 py-1 text-xs font-black text-[#172033] shadow-[0_4px_0_#9AA7B8] transition hover:-translate-y-0.5">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<div className="mt-4 rounded-2xl border-4 border-dashed border-[#3E7582] bg-white/65 p-4 text-sm font-bold leading-6 text-[#42526B]">
|
||||
No completed rounds yet. Finish a match to save your first color steal result here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
{history.map((record) => (
|
||||
<button
|
||||
type="button"
|
||||
key={record.id}
|
||||
onClick={() => setSelectedMatchId(record.id)}
|
||||
className={`w-full rounded-2xl border-4 p-3 text-left font-bold transition hover:-translate-y-0.5 ${selectedMatch?.id === record.id ? 'border-[#159875] bg-[#D9FFF6] shadow-[0_7px_0_#A5DCD0]' : 'border-[#172033] bg-white shadow-[0_7px_0_#9AA7B8]'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-black text-[#172033]">{record.won ? 'Win' : 'Loss'} • {record.score} blocks</span>
|
||||
<span className="text-xs text-[#627084]">{formatDate(record.createdAt)}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[#627084]">Winner: {record.winner} • Player: {record.playerName}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{selectedMatch ? (
|
||||
<section className="rounded-3xl border-4 border-[#172033] bg-white p-5 shadow-[0_10px_0_#9AA7B8]">
|
||||
<p className="text-xs font-black uppercase tracking-[0.25em] text-[#627084]">Match detail</p>
|
||||
<h2 className="mt-1 text-xl font-black text-[#172033]">{formatDate(selectedMatch.createdAt)}</h2>
|
||||
<div className="mt-4 grid grid-cols-7 gap-1 rounded-2xl border-4 border-[#172033] bg-[#244A2B] p-2">
|
||||
{selectedMatch.board.map((owner, index) => (
|
||||
<span key={`${selectedMatch.id}-${index}`} className="aspect-square rounded-md border border-white/40" style={{ backgroundColor: players[owner].color }} />
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-bold leading-6 text-[#42526B]">
|
||||
{selectedMatch.playerName} scored <strong className="text-[#172033]">{selectedMatch.score}</strong> blocks. Winner: <strong className="text-[#172033]">{selectedMatch.winner}</strong>.
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PlayStealAColor.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user