save/load to localStorage

This commit is contained in:
azykov@mail.ru 2026-02-07 20:47:55 +03:00
parent 83f7154665
commit 9af9c7867a
18 changed files with 409 additions and 90 deletions

View File

@ -1,24 +1,44 @@
import { makeAutoObservable } from 'mobx';
import { school } from './school';
import { Progress } from './progress';
import { Progress, ProgressDto } from './progress';
import { game } from './game';
export type CalendarDto = {
day: number,
progress: ProgressDto,
}
export class Calendar {
public day: number = 1;
public progress = new Progress(24, 1, true, () => this.handleProgress());
public progress = new Progress(24, 1, true, this.handleProgress.bind(this));
constructor() {
makeAutoObservable(this);
}
private handleProgress(): boolean {
this.day++;
school.collectStudentGold();
private handleProgress(times: number): boolean {
this.day += times;
game.school.collectStudentGold(times);
return true;
}
public get dayFraction(): number {
return Math.floor(this.progress.value);
}
public serialize(): CalendarDto {
return {
day: this.day,
progress: this.progress.serialize(),
};
}
public static deserialize(data: CalendarDto): Calendar {
const calendar = new Calendar();
calendar.progress = Progress.deserialize(data.progress, true, calendar.handleProgress.bind(calendar));
return calendar;
}
}

28
src/model/game.ts Normal file
View File

@ -0,0 +1,28 @@
import { makeAutoObservable } from 'mobx';
import { School, SchoolDto } from './school';
import { GameClock } from '../utils/gameClock';
export class Game {
public school = new School();
constructor() {
makeAutoObservable(this);
}
public save(): void {
const data = this.school.serialize();
localStorage.setItem('save', JSON.stringify(data))
}
public load(): void {
const json = localStorage.getItem('save');
if (!json)
return;
const data: SchoolDto = JSON.parse(json);
this.school = School.deserialize(data);
}
}
export const game = new Game();

View File

@ -1,21 +1,39 @@
import { Progress } from './progress';
import { school } from './school';
import { IUnit } from './unit';
import { game } from './game';
import { Progress, ProgressDto } from './progress';
import { IUnit } from '../types/unit';
import { makeAutoObservable } from 'mobx';
export type OwnerDto = {
progress: ProgressDto,
}
export class Owner implements IUnit {
public readonly id = 'owner';
public progress = new Progress(2, 1, false, () => this.handleProgress());
public progress = new Progress(2, 1, false, this.handleProgress.bind(this));
constructor() {
makeAutoObservable(this);
}
private handleProgress(): boolean {
school.addReputation(1);
private handleProgress(times: number): boolean {
game.school.addReputation(times);
return true;
}
public serialize(): OwnerDto {
return {
progress: this.progress.serialize(),
}
}
public static deserialize(data: OwnerDto): Owner {
const owner = new Owner();
owner.progress = Progress.deserialize(data.progress, false, owner.handleProgress.bind(owner));
return owner;
}
}

View File

@ -1,5 +1,11 @@
import { makeAutoObservable } from 'mobx';
export type ProgressDto = {
length: number;
speed: number;
value: number;
}
export class Progress {
public value: number = 0;
@ -8,7 +14,7 @@ export class Progress {
public length: number,
public speed: number,
public readonly autoRun: boolean,
private callback: () => boolean,
private callback: (times: number) => void,
) {
makeAutoObservable(this);
}
@ -26,19 +32,49 @@ export class Progress {
}
public tick(time: number): void {
if (this.isFull && this.autoRun) {
this.run();
}
const newValue = this.value + time / this.speed;
this.value = Math.min(this.length, this.value + time / this.speed);
if (this.autoRun)
this.run();
if (this.autoRun) {
const rem = newValue % this.length;
const times = (newValue - rem) / this.length;
this.value = rem;
if (times > 0)
this.run(times);
}
else
this.value = Math.min(this.length, newValue);
}
public run(): void {
private run(times: number): void {
this.callback(times)
}
public manualRun(): void {
if (this.autoRun)
return;
if (this.isFull) {
if (this.callback())
this.reset();
this.run(1)
this.reset();
}
}
public serialize(): ProgressDto {
return {
length: this.length,
speed: this.speed,
value: this.value,
};
}
public static deserialize(
data: ProgressDto,
autoRun: boolean,
callback: (times: number) => void,
): Progress {
const inst = new Progress(data.length, data.speed, autoRun, callback);
inst.value = data.value;
return inst;
}
}

View File

@ -1,22 +1,34 @@
import { makeAutoObservable } from 'mobx';
import { Owner } from './owner';
import { Student } from './student';
import { IUnit } from './unit';
import { Owner, OwnerDto } from './owner';
import { Student, StudentDto } from './student';
import { IUnit } from '../types/unit';
import { Upgrades } from './upgrades';
import { PartialResourceSet, ResourceSet } from '../types/resources';
import { addResourceSets, fullResourceSet, mapResourceSet, multiplyResourceSet, roundResourceSet, subtractResourceSets } from '../utils/resources';
import { addResourceSets, fullResourceSet, multiplyResourceSet, roundResourceSet, subtractResourceSets } from '../utils/resources';
import { v7 as uuid } from 'uuid';
import { GameClock } from '../utils/gameClock';
import { Calendar } from './calendar';
import { Calendar, CalendarDto } from './calendar';
import { getRandomHumanName } from '../utils/humanNames';
import { Time as Time } from './time';
import { UpgradeDto } from './upgrade';
const resourceScale = 100;
export class School {
export type SchoolDto = {
lastTime: number,
calendar: CalendarDto,
resources: ResourceSet,
units: {
owner: OwnerDto,
students: StudentDto[],
},
upgrades: UpgradeDto[],
}
public timeScale: number = 1;
export class School {
public tps: number = 0;
public time = new Time();
public calendar = new Calendar();
// units
@ -38,7 +50,7 @@ export class School {
}
public tick(time: number) {
const scaledTime = time * this.timeScale;
const scaledTime = time;
this.calendar.progress.tick(scaledTime);
@ -48,10 +60,6 @@ export class School {
this.tps = GameClock.tps;
}
public setTimeScale(value: number) {
this.timeScale = value;
}
public get units(): IUnit[] {
return [this.owner, ...this.students]
}
@ -81,9 +89,43 @@ export class School {
this.scaledResources = subtractResourceSets(this.scaledResources, multiplyResourceSet(resources, resourceScale));
}
public collectStudentGold(): void {
this.addGold(this.students.length);
public collectStudentGold(times: number): void {
this.addGold(this.students.length * times);
}
public serialize(): SchoolDto {
return {
lastTime: GameClock.lastTime,
calendar: this.calendar.serialize(),
resources: this.scaledResources,
units: {
owner: this.owner.serialize(),
students: this.students.map((s) => s.serialize()),
},
upgrades: this.upgrades.allItems.map((up) => up.serialize()),
}
}
public static deserialize(data: SchoolDto): School {
const inst = new School();
GameClock.lastTime = data.lastTime;
inst.calendar = Calendar.deserialize(data.calendar);
inst.scaledResources = data.resources;
inst.owner = Owner.deserialize(data.units.owner);
inst.students = data.units.students.map((s) => Student.deserialize(s));
for (let up of inst.upgrades.allItems) {
let upgradeData = data.upgrades.find((u) => u.id === up.id);
if (upgradeData)
up.deserialize(upgradeData);
}
return inst;
}
}
export const school = new School();

View File

@ -1,11 +1,18 @@
import { Progress } from './progress';
import { school } from './school';
import { IUnit } from './unit';
import { game } from './game';
import { Progress, ProgressDto } from './progress';
import { School } from './school';
import { IUnit } from '../types/unit';
import { makeAutoObservable } from 'mobx';
export type StudentDto = {
id: string,
name: string,
progress: ProgressDto,
}
export class Student implements IUnit {
public progress = new Progress(10, 1, true, () => this.handleProgress());
public progress = new Progress(10, 1, true, this.handleProgress.bind(this));
constructor(
public readonly id: string,
@ -14,9 +21,25 @@ export class Student implements IUnit {
makeAutoObservable(this);
}
private handleProgress(): boolean {
school.addReputation(0.1);
private handleProgress(times: number): boolean {
game.school.addReputation(0.1 * times);
return true;
}
public serialize(): StudentDto {
return {
id: this.id,
name: this.name,
progress: this.progress.serialize(),
}
}
public static deserialize(data: StudentDto): Student {
const student = new Student(data.id, data.name);
student.progress = Progress.deserialize(data.progress, true, student.handleProgress.bind(student));
return student;
}
}

28
src/model/time.ts Normal file
View File

@ -0,0 +1,28 @@
import { makeAutoObservable } from 'mobx';
import { GameClock } from '../utils/gameClock';
export class Time {
public timeScale: number = 1;
constructor() {
makeAutoObservable(this);
}
public setTimeScale(value: number) {
this.timeScale = value;
}
public get paused(): boolean {
GameClock.pausedSignal.reportObserved();
return GameClock.paused;
}
public pause() {
GameClock.pause();
}
public resume() {
GameClock.resume();
}
}

View File

@ -1,9 +1,14 @@
import { UpgradePrefab } from "../prefabs/upgrades";
import { UpgradePrefab, upgrades } from "../prefabs/upgrades";
import { addResourceSets, fullResourceSet, isResourceSetGte } from "../utils/resources";
import { evalLevelValue } from "../utils/upgrades";
import { school } from "./school";
import { ResourceSet } from "../types/resources";
import { makeAutoObservable } from "mobx";
import { game } from "./game";
export type UpgradeDto = {
id: string,
level: number,
}
export class Upgrade {
public readonly id: string;
@ -83,20 +88,32 @@ export class Upgrade {
let cost = this.costToBuy;
this.prefab.execute(this.level);
school.removeResources(cost);
game.school.removeResources(cost);
this.increaseLevel();
}
public isVisible(): boolean {
return isResourceSetGte(school.resources, this.costToView);
return isResourceSetGte(game.school.resources, this.costToView);
}
public isOpen(): boolean {
return isResourceSetGte(school.resources, this.costToOpen);
return isResourceSetGte(game.school.resources, this.costToOpen);
}
public isAffordable(): boolean {
return isResourceSetGte(school.resources, this.costToBuy);
return isResourceSetGte(game.school.resources, this.costToBuy);
}
public serialize(): UpgradeDto {
return {
id: this.id,
level: this.level,
}
}
public deserialize(data: UpgradeDto): void {
this.level = data.level;
}
}

View File

@ -1,4 +1,4 @@
import { school } from "../model/school";
import { game } from "../model/game";
import { PartialResourceSet, Resource } from "../types/resources";
import { LevelValue, MaybeLevelValue } from "../types/upgrades";
import { fullResourceSet } from "../utils/resources";
@ -23,7 +23,7 @@ export const upgrades: UpgradePrefab[] = [
costToOpen: (level) => ({ reputation: 20 * (level / 10 + 1) }),
costToBuy: (level) => ({ gold: 10 }),
execute: (level) => {
school.increaseStudentCount();
game.school.increaseStudentCount();
},
},
{

View File

@ -1,5 +1,5 @@
import { makeAutoObservable } from 'mobx';
import { Progress } from './progress';
import { Progress } from '../model/progress';
export interface IUnit {
get id(): string;

View File

@ -1,8 +1,13 @@
import { school } from "../model/school";
import { game } from "../model/game";
import { ExternalSignal } from "./mobx/externalSignal";
export class GameClock {
public static lastTime = 0;
private static frames = new Array<number>();
public static paused: boolean = false;
public static readonly pausedSignal = new ExternalSignal('GameClock.paused');
// public static async start(): Promise<void> {
// let lastTime = performance.now();
@ -18,17 +23,18 @@ export class GameClock {
// }
public static start(): void {
let lastTime = performance.now();
const loop = (now: number) => {
const delta = now - lastTime;
lastTime = now;
let delta = now - GameClock.lastTime;
GameClock.lastTime = now;
GameClock.frames.push(lastTime);
while (GameClock.frames[0] < lastTime - 1000)
if (GameClock.paused)
delta *= 0;
GameClock.frames.push(now);
while (GameClock.frames[0] < now - 1000)
GameClock.frames.shift();
school.tick(delta / 1000);
GameClock.tick(delta / 1000 * game.school.time.timeScale);
requestAnimationFrame(loop);
};
@ -36,6 +42,10 @@ export class GameClock {
requestAnimationFrame(loop);
}
public static tick(time: number): void {
game.school.tick(time);
}
public static get tps(): number {
if (GameClock.frames.length < 2)
return 0;
@ -44,4 +54,21 @@ export class GameClock {
return Math.round(GameClock.frames.length / timeSpan * 1000);
}
public static skipTime(skipTime: number) {
if (skipTime < 0)
return;
GameClock.tick(skipTime);
}
public static pause() {
GameClock.paused = true;
GameClock.pausedSignal.trigger();
}
public static resume() {
GameClock.paused = false;
GameClock.pausedSignal.trigger();
}
}

View File

@ -0,0 +1,21 @@
import { createAtom, IAtom } from "mobx";
export class ExternalSignal {
private readonly atom: IAtom;
constructor(name: string) {
this.atom = createAtom(
name,
() => { }, // onBecomeObserved
() => { } // onBecomeUnobserved
)
}
reportObserved() {
this.atom.reportObserved();
}
trigger() {
this.atom.reportChanged();
}
}

View File

@ -0,0 +1,22 @@
import { observer } from "mobx-react-lite";
import { school } from "../model/school";
import { Student } from "../model/student";
import { Owner } from "../model/owner";
import { OwnerUnitView } from "./OwnerUnitView";
import { IUnit } from "../types/unit";
export const UnitListView = observer(function () {
function renderUnit(unit: IUnit) {
if (unit instanceof Owner)
return <OwnerUnitView unit={unit} />
// // else if (unit instanceof Student)
// // return StudentView({ unit });
else
return '???';
}
return <ul>{
school.units.map((unit) => <li className="unit" key={unit.id}>{renderUnit(unit)}</li>)
}</ul>
});

View File

@ -1,27 +1,65 @@
import { observer } from "mobx-react-lite";
import { school } from "../model/school";
import { ChangeEvent } from "react";
import { ChangeEvent, useState } from "react";
import { GameClock } from "../utils/gameClock";
import { game } from "../model/game";
export const DebugView = observer(function () {
const [skipTime, setSkipTime] = useState<number>(5);
function handleTimeScaleChange(event: ChangeEvent<HTMLInputElement>): void {
school.setTimeScale(Number(event.target.value));
game.school.time.setTimeScale(Number(event.target.value));
}
function handleSkipTime(): void {
GameClock.skipTime(skipTime);
}
function handleSaveClick(): void {
game.save();
}
function handleLoadClick(): void {
game.school.time.pause();
game.load();
}
function handleTickClick(): void {
GameClock.pause();
GameClock.tick(0.1);
}
return <div className="debug">
<div className="timeScale">
Масштаб времени
<input
type="range"
min={0}
max={1}
step={0.1}
value={school.timeScale}
onChange={handleTimeScaleChange}
style={{width: '100%'}}
/>
x{school.timeScale}
<div>
Время
<div className="timeScale">
Масштаб
<input
type="range"
min={1}
max={100}
step={1}
value={game.school.time.timeScale}
onChange={handleTimeScaleChange}
style={{ width: '100%' }}
/>
x{game.school.time.timeScale}
</div>
{
game.school.time.paused
? <button onClick={() => game.school.time.resume()}>|&gt;</button>
: <button onClick={() => game.school.time.pause()}>||</button>
}
<button onClick={handleSkipTime}>
<input type="number" value={skipTime} onChange={(e) => setSkipTime(Number(e.target.value))}></input>
Пропустить
</button>
<button onClick={handleTickClick}>Tick</button>
</div>
<div className="tps">TPS: {school.tps}</div>
<div>
<button onClick={handleSaveClick}>Save</button>
<button onClick={handleLoadClick}>Load</button>
</div>
<div className="tps">TPS: {game.school.tps}</div>
</div>
});

View File

@ -1,6 +1,5 @@
import { observer } from "mobx-react-lite";
import { Owner } from "../model/owner";
import { school } from "../model/school";
type Props = {
unit: Owner;
@ -9,7 +8,7 @@ type Props = {
export const OwnerUnitView = observer(function ({ unit }: Props) {
function handleButtonClick(): void {
if (unit.progress.isFull) {
unit.progress.run();
unit.progress.manualRun();
}
}

View File

@ -1,11 +1,11 @@
import { observer } from "mobx-react-lite";
import { school } from "../model/school";
import { ResourceStringView } from "./ResourceStringView";
import { game } from "../model/game";
export const StatsView = observer(function () {
return <div className="stats">
<ResourceStringView resources={school.resources} partial={false} />
<div className="time">День {school.calendar.day} : { school.calendar.dayFraction }</div>
<ResourceStringView resources={game.school.resources} partial={false} />
<div className="time">День {game.school.calendar.day} : { game.school.calendar.dayFraction }</div>
</div>
});

View File

@ -1,10 +1,10 @@
import { observer } from "mobx-react-lite";
import { school } from "../model/school";
import { Student } from "../model/student";
import { Owner } from "../model/owner";
import { OwnerUnitView } from "./OwnerUnitView";
import { IUnit } from "../model/unit";
import { IUnit } from "../types/unit";
import { StudentUnitView } from "./StudentUnitView";
import { game } from "../model/game";
export const UnitListView = observer(function () {
@ -19,10 +19,10 @@ export const UnitListView = observer(function () {
return <div className="units">
<div className="owner">
<OwnerUnitView unit={school.owner} />
<OwnerUnitView unit={game.school.owner} />
</div>
<div className="students">
{school.students.map((unit) => <StudentUnitView key={unit.id} unit={unit} />)}
{game.school.students.map((unit) => <StudentUnitView key={unit.id} unit={unit} />)}
</div>
</div>
});

View File

@ -1,10 +1,10 @@
import { observer } from "mobx-react-lite";
import { school } from "../model/school";
import { UpgradeView } from "./UpgradeView";
import { game } from "../model/game";
export const UpgradeListView = observer(function () {
return <div className="upgrades">{
school.upgrades.visibleItems.map((upgrade) => <UpgradeView key={upgrade.id} upgrade={upgrade} />)
game.school.upgrades.visibleItems.map((upgrade) => <UpgradeView key={upgrade.id} upgrade={upgrade} />)
}</div>;
});