diff --git a/_templates/material/new/material.ejs.t b/_templates/material/new/material.ejs.t index fae32fb9..275bd6f7 100644 --- a/_templates/material/new/material.ejs.t +++ b/_templates/material/new/material.ejs.t @@ -2,15 +2,15 @@ to: ogl-engine/materials/<%= name %>/<%= name %>.ts --- -interface <%= h.capitalize(name) %>MaterialArgs { - -} - import type { Engine3D } from "@ogl-engine/engine/engine3d"; import fragment from "./shader.frag.glsl"; import vertex from "./shader.vert.glsl"; import { Material } from "../material"; +interface <%= h.capitalize(name) %>MaterialArgs { + +} + export class <%= h.capitalize(name) %>Material extends Material { uniforms: Material["uniforms"] & { diff --git a/assets/textures/skybox/deepspace1/back.webp b/assets/textures/skybox/deepspace1/back.webp new file mode 100644 index 00000000..b18b8315 Binary files /dev/null and b/assets/textures/skybox/deepspace1/back.webp differ diff --git a/assets/textures/skybox/deepspace1/bottom.webp b/assets/textures/skybox/deepspace1/bottom.webp new file mode 100644 index 00000000..e55b2a07 Binary files /dev/null and b/assets/textures/skybox/deepspace1/bottom.webp differ diff --git a/assets/textures/skybox/deepspace1/front.webp b/assets/textures/skybox/deepspace1/front.webp new file mode 100644 index 00000000..1a9ebd1f Binary files /dev/null and b/assets/textures/skybox/deepspace1/front.webp differ diff --git a/assets/textures/skybox/deepspace1/index.ts b/assets/textures/skybox/deepspace1/index.ts new file mode 100644 index 00000000..14b8fafa --- /dev/null +++ b/assets/textures/skybox/deepspace1/index.ts @@ -0,0 +1,15 @@ +import back from "./back.webp"; +import front from "./front.webp"; +import bottom from "./bottom.webp"; +import top from "./top.webp"; +import left from "./left.webp"; +import right from "./right.webp"; + +export const deepspace1 = { + back, + front, + bottom, + top, + left, + right, +}; diff --git a/assets/textures/skybox/deepspace1/left.webp b/assets/textures/skybox/deepspace1/left.webp new file mode 100644 index 00000000..95470181 Binary files /dev/null and b/assets/textures/skybox/deepspace1/left.webp differ diff --git a/assets/textures/skybox/deepspace1/right.webp b/assets/textures/skybox/deepspace1/right.webp new file mode 100644 index 00000000..27ccb583 Binary files /dev/null and b/assets/textures/skybox/deepspace1/right.webp differ diff --git a/assets/textures/skybox/deepspace1/top.webp b/assets/textures/skybox/deepspace1/top.webp new file mode 100644 index 00000000..9305d3ed Binary files /dev/null and b/assets/textures/skybox/deepspace1/top.webp differ diff --git a/assets/textures/skybox/index.ts b/assets/textures/skybox/index.ts index 86ab5056..167e179f 100644 --- a/assets/textures/skybox/index.ts +++ b/assets/textures/skybox/index.ts @@ -4,6 +4,7 @@ import { sectoralpha } from "./sectoralpha"; import { teegarden1 } from "./teegarden1"; import { teegarden2 } from "./teegarden2"; import { gaia } from "./gaia"; +import { deepspace1 } from "./deepspace1"; export const skyboxes = { example, @@ -12,5 +13,6 @@ export const skyboxes = { teegarden1, teegarden2, gaia, + deepspace1, }; export type SkyboxTexture = keyof typeof skyboxes; diff --git a/core/archetypes/asteroidField.ts b/core/archetypes/asteroidField.ts index b00ebc5f..9a3f9b1d 100644 --- a/core/archetypes/asteroidField.ts +++ b/core/archetypes/asteroidField.ts @@ -20,11 +20,7 @@ export const fieldColors = { silica: "#ededed", } as Record; -export const asteroidFieldComponents = [ - "mineable", - "children", - "position", -] as const; +export const asteroidFieldComponents = ["mineable", "position"] as const; export type AsteroidFieldComponent = (typeof asteroidFieldComponents)[number]; export type AsteroidField = RequireComponent; @@ -111,7 +107,6 @@ export function createAsteroidField( getFPoints(data.size) ) ) - .addComponent({ name: "children", entities: [] }) .addComponent({ name: "position", coord: position, diff --git a/core/archetypes/facilityModule.ts b/core/archetypes/facilityModule.ts index 0fb4dcaa..dc0152ce 100644 --- a/core/archetypes/facilityModule.ts +++ b/core/archetypes/facilityModule.ts @@ -1,12 +1,15 @@ import type { Commodity } from "@core/economy/commodity"; import { fromEntries, pipe, map } from "@fxts/core"; import type { Damage } from "@core/components/damage"; +import { fromPolar } from "@core/utils/misc"; +import { findInAncestors } from "@core/utils/findInAncestors"; import { Entity } from "../entity"; import type { PAC } from "../components/production"; import { createProduction } from "../components/production"; import type { Sim } from "../sim"; import type { RequireComponent } from "../tsHelpers"; import modules from "../world/data/facilityModules.json"; +import { createTurret } from "./turret"; export interface FacilityModuleCommonInput { name: string; @@ -104,10 +107,43 @@ export function createFacilityModule( }); } else if (input.type === "military") { entity.addComponent({ - ...input.damage, - name: "damage", - targetId: null, + name: "children", + entities: [], + slots: Array(4) + .fill(0) + .map((_, i) => ({ + angle: (i * Math.PI) / 2, + slug: `turret-${i}`, + })), }); + const turrets = Array(4) + .fill(0) + .map((_, i) => + createTurret(sim, { + slug: "rapidKinetic", + angle: (i * Math.PI) / 2, + damage: { + angle: Math.PI / 2, + }, + slot: `turret-${i}`, + parentId: entity.id, + transform: { + coord: fromPolar((i * Math.PI) / 2, 0.01), + angle: (i * Math.PI) / 2, + world: { + coord: findInAncestors(input.parent, "position") + .cp.position.coord.clone() + .add(fromPolar((i * Math.PI) / 2, 0.01)), + angle: (i * Math.PI) / 2, + }, + }, + }) + ); + entity.cp.children!.entities = turrets.map((turret, turretIndex) => ({ + id: turret.id, + role: "turret", + slot: `turret-${turretIndex}`, + })); } if (input.crew.cost > 0) { entity.addComponent({ diff --git a/core/archetypes/ship.ts b/core/archetypes/ship.ts index cefd2e21..f0d1bb9b 100644 --- a/core/archetypes/ship.ts +++ b/core/archetypes/ship.ts @@ -3,6 +3,8 @@ import pick from "lodash/pick"; import type { DockSize } from "@core/components/dockable"; import { createDocks } from "@core/components/dockable"; import { Vec2 } from "ogl"; +import { random } from "mathjs"; +import { applyParentTransform } from "@core/systems/moving"; import { createDrive } from "../components/drive"; import { Entity } from "../entity"; import { createMining } from "../components/mining"; @@ -13,6 +15,7 @@ import type { RequireComponent } from "../tsHelpers"; import type { Sector } from "./sector"; import type { Faction } from "./faction"; import type { ShipInput } from "../world/ships"; +import { createTurret } from "./turret"; export const shipComponents = [ "autoOrder", @@ -29,6 +32,7 @@ export const shipComponents = [ "model", "subordinates", "experience", + "children", ] as const; export type ShipComponent = (typeof shipComponents)[number]; @@ -66,7 +70,7 @@ export function createShipName( } export function createShip(sim: Sim, initial: InitialShipInput): Ship { - const entity = new Entity(sim); + const entity = new Entity(sim) as Ship; entity .addComponent({ @@ -132,16 +136,6 @@ export function createShip(sim: Sim, initial: InitialShipInput): Ship { }, hitBy: {}, }) - .addComponent({ - ...initial.damage, - name: "damage", - targetId: null, - output: { - base: initial.damage.value, - current: initial.damage.value, - }, - modifiers: {}, - }) .addComponent({ name: "model", slug: initial.slug, @@ -157,10 +151,41 @@ export function createShip(sim: Sim, initial: InitialShipInput): Ship { rank: 1, amount: 0, }) + .addComponent({ + name: "children", + entities: [], + slots: initial.slots, + }) .addTag("selection") .addTag("ship") .addTag(`role:${initial.role}`); + for (const turretInput of initial.turrets) { + const slot = entity.cp.children.slots.find( + ({ slug }) => slug === turretInput.slot + ); + const slotPosition = new Vec2(random(-4e-2, 4e-2), random(-4e-2, 4e-2)); + + const turret = createTurret(sim, { + slug: turretInput.class, + angle: slot!.angle, + damage: { + angle: turretInput.angle, + }, + slot: turretInput.slot, + parentId: entity.id, + transform: { + coord: slotPosition.clone(), + angle: slot!.angle, + world: { + coord: new Vec2(), + angle: slot!.angle, + }, + }, + }); + applyParentTransform(turret, entity.cp.position); + } + if (initial.mining) { entity.addComponent(createMining(initial.mining)); } @@ -183,6 +208,20 @@ export function createShip(sim: Sim, initial: InitialShipInput): Ship { }); } + // if (initial.damage) { + // entity.addComponent({ + // ...initial.damage, + // name: "damage", + // targetId: null, + // output: { + // base: initial.damage.value, + // current: initial.damage.value, + // }, + // modifiers: {}, + // type: "kinetic", + // }); + // } + if (initial.docks) { entity.addComponent(createDocks(initial.docks)); } diff --git a/core/archetypes/turret.ts b/core/archetypes/turret.ts new file mode 100644 index 00000000..33541e6a --- /dev/null +++ b/core/archetypes/turret.ts @@ -0,0 +1,74 @@ +import type { Sim } from "@core/sim"; +import { Entity } from "@core/entity"; +import type { Transform } from "@core/components/transform"; +import { attach } from "@core/components/children"; +import { getTurretBySlug } from "@core/world/turrets"; +import { MissingComponentError } from "../errors"; +import type { RequireComponent } from "../tsHelpers"; + +export const turretComponents = [ + "damage", + "color", + "transform", + "parent", +] as const; + +export type TurretComponent = (typeof turretComponents)[number]; +export type Turret = RequireComponent; + +export function turret(entity: Entity): Turret { + if (!entity.hasComponents(turretComponents)) { + throw new MissingComponentError(entity, turretComponents); + } + + return entity as Turret; +} + +export interface TurretInput { + angle: number; + parentId: number; + transform: Omit; + slot: string; + slug: string; + damage: { + angle: number; + }; +} + +export function createTurret(sim: Sim, input: TurretInput) { + const entity = new Entity(sim) as Turret; + const parent = sim.getOrThrow(input.parentId).requireComponents(["children"]); + const turretInfo = getTurretBySlug(input.slug)!; + + entity + .addComponent({ + angle: input.damage.angle, + cooldown: turretInfo.cooldown, + range: turretInfo.range, + name: "damage", + targetId: null, + output: { + base: turretInfo.damage, + current: turretInfo.damage, + }, + modifiers: {}, + type: turretInfo.type, + }) + .addComponent({ + name: "color", + value: turretInfo.color, + }) + .addComponent({ + name: "transform", + angle: input.angle, + coord: input.transform.coord, + world: { + angle: input.angle, + coord: input.transform.coord.clone(), + }, + }); + + attach(entity, parent, input.slot, "turret"); + + return entity as Turret; +} diff --git a/core/components/children.ts b/core/components/children.ts index 67dc7737..ba1afec3 100644 --- a/core/components/children.ts +++ b/core/components/children.ts @@ -1,5 +1,52 @@ +import type { RequireComponent } from "@core/tsHelpers"; +import { applyParentTransform } from "@core/systems/moving"; +import type { Entity } from "@core/entity"; +import { componentLogger } from "@core/log"; import type { BaseComponent } from "./component"; +export type ChildRole = "turret"; export interface Children extends BaseComponent<"children"> { - entities: number[]; + slots: Array<{ slug: string; angle: number }>; + entities: Array<{ + role: ChildRole; + id: number; + slot: string; + }>; +} + +export function attach( + child: Entity, + parent: RequireComponent<"children">, + slot: string, + role: ChildRole +) { + if (!parent.cp.children.slots.some(({ slug }) => slug === slot)) { + throw new Error(`Slot ${slot} does not exist on parent entity`); + } + + if (!child.hasComponents(["parent"])) { + child.addComponent({ + name: "parent", + id: parent.id, + }); + } else { + componentLogger.log( + `Entity ${child.id} already has a parent, replacing with new parent ${parent.id}`, + "warn" + ); + child.cp.parent.id = parent.id; + } + + parent.cp.children.entities.push({ + id: child.id, + role, + slot, + }); + + if ( + child.hasComponents(["transform"]) && + parent.hasComponents(["position"]) + ) { + applyParentTransform(child, parent.cp.position); + } } diff --git a/core/components/component.ts b/core/components/component.ts index 1ea4e078..60a5e727 100644 --- a/core/components/component.ts +++ b/core/components/component.ts @@ -46,6 +46,7 @@ import type { Movable } from "./movable"; import type { StorageTransfer } from "./storageTransfer"; import type { Policies } from "./policies"; import type { Experience } from "./experience"; +import type { Transform } from "./transform"; export interface CoreComponents { ai: Ai; @@ -102,6 +103,7 @@ export interface CoreComponents { systemManager: SystemManager; teleport: Teleport; trade: Trade; + transform: Transform; } /** diff --git a/core/components/damage.ts b/core/components/damage.ts index 1d98b7b4..57892b36 100644 --- a/core/components/damage.ts +++ b/core/components/damage.ts @@ -1,5 +1,6 @@ import type { BaseComponent } from "./component"; +export type DamageType = "kinetic" | "laser"; // Cosmetic for now export interface Damage extends BaseComponent<"damage"> { cooldown: number; targetId: number | null; @@ -10,6 +11,7 @@ export interface Damage extends BaseComponent<"damage"> { base: number; current: number; }; + type: DamageType; } export function recalculate(cp: Damage): void { diff --git a/core/components/experience.ts b/core/components/experience.ts index 1ee9ba87..6b8b9d2c 100644 --- a/core/components/experience.ts +++ b/core/components/experience.ts @@ -1,4 +1,5 @@ import type { RequireComponent } from "@core/tsHelpers"; +import type { Entity } from "@core/entity"; import type { BaseComponent } from "./component"; export interface Experience extends BaseComponent<"experience"> { @@ -12,6 +13,23 @@ export function getRank(exp: number): number { return ranks.findIndex((threshold) => exp < threshold); } +function rankUp(entity: Entity, rank: number) { + if (entity.hasComponents(["damage"])) { + entity.cp.damage.modifiers.rank = 0.1 * rank; + } + if (entity.hasComponents(["hitpoints"])) { + entity.cp.hitpoints.hp.modifiers.rank = 0.1 * rank; + } + entity.addTag("recalculate:modifiers"); + + for (const child of entity.cp.children?.entities ?? []) { + const childEntity = entity.sim.getOrThrow(child.id); + if (childEntity.hasComponents(["experience"])) { + rankUp(childEntity, rank); + } + } +} + export function addExperience( entity: RequireComponent<"experience">, value: number @@ -21,12 +39,6 @@ export function addExperience( 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"); + rankUp(entity, newRank); } } diff --git a/core/components/hitpoints.ts b/core/components/hitpoints.ts index 5c524561..ee5cb09c 100644 --- a/core/components/hitpoints.ts +++ b/core/components/hitpoints.ts @@ -23,11 +23,13 @@ export function subtractHp( ): void { let delta = value; if (entity.cp.hitpoints.shield?.value) { - entity.cp.hitpoints.shield.value = Math.max( - 0, - entity.cp.hitpoints.shield.value - delta - ); - delta += Math.min(entity.cp.hitpoints.shield.value, value); + entity.cp.hitpoints.shield.value -= delta; + if (entity.cp.hitpoints.shield.value < 0) { + delta = -entity.cp.hitpoints.shield.value; + entity.cp.hitpoints.shield.value = 0; + } else { + return; // shield absorbed all damage + } } entity.cp.hitpoints.hp.value -= delta; diff --git a/core/components/masks.ts b/core/components/masks.ts index d2828fed..6a4bb70b 100644 --- a/core/components/masks.ts +++ b/core/components/masks.ts @@ -56,6 +56,7 @@ export const componentList = [ "storageTransfer", "policies", "experience", + "transform", ]; export const componentMask: Record = componentList.reduce( diff --git a/core/components/position.ts b/core/components/position.ts index 7fd34566..006ea46a 100644 --- a/core/components/position.ts +++ b/core/components/position.ts @@ -1,9 +1,7 @@ -import type { Vec2 } from "ogl"; import type { BaseComponent } from "./component"; +import type { TransformData } from "./transform"; -export interface Position extends BaseComponent<"position"> { - angle: number; - readonly coord: Vec2; +export interface Position extends BaseComponent<"position">, TransformData { sector: number; moved: boolean; } diff --git a/core/components/transform.ts b/core/components/transform.ts new file mode 100644 index 00000000..748bea89 --- /dev/null +++ b/core/components/transform.ts @@ -0,0 +1,16 @@ +import type { Vec2 } from "ogl"; +import type { BaseComponent } from "./component"; + +export interface TransformData { + angle: number; + readonly coord: Vec2; +} + +/** + * Local transform data for an entity. It is used for positioning and rotation + * of children entities, such as turrets or other components that need to + * inherit the position and rotation of their parent. + */ +export interface Transform extends BaseComponent<"transform">, TransformData { + readonly world: TransformData; +} diff --git a/core/entity.ts b/core/entity.ts index a6a5cc02..5b8dede0 100644 --- a/core/entity.ts +++ b/core/entity.ts @@ -135,6 +135,10 @@ export class Entity { } } +/** + * Mock entity for testing purposes. + * It does not register itself in the sim and does not have a unique ID. + */ let pureEntityCounter = 0; export class PureEntity { components = new EntityComponents() as Partial; diff --git a/core/events/pubsub.ts b/core/events/pubsub.ts new file mode 100644 index 00000000..e1a67acc --- /dev/null +++ b/core/events/pubsub.ts @@ -0,0 +1,7 @@ +import { PubSub } from "@core/utils/pubsub"; + +type Events = + | { type: "update-debug"; data: string | number; name: string } + | { type: "remove-debug"; name: string }; + +export const eventHook = new PubSub(); diff --git a/core/sim/Sim.ts b/core/sim/Sim.ts index 4899934c..cd505e33 100644 --- a/core/sim/Sim.ts +++ b/core/sim/Sim.ts @@ -115,7 +115,14 @@ export class Sim extends BaseSim { this.hooks.removeEntity.subscribe("EntityIndexer", ({ entity, reason }) => { logger.log( `Removing entity ${entity.id} ${ - entity.cp.name?.value ?? "[unnamed]" + entity.cp.name?.value ?? + (entity.cp.parent + ? `[unnamed ${ + this.getOrThrow(entity.cp.parent.id).cp.children!.entities.find( + (c) => c.id === entity.id + )!.role + }]` + : "[unnamed]") } (${reason})` ); entityIndexer.remove(entity); diff --git a/core/sim/baseConfig.ts b/core/sim/baseConfig.ts index 7c635cb9..f1701f21 100644 --- a/core/sim/baseConfig.ts +++ b/core/sim/baseConfig.ts @@ -1,6 +1,5 @@ import { isDev } from "@core/settings"; import { facilityPlanningSystem } from "@core/systems/ai/facilityPlanning"; -import { militaryModuleSpottingSystem } from "@core/systems/ai/militaryModuleSpotting"; import { orderPlanningSystem } from "@core/systems/ai/orderPlanning"; import { shipPlanningSystem } from "@core/systems/ai/shipPlanning"; import { shipReturningSystem } from "@core/systems/ai/shipReturning"; @@ -68,7 +67,6 @@ export const createBaseConfig = async (): Promise => { undeployingSystem, attackingSystem, spottingSystem, - militaryModuleSpottingSystem, hitpointsRegeneratingSystem, tauHarassingSystem, deadUnregisteringSystem, diff --git a/core/systems/ai/facilityPlanning.ts b/core/systems/ai/facilityPlanning.ts index e126d697..83c2be60 100644 --- a/core/systems/ai/facilityPlanning.ts +++ b/core/systems/ai/facilityPlanning.ts @@ -364,8 +364,7 @@ export class FacilityPlanningSystem extends System<"plan"> { for (const resource of availableResources) { this.logger.log( - `Evaluating ${resource} mining facility in ${sector.cp.name.value} for ${faction.cp.name.slug}`, - "error" + `Evaluating ${resource} mining facility in ${sector.cp.name.value} for ${faction.cp.name.slug}` ); const facilityModule = factionBlueprints .filter(discriminate("type", "production")) @@ -393,7 +392,7 @@ export class FacilityPlanningSystem extends System<"plan"> { name: `${capitalize(commodityLabel[resource])} Mining Complex`, sector, }; - this.logger.log(`Planned ${plan.name}`, "error"); + this.logger.log(`Planned ${plan.name}`); return plan; } diff --git a/core/systems/ai/militaryModuleSpotting.ts b/core/systems/ai/militaryModuleSpotting.ts deleted file mode 100644 index 60c7e5c3..00000000 --- a/core/systems/ai/militaryModuleSpotting.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { RequireComponent } from "@core/tsHelpers"; -import type { Facility } from "@core/archetypes/facility"; -import { facilityComponents } from "@core/archetypes/facility"; -import { pickRandom } from "@core/utils/generators"; -import { entityIndexer } from "@core/entityIndexer/entityIndexer"; -import { System } from "../system"; -import type { Sim } from "../../sim"; -import { SpottingSystem } from "./spotting"; -import { isInDistance } from "../attacking"; - -export class MilitaryModuleSpottingSystem extends System<"exec"> { - apply = (sim: Sim) => { - super.apply(sim); - - sim.hooks.phase.update.subscribe(this.constructor.name, this.exec); - }; - - exec = (): void => { - if (!this.cooldowns.canUse("exec")) return; - - const cache: Record< - string, - Array> - > = {}; - - for (const entity of entityIndexer.search( - ["parent", "damage"], - ["facilityModule"] - )) { - const facility = this.sim.getOrThrow(entity.cp.parent.id); - if ( - !facility.cp.owner || - (entity.cp.damage.targetId && - this.sim.get(entity.cp.damage.targetId) && - isInDistance(entity, this.sim.get(entity.cp.damage.targetId)!)) - ) - return; - - const enemy = pickRandom( - SpottingSystem.getEnemies( - entityIndexer.searchBySector(facility.cp.position.sector, [ - "hitpoints", - "owner", - "position", - ]), - cache, - facility.requireComponents([...facilityComponents, "owner"]) - ).slice(0, 3) - ); - - if (enemy?.distance <= entity.cp.damage.range) { - entity.cp.damage.targetId = enemy.entity.id; - } - } - - this.cooldowns.use("exec", 1 + Math.random()); - }; -} - -export const militaryModuleSpottingSystem = new MilitaryModuleSpottingSystem(); diff --git a/core/systems/ai/shipPlanning.test.ts b/core/systems/ai/shipPlanning.test.ts index 7ac922a1..da8c6474 100644 --- a/core/systems/ai/shipPlanning.test.ts +++ b/core/systems/ai/shipPlanning.test.ts @@ -1,9 +1,9 @@ -import { matrix } from "mathjs"; +import { Vec2 } from "ogl"; import { createFacility } from "../../archetypes/facility"; import { createFaction } from "../../archetypes/faction"; import { createSector } from "../../archetypes/sector"; import { Sim } from "../../sim/Sim"; -import { shipClasses } from "../../world/ships"; +import { getShipClass } from "../../world/ships"; import { ShipPlanningSystem } from "./shipPlanning"; describe("ShipPlanningSystem", () => { @@ -27,16 +27,22 @@ describe("ShipPlanningSystem", () => { type: "territorial", home: 0, restrictions: { mining: false }, + mining: "preferOwn", }); - faction.cp.blueprints.ships = shipClasses; + faction.cp.blueprints.ships = [ + getShipClass("dart")!, + getShipClass("gunboat")!, + getShipClass("freighterA")!, + ]; const sector = createSector(sim, { - position: matrix([0, 0, 0]), + position: [0, 0, 0], name: "Test Sector", + slug: "test", }); sector.addComponent({ name: "owner", id: faction.id }); const shipyard = createFacility(sim, { owner: faction, - position: matrix([0, 0]), + position: new Vec2(0, 0), sector, }); shipyard.addComponent({ name: "shipyard", building: null, queue: [] }); @@ -50,7 +56,7 @@ describe("ShipPlanningSystem", () => { // 3 Freighters for shipyard // 2 Frigates // 4 Fighters - expect(sim.entities.size).toBe(12); + expect(sim.entities.size).toBe(20); expect(system.getPatrolRequests(faction)[0].patrols).toBe(-2); expect(system.getPatrolRequests(faction)[0].fighters).toBe(-4); @@ -58,7 +64,7 @@ describe("ShipPlanningSystem", () => { system.cooldowns.update(100); system.exec(); expect(shipyard.cp.shipyard!.queue).toHaveLength(0); - expect(sim.entities.size).toBe(12); + expect(sim.entities.size).toBe(20); expect(system.getPatrolRequests(faction)[0].patrols).toBe(0); expect(system.getPatrolRequests(faction)[0].fighters).toBe(0); }); diff --git a/core/systems/ai/spotting.ts b/core/systems/ai/spotting.ts index c1750f8d..c2e41dc7 100644 --- a/core/systems/ai/spotting.ts +++ b/core/systems/ai/spotting.ts @@ -8,11 +8,10 @@ import { System } from "../system"; import type { Sim } from "../../sim"; export const spottingRadius = 7; - -export type EnemyArrayCache = Record< +const cache = new Map< string, Array> ->; +>(); export class SpottingSystem extends System<"exec"> { apply = (sim: Sim) => { @@ -25,7 +24,6 @@ export class SpottingSystem extends System<"exec"> { potentialEnemies: Iterable< RequireComponent<"hitpoints" | "owner" | "position"> >, - cache: EnemyArrayCache, entity: RequireComponent<"owner" | "position"> ) { const entityOwner = entity.sim.getOrThrow(entity.cp.owner.id); @@ -36,7 +34,7 @@ export class SpottingSystem extends System<"exec"> { entity.cp.orders?.value[0]?.type === "pillage" ? "pillage" : "default", ].join(":"); const enemies = - cache[cacheKey] ?? + cache.get(cacheKey) ?? pipe( potentialEnemies, filter((e) => { @@ -59,15 +57,15 @@ export class SpottingSystem extends System<"exec"> { }), toArray ); - if (!cache[cacheKey]) { - cache[cacheKey] = enemies; + if (!cache.get(cacheKey)) { + cache.set(cacheKey, enemies); } return pipe( enemies, map((e) => ({ entity: e, - distance: e.cp.position.coord.distance(entity.cp.position.coord), + distance: e.cp.position.coord.squaredDistance(entity.cp.position.coord), })), sort((a, b) => a.distance - b.distance), toArray @@ -75,10 +73,9 @@ export class SpottingSystem extends System<"exec"> { } exec = (): void => { + cache.clear(); if (!this.cooldowns.canUse("exec")) return; - const cache: EnemyArrayCache = {}; - for (const entity of this.sim.index.orderable.getIt()) { const currentOrder = entity.cp.orders.value[0]; const owner = entity.sim.getOrThrow(entity.cp.owner.id); @@ -106,7 +103,6 @@ export class SpottingSystem extends System<"exec"> { "owner", "position", ]), - cache, entity ).slice(0, 3) ); diff --git a/core/systems/ai/tauHarassing.ts b/core/systems/ai/tauHarassing.ts index 74493b49..caf086cd 100644 --- a/core/systems/ai/tauHarassing.ts +++ b/core/systems/ai/tauHarassing.ts @@ -183,7 +183,7 @@ export class TauHarassingSystem extends System<"exec"> { if (isDev) { // eslint-disable-next-line no-console console.log( - `${faction.cp.name.slug} are launching an attack on ${invadedSector.cp.name.value}` + `${faction.cp.name.slug} are launching an attack on ${invadedSector.cp.name.value}, #${commander.id} as commander` ); } }; diff --git a/core/systems/attacking.ts b/core/systems/attacking.ts index 01766c04..b4ac6e21 100644 --- a/core/systems/attacking.ts +++ b/core/systems/attacking.ts @@ -8,10 +8,16 @@ import type { Entity } from "@core/entity"; import { stopCruise } from "@core/utils/moving"; import { Vec2 } from "ogl"; import { entityIndexer } from "@core/entityIndexer/entityIndexer"; +import type { TransformData } from "@core/components/transform"; +import { filter, flatMap, map, pipe, some } from "@fxts/core"; +import type { Turret } from "@core/archetypes/turret"; +import type { AttackOrder } from "@core/components/orders"; import { regenCooldown } from "./hitpointsRegenerating"; import { System } from "./system"; import { transport3D } from "./transport3d"; +const tempVec2 = new Vec2(); + const sizeMultipliers: Record = { large: [0.1, -5, 10], medium: [0.5, -1, 25], @@ -22,79 +28,45 @@ export function getEvasionChance(speed: number, size: DockSize): number { return Math.max(0, ((b / speed) * 10 * a + c) / 100); } -function getAngleDiff( - origin: RequireComponent<"position">, - target: RequireComponent<"position"> -): number { - const targetVector = new Vec2() - .copy(target.cp.position.coord) - .sub(origin.cp.position.coord); - - const angle = - Math.atan2(targetVector[1], targetVector[0]) - origin.cp.position.angle; - - return angle > Math.PI - ? angle - 2 * Math.PI - : angle < -Math.PI - ? angle + 2 * Math.PI - : angle; -} - -export function isInDistance( - entity: RequireComponent<"damage">, - target: RequireComponent<"position">, - r: number = entity.cp.damage.range -): boolean { - return ( - findInAncestors(entity, "position").cp.position.coord.distance( - target.cp.position.coord - ) <= r - ); -} - -export function isInRange( - entity: RequireComponent<"damage">, - target: RequireComponent<"position"> -) { - const inDistance = isInDistance(entity, target); - if (!inDistance) return false; - - const parentWithPosition = findInAncestors(entity, "position"); - const angleDiff = getAngleDiff(parentWithPosition, target); +/** + * + * @param angle Angle in radians + * @returns Normalized angle in the range of -π to π + */ +function normalizeAngle(angle: number): number { + let out = angle; - return Math.abs(angleDiff) <= entity.cp.damage.angle / 2; -} - -function shouldAttackBack( - attacker: RequireComponent<"damage">, - target: RequireComponent<"position"> -): boolean { - const attackerOrParent = findInAncestors(attacker, "position"); - - return ( - !!target.cp.damage && - target.tags.has("role:military") && - (target.cp.orders?.value[0]?.type !== "attack" || - (target.cp.orders.value[0].targetId !== attackerOrParent.id && - !attacker.sim - .getOrThrow(target.cp.orders.value[0].targetId) - .tags.has("role:military") && - isInDistance(target.requireComponents(["damage"]), attackerOrParent))) - ); + while (out > Math.PI) { + out -= 2 * Math.PI; + } + while (out < -Math.PI) { + out += 2 * Math.PI; + } + return out; } function attack(attacker: RequireComponent<"orders">, target: Entity) { - if (attacker.cp.orders.value[0]) { - attacker.cp.orders.value[0].interrupt = true; - } - attacker.cp.orders.value.splice(1, 0, { + const order: AttackOrder = { type: "attack", actions: [], followOutsideSector: false, ordersForSector: 0, origin: "auto", targetId: findInAncestors(target, "position").id, - }); + }; + + if (attacker.cp.orders.value[0]) { + if ( + attacker.cp.orders.value[0]?.origin === "auto" && + attacker.cp.orders.value[0].type === "attack" + ) { + attacker.cp.orders.value[0] = order; + return; + } + + attacker.cp.orders.value[0].interrupt = true; + } + attacker.cp.orders.value.splice(1, 0, order); } const cdKey = "attack"; @@ -125,9 +97,20 @@ export class AttackingSystem extends System { const target = this.sim .getOrThrow(entity.cp.damage.targetId) .requireComponents(["position", "hitpoints"]); - const entityOrParent = findInAncestors(entity, "position"); + const parentEntity = findInAncestors(entity, "position"); + const entityTransform: TransformData = (entity.cp.transform?.world ?? + entity.cp.position)!; - if (!isInRange(entity, target)) continue; + if ( + !AttackingSystem.isInShootingRange( + entityTransform.coord, + entityTransform.angle, + target.cp.position.coord, + entity.cp.damage.range, + entity.cp.damage.angle + ) + ) + continue; entity.cooldowns.use(cdKey, entity.cp.damage.cooldown); if ( @@ -139,30 +122,24 @@ export class AttackingSystem extends System { target.cp.dockable.size ) ) { - transport3D.hooks.shoot.notify( - entity.requireComponents(["position", "damage"]) - ); dealDamageToEntity(target, entity.cp.damage.output.current, entity.id); - const parentEntity = entityOrParent; + if (entity.hasComponents(["transform"])) { + transport3D.publish({ + type: "shoot", + entity, + }); + } if (target.hasComponents(["drive", "movable"])) { stopCruise(target); } - if (shouldAttackBack(entity, target)) { - if (target.cp.orders) { - attack(target.requireComponents(["orders"]), entity); - } - } else if ( - target.cp.damage && - !target.tags.has("role:military") && - (!target.cp.damage.targetId || - (this.sim.get(target.cp.damage.targetId) && - !isInDistance( - target.requireComponents(["damage"]), - this.sim.get(target.cp.damage.targetId)! - ))) + if ( + AttackingSystem.shouldPursueAttacker(parentEntity, target) && + target.cp.orders ) { - target.cp.damage.targetId = parentEntity.id; + attack(target.requireComponents(["orders"]), parentEntity); + } else if (AttackingSystem.shouldRetribute(parentEntity, target)) { + this.retribute(target, entityTransform.coord, parentEntity.id); } target.cp.subordinates?.ids.forEach((subordinateId) => { const subordinate = this.sim @@ -178,6 +155,107 @@ export class AttackingSystem extends System { target.cooldowns.use(regenCooldown, 2); } }; + + retribute( + target: RequireComponent<"hitpoints" | "position">, + attackerPosition: Vec2, + attackerId: number + ) { + let iterator: Iterable; + + if (target.hasComponents(["children"])) { + iterator = pipe( + target.cp.children.entities, + filter((c) => c.role === "turret"), + map((c) => c.id) + ); + } else if (target.hasComponents(["modules"])) { + iterator = pipe( + target.cp.modules.ids, + map((m) => this.sim.getOrThrow(m)), + filter((m) => m.hasComponents(["children"])), + flatMap((m) => m.cp.children.entities), + filter((c) => c.role === "turret"), + map((c) => c.id) + ); + } else { + throw new Error("This entity has no components to support turrets"); + } + + for (const turretId of iterator) { + const turret = this.sim.getOrThrow(turretId); + if ( + AttackingSystem.isInShootingRange( + turret.cp.transform.world.coord, + turret.cp.transform.world.angle, + attackerPosition, + turret.cp.damage.range, + turret.cp.damage.angle + ) + ) { + turret.cp.damage.targetId = attackerId; + } + } + } + + static isInShootingRange( + originPosition: Vec2, + originAngle: number, + targetPosition: Vec2, + range: number, + arc: number + ): boolean { + const targetVector = tempVec2.copy(targetPosition).sub(originPosition); + const angle = normalizeAngle( + Math.atan2(targetVector[1], targetVector[0]) - originAngle + ); + + return targetVector.len() <= range && Math.abs(angle) <= arc / 2; + } + + static isInTurretShootingRange( + entity: RequireComponent<"position" | "children">, + targetPosition: Vec2 + ): boolean { + return pipe( + entity.cp.children.entities ?? [], + filter((c) => c.role === "turret"), + map((c) => entity.sim.getOrThrow(c.id)), + some((t) => + AttackingSystem.isInShootingRange( + t.cp.transform.world.coord, + t.cp.transform.world.angle, + targetPosition, + t.cp.damage.range, + t.cp.damage.angle + ) + ) + ); + } + + static shouldPursueAttacker( + attacker: Entity, + target: RequireComponent<"position"> + ): boolean { + return ( + target.tags.has("role:military") && + target.cp.orders?.value[0]?.type !== "attack" && + (!( + attacker.cp.dockable?.size === "small" && + target.cp.dockable?.size !== "small" + ) || + attacker.cp.dockable?.size !== "small") + ); + } + + static shouldRetribute( + _attacker: Entity, + target: RequireComponent<"position"> + ): boolean { + return ( + target.hasComponents(["children"]) || target.hasComponents(["modules"]) + ); + } } export const attackingSystem = new AttackingSystem(); diff --git a/core/systems/deadUnregistering.ts b/core/systems/deadUnregistering.ts index 9ef8de94..15fd2533 100644 --- a/core/systems/deadUnregistering.ts +++ b/core/systems/deadUnregistering.ts @@ -4,6 +4,7 @@ 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 { findInAncestors } from "@core/utils/findInAncestors"; import { System } from "./system"; import { transport3D } from "./transport3d"; @@ -38,7 +39,10 @@ export class DeadUnregisteringSystem extends System { } if (entity.hasComponents(["position"])) - transport3D.hooks.explode.notify(entity); + transport3D.publish({ + type: "explode", + entity, + }); const attackers: number[] = []; @@ -54,8 +58,11 @@ export class DeadUnregisteringSystem extends System { 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); + if (!attacker) continue; + + const parentWithExperience = findInAncestors(attacker, "experience"); + if (parentWithExperience) { + addExperience(parentWithExperience, exp); } } diff --git a/core/systems/moving.ts b/core/systems/moving.ts index 51de3123..05ff3bc4 100644 --- a/core/systems/moving.ts +++ b/core/systems/moving.ts @@ -1,5 +1,6 @@ import { entityIndexer } from "@core/entityIndexer/entityIndexer"; import { Vec2 } from "ogl"; +import type { TransformData } from "@core/components/transform"; import type { Sim } from "../sim"; import type { RequireComponent } from "../tsHelpers"; import { System } from "./system"; @@ -24,62 +25,104 @@ function applyToDocked(entity: RequireComponent<"position" | "movable">) { } } +export function applyParentTransform( + entity: RequireComponent<"transform">, + transform: TransformData +): void { + entity.cp.transform.world.coord + .copy(transform.coord) + .add(entity.cp.transform.coord); + entity.cp.transform.world.angle = transform.angle + entity.cp.transform.angle; +} + +export function applyPositionToChildren( + entity: + | RequireComponent<"children" | "position"> + | RequireComponent<"children" | "transform"> +): void { + const worldTransform = (entity.cp.position ?? entity.cp.transform?.world)!; + for (const { id } of entity.cp.children.entities) { + const child = entity.sim.getOrThrow(id); + if (child.hasComponents(["transform"])) { + applyParentTransform(child, worldTransform); + + if (child.hasComponents(["children"])) { + applyPositionToChildren(child); + } + } + } +} + const tempDrag = new Vec2(); const tempAcceleration = new Vec2(); const tempVelocity = new Vec2(); export const dragCoeff = 0.01; -function move(entity: Movable, delta: number) { - const position = entity.cp.position.coord; - const velocity = entity.cp.movable.velocity; - const acceleration = entity.cp.movable.acceleration; - - const dPos = tempVelocity - .copy(velocity) - .multiply(delta) - .add(tempAcceleration.copy(acceleration).multiply(delta ** 2 / 2)); - position.add(dPos); - const dVel = tempAcceleration - .copy(acceleration) - .sub(tempDrag.copy(velocity).multiply(dragCoeff + entity.cp.movable.drag)) - .multiply(delta); - velocity.add(dVel); - - const speed = velocity.len(); - const maxSpeed = entity.hasComponents(["drive"]) - ? entity.cp.drive.state === "cruise" - ? entity.cp.drive.cruise - : entity.cp.drive.maneuver - : baseMaxSpeed; - if (speed > 0) { - entity.cp.movable.velocity.scale(Math.min(speed, maxSpeed) / speed); - } - - entity.cp.position.angle = - (entity.cp.position.angle + entity.cp.movable.rotary) % (2 * Math.PI); - - applyToDocked(entity); -} - export class MovingSystem extends System { entities: Movable[]; apply = (sim: Sim): void => { super.apply(sim); - sim.hooks.phase.update.subscribe(this.constructor.name, this.exec); + sim.hooks.phase.update.subscribe( + this.constructor.name, + this.exec.bind(this) + ); }; // eslint-disable-next-line class-methods-use-this exec(delta: number): void { if (delta > 0) { for (const entity of entityIndexer.search(["movable", "position"])) { - if (!entity.cp.dockable?.dockedIn) { - move(entity, delta); + if (!entity.cp.dockable?.dockedIn || entity.cp.dockable?.undocking) { + this.move(entity, delta); } } } } + + // eslint-disable-next-line class-methods-use-this + move(entity: Movable, delta: number) { + const velocity = entity.cp.movable.velocity; + const acceleration = entity.cp.movable.acceleration; + + const dPos = tempVelocity + .copy(velocity) + .multiply(delta) + .add(tempAcceleration.copy(acceleration).multiply(delta ** 2 / 2)); + const dAngle = entity.cp.movable.rotary; + MovingSystem.changeEntityPosition(entity, dPos, dAngle); + const dVel = tempAcceleration + .copy(acceleration) + .sub(tempDrag.copy(velocity).multiply(dragCoeff + entity.cp.movable.drag)) + .multiply(delta); + velocity.add(dVel); + + const speed = velocity.len(); + const maxSpeed = entity.hasComponents(["drive"]) + ? entity.cp.drive.state === "cruise" + ? entity.cp.drive.cruise + : entity.cp.drive.maneuver + : baseMaxSpeed; + if (speed > 0) { + entity.cp.movable.velocity.scale(Math.min(speed, maxSpeed) / speed); + } + } + + static changeEntityPosition( + entity: RequireComponent<"position" | "movable">, + dPos: Vec2, + dAngle: number + ) { + entity.cp.position.coord.add(dPos); + entity.cp.position.angle = + (entity.cp.position.angle + dAngle) % (2 * Math.PI); + + if (entity.hasComponents(["children"])) { + applyPositionToChildren(entity); + } + applyToDocked(entity); + } } export const movingSystem = new MovingSystem(); diff --git a/core/systems/navigating/flyby.ts b/core/systems/navigating/flyby.ts index cb50ec4f..c83a40a8 100644 --- a/core/systems/navigating/flyby.ts +++ b/core/systems/navigating/flyby.ts @@ -5,7 +5,6 @@ import type { RequireComponent } from "@core/tsHelpers"; import type { Navigable } from "./types"; import { createThrust, type Thrust } from "./thrust"; import { brake } from "./utils"; -import { isInRange } from "../attacking"; const tempVelocity = new Vec2(); const tempForward = new Vec2(); @@ -46,12 +45,6 @@ export function flyBy( thrust.angular = (Math.min(1, 1 - speedPercent + 0.5) * (alignmentToTarget + 1)) / 2; thrust.drag = Math.min(0.7, (1 - alignmentToTarget) ** 2); - if (speed > 0) { - thrust.lateral - .copy(entity.cp.movable.velocity) - .sub(tempVec2.copy(forward).multiply(forwardMag)) - .normalize(); - } if (alignmentToTarget < 0.8) { thrust.throttle = clamp( @@ -74,6 +67,11 @@ export function flyBy( } if (speed > 0) { + thrust.lateral + .copy(entity.cp.movable.velocity) + .sub(tempVec2.copy(forward).multiply(forwardMag)) + .normalize(); + if ( alignmentToVelocity > 0.2 && (entity.cp.hitpoints?.shield?.value ?? 1) / @@ -84,21 +82,6 @@ export function flyBy( thrust.throttle = 1; } else if (alignmentToTarget < 0.4 && alignmentToTargetForward > 0.5) { brake(entity, 0, thrust); - } else if ( - alignmentToTarget > 0.9 && - alignmentToTargetForward > 0.7 && - entity.hasComponents(["damage"]) - ? isInRange(entity, target) - : true - ) { - brake( - entity, - Math.min( - entity.cp.drive.maneuver, - target.cp.movable?.velocity.len() ?? 0 - ), - thrust - ); } else if (alignmentToVelocity < 0) { brake(entity, 0, thrust); } diff --git a/core/systems/navigating/goto.ts b/core/systems/navigating/goto.ts index 2c5abd02..65684d8f 100644 --- a/core/systems/navigating/goto.ts +++ b/core/systems/navigating/goto.ts @@ -12,9 +12,9 @@ const tempForward = new Vec2(); const tempVec2 = new Vec2(); const brakeAlignment: Record = { - large: 0.95, - medium: 0.8, - small: 0.4, + large: 0.98, + medium: 0.92, + small: 0.5, }; export function goToPosition(entity: Navigable, target: Vec2): Thrust { @@ -77,7 +77,7 @@ export function goToPosition(entity: Navigable, target: Vec2): Thrust { ) { brake(entity, 0, thrust); } else if (alignmentToVelocity > 0.2 && alignmentToTarget < 0.2) { - brake(entity, (1 + alignmentToTarget) / 10, thrust); + brake(entity, 0, thrust); } else if ( alignmentToTarget < brakeAlignment[entity.cp.dockable?.size ?? "large"] ) { diff --git a/core/systems/navigating/utils.ts b/core/systems/navigating/utils.ts index b89c2e4f..d5501495 100644 --- a/core/systems/navigating/utils.ts +++ b/core/systems/navigating/utils.ts @@ -16,6 +16,7 @@ export function brake(entity: Navigable, targetSpeed: number, thrust: Thrust) { if (speed > targetSpeed) { thrust.lateral.copy(entity.cp.movable.velocity).normalize(); thrust.throttle = 0; + thrust.drag += 0.1; } return thrust; diff --git a/core/systems/orderExecuting/attack.ts b/core/systems/orderExecuting/attack.ts index 776479d3..a95c2cd0 100644 --- a/core/systems/orderExecuting/attack.ts +++ b/core/systems/orderExecuting/attack.ts @@ -1,43 +1,46 @@ import type { Waypoint } from "@core/archetypes/waypoint"; -import type { - AttackAction, - AttackOrder, - MoveAction, -} from "@core/components/orders"; +import type { AttackAction, AttackOrder } from "@core/components/orders"; import type { RequireComponent } from "@core/tsHelpers"; import { clearTarget, moveToActions, setTarget } from "@core/utils/moving"; -import { filter, first, pipe, sort } from "@fxts/core"; -import { isInDistance, isInRange } from "../attacking"; +import { filter, first, map, min, pipe, sort, toArray } from "@fxts/core"; +import type { Turret } from "@core/archetypes/turret"; +import { entityIndexer } from "@core/entityIndexer/entityIndexer"; +import { AttackingSystem } from "../attacking"; import { defaultIndexer } from "../utils/default"; +import { SpottingSystem } from "../ai/spotting"; type OffensiveEntity = RequireComponent< - "drive" | "movable" | "position" | "orders" | "damage" + "drive" | "movable" | "position" | "orders" | "children" >; export function attackOrder(entity: OffensiveEntity, group: AttackOrder) { - entity.cp.drive.minimalDistance = entity.cp.damage.range * 0.4; - entity.cp.damage.targetId = group.targetId; + const turrets = (entity.cp.children?.entities ?? []) + .filter((c) => c.role === "turret") + .map((c) => entity.sim.getOrThrow(c.id)); + const range = pipe( + turrets, + map((t) => t.cp.damage.range), + min + ); + const target = entity.sim.getOrThrow(group.targetId); - const moveOrders = group.actions.filter((o) => o.type === "move"); - const lastMoveOrder = moveOrders.at(-1) as MoveAction; - const inRange = isInDistance(entity, target); - - const shouldRecreateOrders = lastMoveOrder - ? target.cp.position.sector !== group.ordersForSector - : true; - - if (shouldRecreateOrders) { - group.actions = [ - ...(inRange ? [] : moveToActions(entity, target)), - { type: "attack", targetId: target.id }, - ]; - group.ordersForSector = target.cp.position.sector; - } + entity.cp.drive.minimalDistance = range * 0.4; + + const shouldRecreatMoveActions = + target.cp.position.sector !== group.ordersForSector; + + group.actions = [ + ...(shouldRecreatMoveActions ? moveToActions(entity, target) : []), + { type: "attack", targetId: target.id }, + ]; + group.ordersForSector = target.cp.position.sector; if ( (!group.followOutsideSector && target.cp.position.sector !== entity.cp.position.sector) || - (group.maxDistance && !isInDistance(entity, target, group.maxDistance)) + (group.maxDistance && + entity.cp.position.coord.squaredDistance(target.cp.position.coord) > + group.maxDistance ** 2) ) { group.actions = []; } @@ -56,7 +59,9 @@ export function isAttackOrderCompleted( : group.followOutsideSector ? false : target.cp.position.sector !== entity.cp.position.sector || - (!!group.maxDistance && !isInDistance(entity, target, group.maxDistance)); + (!!group.maxDistance && + entity.cp.position.coord.squaredDistance(target.cp.position.coord) > + group.maxDistance ** 2); } export function attackAction( @@ -65,6 +70,19 @@ export function attackAction( >, action: AttackAction ): boolean { + const turrets = pipe( + entity.cp.children?.entities ?? [], + filter((c) => c.role === "turret"), + map((c) => entity.sim.getOrThrow(c.id)), + toArray + ); + + for (const turret of turrets) { + if (!turret.cp.damage.targetId) { + turret.cp.damage.targetId = action.targetId; + } + } + if ( entity.tags.has("role:military") && entity.cp.dockable?.size === "small" && @@ -73,17 +91,70 @@ export function attackAction( ) { const potentialTarget = pipe( defaultIndexer.sectorShips.getIt(entity.cp.position.sector), - filter((s) => isInRange(entity, s) && s.id !== entity.id), - sort( - (a, b) => - entity.cp.position.coord.squaredDistance(a.cp.position.coord) - - entity.cp.position.coord.squaredDistance(b.cp.position.coord) + filter( + (s) => + s.id !== entity.id && + turrets.some((t) => + AttackingSystem.isInShootingRange( + t.cp.transform.world.coord, + t.cp.transform.world.angle, + s.cp.position.coord, + t.cp.damage.range, + t.cp.damage.angle + ) + ) ), + map((s) => ({ + distance: s.cp.position.coord.squaredDistance(s.cp.position.coord), + entity: s, + })), + sort((a, b) => a.distance - b.distance), first - ); + )?.entity; if (potentialTarget) { action.targetId = potentialTarget.id; } + } else if (entity.cp.dockable?.size !== "small") { + // Set turrets to attack either target or the closest ship in range + const target = entity.sim.getOrThrow(action.targetId); + + for (const turret of turrets) { + turret.cp.damage.targetId = action.targetId; + + if ( + !AttackingSystem.isInShootingRange( + turret.cp.transform.world.coord, + turret.cp.transform.world.angle, + target.cp.position.coord, + turret.cp.damage.range, + turret.cp.damage.angle + ) + ) { + const enemies = SpottingSystem.getEnemies( + entityIndexer.searchBySector(entity.cp.position.sector, [ + "hitpoints", + "owner", + "position", + ]), + entity.requireComponents(["position", "owner"]) + ); + + for (const enemy of enemies) { + if ( + AttackingSystem.isInShootingRange( + turret.cp.transform.world.coord, + turret.cp.transform.world.angle, + enemy.entity.cp.position.coord, + turret.cp.damage.range, + turret.cp.damage.angle + ) + ) { + turret.cp.damage.targetId = enemy.entity.id; + break; + } + } + } + } } const target = entity.sim.getOrThrow(action.targetId); @@ -96,16 +167,18 @@ export function attackAction( entity.cp.drive.mode = "flyby"; } - return entity.cp.damage.targetId - ? !entity.sim.get(entity.cp.damage.targetId) - : true; + return !entity.sim.get(action.targetId); } export function attackActionCleanup(entity: OffensiveEntity): void { clearTarget(entity); entity.cp.drive.limit = Infinity; entity.cp.drive.minimalDistance = 0.01; - entity.cp.damage.targetId = null; + + for (const turret of entity.cp.children?.entities ?? []) { + const turretEntity = entity.sim.getOrThrow(turret.id); + turretEntity.cp.damage.targetId = null; + } } export function attackOrderCompleted(entity: OffensiveEntity) { diff --git a/core/systems/orderExecuting/deployFacility.ts b/core/systems/orderExecuting/deployFacility.ts index dc08dfb3..ba85874c 100644 --- a/core/systems/orderExecuting/deployFacility.ts +++ b/core/systems/orderExecuting/deployFacility.ts @@ -11,7 +11,10 @@ import { addFacilityModule } from "@core/utils/entityModules"; import { transport3D } from "../transport3d"; export function deployFacilityAction(entity: Ship): boolean { - transport3D.hooks.deployFacility.notify(entity); + transport3D.publish({ + type: "deployFacility", + entity, + }); const render = createRender({ ...entity.cp.render, diff --git a/core/systems/orderExecuting/mine.ts b/core/systems/orderExecuting/mine.ts index d276d04f..492912d7 100644 --- a/core/systems/orderExecuting/mine.ts +++ b/core/systems/orderExecuting/mine.ts @@ -50,7 +50,10 @@ export function mineAction( if (!targetField.cp.mineable.mountPoints.used.includes(entity.id)) { targetField.cp.mineable.mountPoints.used.push(entity.id); - transport3D.hooks.startMining.notify(entity); + transport3D.publish({ + type: "startMining", + entity, + }); } if (getAvailableSpace(entity.cp.storage) === 0) { @@ -59,7 +62,10 @@ export function mineAction( targetField.cp.mineable.mountPoints.used = targetField.cp.mineable.mountPoints.used.filter((id) => id !== entity.id); - transport3D.hooks.stopMining.notify(entity); + transport3D.publish({ + type: "stopMining", + entity, + }); return true; } diff --git a/core/systems/transport3d.ts b/core/systems/transport3d.ts index 82cefb71..9a7e027a 100644 --- a/core/systems/transport3d.ts +++ b/core/systems/transport3d.ts @@ -1,30 +1,33 @@ -import type { RequirePureComponent } from "@core/tsHelpers"; -import { Observable } from "@core/utils/observer"; +import type { RequireComponent } from "@core/tsHelpers"; +import { PubSub } from "@core/utils/pubsub"; -export class Transport3D { - hooks: { - shoot: Observable>; - explode: Observable>; - deployFacility: Observable>; - startMining: Observable>; - stopMining: Observable>; - }; +export type ShootEvent = { + type: "shoot"; + entity: RequireComponent<"damage" | "transform">; +}; - constructor() { - this.hooks = { - shoot: new Observable("shoot"), - explode: new Observable("explode"), - deployFacility: new Observable("deployFacility"), - startMining: new Observable("startMining"), - stopMining: new Observable("stopMining"), - }; - } +export type ExplodeEvent = { + type: "explode"; + entity: RequireComponent<"position">; +}; +export type StartMiningEvent = { + type: "startMining"; + entity: RequireComponent<"position">; +}; +export type StopMiningEvent = { + type: "stopMining"; + entity: RequireComponent<"position">; +}; +export type DeployFacilityEvent = { + type: "deployFacility"; + entity: RequireComponent<"position">; +}; - reset() { - for (const key of Object.keys(this.hooks)) { - this.hooks[key].reset(); - } - } -} +export type Event3D = + | ShootEvent + | ExplodeEvent + | StartMiningEvent + | StopMiningEvent + | DeployFacilityEvent; -export const transport3D = new Transport3D(); +export const transport3D = new PubSub(); diff --git a/core/utils/findInAncestors.ts b/core/utils/findInAncestors.ts index 053cee92..64c0027a 100644 --- a/core/utils/findInAncestors.ts +++ b/core/utils/findInAncestors.ts @@ -8,8 +8,9 @@ import type { RequireComponent } from "../tsHelpers"; export function findInAncestors( entity: Entity, component: T, - visited: number[] = [] + v?: number[] ): RequireComponent { + const visited = v ?? []; if (visited.includes(entity.id)) { throw new Error("Cyclic ancestry detected"); } @@ -18,10 +19,11 @@ export function findInAncestors( } if (entity.hasComponents(["parent"])) { + visited.push(entity.id); return findInAncestors( entity.sim.getOrThrow(entity.requireComponents(["parent"]).cp.parent.id), component, - [...visited, entity.id] + visited ); } diff --git a/core/utils/ownership.ts b/core/utils/ownership.ts index a0311824..52148d61 100644 --- a/core/utils/ownership.ts +++ b/core/utils/ownership.ts @@ -22,8 +22,8 @@ export function transferOwnership( } if (entity.cp.children) { - for (const childId of entity.cp.children.entities) { - const child = entity.sim.getOrThrow(childId); + for (const { id } of entity.cp.children.entities) { + const child = entity.sim.getOrThrow(id); if (child.cp.owner) { transferOwnership(child.requireComponents(["owner"]), newOwnerId); diff --git a/core/utils/pubsub.ts b/core/utils/pubsub.ts new file mode 100644 index 00000000..cf514a3b --- /dev/null +++ b/core/utils/pubsub.ts @@ -0,0 +1,32 @@ +export type EventHandler = (_data: T) => void; + +export class PubSub { + private subscribers: Map>> = new Map(); + + subscribe( + event: TType, + callback: (_data: T extends { type: TType } ? T : never) => void + ): () => void { + if (!this.subscribers.has(event)) { + this.subscribers.set(event, new Set()); + } + this.subscribers.get(event)?.add(callback); + return () => { + this.unsubscribe(event, callback); + }; + } + + unsubscribe(event: string, callback: EventHandler): void { + this.subscribers.get(event)?.delete(callback); + } + + publish( + data: T extends { type: TType } ? T : never + ): void { + this.subscribers.get(data.type)?.forEach((callback) => callback(data)); + } + + reset(): void { + this.subscribers.clear(); + } +} diff --git a/core/world/data/base.json b/core/world/data/base.json index a5b119ca..42d18c59 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:3506873d04bc329048f96b7f7027d5cf2e1d39d33adc29e9b17fe7f8ad2366ca -size 3468373 +oid sha256:fae8b681761a22ea3e879291cabb589d3cab1807a1743426466760c09ce2272d +size 3813454 diff --git a/core/world/data/ships.json b/core/world/data/ships.json index 98b7bf43..d99a7300 100644 --- a/core/world/data/ships.json +++ b/core/world/data/ships.json @@ -36,7 +36,9 @@ "value": 30, "regen": 2 } - } + }, + "turrets": [], + "slots": [] }, { "name": "Courier B", @@ -75,7 +77,9 @@ "value": 30, "regen": 2 } - } + }, + "turrets": [], + "slots": [] }, { "name": "Freighter A", @@ -114,7 +118,9 @@ "value": 90, "regen": 4 } - } + }, + "turrets": [], + "slots": [] }, { "name": "Freighter B", @@ -153,7 +159,9 @@ "value": 90, "regen": 4 } - } + }, + "turrets": [], + "slots": [] }, { "name": "Large Freighter A", @@ -192,7 +200,9 @@ "value": 300, "regen": 8 } - } + }, + "turrets": [], + "slots": [] }, { "name": "Large Freighter B", @@ -231,7 +241,9 @@ "value": 300, "regen": 8 } - } + }, + "turrets": [], + "slots": [] }, { "name": "Marble", @@ -270,7 +282,9 @@ "value": 90, "regen": 2 } - } + }, + "turrets": [], + "slots": [] }, { "name": "Miner B", @@ -309,7 +323,9 @@ "value": 90, "regen": 2 } - } + }, + "turrets": [], + "slots": [] }, { "acceleration": 0.13, @@ -346,7 +362,9 @@ "value": 90, "regen": 2 } - } + }, + "turrets": [], + "slots": [] }, { "acceleration": 0.05, @@ -386,7 +404,9 @@ "value": 300, "regen": 10 } - } + }, + "turrets": [], + "slots": [] }, { "acceleration": 0.06, @@ -426,7 +446,9 @@ "value": 300, "regen": 10 } - } + }, + "turrets": [], + "slots": [] }, { "acceleration": 0.2, @@ -467,7 +489,29 @@ "value": 35, "regen": 3 } - } + }, + "slots": [ + { + "slug": "turret1", + "angle": 0 + }, + { + "slug": "turret2", + "angle": 0 + } + ], + "turrets": [ + { + "angle": 0.314, + "class": "rapidKinetic", + "slot": "turret1" + }, + { + "angle": 0.314, + "class": "rapidKinetic", + "slot": "turret2" + } + ] }, { "acceleration": 0.15, @@ -508,7 +552,9 @@ "value": 120, "regen": 6 } - } + }, + "turrets": [], + "slots": [] }, { "acceleration": 0.27, @@ -547,10 +593,12 @@ "value": 45, "regen": 3.5 } - } + }, + "turrets": [], + "slots": [] }, { - "acceleration": 0.18, + "acceleration": 0.03, "build": { "time": 180, "cost": { @@ -570,7 +618,7 @@ "role": "military", "cruise": 1.4, "maneuver": 0.22, - "rotary": 87, + "rotary": 35, "ttc": 2.3, "name": "Hornet", "size": "medium", @@ -586,7 +634,65 @@ "value": 155, "regen": 8 } - } + }, + "turrets": [ + { + "angle": 3.14, + "class": "smallFlak", + "slot": "turret1" + }, + { + "angle": 3.14, + "class": "smallFlak", + "slot": "turret2" + }, + { + "angle": 3.14, + "class": "pulsar", + "slot": "turret3" + }, + { + "angle": 3.14, + "class": "pulsar", + "slot": "turret4" + }, + { + "angle": 0.31, + "class": "kinetic", + "slot": "turret5" + }, + { + "angle": 0.31, + "class": "kinetic", + "slot": "turret6" + } + ], + "slots": [ + { + "slug": "turret1", + "angle": -1.57 + }, + { + "slug": "turret2", + "angle": 1.57 + }, + { + "slug": "turret3", + "angle": -1.57 + }, + { + "slug": "turret4", + "angle": 1.57 + }, + { + "slug": "turret5", + "angle": 0 + }, + { + "slug": "turret6", + "angle": 0 + } + ] }, { "acceleration": 0.09, @@ -625,7 +731,9 @@ "value": 310, "regen": 6.5 } - } + }, + "turrets": [], + "slots": [] }, { "acceleration": 0.1, @@ -662,7 +770,9 @@ "value": 35, "regen": 2.5 } - } + }, + "turrets": [], + "slots": [] }, { "acceleration": 0.15, @@ -700,7 +810,9 @@ "value": 45, "regen": 5 } - } + }, + "turrets": [], + "slots": [] }, { "acceleration": 0.23, @@ -734,7 +846,9 @@ "value": 13, "regen": 2.5 } - } + }, + "turrets": [], + "slots": [] }, { "acceleration": 0.16, @@ -773,7 +887,9 @@ "small": 6, "medium": 2, "large": 0 - } + }, + "turrets": [], + "slots": [] }, { "acceleration": 0.18, @@ -807,7 +923,9 @@ "value": 90, "regen": 5 } - } + }, + "turrets": [], + "slots": [] }, { "acceleration": 0.09, @@ -846,6 +964,8 @@ "value": 30, "regen": 1.9 } - } + }, + "turrets": [], + "slots": [] } ] diff --git a/core/world/data/turrets.json b/core/world/data/turrets.json new file mode 100644 index 00000000..96db4475 --- /dev/null +++ b/core/world/data/turrets.json @@ -0,0 +1,56 @@ +[ + { + "name": "Small Flak", + "slug": "smallFlak", + "damage": 5, + "range": 1, + "cooldown": 1, + "type": "kinetic", + "color": "#e6c297" + }, + { + "name": "Rapid Kinetic", + "slug": "rapidKinetic", + "damage": 4.5, + "range": 1, + "cooldown": 0.7, + "type": "kinetic", + "color": "#ff2e2e" + }, + { + "name": "Kinetic", + "slug": "kinetic", + "damage": 10, + "range": 2, + "cooldown": 1, + "type": "kinetic", + "color": "#ff7220" + }, + { + "name": "Pulsar", + "slug": "pulsar", + "damage": 25, + "range": 0.7, + "cooldown": 2, + "type": "laser", + "color": "#53dfff" + }, + { + "name": "Laser", + "slug": "laser", + "damage": 65, + "range": 2.5, + "cooldown": 7, + "type": "laser", + "color": "#f78a75" + }, + { + "name": "Ion", + "slug": "ion", + "damage": 125, + "range": 2.7, + "cooldown": 8, + "type": "laser", + "color": "#ccff7a" + } +] diff --git a/core/world/ships.ts b/core/world/ships.ts index ba94480a..c75bb937 100644 --- a/core/world/ships.ts +++ b/core/world/ships.ts @@ -37,6 +37,8 @@ export interface ShipInput extends ShipDriveProps { texture: keyof Textures; size: DockSize; role: ShipRole; + turrets: Array<{ slot: string; class: string; angle: number }>; + slots: Array<{ slug: string; angle: number }>; } export const shipClasses = shipClassesData.map((s) => ({ @@ -46,3 +48,7 @@ export const shipClasses = shipClassesData.map((s) => ({ angle: s.damage.angle, }, })) as ShipInput[]; + +export function getShipClass(slug: string) { + return shipClasses.find((s) => s.slug === slug); +} diff --git a/core/world/turrets.ts b/core/world/turrets.ts new file mode 100644 index 00000000..327661b5 --- /dev/null +++ b/core/world/turrets.ts @@ -0,0 +1,26 @@ +import { fromEntries, map, pipe } from "@fxts/core"; +import type { DamageType } from "@core/components/damage"; +import turretClasses from "./data/turrets.json"; + +const turretClassesMap = pipe( + turretClasses, + map( + (t) => + [ + t.slug, + { + ...t, + type: t.type as DamageType, + }, + ] as const + ), + fromEntries +); + +export function listTurrets() { + return turretClasses; +} + +export function getTurretBySlug(slug: string) { + return turretClassesMap[slug]; +} diff --git a/devtools/charts/styles.scss b/devtools/charts/styles.scss index 6e91c9f0..9438a998 100644 --- a/devtools/charts/styles.scss +++ b/devtools/charts/styles.scss @@ -1,3 +1,25 @@ .root { position: relative; + max-width: usesize(100); + margin: usesize(10) auto; + + ul { + display: flex; + gap: usesize(1); + } + + li { + &::marker { + color: transparent; + } + + border: 1px solid var(--palette-border); + border-radius: usesize(0.2); + display: block; + padding: usesize(1); + + a:any-link { + color: var(--palette-text-1) !important; + } + } } diff --git a/devtools/index.scss b/devtools/index.scss index 3f162159..8222408f 100644 --- a/devtools/index.scss +++ b/devtools/index.scss @@ -30,7 +30,9 @@ } .root { - padding-bottom: usesize(1.6); + --header-height: usesize(6); + --body-bottom-padding: usesize(1.6); + padding-bottom: var(--body-bottom-padding); } .header { @@ -44,6 +46,7 @@ gap: usesize(1.6); align-items: center; z-index: 1; + height: var(--header-height); .headerText { margin-bottom: 0; diff --git a/devtools/index.tsx b/devtools/index.tsx index 7f008b2c..f825ea01 100644 --- a/devtools/index.tsx +++ b/devtools/index.tsx @@ -11,6 +11,8 @@ import { Sectors } from "./Sectors"; import { Charts } from "./charts"; import FacilityBuilder from "./facilityBuilder"; import { Layout } from "./layout"; +import { Turrets } from "./turrets"; +import { Scenarios } from "./scenarios"; const DevToolsCard: React.FC<{ link: string; @@ -33,6 +35,11 @@ const DevToolsIndex: React.FC = () => ( description="Manage ships blueprints, their costs and other properties" link="/dev/ships" /> + ( description="Build prototypes of facilities and see their stats" link="/dev/facility-builder" /> + ); @@ -78,6 +90,8 @@ export const DevTools: React.FC = () => ( } /> } /> } /> + } /> + } /> } /> diff --git a/devtools/layout.tsx b/devtools/layout.tsx index a420136f..c6341c10 100644 --- a/devtools/layout.tsx +++ b/devtools/layout.tsx @@ -13,6 +13,10 @@ const titles = { "/dev/sectors": "Sectors", "/dev/charts": "Charts", "/dev/facility-builder": "Facility Builder", + "/dev/turrets": "Turret Editor", + "/dev/scenarios": "Scenarios", + "/dev/scenarios/frigate": "Scenario: Frigate Under Attack", + "/dev/scenarios/dogfight": "Scenario: Dogfight", }; export const Layout: React.FC> = ({ children }) => { diff --git a/devtools/scenarios/dogfight.tsx b/devtools/scenarios/dogfight.tsx new file mode 100644 index 00000000..305cb855 --- /dev/null +++ b/devtools/scenarios/dogfight.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import { Sim } from "@core/sim"; +import { movingSystem } from "@core/systems/moving"; +import { spottingSystem } from "@core/systems/ai/spotting"; +import { hitpointsRegeneratingSystem } from "@core/systems/hitpointsRegenerating"; +import { cooldownUpdatingSystem } from "@core/systems/cooldowns"; +import { orderPlanningSystem } from "@core/systems/ai/orderPlanning"; +import { orderExecutingSystem } from "@core/systems/orderExecuting/orderExecuting"; +import { attackingSystem } from "@core/systems/attacking"; +import { deadUnregisteringSystem } from "@core/systems/deadUnregistering"; +import { pathPlanningSystem } from "@core/systems/pathPlanning"; +import { createSector } from "@core/archetypes/sector"; +import type { Faction } from "@core/archetypes/faction"; +import { createFaction } from "@core/archetypes/faction"; +import Color from "color"; +import { createShip } from "@core/archetypes/ship"; +import { shipClasses } from "@core/world/ships"; +import { random } from "mathjs"; +import { pickRandom } from "@core/utils/generators"; +import { moveToActions } from "@core/utils/moving"; +import { createWaypoint } from "@core/archetypes/waypoint"; +import { fromPolar } from "@core/utils/misc"; +import { + changeRelations, + relationThresholds, +} from "@core/components/relations"; +import settings from "@core/settings"; +import { TacticalMap } from "@ui/components/TacticalMap/TacticalMap"; +import { gameStore } from "@ui/state/game"; +import { Vec2 } from "ogl"; +import { NavigatingSystem } from "@core/systems/navigating"; +import { useSearchParams } from "react-router-dom"; +import { Asteroids } from "@ogl-engine/builders/Asteroids"; +import { Engine3D } from "@ogl-engine/engine/engine3d"; +import type { TacticalMapScene } from "@ogl-engine/engine/Scene"; + +export const Dogfight = () => { + const [params] = useSearchParams(); + const [sim, setSim] = React.useState(null); + const engine = React.useMemo(() => { + const e = new Engine3D(); + e.hooks.onInit.subscribe("Dogfight", () => { + const asteroids = new Asteroids( + window.renderer, + 10, + 1, + [[new Vec2(0, 0), 10]] as const, + ["ice"] + ); + asteroids.setParent(window.renderer.scene); + }); + return e; + }, []); + + const factions = Number(params.get("factions") ?? 4); + const fighters = Number(params.get("fighters") ?? 2); + + React.useEffect(() => { + settings.bootTime = 0; + const xSim = new Sim({ + systems: [ + pathPlanningSystem, + movingSystem, + spottingSystem, + hitpointsRegeneratingSystem, + new NavigatingSystem(), + cooldownUpdatingSystem, + orderPlanningSystem, + orderExecutingSystem, + attackingSystem, + deadUnregisteringSystem, + ], + }); + xSim.init(); + + document.addEventListener("keydown", (e) => { + if (e.key === " ") { + if (xSim.speed > 0) { + xSim.pause(); + } else { + xSim.setSpeed(xSim.prevSpeed); + } + } + }); + + const sector = createSector(xSim, { + position: [0, 0, 0], + name: "Sector", + slug: "sector", + }); + gameStore.setSector(sector); + + const generatedFactions: Faction[] = []; + for (let i = 0; i < factions; i++) { + const faction = createFaction(`Faction #${i + 1}`, xSim); + faction.cp.color.value = Color.hsv((i / factions) * 360, 100, 100) + .rgb() + .string(); + faction.cp.policies.enemySpotted.military = "attack"; + generatedFactions.push(faction); + + for (let j = 0; j < fighters; j++) { + const fighter = createShip(xSim, { + ...pickRandom(shipClasses.filter(({ slug }) => slug === "dart")), + angle: random(-Math.PI, Math.PI), + position: fromPolar( + (2 * Math.PI * (i * fighters + j)) / (factions * fighters), + random(4, 6) + ), + owner: faction, + sector, + }); + const t = random(0.75, 1); + fighter.cp.drive.cruise *= t; + fighter.cp.drive.maneuver /= t; + fighter.cp.orders.value = [ + { + type: "move", + actions: moveToActions( + fighter, + createWaypoint(xSim, { + value: new Vec2(0, 0), + sector: sector.id, + owner: faction.id, + }), + {} + ), + origin: "manual", + }, + ]; + } + } + for (const factionA of generatedFactions) { + for (const factionB of generatedFactions) { + if (factionA === factionB) continue; + changeRelations(factionA, factionB, relationThresholds.attack - 1); + } + } + + xSim.start(); + setSim(xSim); + + return () => { + xSim.destroy(); + }; + }, [factions, fighters]); + + if (!sim) return null; + + return ; +}; diff --git a/devtools/scenarios/frigateUnderAttack.tsx b/devtools/scenarios/frigateUnderAttack.tsx new file mode 100644 index 00000000..e461ceff --- /dev/null +++ b/devtools/scenarios/frigateUnderAttack.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import { Sim } from "@core/sim"; +import { movingSystem } from "@core/systems/moving"; +import { spottingSystem } from "@core/systems/ai/spotting"; +import { hitpointsRegeneratingSystem } from "@core/systems/hitpointsRegenerating"; +import { cooldownUpdatingSystem } from "@core/systems/cooldowns"; +import { orderPlanningSystem } from "@core/systems/ai/orderPlanning"; +import { orderExecutingSystem } from "@core/systems/orderExecuting/orderExecuting"; +import { attackingSystem } from "@core/systems/attacking"; +import { deadUnregisteringSystem } from "@core/systems/deadUnregistering"; +import { pathPlanningSystem } from "@core/systems/pathPlanning"; +import { createSector } from "@core/archetypes/sector"; +import { createFaction } from "@core/archetypes/faction"; +import Color from "color"; +import { createShip } from "@core/archetypes/ship"; +import { shipClasses } from "@core/world/ships"; +import { random } from "mathjs"; +import { pickRandom } from "@core/utils/generators"; +import { fromPolar } from "@core/utils/misc"; +import { + changeRelations, + relationThresholds, +} from "@core/components/relations"; +import settings from "@core/settings"; +import { TacticalMap } from "@ui/components/TacticalMap/TacticalMap"; +import { gameStore } from "@ui/state/game"; +import { Vec2 } from "ogl"; +import { NavigatingSystem } from "@core/systems/navigating"; +import { useSearchParams } from "react-router-dom"; +import { Asteroids } from "@ogl-engine/builders/Asteroids"; +import { Engine3D } from "@ogl-engine/engine/engine3d"; +import type { TacticalMapScene } from "@ogl-engine/engine/Scene"; + +export const FrigateUnderAttack = () => { + const [params] = useSearchParams(); + const [sim, setSim] = React.useState(null); + const engine = React.useMemo(() => { + const e = new Engine3D(); + e.hooks.onInit.subscribe("FrigateUnderAttack", () => { + const asteroids = new Asteroids( + window.renderer, + 10, + 1, + [[new Vec2(0, 0), 10]] as const, + ["ice"] + ); + asteroids.setParent(window.renderer.scene); + }); + return e; + }, []); + + const fighters = Number(params.get("fighters") ?? 5); + + React.useEffect(() => { + settings.bootTime = 0; + const xSim = new Sim({ + systems: [ + pathPlanningSystem, + movingSystem, + spottingSystem, + hitpointsRegeneratingSystem, + new NavigatingSystem(), + cooldownUpdatingSystem, + orderPlanningSystem, + orderExecutingSystem, + attackingSystem, + deadUnregisteringSystem, + ], + }); + xSim.init(); + + document.addEventListener("keydown", (e) => { + if (e.key === " ") { + if (xSim.speed > 0) { + xSim.pause(); + } else { + xSim.setSpeed(xSim.prevSpeed); + } + } + }); + + const sector = createSector(xSim, { + position: [0, 0, 0], + name: "Sector", + slug: "sector", + }); + gameStore.setSector(sector); + + const frigateFaction = createFaction("Frigate Faction", xSim); + const fightersFaction = createFaction("Fighters Faction", xSim); + frigateFaction.cp.color.value = Color.hsv(0, 100, 100).rgb().string(); + fightersFaction.cp.color.value = Color.hsv(120, 100, 100).rgb().string(); + changeRelations( + frigateFaction, + fightersFaction, + relationThresholds.attack - 1 + ); + + const frigate = createShip(xSim, { + ...shipClasses.find(({ slug }) => slug === "tauFrigate")!, + angle: 0, + position: new Vec2(0, 0), + owner: frigateFaction, + sector, + }); + + for (let j = 0; j < fighters; j++) { + const fighter = createShip(xSim, { + ...pickRandom(shipClasses.filter(({ slug }) => slug === "dart")), + angle: random(-Math.PI, Math.PI), + position: fromPolar((2 * Math.PI * j) / fighters, random(1, 2)), + owner: fightersFaction, + sector, + }); + fighter.cp.orders.value = [ + { + type: "attack", + actions: [], + origin: "manual", + followOutsideSector: false, + ordersForSector: sector.id, + targetId: frigate.id, + }, + ]; + } + + xSim.start(); + setSim(xSim); + + return () => { + xSim.destroy(); + }; + }, [fighters]); + + if (!sim) return null; + + return ; +}; diff --git a/devtools/scenarios/index.tsx b/devtools/scenarios/index.tsx new file mode 100644 index 00000000..470bd8b0 --- /dev/null +++ b/devtools/scenarios/index.tsx @@ -0,0 +1,50 @@ +import { Route, Routes, Link } from "react-router-dom"; +import React from "react"; +import styles from "../charts/styles.scss"; +import { Dogfight } from "./dogfight"; +import { FrigateUnderAttack } from "./frigateUnderAttack"; +import { StationUnderAttack } from "./stationUnderAttack"; + +export const Index: React.FC = () => ( +
+
    +
  • + Dogfight +
  • +
  • + Frigate +
  • +
  • + Station +
  • +
+
+); + +export const Scenarios: React.FC = () => { + // Hack to delay rendering by one cycle, until the pane is mounted + const [mount, setMount] = React.useState(false); + React.useEffect(() => { + setMount(true); + }, []); + + if (!mount) { + return null; + } + + return ( +
+ + } /> + } /> + } /> + } /> + +
+ ); +}; diff --git a/devtools/scenarios/stationUnderAttack.tsx b/devtools/scenarios/stationUnderAttack.tsx new file mode 100644 index 00000000..0b14edc9 --- /dev/null +++ b/devtools/scenarios/stationUnderAttack.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { Sim } from "@core/sim"; +import { movingSystem } from "@core/systems/moving"; +import { spottingSystem } from "@core/systems/ai/spotting"; +import { hitpointsRegeneratingSystem } from "@core/systems/hitpointsRegenerating"; +import { cooldownUpdatingSystem } from "@core/systems/cooldowns"; +import { orderPlanningSystem } from "@core/systems/ai/orderPlanning"; +import { orderExecutingSystem } from "@core/systems/orderExecuting/orderExecuting"; +import { attackingSystem } from "@core/systems/attacking"; +import { deadUnregisteringSystem } from "@core/systems/deadUnregistering"; +import { pathPlanningSystem } from "@core/systems/pathPlanning"; +import { createSector } from "@core/archetypes/sector"; +import { createFaction } from "@core/archetypes/faction"; +import Color from "color"; +import { createShip } from "@core/archetypes/ship"; +import { shipClasses } from "@core/world/ships"; +import { random } from "mathjs"; +import { pickRandom } from "@core/utils/generators"; +import { fromPolar } from "@core/utils/misc"; +import { + changeRelations, + relationThresholds, +} from "@core/components/relations"; +import settings from "@core/settings"; +import { TacticalMap } from "@ui/components/TacticalMap/TacticalMap"; +import { gameStore } from "@ui/state/game"; +import { Vec2 } from "ogl"; +import { NavigatingSystem } from "@core/systems/navigating"; +import { useSearchParams } from "react-router-dom"; +import { Asteroids } from "@ogl-engine/builders/Asteroids"; +import { Engine3D } from "@ogl-engine/engine/engine3d"; +import type { TacticalMapScene } from "@ogl-engine/engine/Scene"; +import { createFacility } from "@core/archetypes/facility"; +import { addFacilityModule } from "@core/utils/entityModules"; +import { facilityModules } from "@core/archetypes/facilityModule"; + +export const StationUnderAttack = () => { + const [params] = useSearchParams(); + const [sim, setSim] = React.useState(null); + const engine = React.useMemo(() => { + const e = new Engine3D(); + e.hooks.onInit.subscribe("StationUnderAttack", () => { + const asteroids = new Asteroids( + window.renderer, + 10, + 1, + [[new Vec2(0, 0), 10]] as const, + ["ice"] + ); + asteroids.setParent(window.renderer.scene); + }); + return e; + }, []); + + const fighters = Number(params.get("fighters") ?? 5); + + React.useEffect(() => { + settings.bootTime = 0; + const xSim = new Sim({ + systems: [ + pathPlanningSystem, + movingSystem, + spottingSystem, + hitpointsRegeneratingSystem, + new NavigatingSystem(), + cooldownUpdatingSystem, + orderPlanningSystem, + orderExecutingSystem, + attackingSystem, + deadUnregisteringSystem, + ], + }); + xSim.init(); + + document.addEventListener("keydown", (e) => { + if (e.key === " ") { + if (xSim.speed > 0) { + xSim.pause(); + } else { + xSim.setSpeed(xSim.prevSpeed); + } + } + }); + + const sector = createSector(xSim, { + position: [0, 0, 0], + name: "Sector", + slug: "sector", + }); + gameStore.setSector(sector); + + const stationFaction = createFaction("Station Faction", xSim); + const fightersFaction = createFaction("Fighters Faction", xSim); + stationFaction.cp.color.value = Color.hsv(0, 100, 100).rgb().string(); + fightersFaction.cp.color.value = Color.hsv(120, 100, 100).rgb().string(); + changeRelations( + stationFaction, + fightersFaction, + relationThresholds.attack - 1 + ); + + const station = createFacility(xSim, { + owner: stationFaction, + sector, + position: new Vec2(0, 0), + }); + addFacilityModule( + station, + facilityModules.smallDefense.create(xSim, station) + ); + + for (let j = 0; j < fighters; j++) { + const fighter = createShip(xSim, { + ...pickRandom(shipClasses.filter(({ slug }) => slug === "dart")), + angle: random(-Math.PI, Math.PI), + position: fromPolar((2 * Math.PI * j) / fighters, random(1, 2)), + owner: fightersFaction, + sector, + }); + fighter.cp.orders.value = [ + { + type: "attack", + actions: [], + origin: "manual", + followOutsideSector: false, + ordersForSector: sector.id, + targetId: station.id, + }, + ]; + } + + xSim.start(); + setSim(xSim); + + return () => { + xSim.destroy(); + }; + }, [fighters]); + + if (!sim) return null; + + return ; +}; diff --git a/devtools/ships/Fight.tsx b/devtools/ships/Fight.tsx index 7565bb10..89043ad6 100644 --- a/devtools/ships/Fight.tsx +++ b/devtools/ships/Fight.tsx @@ -1,92 +1,201 @@ import React from "react"; -import { useFormContext } from "react-hook-form"; +import { useFieldArray, useFormContext } from "react-hook-form"; import type { ShipInput } from "@core/world/ships"; import { useThrottledFormState } from "@devtools/utils"; -import { Table, TableCell, TableHeader } from "../components/Table"; +import { fromPolar } from "@core/utils/misc"; +import { Vec2 } from "ogl"; +import { IconButton } from "@kit/IconButton"; +import { ArrowLeftIcon, CloseIcon } from "@assets/ui/icons"; +import clsx from "clsx"; +import { getTurretBySlug, listTurrets } from "@core/world/turrets"; +import { Select, SelectButton, SelectOption, SelectOptions } from "@kit/Select"; +import { Button } from "@kit/Button"; +import { AttackingSystem } from "@core/systems/attacking"; import type { FormData } from "./utils"; +import { Table, TableCell, TableHeader } from "../components/Table"; +import styles from "./styles.scss"; + +function getDps(ship: ShipInput, direction: Vec2): number { + let dps = 0; + for (const turret of ship.turrets) { + const turretInfo = getTurretBySlug(turret.class); + const slot = ship.slots.find((s) => s.slug === turret.slot)!; + if ( + AttackingSystem.isInShootingRange( + new Vec2(0, 0), + slot.angle, + direction, + turretInfo.range, + turret.angle + ) + ) { + dps += turretInfo.damage / turretInfo.cooldown; + } + } + + return dps; +} const ShipFightEditor: React.FC<{ index: number }> = ({ index }) => { - const { register, getValues } = useFormContext(); + const { control, register, getValues, setValue } = useFormContext(); const ship = useThrottledFormState(`ships.${index.toString()}`); + const { append, remove } = useFieldArray({ + control, + name: `ships.${index}.turrets`, + }); + const [expanded, setExpanded] = React.useState(false); if (!ship) { return null; } + const availableSlots = ship.slots.filter((slot) => + ship.turrets.every((t) => t.slot !== slot.slug) + ); + return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + <> + + + setExpanded(!expanded)}> + + + + + + + + + + + + + + + + + + + + {getDps(ship, fromPolar(0, 0.5)).toFixed(1)} + + + {getDps(ship, fromPolar(0, -0.5)).toFixed(1)} + + + {getDps(ship, fromPolar(0.5, 0)).toFixed(1)} + + + {getDps(ship, fromPolar(-0.5, 0)).toFixed(1)} + + + {expanded && ( + <> + {ship.turrets.map((turret, turretIndex) => ( + + + + + + + + + Angle + + remove(turretIndex)}> + + + + + ))} + {availableSlots.length > 0 && ( + + + + + + + )} + + )} + ); }; @@ -99,24 +208,24 @@ export const FightEditor: React.FC<{ ships: ShipInput[] }> = ({ ships }) => ( - - - - + + + + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} - Attack - Cooldown - Range - Angle HP Max Regen Shield Max Regen + DPS (Front) + DPS (Back) + DPS (Left) + DPS (Right) diff --git a/devtools/ships/index.tsx b/devtools/ships/index.tsx index e3f382ed..6d8cfac8 100644 --- a/devtools/ships/index.tsx +++ b/devtools/ships/index.tsx @@ -10,6 +10,7 @@ import clsx from "clsx"; import { shipClasses } from "@core/world/ships"; import { Button } from "@kit/Button"; import { Tab, TabList } from "@kit/Tabs"; +import { useQs } from "@devtools/utils"; import type { FormData } from "./utils"; import styles from "./styles.scss"; import { GeneralEditor } from "./General"; @@ -22,10 +23,16 @@ const Editor: React.FC<{}> = () => { const { getValues, control } = useFormContext(); const { append } = useFieldArray({ control, name: "ships" }); const [ships, setShips] = React.useState(getValues().ships); + const qs = useQs(); return (
- + + qs.set(new URLSearchParams({ tab: index.toString() })) + } + >
+
+ +
+ + + + + + + + + + + + + + + + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} + + + + {Object.values(turrets).map((_, turretIndex) => ( + + ))} + +
+ Slug + Damage + Range + Cooldown + Type + Color + DPS +
+
+ ); +}; + +export const Turrets: React.FC = () => { + const form = useForm({ + defaultValues: { turrets: turretClasses }, + }); + const [expanded, setExpanded] = React.useState(false); + + return ( + +
+ + Object.values(data!)[0]} + expanded={expanded} + onExpand={() => setExpanded(!expanded)} + /> +
+
+ ); +}; diff --git a/devtools/turrets/styles.scss b/devtools/turrets/styles.scss new file mode 100644 index 00000000..5efa8d19 --- /dev/null +++ b/devtools/turrets/styles.scss @@ -0,0 +1,38 @@ +.editorContainer { + overflow-y: scroll; + padding: usesize(0.8); +} +.root { + display: grid; + grid-template-columns: 1fr usesize(12); +} +.rootExpanded { + grid-template-columns: 1fr usesize(35); +} +.toolbar { + display: flex; + gap: usesize(0.8); + margin-bottom: usesize(0.8); +} +.hr { + margin-bottom: usesize(0.8); +} +.swatch { + border-radius: usesize(0.2); + display: inline-block; + width: usesize(3.2); + height: usesize(3.2); +} +.color { + appearance: none; + background: none; + color: var(--palette-text-1); + border: none; + display: flex; + align-items: center; + gap: usesize(0.8); + padding: 0; +} +.colorPicker { + position: absolute; +} diff --git a/devtools/turrets/styles.scss.d.ts b/devtools/turrets/styles.scss.d.ts new file mode 100644 index 00000000..cc2403cc --- /dev/null +++ b/devtools/turrets/styles.scss.d.ts @@ -0,0 +1,11 @@ +/* @generated */ +/* prettier-ignore */ +/* eslint-disable */ +export const editorContainer: string; +export const root: string; +export const rootExpanded: string; +export const toolbar: string; +export const hr: string; +export const swatch: string; +export const color: string; +export const colorPicker: string; diff --git a/devtools/utils.ts b/devtools/utils.ts index a5617086..61e2629e 100644 --- a/devtools/utils.ts +++ b/devtools/utils.ts @@ -1,6 +1,7 @@ import throttle from "lodash/throttle"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useWatch } from "react-hook-form"; +import { useLocation, useNavigate } from "react-router"; export function useThrottledFormState(name?: string): T { const data: T = useWatch(name ? { name } : undefined!); @@ -18,3 +19,19 @@ export const formatInt = Intl.NumberFormat(window.navigator.language, { maximumFractionDigits: 0, compactDisplay: "short", }).format; + +export function useQs() { + const { search } = useLocation(); + const navigate = useNavigate(); + const params = useMemo(() => new URLSearchParams(search), [search]); + const set = useCallback( + (searchParams: URLSearchParams) => { + navigate({ + search: searchParams.toString(), + }); + }, + [navigate] + ); + + return { params, set }; +} diff --git a/gateway/index.tsx b/gateway/index.tsx index 15a5964d..7aaae42f 100644 --- a/gateway/index.tsx +++ b/gateway/index.tsx @@ -7,6 +7,7 @@ import { isDev } from "@core/settings"; import Bugsnag from "@bugsnag/js"; import BugsnagPluginReact from "@bugsnag/plugin-react"; import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"; +import { DraggablePane } from "@ui/context/Pane"; import packageJson from "../package.json"; let ErrorBoundary: React.FC< @@ -56,6 +57,7 @@ root.render( > + {isDev && } />} } /> diff --git a/ogl-engine/ParticleGenerator.ts b/ogl-engine/ParticleGenerator.ts index 7dd9dcb6..1c062834 100644 --- a/ogl-engine/ParticleGenerator.ts +++ b/ogl-engine/ParticleGenerator.ts @@ -11,7 +11,6 @@ const trs = new Mat4(); const tempVec3 = new Vec3(); const tempWorldScale = new Vec3(); const tempMat4 = new Mat4(); -const tempWorldMatrix = new Mat4(); interface Particle { angularVelocity: number; @@ -212,7 +211,6 @@ export class ParticleGenerator extends Transform implements Destroyable { if (this.lastKilled) { this.lastKilled = i; } - continue; } trs @@ -231,29 +229,14 @@ export class ParticleGenerator extends Transform implements Destroyable { } } - override lookAt(target: Vec3, invert = false, useWorldMatrix = true) { - if (useWorldMatrix) { - if (invert) this.matrix.lookAt(this.position, target, this.up); - else this.matrix.lookAt(target, this.position, this.up); - - // Extract world-space rotation - // @ts-expect-error - // eslint-disable-next-line no-underscore-dangle - this.matrix.getRotation(this.quaternion._target); - this.rotation.fromQuaternion(this.quaternion); - - // Convert world rotation to local using inverse of parent world matrix - const invParentWorld = tempWorldMatrix - .copy(this.parent!.worldMatrix) + override lookAt(target: Vec3, invert = false, useWorldTransform = true) { + if (useWorldTransform) { + const invWorldMatrix = tempMat4 + .copy(this.parent?.worldMatrix ?? this.worldMatrix) .inverse(); - const worldRotation = tempMat4.fromQuaternion(this.quaternion); - worldRotation.multiply(invParentWorld); - // @ts-expect-error - // eslint-disable-next-line no-underscore-dangle - worldRotation.getRotation(this.quaternion._target); - // this.rotation.fromQuaternion(this.quaternion); - - this.updateMatrixWorld(); + const locaTarget = tempVec3.copy(target).applyMatrix4(invWorldMatrix); + + super.lookAt(locaTarget, invert); } else { super.lookAt(target, invert); } diff --git a/ogl-engine/builders/LaserWeapon.ts b/ogl-engine/builders/LaserWeapon.ts new file mode 100644 index 00000000..0bca368a --- /dev/null +++ b/ogl-engine/builders/LaserWeapon.ts @@ -0,0 +1,156 @@ +import { BaseMesh } from "@ogl-engine/engine/BaseMesh"; +import { CrossGeometry } from "@ogl-engine/engine/CrossGeometry"; +import type { Engine3D } from "@ogl-engine/engine/engine3d"; +import type { OnBeforeRenderTask } from "@ogl-engine/engine/task"; +import { LaserMaterial } from "@ogl-engine/materials/laser/laser"; +import { LaserImpactMaterial } from "@ogl-engine/materials/laserImpact/laserImpact"; +import type { Destroyable } from "@ogl-engine/types"; +import { createTimeline } from "@ogl-engine/utils/timeline"; +import { Mat4, Sphere, Transform, Vec3 } from "ogl"; + +const tempMat4 = new Mat4(); +const tempScale = new Vec3(); + +interface LaserWeaponOpts { + color: string; + width: number; +} + +const timeline = createTimeline([ + [0, 0], + [0.2, 0.01], + [0.35, 1], + [0.55, 1], + [0.85, 0.1], + [1, 0], +]); + +const tempVec3 = new Vec3(); +const defaultTarget = new Vec3(); + +class Beam extends BaseMesh { + name = "Beam"; + + constructor(engine: Engine3D, opts: LaserWeaponOpts) { + super(engine, { + geometry: new CrossGeometry(engine.gl), + material: new LaserMaterial(engine, opts), + }); + this.material.uniforms.uIntensity.value = 0; + this.position.z += 0.5; + this.rotation.x = Math.PI / 2; + } +} + +// class Impact extends BaseMesh { +// name = "Impact"; + +// constructor(engine: Engine3D, opts: LaserWeaponOpts) { +// super(engine, { +// geometry: new CrossGeometry(engine.gl), +// material: new LaserMaterial(engine, opts), +// }); +// this.material.uniforms.uIntensity.value = 0; +// this.position.z += 0.5; +// this.rotation.x -= Math.PI / 2; +// } +// } + +class Exhaust extends BaseMesh { + name = "Exhaust"; + + constructor(engine: Engine3D, opts: LaserWeaponOpts) { + super(engine, { + geometry: new Sphere(engine.gl), + material: new LaserImpactMaterial(engine, opts), + }); + this.material.uniforms.uIntensity.value = 0; + // this.rotation.x -= Math.PI / 2; + } +} + +export class LaserWeaponEffect extends Transform implements Destroyable { + id: number; + startedAt: number; + task: OnBeforeRenderTask | null = null; + duration = 0.5; // seconds + width: number; + beam: Beam; + exhaust: Exhaust; + engine: Engine3D; + name = "LaserWeaponEffect"; + target = defaultTarget; + + constructor(engine: Engine3D, opts: LaserWeaponOpts & { id: number }) { + super(); + this.engine = engine; + this.id = opts.id; + this.startedAt = engine.uniforms.uTime.value; + this.width = opts.width; + this.beam = new Beam(engine, opts); + this.beam.setParent(this); + this.exhaust = new Exhaust(engine, opts); + this.exhaust.setParent(this); + } + + fire() { + if (this.task) return; + + this.startedAt = this.engine.uniforms.uTime.value; + + this.task = this.engine.addOnBeforeRenderTask(() => { + const t = + (this.engine.uniforms.uTime.value - this.startedAt) / this.duration; + const p = timeline(t); + this.beam.material.uniforms.uIntensity.value = p; + this.exhaust.material.uniforms.uIntensity.value = p; + + this.lookAt(this.target, false, true); + const worldPosition = tempVec3; + const scale = tempScale; + this.worldMatrix.getTranslation(worldPosition); + this.parent!.worldMatrix.getScaling(scale); + this.scale.set( + this.width, + this.width, + worldPosition.distance(this.target) / scale.z + ); + this.beam.material.uniforms.uAspectRatio.value = + this.scale.z / this.width; + + const exhaustSize = + (this.width * + (1 + Math.sin(this.engine.uniforms.uTime.value * 80) * 0.025 * p)) / + 4; + this.exhaust.scale.set( + exhaustSize, + exhaustSize, + (exhaustSize / this.scale.z) * this.width + ); + + if (t >= 1) { + this.task!.cancel(); + this.task = null; + } + }); + } + + setTarget(target: Vec3) { + this.target = target; + } + + destroy(): void { + this.task?.cancel(); + } + + override lookAt(target: Vec3, invert = false, useWorldMatrix = false) { + if (useWorldMatrix) { + const invWorldMatrix = tempMat4.copy(this.parent!.worldMatrix).inverse(); + const locaTarget = tempVec3.copy(target).applyMatrix4(invWorldMatrix); + + super.lookAt(locaTarget, invert); + } else { + super.lookAt(target, invert); + } + } +} diff --git a/ogl-engine/engine/BaseMesh.ts b/ogl-engine/engine/BaseMesh.ts index 66f2ade3..80b70f2c 100644 --- a/ogl-engine/engine/BaseMesh.ts +++ b/ogl-engine/engine/BaseMesh.ts @@ -173,6 +173,10 @@ export class BaseMesh } } + isMounted(): boolean { + return !!this.parent; + } + addBoundingBox() { if (this.children.some((child) => child instanceof BoundingBox)) return; diff --git a/ogl-engine/engine/CrossGeometry.ts b/ogl-engine/engine/CrossGeometry.ts index e6615091..5c334ab8 100644 --- a/ogl-engine/engine/CrossGeometry.ts +++ b/ogl-engine/engine/CrossGeometry.ts @@ -57,7 +57,7 @@ export class CrossGeometry extends Geometry { size: 2, data: new Float32Array([ // First Plane UVs - 0, 1, 1, 1, 0, 0, 1, 0, + 1, 1, 1, 0, 0, 1, 0, 0, // Second Plane UVs 0, 1, 1, 1, 0, 0, 1, 0, ]), diff --git a/ogl-engine/engine/engine.ts b/ogl-engine/engine/engine.ts index 3bf1f893..028b7baf 100644 --- a/ogl-engine/engine/engine.ts +++ b/ogl-engine/engine/engine.ts @@ -20,7 +20,7 @@ export abstract class Engine { protected lastFrameTime: number; public originalDelta = 0; - private deltaMultiplier = 1; + protected deltaMultiplier = 1; constructor() { this.hooks = { diff --git a/ogl-engine/engine/engine3d.ts b/ogl-engine/engine/engine3d.ts index 02b3a34d..214274c1 100644 --- a/ogl-engine/engine/engine3d.ts +++ b/ogl-engine/engine/engine3d.ts @@ -94,32 +94,8 @@ export class Engine3D extends Engine { constructor() { super(); - - const folder = getPane().addOrReplaceFolder({ - title: "Renderer", - expanded: true, - }); - folder.addBinding(this.performance, "fps", { - readonly: true, - interval: 500, - label: "FPS", - }); - folder.addBinding(this.performance, "fps", { - view: "graph", - label: "FPS Graph", - interval: 500, - readonly: true, - }); - folder.addBinding(this.performance, "averageFrameTime", { - view: "graph", - label: "Avg Frame Time [ms]", - interval: 500, - readonly: true, - min: 0, - max: 4, - }); - this.initUniforms(); + this.initPane(); } init = (canvas: HTMLCanvasElement) => { @@ -155,6 +131,43 @@ export class Engine3D extends Engine { this.initialized = true; }; + initPane() { + console.log("Initializing pane"); + const folder = getPane().addOrReplaceFolder({ + title: "Renderer", + expanded: true, + }); + folder.addBinding(this.performance, "fps", { + readonly: true, + interval: 500, + label: "FPS", + }); + folder.addBinding(this.performance, "fps", { + view: "graph", + label: "FPS Graph", + interval: 500, + readonly: true, + }); + folder.addBinding(this.performance, "averageFrameTime", { + view: "graph", + label: "Avg Frame Time [ms]", + interval: 500, + readonly: true, + min: 0, + max: 4, + }); + folder.addBinding(this.uniforms.uTime, "value", { + readonly: true, + }); + folder + .addButton({ + title: "Toggle Ticker", + }) + .on("click", () => { + this.setDeltaMultiplier(this.deltaMultiplier === 1 ? 0 : 1); + }); + } + private initUniforms() { this.uniforms = { env: { @@ -358,7 +371,9 @@ export class Engine3D extends Engine { } this.performance.updateFrameTime(performance.now() - startTime); - this.executeOnBeforeRenderTasks(); + if (this.deltaMultiplier > 0) { + this.executeOnBeforeRenderTasks(); + } } private renderSimple = () => { diff --git a/ogl-engine/materials/base.vert.glsl b/ogl-engine/materials/base.vert.glsl index 3512bf76..528f9fea 100644 --- a/ogl-engine/materials/base.vert.glsl +++ b/ogl-engine/materials/base.vert.glsl @@ -3,21 +3,26 @@ precision highp float; in vec2 uv; in vec3 position; +in vec3 normal; out vec2 vUv; out float vFragDepth; out vec3 worldPosition; +out vec3 vNormal; +out vec3 vViewDirection; uniform mat4 modelMatrix; uniform mat4 viewMatrix; uniform mat4 projectionMatrix; -uniform float uTime; +uniform vec3 cameraPosition; void main() { vec4 mPosition = modelMatrix * vec4(position, 1.0f); worldPosition = mPosition.xyz; vUv = uv; + vNormal = normalize(modelMatrix * vec4(normal, 0.0f)).xyz; + vViewDirection = normalize(cameraPosition - worldPosition.xyz); gl_Position = projectionMatrix * viewMatrix * mPosition; vFragDepth = 1.f + gl_Position.w; diff --git a/ogl-engine/materials/engineTrail/shader.frag.glsl b/ogl-engine/materials/engineTrail/shader.frag.glsl index 8b2b7ed3..05044269 100644 --- a/ogl-engine/materials/engineTrail/shader.frag.glsl +++ b/ogl-engine/materials/engineTrail/shader.frag.glsl @@ -18,7 +18,7 @@ void main() { float alphaY = K / (K + alphaFactorY * alphaFactorY) - K; float alpha = alphaX * alphaY; - fragData[0] = vec4(uColor * (1.f + emissive) * alphaX, alpha); + fragData[0] = vec4(mix(uColor, vec3(1.f), pow(alphaY, 2.f) - 0.4f), alpha); fragData[1] = vec4(fragData[0].rgb, emissive * alpha); gl_FragDepth = log2(vFragDepth) / uCameraScale; } \ No newline at end of file diff --git a/ogl-engine/materials/kineticGun/shader.frag.glsl b/ogl-engine/materials/kineticGun/shader.frag.glsl index 3d471bc1..8ebd0bf6 100644 --- a/ogl-engine/materials/kineticGun/shader.frag.glsl +++ b/ogl-engine/materials/kineticGun/shader.frag.glsl @@ -5,19 +5,22 @@ precision highp float; in vec2 vUv; in float vT; +in float vFragDepth; out vec4 fragData[3]; uniform vec4 uColor; +uniform float uCameraScale; void main() { float a = 1.f - 2.f * distance(vUv, vec2(0.5f, 0.5f)); - fragData[0].rgb = mix(uColor.rgb, vec3(1.f), a * a); + fragData[0].rgb = mix(uColor.rgb, vec3(2.f), a * a * a); fragData[0].a = a * uColor.a; if(fragData[0].a < 1.f / 255.f) { discard; } - fragData[1] = vec4(fragData[0].rgb, luma(fragData[0])); + fragData[1] = vec4(fragData[0].rgb, a * a * 5.f); + gl_FragDepth = log2(vFragDepth) / uCameraScale; } \ No newline at end of file diff --git a/ogl-engine/materials/laser/laser.ts b/ogl-engine/materials/laser/laser.ts new file mode 100644 index 00000000..45c06407 --- /dev/null +++ b/ogl-engine/materials/laser/laser.ts @@ -0,0 +1,51 @@ +import type { Engine3D } from "@ogl-engine/engine/engine3d"; +import { Vec3 } from "ogl"; +import fragment from "./shader.frag.glsl"; +import vertex from "../base.vert.glsl"; +import type { Uniform } from "../material"; +import { Material } from "../material"; + +interface LaserMaterialArgs { + color: string; +} + +export class LaserMaterial extends Material { + uniforms: Material["uniforms"] & { + uColor: Uniform; + uIntensity: Uniform; + uAspectRatio: Uniform; + }; + + constructor(engine: Engine3D, { color }: LaserMaterialArgs) { + super(engine); + + this.createProgram( + vertex, + fragment, + {}, + { + cullFace: null, + transparent: true, + depthWrite: false, + } + ); + + this.uniforms.uColor = { value: new Vec3() }; + Material.colorToVec3(color, this.uniforms.uColor); + + this.uniforms.uIntensity = { + value: 1, + meta: { pane: { min: 0, max: 1 } }, + }; + this.uniforms.uAspectRatio = { + value: 1, + }; + + this.program.setBlendFunc( + this.engine.gl.SRC_ALPHA, + this.engine.gl.ONE, + this.engine.gl.SRC_ALPHA, + this.engine.gl.DST_ALPHA + ); + } +} diff --git a/ogl-engine/materials/laser/shader.frag.glsl b/ogl-engine/materials/laser/shader.frag.glsl new file mode 100644 index 00000000..0f2ddd30 --- /dev/null +++ b/ogl-engine/materials/laser/shader.frag.glsl @@ -0,0 +1,39 @@ +#version 300 es +precision highp float; + +#pragma glslify: triangle = require("./ogl-engine/shader/triangle"); +#pragma glslify: fbm3 = require("./ogl-engine/shader/fbm3"); + +in vec2 vUv; +in float vFragDepth; + +out vec4 fragData[3]; + +uniform vec3 uColor; +uniform float uCameraScale; +uniform float uTime; +uniform float uIntensity; +uniform float uAspectRatio; + +#define width 10. + +float sdCircle(vec2 p, float r) { + return length(p) - r; +} + +void main() { + float d = triangle(vUv.y); + float falloff = 1.f - d; + float noise = fbm3(vec3(vUv.y * 20.f, (vUv.x * 10.f + uTime * 15.f * uIntensity) * uAspectRatio / 50.f, uTime), 2); + float mask = clamp(-sdCircle((vUv - vec2(0.5f)) * 2.f, 1.16f), 0.f, 1.f); + float flux = max(0.1f, noise * 3.f); + float core = max(((1.f - d) * width - (width - 1.f)) + flux, 0.f); + + vec3 color = uColor * falloff + vec3(core); + float alpha = (falloff - triangle(fract(uTime * 15.f * uIntensity)) * 0.15f + core) * mask * uIntensity; + + alpha = clamp(alpha, 0.f, 1.f); + fragData[0] = vec4(color, alpha); + fragData[1] = vec4(max(vec3(0), color - 1.0f), alpha); + gl_FragDepth = log2(vFragDepth) / uCameraScale; +} \ No newline at end of file diff --git a/ogl-engine/materials/laserImpact/laserImpact.ts b/ogl-engine/materials/laserImpact/laserImpact.ts new file mode 100644 index 00000000..927b3085 --- /dev/null +++ b/ogl-engine/materials/laserImpact/laserImpact.ts @@ -0,0 +1,46 @@ +import type { Engine3D } from "@ogl-engine/engine/engine3d"; +import { Vec3 } from "ogl"; +import fragment from "./shader.frag.glsl"; +import vertex from "../base.vert.glsl"; +import type { Uniform } from "../material"; +import { Material } from "../material"; + +interface LaserImpactMaterialArgs { + color: string; +} + +export class LaserImpactMaterial extends Material { + uniforms: Material["uniforms"] & { + uColor: Uniform; + uIntensity: Uniform; + }; + + constructor(engine: Engine3D, { color }: LaserImpactMaterialArgs) { + super(engine); + + this.createProgram( + vertex, + fragment, + {}, + { + transparent: true, + depthWrite: false, + } + ); + + this.uniforms.uColor = { value: new Vec3() }; + Material.colorToVec3(color, this.uniforms.uColor); + + this.uniforms.uIntensity = { + value: 1, + meta: { pane: { min: 0, max: 1 } }, + }; + + this.program.setBlendFunc( + this.engine.gl.SRC_ALPHA, + this.engine.gl.ONE, + this.engine.gl.SRC_ALPHA, + this.engine.gl.DST_ALPHA + ); + } +} diff --git a/ogl-engine/materials/laserImpact/shader.frag.glsl b/ogl-engine/materials/laserImpact/shader.frag.glsl new file mode 100644 index 00000000..46102226 --- /dev/null +++ b/ogl-engine/materials/laserImpact/shader.frag.glsl @@ -0,0 +1,29 @@ +#version 300 es +precision highp float; + +in vec2 vUv; +in float vFragDepth; +in vec3 vNormal; +in vec3 vViewDirection; + +out vec4 fragData[3]; + +uniform vec3 uColor; +uniform float uCameraScale; +uniform float uTime; +uniform float uIntensity; + +#define tMultiplier 10. + +float fresnel(float amount, vec3 normal, vec3 view) { + return pow(1.0f - dot(normalize(normal), normalize(view)), amount); +} + +void main() { + float t = fract(uTime * tMultiplier); + float f = fresnel(t * 120.f, vNormal, vViewDirection); + float alpha = max(uIntensity * 1.2f - f, 0.f); + fragData[0] = vec4(uColor, alpha * 1.f); + fragData[1] = fragData[0]; + gl_FragDepth = log2(vFragDepth) / uCameraScale; +} \ No newline at end of file diff --git a/ogl-engine/materials/material.ts b/ogl-engine/materials/material.ts index 4705ee54..096e4ba2 100644 --- a/ogl-engine/materials/material.ts +++ b/ogl-engine/materials/material.ts @@ -4,9 +4,17 @@ import { entries, join, map, pipe } from "@fxts/core"; import type { Engine3D } from "@ogl-engine/engine/engine3d"; import type { Light } from "@ogl-engine/engine/Light"; import Color from "color"; -import type { ProgramOptions, Mesh, Vec3, Vec4 } from "ogl"; +import type { ProgramOptions, Mesh, Vec3, Vec4, Vec2 } from "ogl"; import { Texture, Program } from "ogl"; -import type { FolderApi } from "tweakpane"; +import type { BindingParams, FolderApi } from "tweakpane"; +import { getPane } from "@ui/context/Pane"; + +export interface Uniform { + value: T; + meta?: { + pane?: BindingParams; + }; +} export abstract class Material { engine: Engine3D; @@ -34,7 +42,11 @@ export abstract class Material { mesh.program = this.program; } - createPaneSettings(folder: FolderApi) { + createPaneSettings(folder?: FolderApi) { + if (!folder) { + folder = getPane().addOrReplaceFolder({ title: "Material" }); + } + for (const uniform of Object.keys(this.uniforms)) { if ( [ @@ -66,7 +78,7 @@ export abstract class Material { } else { folder.addBinding(this.uniforms[uniform], "value", { label: uniform, - view: uniform.match(/[cC]olor/) ? "color" : undefined, + ...(this.uniforms[uniform].meta?.pane ?? {}), }); } } diff --git a/ogl-engine/materials/skybox/skybox.ts b/ogl-engine/materials/skybox/skybox.ts index af208947..003eea11 100644 --- a/ogl-engine/materials/skybox/skybox.ts +++ b/ogl-engine/materials/skybox/skybox.ts @@ -51,6 +51,12 @@ const settings: Partial< intensity: 0.83, direction: [-0.96, -0.891, -0.4], }, + deepspace1: { + ambient: 0.02, + color: "#c9d5f6", + intensity: 0.27, + direction: [2.04, -3.04, -0.21], + }, }; export class Skybox extends Mesh implements Destroyable { diff --git a/ogl-engine/particles/kineticGun.ts b/ogl-engine/particles/kineticGun.ts index 461737cf..e38e921c 100644 --- a/ogl-engine/particles/kineticGun.ts +++ b/ogl-engine/particles/kineticGun.ts @@ -2,14 +2,22 @@ import { OneShotParticleGenerator } from "@ogl-engine/ParticleGenerator"; import { random } from "mathjs"; import type { Engine3D } from "@ogl-engine/engine/engine3d"; import { KineticGunMaterial } from "@ogl-engine/materials/kineticGun/kineticGun"; +import { Vec3 } from "ogl"; +import { Material } from "@ogl-engine/materials/material"; import { RosetteGeometry } from "./rosette"; -const particleSize = 1.4; -const particleLife = 6; -const spread = 0.025; +const spread = 0.25; +const tempTranslation = new Vec3(); +const tempMin = new Vec3(); +const tempMax = new Vec3(); export class KineticGunParticleGenerator extends OneShotParticleGenerator { scaleForces = false; + speed = 3; + size = 0.3; + life = 2; + targetId: number | null = null; + cached = 0; constructor(engine: Engine3D) { super( @@ -19,9 +27,9 @@ export class KineticGunParticleGenerator extends OneShotParticleGenerator { particle.velocity.set( random(-spread, spread), random(-spread, spread), - random(2.5, 2.9) + random(this.speed, this.speed * 1.2) ); - particle.life = particleLife; + particle.life = this.life; this.worldMatrix.getRotation(particle.rotation); particle.rotation.rotateX(Math.PI / 2); }, @@ -29,16 +37,52 @@ export class KineticGunParticleGenerator extends OneShotParticleGenerator { 9 ); - this.spawnRate = 8; + this.spawnRate = 12; this.onParticleUpdate = (particle) => { - particle.t = particle.life / particleLife; - particle.scale.set(particleSize, particleSize * 9, particleSize); + particle.t = particle.life / this.life; + particle.scale.set(this.size, this.size * 9, this.size); + + if (this.targetId) { + const target = engine.getByEntityId(this.targetId); + if (target) { + // Check if particle.position is in target.geometry.bounds + if (target.geometry?.bounds) { + const min = tempMin; + const max = tempMax; + + if (this.cached !== this.engine.uniforms.uTime.value) { + this.cached = this.engine.uniforms.uTime.value; + target.worldMatrix.getTranslation(tempTranslation); + min.copy(target.geometry.bounds.min).add(tempTranslation); + max.copy(target.geometry.bounds.max).add(tempTranslation); + } + + if ( + particle.position.x >= min.x && + particle.position.x <= max.x && + particle.position.y >= min.y && + particle.position.y <= max.y && + particle.position.z >= min.z && + particle.position.z <= max.z + ) { + particle.life = 0; + } + } + } + } }; const material = new KineticGunMaterial(engine, { - color: "rgb(255, 57, 57)", + color: "#ffffff", }); this.mesh.applyMaterial(material); } + + setColor(color: string) { + Material.colorToVec4( + color, + (this.mesh.material as KineticGunMaterial).uniforms.uColor + ); + } } diff --git a/ogl-engine/stories/Story3d.tsx b/ogl-engine/stories/Story3d.tsx index 6865bedf..9b6b826e 100644 --- a/ogl-engine/stories/Story3d.tsx +++ b/ogl-engine/stories/Story3d.tsx @@ -96,7 +96,7 @@ export const story3dMeta = { }, args: { postProcessing: true, - skybox: Object.keys(skyboxes)[0], + skybox: skyboxes.deepspace1, pane: false, grid: false, }, diff --git a/ogl-engine/stories/laser.stories.tsx b/ogl-engine/stories/laser.stories.tsx new file mode 100644 index 00000000..6c27208b --- /dev/null +++ b/ogl-engine/stories/laser.stories.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import type { StoryFn, Meta } from "@storybook/react"; +import { Styles } from "@kit/theming/style"; +import { merge } from "lodash"; +import type { Engine3D } from "@ogl-engine/engine/engine3d"; +import { LaserWeaponEffect } from "@ogl-engine/builders/LaserWeapon"; +import { Transform } from "ogl"; +import { AxesHelper } from "@ogl-engine/utils/AxesHelper"; +import { Story3d, story3dMeta } from "./Story3d"; +import type { Story3dArgs } from "./Story3d"; + +interface LaserStoryProps extends Story3dArgs { + motion: boolean; +} + +const LaserStory: React.FC = ({ motion, ...props }) => { + const engineRef = React.useRef(); + const intervalRef = React.useRef(); + const onInit = React.useCallback(async (engine: Engine3D) => { + engineRef.current = engine; + engine.camera.position.set(1, 0, 0); + engine.camera.lookAt([0, 0, 0]); + + const transform = new Transform(); + transform.name = "Container"; + transform.setParent(engine.scene); + + const target = new AxesHelper(engine); + target.position.set(10, 0, 0); + target.setParent(engine.scene); + + const laser = new LaserWeaponEffect(engine, { + color: "rgb(54, 54, 255)", + width: 0.1, + id: 1, + }); + laser.beam.material.createPaneSettings(); + laser.setParent(transform); + laser.setTarget(target.position); + + const axes = new AxesHelper(engine, { + symmetric: false, + size: 1, + }); + axes.setParent(transform); + + intervalRef.current = setInterval(() => { + laser.fire(); + }, 3000) as unknown as number; + }, []); + + const onUpdate = React.useCallback((engine: Engine3D) => { + if (!motion) return; + + const transform = engine.scene.children.find( + (c) => c.name === "Container" + )!; + transform.position.set( + Math.sin(engine.uniforms.uTime.value) * 2, + 0, + Math.cos(engine.uniforms.uTime.value) * 2 + ); + transform.updateMatrixWorld(true); + // const laser = transform.children[0] as LaserWeaponEffect; + // laser.target.y = Math.sin(engine.uniforms.uTime.value); + }, []); + + React.useEffect( + () => () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }, + [] + ); + + return ; +}; + +export default { + title: "OGL / Laser", + ...merge( + { + args: { + motion: false, + }, + argTypes: { + motion: { + control: { + type: "boolean", + }, + }, + }, + }, + story3dMeta + ), +} as Meta; + +const Template: StoryFn = (props) => ( +
+ + + +
+); + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/ogl-engine/utils/AxesHelper.ts b/ogl-engine/utils/AxesHelper.ts new file mode 100644 index 00000000..aa218f2f --- /dev/null +++ b/ogl-engine/utils/AxesHelper.ts @@ -0,0 +1,94 @@ +import { BaseMesh } from "@ogl-engine/engine/BaseMesh"; +import type { Engine3D } from "@ogl-engine/engine/engine3d"; +import { Material } from "@ogl-engine/materials/material"; +import { Geometry, Program, Vec3 } from "ogl"; + +const vertex = /* glsl */ `#version 300 es +precision highp float; + +in vec3 position; +in vec3 color; + +out vec3 vColor; +out float vFragDepth; + +uniform mat4 modelViewMatrix; +uniform mat4 projectionMatrix; + + +void main() { + vColor = color; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + vFragDepth = 1.f + gl_Position.w; +} +`; + +const fragment = /* glsl */ `#version 300 es +precision highp float; + +in vec3 vColor; +in float vFragDepth; + +out vec4 fragData[3]; + +uniform float uCameraScale; + +void main() { + fragData[0] = vec4(vColor, 1.0); + gl_FragDepth = log2(vFragDepth) / uCameraScale; +} +`; + +class AxesHelperMaterial extends Material { + constructor(engine: Engine3D) { + super(engine); + this.createProgram(vertex, fragment, {}, {}); + } +} + +export class AxesHelper extends BaseMesh { + constructor( + engine: Engine3D, + { + size = 1, + symmetric = false, + xColor = new Vec3(0.96, 0.21, 0.32), + yColor = new Vec3(0.44, 0.64, 0.11), + zColor = new Vec3(0.18, 0.52, 0.89), + ...meshProps + } = {} + ) { + const a = symmetric ? -size : 0; + const b = size; + + // prettier-ignore + const vertices = new Float32Array([ + a, 0, 0, b, 0, 0, + 0, a, 0, 0, b, 0, + 0, 0, a, 0, 0, b + ]); + + // prettier-ignore + const colors = new Float32Array([ + ...xColor, ...xColor, + ...yColor, ...yColor, + ...zColor, ...zColor + ]); + + const geometry = new Geometry(engine.gl, { + position: { size: 3, data: vertices }, + color: { size: 3, data: colors }, + }); + + const program = new Program(engine.gl, { vertex, fragment }); + + super(engine, { + ...meshProps, + mode: engine.gl.LINES, + geometry, + program, + material: new AxesHelperMaterial(engine), + calculateTangents: false, + }); + } +} diff --git a/ogl-engine/utils/timeline.ts b/ogl-engine/utils/timeline.ts new file mode 100644 index 00000000..43a4944f --- /dev/null +++ b/ogl-engine/utils/timeline.ts @@ -0,0 +1,23 @@ +import { lerp } from "@core/utils/misc"; + +type Point = [number, number]; +export type Timeline = (_t: number) => number; + +export function createTimeline(points: Point[]): Timeline { + if (points.length < 2) { + throw new Error("At least two points are required to create a timeline."); + } + + return (t) => { + for (let i = 0; i < points.length - 1; i++) { + const [tStart, vStart] = points[i]; + const [tEnd, vEnd] = points[i + 1]; + + if (t >= tStart && t <= tEnd) { + return lerp(vStart, vEnd, (t - tStart) / (tEnd - tStart)); + } + } + + return points[points.length - 1][1]; + }; +} diff --git a/package-lock.json b/package-lock.json index 6d3987cd..f54b77c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "ogl": "^1.0.8", "react": "^18.2.0", "react-click-away-listener": "^2.2.2", + "react-colorful": "^5.6.1", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.13", "react-hook-form": "^7.50.1", @@ -27057,6 +27058,15 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-docgen": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-7.1.0.tgz", @@ -52138,6 +52148,12 @@ "integrity": "sha512-6x5Ra+2xagMq+5YcBu42jtht+bKVErgPXaI/GqYP5M8Xz2o/UbGyMd3TISQK6ixfORUTvdHeRnOVU3TbSpZ5Zg==", "requires": {} }, + "react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "requires": {} + }, "react-docgen": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-7.1.0.tgz", diff --git a/package.json b/package.json index dfcfefad..dade0d49 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "ogl": "^1.0.8", "react": "^18.2.0", "react-click-away-listener": "^2.2.2", + "react-colorful": "^5.6.1", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.13", "react-hook-form": "^7.50.1", diff --git a/ui/components/DevOverlay/DevOverlay.tsx b/ui/components/DevOverlay/DevOverlay.tsx index e00333fc..6a890579 100644 --- a/ui/components/DevOverlay/DevOverlay.tsx +++ b/ui/components/DevOverlay/DevOverlay.tsx @@ -11,7 +11,7 @@ const DevOverlay: React.FC = () => { React.useEffect(() => { actionLoader.link(sim); - return actionLoader.reset; + return actionLoader.reset.bind(actionLoader); }, []); const [actions, setActions] = React.useState(actionLoader.all()); const [[overlay, selectedUnits], gameStore] = useGameStore((store) => [ diff --git a/ui/components/DraggableContainer/DraggableContainer.tsx b/ui/components/DraggableContainer/DraggableContainer.tsx index 40f27f9b..7ff17cba 100644 --- a/ui/components/DraggableContainer/DraggableContainer.tsx +++ b/ui/components/DraggableContainer/DraggableContainer.tsx @@ -3,12 +3,12 @@ import clsx from "clsx"; import styles from "./DraggableContainer.scss"; export const DraggableContainer: React.FC< - React.PropsWithChildren<{ className?: string }> -> = ({ children, className }) => { + React.PropsWithChildren<{ className?: string; style?: React.CSSProperties }> +> = ({ children, className, style }) => { const [isDragging, setIsDragging] = React.useState(false); const [position, setPosition] = React.useState({ - x: window.innerWidth - 400, - y: 0, + x: window.innerWidth - 300, + y: 100, }); const offsetRef = React.useRef({ x: 0, y: 0 }); @@ -52,6 +52,7 @@ export const DraggableContainer: React.FC< position: "fixed", left: Math.max(50, Math.min(window.innerWidth - 50, position.x)), top: Math.max(50, Math.min(window.innerHeight - 50, position.y)), + ...style, }} > {/* eslint-disable-next-line jsx-a11y/control-has-associated-label, react/button-has-type */} diff --git a/ui/components/TacticalMap/EntityMesh.ts b/ui/components/TacticalMap/EntityMesh.ts index 1eb264e2..d1cb0d4d 100644 --- a/ui/components/TacticalMap/EntityMesh.ts +++ b/ui/components/TacticalMap/EntityMesh.ts @@ -17,6 +17,8 @@ import { taskPriority, type OnBeforeRenderTask } from "@ogl-engine/engine/task"; import models from "@assets/models"; import type { Material } from "@ogl-engine/materials/material"; import { materials } from "@ogl-engine/materials"; +import { LaserWeaponEffect } from "@ogl-engine/builders/LaserWeapon"; +import type { Turret } from "@core/archetypes/turret"; const tempVec3 = new Vec3(); export const distanceScale = 250; @@ -173,6 +175,20 @@ export class EntityMesh extends BaseMesh { } } + for (const { id, role } of entity.cp.children?.entities ?? []) { + if (role !== "turret") continue; + + const turret = this.entity.sim.getOrThrow(id); + if (turret.cp.damage.type === "laser") { + const laserWeapon = new LaserWeaponEffect(engine, { + id, + color: turret.cp.color.value, + width: 1.55, + }); + laserWeapon.setParent(this); + } + } + if (entity.tags.has("selection")) { this.indicator = new EntityIndicator(engine); this.tasks.push( @@ -214,6 +230,9 @@ export class EntityMesh extends BaseMesh { generator.position.copy(input.position); generator.rotation.copy(input.rotation); generator.scale.copy(input.scale); + if (type === "hyperslingshot") { + generator.scale.multiply(200); + } generator.setParent(this); generator.updateMatrixWorld(); } diff --git a/ui/components/TacticalMap/TacticalMap.tsx b/ui/components/TacticalMap/TacticalMap.tsx index 1f4df4c3..2498fe6a 100644 --- a/ui/components/TacticalMap/TacticalMap.tsx +++ b/ui/components/TacticalMap/TacticalMap.tsx @@ -42,7 +42,12 @@ import { createStopMiningHandler } from "./events/stopMining"; const tempCameraFocus = new Vec3(); -export class TacticalMap extends React.PureComponent<{ sim: Sim }> { +interface TacticalMapProps { + sim: Sim; + engine?: Engine3D; +} + +export class TacticalMap extends React.PureComponent { engine: Engine3D; sim: Sim; raycast = new Raycast(); @@ -56,29 +61,23 @@ export class TacticalMap extends React.PureComponent<{ sim: Sim }> { onUnmountCallbacks: (() => void)[] = []; - constructor(props) { + constructor(props: TacticalMapProps) { super(props); this.sim = props.sim; - this.engine = new Engine3D(); + this.engine = props.engine ?? new Engine3D(); this.engine.setScene(new TacticalMapScene(this.engine)); - transport3D.hooks.shoot.subscribe( - "TacticalMap", - createShootHandler(this.engine, this.sim) - ); - transport3D.hooks.explode.subscribe( - "TacticalMap", - createExplodeHandler(this.engine) - ); - transport3D.hooks.deployFacility.subscribe( - "TacticalMap", + transport3D.subscribe("shoot", createShootHandler(this.engine, this.sim)); + transport3D.subscribe("explode", createExplodeHandler(this.engine)); + transport3D.subscribe( + "deployFacility", createDeployFacilityHandler(this.engine, this.meshes) ); - transport3D.hooks.startMining.subscribe( - "TacticalMap", + transport3D.subscribe( + "startMining", createStartMiningHandler(this.engine, this.meshes) ); - transport3D.hooks.stopMining.subscribe( - "TacticalMap", + transport3D.subscribe( + "stopMining", createStopMiningHandler(this.engine, this.meshes) ); const onSpeedChange = (speed: number) => { @@ -158,7 +157,7 @@ export class TacticalMap extends React.PureComponent<{ sim: Sim }> { .requireComponents(["position"]); if ( multiple && - entity.cp.owner?.id === this.sim.index.player.get()[0].id + entity.cp.owner?.id === this.sim.index.player.get()[0]?.id ) { if (gameStore.selectedUnits.includes(entity)) { gameStore.unselectUnit(entity); @@ -488,7 +487,7 @@ export class TacticalMap extends React.PureComponent<{ sim: Sim }> { new Skybox( this.engine, (mapData.sectors.find((s) => s.id === gameStore.sector.cp.name.slug) - ?.skybox as SkyboxTexture) ?? "example" + ?.skybox as SkyboxTexture) ?? "deepspace1" ) ); } diff --git a/ui/components/TacticalMap/events/deployFacility.ts b/ui/components/TacticalMap/events/deployFacility.ts index 993f4a6c..341d0a73 100644 --- a/ui/components/TacticalMap/events/deployFacility.ts +++ b/ui/components/TacticalMap/events/deployFacility.ts @@ -1,14 +1,14 @@ -import type { RequireComponent } from "@core/tsHelpers"; import type { Engine3D } from "@ogl-engine/engine/engine3d"; import { gameStore } from "@ui/state/game"; -import type { Entity } from "@core/entity"; +import type { EventHandler } from "@core/utils/pubsub"; +import type { DeployFacilityEvent } from "@core/systems/transport3d"; import type { EntityMesh } from "../EntityMesh"; export function createDeployFacilityHandler( engine: Engine3D, - meshes: WeakMap -) { - return (entity: RequireComponent<"position">) => { + meshes: WeakMap<{ id: number }, EntityMesh> +): EventHandler { + return ({ entity }) => { const mesh = engine.getByEntityId(entity.id); mesh?.destroy(); mesh?.setParent(null); diff --git a/ui/components/TacticalMap/events/explode.ts b/ui/components/TacticalMap/events/explode.ts index 81514254..404476f6 100644 --- a/ui/components/TacticalMap/events/explode.ts +++ b/ui/components/TacticalMap/events/explode.ts @@ -1,12 +1,12 @@ -import type { RequirePureComponent } from "@core/tsHelpers"; -import type { ObserverFn } from "@core/utils/observer"; +import type { ExplodeEvent } from "@core/systems/transport3d"; +import type { EventHandler } from "@core/utils/pubsub"; import type { Engine3D } from "@ogl-engine/engine/engine3d"; import { ExplosionParticleGenerator } from "@ogl-engine/particles/explosion"; export function createExplodeHandler( engine: Engine3D -): ObserverFn> { - return (entity) => { +): EventHandler { + return ({ entity }) => { const mesh = engine.getByEntityId(entity.id); if (mesh) { const explosion = new ExplosionParticleGenerator(engine!); diff --git a/ui/components/TacticalMap/events/shoot.ts b/ui/components/TacticalMap/events/shoot.ts index 95278c22..1ed70148 100644 --- a/ui/components/TacticalMap/events/shoot.ts +++ b/ui/components/TacticalMap/events/shoot.ts @@ -1,29 +1,67 @@ import type { Sim } from "@core/sim"; -import type { RequirePureComponent } from "@core/tsHelpers"; -import type { ObserverFn } from "@core/utils/observer"; +import type { ShootEvent } from "@core/systems/transport3d"; +import type { EventHandler } from "@core/utils/pubsub"; +import { LaserWeaponEffect } from "@ogl-engine/builders/LaserWeapon"; import type { Engine3D } from "@ogl-engine/engine/engine3d"; import { taskPriority } from "@ogl-engine/engine/task"; import { KineticGunParticleGenerator } from "@ogl-engine/particles/kineticGun"; +import { Vec3 } from "ogl"; +import { findInAncestors } from "@core/utils/findInAncestors"; +import { distanceScale } from "../EntityMesh"; + +const tempVec3 = new Vec3(); export function createShootHandler( engine: Engine3D, sim: Sim -): ObserverFn> { - return (entity) => { +): EventHandler { + return ({ entity: turret }) => { + const entity = findInAncestors(turret, "position"); const shooter = engine.getByEntityId(entity.id); - const target = engine.getByEntityId(entity.cp.damage.targetId!); - const targetEntity = sim.get(entity.cp.damage.targetId!); + const target = engine.getByEntityId(turret.cp.damage.targetId!); + const targetEntity = sim.get(turret.cp.damage.targetId!); if (!(shooter && target && targetEntity)) return; - const generator = new KineticGunParticleGenerator(engine); - generator.setParent(shooter); - generator.rotation.y = Math.PI / 2; + if (turret.cp.damage.type === "kinetic") { + const generator = new KineticGunParticleGenerator(engine); + generator.setColor(turret.cp.color?.value ?? "#ffffff"); + generator.targetId = turret.cp.damage.targetId; + generator.setParent(shooter); + generator.position.set( + turret.cp.transform.coord.x * distanceScale, + 0, + turret.cp.transform.coord.y * distanceScale + ); + + tempVec3.copy(target.position); + tempVec3.x += targetEntity.cp.movable?.velocity.x || 0; + tempVec3.z += targetEntity.cp.movable?.velocity.y || 0; + generator.lookAt(tempVec3.scale(distanceScale)); + + generator.speed = distanceScale * 0.95; + + const task = engine.addOnBeforeRenderTask(() => { + if ( + !entity || + entity.cp.hitpoints!.hp.value <= 0 || + !target || + !target.isMounted() + ) { + task.cancel(); + return; + } - const task = engine.addOnBeforeRenderTask(() => { - if (!entity || entity.cp.hitpoints!.hp.value <= 0) { - task.cancel(); + generator.lookAt(target.position); + }, taskPriority.low); + } else { + const weapon = shooter.children.find( + (c) => c instanceof LaserWeaponEffect && c.id === turret.id + ) as LaserWeaponEffect | undefined; + if (weapon) { + weapon.setTarget(target.position); + weapon.fire(); } - }, taskPriority.low); + } }; } diff --git a/ui/components/TacticalMap/events/startMining.ts b/ui/components/TacticalMap/events/startMining.ts index 442302d0..cdbb8770 100644 --- a/ui/components/TacticalMap/events/startMining.ts +++ b/ui/components/TacticalMap/events/startMining.ts @@ -1,7 +1,8 @@ -import type { RequireComponent } from "@core/tsHelpers"; import type { Engine3D } from "@ogl-engine/engine/engine3d"; import type { Entity } from "@core/entity"; import { CloudParticleGenerator } from "@ogl-engine/particles/cloud"; +import type { StartMiningEvent } from "@core/systems/transport3d"; +import type { EventHandler } from "@core/utils/pubsub"; import type { EntityMesh } from "../EntityMesh"; const cloudName = "miningCloud"; @@ -9,8 +10,8 @@ const cloudName = "miningCloud"; export function createStartMiningHandler( engine: Engine3D, _meshes: WeakMap -) { - return (entity: RequireComponent<"position">) => { +): EventHandler { + return ({ entity }) => { const mesh = engine.getByEntityId(entity.id); if (mesh && !mesh?.children.some((child) => child.name === cloudName)) { const cloud = new CloudParticleGenerator(engine); diff --git a/ui/components/TacticalMap/events/stopMining.ts b/ui/components/TacticalMap/events/stopMining.ts index e70a9713..9fce90f5 100644 --- a/ui/components/TacticalMap/events/stopMining.ts +++ b/ui/components/TacticalMap/events/stopMining.ts @@ -1,7 +1,8 @@ -import type { RequireComponent } from "@core/tsHelpers"; import type { Engine3D } from "@ogl-engine/engine/engine3d"; import type { Entity } from "@core/entity"; import { CloudParticleGenerator } from "@ogl-engine/particles/cloud"; +import type { StopMiningEvent } from "@core/systems/transport3d"; +import type { EventHandler } from "@core/utils/pubsub"; import type { EntityMesh } from "../EntityMesh"; const cloudName = "miningCloud"; @@ -9,8 +10,8 @@ const cloudName = "miningCloud"; export function createStopMiningHandler( engine: Engine3D, _meshes: WeakMap -) { - return (entity: RequireComponent<"position">) => { +): EventHandler { + return ({ entity }) => { const mesh = engine.getByEntityId(entity.id); if (!mesh) return; diff --git a/ui/context/Pane.tsx b/ui/context/Pane.tsx index c5b3d90d..caa6ae31 100644 --- a/ui/context/Pane.tsx +++ b/ui/context/Pane.tsx @@ -1,3 +1,4 @@ +import { eventHook } from "@core/events/pubsub"; import { storageHook } from "@core/hooks"; import { defaultLogger } from "@core/log"; import type { GameSettings } from "@core/settings"; @@ -88,6 +89,12 @@ const OglPlugin: TpPluginBundle = { }; export class Pane extends BasePane { + debug: { + folder: FolderApi; + values: Record; + unsubscribe: () => void; + }; + constructor({ container }: { container?: HTMLDivElement } = {}) { logger.log("Creating Pane"); super({ container }); @@ -95,6 +102,17 @@ export class Pane extends BasePane { this.element.addEventListener("mousedown", (e) => { e.stopPropagation(); }); + + this.debug = { + folder: this.addFolder({ + title: "Debug", + }), + values: {}, + unsubscribe: eventHook.subscribe( + "update-debug", + this.updateDebugValue.bind(this) + ), + }; } addOrReplaceFolder(props: FolderParams) { @@ -113,8 +131,22 @@ export class Pane extends BasePane { }); } + updateDebugValue(event: { data: string | number; name: string }) { + if (this.debug.values[event.name] === undefined) { + this.debug.values[event.name] = event.data; + this.debug.folder.addBinding(this.debug.values, event.name, { + readonly: true, + interval: 200, + label: event.name, + }); + } else { + this.debug.values[event.name] = event.data; + } + } + dispose() { logger.log("Disposing Pane"); + this.debug.unsubscribe(); super.dispose(); } } @@ -148,16 +180,28 @@ export const DraggablePane: React.FC = () => { if (container !== null) { pane = new Pane({ container }); pane.registerPlugin(OglPlugin); - pane.hidden = !process.env.STORYBOOK; + pane.hidden = !( + process.env.STORYBOOK || + window.location.pathname.startsWith("/dev/scenarios/") + ); } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code === "KeyK" && (event.metaKey || event.ctrlKey)) { + getPane().hidden = !getPane().hidden; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); pane.dispose(); }; }, [container]); return ( - +
); diff --git a/ui/index.tsx b/ui/index.tsx index d75a8f58..3efd74f9 100644 --- a/ui/index.tsx +++ b/ui/index.tsx @@ -8,7 +8,6 @@ import { Main } from "./views/Main"; import { NewGame } from "./views/NewGame"; import { SettingsView } from "./views/Settings"; import useFullscreen from "./hooks/useFullscreen"; -import { DraggablePane } from "./context/Pane"; const viewComponents: Record = { game: Game, @@ -43,7 +42,6 @@ export const Root: React.FC = () => { -
); }; diff --git a/ui/scenarios/shooting.stories.tsx b/ui/scenarios/shooting.stories.tsx index 27867c0c..0527f8dc 100644 --- a/ui/scenarios/shooting.stories.tsx +++ b/ui/scenarios/shooting.stories.tsx @@ -17,6 +17,8 @@ import { gameStore } from "@ui/state/game"; import { System } from "@core/systems/system"; import { defaultIndexer } from "@core/systems/utils/default"; import { Vec2 } from "ogl"; +import type { Turret } from "@core/archetypes/turret"; +import { applyPositionToChildren } from "@core/systems/moving"; class MovingSystem extends System { apply(sim: Sim) { @@ -27,15 +29,17 @@ class MovingSystem extends System { for (let i = 0; i < shipsNum; i++) { const ship = defaultIndexer.ships.get()[i]; + const t = sim.getTime() / 10; - const angle = sim.getTime() + (i * Math.PI * 2) / shipsNum; + const angle = t + (i * Math.PI * 2) / shipsNum; const r = 0.1; ship.cp.position.coord.copy(fromPolar(angle, r)); - const nextAngle = sim.getTime() + ((i + 1) * Math.PI * 2) / shipsNum; + const nextAngle = t + ((i + 1) * Math.PI * 2) / shipsNum; const nextPos = fromPolar(nextAngle, r); const vec = nextPos.sub(ship.cp.position.coord); ship.cp.position.angle = Math.atan2(vec.y, vec.x); + applyPositionToChildren(ship); } }); } @@ -76,7 +80,12 @@ const Game: React.FC<{ fighters: number }> = ({ fighters: fightersNumber }) => { } for (let i = 0; i < fighters.length; i++) { - fighters[i].cp.damage!.targetId = fighters[(i + 1) % fighters.length].id; + for (const { id, role } of fighters[i].cp.children?.entities ?? []) { + if (role !== "turret") continue; + + const turret = xSim.getOrThrow(id); + turret.cp.damage.targetId = fighters[(i + 1) % fighters.length].id; + } } xSim.start(); diff --git a/ui/state/game.ts b/ui/state/game.ts index edb9d726..a6cf06fb 100644 --- a/ui/state/game.ts +++ b/ui/state/game.ts @@ -46,6 +46,9 @@ export class GameStore { setPanelExpanded: action.bound, togglePanelExpanded: action.bound, }); + + window.select = (ids: number[]) => + this.setSelectedUnits(ids.map((id) => window.sim.getOrThrow(id))); } setSector(sector: Sector) { diff --git a/ui/views/Game.tsx b/ui/views/Game.tsx index 865b9fa4..32925638 100644 --- a/ui/views/Game.tsx +++ b/ui/views/Game.tsx @@ -23,7 +23,6 @@ import type { Faction } from "@core/archetypes/faction"; import { MapOverlay } from "@ui/components/MapOverlay/MapOverlay"; import type { GameOverlayType } from "@ui/state/game"; import { useGameStore } from "@ui/state/game"; -import { getPane } from "@ui/context/Pane"; import styles from "./Game.scss"; import { Panel } from "../components/Panel"; @@ -44,7 +43,6 @@ const Game: React.FC = () => { const [dialog, setDialog] = useGameDialog(); const { addNotification } = useNotifications(); const [gameSettings] = useGameSettings(); - const pressedKeys = React.useRef(new Set()); const player = sim.index.player.get()[0]!; const [[currentSector, overlay, selectedUnits, selectionBox], gameStore] = @@ -82,59 +80,43 @@ const Game: React.FC = () => { React.useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - pressedKeys.current.add(event.code); - - if (event.code === "Escape") { - if (overlay) { - gameStore.setOverlay(null); - } else { - setDialog(dialog ? null : { type: "config" }); + if (event.shiftKey) { + if (event.code === "Digit1") sim.setSpeed(1); + if (event.code === "Digit2") sim.setSpeed(5); + if (event.code === "Digit3" && gameSettings.dev) sim.setSpeed(50); + } else if (!(event.metaKey || event.ctrlKey)) { + if (event.code === "Escape") { + if (overlay) { + gameStore.setOverlay(null); + } else { + setDialog(dialog ? null : { type: "config" }); + } } - } - if (event.target instanceof HTMLInputElement) return; + if (event.target instanceof HTMLInputElement) return; - if ( - event.code in overlayKeyCodes && - (overlayKeyCodes[event.code] !== "dev" || gameSettings.dev) - ) { - if (gameStore.overlay === overlayKeyCodes[event.code]) { - gameStore.closeOverlay(); - } else { - gameStore.setOverlay(overlayKeyCodes[event.code]); + if ( + event.code in overlayKeyCodes && + (overlayKeyCodes[event.code] !== "dev" || gameSettings.dev) + ) { + if (gameStore.overlay === overlayKeyCodes[event.code]) { + gameStore.closeOverlay(); + } else { + gameStore.setOverlay(overlayKeyCodes[event.code]); + } + } + if (event.code === "Space") { + if (sim.speed === 0) sim.unpause(); + else sim.pause(); } - } - if (event.code === "Space") { - if (sim.speed === 0) sim.unpause(); - else sim.pause(); - } - if ( - event.code === "KeyK" && - pressedKeys.current.has("MetaLeft") && - gameSettings.dev - ) { - getPane().hidden = !getPane().hidden; - } - - if (event.code === "KeyQ") { - gameStore.togglePanelExpanded(); - } - if ( - event.code.startsWith("Digit") && - pressedKeys.current.has("ShiftLeft") - ) { - if (event.code === "Digit1") sim.setSpeed(1); - if (event.code === "Digit2") sim.setSpeed(5); - if (event.code === "Digit3" && gameSettings.dev) sim.setSpeed(50); + if (event.code === "KeyQ") { + gameStore.togglePanelExpanded(); + } } }; - const onKeyUp = (event: KeyboardEvent) => { - pressedKeys.current.delete(event.code); - }; document.addEventListener("keydown", onKeyDown); - document.addEventListener("keyup", onKeyUp); return () => document.removeEventListener("keydown", onKeyDown); }, [setDialog, overlay, gameSettings.dev]); diff --git a/window.d.ts b/window.d.ts index 7e5c8d75..1e61a061 100644 --- a/window.d.ts +++ b/window.d.ts @@ -16,6 +16,7 @@ interface Window { getOrThrow: (id: number) => any; } & Record; renderer: any; + select: (_ids: number[]) => void; selected: { id: number; } | null;