Merge pull request #7 from evanpelle/economic-system

Economic system
This commit is contained in:
evanpelle
2024-11-07 19:00:20 -08:00
committed by GitHub
7 changed files with 220 additions and 68 deletions
+167 -47
View File
@@ -10,10 +10,10 @@ import { SendSetTargetTroopRatioEvent } from '../../Transport';
@customElement('control-panel') @customElement('control-panel')
export class ControlPanel extends LitElement implements Layer { export class ControlPanel extends LitElement implements Layer {
private game: Game private game: Game;
public clientID: ClientID public clientID: ClientID;
public eventBus: EventBus public eventBus: EventBus;
public uiState: UIState public uiState: UIState;
@state() @state()
private attackRatio: number = .2; private attackRatio: number = .2;
@@ -21,6 +21,9 @@ export class ControlPanel extends LitElement implements Layer {
@state() @state()
private targetTroopRatio = 1; private targetTroopRatio = 1;
@state()
private currentTroopRatio = 1;
@state() @state()
private _population: number; private _population: number;
@@ -43,40 +46,42 @@ export class ControlPanel extends LitElement implements Layer {
private _manpower: number = 0; private _manpower: number = 0;
@state() @state()
private _gold: number private _gold: number;
@state() @state()
private _goldPerSecond: number private _goldPerSecond: number;
init(game: Game) { init(game: Game) {
this.game = game; this.game = game;
this.attackRatio = .20 this.attackRatio = .20;
this.uiState.attackRatio = this.attackRatio this.uiState.attackRatio = this.attackRatio;
this.currentTroopRatio = this.targetTroopRatio;
} }
tick() { tick() {
// Update game state based on numTroops value if needed
if (!this._isVisible && !this.game.inSpawnPhase()) { if (!this._isVisible && !this.game.inSpawnPhase()) {
this.toggleVisibility(); this.setVisibile(true);
} }
const player = this.game.playerByClientID(this.clientID) const player = this.game.playerByClientID(this.clientID);
if (player == null) { if (player == null || !player.isAlive()) {
this._isVisible = false this.setVisibile(false);
return return;
} }
this._population = player.population()
this._maxPopulation = this.game.config().maxPopulation(player) this._population = player.population();
this._gold = player.gold() this._maxPopulation = this.game.config().maxPopulation(player);
this._troops = player.troops() this._gold = player.gold();
this._workers = player.workers() this._troops = player.troops();
this.popRate = this.game.config().populationIncreaseRate(player) * 10 this._workers = player.workers();
this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10 this.popRate = this.game.config().populationIncreaseRate(player) * 10;
this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10;
this.currentTroopRatio = player.troops() / player.population();
} }
onAttackRatioChange(newRatio: number) { onAttackRatioChange(newRatio: number) {
this.uiState.attackRatio = newRatio this.uiState.attackRatio = newRatio;
} }
renderLayer(context: CanvasRenderingContext2D) { renderLayer(context: CanvasRenderingContext2D) {
@@ -87,32 +92,29 @@ export class ControlPanel extends LitElement implements Layer {
return false; return false;
} }
toggleVisibility() { setVisibile(visible: boolean) {
this._isVisible = !this._isVisible; this._isVisible = visible;
this.requestUpdate(); this.requestUpdate();
} }
targetTroops(): number { targetTroops(): number {
return this._manpower * this.targetTroopRatio return this._manpower * this.targetTroopRatio;
} }
onTroopChange(newRatio: number) { onTroopChange(newRatio: number) {
this.eventBus.emit(new SendSetTargetTroopRatioEvent(newRatio)) this.eventBus.emit(new SendSetTargetTroopRatioEvent(newRatio));
} }
delta(): number { delta(): number {
const d = this._population - this.targetTroops() const d = this._population - this.targetTroops();
// if (Math.abs(d) < this._manpower / 200) { return d;
// return 0
// }
return d
} }
static styles = css` static styles = css`
:host { :host {
display: block; display: block;
} }
.control-panel { .control-panel {
position: fixed; position: fixed;
bottom: 10px; bottom: 10px;
@@ -126,13 +128,62 @@ export class ControlPanel extends LitElement implements Layer {
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out; transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
} }
.hidden { .hidden {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
} }
.slider-container { .slider-container {
position: relative;
margin-bottom: 15px; margin-bottom: 15px;
height: 48px;
} }
.slider-track {
position: absolute;
width: 100%;
height: 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
top: 20px;
}
.slider-fill {
position: absolute;
height: 8px;
background: rgba(0, 150, 255, 0.6);
border-radius: 4px;
top: 20px;
transition: width 0.3s ease-out;
}
.slider-thumb {
position: absolute;
width: 16px;
height: 16px;
background: white;
border: 2px solid rgb(0, 150, 255);
border-radius: 50%;
top: 16px;
transform: translateX(-50%);
cursor: pointer;
transition: transform 0.1s ease;
}
.slider-thumb:hover {
transform: translateX(-50%) scale(1.1);
}
input[type="range"] {
position: absolute;
width: 100%;
top: 12px;
margin: 0;
opacity: 0;
cursor: pointer;
}
.control-panel-info { .control-panel-info {
color: white; color: white;
margin-bottom: 15px; margin-bottom: 15px;
@@ -140,26 +191,77 @@ export class ControlPanel extends LitElement implements Layer {
background-color: rgba(0, 0, 0, 0.3); background-color: rgba(0, 0, 0, 0.3);
border-radius: 5px; border-radius: 5px;
} }
.info-row { .info-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 5px; margin-bottom: 5px;
} }
.info-label { .info-label {
font-weight: bold; font-weight: bold;
} }
label { label {
display: block; display: block;
color: white; color: white;
margin-bottom: 5px; margin-bottom: 5px;
} }
input[type="range"] {
width: 100%;
}
.slider-value { .slider-value {
color: white; color: white;
text-align: right; text-align: right;
} }
.attack-slider {
position: relative;
margin-bottom: 15px;
height: 48px;
}
.attack-slider .slider-track {
position: absolute;
width: 100%;
height: 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
top: 20px;
}
.attack-slider .slider-fill {
position: absolute;
height: 8px;
background: rgba(255, 0, 0, 0.6);
border-radius: 4px;
top: 20px;
transition: width 0.3s ease-out;
}
.attack-slider .slider-thumb {
position: absolute;
width: 16px;
height: 16px;
background: white;
border: 2px solid rgb(255, 0, 0);
border-radius: 50%;
top: 16px;
transform: translateX(-50%);
cursor: pointer;
transition: transform 0.1s ease;
}
.attack-slider .slider-thumb:hover {
transform: translateX(-50%) scale(1.1);
}
.attack-slider input[type="range"] {
position: absolute;
width: 100%;
top: 12px;
margin: 0;
opacity: 0;
cursor: pointer;
}
`; `;
render() { render() {
@@ -183,21 +285,39 @@ export class ControlPanel extends LitElement implements Layer {
<span>${renderNumber(this._goldPerSecond)}</span> <span>${renderNumber(this._goldPerSecond)}</span>
</div> </div>
</div> </div>
<div class="slider-container"> <div class="slider-container">
<label for="numTroops">Troops: ${renderTroops(this._troops)} | Workers: ${renderTroops(this._workers)}</label> <label>Troops: ${renderTroops(this._troops)} | Workers: ${renderTroops(this._workers)}</label>
<input type="range" id="numTroops" min="1" max="10" .value=${this.targetTroopRatio * 10} <div class="slider-track"></div>
@input=${(e: Event) => { <div class="slider-fill" style="width: ${this.currentTroopRatio * 100}%"></div>
this.targetTroopRatio = parseInt((e.target as HTMLInputElement).value) / 10; <div class="slider-thumb" style="left: ${this.targetTroopRatio * 100}%"></div>
<input
type="range"
min="1"
max="100"
.value=${this.targetTroopRatio * 100}
@input=${(e: Event) => {
this.targetTroopRatio = parseInt((e.target as HTMLInputElement).value) / 100;
this.onTroopChange(this.targetTroopRatio); this.onTroopChange(this.targetTroopRatio);
}}> }}
>
</div> </div>
<div class="slider-container">
<label for="numTroops">Attack Ratio: ${this.attackRatio * 100}%</label> <div class="attack-slider">
<input type="range" id="numTroops" min="1" max="10" value=${this.attackRatio * 10} <label>Attack Ratio: ${(this.attackRatio * 100).toFixed(0)}%</label>
@input=${(e: Event) => { <div class="slider-track"></div>
this.attackRatio = parseInt((e.target as HTMLInputElement).value) / 10; <div class="slider-fill" style="width: ${this.attackRatio * 100}%"></div>
<div class="slider-thumb" style="left: ${this.attackRatio * 100}%"></div>
<input
type="range"
min="1"
max="100"
.value=${this.attackRatio * 100}
@input=${(e: Event) => {
this.attackRatio = parseInt((e.target as HTMLInputElement).value) / 100;
this.onAttackRatioChange(this.attackRatio); this.onAttackRatioChange(this.attackRatio);
}}> }}
>
</div> </div>
</div> </div>
`; `;
+35 -17
View File
@@ -1,22 +1,20 @@
import { LitElement, html, css } from 'lit'; import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js'; import { customElement, state } from 'lit/decorators.js';
import { EventBus } from '../../../../core/EventBus'; import { EventBus } from '../../../../core/EventBus';
import { Cell, Game, Player } from '../../../../core/game/Game'; import { Cell, Game, Item, Items, Player } from '../../../../core/game/Game';
import { SendNukeIntentEvent } from '../../../Transport'; import { SendNukeIntentEvent } from '../../../Transport';
import nukeIcon from '../../../../../resources/images/NukeIconWhite.svg'; import nukeIcon from '../../../../../resources/images/NukeIconWhite.svg';
import goldCoinIcon from '../../../../../resources/images/GoldCoinIcon.svg'; import goldCoinIcon from '../../../../../resources/images/GoldCoinIcon.svg';
import { renderNumber } from '../../Utils'; import { renderNumber } from '../../Utils';
interface BuildItem { interface BuildItem {
id: string; item: Item
name: string;
icon: string; icon: string;
cost: number;
} }
const buildTable: BuildItem[][] = [ const buildTable: BuildItem[][] = [
[ [
{ id: 'nuke', name: 'Nuke', icon: nukeIcon, cost: 1_000_000 }, { item: Items.Nuke, icon: nukeIcon },
// { id: 'battleship', name: 'Battleship', icon: '🚢', cost: 500, buildTime: 20 } // { id: 'battleship', name: 'Battleship', icon: '🚢', cost: 500, buildTime: 20 }
] ]
]; ];
@@ -24,11 +22,10 @@ const buildTable: BuildItem[][] = [
@customElement('build-menu') @customElement('build-menu')
export class BuildMenu extends LitElement { export class BuildMenu extends LitElement {
public game: Game; public game: Game;
public eventBus: EventBus public eventBus: EventBus;
private myPlayer: Player;
private myPlayer: Player private clickedCell: Cell;
private clickedCell: Cell
static styles = css` static styles = css`
:host { :host {
@@ -72,15 +69,27 @@ export class BuildMenu extends LitElement {
margin: 8px; margin: 8px;
padding: 10px; padding: 10px;
} }
.build-button:hover { .build-button:not(:disabled):hover {
background-color: #3A3A3A; background-color: #3A3A3A;
transform: scale(1.05); transform: scale(1.05);
border-color: #666; border-color: #666;
} }
.build-button:active { .build-button:not(:disabled):active {
background-color: #4A4A4A; background-color: #4A4A4A;
transform: scale(0.95); transform: scale(0.95);
} }
.build-button:disabled {
background-color: #1A1A1A;
border-color: #333;
cursor: not-allowed;
opacity: 0.7;
}
.build-button:disabled img {
opacity: 0.5;
}
.build-button:disabled .build-cost {
color: #FF4444;
}
.build-icon { .build-icon {
font-size: 40px; font-size: 40px;
margin-bottom: 5px; margin-bottom: 5px;
@@ -128,6 +137,10 @@ export class BuildMenu extends LitElement {
@state() @state()
private _hidden = true; private _hidden = true;
private canAfford(item: BuildItem): boolean {
return this.myPlayer && this.myPlayer.gold() >= item.item.cost;
}
public onBuildSelected: (item: BuildItem) => void = () => { public onBuildSelected: (item: BuildItem) => void = () => {
this.eventBus.emit(new SendNukeIntentEvent(this.myPlayer, this.clickedCell, null)) this.eventBus.emit(new SendNukeIntentEvent(this.myPlayer, this.clickedCell, null))
this.hideMenu() this.hideMenu()
@@ -139,11 +152,16 @@ export class BuildMenu extends LitElement {
${buildTable.map(row => html` ${buildTable.map(row => html`
<div class="build-row"> <div class="build-row">
${row.map(item => html` ${row.map(item => html`
<button class="build-button" @click=${() => this.onBuildSelected(item)}> <button
<img src=${item.icon} alt="${item.name}" width="40" height="40"> class="build-button"
<span class="build-name">${item.name}</span> @click=${() => this.onBuildSelected(item)}
?disabled=${!this.canAfford(item)}
title=${!this.canAfford(item) ? 'Not enough money' : ''}
>
<img src=${item.icon} alt="${item.item.name}" width="40" height="40">
<span class="build-name">${item.item.name}</span>
<span class="build-cost"> <span class="build-cost">
${renderNumber(item.cost)} ${renderNumber(item.item.cost)}
<img src=${goldCoinIcon} alt="gold" width="12" height="12" style="vertical-align: middle;"> <img src=${goldCoinIcon} alt="gold" width="12" height="12" style="vertical-align: middle;">
</span> </span>
</button> </button>
@@ -160,8 +178,8 @@ export class BuildMenu extends LitElement {
} }
showMenu(player: Player, clickedCell: Cell) { showMenu(player: Player, clickedCell: Cell) {
this.myPlayer = player this.myPlayer = player;
this.clickedCell = clickedCell this.clickedCell = clickedCell;
this._hidden = false; this._hidden = false;
this.requestUpdate(); this.requestUpdate();
} }
+1 -1
View File
@@ -163,7 +163,7 @@ export class DefaultConfig implements Config {
} }
troopAdjustmentRate(player: Player): number { troopAdjustmentRate(player: Player): number {
const maxDiff = this.maxPopulation(player) / 300 const maxDiff = this.maxPopulation(player) / 1000
const target = player.population() * player.targetTroopRatio() const target = player.population() * player.targetTroopRatio()
const diff = target - player.troops() const diff = target - player.troops()
if (Math.abs(diff) < maxDiff) { if (Math.abs(diff) < maxDiff) {
+1 -1
View File
@@ -6,7 +6,7 @@ export const devConfig = new class extends DefaultConfig {
return 95 return 95
} }
numSpawnPhaseTurns(): number { numSpawnPhaseTurns(): number {
return 40 return 20
} }
gameCreationRate(): number { gameCreationRate(): number {
return 20 * 1000 return 20 * 1000
-1
View File
@@ -2,7 +2,6 @@ import { PriorityQueue } from "@datastructures-js/priority-queue";
import { Cell, Execution, MutableGame, MutablePlayer, Player, PlayerID, TerrainType, TerraNullius, Tile } from "../game/Game"; import { Cell, Execution, MutableGame, MutablePlayer, Player, PlayerID, TerrainType, TerraNullius, Tile } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom"; import { PseudoRandom } from "../PseudoRandom";
import { manhattanDist } from "../Util"; import { manhattanDist } from "../Util";
import { Terrain } from "../game/TerrainMapLoader";
export class AttackExecution implements Execution { export class AttackExecution implements Execution {
private breakAlliance = false private breakAlliance = false
+8 -1
View File
@@ -1,4 +1,4 @@
import { Cell, Execution, MutableGame, MutablePlayer, PlayerID, Tile } from "../game/Game"; import { Cell, Execution, Items, MutableGame, MutablePlayer, PlayerID, Tile } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom"; import { PseudoRandom } from "../PseudoRandom";
import { bfs, dist, euclideanDist, manhattanDist } from "../Util"; import { bfs, dist, euclideanDist, manhattanDist } from "../Util";
@@ -26,6 +26,13 @@ export class NukeExecution implements Execution {
} }
tick(ticks: number): void { tick(ticks: number): void {
if (this.sender.gold() < Items.Nuke.cost) {
console.warn(`player ${this.sender} insufficient gold for nuke`)
this.active = false
return
}
this.sender.removeGold(Items.Nuke.cost)
const rand = new PseudoRandom(this.mg.ticks()) const rand = new PseudoRandom(this.mg.ticks())
const tile = this.mg.tile(this.cell) const tile = this.mg.tile(this.cell)
const toDestroy = bfs(tile, (n: Tile) => { const toDestroy = bfs(tile, (n: Tile) => {
+8
View File
@@ -25,6 +25,14 @@ export enum GameMap {
Mena Mena
} }
export class Item {
constructor(public readonly name: string, public readonly cost: Gold) { }
}
export const Items = {
Nuke: new Item("Nuke", 1_000_000),
} as const;
export class Nation { export class Nation {
constructor( constructor(
public readonly name: string, public readonly name: string,