forest: map with trees
This commit is contained in:
@@ -5,6 +5,12 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>forest</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+26
@@ -10,6 +10,8 @@
|
||||
"dependencies": {
|
||||
"@incgame/engine": "file:../engine",
|
||||
"break_eternity.js": "^2.1.3",
|
||||
"mobx": "^7.0.3",
|
||||
"mobx-react-lite": "^5.0.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
@@ -1010,6 +1012,30 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/mobx": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/mobx/-/mobx-7.0.3.tgz",
|
||||
"integrity": "sha512-KopyyzqZ8xT1fqxFNl8ItKKMpGifRe2ySaNAHs80/cx0mL1bpJtJ46S+FSyzXKRjW0TODWj5zwq/Y9hUdJUy4w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mobx"
|
||||
}
|
||||
},
|
||||
"node_modules/mobx-react-lite": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-5.0.3.tgz",
|
||||
"integrity": "sha512-8qT/d/8Cg8ljt+heKhoFHNoqgedyeSGzdTixT0lgbFMMFlWfHUcXHZ8UD63eQi3ELjkxaH1VU07lXWhRwM0cVA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mobx"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mobx": "^7.0.0",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"dependencies": {
|
||||
"@incgame/engine": "file:../engine",
|
||||
"break_eternity.js": "^2.1.3",
|
||||
"mobx": "^7.0.3",
|
||||
"mobx-react-lite": "^5.0.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
|
||||
+111
-165
@@ -1,184 +1,130 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 10px 16px;
|
||||
background: var(--panel-bg);
|
||||
border-bottom: 4px solid var(--panel-border);
|
||||
box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar__title {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
text-shadow: 2px 2px 0 var(--panel-border);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
.topbar .spacer {
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
.inventory-slot {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 3px solid var(--panel-border);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
.game-map {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
background-color: #4caf50;
|
||||
background-image:
|
||||
linear-gradient(45deg, #46a047 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #46a047 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #46a047 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #46a047 75%);
|
||||
background-size: 32px 32px;
|
||||
background-position: 0 0, 0 16px, 16px -16px, -16px 0px;
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
.entity {
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
.entity.log-shadow {
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
margin: 8px 0px 0px 0px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
|
||||
.entity.tree-shadow {
|
||||
width: 64px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
margin: 26px 0px 0px -11px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.character-shadow {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin: 32px 0px 0px 0px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.entity.tree {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
margin: -12px;
|
||||
}
|
||||
|
||||
.entity.tree.tree--chopping svg {
|
||||
animation: chop-shake 0.12s infinite;
|
||||
}
|
||||
|
||||
.entity.character {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.entity.character.character--walking svg {
|
||||
animation: walk-bob 0.3s infinite steps(2);
|
||||
}
|
||||
|
||||
@keyframes chop-shake {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translate(-2px, 1px) rotate(-6deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(1px, -2px) rotate(5deg);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-1px, 1px) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes walk-bob {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-118
@@ -1,125 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import { deserializeGame, type Game } from '@incgame/engine'
|
||||
import heroImg from './assets/hero.png'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import './App.css'
|
||||
import { useState } from 'react';
|
||||
import GameMap from './components/GameMap';
|
||||
import Topbar from './components/Topbar';
|
||||
import { GameStore } from './game/GameStore';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [game] = useState<Game>(() => deserializeGame({ time: 0, nodes: [] }))
|
||||
const [time, setTime] = useState(() => game.time.toString())
|
||||
|
||||
function handleTick() {
|
||||
game.tick(0.1)
|
||||
setTime(game.time.toString())
|
||||
}
|
||||
const [store] = useState(() => new GameStore());
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
<div className="app">
|
||||
<Topbar store={store} />
|
||||
<GameMap store={store} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="counter" onClick={handleTick}>
|
||||
@incgame/engine time: {time}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { PointerEvent } from 'react';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import type { LogDrop, TreeState } from '../game/types';
|
||||
import { CHARACTER_SPRITE, LOG_SPRITE, TREE_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
interface Props {
|
||||
store: GameStore;
|
||||
}
|
||||
|
||||
// Each entity below is its own observer so mobx can subscribe it only to the fields it actually
|
||||
// reads. Otherwise, with everything read inside GameMap's single observer, the character moving
|
||||
// every animation frame would re-render the whole tree/log list too, not just itself.
|
||||
|
||||
const LogEntity = observer(function LogEntity({ log }: { log: LogDrop }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="entity log-shadow" style={{ left: log.x, top: log.y }} />
|
||||
<div className="entity log" style={{ left: log.x, top: log.y - log.z, zIndex: Math.floor(log.y - log.z) }}>
|
||||
<PixelSprite pixels={LOG_SPRITE} size={20} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TreeEntity = observer(function TreeEntity({
|
||||
tree,
|
||||
store,
|
||||
onPointerDown,
|
||||
onPointerUp,
|
||||
}: {
|
||||
tree: TreeState;
|
||||
store: GameStore;
|
||||
onPointerDown: (e: PointerEvent<HTMLDivElement>, id: string) => void;
|
||||
onPointerUp: (e: PointerEvent<HTMLDivElement>) => void;
|
||||
}) {
|
||||
const isChopping = tree.id === store.heldTreeId && store.isChopping;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="entity tree-shadow" style={{ left: tree.x, top: tree.y, zIndex: Math.floor(tree.y) - 1 }} />
|
||||
<div
|
||||
className={`entity tree ${isChopping ? 'tree--chopping' : ''}`}
|
||||
style={{ left: tree.x, top: tree.y, zIndex: Math.floor(tree.y) }}
|
||||
onPointerDown={(e) => onPointerDown(e, tree.id)}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<PixelSprite pixels={TREE_SPRITE} size={64} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CharacterEntity = observer(function CharacterEntity({ store }: { store: GameStore }) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="entity character-shadow"
|
||||
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) - 1 }}
|
||||
/>
|
||||
<div
|
||||
className={`entity character ${store.isMoving ? 'character--walking' : ''}`}
|
||||
style={{ left: store.character.x, top: store.character.y, zIndex: Math.floor(store.character.y) }}
|
||||
>
|
||||
<PixelSprite pixels={CHARACTER_SPRITE} size={48} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function GameMap({ store }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container)
|
||||
return;
|
||||
|
||||
const { width, height } = container.getBoundingClientRect();
|
||||
if (width > 0 && height > 0) {
|
||||
store.initialize(width, height);
|
||||
store.start();
|
||||
setReady(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
store.stop();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('blur', store.release);
|
||||
return () => window.removeEventListener('blur', store.release);
|
||||
}, [store]);
|
||||
|
||||
function handleTreePointerDown(e: PointerEvent<HTMLDivElement>, id: string) {
|
||||
if (e.button !== 0)
|
||||
return;
|
||||
// Stop this from also bubbling to the map's own pointer handlers below (which would
|
||||
// otherwise interpret the same press as "walk to this empty ground point").
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
store.holdTree(id);
|
||||
}
|
||||
|
||||
function handleTreePointerUp(e: PointerEvent<HTMLDivElement>) {
|
||||
e.stopPropagation();
|
||||
store.release();
|
||||
}
|
||||
|
||||
function handleMapPointerDown(e: PointerEvent<HTMLDivElement>) {
|
||||
if (e.button !== 0)
|
||||
return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
store.moveTo(e.clientX - rect.left, e.clientY - rect.top);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="game-map"
|
||||
ref={containerRef}
|
||||
onPointerDown={handleMapPointerDown}
|
||||
onPointerUp={store.release}
|
||||
onPointerCancel={store.release}
|
||||
>
|
||||
{ready && (
|
||||
<>
|
||||
{store.logDrops.map((log) => (
|
||||
<LogEntity key={log.id} log={log} />
|
||||
))}
|
||||
|
||||
{store.trees.map((tree) => (
|
||||
<TreeEntity
|
||||
key={tree.id}
|
||||
tree={tree}
|
||||
store={store}
|
||||
onPointerDown={handleTreePointerDown}
|
||||
onPointerUp={handleTreePointerUp}
|
||||
/>
|
||||
))}
|
||||
|
||||
<CharacterEntity store={store} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GameMap);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { memo } from 'react';
|
||||
import type { PixelGrid } from '../game/sprites';
|
||||
|
||||
interface Props {
|
||||
pixels: PixelGrid;
|
||||
size: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PixelSprite({ pixels, size, className }: Props) {
|
||||
const rows = pixels.length;
|
||||
const cols = pixels[0]?.length ?? 0;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={cols > 0 ? size * (rows / cols) : size}
|
||||
viewBox={`0 0 ${cols} ${rows}`}
|
||||
shapeRendering="crispEdges"
|
||||
>
|
||||
{pixels.flatMap((row, y) =>
|
||||
row.map((color, x) =>
|
||||
color ? <rect key={`${x}-${y}`} x={x} y={y} width={1} height={1} fill={color} /> : null,
|
||||
),
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// `pixels` is always one of the module-level sprite constants and `size` is a literal passed by
|
||||
// the caller, so both are reference/value-stable across re-renders — memoizing means an entity
|
||||
// re-rendering every frame for its position (left/top) doesn't also regenerate this sprite's
|
||||
// (potentially hundreds of) <rect> elements each time.
|
||||
export default memo(PixelSprite);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { GameStore } from '../game/GameStore';
|
||||
import { COIN_SPRITE, LOG_SPRITE } from '../game/sprites';
|
||||
import PixelSprite from './PixelSprite';
|
||||
|
||||
interface Props {
|
||||
store: GameStore;
|
||||
}
|
||||
|
||||
function Topbar({ store }: Props) {
|
||||
return (
|
||||
<div className="topbar">
|
||||
<h1 className="topbar__title">Forest</h1>
|
||||
<div className="spacer" />
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={LOG_SPRITE} size={28} />
|
||||
<span>{store.inventory.logs}</span>
|
||||
</div>
|
||||
<div className="inventory-slot">
|
||||
<PixelSprite pixels={COIN_SPRITE} size={24} />
|
||||
<span>{store.inventory.coins}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Topbar);
|
||||
@@ -0,0 +1,317 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import type { Inventory, LogDrop, TreeState, Vec2 } from './types';
|
||||
import {
|
||||
CHARACTER_SPEED,
|
||||
CHOP_INTERVAL,
|
||||
COIN_INTERVAL,
|
||||
INTERACTION_RADIUS,
|
||||
LOG_COLLECT_ARC_HEIGHT,
|
||||
LOG_COLLECT_DURATION,
|
||||
LOG_EJECT_RADIUS,
|
||||
LOG_FALL_ARC_HEIGHT,
|
||||
LOG_FALL_DURATION,
|
||||
LOGS_PER_TREE,
|
||||
PICKUP_RADIUS,
|
||||
TREE_COUNT,
|
||||
TREE_EDGE_MARGIN,
|
||||
TREE_MIN_DISTANCE_BETWEEN,
|
||||
TREE_MIN_DISTANCE_FROM_CENTER,
|
||||
} from './constants';
|
||||
|
||||
function randomId(prefix: string): string {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export class GameStore {
|
||||
character: Vec2 = { x: 0, y: 0 };
|
||||
trees: TreeState[] = [];
|
||||
logDrops: LogDrop[] = [];
|
||||
inventory: Inventory = { logs: 0, coins: 0 };
|
||||
heldTreeId: string | null = null;
|
||||
/** Free-form walk destination set by pressing on empty map, mutually exclusive with heldTreeId. */
|
||||
moveTarget: Vec2 | null = null;
|
||||
|
||||
private chopProgress = 0;
|
||||
private coinProgress = 0;
|
||||
private lastTimestamp: number | null = null;
|
||||
private rafId: number | null = null;
|
||||
|
||||
constructor() {
|
||||
// autoBind so methods stay correctly bound when passed as bare references (event handlers,
|
||||
// e.g. `onPointerUp={store.release}` or `window.addEventListener('blur', store.release)`).
|
||||
makeAutoObservable(this, {}, { autoBind: true });
|
||||
}
|
||||
|
||||
get heldTree(): TreeState | undefined {
|
||||
return this.trees.find((tree) => tree.id === this.heldTreeId);
|
||||
}
|
||||
|
||||
/** Whether the character is currently in chopping range of the held tree (as opposed to still walking to it). */
|
||||
get isChopping(): boolean {
|
||||
const tree = this.heldTree;
|
||||
if (!tree)
|
||||
return false;
|
||||
return Math.hypot(tree.x - this.character.x, tree.y - this.character.y) <= INTERACTION_RADIUS;
|
||||
}
|
||||
|
||||
/** Whether the character is actively walking (toward a held tree or a free-form move target). */
|
||||
get isMoving(): boolean {
|
||||
if (this.heldTreeId)
|
||||
return !this.isChopping;
|
||||
if (this.moveTarget)
|
||||
return Math.hypot(this.moveTarget.x - this.character.x, this.moveTarget.y - this.character.y) > 0.5;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** (Re)initializes the game for a map of the given size, placing the character in the center and scattering trees. */
|
||||
initialize(width: number, height: number) {
|
||||
this.character = { x: width / 2, y: height / 2 };
|
||||
this.trees = this.spawnTrees(width, height);
|
||||
this.logDrops = [];
|
||||
this.inventory = { logs: 0, coins: 0 };
|
||||
this.heldTreeId = null;
|
||||
this.moveTarget = null;
|
||||
this.chopProgress = 0;
|
||||
this.coinProgress = 0;
|
||||
}
|
||||
|
||||
private spawnTrees(width: number, height: number): TreeState[] {
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const trees: TreeState[] = [];
|
||||
let attempts = 0;
|
||||
|
||||
while (trees.length < TREE_COUNT && attempts < 1000) {
|
||||
attempts++;
|
||||
const x = TREE_EDGE_MARGIN + Math.random() * Math.max(0, width - TREE_EDGE_MARGIN * 2);
|
||||
const y = TREE_EDGE_MARGIN + Math.random() * Math.max(0, height - TREE_EDGE_MARGIN * 2);
|
||||
|
||||
if (Math.hypot(x - centerX, y - centerY) < TREE_MIN_DISTANCE_FROM_CENTER)
|
||||
continue;
|
||||
if (trees.some((tree) => Math.hypot(tree.x - x, tree.y - y) < TREE_MIN_DISTANCE_BETWEEN))
|
||||
continue;
|
||||
|
||||
trees.push({ id: randomId('tree'), x, y, logsRemaining: LOGS_PER_TREE });
|
||||
}
|
||||
|
||||
return trees;
|
||||
}
|
||||
|
||||
holdTree(id: string) {
|
||||
if (this.trees.some((tree) => tree.id === id)) {
|
||||
this.moveTarget = null;
|
||||
this.heldTreeId = id;
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts walking freely toward an arbitrary point on the map (e.g. a click on empty ground). */
|
||||
moveTo(x: number, y: number) {
|
||||
this.heldTreeId = null;
|
||||
this.chopProgress = 0;
|
||||
this.moveTarget = { x, y };
|
||||
}
|
||||
|
||||
release() {
|
||||
this.heldTreeId = null;
|
||||
this.moveTarget = null;
|
||||
this.chopProgress = 0;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.rafId !== null)
|
||||
return;
|
||||
this.lastTimestamp = null;
|
||||
|
||||
const loop = (timestamp: number) => {
|
||||
const last = this.lastTimestamp ?? timestamp;
|
||||
// Cap the per-frame step so a stalled/backgrounded tab doesn't cause a huge catch-up jump
|
||||
// (e.g. character teleporting across the map) once it resumes.
|
||||
const deltaTime = Math.min((timestamp - last) / 1000, 0.1);
|
||||
this.lastTimestamp = timestamp;
|
||||
this.tick(deltaTime);
|
||||
this.rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
this.rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
this.lastTimestamp = null;
|
||||
}
|
||||
|
||||
private tick(deltaTime: number) {
|
||||
this.updateMovementAndChopping(deltaTime);
|
||||
this.updateLogFlights(deltaTime);
|
||||
this.updatePickups();
|
||||
this.updateCoinConversion(deltaTime);
|
||||
}
|
||||
|
||||
private updateMovementAndChopping(deltaTime: number) {
|
||||
const tree = this.heldTree;
|
||||
if (tree) {
|
||||
this.walkTowardTree(tree, deltaTime);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.moveTarget) {
|
||||
this.walkTowardPoint(this.moveTarget, deltaTime);
|
||||
return;
|
||||
}
|
||||
|
||||
this.chopProgress = 0;
|
||||
}
|
||||
|
||||
private walkTowardPoint(target: Vec2, deltaTime: number) {
|
||||
const dx = target.x - this.character.x;
|
||||
const dy = target.y - this.character.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
if (distance <= 0.5) {
|
||||
this.character = { x: target.x, y: target.y };
|
||||
return;
|
||||
}
|
||||
|
||||
const step = Math.min(CHARACTER_SPEED * deltaTime, distance);
|
||||
const ratio = step / distance;
|
||||
this.character = {
|
||||
x: this.character.x + dx * ratio,
|
||||
y: this.character.y + dy * ratio,
|
||||
};
|
||||
}
|
||||
|
||||
private walkTowardTree(tree: TreeState, deltaTime: number) {
|
||||
const dx = tree.x - this.character.x;
|
||||
const dy = tree.y - this.character.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
// A small tolerance keeps the character from flip-flopping between "walking" and "chopping"
|
||||
// every frame once it's essentially at the interaction radius, which floating-point error on
|
||||
// the two-axis step below can otherwise cause (each flip resets chopProgress, so chopping
|
||||
// could stall forever right at the boundary).
|
||||
if (distance > INTERACTION_RADIUS + 0.5) {
|
||||
const step = Math.min(CHARACTER_SPEED * deltaTime, distance - INTERACTION_RADIUS);
|
||||
const ratio = step / distance;
|
||||
this.character = {
|
||||
x: this.character.x + dx * ratio,
|
||||
y: this.character.y + dy * ratio,
|
||||
};
|
||||
this.chopProgress = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.chopProgress += deltaTime;
|
||||
while (this.chopProgress >= CHOP_INTERVAL && tree.logsRemaining > 0) {
|
||||
this.chopProgress -= CHOP_INTERVAL;
|
||||
tree.logsRemaining -= 1;
|
||||
|
||||
// Eject the log on a ballistic arc to a random spot within a circle around the tree; it
|
||||
// only becomes pickable once it lands (see updateLogFlights/updatePickups).
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const landDistance = Math.random() * LOG_EJECT_RADIUS;
|
||||
const toX = tree.x + Math.cos(angle) * landDistance;
|
||||
const toY = tree.y + Math.sin(angle) * landDistance;
|
||||
this.logDrops.push({
|
||||
id: randomId('log'),
|
||||
x: tree.x,
|
||||
y: tree.y,
|
||||
z: 0,
|
||||
phase: 'falling',
|
||||
flight: {
|
||||
fromX: tree.x,
|
||||
fromY: tree.y,
|
||||
toX,
|
||||
toY,
|
||||
elapsed: 0,
|
||||
duration: LOG_FALL_DURATION,
|
||||
arcHeight: LOG_FALL_ARC_HEIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
if (tree.logsRemaining <= 0) {
|
||||
this.trees = this.trees.filter((t) => t.id !== tree.id);
|
||||
this.heldTreeId = null;
|
||||
this.chopProgress = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Advances every log currently in a ballistic flight (falling from a tree, or flying into the
|
||||
* inventory once collected), landing/collecting it once its flight completes. Mutates logs in
|
||||
* place and only reassigns the `logDrops` array when one is actually removed (collected) — a
|
||||
* landed log sitting idle, or the array being rebuilt every tick regardless, would otherwise
|
||||
* force every observer of `logDrops` (i.e. the whole map) to re-render every animation frame. */
|
||||
private updateLogFlights(deltaTime: number) {
|
||||
if (this.logDrops.length === 0)
|
||||
return;
|
||||
|
||||
let collectedIds: Set<string> | null = null;
|
||||
for (const log of this.logDrops) {
|
||||
if (log.phase === 'landed')
|
||||
continue;
|
||||
|
||||
const flight = log.flight!;
|
||||
flight.elapsed += deltaTime;
|
||||
const t = Math.min(1, flight.elapsed / flight.duration);
|
||||
log.x = flight.fromX + (flight.toX - flight.fromX) * t;
|
||||
log.y = flight.fromY + (flight.toY - flight.fromY) * t;
|
||||
log.z = flight.arcHeight * 4 * t * (1 - t);
|
||||
|
||||
if (t < 1)
|
||||
continue;
|
||||
|
||||
if (log.phase === 'falling') {
|
||||
log.phase = 'landed';
|
||||
log.flight = undefined;
|
||||
log.z = 0;
|
||||
} else {
|
||||
// Collecting flight finished: the log has reached the character.
|
||||
(collectedIds ??= new Set()).add(log.id);
|
||||
this.inventory.logs += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (collectedIds)
|
||||
this.logDrops = this.logDrops.filter((log) => !collectedIds!.has(log.id));
|
||||
}
|
||||
|
||||
/** Starts a landed log flying into the inventory once the character gets close enough. */
|
||||
private updatePickups() {
|
||||
for (const log of this.logDrops) {
|
||||
if (log.phase !== 'landed')
|
||||
continue;
|
||||
|
||||
const distance = Math.hypot(log.x - this.character.x, log.y - this.character.y);
|
||||
if (distance > PICKUP_RADIUS)
|
||||
continue;
|
||||
|
||||
log.phase = 'collecting';
|
||||
log.flight = {
|
||||
fromX: log.x,
|
||||
fromY: log.y,
|
||||
toX: this.character.x,
|
||||
toY: this.character.y,
|
||||
elapsed: 0,
|
||||
duration: LOG_COLLECT_DURATION,
|
||||
arcHeight: LOG_COLLECT_ARC_HEIGHT,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private updateCoinConversion(deltaTime: number) {
|
||||
if (this.inventory.logs <= 0) {
|
||||
this.coinProgress = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.coinProgress += deltaTime;
|
||||
while (this.coinProgress >= COIN_INTERVAL && this.inventory.logs > 0) {
|
||||
this.coinProgress -= COIN_INTERVAL;
|
||||
this.inventory.logs -= 1;
|
||||
this.inventory.coins += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export const TREE_COUNT = 5;
|
||||
export const LOGS_PER_TREE = 5;
|
||||
|
||||
/** Character movement speed, px/s. */
|
||||
export const CHARACTER_SPEED = 160;
|
||||
|
||||
/** How close the character must get to a tree's center to start chopping it. */
|
||||
export const INTERACTION_RADIUS = 56;
|
||||
|
||||
/** How close a landed log must be to the character to start flying into the inventory. */
|
||||
export const PICKUP_RADIUS = 46;
|
||||
|
||||
/** Radius of the circle around a tree that ejected logs can land in. */
|
||||
export const LOG_EJECT_RADIUS = 70;
|
||||
|
||||
/** Seconds for a log's ballistic flight from the tree to its landing spot. */
|
||||
export const LOG_FALL_DURATION = 0.5;
|
||||
|
||||
/** Peak height (px) of a log's arc while falling from the tree. */
|
||||
export const LOG_FALL_ARC_HEIGHT = 40;
|
||||
|
||||
/** Seconds for a landed log's flight into the character's inventory. */
|
||||
export const LOG_COLLECT_DURATION = 0.25;
|
||||
|
||||
/** Peak height (px) of a log's little hop while flying into the inventory. */
|
||||
export const LOG_COLLECT_ARC_HEIGHT = 16;
|
||||
|
||||
/** Seconds between logs dropping while a tree is held and in range. */
|
||||
export const CHOP_INTERVAL = 0.6;
|
||||
|
||||
/** Seconds for one log in the inventory to turn into one coin. */
|
||||
export const COIN_INTERVAL = 1;
|
||||
|
||||
/** Trees won't spawn closer than this to the character's starting position. */
|
||||
export const TREE_MIN_DISTANCE_FROM_CENTER = 140;
|
||||
|
||||
/** Trees won't spawn closer than this to each other. */
|
||||
export const TREE_MIN_DISTANCE_BETWEEN = 100;
|
||||
|
||||
/** Trees won't spawn within this many px of the map edge. */
|
||||
export const TREE_EDGE_MARGIN = 70;
|
||||
@@ -0,0 +1,135 @@
|
||||
/** Pixel grids for 8-bit-style sprites. Each cell is a CSS color, or '' for transparent. */
|
||||
export type PixelGrid = string[][];
|
||||
|
||||
function grid(rows: string[], palette: Record<string, string>): PixelGrid {
|
||||
return rows.map((row) => [...row].map((ch) => palette[ch] ?? ''));
|
||||
}
|
||||
|
||||
const CHARACTER_PALETTE = {
|
||||
O: '#1b1611',
|
||||
H: '#4a2f1c',
|
||||
S: '#f0c090',
|
||||
s: '#d9a873',
|
||||
B: '#3f6fb0',
|
||||
b: '#2f5488',
|
||||
P: '#2c2c3e',
|
||||
p: '#20202e',
|
||||
F: '#2a1f14',
|
||||
};
|
||||
|
||||
export const CHARACTER_SPRITE: PixelGrid = grid(
|
||||
[
|
||||
'....OOOOOOOO....',
|
||||
'..OHHHHHHHHHHO..',
|
||||
'.OHHHHHHHHHHHHO.',
|
||||
'OHHHHHHHHHHHHHHO',
|
||||
'OHHSSSSSSSSSSHHO',
|
||||
'OSSSSOSSSSOSSSSO',
|
||||
'OSSSSSSssSSSSSSO',
|
||||
'.OSSSSSSSSSSSSO.',
|
||||
'......OSSO......',
|
||||
'...OBBBBBBBBO...',
|
||||
'..OBBBBBBBBBBO..',
|
||||
'.OSBBBBBBBBBBSO.',
|
||||
'.OSBBbBBBBbBBSO.',
|
||||
'.OSBBbBBBBbBBSO.',
|
||||
'..OBBbBBBBbBBO..',
|
||||
'..OPPpPPPPpPPO..',
|
||||
'..OPPpPPPPpPPO..',
|
||||
'..OPPpO..OpPPO..',
|
||||
'..OPPpO..OpPPO..',
|
||||
'..OPPpO..OpPPO..',
|
||||
'..OFFFO..OFFFO..',
|
||||
'.OFFFFO..OFFFFO.',
|
||||
],
|
||||
CHARACTER_PALETTE,
|
||||
);
|
||||
|
||||
const TREE_PALETTE = {
|
||||
O: '#14290f',
|
||||
G: '#2e7d32',
|
||||
g: '#1b5e20',
|
||||
H: '#66bb6a',
|
||||
T: '#6b4226',
|
||||
t: '#4e2f1a',
|
||||
n: '#8a5a34',
|
||||
};
|
||||
|
||||
export const TREE_SPRITE: PixelGrid = grid(
|
||||
[
|
||||
'......OggO......',
|
||||
'....OGGggGGO....',
|
||||
'..OGGGGggGGGGO..',
|
||||
'.OGgGGGHHGGGgGO.',
|
||||
'OGgGGGGHHGGGGgGO',
|
||||
'OGGgGGGggGGGgGGO',
|
||||
'OGgGGGgGGgGGGgGO',
|
||||
'OGGgGGGGGGGGgGGO',
|
||||
'OGgGGGGggGGGGgGO',
|
||||
'.OGgGGGggGGGgGO.',
|
||||
'..OGgGGggGGgGO..',
|
||||
'...OGgGggGgGO...',
|
||||
'....OGgGGgGO....',
|
||||
'......OggO......',
|
||||
'......OTTO......',
|
||||
'......TTTT......',
|
||||
'......TttT......',
|
||||
'......tnnt......',
|
||||
'......TTTT......',
|
||||
'......TOOT......',
|
||||
],
|
||||
TREE_PALETTE,
|
||||
);
|
||||
|
||||
const LOG_PALETTE = {
|
||||
O: '#2a1a0d',
|
||||
B: '#7a4a2b',
|
||||
C: '#a9703f',
|
||||
};
|
||||
|
||||
export const LOG_SPRITE: PixelGrid = grid(
|
||||
[
|
||||
'..OOOOOOOOOOOOOOOO..',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'OBBBBBBBBBBBBBBBBBBO',
|
||||
'OBCCCCCCCCCCCCCCCCBO',
|
||||
'..OOOOOOOOOOOOOOOO..',
|
||||
],
|
||||
LOG_PALETTE,
|
||||
);
|
||||
|
||||
const COIN_PALETTE = {
|
||||
Y: '#f5c518',
|
||||
D: '#c99a0e',
|
||||
W: '#fff2b0',
|
||||
E: '#a97c08',
|
||||
};
|
||||
|
||||
export const COIN_SPRITE: PixelGrid = grid(
|
||||
[
|
||||
'.....YYYYYY.....',
|
||||
'...YYYYYYYYYY...',
|
||||
'..YWWYYYYYYYYY..',
|
||||
'.YYWWYYYYYYYYYY.',
|
||||
'.YYYYYYYYYYYYYY.',
|
||||
'YYYYYDDDDDDYYYYY',
|
||||
'YYYYYDYYYYDYYYYY',
|
||||
'YYYYYDYYYYDYYYYY',
|
||||
'YYYYYDYYYYDYYYYY',
|
||||
'YYYYYDYYYYDYYYYY',
|
||||
'YYYYYDDDDDDYYYYY',
|
||||
'.YYYYYYYYYYYYYY.',
|
||||
'.YYYYYYYYYYEEYY.',
|
||||
'..YYYYYYYYYEEY..',
|
||||
'...YYYYYYYYYY...',
|
||||
'.....YYYYYY.....',
|
||||
],
|
||||
COIN_PALETTE,
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface Vec2 {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface TreeState {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
logsRemaining: number;
|
||||
}
|
||||
|
||||
export type LogPhase = 'falling' | 'landed' | 'collecting';
|
||||
|
||||
export interface LogFlight {
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
elapsed: number;
|
||||
duration: number;
|
||||
arcHeight: number;
|
||||
}
|
||||
|
||||
export interface LogDrop {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Visual height above ground during a flight, for the ballistic arc. 0 once landed. */
|
||||
z: number;
|
||||
phase: LogPhase;
|
||||
flight?: LogFlight;
|
||||
}
|
||||
|
||||
export interface Inventory {
|
||||
logs: number;
|
||||
coins: number;
|
||||
}
|
||||
+12
-98
@@ -1,111 +1,25 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
--panel-bg: #3a2a1e;
|
||||
--panel-border: #1b1611;
|
||||
--text: #f5e9d0;
|
||||
--accent: #f5c518;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
font: 16px/1.4 'Press Start 2P', ui-monospace, Consolas, monospace;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
||||
exclude: ['@incgame/engine'],
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
fs: {
|
||||
// The engine package lives outside this project's root (a sibling directory reached
|
||||
// through a node_modules symlink); allow Vite to serve/watch files from there too.
|
||||
|
||||
Reference in New Issue
Block a user