initial commit

This commit is contained in:
azykov@mail.ru 2026-02-07 15:36:28 +03:00
commit 83f7154665
39 changed files with 3490 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/node_modules

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
24

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>School of Magic</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2317
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"human-names": "^1.0.12",
"mobx-react-lite": "^4.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^3.0.0",
"sass": "^1.97.3",
"typescript": "^4.9.3",
"vite": "^4.0.0"
}
}

1
public/icon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

174
src/App.scss Normal file
View File

@ -0,0 +1,174 @@
#root {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
text-align: left;
font-size: 16pt;
background: #404040;
color: white;
}
.App {
display: grid;
grid-template-columns: 1fr 15rem;
grid-template-rows: auto 1fr auto;
grid-template-areas: "header header" "school upgrades" "debug debug";
width: 100vw;
height: 100vh;
overflow: hidden;
// margin: 1rem;
}
* {
-webkit-user-select: none;
-webkit-touch-callout: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.stats {
grid-area: header;
padding: 1rem;
display: flex;
justify-content: space-between;
}
.resources {
white-space: nowrap;
& > * {
display: inline-block;
&:not(:last-child) {
margin-right: 1rem;
}
}
}
.units {
overflow-y: auto;
& > * {
&:not(:last-child) {
margin-bottom: 1rem;
}
&.students {
display: flex;
flex-direction: row;
gap: 1rem;
flex-flow: wrap;
}
}
}
.school {
grid-area: school;
padding: 1rem;
}
.unit {
width: auto;
&.button {
display: inline-block;
padding: 8px 12px;
background: #ffffff40;
border: 2px solid #ffffff;
border-radius: 4px;
&.owner {
background: #ff800040;
border-color: #ff800080;
}
&.student {
background: #0080ff40;
border-color: #0080ff80;
}
& > .cooldown {
background: #ffffff40;
min-height: 8px;
margin-top: 4px;
border-radius: 2px;
}
}
}
.upgrades {
grid-area: upgrades;
display: flex;
flex-direction: column;
padding: 1rem;
gap: 1rem;
overflow-y: auto;
& > .upgrade {
font-size: 60%;
border: 2px solid #ffffff;
background-color: #80ff8080;
border-radius: 4px;
padding: 1rem 8px;
text-align: center;
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
&.closed {
opacity: 0.5;
border-color: transparent;
}
&:hover:not(.disabled) {
background-color: #80ff80c0;
}
& > * {
line-height: 1rem;
}
& > .name {
font-weight: bold;
white-space: nowrap;
}
& > .cost {
font-size: 80%;
}
& > .level {
border: 1px solid #ffffff;
background-color: #408040;
position: absolute;
bottom: -5px;
right: -5px;
padding: 1px 8px;
}
}
}
.debug {
grid-area: debug;
padding: 1rem;
display: flex;
justify-content: space-between;
}

18
src/App.tsx Normal file
View File

@ -0,0 +1,18 @@
import './App.scss'
import { observer } from 'mobx-react-lite'
import { StatsView } from './views/StatsView'
import { SchoolView } from './views/SchoolView'
import { UpgradeListView } from './views/UpgradeListView'
import { DebugView } from './views/DebugView'
export const App = observer(function () {
return (
<div className="App">
<StatsView />
<SchoolView />
<UpgradeListView />
<DebugView />
</div>
)
})

70
src/index.css Normal file
View File

@ -0,0 +1,70 @@
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

13
src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App'
import './index.css'
import { GameClock } from './utils/gameClock'
GameClock.start();
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

24
src/model/calendar.ts Normal file
View File

@ -0,0 +1,24 @@
import { makeAutoObservable } from 'mobx';
import { school } from './school';
import { Progress } from './progress';
export class Calendar {
public day: number = 1;
public progress = new Progress(24, 1, true, () => this.handleProgress());
constructor() {
makeAutoObservable(this);
}
private handleProgress(): boolean {
this.day++;
school.collectStudentGold();
return true;
}
public get dayFraction(): number {
return Math.floor(this.progress.value);
}
}

21
src/model/owner.ts Normal file
View File

@ -0,0 +1,21 @@
import { Progress } from './progress';
import { school } from './school';
import { IUnit } from './unit';
import { makeAutoObservable } from 'mobx';
export class Owner implements IUnit {
public readonly id = 'owner';
public progress = new Progress(2, 1, false, () => this.handleProgress());
constructor() {
makeAutoObservable(this);
}
private handleProgress(): boolean {
school.addReputation(1);
return true;
}
}

44
src/model/progress.ts Normal file
View File

@ -0,0 +1,44 @@
import { makeAutoObservable } from 'mobx';
export class Progress {
public value: number = 0;
constructor(
public length: number,
public speed: number,
public readonly autoRun: boolean,
private callback: () => boolean,
) {
makeAutoObservable(this);
}
public get normalValue(): number {
return this.value / this.length;
}
public get isFull() {
return this.value == this.length;
}
public reset() {
this.value = 0;
}
public tick(time: number): void {
if (this.isFull && this.autoRun) {
this.run();
}
this.value = Math.min(this.length, this.value + time / this.speed);
if (this.autoRun)
this.run();
}
public run(): void {
if (this.isFull) {
if (this.callback())
this.reset();
}
}
}

89
src/model/school.ts Normal file
View File

@ -0,0 +1,89 @@
import { makeAutoObservable } from 'mobx';
import { Owner } from './owner';
import { Student } from './student';
import { IUnit } from './unit';
import { Upgrades } from './upgrades';
import { PartialResourceSet, ResourceSet } from '../types/resources';
import { addResourceSets, fullResourceSet, mapResourceSet, multiplyResourceSet, roundResourceSet, subtractResourceSets } from '../utils/resources';
import { v7 as uuid } from 'uuid';
import { GameClock } from '../utils/gameClock';
import { Calendar } from './calendar';
import { getRandomHumanName } from '../utils/humanNames';
const resourceScale = 100;
export class School {
public timeScale: number = 1;
public tps: number = 0;
public calendar = new Calendar();
// units
public owner: Owner = new Owner();
public students: Array<Student> = new Array<Student>();
public upgrades = new Upgrades();
public scaledResources: ResourceSet = multiplyResourceSet({ reputation: 19, gold: 1000 }, resourceScale);
constructor() {
this.increaseStudentCount();
makeAutoObservable(this);
}
public get resources(): ResourceSet {
return roundResourceSet(multiplyResourceSet(this.scaledResources, 1 / resourceScale), 2);
}
public tick(time: number) {
const scaledTime = time * this.timeScale;
this.calendar.progress.tick(scaledTime);
for (let unit of this.units)
unit.progress.tick(scaledTime);
this.tps = GameClock.tps;
}
public setTimeScale(value: number) {
this.timeScale = value;
}
public get units(): IUnit[] {
return [this.owner, ...this.students]
}
public addResources(value: PartialResourceSet): void {
this.scaledResources = roundResourceSet(
addResourceSets(
this.scaledResources,
multiplyResourceSet(fullResourceSet(value), resourceScale),
),
);
}
public addReputation(reputation: number): void {
this.addResources({ reputation });
}
public addGold(gold: number): void {
this.addResources({ gold });
}
public increaseStudentCount(): void {
this.students.push(new Student(uuid(), getRandomHumanName().join(' ')));
}
public removeResources(resources: ResourceSet) {
this.scaledResources = subtractResourceSets(this.scaledResources, multiplyResourceSet(resources, resourceScale));
}
public collectStudentGold(): void {
this.addGold(this.students.length);
}
}
export const school = new School();

22
src/model/student.ts Normal file
View File

@ -0,0 +1,22 @@
import { Progress } from './progress';
import { school } from './school';
import { IUnit } from './unit';
import { makeAutoObservable } from 'mobx';
export class Student implements IUnit {
public progress = new Progress(10, 1, true, () => this.handleProgress());
constructor(
public readonly id: string,
public readonly name: string,
) {
makeAutoObservable(this);
}
private handleProgress(): boolean {
school.addReputation(0.1);
return true;
}
}

7
src/model/unit.ts Normal file
View File

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

102
src/model/upgrade.ts Normal file
View File

@ -0,0 +1,102 @@
import { UpgradePrefab } 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";
export class Upgrade {
public readonly id: string;
public level: number;
constructor(public readonly prefab: UpgradePrefab) {
this.id = prefab.id;
this.level = 0;
makeAutoObservable(this);
}
get name(): string {
return evalLevelValue(this.prefab.name, this.level);
}
get description(): string {
return evalLevelValue(this.prefab.description, this.level);
}
get costToView(): ResourceSet {
return this.evalCostToView(this.level);
}
get costToOpen(): ResourceSet {
return this.evalCostToOpen(this.level);
}
get costToBuy(): ResourceSet {
return this.evalCostToBuy(this.level);
}
public evalCostToStepView(level: number): ResourceSet {
return fullResourceSet(evalLevelValue(this.prefab.costToView, level));
}
public evalCostToView(level: number): ResourceSet {
let sum = fullResourceSet();
for (let lvl = 0; lvl <= level; lvl++)
sum = addResourceSets(sum, this.evalCostToStepView(lvl));
return sum;
}
public evalCostToStepOpen(level: number): ResourceSet {
return fullResourceSet(evalLevelValue(this.prefab.costToOpen, level));
}
public evalCostToOpen(level: number): ResourceSet {
let sum = fullResourceSet();
for (let lvl = 0; lvl <= level; lvl++)
sum = addResourceSets(sum, this.evalCostToStepOpen(lvl));
return sum;
}
public evalCostToStepBuy(level: number): ResourceSet {
return fullResourceSet(evalLevelValue(this.prefab.costToBuy, level));
}
public evalCostToBuy(level: number): ResourceSet {
return this.evalCostToStepBuy(level);
// let sum = fullResourceSet();
// for (let lvl = 0; lvl <= level; lvl++)
// sum = addResourceSets(sum, this.evalCostToStepBuy(lvl));
// return sum;
}
public increaseLevel(): void {
this.level++;
}
public execute(): void {
let cost = this.costToBuy;
this.prefab.execute(this.level);
school.removeResources(cost);
this.increaseLevel();
}
public isVisible(): boolean {
return isResourceSetGte(school.resources, this.costToView);
}
public isOpen(): boolean {
return isResourceSetGte(school.resources, this.costToOpen);
}
public isAffordable(): boolean {
return isResourceSetGte(school.resources, this.costToBuy);
}
}

18
src/model/upgrades.ts Normal file
View File

@ -0,0 +1,18 @@
import { makeAutoObservable } from 'mobx';
import { UpgradePrefab, upgrades } from '../prefabs/upgrades';
import { Upgrade } from './upgrade';
export class Upgrades {
public readonly allItems: Upgrade[];
constructor() {
this.allItems = upgrades.map((item) => new Upgrade(item));
makeAutoObservable(this);
}
public get visibleItems(): Upgrade[] {
return this.allItems.filter((item) => item.isVisible());
}
}

48
src/prefabs/upgrades.ts Normal file
View File

@ -0,0 +1,48 @@
import { school } from "../model/school";
import { PartialResourceSet, Resource } from "../types/resources";
import { LevelValue, MaybeLevelValue } from "../types/upgrades";
import { fullResourceSet } from "../utils/resources";
export type UpgradePrefab = {
id: string;
maxLevel?: number;
name: MaybeLevelValue<string>;
description: MaybeLevelValue<string>;
costToView: MaybeLevelValue<PartialResourceSet>;
costToOpen: MaybeLevelValue<PartialResourceSet>;
costToBuy: MaybeLevelValue<PartialResourceSet>;
execute: LevelValue<void>;
}
export const upgrades: UpgradePrefab[] = [
{
id: 'moreStudents',
name: "Больше учеников",
description: (level) => `Увеличить количество учеников с ${level} до ${level + 1}`,
costToView: (level) => fullResourceSet(),
costToOpen: (level) => ({ reputation: 20 * (level / 10 + 1) }),
costToBuy: (level) => ({ gold: 10 }),
execute: (level) => {
school.increaseStudentCount();
},
},
{
id: 'fixedCostTest',
name: "fixedCostTest",
description: "fixedCostTest",
costToView: (level) => fullResourceSet(),
costToOpen: fullResourceSet(),
costToBuy: { reputation: 3 },
execute: (level) => { console.log('fixed', level)},
},
{
id: 'runningCostTest',
name: "runningCostTest",
description: (level) => `runningCostTest ${level} => ${level + 1}`,
costToView: (level) => fullResourceSet(),
costToOpen: (level) => ({ gold: 1000 }),
costToBuy: (level) => ({ reputation: level * 2 + 1 }),
execute: (level) => { console.log('running', level)},
}
]

3
src/types/gameClock.ts Normal file
View File

@ -0,0 +1,3 @@
export interface ITick {
tick(deltaTime: number): void;
}

11
src/types/resources.ts Normal file
View File

@ -0,0 +1,11 @@
export const RESOURCES = ['reputation', 'gold'] as const;
export type Resource = (typeof RESOURCES)[number];
export type ResourceSet = Record<Resource, number>;
export type PartialResourceSet = Partial<ResourceSet>;
export const ResourceNames: Record<Resource, string> = {
reputation: 'Репутация',
gold: 'Золото',
}

2
src/types/upgrades.ts Normal file
View File

@ -0,0 +1,2 @@
export type LevelValue<T> = (level: number) => T;
export type MaybeLevelValue<T> = T | LevelValue<T>;

47
src/utils/gameClock.ts Normal file
View File

@ -0,0 +1,47 @@
import { school } from "../model/school";
export class GameClock {
private static frames = new Array<number>();
// public static async start(): Promise<void> {
// let lastTime = performance.now();
// setInterval(() => {
// let now = performance.now();
// let delta = now - lastTime;
// school.tick(delta / 1000);
// lastTime = now;
// }, 33);
// }
public static start(): void {
let lastTime = performance.now();
const loop = (now: number) => {
const delta = now - lastTime;
lastTime = now;
GameClock.frames.push(lastTime);
while (GameClock.frames[0] < lastTime - 1000)
GameClock.frames.shift();
school.tick(delta / 1000);
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
public static get tps(): number {
if (GameClock.frames.length < 2)
return 0;
let timeSpan = GameClock.frames[GameClock.frames.length - 1] - GameClock.frames[0];
return Math.round(GameClock.frames.length / timeSpan * 1000);
}
}

124
src/utils/humanNames.ts Normal file
View File

@ -0,0 +1,124 @@
export const humans: [string, string, 0 | 1][] = [
["Альрик", "Блэкмор", 0],
["Эдгар", "Кроули", 0],
["Мириам", "Фоксгрейв", 1],
["Беатрис", "Уинтерборн", 1],
["Тобиас", "Гримвуд", 0],
["Селеста", "Морвен", 1],
["Персиваль", "Хоторн", 0],
["Люсинда", "Брайтвелл", 1],
["Гектор", "Дрейвен", 0],
["Виолетта", "Муркрофт", 1],
["Руфус", "Кинсбери", 0],
["Офелия", "Дарквуд", 1],
["Амброз", "Фарнли", 0],
["Корделия", "Найтли", 1],
["Игнатиус", "Брамвелл", 0],
["Розалин", "Греймарк", 1],
["Бэзил", "Крофтон", 0],
["Эвелин", "Шадоур", 1],
["Седрик", "Мелроуз", 0],
["Матильда", "Фэйрхолл", 1],
["Кассиус", "Рейвенхарт", 0],
["Дороти", "Вейл", 1],
["Октавиус", "Торнфилд", 0],
["Элеонора", "Блум", 1],
["Фабиан", "Голдкрест", 0],
["Гвендолин", "Брукшир", 1],
["Морис", "Найтвуд", 0],
["Агнес", "Холлоуэй", 1],
["Леонард", "Вестфорд", 0],
["Имоджен", "Квинс", 1],
["Бернард", "Кроуфорд", 0],
["Сибилла", "Хейзелтон", 1],
["Реджинальд", "Сторм", 0],
["Флора", "Эмберлин", 1],
["Годрик", "Айронвуд", 0],
["Кларисса", "Бельмонт", 1],
["Освальд", "Пенрит", 0],
["Лилиан", "Сноу", 1],
["Тристан", "Блэквилл", 0],
["Мейв", "Ферн", 1],
["Эммет", "Фроствейл", 0],
["Виннифред", "Кейл", 1],
["Птолемей", "Грейсон", 0],
["Серафина", "Ларк", 1],
["Роланд", "Киплинг", 0],
["Дафна", "Риверс", 1],
["Алдус", "Брайтвуд", 0],
["Жозефина", "Марш", 1],
["Виктор", "Фарроу", 0],
["Нора", "Хиллкрофт", 1],
["Клеменс", "Вейлкрофт", 0],
["Теодора", "Мист", 1],
["Августин", "Руквуд", 0],
["Белинда", "Грейвс", 1],
["Орион", "Мортлейк", 0],
["Кассандра", "Фэйрчайлд", 1],
["Лоуренс", "Шейд", 0],
["Элиза", "Мерроу", 1],
["Сильвестр", "Брайр", 0],
["Хелена", "Старк", 1],
["Теренс", "Кроссвелл", 0],
["Миранда", "Кроу", 1],
["Джаспер", "Вулф", 0],
["Аделаида", "Найтингейл", 1],
["Горацио", "Блэкворт", 0],
["Ребекка", "Винтер", 1],
["Финеас", "Голдберн", 0],
["Марианна", "Шор", 1],
["Эверетт", "Локвуд", 0],
["Кэтрин", "Фэйрвью", 1],
["Руперт", "Даск", 0],
["Селина", "Дейл", 1],
["Дориан", "Кин", 0],
["Фелисити", "Хартвелл", 1],
["Илайас", "Моркрест", 0],
["Беатриса", "Гринвей", 1],
["Малкольм", "Стрейд", 0],
["Оливия", "Брайтфорд", 1],
["Конрад", "Эшфилд", 0],
["Лавиния", "Вейлвуд", 1],
["Гарольд", "Треверс", 0],
["Пенелопа", "Кроуэлл", 1],
["Сайлас", "Рук", 0],
["Амелия", "Фоксвуд", 1],
["Юлиус", "Дрейк", 0],
["Эстер", "Брайткрофт", 1],
["Натан", "Кобб", 0],
["Розамунд", "Сильвер", 1],
["Арчибальд", "Мур", 0],
["Ирена", "Кастелл", 1],
["Валентин", "Фернвуд", 0],
["Глория", "Сейбл", 1],
["Кристофер", "Даркмор", 0],
["Люсия", "Эмбер", 1],
["Доминик", "Блэкридж", 0],
["София", "Рейн", 1],
["Тадеус", "Фолкнер", 0],
["Изабель", "Винчестер", 1]
];
const humanMales = humans.filter(([, , s]) => s === 0);
const humanFemales = humans.filter(([, , s]) => s === 1);
function randomInRange(min: number, max: number, digits: number = 0): number {
const v = (Math.random() * (max - min)) + min;
return Math.round(v * 10 ** digits) / (10 ** digits);
}
function getRandomHuman(): [string, string, 0 | 1] {
const idx = randomInRange(0, humans.length - 1);
return humans[idx];
}
function getRandomLastName(gender: 0 | 1): string {
const hums = gender ? humanFemales : humanMales;
return hums[randomInRange(0, hums.length - 1)][1];
}
export function getRandomHumanName(): [string, string] {
const human = getRandomHuman();
const gender = human[2];
return [human[0], getRandomLastName(gender)];
}

54
src/utils/resources.ts Normal file
View File

@ -0,0 +1,54 @@
import { PartialResourceSet, RESOURCES, ResourceSet } from "../types/resources";
// not exported to prevent non-cloned usage
// use fullResourceSet() instead
const zeroResourceSet = Object.fromEntries(RESOURCES.map((res) => [res, 0])) as ResourceSet;
export function fullResourceSet(partial: PartialResourceSet = {}): ResourceSet {
return {...zeroResourceSet, ...partial};
}
export function isResourceSetEqual(a: ResourceSet, b: ResourceSet): boolean {
return RESOURCES.every((r) => a[r] == b[r]);
}
export function isResourceSetZero(a: ResourceSet): boolean {
return RESOURCES.every((r) => a[r] == 0);
}
export function isResourceSetGte(a: ResourceSet, b: ResourceSet): boolean {
return RESOURCES.every((r) => a[r] >= b[r]);
}
export function addResourceSets(a: ResourceSet, b: ResourceSet): ResourceSet {
const res = fullResourceSet(a);
let k: keyof ResourceSet;
for (k in b)
res[k] = res[k] + b[k];
return res;
}
export function subtractResourceSets(a: ResourceSet, b: ResourceSet): ResourceSet {
const res = fullResourceSet(a);
let k: keyof ResourceSet;
for (k in b)
res[k] = res[k] - b[k];
return res;
}
export function mapResourceSet(a: ResourceSet, predicate: (v: number) => number): ResourceSet {
const res = fullResourceSet(a);
let k: keyof ResourceSet;
for (k in a)
res[k] = predicate(res[k]);
return res;
}
export function multiplyResourceSet(a: ResourceSet, factor: number): ResourceSet {
return mapResourceSet(a, (v) => v * factor);
}
export function roundResourceSet(a: ResourceSet, digits: number = 0): ResourceSet {
const factor = 10 ** digits;
return mapResourceSet(a, (v) => Math.round(v * factor) / factor);
}

8
src/utils/upgrades.ts Normal file
View File

@ -0,0 +1,8 @@
import { MaybeLevelValue } from "../types/upgrades";
export function evalLevelValue<T>(value: MaybeLevelValue<T>, level: number): T {
if (typeof(value) === 'function')
return (value as (level: number) => T)(level);
else
return value;
}

27
src/views/DebugView.tsx Normal file
View File

@ -0,0 +1,27 @@
import { observer } from "mobx-react-lite";
import { school } from "../model/school";
import { ChangeEvent } from "react";
export const DebugView = observer(function () {
function handleTimeScaleChange(event: ChangeEvent<HTMLInputElement>): void {
school.setTimeScale(Number(event.target.value));
}
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="tps">TPS: {school.tps}</div>
</div>
});

View File

@ -0,0 +1,20 @@
import { observer } from "mobx-react-lite";
import { Owner } from "../model/owner";
import { school } from "../model/school";
type Props = {
unit: Owner;
}
export const OwnerUnitView = observer(function ({ unit }: Props) {
function handleButtonClick(): void {
if (unit.progress.isFull) {
unit.progress.run();
}
}
return <div className="unit button owner" onClick={handleButtonClick}>
<div className="name">Владелец школы</div>
<div className="cooldown" style={{width: `${Math.round(unit.progress.normalValue*100)}%`}}></div>
</div>
});

View File

@ -0,0 +1,22 @@
import { observer } from "mobx-react-lite";
import { PartialResourceSet, ResourceNames } from "../types/resources";
import { ReactNode } from "react";
type Props = {
className?: string;
resources: PartialResourceSet;
partial?: boolean;
prefix?: ReactNode;
}
export const ResourceStringView = observer(function (props: Props) {
return <div className={`${props.className ?? ''} resources`}>{
props.prefix !== undefined
? <>{props.prefix}:{' '}</>
: <></>
}{
Object.entries(props.resources)
.filter((kvp) => !props.partial || kvp[1] != 0)
.map((kvp) => <div key={kvp[0]}>{(ResourceNames as Record<string, string>)[kvp[0]]} {kvp[1]}</div>)
}</div>
});

11
src/views/SchoolView.tsx Normal file
View File

@ -0,0 +1,11 @@
import { UnitListView } from './UnitListView'
import { observer } from 'mobx-react-lite'
export const SchoolView = observer(function () {
return (
<div className="school">
<UnitListView />
</div>
)
})

11
src/views/StatsView.tsx Normal file
View File

@ -0,0 +1,11 @@
import { observer } from "mobx-react-lite";
import { school } from "../model/school";
import { ResourceStringView } from "./ResourceStringView";
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>
</div>
});

View File

@ -0,0 +1,14 @@
import { observer } from "mobx-react-lite";
import { Student } from "../model/student";
type Props = {
unit: Student;
}
export const StudentUnitView = observer(function ({ unit }: Props) {
return <div className="unit button student">
<div className="name">{unit.name ?? 'Ученик'}</div>
<div className="cooldown" style={{width: `${Math.round(unit.progress.normalValue*100)}%`}}></div>
</div>
});

View File

@ -0,0 +1,28 @@
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 { StudentUnitView } from "./StudentUnitView";
export const UnitListView = observer(function () {
function renderUnit(unit: IUnit) {
if (unit instanceof Owner)
return <OwnerUnitView unit={unit} />
else if (unit instanceof Student)
return <StudentUnitView unit={unit} />
else
return '???';
}
return <div className="units">
<div className="owner">
<OwnerUnitView unit={school.owner} />
</div>
<div className="students">
{school.students.map((unit) => <StudentUnitView key={unit.id} unit={unit} />)}
</div>
</div>
});

View File

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

51
src/views/UpgradeView.tsx Normal file
View File

@ -0,0 +1,51 @@
import { observer } from "mobx-react-lite";
import { ResourceStringView } from "./ResourceStringView";
import { Upgrade } from "../model/upgrade";
import { isResourceSetZero } from "../utils/resources";
type Props = {
upgrade: Upgrade;
}
export const UpgradeView = observer(function ({ upgrade }: Props) {
function handleClick(): void {
if (upgrade.isOpen() && upgrade.isAffordable())
upgrade.execute();
}
let classNames = ['upgrade'];
if (!upgrade.isOpen())
classNames.push('closed')
return <div
className={classNames.join(' ')}
onClick={handleClick}
>
<div className="name">{upgrade.name}</div>
{upgrade.level > 0 ? <div className="level">{upgrade.level} ур</div> : <></>}
<div className="description">{upgrade.description}</div>
{
!upgrade.isOpen()
? (
!isResourceSetZero(upgrade.costToOpen)
? <ResourceStringView className="cost" prefix="Открывается на" resources={upgrade.costToOpen} partial={true} />
: <></>
)
: (
!isResourceSetZero(upgrade.costToBuy)
? <ResourceStringView className="cost" prefix="Покупка" resources={upgrade.costToBuy} partial={true} />
: <></>
)
}
{/* <div>Открывается на: <ResourceStringView className="cost" resources={upgrade.costToOpen} partial={true} /></div>
<div>Покупка: <ResourceStringView className="cost" resources={upgrade.costToBuy} partial={true} /></div> */}
{/* {
[...Array(10).keys()].map((lvl: number) => <div>
{lvl}
<ResourceStringView className="cost" resources={upgrade.evalCostToUpgrade(lvl)} partial={true} />
<ResourceStringView className="cost" resources={upgrade.evalCostToStepUpgrade(lvl)} partial={true} />
</div>)
} */}
</div>;
});

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
tsconfig.node.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})