diff --git a/package.json b/package.json index 97cfaa8..ffa6d61 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "chalk": "^4.1.1", "faker": "^5.5.3", "frigid": "^1.3.8", + "fs-extra": "^10.0.0", "get-port": "^5.1.1", "logger": "^0.0.1", "neo-blessed": "^0.2.0", diff --git a/src/Item.ts b/src/Item.ts index f68b381..58859d3 100644 --- a/src/Item.ts +++ b/src/Item.ts @@ -1,5 +1,6 @@ import { Serializable } from 'frigid'; -import { Renderable } from './ui/UI'; +import { getTheme } from './ui/Theme.js'; +import { Renderable } from './ui/UI.js'; export type ItemID = string; @@ -41,6 +42,6 @@ export class ItemState extends Serializable implements Renderable { } render() { - return ` ${this.item.name}{|}${this.qty} `; + return getTheme().normal(` ${this.item.name}{|}${this.qty} `); } } \ No newline at end of file diff --git a/src/Progressbar.ts b/src/Progressbar.ts index dcac833..ae4d720 100644 --- a/src/Progressbar.ts +++ b/src/Progressbar.ts @@ -1,19 +1,27 @@ import chalk from "chalk"; +import { getTheme } from "./ui/Theme.js"; export enum ProgressbarStyle { indicator = 'indicator', progress = 'progress' } -export function progressbar(completion, width, style: ProgressbarStyle = ProgressbarStyle.indicator) { +export const barCache: Map = new Map(); + +export function progressbar(completion: number, width: number, style: ProgressbarStyle = ProgressbarStyle.indicator) { + const cacheKey = `${completion}-${width}-${style}`; + if(barCache.has(cacheKey)) { + stats.cacheHits ++; + return barCache.get(cacheKey); + } let chalkFn if(style === ProgressbarStyle.indicator) { - if(completion > 0.8) chalkFn = chalk.bgBlue.cyan; - else if(completion > 0.5) chalkFn = chalk.bgBlue.green; - else if(completion > 0.2) chalkFn = chalk.bgBlue.yellow; - else chalkFn = chalk.bgBlue.red; + if(completion > getTheme().progressBar.indicator.buckets[2]) chalkFn = getTheme().progressBar.indicator.excellent; + else if(completion > getTheme().progressBar.indicator.buckets[1]) chalkFn = getTheme().progressBar.indicator.normal; + else if(completion > getTheme().progressBar.indicator.buckets[0]) chalkFn = getTheme().progressBar.indicator.warning; + else chalkFn = getTheme().progressBar.indicator.critical; } else if(style === ProgressbarStyle.progress) { - chalkFn = chalk.bgBlue.cyan; + chalkFn = getTheme().progressBar.normal; } const chars = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; let str = ''; @@ -22,5 +30,12 @@ export function progressbar(completion, width, style: ProgressbarStyle = Progres const char = chars[remainder]; str += chalkFn(char); } + stats.cacheMisses ++; + barCache.set(cacheKey, str); return str; +} + +export const stats = { + cacheHits: 0, + cacheMisses: 0 } \ No newline at end of file diff --git a/src/Time.ts b/src/Time.ts index 3e6e664..38be5bc 100644 --- a/src/Time.ts +++ b/src/Time.ts @@ -2,6 +2,7 @@ import chalk from "chalk"; import { Serializable } from "frigid"; import { isThisTypeNode } from "typescript"; import log from "./log.js"; +import { getTheme } from "./ui/Theme.js"; import { Renderable } from "./ui/UI.js"; const daysInMonth = [ @@ -18,7 +19,7 @@ const months = [ 'Oct', 'Nov', 'Dec' ] -export default class Time extends Serializable implements Renderable{ +export default class Time extends Serializable implements Renderable { rate: number; paused = true; @@ -56,10 +57,22 @@ export default class Time extends Serializable implements Renderable{ render() { const sym = (this.hour >= 6 && this.hour < 20) ? - chalk.yellowBright('☼') : - chalk.blue('☾') + chalk.ansi256(226).bgAnsi256(27)(' ⬤ ') : + chalk.ansi256(254).bgAnsi256(17)(' ☾ ') - return `${sym} ${this.hour.toString().padStart(2, ' ')}:${this.minute.toString().padStart(2, '0')} ${months[this.month]} ${this.day + 1}, ${this.normalizedYear}` + return `${sym} ${ + getTheme().normal(`${ + this.hour.toString().padStart(2, ' ') + }:${ + this.minute.toString().padStart(2, '0') + } ${ + months[this.month] + } ${ + this.day + 1 + }, ${ + this.normalizedYear + }`) + }`; // return '☾' || '☼'; } diff --git a/src/index.ts b/src/index.ts index c3cb857..7f8644c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,11 @@ import { Game } from './Game.js'; import { render } from './ui/UI.js'; +import { ensureDirSync } from 'fs-extra'; +import { parse } from 'path'; -const saveFile = process.argv[2]; +const saveFile = process.argv[2] || 'data/world01.json'; -const game = Game.create(saveFile || 'data/world01.json'); +ensureDirSync(parse(saveFile).dir); + +const game = Game.create(saveFile); render(game); diff --git a/src/multiplayer/mDNS.ts b/src/multiplayer/mDNS.ts index 21a6323..884cb30 100644 --- a/src/multiplayer/mDNS.ts +++ b/src/multiplayer/mDNS.ts @@ -54,7 +54,7 @@ export async function ready(name, onThing?) { }) pawns.push(pawn); } - new Popup(`${(() => { + Popup.show(`${(() => { if(pawns.length === 0) return `A care package has arrived from ${from}.`; if(pawns.length === 1) return `A traveler from ${from} named ${pawns[0].toString()} has arrived.`; if(pawns.length > 1) return `A caravan of ${pawns.length} people from ${from} has arrived.` diff --git a/src/ui/GiftPopup.ts b/src/ui/GiftPopup.ts index db385cb..4baeaf0 100644 --- a/src/ui/GiftPopup.ts +++ b/src/ui/GiftPopup.ts @@ -4,6 +4,7 @@ import { Game } from '../Game.js'; import { ItemState } from '../Item.js'; import { Player } from "../multiplayer/Player"; import { Pawn } from '../Pawn.js'; +import { getTheme } from './Theme.js'; import { boxStyle, screen } from './UI.js'; export class GiftPopup { @@ -68,7 +69,7 @@ export class GiftPopup { this.box.setContent(`${(() => { let pawns = []; for (const [pawn, qty] of this.pawns.entries()) { - const style = i === this.selected ? chalk.underline : _ => _; + const style = i === this.selected ? getTheme().selected : getTheme().normal; if(qty > 0) { pawns.push(style(`{|}${pawn.toString()} `)) } else { @@ -78,7 +79,7 @@ export class GiftPopup { i ++; } return pawns.join('\n') - })()}\n\n{|}${chalk.green('escape')}: Cancel \n{|}${chalk.green('enter')}: Okay `); + })()}\n\n{|}${getTheme().hotkey('escape')}${getTheme().normal(': Cancel ')}\n{|}${getTheme().hotkey('enter')}${getTheme().normal(': Okay ')}`); screen.render(); } } \ No newline at end of file diff --git a/src/ui/Menu.ts b/src/ui/Menu.ts index d5059b1..f900fbf 100644 --- a/src/ui/Menu.ts +++ b/src/ui/Menu.ts @@ -1,14 +1,15 @@ import { Pawn } from '../Pawn.js'; import log from '../log.js'; -import { menuPanel, tags, Renderable } from './UI.js'; -import chalk from 'chalk'; +import { menuPanel, Renderable } from './UI.js'; import { Game } from '../Game.js'; import { ChopTreeTask } from '../tasks/ChopTreeTask.js'; -import { progressbar } from '../Progressbar.js'; +import { progressbar, stats, barCache } from '../Progressbar.js'; import { Popup } from './Popup.js'; import mdns from '../multiplayer/mDNS.js'; import { GiftPopup } from './GiftPopup.js'; import { PawnDetails } from './PawnDetails.js'; +import { defaultTheme, getTheme } from './Theme.js'; +import { inspect } from 'util'; enum SubMenu { NONE = 'NONE', @@ -38,9 +39,9 @@ export class Menu implements Renderable { } else if (key.full === 'q') { this.subMenu = SubMenu.TREES; } else if (key.full === '1') { - new Popup('this is a test!'); + Popup.show(inspect(stats)); } else if (key.full === '2') { - new Popup('Etiam hendrerit elit sit amet metus congue dictum nec eu lacus. Sed aliquam in justo efficitur faucibus. Duis tellus diam, congue volutpat lorem et, semper consectetur erat. Nunc ac velit dignissim, tincidunt augue eget, tristique orci. Duis lacus sapien, bibendum id pharetra vel, semper et nunc. Vestibulum eu tellus imperdiet, lacinia ante ac, porta nisl. Donec at eleifend risus, ac dictum odio.'); + Popup.show('Etiam hendrerit elit sit amet metus congue dictum nec eu lacus. Sed aliquam in justo efficitur faucibus. Duis tellus diam, congue volutpat lorem et, semper consectetur erat. Nunc ac velit dignissim, tincidunt augue eget, tristique orci. Duis lacus sapien, bibendum id pharetra vel, semper et nunc. Vestibulum eu tellus imperdiet, lacinia ante ac, porta nisl. Donec at eleifend risus, ac dictum odio.'); } else if (key.full === 'escape') { this.subMenu = SubMenu.NONE; } @@ -103,29 +104,30 @@ export class Menu implements Renderable { const colSpace = ((menuPanel.width - 2) / 2); - return (` Menus:${' '.repeat(colSpace - 8)}Actions:\n ${ - chalk.greenBright('q') - }: ${ - (this.subMenu !== SubMenu.TREES ? chalk.bold.black : _ => _)('Chop Trees') + return (` ${getTheme().header('Menus')}${getTheme().normal(':')}${ + ' '.repeat(colSpace - 8) + }${getTheme().header('Actions')}${getTheme().normal(':')}\n ${ + getTheme().hotkey('q') + }${getTheme().normal(': ')}${ + (this.subMenu !== SubMenu.TREES ? getTheme().normal : getTheme().selected)('Chop Trees') }${ ' '.repeat(colSpace - 15) - }${chalk.greenBright('z')}: ${ - (this.subMenu !== SubMenu.NONE ? chalk.bold.black : _ => _)('Create Pawn') + }${getTheme().hotkey('z')}${getTheme().normal(': ')}${ + (this.subMenu !== SubMenu.NONE ? getTheme().normal : getTheme().selected)('Create Pawn') }\n${ ' '.repeat(colSpace) }${ - chalk.greenBright('x') - }: ${ - (this.subMenu !== SubMenu.NONE ? chalk.bold.black : _ => _)('Clear Tasks') - }\ -`); + getTheme().hotkey('x') + }${getTheme().normal(': ')}${ + (this.subMenu !== SubMenu.NONE ? getTheme().normal : getTheme().selected)('Clear Tasks') + }`); } renderTopBar() { const idlers = Game.current.pawns.filter(pawn => pawn.idle); - return ` ${Game.current.clock.render()}{|}Idle: ${idlers.length} `; + return ` ${Game.current.clock.render()}{|}${getTheme().normal(`Idle: ${idlers.length}`)} `; } renderPawns() { @@ -134,10 +136,10 @@ export class Menu implements Renderable { 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`; + str += ` ${getTheme().selected(` ❯ ${pawn.toString()}`)}{|}${pawn.status} \n`; + str += ` ${getTheme().normal('Energy')}{|}${progressbar(pawn.energy / 100, (menuPanel.width - 4) / 2)} \n`; } else { - str += ` ${tags.bright}${tags.black.fg} ${pawn.toString()}${tags.reset}{|}${pawn.status} `; + str += ` ${getTheme().normal(pawn.toString())}{|}${pawn.status} `; } return str; })()}`).join('\n') @@ -152,9 +154,9 @@ export class Menu implements Renderable { }${(() => { return Object.values(View).map(view => { if(view === this.view) { - return chalk.cyan.inverse(` ${view} `); + return getTheme().tab.selected(` ${view} `); } else { - return chalk.cyan(` ${view} `); + return getTheme().tab.normal(` ${view} `); } }).join(''); })()}{/center}\n\n${(() => { @@ -169,10 +171,10 @@ export class Menu implements Renderable { multiplayerSelected = 0; renderMultiplayer() { - if(mdns.players.length === 0) return `{center}${tags.bright}${tags.black.fg}No friends online{/center}`; + if(mdns.players.length === 0) return `{center}${getTheme().normal('No friends online')}{/center}`; return mdns.players.map((player, i) => { - if(i === this.multiplayerSelected) return ' ❯ ' + player.toString(); - else return ' ' + chalk.bold.black(player.toString()); + if(i === this.multiplayerSelected) return ' ' + getTheme().selected(' ❯ ' + player.toString()); + else return ' ' + getTheme().normal(player.toString()); }).join('\n'); } @@ -184,7 +186,7 @@ export class Menu implements Renderable { return `${(() => { switch(this.subMenu) { case SubMenu.NONE: - return `{center}${tags.bright}${tags.black.fg}* Select a menu above for options *`; + return `{center}${getTheme().normal('* Select a menu above for options *')}{/center}`; case SubMenu.TREES: return this.renderTreesSubMenu(); } @@ -194,15 +196,15 @@ export class Menu implements Renderable { renderTreesSubMenu() { return [ `{center}Chop Trees`, - `{left} ${chalk.greenBright('-=_+')}: ${this.trees}`, - `{left} ${chalk.greenBright('enter')}: Create Task`, - `{left} ${chalk.greenBright('escape')}: Cancel` + `{left} ${getTheme().hotkey('-=_+')}: ${this.trees}`, + `{left} ${getTheme().hotkey('enter')}: Create Task`, + `{left} ${getTheme().hotkey('escape')}: Cancel` ].join('\n'); } render() { const width = menuPanel.width - 2; - const hr = chalk.bold.black('━'.repeat(width)); + const hr = getTheme().normal('━'.repeat(width)); const content = [ this.renderTopBar(), hr, diff --git a/src/ui/Popup.ts b/src/ui/Popup.ts index cfd2418..2e2c4d0 100644 --- a/src/ui/Popup.ts +++ b/src/ui/Popup.ts @@ -1,18 +1,23 @@ import chalk from 'chalk'; import blessed from 'neo-blessed'; import { Game } from '../Game.js'; +import { getTheme } from './Theme.js'; import { boxStyle, screen } from './UI.js'; export class Popup { box; - constructor(content) { + static show(content) { + new Popup(content) + } + + private constructor(content) { this.box = blessed.box({ top: 'center', left: 'center', width: 'shrink', height: 'shrink', - content: content + `\n\n{|}` + chalk.green('enter') + `: Okay `, + content: getTheme().normal(content) + `\n\n{|}` + getTheme().hotkey('enter') + getTheme().normal(`: Okay `), tags: true, ...boxStyle(), }); diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts new file mode 100644 index 0000000..6252cfc --- /dev/null +++ b/src/ui/Theme.ts @@ -0,0 +1,77 @@ +import chalk from "chalk"; + +type StyleFunction = (text: string) => string; + +export type Theme = { + header: StyleFunction, + subheader: StyleFunction, + normal: StyleFunction, + selected: StyleFunction, + hotkey: StyleFunction, + tab: { + normal: StyleFunction, + selected: StyleFunction + }, + border: { + focused: string, + normal: string + }, + progressBar: { + indicator: { + critical: StyleFunction, + warning: StyleFunction, + normal: StyleFunction, + excellent: StyleFunction, + buckets: [number, number, number] + }, + normal: StyleFunction + } +} + +export const defaultTheme: Theme = { + header: chalk.ansi256(255).bold, + subheader: chalk.ansi256(243).bold, + normal: chalk.ansi256(243), + selected: chalk.ansi256(250), + hotkey: chalk.ansi256(40), + tab: { + normal: chalk.ansi256(117).bgAnsi256(232), + selected: chalk.ansi256(232).bgAnsi256(117) + }, + border: { + focused: '#ffffff', + normal: '#222222' + }, + progressBar: { + indicator: { + critical: chalk.bgAnsi256(235).ansi256(88), + warning: chalk.bgAnsi256(235).ansi256(202), + normal: chalk.bgAnsi256(235).ansi256(70), + excellent: chalk.bgAnsi256(235).ansi256(87), + buckets: [.1, .25, .95] + }, + normal: chalk.bgAnsi256(235).ansi256(243) + } +} + +const debugStyle = chalk.ansi256(213); +export const debugTheme: Theme = { + header: debugStyle.inverse, + subheader: debugStyle, + normal: debugStyle, + selected: debugStyle.inverse, + hotkey: debugStyle, + tab: { + normal: debugStyle, + selected: debugStyle.inverse, + }, + border: { + focused: '#ff88ff', + normal: '#ff00ff' + }, + progressBar: defaultTheme.progressBar +} + +export function getTheme(): Theme { + return defaultTheme; +} \ No newline at end of file diff --git a/src/ui/UI.ts b/src/ui/UI.ts index 5384060..d7d374b 100644 --- a/src/ui/UI.ts +++ b/src/ui/UI.ts @@ -1,41 +1,26 @@ import blessed from 'neo-blessed'; import ansi from 'sisteransi'; +import { getTheme } from './Theme.js'; export const screen = blessed.screen({ - smartCSR: true + smartCSR: true, + terminal: 'xterm-256color' }); 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}' -}; - export const boxStyle = () => { return { style: { border: { - fg: 'white' + fg: getTheme().border.normal }, focus: { border: { - fg: 'cyan' + fg: getTheme().border.focused } } }, @@ -79,7 +64,7 @@ const titleBar = blessed.box({ }); export function setTitle(title) { - titleBar.setContent(` ${title}{|}{bold}{black-fg}v0.1.0 {/}`); + titleBar.setContent(` ${getTheme().header(title)}{|}${getTheme().subheader('v0.1.0')} {/}`); } setTitle(''); @@ -94,7 +79,9 @@ process.stdout.write(ansi.cursor.hide); screen.key(['C-c'], function(ch, key) { process.stdout.write(ansi.cursor.show); - return process.exit(0); + setTimeout(_ => { + process.exit(0); + }) }); tasksPanel.key('f2', () => { diff --git a/yarn.lock b/yarn.lock index b6e9099..da32f0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -118,6 +118,15 @@ frigid@^1.3.8: resolved "https://registry.yarnpkg.com/frigid/-/frigid-1.3.8.tgz#a16919821e5426344bc98d301099f7631d2bae8a" integrity sha512-i3HgB/5hQsALyumWoRlBvEpAXfTmM3Xw+Ica6E9mTASUVYtqZQ8mzUX8/3zscTUM4bCKhSa7MSvXl9L7pt5ICg== +fs-extra@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.0.0.tgz#9ff61b655dde53fb34a82df84bb214ce802e17c1" + integrity sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -134,6 +143,11 @@ get-port@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.6" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" + integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== + has-flag@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" @@ -169,6 +183,15 @@ is-regex@^1.0.4: call-bind "^1.0.2" has-symbols "^1.0.2" +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + logger@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/logger/-/logger-0.0.1.tgz#cb08171f8a6f6f674b8499dadf50bed4befb72c4" @@ -232,6 +255,11 @@ typescript@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805" +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"