уровни игры
This commit is contained in:
+33
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user