commit 83821b46688c175612fefe95aeabb205396d3d4e Author: Valerie Date: Mon Jun 14 22:03:55 2021 -0400 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7505a02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +out +data +*.log \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6d7f1cf --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "df-idle", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "type": "module", + "dependencies": { + "@types/blessed": "^0.1.17", + "chalk": "^4.1.1", + "faker": "^5.5.3", + "frigid": "^1.3.3", + "logger": "^0.0.1", + "neo-blessed": "^0.2.0", + "printable-characters": "^1.0.42", + "sisteransi": "^1.0.5", + "typescript": "^4.3.2" + }, + "scripts": { + "compile:watch": "tsc --watch", + "start": "node --enable-source-maps out/index.js", + "dev": "supervisor -w out -n exit -t -k --exec yarn -- start" + } +} diff --git a/src/ChopTreeTask.ts b/src/ChopTreeTask.ts new file mode 100644 index 0000000..662eb31 --- /dev/null +++ b/src/ChopTreeTask.ts @@ -0,0 +1,26 @@ +import chalk from 'chalk'; +import { Game } from './Game.js'; +import { Item } from './Item.js'; +import { Pawn } from './Pawn.js'; +import { Task } from './Task.js'; + + +export class ChopTreeTask extends Task { + work = 100; + + reward() { + Game.current.inv.add(Item.LOG, 1); + } + + static serializationDependencies() { + return [Pawn]; + } + + get title() { + return chalk.yellow('Chop Trees'); + } + + get status() { + return chalk.yellow('LOGGING'); + } +} diff --git a/src/Game.ts b/src/Game.ts new file mode 100644 index 0000000..e8350ae --- /dev/null +++ b/src/Game.ts @@ -0,0 +1,78 @@ +import { Frigid, Serializable } from 'frigid'; +import { DEBUG } from 'frigid/out/Serializable.js'; +import { Pawn } from './Pawn.js'; +import { TaskList } from './TaskList.js'; +import { Inventory } from './Inventory.js'; +import { Menu } from './Menu.js'; +import Time, { Tickable } from './Time.js'; +import { render, Renderable } from './UI.js'; +import log from './log.js'; +import { ChopTreeTask } from './ChopTreeTask.js'; +import { Task } from './Task.js'; + +let game = null; + +export class Game extends Frigid implements Tickable, Renderable { + pawns: Pawn[] = []; + selected: Pawn; + inventory: Inventory; + board: TaskList; + menu: Menu; + clock: Time; + + [DEBUG] = true; + + static get current(): Game { + if (!game) throw new Error('Somehow called a game before it existed?'); + return game; + } + + async tick() { + for(const pawn of this.pawns) { + pawn.tick(); + } + render(); + } + + get inv() { return this.inventory; } + + removePawn(pawn: Pawn) { + if(pawn === this.selected) { + if(this.pawns.indexOf(this.selected) === this.pawns.length - 1) this.advanceSelection(-1); + else this.advanceSelection(1); + } + + this.pawns = this.pawns.filter(testPawn => { + return pawn !== testPawn; + }); + } + + advanceSelection(number) { + let index = this.pawns.indexOf(this.selected); + this.selected = this.pawns[Math.min(Math.max(index + number, 0), this.pawns.length - 1)]; + } + + ctor () { + this.pawns ??= []; + this.selected ??= this.pawns[0] || null; + this.menu = new Menu(); + this.board ??= new TaskList(); + this.board.game = this; + this.inventory ??= new Inventory(); + this.clock ??= new Time(); + this.clock.thing = this; + this.clock.start(); + game = this; + render(this); + } + + static serializationDependencies() { + return [Pawn, Inventory, TaskList, Time, ChopTreeTask, Task]; + } + + render() { + log.info('=== [ render ] ==='); + this.menu.render(); + this.board.render(); + } +} \ No newline at end of file diff --git a/src/Inventory.ts b/src/Inventory.ts new file mode 100644 index 0000000..02aaedd --- /dev/null +++ b/src/Inventory.ts @@ -0,0 +1,29 @@ +import { Serializable } from 'frigid'; +import { Item } from './Item.js'; +import { SMap } from './SMap.js'; +import { ItemID } from './index.js'; + +export class Inventory extends Serializable { + items = new SMap(); + + static serializationDependencies() { + return [SMap]; + } + + add(item: Item, qty: number = 1) { + const id = item.id; + this.ditem(id, qty); + } + + remove(item: Item, qty: number = 1) { + const id = item.id; + this.ditem(id, -qty); + } + + ditem(id, n) { + if (this.items.has(id)) + this.items.set(id, this.items.get(id) + n); + else + this.items.set(id, n); + } +} diff --git a/src/Item.ts b/src/Item.ts new file mode 100644 index 0000000..c3301d5 --- /dev/null +++ b/src/Item.ts @@ -0,0 +1,20 @@ +import { Serializable } from 'frigid'; +import { ItemID } from './index.js'; + +// ITEMS SHALL BE SINGULAR +export class Item extends Serializable { + static LOG = new Item().setName("Log").setId('resources:log'); + + name = ''; + id: ItemID = ''; + + setName(name) { + this.name = name; + return this; + } + + setId(id) { + this.id = id; + return this; + } +} diff --git a/src/Menu.ts b/src/Menu.ts new file mode 100644 index 0000000..27b6c4e --- /dev/null +++ b/src/Menu.ts @@ -0,0 +1,79 @@ +import { Pawn } from './Pawn.js'; +import log from './log.js'; +import { screen, menuPanel, render, tags, Renderable } from './UI.js'; +import chalk from 'chalk'; +import { Game } from './Game.js'; +import { Task } from './Task.js'; +import { ChopTreeTask } from './ChopTreeTask.js'; +import { progressbar } from './Progressbar.js'; + +export class Menu implements Renderable { + + constructor() { + screen.on('keypress', (evt, key) => { + log.info('keypress', key); + if (key.full === 'delete') { + Game.current.removePawn(Game.current.selected); + } else if (key.full === 'up') { + Game.current.advanceSelection(-1); + } else if (key.full === 'down') { + Game.current.advanceSelection(1); + } else if (key.full === 'c') { + Game.current.pawns.push(new Pawn()); + Game.current.sync(); + } else if (key.full === 'x') { + let i = 0; + for(const task of Game.current.board.tasks) { + setTimeout(_ => { + Game.current.board.removeTask(task); + }, i * 100); + i ++; + } + Game.current.sync(); + } else if (key.full === 't') { + const job: Task = new ChopTreeTask(); + Game.current.board.addTask(job); + } + // const pawn = new Pawn(); + // Game.current.pawns.push(pawn); + Game.current.sync(); + }); + } + + renderJobs() { + return (`\ + ${chalk.greenBright('t')}: Chop Trees + ${chalk.greenBright('c')}: Create Pawn + ${chalk.greenBright('x')}: Clear Tasks +`); + + } + + topBar() { + const idlers = Game.current.pawns.filter(pawn => pawn.idle); + return ` ${Game.current.clock.toString()}{|}Idle: ${idlers.length} `; + } + + renderPawns() { + return `${ + Game.current.pawns.map(pawn => `${(function() { + const selected = pawn === Game.current.selected; + let str = ''; + if(selected) { + str += ` ${tags.white.fg} ❯ ${pawn.toString()}${tags.reset}{|}${pawn.status} \n`; + str += ` Energy{|}${progressbar(pawn.energy / 100, (menuPanel.width - 4) / 2)} \n`; + } else { + str += ` ${tags.bright}${tags.black.fg} ${pawn.toString()}${tags.reset}{|}${pawn.status} `; + } + return str; + })()}`).join('\n') + }`; + } + + render() { + const width = menuPanel.width - 2; + const hr = chalk.bold.black('━'.repeat(width)); + const content = [this.topBar(), hr, this.renderPawns(), hr, this.renderJobs()].join('\n'); + menuPanel.setContent(content); + } +} diff --git a/src/Pawn.ts b/src/Pawn.ts new file mode 100644 index 0000000..604ad05 --- /dev/null +++ b/src/Pawn.ts @@ -0,0 +1,120 @@ +import { Serializable } from 'frigid'; +import faker from 'faker'; +import chalk from 'chalk'; +import log from './log.js'; +import { Task } from './Task.js'; +import { Tickable } from './Time.js'; +import { ChopTreeTask } from './ChopTreeTask.js'; +import { Game } from './Game.js'; +import { render } from './UI.js'; + +const LABORS = { + CUT_TREE: Symbol('CUT_TREE'), + MINING: Symbol('CUT_TREE'), +} + +const SKILLS = { + PICKAXE: Symbol('PICKAXE'), + HATCHET: Symbol('HATCHET') +} + +// const STATUS = { +// IDLE: Symbol('IDLE') +// } + +const energyScale = 0.1; + +export class Pawn extends Serializable implements Tickable { + name: { + first: string, + last: string + }; + job: Task; + awake: boolean; + + energy: number; + fun: number; + + async tick() { + this.energy -= energyScale; + + if(this.awake === false) { + this.energy += energyScale * 4; + if(this.energy >= 100) { + this.awake = true; + } + } else { + if(this.job) { + this.job.doWork(1, this); + this.energy -= energyScale; + if(this.job?.completed) { + this.stopWorking(); + } + } else { + const inactive = Game.current.board.tasks.filter(task => { + return task.worker === null; + }); + if(inactive.length > 0) { + const task = inactive[0]; + // const task = inactive[Math.floor(Math.random() * inactive.length)]; + this.assignJob(task); + } + } + if(this.energy <= 0) { + this.stopWorking(); + this.awake = false; + } + } + + } + + get idle() { + return !this.job && this.awake; + } + + ctor() { + log.info('Pawn::ctor') + this.name ??= { + first: faker.name.firstName(), + last: faker.name.lastName() + }; + this.awake ??= true; + this.energy ??= 100; + if(this.job?.completed) { + this.stopWorking(); + } + } + + stopWorking() { + if(this.job) { + this.job.stopJob(); + this.job = null; + } + } + + assignJob(task: Task) { + this.job?.stopJob() + this.job = task; + this.job.claim(this); + } + + get status() { + if(this.job) { + return this.job.status; + } else { + return this.awake ? chalk.bold.black('IDLE') : chalk.blue('RESTING') + } + } + + static serializationDependencies() { + return [Task, ChopTreeTask] + } + + toString() { + if(this.name) { + return this.name.first + ' ' + this.name.last; + } else { + return '[Object Pawn]'; + } + } +} diff --git a/src/Progressbar.ts b/src/Progressbar.ts new file mode 100644 index 0000000..d9c2795 --- /dev/null +++ b/src/Progressbar.ts @@ -0,0 +1,12 @@ +import chalk from "chalk"; + +export function progressbar(completion, width, style = chalk.bold.bgRed.green) { + const chars = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; + let str = ''; + for(let i = 0; i < width; i ++) { + const remainder = Math.floor(Math.min(Math.max(0, (completion * width) - i), 1) * 8); + const char = chars[remainder]; + str += style(char); + } + return str; +} \ No newline at end of file diff --git a/src/SMap.ts b/src/SMap.ts new file mode 100644 index 0000000..f870f01 --- /dev/null +++ b/src/SMap.ts @@ -0,0 +1,17 @@ +import { Serializable } from 'frigid'; + +export class SMap extends Serializable { + dict: any = {}; + set(k: K, v: V) { + if (v === undefined) { + delete this.dict[k]; + } + this.dict[k] = v; + } + get(k: K): V { + return this.dict[k]; + } + has(k: K): boolean { + return k in this.dict; + } +} diff --git a/src/Task.ts b/src/Task.ts new file mode 100644 index 0000000..480e0cc --- /dev/null +++ b/src/Task.ts @@ -0,0 +1,66 @@ +import { Serializable } from 'frigid'; +import EventEmitter from 'events'; +import chalk from 'chalk'; +import { Pawn } from './Pawn.js'; +import { render, tasksPanel } from './UI.js'; +import { Game } from './Game.js'; +import { progressbar } from './Progressbar.js'; + +export class Task extends Serializable { + work = 0; + progress = 0; + worker: Pawn; + + ctor() { + this.worker ??= null; + this.testCompletion(); + } + + reward() {} + + get completed() { + return this.completion >= 1; + } + + stopJob() { + this.worker = null; + } + + doWork(work = 1, pawn: Pawn) { + this.worker = pawn; + this.progress += work; + this.testCompletion(); + } + + testCompletion() { + if (this.progress >= this.work) { + this.reward(); + Game.current.board.removeTask(this); + } + } + + claim(pawn: Pawn) { + this.worker = pawn; + } + + get completion() { + return Math.min(1, this.progress / this.work); + } + + toString() { + // HACK magic number 2 here, is the border + // of the panel + const width = tasksPanel.width - 2; + const left = ' ' + this.title + ' ' + (this.worker?.toString() || chalk.bold.black('Queued')); + const bar = width - 2; + return `${left}\n ${progressbar(this.completion, bar)}\n`; + } + + get title() { + return chalk.bgRedBright.black('[Abstract Task]'); + } + + get status() { + return chalk.bgRedBright.black('DOING A TASK'); + } +} \ No newline at end of file diff --git a/src/TaskList.ts b/src/TaskList.ts new file mode 100644 index 0000000..b6118f7 --- /dev/null +++ b/src/TaskList.ts @@ -0,0 +1,30 @@ +import { Serializable } from 'frigid'; +import { ChopTreeTask } from "./ChopTreeTask.js"; +import { Game } from './Game.js'; +import { Task } from "./Task.js"; +import { render, Renderable, tasksPanel } from './UI.js'; + +export class TaskList extends Serializable implements Renderable { + tasks: Task[] = []; + game: Game; + + static serializationDependencies() { + return [ChopTreeTask, Task]; + } + + addTask(task) { + this.tasks = [...this.tasks, task]; + } + + removeTask(task) { + this.tasks = this.tasks.filter(v => v !== task); + } + + render() { + // const width = tasksPanel.width; + tasksPanel.setContent(`${this.tasks.map(task => { + return task.toString(); + }).join('\n')}`); + // return this.tasks.map(task => task.toString()).join('\n'); + } +} diff --git a/src/Time.ts b/src/Time.ts new file mode 100644 index 0000000..1e246ca --- /dev/null +++ b/src/Time.ts @@ -0,0 +1,83 @@ +import { Serializable } from "frigid"; +import log from "./log.js"; + +const daysInMonth = [ + 31, 28, 31, + 30, 31, 30, + 31, 31, 30, + 31, 30, 31 +]; + +const months = [ + 'Jan', 'Feb', 'Mar', + 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', + 'Oct', 'Nov', 'Dec' +] + +export default class Time extends Serializable{ + rate: number; + + thing: Tickable; + + year: number; + month: number; + day: number; + hour: number; + minute: number; + + toString() { + return `${this.hour}:${this.minute.toString().padStart(2, '0')} ${this.year}-${this.month}-${this.day}` + // return '☾' || '☼'; + } + + ctor() { + this.rate ??= 10; + this.minute ??= 0; + this.hour ??= 0; + this.day ??= 0; + this.month ??= 0; + this.year ??= 0; + } + + start() { + setTimeout(this.doTick.bind(this), 0); + } + + advanceTime(minutes) { + this.minute ++; + while(this.minute >= 60) { + this.minute -= 60; + this.hour ++; + } + while(this.hour >= 24) { + this.hour -= 24; + this.day ++; + } + while(this.day >= daysInMonth[this.month]) { + this.day -= daysInMonth[this.month]; + this.month ++; + } + while(this.month >= 12) { + this.month -= 12; + this.year ++; + } + } + + async doTick() { + this.advanceTime(1); + const timeout = 1000 / this.rate; + const start = new Date().getTime(); + if(this.thing) { + await this.thing.tick(); + } + const elapsed = new Date().getTime() - start; + const wait = Math.max(timeout - elapsed, 0); + setTimeout(this.doTick.bind(this), wait) + } +} + + +export interface Tickable { + tick: () => Promise +} \ No newline at end of file diff --git a/src/UI.ts b/src/UI.ts new file mode 100644 index 0000000..b6ad281 --- /dev/null +++ b/src/UI.ts @@ -0,0 +1,85 @@ + +import blessed from 'neo-blessed'; +import ansi from 'sisteransi'; + +export const screen = blessed.screen({ + smartCSR: true +}); + +export interface Renderable { + render: () => void +} + +const fg = (color) => '{' + color + '-fg}'; +const bg = (color) => '{' + color + '-bg}'; +const color = (color) => { return { fg: fg(color), bg: bg(color) } } + +export const tags = { + black: color('black'), + red: color('red'), + green: color('green'), + yellow: color('yellow'), + blue: color('blue'), + magenta: color('magenta'), + cyan: color('cyan'), + white: color('white'), + reset: '{/}', + bright: '{bold}' +} + +let currentRenderable = null; +export function render(thing?: Renderable) { + if(!!thing) currentRenderable = thing; + currentRenderable.render(); + screen.render(); +} + +export const tasksPanel = blessed.box({ + top: 1, + left: 0, + width: '50%+1', + height: '100%-1', + border: { + type: "line" + }, + style: { + border: { + } + }, + tags: true +}); + +export const menuPanel = blessed.box({ + top: 1, + left: '50%+1', + width: '50%', + height: '100%-1', + border: { + type: "line" + }, + style: { + border: { + } + }, + tags: true +}); + +const titleBar = blessed.box({ + top: 0, + left: 0, + width: '100%', + height: 1, + tags: true, + content: ' Colony Manager Sim{|}{bold}{black-fg}v0.1.0 {/}' +}) + +screen.append(tasksPanel); +screen.append(menuPanel); +screen.append(titleBar); + +process.stdout.write(ansi.cursor.hide); + +screen.key(['C-c'], function(ch, key) { + process.stdout.write(ansi.cursor.show); + return process.exit(0); +}); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..13e96e0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,7 @@ +import { Game } from './Game.js'; +import { render } from './UI.js'; + +export type ItemID = string; + +const game = Game.create('data/world01.json'); +render(game); diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..9641e7b --- /dev/null +++ b/src/log.ts @@ -0,0 +1,15 @@ +import logger from 'logger'; + +const log: { + fatal: (...args) => void, + error: (...args) => void, + warn: (...args) => void, + info: (...args) => void, + debug: (...args) => void, +} = logger.createLogger('debug.log'); + +(log as any).format = function(level, date, message) { + return `${date.getTime()} [${level}]${message}`; +}; + +export default log; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..64a23ae --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true, + "outDir": "out", + "declaration": true, + "sourceMap": true + }, + "include": [ + "src/**/*.ts" + ] +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..0fbc73e --- /dev/null +++ b/yarn.lock @@ -0,0 +1,74 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/blessed@^0.1.17": + version "0.1.17" + resolved "https://registry.npmjs.org/@types/blessed/-/blessed-0.1.17.tgz" + dependencies: + "@types/node" "*" + +"@types/node@*": + version "15.12.2" + resolved "https://registry.npmjs.org/@types/node/-/node-15.12.2.tgz" + +ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" + dependencies: + color-convert "^2.0.1" + +chalk@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz" + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" + +faker@^5.5.3: + version "5.5.3" + resolved "https://registry.npmjs.org/faker/-/faker-5.5.3.tgz" + +frigid@^1.3.3: + version "1.3.5" + resolved "https://registry.yarnpkg.com/frigid/-/frigid-1.3.5.tgz#8712a349061b3f816758b45bc317d5f7c0b8aef0" + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" + +logger@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/logger/-/logger-0.0.1.tgz#cb08171f8a6f6f674b8499dadf50bed4befb72c4" + +neo-blessed@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/neo-blessed/-/neo-blessed-0.2.0.tgz#30f9495fdd104494402b62c6273a9c9b82de4f2b" + +printable-characters@^1.0.42: + version "1.0.42" + resolved "https://registry.yarnpkg.com/printable-characters/-/printable-characters-1.0.42.tgz#3f18e977a9bd8eb37fcc4ff5659d7be90868b3d8" + +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz" + dependencies: + has-flag "^4.0.0" + +typescript@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805"