diff --git a/core/archetypes/facility.ts b/core/archetypes/facility.ts index ed465938..1fdee4ee 100644 --- a/core/archetypes/facility.ts +++ b/core/archetypes/facility.ts @@ -87,7 +87,8 @@ export function createFacility(sim: Sim, initial: InitialFacilityInput) { .addComponent({ name: "journal", entries: [] }) .addComponent({ name: "hitpoints", - hp: { max: 100000, regen: 0, value: 100000 }, + hp: { base: 100000, max: 100000, regen: 0, value: 100000, modifiers: {} }, + hitBy: {}, }) .addComponent({ name: "name", diff --git a/core/archetypes/ship.ts b/core/archetypes/ship.ts index 3925fa57..cefd2e21 100644 --- a/core/archetypes/ship.ts +++ b/core/archetypes/ship.ts @@ -28,6 +28,7 @@ export const shipComponents = [ "journal", "model", "subordinates", + "experience", ] as const; export type ShipComponent = (typeof shipComponents)[number]; @@ -118,20 +119,28 @@ export function createShip(sim: Sim, initial: InitialShipInput): Ship { .addComponent({ name: "hitpoints", hp: { + base: initial.hitpoints.hp.value, max: initial.hitpoints.hp.value, regen: initial.hitpoints.hp.regen, value: initial.hitpoints.hp.value, + modifiers: {}, }, shield: { max: initial.hitpoints.shield.value, regen: initial.hitpoints.shield.regen, value: initial.hitpoints.shield.value, }, + hitBy: {}, }) .addComponent({ ...initial.damage, name: "damage", targetId: null, + output: { + base: initial.damage.value, + current: initial.damage.value, + }, + modifiers: {}, }) .addComponent({ name: "model", @@ -143,6 +152,11 @@ export function createShip(sim: Sim, initial: InitialShipInput): Ship { value: createShipName(entity.requireComponents(["model"])), }) .addComponent({ name: "subordinates", ids: [] }) + .addComponent({ + name: "experience", + rank: 1, + amount: 0, + }) .addTag("selection") .addTag("ship") .addTag(`role:${initial.role}`); diff --git a/core/components/component.ts b/core/components/component.ts index c4780cfa..1ea4e078 100644 --- a/core/components/component.ts +++ b/core/components/component.ts @@ -45,6 +45,7 @@ import type { CrewRequirement } from "./crewRequirement"; import type { Movable } from "./movable"; import type { StorageTransfer } from "./storageTransfer"; import type { Policies } from "./policies"; +import type { Experience } from "./experience"; export interface CoreComponents { ai: Ai; @@ -67,6 +68,7 @@ export interface CoreComponents { dockable: Dockable; docks: Docks; drive: Drive; + experience: Experience; facilityModuleBonus: FacilityModuleBonus; facilityModuleQueue: FacilityModuleQueue; hecsPosition: HECSPosition; diff --git a/core/components/damage.ts b/core/components/damage.ts index 161ee640..1d98b7b4 100644 --- a/core/components/damage.ts +++ b/core/components/damage.ts @@ -4,6 +4,19 @@ export interface Damage extends BaseComponent<"damage"> { cooldown: number; targetId: number | null; range: number; - value: number; angle: number; + modifiers: Record; + output: { + base: number; + current: number; + }; +} + +export function recalculate(cp: Damage): void { + let multiplier = 1; + for (const mod of Object.values(cp.modifiers)) { + multiplier += mod; + } + + cp.output.current = cp.output.base * multiplier; } diff --git a/core/components/experience.ts b/core/components/experience.ts new file mode 100644 index 00000000..1ee9ba87 --- /dev/null +++ b/core/components/experience.ts @@ -0,0 +1,32 @@ +import type { RequireComponent } from "@core/tsHelpers"; +import type { BaseComponent } from "./component"; + +export interface Experience extends BaseComponent<"experience"> { + amount: number; + rank: number; +} + +export const ranks = [200, 600, 1400, 3000, 6200, Infinity]; + +export function getRank(exp: number): number { + return ranks.findIndex((threshold) => exp < threshold); +} + +export function addExperience( + entity: RequireComponent<"experience">, + value: number +): void { + entity.cp.experience.amount += value; + const newRank = getRank(entity.cp.experience.amount); + if (newRank > entity.cp.experience.rank) { + entity.cp.experience.rank = newRank; + + if (entity.hasComponents(["damage"])) { + entity.cp.damage.modifiers.rank = 0.1 * entity.cp.experience.rank; + } + if (entity.hasComponents(["hitpoints"])) { + entity.cp.hitpoints.hp.modifiers.rank = 0.1 * entity.cp.experience.rank; + } + entity.addTag("recalculate:modifiers"); + } +} diff --git a/core/components/hitpoints.ts b/core/components/hitpoints.ts index 99fa4ed3..5c524561 100644 --- a/core/components/hitpoints.ts +++ b/core/components/hitpoints.ts @@ -3,19 +3,21 @@ import type { BaseComponent } from "./component"; export interface HitPoints extends BaseComponent<"hitpoints"> { hp: { + base: number; max: number; value: number; regen: number; + modifiers: Record; }; shield?: { max: number; value: number; regen: number; }; - hit?: boolean; + hitBy: Record; // entityId: timestamp } -export function changeHp( +export function subtractHp( entity: RequireComponent<"hitpoints">, value: number ): void { @@ -25,9 +27,30 @@ export function changeHp( 0, entity.cp.hitpoints.shield.value - delta ); - delta -= Math.min(entity.cp.hitpoints.shield.value, value); + delta += Math.min(entity.cp.hitpoints.shield.value, value); } entity.cp.hitpoints.hp.value -= delta; - entity.cp.hitpoints.hit = true; +} + +export function dealDamageToEntity( + entity: RequireComponent<"hitpoints">, + value: number, + attackerId: number +): void { + subtractHp(entity, value); + entity.cp.hitpoints.hitBy[attackerId] = entity.sim.getTime(); +} + +export function recalculate(cp: HitPoints): void { + let multiplier = 1; + for (const mod of Object.values(cp.hp.modifiers)) { + multiplier += mod; + } + + const diff = cp.hp.base * multiplier - cp.hp.max; + cp.hp.max = cp.hp.base * multiplier; + if (multiplier > 1) { + cp.hp.value += diff; + } } diff --git a/core/components/masks.ts b/core/components/masks.ts index f1f6101b..d2828fed 100644 --- a/core/components/masks.ts +++ b/core/components/masks.ts @@ -55,6 +55,7 @@ export const componentList = [ "movable", "storageTransfer", "policies", + "experience", ]; export const componentMask: Record = componentList.reduce( diff --git a/core/sim/baseConfig.ts b/core/sim/baseConfig.ts index f76cb8b1..7c635cb9 100644 --- a/core/sim/baseConfig.ts +++ b/core/sim/baseConfig.ts @@ -32,6 +32,7 @@ import { fogOfWarUpdatingSystem } from "@core/systems/fogOfWarUpdating"; import { storageTransferringSystem } from "@core/systems/storageTransferring"; import { sectorClaimingSystem } from "@core/systems/sectorClaiming"; import { NavigatingSystem } from "@core/systems/navigating"; +import { modifierRecalculatingSystem } from "@core/systems/modifierRecalculating"; import type { SimConfig } from "./Sim"; export const bootstrapSystems = [ @@ -56,6 +57,7 @@ export const bootstrapSystems = [ disposableUnregisteringSystem, crewGrowingSystem, storageTransferringSystem, + modifierRecalculatingSystem, ]; export const createBaseConfig = async (): Promise => { diff --git a/core/systems/attacking.ts b/core/systems/attacking.ts index 93b8bb53..01766c04 100644 --- a/core/systems/attacking.ts +++ b/core/systems/attacking.ts @@ -1,4 +1,4 @@ -import { changeHp } from "@core/components/hitpoints"; +import { dealDamageToEntity } from "@core/components/hitpoints"; import settings from "@core/settings"; import type { Sim } from "@core/sim"; import type { RequireComponent } from "@core/tsHelpers"; @@ -142,7 +142,7 @@ export class AttackingSystem extends System { transport3D.hooks.shoot.notify( entity.requireComponents(["position", "damage"]) ); - changeHp(target, entity.cp.damage.value); + dealDamageToEntity(target, entity.cp.damage.output.current, entity.id); const parentEntity = entityOrParent; if (target.hasComponents(["drive", "movable"])) { diff --git a/core/systems/deadUnregistering.ts b/core/systems/deadUnregistering.ts index 0f62b765..9ef8de94 100644 --- a/core/systems/deadUnregistering.ts +++ b/core/systems/deadUnregistering.ts @@ -2,9 +2,18 @@ import type { Sim } from "@core/sim"; import { dumpCargo } from "@core/components/storage"; import type { Faction } from "@core/archetypes/faction"; import { entityIndexer } from "@core/entityIndexer/entityIndexer"; +import type { DockSize } from "@core/components/dockable"; +import { addExperience } from "@core/components/experience"; import { System } from "./system"; import { transport3D } from "./transport3d"; +const expValues: Record = { + large: 200, + medium: 50, + small: 20, +}; +const timestampThreshold = 120; // 2 minutes + export class DeadUnregisteringSystem extends System { apply = (sim: Sim) => { super.apply(sim); @@ -27,8 +36,29 @@ export class DeadUnregisteringSystem extends System { time: this.sim.getTime(), }); } + if (entity.hasComponents(["position"])) transport3D.hooks.explode.notify(entity); + + const attackers: number[] = []; + + for (const [attackerId, timestamp] of Object.entries( + entity.cp.hitpoints.hitBy + )) { + if (timestamp + timestampThreshold > this.sim.getTime()) { + attackers.push(Number(attackerId)); + } + } + + const exp = + expValues[entity.cp.dockable?.size || "small"] / attackers.length; + for (const attackerId of attackers) { + const attacker = this.sim.get(attackerId); + if (attacker?.hasComponents(["experience"])) { + addExperience(attacker, exp); + } + } + entity.unregister("dead"); } } diff --git a/core/systems/hitpointsRegenerating.ts b/core/systems/hitpointsRegenerating.ts index cc95507a..f6ce8ae8 100644 --- a/core/systems/hitpointsRegenerating.ts +++ b/core/systems/hitpointsRegenerating.ts @@ -29,8 +29,6 @@ export class HitpointsRegeneratingSystem extends System<"exec"> { entity.cp.hitpoints.shield.max ); } - - entity.cp.hitpoints.hit = true; } }; } diff --git a/core/systems/modifierRecalculating.ts b/core/systems/modifierRecalculating.ts new file mode 100644 index 00000000..7de025c6 --- /dev/null +++ b/core/systems/modifierRecalculating.ts @@ -0,0 +1,29 @@ +import type { Sim } from "@core/sim"; +import { entityIndexer } from "@core/entityIndexer/entityIndexer"; +import { recalculate as recalculateDamage } from "@core/components/damage"; +import { recalculate as recalculateHitpoints } from "@core/components/hitpoints"; +import { System } from "./system"; + +export class ModifierRecalculatingSystem extends System { + apply(sim: Sim) { + sim.hooks.phase.start.subscribe( + this.constructor.name, + this.recalculateModifiers.bind(this) + ); + } + + // eslint-disable-next-line class-methods-use-this + recalculateModifiers(): void { + for (const entity of entityIndexer.search([], ["recalculate:modifiers"])) { + if (entity.hasComponents(["damage"])) { + recalculateDamage(entity.cp.damage); + } + if (entity.hasComponents(["hitpoints"])) { + recalculateHitpoints(entity.cp.hitpoints); + } + entity.removeTag("recalculate:modifiers"); + } + } +} + +export const modifierRecalculatingSystem = new ModifierRecalculatingSystem(); diff --git a/core/tags.ts b/core/tags.ts index 64e9503e..5ad59d6d 100644 --- a/core/tags.ts +++ b/core/tags.ts @@ -20,7 +20,7 @@ const tags = [ "ai:attack-force", "ai:spare", "ai:mission", - "ui:arrow", + "recalculate:modifiers", ...shipRoles.map<`role:${ShipRole}`>((role) => `role:${role}`), ...modules.map<`facilityModuleType:${FacilityModuleType}`>( ({ type: facilityModuleType }) => diff --git a/core/world/data/base.json b/core/world/data/base.json index dbafde4b..a5b119ca 100644 --- a/core/world/data/base.json +++ b/core/world/data/base.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cef32af0ebdff35f2bf2fb67bf0cf844705246fd122d3a837524dbaf7be9f829 -size 3379577 +oid sha256:3506873d04bc329048f96b7f7027d5cf2e1d39d33adc29e9b17fe7f8ad2366ca +size 3468373 diff --git a/devtools/facilityModules/General.tsx b/devtools/facilityModules/General.tsx index 35087cbd..b940c1fd 100644 --- a/devtools/facilityModules/General.tsx +++ b/devtools/facilityModules/General.tsx @@ -71,10 +71,10 @@ const FacilityModuleGeneralEditor: React.FC<{ index: number }> = ({ {facilityModule.type === "military" && ( )} diff --git a/ui/components/ExperienceBar/ExperienceBar.tsx b/ui/components/ExperienceBar/ExperienceBar.tsx new file mode 100644 index 00000000..ef2aed87 --- /dev/null +++ b/ui/components/ExperienceBar/ExperienceBar.tsx @@ -0,0 +1,26 @@ +import { getRank, ranks } from "@core/components/experience"; +import React from "react"; +import styles from "./styles.scss"; + +export interface ExperienceBarProps { + amount: number; +} + +export const ExperienceBar: React.FC = ({ amount }) => { + const rank = getRank(amount); + const progress = rank === 5 ? 0 : amount / ranks[rank]; + const expLabel = rank === 5 ? "Max Rank" : `${amount}/${ranks[rank]}`; + const rankLabel = rank ? `Rank ${rank}` : "No Rank"; + + return ( + <> +
{rankLabel}
+
+ {expLabel} +
+ + ); +}; diff --git a/ui/components/ExperienceBar/styles.scss b/ui/components/ExperienceBar/styles.scss new file mode 100644 index 00000000..b305489f --- /dev/null +++ b/ui/components/ExperienceBar/styles.scss @@ -0,0 +1,32 @@ +.root { + --height: usesize(1.8); + --percent: 0%; + + height: var(--height); + border: usesize(0.1) solid var(--palette-border); + border-radius: usesize(0.2); + position: relative; + flex: 1; + + &::before { + content: ""; + display: block; + height: 100%; + width: var(--percent); + background-color: var(--palette-primary); + position: absolute; + top: 0; + left: 0; + border-radius: usesize(0.2); + } +} + +.label { + position: absolute; + display: inline-block; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + mix-blend-mode: difference; + font-size: var(--typography-label); +} diff --git a/ui/components/ExperienceBar/styles.scss.d.ts b/ui/components/ExperienceBar/styles.scss.d.ts new file mode 100644 index 00000000..666162f5 --- /dev/null +++ b/ui/components/ExperienceBar/styles.scss.d.ts @@ -0,0 +1,5 @@ +/* @generated */ +/* prettier-ignore */ +/* eslint-disable */ +export const root: string; +export const label: string; diff --git a/ui/components/HitPoints/HitPoints.tsx b/ui/components/HitPoints/HitPoints.tsx index 8e99fa1c..869735e6 100644 --- a/ui/components/HitPoints/HitPoints.tsx +++ b/ui/components/HitPoints/HitPoints.tsx @@ -31,12 +31,9 @@ export const HitPointsInfo: React.FC = ({ hp }) => { ); return ( - <> -
- {!!hp.shield && Shield} - Health -
-
- +
+ {!!hp.shield && Shield} + Health +
); }; diff --git a/ui/components/Panel/Panel.scss b/ui/components/Panel/Panel.scss index d312f9e6..8521df58 100644 --- a/ui/components/Panel/Panel.scss +++ b/ui/components/Panel/Panel.scss @@ -52,3 +52,12 @@ .turbo { color: var(--palette-error); } + +.hpBar { + display: flex; + align-items: center; + gap: usesize(0.8); + padding-bottom: usesize(0.8); + margin-bottom: usesize(0.8); + border-bottom: usesize(0.1) solid var(--palette-border); +} diff --git a/ui/components/Panel/Panel.scss.d.ts b/ui/components/Panel/Panel.scss.d.ts index aa7e59d3..e6e66a6e 100644 --- a/ui/components/Panel/Panel.scss.d.ts +++ b/ui/components/Panel/Panel.scss.d.ts @@ -11,3 +11,4 @@ export const spacer: string; export const tab: string; export const manage: string; export const turbo: string; +export const hpBar: string; diff --git a/ui/components/Panel/Panel.tsx b/ui/components/Panel/Panel.tsx index 073f3fe9..33525378 100644 --- a/ui/components/Panel/Panel.tsx +++ b/ui/components/Panel/Panel.tsx @@ -42,6 +42,7 @@ import { Docks } from "../Docks"; import ShipBuildingQueue from "../ShipBuildingQueue"; import { Production } from "../Production"; import { Teleport } from "../Teleport/Teleport"; +import { ExperienceBar } from "../ExperienceBar/ExperienceBar"; export interface PanelProps { expanded?: boolean; @@ -196,7 +197,12 @@ export const Panel: React.FC = ({ entity, expanded }) => { )} {entity.hasComponents(["hitpoints"]) && ( - +
+ + {entity.hasComponents(["experience"]) && showSensitive && ( + + )} +
)} {entity.hasComponents(shipComponents) && (