уровни игры

This commit is contained in:
azykov@mail.ru
2026-09-04 16:20:32 +03:00
parent a05d5f4547
commit b8e0d01359
6 changed files with 84 additions and 3 deletions
+33
View File
@@ -192,6 +192,39 @@
animation: walk-bob 0.3s infinite steps(2); animation: walk-bob 0.3s infinite steps(2);
} }
.next-level-btn {
position: absolute;
left: 50%;
top: 40%;
transform: translate(-50%, -50%);
padding: 16px 24px;
background: var(--panel-bg);
border: 4px solid var(--accent);
color: var(--accent);
font-family: inherit;
font-size: 14px;
text-shadow: 2px 2px 0 var(--panel-border);
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.35);
cursor: pointer;
animation: next-level-pop 0.6s ease-out;
}
.next-level-btn:hover {
box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.35);
margin: -4px 0px 0px -4px;
}
@keyframes next-level-pop {
0% {
transform: translate(-50%, -50%) scale(0.85);
opacity: 0;
}
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
}
@keyframes chop-shake { @keyframes chop-shake {
0%, 100% { 0%, 100% {
transform: translate(0, 0) rotate(0deg); transform: translate(0, 0) rotate(0deg);
+11
View File
@@ -150,6 +150,17 @@ function GameMap({ store }: Props) {
))} ))}
<CharacterEntity store={store} /> <CharacterEntity store={store} />
{store.canAdvanceLevel && (
<button
type="button"
className="next-level-btn"
onPointerDown={(e) => e.stopPropagation()}
onClick={store.nextLevel}
>
Перейти на следующий уровень
</button>
)}
</> </>
)} )}
</div> </div>
+3
View File
@@ -11,6 +11,9 @@ function Topbar({ store }: Props) {
return ( return (
<div className="topbar"> <div className="topbar">
<h1 className="topbar__title">Forest</h1> <h1 className="topbar__title">Forest</h1>
<div className="inventory-slot">
<span>Уровень {store.level}</span>
</div>
<div className="spacer" /> <div className="spacer" />
<div className="inventory-slot"> <div className="inventory-slot">
<PixelSprite pixels={LOG_SPRITE} size={28} /> <PixelSprite pixels={LOG_SPRITE} size={28} />
+30 -2
View File
@@ -8,10 +8,10 @@ import {
LOG_FALL_ARC_HEIGHT, LOG_FALL_ARC_HEIGHT,
LOG_FALL_DURATION, LOG_FALL_DURATION,
LOGS_PER_TREE, LOGS_PER_TREE,
TREE_COUNT,
TREE_EDGE_MARGIN, TREE_EDGE_MARGIN,
TREE_MIN_DISTANCE_BETWEEN, TREE_MIN_DISTANCE_BETWEEN,
TREE_MIN_DISTANCE_FROM_CENTER, TREE_MIN_DISTANCE_FROM_CENTER,
treesForLevel,
} from './constants'; } from './constants';
import { loadSave, writeSave } from './persistence'; import { loadSave, writeSave } from './persistence';
import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades'; import { costForNextLevel, UPGRADES, type UpgradeId } from './upgrades';
@@ -24,6 +24,7 @@ function randomId(prefix: string): string {
} }
export class GameStore { export class GameStore {
level = 1;
character: Vec2 = { x: 0, y: 0 }; character: Vec2 = { x: 0, y: 0 };
trees: TreeState[] = []; trees: TreeState[] = [];
logDrops: LogDrop[] = []; logDrops: LogDrop[] = [];
@@ -44,6 +45,8 @@ export class GameStore {
private saveProgress = 0; private saveProgress = 0;
private lastTimestamp: number | null = null; private lastTimestamp: number | null = null;
private rafId: number | null = null; private rafId: number | null = null;
/** Map dimensions from the last initialize(), reused to spawn each new level's trees. */
private mapSize: { width: number; height: number } | null = null;
constructor() { constructor() {
// autoBind so methods stay correctly bound when passed as bare references (event handlers, // autoBind so methods stay correctly bound when passed as bare references (event handlers,
@@ -100,6 +103,11 @@ export class GameStore {
return 1 / logsPerSecond; return 1 / logsPerSecond;
} }
/** Whether every tree on the current level has been chopped down and every log collected. */
get canAdvanceLevel(): boolean {
return this.trees.length === 0 && this.logDrops.length === 0;
}
/** Buys the next level of the given upgrade if there are enough coins. Returns whether it bought. */ /** Buys the next level of the given upgrade if there are enough coins. Returns whether it bought. */
buyUpgrade(id: UpgradeId): boolean { buyUpgrade(id: UpgradeId): boolean {
const level = this.upgradeLevels[id]; const level = this.upgradeLevels[id];
@@ -116,6 +124,7 @@ export class GameStore {
* logs/progress) if one exists; otherwise places the character in the center and scatters * logs/progress) if one exists; otherwise places the character in the center and scatters
* fresh trees. */ * fresh trees. */
initialize(width: number, height: number) { initialize(width: number, height: number) {
this.mapSize = { width, height };
this.heldTreeId = null; this.heldTreeId = null;
this.moveTarget = null; this.moveTarget = null;
this.chopProgress = 0; this.chopProgress = 0;
@@ -123,6 +132,8 @@ export class GameStore {
this.saveProgress = 0; this.saveProgress = 0;
const save = loadSave(); const save = loadSave();
this.level = save?.level ?? 1;
if (save?.character && save.trees && save.logDrops) { if (save?.character && save.trees && save.logDrops) {
this.character = { ...save.character }; this.character = { ...save.character };
this.trees = save.trees.map((tree) => ({ ...tree })); this.trees = save.trees.map((tree) => ({ ...tree }));
@@ -138,6 +149,21 @@ export class GameStore {
this.upgradeLevels = { ...this.upgradeLevels, ...save.upgradeLevels }; this.upgradeLevels = { ...this.upgradeLevels, ...save.upgradeLevels };
} }
/** Advances to the next level once every tree is chopped and every log collected: respawns a
* fresh batch of trees (level + 4 of them) and clears the ground of logs. */
nextLevel() {
if (!this.canAdvanceLevel || !this.mapSize)
return;
this.level += 1;
this.heldTreeId = null;
this.moveTarget = null;
this.chopProgress = 0;
this.trees = this.spawnTrees(this.mapSize.width, this.mapSize.height);
this.logDrops = [];
this.save();
}
/** Persists the full game (world state + progress) to localStorage right away. */ /** Persists the full game (world state + progress) to localStorage right away. */
save() { save() {
writeSave({ writeSave({
@@ -147,6 +173,7 @@ export class GameStore {
character: { ...this.character }, character: { ...this.character },
trees: this.trees.map((tree) => ({ ...tree })), trees: this.trees.map((tree) => ({ ...tree })),
logDrops: this.logDrops.map((log) => ({ ...log, flight: log.flight ? { ...log.flight } : undefined })), logDrops: this.logDrops.map((log) => ({ ...log, flight: log.flight ? { ...log.flight } : undefined })),
level: this.level,
}); });
} }
@@ -155,8 +182,9 @@ export class GameStore {
const centerY = height / 2; const centerY = height / 2;
const trees: TreeState[] = []; const trees: TreeState[] = [];
let attempts = 0; let attempts = 0;
const treeCount = treesForLevel(this.level);
while (trees.length < TREE_COUNT && attempts < 1000) { while (trees.length < treeCount && attempts < 1000) {
attempts++; attempts++;
const x = TREE_EDGE_MARGIN + Math.random() * Math.max(0, width - TREE_EDGE_MARGIN * 2); 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); const y = TREE_EDGE_MARGIN + Math.random() * Math.max(0, height - TREE_EDGE_MARGIN * 2);
+5 -1
View File
@@ -1,4 +1,8 @@
export const TREE_COUNT = 5; /** Number of trees spawned on a given level. */
export function treesForLevel(level: number): number {
return level + 4;
}
export const LOGS_PER_TREE = 5; export const LOGS_PER_TREE = 5;
/** How close the character must get to a tree's center to start chopping it. */ /** How close the character must get to a tree's center to start chopping it. */
+2
View File
@@ -12,6 +12,8 @@ export interface SaveData {
character?: Vec2; character?: Vec2;
trees?: TreeState[]; trees?: TreeState[];
logDrops?: LogDrop[]; logDrops?: LogDrop[];
/** Absent on older saves, which are treated as level 1. */
level?: number;
} }
function isValidSave(value: unknown): value is SaveData { function isValidSave(value: unknown): value is SaveData {