forest: map with trees
This commit is contained in:
@@ -5,6 +5,12 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>forest</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+26
@@ -10,6 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@incgame/engine": "file:../engine",
|
"@incgame/engine": "file:../engine",
|
||||||
"break_eternity.js": "^2.1.3",
|
"break_eternity.js": "^2.1.3",
|
||||||
|
"mobx": "^7.0.3",
|
||||||
|
"mobx-react-lite": "^5.0.3",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
},
|
},
|
||||||
@@ -1010,6 +1012,30 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.18",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@incgame/engine": "file:../engine",
|
"@incgame/engine": "file:../engine",
|
||||||
"break_eternity.js": "^2.1.3",
|
"break_eternity.js": "^2.1.3",
|
||||||
|
"mobx": "^7.0.3",
|
||||||
|
"mobx-react-lite": "^5.0.3",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
},
|
},
|
||||||
|
|||||||
+111
-165
@@ -1,184 +1,130 @@
|
|||||||
.counter {
|
.app {
|
||||||
font-size: 16px;
|
display: flex;
|
||||||
padding: 5px 10px;
|
flex-direction: column;
|
||||||
border-radius: 5px;
|
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);
|
color: var(--accent);
|
||||||
background: var(--accent-bg);
|
text-shadow: 2px 2px 0 var(--panel-border);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.topbar .spacer {
|
||||||
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;
|
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#next-steps {
|
.inventory-slot {
|
||||||
display: flex;
|
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;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
text-decoration: none;
|
padding: 6px 12px;
|
||||||
transition: box-shadow 0.3s;
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 3px solid var(--panel-border);
|
||||||
&:hover {
|
font-size: 14px;
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.button-icon {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
.game-map {
|
||||||
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 {
|
|
||||||
position: relative;
|
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,
|
.entity {
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4.5px;
|
transform: translate(-50%, -50%);
|
||||||
border: 5px solid transparent;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
.entity.log-shadow {
|
||||||
left: 0;
|
width: 20px;
|
||||||
border-left-color: var(--border);
|
height: 10px;
|
||||||
|
margin: 8px 0px 0px 0px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
&::after {
|
|
||||||
right: 0;
|
.entity.tree-shadow {
|
||||||
border-right-color: var(--border);
|
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 { useState } from 'react';
|
||||||
import { deserializeGame, type Game } from '@incgame/engine'
|
import GameMap from './components/GameMap';
|
||||||
import heroImg from './assets/hero.png'
|
import Topbar from './components/Topbar';
|
||||||
import reactLogo from './assets/react.svg'
|
import { GameStore } from './game/GameStore';
|
||||||
import viteLogo from './assets/vite.svg'
|
import './App.css';
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [game] = useState<Game>(() => deserializeGame({ time: 0, nodes: [] }))
|
const [store] = useState(() => new GameStore());
|
||||||
const [time, setTime] = useState(() => game.time.toString())
|
|
||||||
|
|
||||||
function handleTick() {
|
|
||||||
game.tick(0.1)
|
|
||||||
setTime(game.time.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="app">
|
||||||
<section id="center">
|
<Topbar store={store} />
|
||||||
<div className="hero">
|
<GameMap store={store} />
|
||||||
<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>
|
</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 {
|
:root {
|
||||||
--text: #6b6375;
|
--panel-bg: #3a2a1e;
|
||||||
--text-h: #08060d;
|
--panel-border: #1b1611;
|
||||||
--bg: #fff;
|
--text: #f5e9d0;
|
||||||
--border: #e5e4e7;
|
--accent: #f5c518;
|
||||||
--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;
|
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
font: 16px/1.4 'Press Start 2P', ui-monospace, Consolas, monospace;
|
||||||
--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;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
color-scheme: dark;
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
body {
|
body {
|
||||||
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
#root {
|
||||||
h2 {
|
height: 100%;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
|||||||
exclude: ['@incgame/engine'],
|
exclude: ['@incgame/engine'],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
fs: {
|
fs: {
|
||||||
// The engine package lives outside this project's root (a sibling directory reached
|
// 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.
|
// through a node_modules symlink); allow Vite to serve/watch files from there too.
|
||||||
|
|||||||
Reference in New Issue
Block a user